diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml
new file mode 100644
index 0000000..229803c
--- /dev/null
+++ b/.github/workflows/docs.yaml
@@ -0,0 +1,55 @@
+name: Docs
+
+# Build the Material for MkDocs site and publish to GitHub Pages.
+# Fully decoupled from ci.yaml — no Go, CGO, or bifrost checkout needed.
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - docs/**
+ - overrides/**
+ - examples/**
+ - mkdocs.yml
+ - requirements-docs.txt
+ - .github/workflows/docs.yaml
+ pull_request:
+ paths:
+ - docs/**
+ - overrides/**
+ - examples/**
+ - mkdocs.yml
+ - requirements-docs.txt
+ - .github/workflows/docs.yaml
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.x'
+ - run: pip install -r requirements-docs.txt
+ - run: mkdocs build --strict
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: site
+
+ # Deploy only from main; PRs just build (above) to catch --strict failures.
+ deploy:
+ if: github.ref == 'refs/heads/main' && github.event_name == 'push'
+ needs: build
+ runs-on: ubuntu-latest
+ permissions:
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index a6e5138..55f06a5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -171,3 +171,6 @@ coverage.txt
# benchmark run output (results summarized in docs/PR, not tracked)
deploy/eval-containers/sweep-results*.csv
+
+# MkDocs build output
+site/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 16465e0..d6ef954 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -6,6 +6,9 @@ repos:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
+ # --unsafe: validate syntax without resolving custom tags, so MkDocs'
+ # !!python/name: tags in mkdocs.yml don't break the hook.
+ args: [--unsafe]
- id: check-added-large-files
- repo: https://github.com/dnephin/pre-commit-golang
diff --git a/adapters/bifrost/plugin.go b/adapters/bifrost/plugin.go
index 465ba0f..ecfacef 100644
--- a/adapters/bifrost/plugin.go
+++ b/adapters/bifrost/plugin.go
@@ -8,11 +8,11 @@
package bifrost
import (
+ bschemas "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/components"
"github.com/rossoctl/context-guru/schema"
"github.com/rossoctl/context-guru/session"
"github.com/rossoctl/context-guru/store"
- bschemas "github.com/maximhq/bifrost/core/schemas"
)
// SessionContextKey is the BifrostContext value key the transport sets from the
diff --git a/adapters/bifrost/plugin_test.go b/adapters/bifrost/plugin_test.go
index 7de4e1b..e7d6fab 100644
--- a/adapters/bifrost/plugin_test.go
+++ b/adapters/bifrost/plugin_test.go
@@ -6,11 +6,11 @@ import (
"testing"
"time"
+ bschemas "github.com/maximhq/bifrost/core/schemas"
_ "github.com/rossoctl/context-guru/components/all"
"github.com/rossoctl/context-guru/config"
"github.com/rossoctl/context-guru/schema"
"github.com/rossoctl/context-guru/store"
- bschemas "github.com/maximhq/bifrost/core/schemas"
)
func toolMsg(text string) bschemas.ChatMessage {
diff --git a/apply/apply.go b/apply/apply.go
index a6813e2..abffd81 100644
--- a/apply/apply.go
+++ b/apply/apply.go
@@ -27,11 +27,11 @@ import (
"strconv"
"strings"
+ bschemas "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/components"
"github.com/rossoctl/context-guru/schema"
"github.com/rossoctl/context-guru/session"
"github.com/rossoctl/context-guru/store"
- bschemas "github.com/maximhq/bifrost/core/schemas"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/apply/apply_test.go b/apply/apply_test.go
index bd8a915..d63a903 100644
--- a/apply/apply_test.go
+++ b/apply/apply_test.go
@@ -6,12 +6,12 @@ import (
"strings"
"testing"
+ bschemas "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/apply"
"github.com/rossoctl/context-guru/components"
_ "github.com/rossoctl/context-guru/components/all"
"github.com/rossoctl/context-guru/config"
"github.com/rossoctl/context-guru/store"
- bschemas "github.com/maximhq/bifrost/core/schemas"
"github.com/tidwall/gjson"
)
diff --git a/components/all/all_test.go b/components/all/all_test.go
index 798780c..1c7d3ba 100644
--- a/components/all/all_test.go
+++ b/components/all/all_test.go
@@ -5,13 +5,13 @@ import (
"strings"
"testing"
+ "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/components"
_ "github.com/rossoctl/context-guru/components/all"
"github.com/rossoctl/context-guru/config"
"github.com/rossoctl/context-guru/expand"
"github.com/rossoctl/context-guru/schema"
"github.com/rossoctl/context-guru/store"
- "github.com/maximhq/bifrost/core/schemas"
)
func toolMsg(text string) schemas.ChatMessage {
diff --git a/components/all/llm_test.go b/components/all/llm_test.go
index 16d5c00..e57f524 100644
--- a/components/all/llm_test.go
+++ b/components/all/llm_test.go
@@ -6,10 +6,10 @@ import (
"strings"
"testing"
+ bschemas "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/components"
"github.com/rossoctl/context-guru/schema"
"github.com/rossoctl/context-guru/store"
- bschemas "github.com/maximhq/bifrost/core/schemas"
)
// stubModel is a fixed LLM used to drive the model-based components in tests.
diff --git a/components/all/markermode_test.go b/components/all/markermode_test.go
index 1ea7fd3..00f03ee 100644
--- a/components/all/markermode_test.go
+++ b/components/all/markermode_test.go
@@ -7,12 +7,12 @@ import (
"strings"
"testing"
+ "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/apply"
"github.com/rossoctl/context-guru/config"
"github.com/rossoctl/context-guru/expand"
"github.com/rossoctl/context-guru/schema"
"github.com/rossoctl/context-guru/store"
- "github.com/maximhq/bifrost/core/schemas"
"github.com/tidwall/gjson"
)
diff --git a/components/all/more_test.go b/components/all/more_test.go
index 045293f..de2152e 100644
--- a/components/all/more_test.go
+++ b/components/all/more_test.go
@@ -6,13 +6,13 @@ import (
"strings"
"testing"
+ "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/components"
_ "github.com/rossoctl/context-guru/components/all"
"github.com/rossoctl/context-guru/config"
"github.com/rossoctl/context-guru/expand"
"github.com/rossoctl/context-guru/schema"
"github.com/rossoctl/context-guru/store"
- "github.com/maximhq/bifrost/core/schemas"
)
func run(t *testing.T, yaml string, req *schemas.BifrostChatRequest) (*components.RunReport, store.Store) {
diff --git a/components/all/p4_test.go b/components/all/p4_test.go
index 622fccc..b4c956a 100644
--- a/components/all/p4_test.go
+++ b/components/all/p4_test.go
@@ -6,9 +6,9 @@ import (
"strings"
"testing"
+ bschemas "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/expand"
"github.com/rossoctl/context-guru/schema"
- bschemas "github.com/maximhq/bifrost/core/schemas"
)
func userMsg(text string) bschemas.ChatMessage {
diff --git a/components/all/reuse_test.go b/components/all/reuse_test.go
index 9b2edfc..076fba7 100644
--- a/components/all/reuse_test.go
+++ b/components/all/reuse_test.go
@@ -6,10 +6,10 @@ import (
"sync"
"testing"
+ bschemas "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/components"
"github.com/rossoctl/context-guru/schema"
"github.com/rossoctl/context-guru/store"
- bschemas "github.com/maximhq/bifrost/core/schemas"
)
// countingModel is a stubModel that records how many times it was called, so a
diff --git a/components/all/skeleton_test.go b/components/all/skeleton_test.go
index 01479fb..17e4e8b 100644
--- a/components/all/skeleton_test.go
+++ b/components/all/skeleton_test.go
@@ -11,9 +11,9 @@ import (
"strings"
"testing"
+ "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/expand"
"github.com/rossoctl/context-guru/schema"
- "github.com/maximhq/bifrost/core/schemas"
)
func TestSkeletonElidesBodies(t *testing.T) {
diff --git a/components/component.go b/components/component.go
index 06ef8a3..ef1ed5c 100644
--- a/components/component.go
+++ b/components/component.go
@@ -21,8 +21,8 @@ import (
"context"
"time"
- "github.com/rossoctl/context-guru/store"
"github.com/maximhq/bifrost/core/schemas"
+ "github.com/rossoctl/context-guru/store"
)
// Component is the common surface: identity + a per-request enable check.
diff --git a/components/offload/cmdfilter.go b/components/offload/cmdfilter.go
index 0d67d90..338205f 100644
--- a/components/offload/cmdfilter.go
+++ b/components/offload/cmdfilter.go
@@ -8,11 +8,11 @@ import (
"encoding/hex"
"strings"
+ "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/components"
"github.com/rossoctl/context-guru/components/dsl"
"github.com/rossoctl/context-guru/expand"
"github.com/rossoctl/context-guru/schema"
- "github.com/maximhq/bifrost/core/schemas"
"gopkg.in/yaml.v3"
)
diff --git a/components/offload/collapse.go b/components/offload/collapse.go
index 7ff736a..76ffe8c 100644
--- a/components/offload/collapse.go
+++ b/components/offload/collapse.go
@@ -4,10 +4,10 @@ import (
"fmt"
"strings"
+ "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/components"
"github.com/rossoctl/context-guru/expand"
"github.com/rossoctl/context-guru/schema"
- "github.com/maximhq/bifrost/core/schemas"
"gopkg.in/yaml.v3"
)
diff --git a/components/offload/common.go b/components/offload/common.go
index 8b8599b..93140c1 100644
--- a/components/offload/common.go
+++ b/components/offload/common.go
@@ -3,8 +3,8 @@ package offload
import (
"strings"
- "github.com/rossoctl/context-guru/schema"
bschemas "github.com/maximhq/bifrost/core/schemas"
+ "github.com/rossoctl/context-guru/schema"
)
// errWords mark a tool output (or an item) as carrying a failure — such items
diff --git a/components/offload/dedup.go b/components/offload/dedup.go
index 88e1ca7..002e2af 100644
--- a/components/offload/dedup.go
+++ b/components/offload/dedup.go
@@ -1,9 +1,9 @@
package offload
import (
+ "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/components"
"github.com/rossoctl/context-guru/schema"
- "github.com/maximhq/bifrost/core/schemas"
"gopkg.in/yaml.v3"
)
diff --git a/components/offload/extract.go b/components/offload/extract.go
index 98d2778..bdd96e7 100644
--- a/components/offload/extract.go
+++ b/components/offload/extract.go
@@ -5,11 +5,11 @@ import (
"strings"
"time"
+ bschemas "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/components"
"github.com/rossoctl/context-guru/expand"
"github.com/rossoctl/context-guru/internal/extract"
"github.com/rossoctl/context-guru/schema"
- bschemas "github.com/maximhq/bifrost/core/schemas"
"gopkg.in/yaml.v3"
)
diff --git a/components/offload/failed_run.go b/components/offload/failed_run.go
index 5395dd3..3db3b4a 100644
--- a/components/offload/failed_run.go
+++ b/components/offload/failed_run.go
@@ -3,10 +3,10 @@ package offload
import (
"regexp"
+ "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/components"
"github.com/rossoctl/context-guru/expand"
"github.com/rossoctl/context-guru/schema"
- "github.com/maximhq/bifrost/core/schemas"
"gopkg.in/yaml.v3"
)
diff --git a/components/offload/mask.go b/components/offload/mask.go
index 2a8c066..8f711cd 100644
--- a/components/offload/mask.go
+++ b/components/offload/mask.go
@@ -1,10 +1,10 @@
package offload
import (
+ bschemas "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/components"
"github.com/rossoctl/context-guru/expand"
"github.com/rossoctl/context-guru/schema"
- bschemas "github.com/maximhq/bifrost/core/schemas"
"gopkg.in/yaml.v3"
)
diff --git a/components/offload/phi_evict.go b/components/offload/phi_evict.go
index a396e00..920ccca 100644
--- a/components/offload/phi_evict.go
+++ b/components/offload/phi_evict.go
@@ -3,10 +3,10 @@ package offload
import (
"sort"
+ bschemas "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/components"
"github.com/rossoctl/context-guru/expand"
"github.com/rossoctl/context-guru/schema"
- bschemas "github.com/maximhq/bifrost/core/schemas"
"gopkg.in/yaml.v3"
)
diff --git a/components/offload/skeleton.go b/components/offload/skeleton.go
index 6671b4a..765fd74 100644
--- a/components/offload/skeleton.go
+++ b/components/offload/skeleton.go
@@ -13,11 +13,11 @@ import (
"regexp"
"strings"
+ "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/components"
"github.com/rossoctl/context-guru/expand"
"github.com/rossoctl/context-guru/internal/treesitter"
"github.com/rossoctl/context-guru/schema"
- "github.com/maximhq/bifrost/core/schemas"
sitter "github.com/tree-sitter/go-tree-sitter"
"gopkg.in/yaml.v3"
)
diff --git a/components/offload/smartcrush.go b/components/offload/smartcrush.go
index 1c1c241..fd59a6e 100644
--- a/components/offload/smartcrush.go
+++ b/components/offload/smartcrush.go
@@ -5,10 +5,10 @@ import (
"fmt"
"strings"
+ bschemas "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/components"
"github.com/rossoctl/context-guru/expand"
"github.com/rossoctl/context-guru/schema"
- bschemas "github.com/maximhq/bifrost/core/schemas"
"gopkg.in/yaml.v3"
)
diff --git a/components/offload/state.go b/components/offload/state.go
index 1e9360e..33ed3ef 100644
--- a/components/offload/state.go
+++ b/components/offload/state.go
@@ -5,8 +5,8 @@ import (
"encoding/hex"
"encoding/json"
- "github.com/rossoctl/context-guru/components"
bschemas "github.com/maximhq/bifrost/core/schemas"
+ "github.com/rossoctl/context-guru/components"
)
// State reuse over the generic Store (key→bytes), session-scoped by key prefix
diff --git a/components/offload/summarize.go b/components/offload/summarize.go
index 1f8f632..101f0ce 100644
--- a/components/offload/summarize.go
+++ b/components/offload/summarize.go
@@ -6,10 +6,10 @@ import (
"strings"
"time"
+ bschemas "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/components"
"github.com/rossoctl/context-guru/expand"
"github.com/rossoctl/context-guru/schema"
- bschemas "github.com/maximhq/bifrost/core/schemas"
"gopkg.in/yaml.v3"
)
diff --git a/components/offload/zz_live_test.go b/components/offload/zz_live_test.go
index 7584479..c4282be 100644
--- a/components/offload/zz_live_test.go
+++ b/components/offload/zz_live_test.go
@@ -6,9 +6,9 @@ import (
"strings"
"testing"
+ bschemas "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/internal/cheapmodel"
"github.com/rossoctl/context-guru/schema"
- bschemas "github.com/maximhq/bifrost/core/schemas"
)
// Live example of the summarize component: real model compresses a realistic
diff --git a/components/pipeline.go b/components/pipeline.go
index cb7c129..5c246e3 100644
--- a/components/pipeline.go
+++ b/components/pipeline.go
@@ -3,8 +3,8 @@ package components
import (
"fmt"
- "github.com/rossoctl/context-guru/schema"
"github.com/maximhq/bifrost/core/schemas"
+ "github.com/rossoctl/context-guru/schema"
)
// Pipeline runs an ordered list of components over a request. Order is set by
diff --git a/components/pipeline_test.go b/components/pipeline_test.go
index 60bea51..8611f67 100644
--- a/components/pipeline_test.go
+++ b/components/pipeline_test.go
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
- "github.com/rossoctl/context-guru/store"
"github.com/maximhq/bifrost/core/schemas"
+ "github.com/rossoctl/context-guru/store"
)
// --- fakes ---
diff --git a/components/reformat/cacheinject.go b/components/reformat/cacheinject.go
index 803c084..4351749 100644
--- a/components/reformat/cacheinject.go
+++ b/components/reformat/cacheinject.go
@@ -4,8 +4,8 @@
package reformat
import (
- "github.com/rossoctl/context-guru/components"
"github.com/maximhq/bifrost/core/schemas"
+ "github.com/rossoctl/context-guru/components"
)
func init() { components.Register("cacheinject", newCacheinject) }
diff --git a/components/reformat/format.go b/components/reformat/format.go
index 6744606..f53a89a 100644
--- a/components/reformat/format.go
+++ b/components/reformat/format.go
@@ -4,9 +4,9 @@ import (
"encoding/json"
"strings"
+ "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/components"
"github.com/rossoctl/context-guru/schema"
- "github.com/maximhq/bifrost/core/schemas"
"gopkg.in/yaml.v3"
)
diff --git a/components/reformat/toon.go b/components/reformat/toon.go
index 9bc2b4b..5c44628 100644
--- a/components/reformat/toon.go
+++ b/components/reformat/toon.go
@@ -6,9 +6,9 @@ import (
"strconv"
"strings"
+ "github.com/maximhq/bifrost/core/schemas"
"github.com/rossoctl/context-guru/components"
"github.com/rossoctl/context-guru/schema"
- "github.com/maximhq/bifrost/core/schemas"
"gopkg.in/yaml.v3"
)
diff --git a/components/trigger.go b/components/trigger.go
index 12e513f..3f8cd72 100644
--- a/components/trigger.go
+++ b/components/trigger.go
@@ -1,8 +1,8 @@
package components
import (
- "github.com/rossoctl/context-guru/schema"
"github.com/maximhq/bifrost/core/schemas"
+ "github.com/rossoctl/context-guru/schema"
)
// Trigger is the shared, configurable gate that decides whether an expensive
diff --git a/docs/RESULTS.md b/docs/RESULTS.md
index cf19404..9134edb 100644
--- a/docs/RESULTS.md
+++ b/docs/RESULTS.md
@@ -14,6 +14,10 @@ same gateway with an empty pipeline (passthrough); each `cg-*` = that one compon
content-token savings on the resolved tasks, up to 93.5% on a long session**, reward preserved.
- **Component overhead is single-digit milliseconds** — the token savings cost effectively no compute.
+
+
+
+
## Per-component summary
| component | fires on | mean save% (all 10) | mean save% (resolved 6) | reward kept | compute/req |
@@ -154,18 +158,18 @@ AFTER:
```text
BEFORE:
415 directive.
-416
+416
417 Returns
418 -------
419 bool
420 True if the member should be skipped during creation of the docs,
421 False if it should be included in the docs.
-422
+422
423 """
424 has_doc …
AFTER:
415 directive.
-416
+416
417 Returns
418 -------
419 bool
@@ -237,7 +241,7 @@ tokens (~95%)**, still containment-verified (a live, reproducible example —
## Methodology & caveats
-- Harness: [`deploy/eval-containers/sweep.py`](../deploy/eval-containers/sweep.py) (resumable) → `aggregate.py`.
+- Harness: [`deploy/eval-containers/sweep.py`](https://github.com/rossoctl/context-guru/blob/main/deploy/eval-containers/sweep.py) (resumable) → `aggregate.py`.
One cell = one `(task, config)` run through the compose stack; reward from `output/task/result.json`,
savings from the gateway `/stats` (within-run `tokens_before → tokens_after`), component time from the
gateway's per-component `duration_ms` logs, example rewrites from `CONTEXT_GURU_DUMP`.
diff --git a/docs/community.md b/docs/community.md
new file mode 100644
index 0000000..adbfc9c
--- /dev/null
+++ b/docs/community.md
@@ -0,0 +1,37 @@
+# Community
+
+context-guru is a [Rossoctl](https://github.com/rossoctl) platform
+component, licensed under **Apache-2.0**.
+
+## Contributing
+
+!!! note "Sign-off is mandatory"
+ Every commit must be signed off under the
+ [DCO](https://developercertificate.org/):
+
+ ```sh
+ git commit -s -m "feat: ..."
+ ```
+
+ This adds a `Signed-off-by:` trailer. PRs with unsigned commits will not be
+ merged.
+
+- **AI attribution** — when a commit was assisted by an AI agent, attribute it
+ with an `Assisted-By:` trailer. Do **not** use `Co-Authored-By`, and do not add
+ "Generated with" lines.
+- **Conventional-commit titles** — `feat:`, `fix:`, `docs:`, `refactor:`,
+ `test:`, `chore:`. Keep PRs focused; CI (lint, test, build, Trivy) must be
+ green.
+- **Every reduction must be reversible and fail-open** — add tests for both the
+ happy path and the fault-injection (fail-open) path.
+
+## Governance & policies
+
+| Document | |
+|---|---|
+| [CONTRIBUTING.md](https://github.com/rossoctl/context-guru/blob/main/CONTRIBUTING.md) | How to contribute, DCO, PR norms. |
+| [GOVERNANCE.md](https://github.com/rossoctl/context-guru/blob/main/GOVERNANCE.md) | Project governance model. |
+| [MAINTAINERS.md](https://github.com/rossoctl/context-guru/blob/main/MAINTAINERS.md) | Current maintainers. |
+| [SECURITY.md](https://github.com/rossoctl/context-guru/blob/main/SECURITY.md) | Reporting security issues (do not open public issues for vulnerabilities). |
+| [CODE_OF_CONDUCT.md](https://github.com/rossoctl/context-guru/blob/main/CODE_OF_CONDUCT.md) | Community standards. |
+| [LICENSE](https://github.com/rossoctl/context-guru/blob/main/LICENSE) | Apache-2.0. |
diff --git a/docs/components.md b/docs/components.md
index bfc4f5e..520049c 100644
--- a/docs/components.md
+++ b/docs/components.md
@@ -10,6 +10,7 @@ messages (`role:"tool"`; for Anthropic, `tool_result` blocks normalized to that
| Component | Kind | What it drops | Recoverable | Fires on | Key config (default) |
|---|---|---|---|---|---|
| `format` | Reformat | nothing (compacts JSON) | n/a (lossless) | pretty-printed JSON tool output | `min_tokens` (50) |
+| `toon` | Reformat | nothing (re-encodes JSON arrays as TOON) | n/a (lossless) | uniform flat JSON object-arrays | `min_tokens` (50) |
| `cacheinject` | Reformat | nothing (adds `cache_control`) | n/a (lossless) | Anthropic-family requests | — |
| `skeleton` | Offload | function/method bodies | via expand | fenced ` ```lang ` code blocks | `min_tokens` (80) |
| `dedup` | Offload | later byte-identical tool outputs | via expand | repeated identical outputs | `min_tokens` (100) |
@@ -56,6 +57,27 @@ before: { "id": 1, after: {"id":1,"name":"ada","tags":["x","y"]}
- **Lossiness:** none — nothing stashed. **Shines:** verbose pretty-printed JSON/MCP payloads.
**Inert:** already-compact JSON, non-JSON text, small outputs.
+### `toon`
+Re-encodes a JSON array of uniform, flat objects as **TOON** (Token-Oriented Object Notation):
+one header listing the field names once, then one comma-separated row per element. It drops the
+braces, repeated keys, and quotes that dominate a JSON array's token cost. It's a Reformat (repack
+in place, nothing stashed): every scalar value is preserved, with one small representational
+simplification — JSON `null` renders as an empty cell (indistinguishable from `""`). Only arrays
+whose elements share one key set and hold scalar values are encoded; anything nested, ragged, or
+non-array is left untouched, and the pipeline's never-worse guard reverts any case that fails to
+shrink.
+
+```
+before: [{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]
+after: [2]{id,name}:
+ 1,Alice
+ 2,Bob
+```
+
+- **Config:** `min_tokens` (50). **Lossiness:** none — nothing stashed (JSON `null` → empty cell).
+ **Shines:** long homogeneous JSON arrays (the llm-d TOON config). **Inert:** nested/ragged/non-array
+ output, or not smaller.
+
### `cacheinject`
Places an Anthropic `cache_control: {type: ephemeral}` breakpoint on the last content block of
the message just **before** the newest turn (a stable prefix boundary), so the provider KV cache
diff --git a/docs/components/cacheinject.md b/docs/components/cacheinject.md
new file mode 100644
index 0000000..275add9
--- /dev/null
+++ b/docs/components/cacheinject.md
@@ -0,0 +1,41 @@
+# cacheinject
+
+!!! info "Reformat — lossless"
+ Places an Anthropic `cache_control` breakpoint at a stable prefix boundary so the provider KV cache hits across turns.
+
+## How it works
+
+`cacheinject` places an Anthropic `cache_control: {type: ephemeral}` breakpoint on the last content
+block of the message just **before** the newest turn (a stable prefix boundary), so the provider KV
+cache hits across turns. It adds a control directive and changes no model-visible content.
+
+The savings lever is provider-side cache hits, invisible to `/stats` token counts. `/stats` will
+list it under `top_passthrough` since it saves no *content* tokens — that's expected, not dead
+weight.
+
+## Before → After
+
+```
+before: [ … prev turn last block ] after: [ … prev turn last block {cache_control: ephemeral} ]
+ [ newest turn ] [ newest turn ]
+```
+
+## Lossiness
+
+None. It only attaches a cache directive; model-visible content is unchanged.
+
+## Configuration
+
+_No configuration._
+
+## When it shines
+
+Anthropic/Bedrock/Vertex agents that don't self-cache (the savings lever is provider-side cache
+hits, invisible to `/stats` token counts).
+
+## When it's inert
+
+Non-cache-aware providers, string-content messages (can't carry a block breakpoint), a breakpoint
+already present.
+
+See also: [Components overview](../components.md) · [Choose a preset](../how-to/choose-a-preset.md)
diff --git a/docs/components/cmdfilter.md b/docs/components/cmdfilter.md
new file mode 100644
index 0000000..1742525
--- /dev/null
+++ b/docs/components/cmdfilter.md
@@ -0,0 +1,41 @@
+# cmdfilter
+
+!!! info "Offload — lossy, reversible"
+ Shrinks tool output with declarative DSL filters, stashing the original and appending a recovery hint only when the filter was actually lossy.
+
+## How it works
+
+`cmdfilter` shrinks tool output with **declarative DSL filters** (see
+[The DSL filter engine](dsl.md)). It matches a filter on the output's first non-empty line, applies
+its 8-stage pipeline, stashes the original, and appends a recovery hint only when the filter was
+actually lossy. It ships builtin `pytest` / `npm-install` / `make` filters, and is `Enabled` only
+when ≥1 filter is loaded.
+
+## Before → After
+
+```
+before: pytest … 100 lines of PASSED + warnings + 1 failure
+after: <> [full output: …]
+```
+
+## Lossiness
+
+Lossy but reversible — the original is stashed and recovered via `context_guru_expand` /
+`GET /expand`. A recovery hint is appended only when the filter actually dropped content.
+
+## Configuration
+
+| Key | Default | Meaning |
+|---|---|---|
+| `filters` | `[]` | Inline filter YAML docs, added with no recompile. |
+| `disable_builtins` | `false` | Disable the shipped `pytest` / `npm-install` / `make` filters. |
+
+## When it shines
+
+Noisy but structured command/log output (test runners, package managers, build tools).
+
+## When it's inert
+
+Output whose first line matches no filter, or where filtering doesn't shrink it.
+
+See also: [Components overview](../components.md) · [Choose a preset](../how-to/choose-a-preset.md)
diff --git a/docs/components/collapse.md b/docs/components/collapse.md
new file mode 100644
index 0000000..b4b5fdf
--- /dev/null
+++ b/docs/components/collapse.md
@@ -0,0 +1,42 @@
+# collapse
+
+!!! info "Offload — lossy, reversible"
+ Content-agnostic fallback for an oversized tool output: keep a head + tail window, stash the full original.
+
+## How it works
+
+`collapse` is the content-agnostic fallback for an oversized tool output nothing more specific
+handled: it keeps a `head_lines` + `tail_lines` window and stashes the full original behind a
+`<>` marker. It runs late (after `cmdfilter`/`format`) and skips content already marked.
+
+## Before → After
+
+```
+before: <2,000-line log>
+after:
+ ... (1960 lines omitted) <> [full output: call context_guru_expand]
+
+```
+
+## Lossiness
+
+Lossy but reversible — the full original is stashed and recovered via `context_guru_expand` /
+`GET /expand`.
+
+## Configuration
+
+| Key | Default | Meaning |
+|---|---|---|
+| `max_tokens` | 2000 | Threshold above which an output is collapsed. |
+| `head_lines` | 20 | Lines kept from the start. |
+| `tail_lines` | 20 | Lines kept from the end. |
+
+## When it shines
+
+A catch-all last stage for huge outputs.
+
+## When it's inert
+
+Output ≤ `max_tokens`, or too few lines for head/tail to help.
+
+See also: [Components overview](../components.md) · [Choose a preset](../how-to/choose-a-preset.md)
diff --git a/docs/components/dedup.md b/docs/components/dedup.md
new file mode 100644
index 0000000..9bc379b
--- /dev/null
+++ b/docs/components/dedup.md
@@ -0,0 +1,37 @@
+# dedup
+
+!!! info "Offload — lossy, reversible"
+ Replaces a tool output byte-identical to an earlier one in the same request with a short pointer + marker.
+
+## How it works
+
+`dedup` replaces a tool output byte-identical to an earlier one in the same request with a short
+pointer + `<>` marker. Exact match only (near-duplicate is deferred).
+
+## Before → After
+
+```
+before: … (later, identical)
+after: … [identical to an earlier tool output] <>
+```
+
+## Lossiness
+
+Lossy but reversible — the duplicated output is stashed and recovered via `context_guru_expand` /
+`GET /expand`.
+
+## Configuration
+
+| Key | Default | Meaning |
+|---|---|---|
+| `min_tokens` | 100 | Skip outputs smaller than this token count. |
+
+## When it shines
+
+Agents that re-read the same file/command output repeatedly.
+
+## When it's inert
+
+No exact repeats, small outputs.
+
+See also: [Components overview](../components.md) · [Choose a preset](../how-to/choose-a-preset.md)
diff --git a/docs/components/dsl.md b/docs/components/dsl.md
new file mode 100644
index 0000000..4e6b22a
--- /dev/null
+++ b/docs/components/dsl.md
@@ -0,0 +1,64 @@
+# The DSL filter engine
+
+!!! info "DSL engine — powers `cmdfilter` (lossy)"
+ A declarative, user-extensible text-filter engine authored in YAML (no recompile), wrapped by the `cmdfilter` Offload component.
+
+## How it works
+
+`components/dsl` is a declarative, user-extensible text-filter engine (adapted from rtk), wrapped
+by [`cmdfilter`](cmdfilter.md). Filters are authored in YAML (no recompile), matched
+first-by-sorted-name, and each runs a fixed **8-stage** pipeline. Because filters drop lines they
+are lossy, which is why the wrapping `cmdfilter` component is an Offload (it stashes the original
+first).
+
+```mermaid
+flowchart LR
+ I[input] --> S1[1 strip_ansi] --> S2["2 replace[]"] --> S3["3 match_output[] + unless"]
+ S3 --> S4[4 strip / keep lines] --> S5[5 truncate_lines_at] --> S6[6 head / tail]
+ S6 --> S7[7 max_lines] --> S8[8 on_empty] --> O[output + Lossiness]
+```
+
+## Filter fields
+
+All optional except `match`:
+
+- `match` — regex vs the selector (= first non-empty line)
+- `strip_ansi`
+- `replace` — chained `pattern`→`replacement`, `$1` backrefs
+- `match_output` — whole-blob short-circuit: `pattern`/`message`/`unless`
+- `strip_lines_matching` **xor** `keep_lines_matching`
+- `truncate_lines_at` — per-line char cap
+- `head_lines` / `tail_lines`
+- `max_lines` — absolute cap with omission marker
+- `on_empty` — replacement when output is blank
+
+## Lossiness
+
+`Lossiness` is reported back to `cmdfilter` (it drives whether a recovery hint is appended):
+
+- **None** — nothing dropped / reversible reformat
+- **Tail** — a clean contiguous tail dropped
+- **Whole** — non-contiguous or whole-blob loss
+
+## Example
+
+```yaml
+schema_version: 1
+filters:
+ pytest:
+ description: keep failures + summary, drop passing noise
+ match: "(pytest|=+ test session starts)"
+ strip_lines_matching: ["^\\s*$", " PASSED", "^\\.+$"]
+ max_lines: 80
+ on_empty: "pytest: all passed"
+tests: # inline; run via dsl.RunTests (a `verify` command)
+ pytest:
+ - name: all-green
+ input: "pytest\n....\n"
+ expected: "pytest: all passed"
+```
+
+Documents load with `schema_version: 1` and strict unknown-field rejection. Inline `tests`
+(input → expected) run via `dsl.RunTests`, so a filter ships with its own regression check.
+
+See also: [Components overview](../components.md) · [Choose a preset](../how-to/choose-a-preset.md)
diff --git a/docs/components/extract.md b/docs/components/extract.md
new file mode 100644
index 0000000..091f8e1
--- /dev/null
+++ b/docs/components/extract.md
@@ -0,0 +1,78 @@
+# extract
+
+!!! info "Offload — lossy, reversible"
+ Projects a large tool output down to what's relevant to the most recent user query; optionally an LLM writes the filter.
+
+## How it works
+
+`extract` projects a large tool output down to what's relevant to the **most recent user query**.
+v1 ships the `deterministic` strategy: keep `head`/`tail` context, every line overlapping the
+query's keywords, and every line carrying an error word; collapse the gaps with `…`. It stashes the
+original behind a `<>` marker.
+
+The relevance signal is the **full conversational context** (the task in the first user turn + the
+most recent assistant/user turns), not just one trailing sentence — so it keeps what the agent
+actually needs on any agent/benchmark.
+
+With `code`, a cheap LLM writes a Starlark filter run in a sandbox (no imports/IO, step + 2s
+limits). It sees the **full tool output** (bounded to ~32k chars) so it writes code **specific to
+that content** — deleting the exact irrelevant lines/records AND words/sentences/parts within lines
+— not a blind generic filter. It has regex helpers (`re_sub`/`re_findall`/`re_split`/`re_match`,
+RE2, pure-Go). **Guarantee (default): deletion-only** — the result is accepted only if it is an
+in-order **character subsequence** of the input (obtainable by deleting characters), so the model
+can trim anything but provably cannot fabricate, reorder, or reword; else it falls back to
+deterministic. JSON bodies are decoded and filtered structurally. `rlm` currently maps to `code`.
+
+`rewrite` (opt-in, `code` only) drops the containment proof so the model may reword / summarize /
+rewrite freely (lossy, **unverified** — only sanity + strictly-smaller apply). Pair it with a
+non-`full` `marker_mode`. Default `false` keeps the verified deletion-only guarantee.
+
+The LLM strategies (`code`/`rlm`) call a model chosen by `model.source`: `incoming` (default —
+reuse the proxied request's own model + key) or `config` (a dedicated cheap model set via
+`CHEAP_MODEL*` env / the gateway's `CheapModel`). When no model is available, `extract` degrades to
+its deterministic projection.
+
+**Gating + reuse (LLM strategies):** a `trigger` decides whether to spend a model call —
+`min_output_tokens` (per tool output; folds in legacy `min_tokens`), `min_request_tokens`,
+`min_messages` — so `code` fires only on a large output in a large request, not every turn. A
+reduced output is cached per session by content hash, so the **same output re-sent on a later turn
+reuses the prior compaction** (no new model call, byte-identical result → prefix stays KV-cache
+stable).
+
+## Before → After
+
+```
+before: <300-line API dump> (query mentions "auth timeout")
+after: … lines mentioning auth/timeout + any error lines …
+ <> [full output: call context_guru_expand]
+```
+
+## Lossiness
+
+Lossy but reversible — the original is stashed and recovered via `context_guru_expand` /
+`GET /expand`. The default `code` guarantee is deletion-only (a verified character subsequence);
+`rewrite` mode is unverified and lossier.
+
+## Configuration
+
+| Key | Default | Meaning |
+|---|---|---|
+| `min_tokens` | 300 | Output floor before extraction runs. |
+| `head_lines` / `tail_lines` | 5 | Context lines kept at start/end. |
+| `strategy` | `deterministic` | `deterministic` \| `code` \| `rlm` (`rlm` maps to `code`). |
+| `model.source` | `incoming` | LLM source for `code`/`rlm`: `incoming` (proxied model+key) or `config` (cheap model). |
+| `trigger` | — | Gates a model call: `min_output_tokens`, `min_request_tokens`, `min_messages`. |
+| `marker_mode` | — | How the recovery marker is emitted; pair a non-`full` mode with `rewrite`. |
+| `rewrite` | `false` | Opt-in (`code` only): drop the containment proof for free rewording (lossy, unverified). |
+
+## When it shines
+
+Big query-focused MCP/API outputs, logs and file reads; structured JSON where a filter selects
+records precisely.
+
+## When it's inert
+
+No user query, output below floor, request below `trigger`, projection not smaller, or (for `code`)
+no model available → deterministic.
+
+See also: [Components overview](../components.md) · [Choose a preset](../how-to/choose-a-preset.md)
diff --git a/docs/components/failed_run.md b/docs/components/failed_run.md
new file mode 100644
index 0000000..5886c23
--- /dev/null
+++ b/docs/components/failed_run.md
@@ -0,0 +1,40 @@
+# failed_run
+
+!!! info "Offload — lossy, reversible"
+ Keeps the most recent test/build run in full, collapses every earlier superseded run to a pointer + marker.
+
+## How it works
+
+`failed_run` recognizes test/build run output (regex: `N passed/failed`, `BUILD SUCCESS/FAIL`,
+`Traceback`, `FAILED`, `panic:`, `npm ERR!`, pytest session banners). It keeps the **most recent**
+run in full and collapses every earlier run to a pointer + `<>` marker — a superseded run
+is safely recoverable. Needs ≥2 run-like outputs. False positives cost only an expand round-trip,
+never data.
+
+## Before → After
+
+```
+before: [run 1] 3 failed, 5 passed … [run 2 after fix] 8 passed
+after: [superseded by a later run] <> [full output: …] [run 2] 8 passed
+```
+
+## Lossiness
+
+Lossy but reversible — superseded runs are stashed and recovered via `context_guru_expand` /
+`GET /expand`.
+
+## Configuration
+
+| Key | Default | Meaning |
+|---|---|---|
+| `min_tokens` | 100 | Skip runs smaller than this token count. |
+
+## When it shines
+
+Iterative fix→re-run loops.
+
+## When it's inert
+
+<2 runs detected, small outputs.
+
+See also: [Components overview](../components.md) · [Choose a preset](../how-to/choose-a-preset.md)
diff --git a/docs/components/format.md b/docs/components/format.md
new file mode 100644
index 0000000..efa7552
--- /dev/null
+++ b/docs/components/format.md
@@ -0,0 +1,39 @@
+# format
+
+!!! info "Reformat — lossless"
+ Re-encodes a pretty-printed JSON tool output as compact JSON — same value, fewer whitespace tokens.
+
+## How it works
+
+`format` only acts on tool messages whose trimmed text starts with `{`/`[`, is valid JSON, is
+≥ `min_tokens`, and gets smaller. It repacks the value as compact JSON, stripping the indentation
+and newlines that dominate a pretty-printed payload's token cost. v1 is json-compact only (a TOON
+encoder is planned — see [`toon`](toon.md)).
+
+## Before → After
+
+```
+before: { "id": 1, after: {"id":1,"name":"ada","tags":["x","y"]}
+ "name": "ada",
+ "tags": [ "x", "y" ] }
+```
+
+## Lossiness
+
+None — nothing stashed. The value is identical; only whitespace tokens are removed.
+
+## Configuration
+
+| Key | Default | Meaning |
+|---|---|---|
+| `min_tokens` | 50 | Skip tool outputs smaller than this token count. |
+
+## When it shines
+
+Verbose pretty-printed JSON/MCP payloads.
+
+## When it's inert
+
+Already-compact JSON, non-JSON text, small outputs.
+
+See also: [Components overview](../components.md) · [Choose a preset](../how-to/choose-a-preset.md)
diff --git a/docs/components/mask.md b/docs/components/mask.md
new file mode 100644
index 0000000..b907581
--- /dev/null
+++ b/docs/components/mask.md
@@ -0,0 +1,40 @@
+# mask
+
+!!! info "Offload — lossy, reversible"
+ Age-based garbage collection: keep the newest few tool outputs verbatim, replace older ones with a short marker + stash.
+
+## How it works
+
+`mask` is age-based garbage collection: it keeps the newest `keep_recent` tool outputs verbatim and
+replaces older ones (≥ `min_tokens`) with a short `<>` marker + stash. It is complementary
+to the content-based offloaders.
+
+## Before → After
+
+```
+after (older): [older tool output masked] <> [full output: call context_guru_expand]
+```
+
+## Lossiness
+
+Lossy but reversible — masked outputs are stashed and recovered via `context_guru_expand` /
+`GET /expand`.
+
+## Configuration
+
+| Key | Default | Meaning |
+|---|---|---|
+| `keep_recent` | 3 | Newest tool outputs kept verbatim. |
+| `min_tokens` | 100 | Only mask older outputs at least this large. |
+
+## When it shines
+
+Long agent trajectories where old tool results are unlikely to matter. In the `agent` preset it is
+the biggest lever (~27% content-token savings, no reward loss — see
+[RESULTS.md](../RESULTS.md)).
+
+## When it's inert
+
+≤ `keep_recent` tool outputs, small outputs.
+
+See also: [Components overview](../components.md) · [Choose a preset](../how-to/choose-a-preset.md)
diff --git a/docs/components/phi_evict.md b/docs/components/phi_evict.md
new file mode 100644
index 0000000..88635b0
--- /dev/null
+++ b/docs/components/phi_evict.md
@@ -0,0 +1,57 @@
+# phi_evict
+
+!!! info "Offload — lossy, reversible"
+ Ranks tool outputs by a lean-ctx-style context-field score and evicts the lowest until the transcript fits the budget.
+
+## How it works
+
+`phi_evict` ranks tool outputs by a lean-ctx-style context-field score and evicts the lowest until
+the transcript fits `budget_tokens`. It never evicts the most recent tool output. Evicted outputs
+are stashed behind a `<>` marker.
+
+$$\Phi = w_R\cdot\text{relevance} + w_H\cdot\text{recency} - w_C\cdot\text{cost} - w_D\cdot\text{redundancy}$$
+
+- **relevance** = keyword overlap with the last user message
+- **recency** = position (newest = 1)
+- **cost** = share of total tool tokens
+- **redundancy** = 1 if a duplicate seen earlier
+
+This is the scalar-ranking essence of lean-ctx's Context Field (the full heat-diffusion/MMR/bandit
+machinery is a documented refinement).
+
+## Before → After
+
+```
+before: [ … many tool outputs, transcript over budget_tokens … ]
+after: [ … lowest-Φ outputs evicted to <> markers until it fits; newest kept … ]
+```
+
+## Lossiness
+
+Lossy but reversible — evicted outputs are stashed and recovered via `context_guru_expand` /
+`GET /expand`.
+
+## Configuration
+
+| Key | Default | Meaning |
+|---|---|---|
+| `budget_tokens` | 120000 | Target transcript size; evict until it fits. |
+| `weights` | `balanced` | Φ weight preset (see below). |
+
+Weight presets (`{R, H, C, D}`):
+
+| Preset | R (relevance) | H (recency) | C (cost) | D (redundancy) |
+|---|---|---|---|---|
+| `balanced` | .40 | .20 | .30 | .10 |
+| `aggressive` (punish cost) | .30 | .10 | .45 | .15 |
+| `conservative` | .45 | .20 | .05 | .05 |
+
+## When it shines
+
+Very long transcripts that must fit a context budget.
+
+## When it's inert
+
+Total tool tokens ≤ budget, or ≤1 tool output.
+
+See also: [Components overview](../components.md) · [Choose a preset](../how-to/choose-a-preset.md)
diff --git a/docs/components/skeleton.md b/docs/components/skeleton.md
new file mode 100644
index 0000000..4bb5272
--- /dev/null
+++ b/docs/components/skeleton.md
@@ -0,0 +1,50 @@
+# skeleton
+
+!!! info "Offload — lossy, reversible"
+ Replaces function/method/constructor bodies in fenced code blocks with a placeholder, keeping signatures; stashes the whole original.
+
+## How it works
+
+`skeleton` parses fenced ` ```lang ` code blocks with tree-sitter and replaces
+function/method/constructor **bodies** with a placeholder, keeping signatures, imports, types, and
+class bodies (so method signatures survive). It stashes the whole original message, recoverable via
+the `<>` marker.
+
+```mermaid
+flowchart LR
+ A["go fenced block
full func bodies"] --> B{"tree-sitter parse
lang known? body ≥ min_tokens?"}
+ B -->|no| A
+ B -->|yes| C["signatures + { … }
+ <> marker"]
+ C --> D[(Store: original)]
+```
+
+Grammars: go, python, js/ts/tsx, rust, java, c/cpp, ruby, php, c#, kotlin, swift, scala.
+
+## Before → After
+
+```
+before: func Add(a, b int) int { after: func Add(a, b int) int { … }
+ return a + b func Sub(a, b int) int { … }
+ } <> [full source: call context_guru_expand]
+```
+
+## Lossiness
+
+Lossy but reversible — the full original message is stashed in the Store and recovered via
+`context_guru_expand` / `GET /expand`.
+
+## Configuration
+
+| Key | Default | Meaning |
+|---|---|---|
+| `min_tokens` | 80 | Minimum body size (per body) before it is skeletonized. |
+
+## When it shines
+
+The `coding` preset — the agent reads big source files but mostly needs the shape.
+
+## When it's inert
+
+No fenced blocks, unfenced file reads, unknown language, skeleton not smaller than the body.
+
+See also: [Components overview](../components.md) · [Choose a preset](../how-to/choose-a-preset.md)
diff --git a/docs/components/smartcrush.md b/docs/components/smartcrush.md
new file mode 100644
index 0000000..9d9395a
--- /dev/null
+++ b/docs/components/smartcrush.md
@@ -0,0 +1,42 @@
+# smartcrush
+
+!!! info "Offload — lossy, reversible"
+ Statistical JSON-array compressor: keep first/last items plus any error item, drop the rest, stash the full original.
+
+## How it works
+
+`smartcrush` is a statistical JSON-**array** compressor: it parses the array, keeps `keep_first` +
+`keep_last` items plus any item whose raw JSON carries an error signal, drops the rest, and stashes
+the full original behind a `<>` marker. Kept items are verbatim (schema-preserving). v1
+uses fixed anchors (headroom's Kneedle adaptive-K is a documented refinement).
+
+## Before → After
+
+```
+before: [ {…}, {…}, … 200 items … ]
+after: [ item0, item1, item2, item198, item199 ] [5 of 200 items shown; full array: call …] <>
+```
+
+## Lossiness
+
+Lossy but reversible — the full original array is stashed and recovered via `context_guru_expand` /
+`GET /expand`. Kept items are byte-verbatim.
+
+## Configuration
+
+| Key | Default | Meaning |
+|---|---|---|
+| `min_items` | 5 | Minimum array length before crushing. |
+| `min_tokens` | 200 | Output floor before crushing. |
+| `keep_first` | 3 | Leading items kept verbatim. |
+| `keep_last` | 2 | Trailing items kept verbatim. |
+
+## When it shines
+
+Long homogeneous JSON arrays (list endpoints, search hits) — the `mcp` preset.
+
+## When it's inert
+
+Non-array output, fewer than `min_items`, nothing to drop.
+
+See also: [Components overview](../components.md) · [Choose a preset](../how-to/choose-a-preset.md)
diff --git a/docs/components/summarize.md b/docs/components/summarize.md
new file mode 100644
index 0000000..212b08a
--- /dev/null
+++ b/docs/components/summarize.md
@@ -0,0 +1,63 @@
+# summarize
+
+!!! info "Offload (LLM) — lossy, reversible"
+ Compresses the middle of the trajectory into one LLM-written summary; the replaced span is stashed under a marker.
+
+## How it works
+
+`summarize` compresses the **middle of the trajectory** into one LLM-written summary (ported from
+CE-Manager's ReSum-style summarizer). It restructures the message list to
+`[msg0, , last-K]`; the replaced span is stashed under a marker carried in
+the summary message, so `expand` restores the full earlier trajectory. This is the one component
+that changes the message count — `apply.Body` rebuilds the body keeping the retained messages
+byte-identical.
+
+The summarizer is grounded in the **current task** (first user turn + recent turns are passed as
+"summarize toward this"), not a blind digest of the middle.
+
+`summarize` calls a model chosen by `model.source`: `incoming` (default — reuse the proxied
+request's own model + key) or `config` (a dedicated cheap model set via `CHEAP_MODEL*` env / the
+gateway's `CheapModel`). When no model is available it degrades to a no-op.
+
+**Gating + reuse:** a `trigger` (`min_request_tokens`, `min_messages`; legacy `start_from_message`
+folds into `min_messages`) gates the first summary so it fires only on a large/deep transcript.
+After that, the summary is **checkpointed per session** and **reused verbatim** (no model call, and
+byte-identical so the prefix stays KV-cache stable) until the un-summarized tail grows past
+`resummarize_tokens`, when the checkpoint rolls forward with a fresh summary. This is what stops it
+re-summarizing every turn.
+
+Run it **alone** (its own preset) — it restructures the whole transcript.
+
+## Before → After
+
+```
+before: [system, u1, tool, a1, tool, u2, … 30 turns …, uN-1, uN]
+after: [system, "=== History Summary === … … <>", uN-1, uN]
+```
+
+## Lossiness
+
+Lossy but reversible — the replaced span is stashed under the summary message's marker and
+recovered via `context_guru_expand` / `GET /expand`.
+
+## Configuration
+
+| Key | Default | Meaning |
+|---|---|---|
+| `summary_level` | `regular` | `concise` \| `regular` \| `highly_detailed`. |
+| `keep_last` | 3 | Trailing messages kept verbatim. |
+| `min_tokens` | 500 | Span floor — minimum middle size before summarizing. |
+| `include_tool_calls` | `false` | `false` → tool outputs masked in the summarized trajectory. |
+| `resummarize_tokens` | 6000 | Tail growth that triggers rolling the checkpoint forward. |
+| `model.source` | `incoming` | LLM source: `incoming` (proxied model+key) or `config` (cheap model). |
+| `trigger` | — | Gates the first summary: `min_request_tokens`, `min_messages`. |
+
+## When it shines
+
+Long agentic sessions where the bulk is stale middle context.
+
+## When it's inert
+
+Transcript below `trigger`, span below `min_tokens`, or no model available (no-op).
+
+See also: [Components overview](../components.md) · [Choose a preset](../how-to/choose-a-preset.md)
diff --git a/docs/components/toon.md b/docs/components/toon.md
new file mode 100644
index 0000000..1b4f4bb
--- /dev/null
+++ b/docs/components/toon.md
@@ -0,0 +1,45 @@
+# toon
+
+!!! info "Reformat — lossless"
+ Re-encodes a JSON array of uniform, flat objects as TOON (Token-Oriented Object Notation) — one header, one row per element, no repeated keys or quotes.
+
+## How it works
+
+`toon` re-encodes a JSON array of uniform, flat objects as **TOON** (Token-Oriented Object
+Notation): one header listing the field names once, then one comma-separated row per element. It
+drops the braces, repeated keys, and quotes that dominate a JSON array's token cost. It's a
+Reformat (repack in place, nothing stashed): every scalar value is preserved, with one small
+representational simplification — JSON `null` renders as an empty cell (indistinguishable from
+`""`). Only arrays whose elements share one shared scalar key-set are encoded; anything nested,
+ragged, or non-array is left untouched, and the pipeline's never-worse guard reverts any case that
+fails to shrink.
+
+## Before → After
+
+```
+before: [{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]
+after: [2]{id,name}:
+ 1,Alice
+ 2,Bob
+```
+
+## Lossiness
+
+None — nothing stashed. Every scalar is preserved; the only representational change is JSON `null`
+→ empty cell (indistinguishable from `""`).
+
+## Configuration
+
+| Key | Default | Meaning |
+|---|---|---|
+| `min_tokens` | 50 | Skip tool outputs smaller than this token count. |
+
+## When it shines
+
+Long homogeneous JSON arrays (the llm-d TOON config).
+
+## When it's inert
+
+Nested/ragged/non-array output, or output that is not smaller after re-encoding.
+
+See also: [Components overview](../components.md) · [Choose a preset](../how-to/choose-a-preset.md)
diff --git a/docs/examples/before-after.md b/docs/examples/before-after.md
new file mode 100644
index 0000000..452017f
--- /dev/null
+++ b/docs/examples/before-after.md
@@ -0,0 +1,106 @@
+# Before → After showcase
+
+One input context run through each of the three [llm-d compaction service](llm-d-service.md)
+configs, showing what each did to the same `messages` array. Captured with the store disabled and
+`marker_mode: off`, so outputs carry no `<>` markers and are drop-in inference-request bodies.
+
+!!! note "About this capture"
+ The configs here are the committed ones with triggers lowered so this small readable example
+ actually fires. The two LLM configs used **`claude-haiku-4-5`** via `model.source: config`
+ (`CHEAP_MODEL_*` env).
+
+## The numbers
+
+| Config | messages | body bytes |
+|---|---|---|
+| **before** (shared input) | 11 | 2667 |
+| **TOON** (`format` + `toon`, no LLM) | 11 | 2376 |
+| **Extract** (`strategy: code`, LLM) | 11 | 2145 |
+| **Summarize** (LLM) | 4 | 1298 |
+
+TOON and Extract keep all 11 messages and shrink content in place. Summarize is the outlier — it
+collapses 11 messages to 4 by restructuring the transcript.
+
+## The shared input
+
+The task: *"List the admins, then fix the failing test_col_insert."* The 11-message transcript
+carries a 6-user JSON array (tool `c1`), 14 lines of `col_insert` grep hits (`c2`), the buggy
+implementation (`c3`), and a failing pytest run (`c4`), then a final user question:
+*"Which users are admins, and what's the fix?"*
+
+## What each config did
+
+=== "TOON"
+
+ `11 → 11 messages, 2667 → 2376 bytes` — deterministic, no LLM.
+
+ `toon` rewrote the user-list JSON array in tool `c1` to **TOON**: the field names are declared
+ once as a header, then one compact row per element. Everything else is untouched.
+
+ ```text
+ before (tool c1):
+ [{"id": 1, "name": "Alice", "role": "admin", "active": true}, {"id": 2, ...}, ...]
+
+ after (tool c1):
+ [6]{active,id,name,role}:
+ true,1,Alice,admin
+ false,2,Bob,member
+ true,3,Carol,admin
+ true,4,Dave,member
+ false,5,Eve,admin
+ true,6,Frank,member
+ ```
+
+ Lossless repack: same values, fewer tokens. The grep hits, implementation, and pytest run
+ pass through unchanged.
+
+=== "Extract"
+
+ `11 → 11 messages, 2667 → 2145 bytes` — LLM (`claude-haiku-4-5`), `strategy: code`.
+
+ `extract` had a cheap LLM write a sandboxed, deletion-only filter tuned to *this* content and
+ the current query. It made two content-specific cuts:
+
+ - **tool `c1`** — filtered the 6-user array down to just the **three admins** (Alice, Carol,
+ Eve), verbatim, dropping the non-admin members.
+ - **tool `c3`** — trimmed the implementation to the buggy lines, dropping the 12 lines of
+ `# helper line N` noise below the bug.
+ - **tool `c4`** — dropped the trailing `1 failed, 180 passed` summary line, keeping the
+ failure and the `IndexError` traceback.
+
+ ```text
+ after (tool c1):
+ [{"active":true,"id":1,"name":"Alice","role":"admin"},
+ {"active":true,"id":3,"name":"Carol","role":"admin"},
+ {"active":false,"id":5,"name":"Eve","role":"admin"}]
+ ```
+
+ Deletion-only: the model can trim but provably cannot fabricate or reword.
+
+=== "Summarize"
+
+ `11 → 4 messages, 2667 → 1298 bytes` — LLM (`claude-haiku-4-5`).
+
+ `summarize` collapsed the middle of the transcript into a single
+ **`=== History Summary ===`** system message, keeping the head and the last couple of turns.
+ The result is `[system, , tool c4, final user question]`.
+
+ ```text
+ [1] system:
+ === History Summary ===
+ The earlier trajectory is summarized below.
+
+
+ • The user requested two tasks: (1) list the admins, (2) fix the failing test_col_insert
+ • The assistant listed users, searched col_insert refs, read the impl, ran the failing test
+ • All tool outputs were masked, so specific results are unavailable
+
+
+ Use this summary as the older context ... Continue the task accordingly.
+ ```
+
+ The summarizer is grounded in the current task (the first user turn + recent turns are passed
+ as "summarize toward this"), not a blind digest. It changes the message count — the one
+ component that does — so it runs alone.
+
+See how these configs are wired in the [llm-d compaction service](llm-d-service.md) guide.
diff --git a/docs/examples/llm-d-service.md b/docs/examples/llm-d-service.md
new file mode 100644
index 0000000..a6efe09
--- /dev/null
+++ b/docs/examples/llm-d-service.md
@@ -0,0 +1,75 @@
+# llm-d compaction service
+
+A ready-to-run example that turns context-guru into a stateless HTTP **compaction service** for
+[`llm-d-router`](https://github.com/ronenkat/llm-d-router). It plugs into the router's
+`request-inline-compaction` step: the router POSTs an inference request, context-guru shrinks the
+`messages` array and hands back a smaller request of the exact same shape.
+
+!!! note "Full example on GitHub"
+ Source, configs, build script, and Go client:
+ [`examples/llm-d-service`](https://github.com/rossoctl/context-guru/tree/main/examples/llm-d-service).
+
+## The contract
+
+The router's `request-inline-compaction` step calls an external service with a simple contract:
+
+```text
+POST /compact body = the inference request JSON
+ 200 + non-empty JSON -> router swaps that body in (now smaller)
+ anything else -> router keeps the original (passthrough)
+```
+
+This service is that endpoint. It parses `messages[]`, runs the pipeline, splices the result back,
+and returns `200` — **with no upstream call, fail-open, and no markers**.
+
+```text
+ ┌─────────────────────── /compact ───────────────────────┐
+ request ───▶│ parse messages[] ─▶ run pipeline ─▶ splice back ─▶ 200 │───▶ smaller request
+ (JSON) │ (format · toon · extract · summarize · …) │ (same schema)
+ └──────────────────────────────────────────────────────────┘
+ no upstream call · fail-open · no markers
+```
+
+## Stateless by design
+
+This is the [reversibility](../how-to/recover-context.md) story turned **off** on purpose:
+
+- **`store: { enabled: false }`** — nothing is stashed.
+- **`marker_mode: "off"`** — no `<>` markers appear.
+
+The result is a clean, directly-usable inference request body — compaction is **one-way**. The
+router owns request routing; context-guru just returns a smaller body it can forward as-is.
+
+Send the step's opt-in header on requests you want compacted:
+
+```text
+x-llm-d-optimization: compaction
+```
+
+## The three committed configs
+
+| Config | Component(s) | LLM? | What it does |
+|---|---|:--:|---|
+| `configs/toon.yaml` | `format` + `toon` | no | Re-encodes uniform JSON object-arrays as **TOON** (field names once, then one row per element). Deterministic, zero-cost, zero-latency. |
+| `configs/extract-code.yaml` | `extract` (`strategy: code`) | yes | A cheap LLM writes a sandboxed filter that **deletes irrelevant lines** from large tool outputs — verified deletion-only, never invents text. |
+| `configs/summarize.yaml` | `summarize` | yes | A cheap LLM **summarizes the middle** of a long transcript into one message, keeping the head + last few turns. |
+
+All three set `store: { enabled: false }` and `marker_mode: "off"`.
+
+!!! tip "Per-request overrides ride on headers/query params"
+ The router forwards the body verbatim, so overrides go on the request:
+ `?provider=anthropic`, `?preset=`, `x-context-guru-pipeline: format,toon`,
+ `x-context-guru-session: `, `x-context-guru-bypass: true`.
+
+## Choosing a config
+
+- **Deterministic, no credentials** → `toon.yaml`. Best for structured/tabular tool output.
+- **Big noisy outputs, only a slice relevant** → `extract-code.yaml`. Needs a cheap model.
+- **Long transcripts where the history is the cost** → `summarize.yaml`. Needs a cheap model.
+
+The LLM configs use a cheap model you point at any OpenAI- or Anthropic-compatible endpoint. If no
+model is reachable they degrade gracefully — `extract` falls back to a deterministic line
+projection and `summarize` no-ops — and the request still returns `200`.
+
+See all three run on one input, with the full `messages` before and after, in the
+[Before → After showcase](before-after.md).
diff --git a/docs/get-started/overview.md b/docs/get-started/overview.md
new file mode 100644
index 0000000..b2a7582
--- /dev/null
+++ b/docs/get-started/overview.md
@@ -0,0 +1,102 @@
+# Technical Overview
+
+context-guru is provider-agnostic **context engineering** for LLM agents: a Go
+core that shrinks the tokens a request carries — losslessly, or
+lossy-but-reversible — **without touching the agent**. The same core runs as an
+HTTP **proxy/gateway** or as an **in-process plugin**.
+
+## The problem
+
+Agents re-send bulky text on every turn: system prompts, past turns, and
+especially **tool outputs** — file reads, command logs, API responses. On a
+long agentic session (say Claude Code on SWE-bench) the transcript of tool
+outputs re-sent each turn dominates the token bill — it costs money and latency
+and pushes against the context window. context-guru parses the request's
+`messages` array, rewrites the bulky parts, and hands back a **smaller request
+of the exact same shape** before it reaches the model.
+
+## The three guarantees
+
+!!! note "These hold for every component, every request"
+ - **Fail open, always** — any component error or panic reverts *that
+ component only*; the original request is always a valid fallback.
+ - **Never worse** — a component that grows the request is reverted.
+ - **Reversible** — every lossy drop leaves a `<>` marker and stashes
+ the original, recoverable via a model-callable `context_guru_expand` tool
+ or `GET /expand`.
+
+## Architecture
+
+```mermaid
+flowchart LR
+ A[Agent] -->|chat request| H{Host adapter}
+ H -->|proxy: proxy.Handler| P[apply.Body]
+ H -->|in-process: AuthBridge plugin| P
+ P -->|messages array| PIPE[Pipeline
ordered components]
+ PIPE --> P
+ P -->|byte-lossless splice| UP[Upstream provider]
+ UP -->|response| EX[expand loop]
+ EX -->|resolve markers from Store| UP
+ EX --> A
+ PIPE -.per-component Report.-> M[Emitter / Aggregator]
+ PIPE -.stash originals.-> S[(Store
TTL+LRU)]
+ EX -.resolve.-> S
+```
+
+Components implement one of two lossiness-typed interfaces and are stacked in
+config order:
+
+```mermaid
+flowchart TD
+ C["Component — Name() · Enabled(ctx)"]
+ C --> R["Reformat: lossless repack
format · cacheinject"]
+ C --> O["Offload: drop + stash, returns cache_keys
skeleton · dedup · collapse · failed_run
cmdfilter · extract · smartcrush · mask · phi_evict"]
+```
+
+## Core concepts
+
+- **Component** — one compaction step with `Name()` and `Enabled(ctx)`.
+- **Reformat vs Offload** — a *Reformat* component repacks losslessly (nothing
+ leaves the wire, nothing to stash); an *Offload* component drops bytes and
+ **must** return the `cache_keys` under which it stashed the originals, or the
+ pipeline reverts it.
+- **Pipeline** — the ordered list of components applied to a request, run
+ fail-open and never-worse; each component is isolated by a snapshot/restore
+ guard.
+- **Marker** — the `<>` placeholder an Offload writes in place of
+ dropped content, pointing at the stashed original.
+- **Store** — the per-session state backend (in-memory TTL+LRU by default) that
+ holds rewind data (`cache_key → original`) and sticky ids.
+- **Session** — the key that ties turns of one conversation together (an
+ explicit host id, else a content hash).
+- **Preset** — a named default pipeline (e.g. `balanced`, `agent`) that explicit
+ config fields override.
+- **Expand loop** — the host-glue continuation that resolves a `<>`
+ marker from the Store when the model calls `context_guru_expand`.
+
+The deep dive lives in [Architecture](../design.md).
+
+## Next steps
+
+
+
+- :material-rocket-launch: **[Quickstart: Proxy](quickstart-proxy.md)**
+
+ Build the binary, run `--preset balanced`, point an agent at it, and check
+ savings.
+
+- :material-scissors-cutting: **[Components](../components.md)**
+
+ Every registered component: how it works, before → after, lossiness,
+ config, best use.
+
+- :material-book-open-variant: **[How-to Guides](../how-to/choose-a-preset.md)**
+
+ Choose a preset, integrate as a plugin, recover offloaded context, measure
+ savings.
+
+- :material-chart-bar: **[Benchmarks](../RESULTS.md)**
+
+ Per-component SWE-bench results — `mask` ≈ 27% token savings, no reward loss.
+
+
diff --git a/docs/get-started/quickstart-compaction.md b/docs/get-started/quickstart-compaction.md
new file mode 100644
index 0000000..6aae5ae
--- /dev/null
+++ b/docs/get-started/quickstart-compaction.md
@@ -0,0 +1,71 @@
+# Quickstart: Compaction service
+
+Run context-guru as a **stateless HTTP compaction service** — the pattern
+[`llm-d-router`](https://github.com/ronenkat/llm-d-router)'s
+`request-inline-compaction` step calls. It shrinks a request body and hands it
+straight back, with no state and no markers.
+
+## The `POST /compact` contract
+
+```
+POST /compact body = the inference request JSON
+ 200 + non-empty JSON -> caller swaps that body in (now smaller)
+ anything else -> caller keeps the original (passthrough)
+```
+
+!!! note "One-way by design"
+ This mode sets `store: { enabled: false }` and `marker_mode: "off"`, so
+ nothing is stored and no `<>` markers appear — the returned body is
+ a clean, directly-usable inference request. Compaction is irreversible here.
+ It never calls upstream and it never errors your caller: unparseable bodies,
+ bodies without a `messages` array, or any component failure return the
+ **original body with `200`**.
+
+## Build
+
+```sh
+./examples/llm-d-service/build.sh # -> bin/context-guru-proxy
+```
+
+## Run with a config
+
+The deterministic `toon` config needs no credentials:
+
+```sh
+bin/context-guru-proxy --config examples/llm-d-service/configs/toon.yaml
+# listens on :4000 (set LISTEN_ADDR to change)
+```
+
+## Try it
+
+```sh
+curl -s -XPOST localhost:4000/compact -H 'content-type: application/json' -d '{
+ "model": "gpt-4o-mini",
+ "messages": [
+ {"role": "user", "content": "list users"},
+ {"role": "tool", "tool_call_id": "c1",
+ "content": "[{\"id\":1,\"name\":\"Alice\",\"role\":\"admin\"},{\"id\":2,\"name\":\"Bob\",\"role\":\"user\"},{\"id\":3,\"name\":\"Carol\",\"role\":\"admin\"},{\"id\":4,\"name\":\"Dave\",\"role\":\"user\"},{\"id\":5,\"name\":\"Eve\",\"role\":\"admin\"}]"}
+ ]
+}'
+```
+
+The response is the same request with the tool output re-encoded as TOON (field
+names once, then one row per element) — smaller, no markers:
+
+```
+[5]{id,name,role}:
+1,Alice,admin
+2,Bob,user
+...
+```
+
+!!! tip "Below the gate = passthrough"
+ Outputs below a component's `min_tokens` gate (or below an LLM config's
+ `trigger`) pass through unchanged — that's expected. Send a larger tool
+ output to see it act.
+
+## Next
+
+The full walkthrough — LLM-backed configs (`extract`, `summarize`), the Go
+client, per-request overrides, and wiring into `llm-d-router` — is in the
+[llm-d compaction service example](../examples/llm-d-service.md).
diff --git a/docs/get-started/quickstart-proxy.md b/docs/get-started/quickstart-proxy.md
new file mode 100644
index 0000000..7160cef
--- /dev/null
+++ b/docs/get-started/quickstart-proxy.md
@@ -0,0 +1,122 @@
+# Quickstart: Proxy
+
+Run context-guru as a standalone proxy in front of your LLM provider, point an
+agent at it, and watch the tokens drop. One port serves both the OpenAI and
+Anthropic dialects.
+
+## Prerequisites
+
+- **Go 1.26** and a **C toolchain** — `CGO_ENABLED=1` is required (the
+ `skeleton` component binds tree-sitter via cgo).
+- The **bifrost** repo checked out beside this one — `go.mod` pins it with
+ `replace github.com/maximhq/bifrost/core => ../bifrost/core`, so build from the
+ parent directory that holds both repos.
+
+!!! warning "Build from the parent directory"
+ The local `replace` only resolves when `context-guru/` and `bifrost/` are
+ siblings:
+
+ ```
+ /
+ context-guru/ ← this repo (dir: lab-context-engineering)
+ bifrost/ ← https://github.com/maximhq/bifrost
+ ```
+
+## Build
+
+=== "go build"
+
+ ```sh
+ cd .../context-engineering # dir containing lab-context-engineering/ and bifrost/
+ CGO_ENABLED=1 go build -o bin/context-guru-proxy \
+ ./lab-context-engineering/cmd/context-guru-proxy
+ ```
+
+=== "make"
+
+ ```sh
+ make build
+ ```
+
+=== "docker"
+
+ ```sh
+ # build context is the parent dir that holds both repos
+ docker build -f lab-context-engineering/Dockerfile -t context-guru:local .
+ ```
+
+## Run
+
+```sh
+context-guru-proxy --preset balanced # or --config cg.yaml
+```
+
+It listens on `:4000` by default (set `LISTEN_ADDR` to change). See
+[Config & environment](../reference/config.md) for all flags and env vars, and
+[Presets](../reference/presets.md) to pick a pipeline.
+
+## Point an agent at it
+
+Set the base URL to the proxy — one port, both dialects:
+
+=== "Anthropic"
+
+ ```sh
+ ANTHROPIC_BASE_URL=http://localhost:4000/anthropic
+ ```
+
+=== "OpenAI"
+
+ ```sh
+ OPENAI_BASE_URL=http://localhost:4000/openai/v1
+ ```
+
+In gateway mode, set `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` on the proxy and it
+injects the real key on forward; leave them empty to pass the client's auth
+through.
+
+## Send a request
+
+```sh
+curl -s -XPOST localhost:4000/openai/v1/chat/completions \
+ -H 'content-type: application/json' \
+ -d '{
+ "model": "gpt-4o-mini",
+ "messages": [
+ {"role": "user", "content": "list users"},
+ {"role": "tool", "tool_call_id": "c1",
+ "content": "[{\"id\":1,\"name\":\"Alice\"},{\"id\":2,\"name\":\"Bob\"}]"}
+ ]
+ }'
+```
+
+## Check savings
+
+```sh
+curl -s localhost:4000/stats | jq # token-weighted savings rollup
+```
+
+`/stats` reports `tokens_before/after`, `saved_tokens`, `savings_pct`
+(token-weighted), plus `wasted_tokens`/`bounces` and per-component rollups. See
+[Measure savings](../how-to/measure-savings.md).
+
+## Recover an offloaded original
+
+A lossy Offload leaves a `<>` marker; recover the stashed original by
+its id:
+
+```sh
+curl -s 'localhost:4000/expand?id='
+```
+
+In normal operation the model recovers it automatically via the
+`context_guru_expand` tool — see [Recover offloaded context](../how-to/recover-context.md).
+
+## Per-request headers
+
+| Header | Effect |
+|---|---|
+| `x-context-guru-session: ` | Set the session key explicitly (otherwise a content hash). |
+| `x-context-guru-bypass: true` | Skip the pipeline entirely for this request. |
+
+The full route and header reference is in [Routes & headers](../reference/routes.md).
diff --git a/docs/how-to/bifrost-plugin.md b/docs/how-to/bifrost-plugin.md
new file mode 100644
index 0000000..d13afa9
--- /dev/null
+++ b/docs/how-to/bifrost-plugin.md
@@ -0,0 +1,72 @@
+# Run as a bifrost LLMPlugin
+
+`adapters/bifrost.Plugin` implements bifrost's `schemas.LLMPlugin`. Register it in
+`BifrostConfig.LLMPlugins` and the context-guru pipeline runs as a `PreRequestHook` inside any
+bifrost proxy — no separate process, no transport of its own.
+
+```go
+p := bifrost.New(pipe, store) // pipe from config.Build, store from config.NewStore
+// register p in BifrostConfig.LLMPlugins
+```
+
+## Where it sits
+
+- **`PreRequestHook`** runs the pipeline over `req.ChatRequest` in place — the canonical mutate
+ phase. Non-chat requests pass through. It never aborts a request; fail-open lives inside the
+ pipeline itself.
+- **Session id** comes from the `context-guru-session` context value (set by the transport from the
+ request header or Anthropic `metadata.user_id`), falling back to the content hash.
+- **`PreLLMHook` / `PostLLMHook` are pass-throughs.** The expand loop is *not* a hook — it must
+ re-invoke upstream, which a hook cannot. It belongs in a transport wrapper that reuses the shared
+ [`expand/`](recover-context.md) package + [store](recover-context.md) on the response path, the
+ same way the other hosts do.
+
+!!! note "Reuses the shared core"
+ Like every host, the bifrost plugin drives the one pipeline over the one bifrost schema. The
+ request path is `apply`-style pipeline execution; the response path reuses `expand/` + the
+ `Store` to resolve `<>` markers. See [Architecture](../design.md).
+
+## The request → expand-loop flow
+
+The transport wrapper mirrors the shipped proxy: run the pipeline, forward, then run the expand
+loop server-side (capped at 3 rounds) before returning. This is the same flow the bifrost wrapper
+implements around the plugin's `PreRequestHook`.
+
+```mermaid
+sequenceDiagram
+ participant Agent
+ participant Proxy as proxy.Handler
+ participant Apply as apply.Body
+ participant Up as Upstream
+ Agent->>Proxy: POST /anthropic/v1/messages (or /openai/v1/chat/completions)
+ Proxy->>Proxy: FORCE_MODEL? overwrite "model"
+ Proxy->>Apply: body, provider, x-context-guru-session, bypass?
+ Apply-->>Proxy: rewritten body (fail open → original)
+ Proxy->>Up: forward (drop placeholder auth, inject real key)
+ Up-->>Proxy: response
+ loop up to 3 rounds
+ Proxy->>Proxy: expand tool called (only)? resolve from Store, re-invoke
+ end
+ Proxy-->>Agent: response (streaming passes straight through)
+```
+
+## The three host adapters
+
+The same core runs behind three hosts; they differ only in how they obtain the body, provider, and
+session id, and where they run the expand loop.
+
+| Option | Host code | Body source | Expand loop |
+|---|---|---|---|
+| **Proxy / gateway** | `proxy/` + `cmd/context-guru-proxy/` | HTTP request body | server-side, wraps the chat route |
+| **AuthBridge plugin** | `cortex` (imports this module) | `pctx.Body` | response path (`OnResponse`) |
+| **bifrost `LLMPlugin`** | `adapters/bifrost/` | `req.ChatRequest` | transport wrapper |
+
+- **Proxy / gateway** — a standalone HTTP proxy that reuses bifrost's `ChatMessage` type (not its
+ transport). One port serves both dialects; runs the expand loop itself.
+- **AuthBridge in-process plugin** — an outbound `WritesBody` plugin living in `cortex`,
+ reusing `apply.Body` on `pctx.Body` and running the continuation in `OnResponse`.
+- **bifrost `LLMPlugin`** — this page: embed the pipeline in an existing bifrost deployment via a
+ `PreRequestHook`.
+
+For the proxy and AuthBridge paths in full, see
+[Run behind a proxy or gateway](../integrations.md).
diff --git a/docs/how-to/choose-a-preset.md b/docs/how-to/choose-a-preset.md
new file mode 100644
index 0000000..9687f9e
--- /dev/null
+++ b/docs/how-to/choose-a-preset.md
@@ -0,0 +1,98 @@
+# Choose a preset
+
+A preset is a named, ordered pipeline. Set it once (`--preset` / `PRESET`, or `preset:` in
+YAML) and every explicit field you add overrides it. This guide maps workloads to presets and
+names the caveats before you commit.
+
+!!! tip "Presets are just defaults"
+ A preset expands to a default `pipeline:` list. Explicit `pipeline:` / `components:` blocks
+ always win, so start from the closest preset and tune from there. See
+ [Architecture](../design.md#config-registry).
+
+## Which preset?
+
+| Your workload | Preset |
+|---|---|
+| Nothing — A/B baseline / passthrough control | `off` |
+| Any traffic, want a guaranteed-safe win only | `safe` |
+| General agent traffic, good default | `balanced` |
+| Squeeze harder, tolerate LLM/structural offload | `aggressive` |
+| Coding agent reading big source files | `coding` |
+| MCP / list-endpoint JSON arrays | `mcp` |
+| Long agentic sessions (SWE-bench-style, re-sent transcripts) | `agent` |
+| One long transcript to compress, run standalone | `summarize` |
+
+## The presets
+
+Every preset below is exactly the list in [`config/config.go`](../design.md#config-registry);
+per-component behavior is in [Components](../components.md).
+
+### `off` — `[]`
+No components. Passthrough. Use it as the A/B control when you measure savings — the baseline in
+[Benchmarks](../RESULTS.md) is this preset.
+
+### `safe` — `[format, cacheinject]`
+Two lossless [Reformat](../components.md#reformat-lossless) components only: compact JSON
+(`format`) and an Anthropic cache breakpoint (`cacheinject`). Nothing is ever dropped, so there is
+nothing to expand.
+
+- **Fits:** any traffic where you want a zero-risk win and no reversibility surface.
+- **Caveat:** `cacheinject`'s savings are provider-side cache hits, invisible to `/stats` token
+ counts — it will show up under `top_passthrough`. That's expected, not dead weight.
+
+### `balanced` — `[format, dedup, failed_run, cmdfilter, cacheinject]`
+The default. Adds three cheap, high-precision offloaders: exact-dup removal (`dedup`), superseded
+test/build runs (`failed_run`), and DSL command-log filtering (`cmdfilter`).
+
+- **Fits:** general agent traffic; the safe everyday choice.
+- **Caveat:** `cmdfilter` only fires when ≥1 filter is loaded and the output's first line matches
+ one. Its builtins cover pytest / npm-install / make; author more with a
+ [custom DSL filter](custom-dsl-filter.md).
+
+### `aggressive` — `[format, dedup, failed_run, cmdfilter, smartcrush, extract, cacheinject]`
+`balanced` plus JSON-array crushing (`smartcrush`) and query-relevance projection (`extract`).
+
+- **Fits:** you want more savings and accept structural/LLM offload with expand recovery.
+- **Caveat:** `extract` with `strategy: code`/`rlm` spends a model call (gated by its `trigger`);
+ the default `deterministic` strategy is free. Keep the [store](recover-context.md) on so the
+ extra offloads stay recoverable.
+
+### `coding` — `[format, skeleton, cmdfilter, cacheinject]`
+Swaps in `skeleton`, which tree-sitter-parses fenced code blocks and replaces function bodies with
+`{ … }`, keeping signatures/imports/types.
+
+- **Fits:** a coding agent that reads large source files but mostly needs the shape.
+- **Caveat:** `skeleton` is inert on unfenced file reads, unknown languages, or when the skeleton
+ isn't smaller than the body.
+
+### `mcp` — `[format, smartcrush, cacheinject]`
+Targets homogeneous JSON arrays (list endpoints, search hits): keep `keep_first` + `keep_last`
+items plus any item carrying an error signal, drop the middle.
+
+- **Fits:** MCP tools and REST list endpoints returning long uniform arrays.
+- **Caveat:** inert on non-array output or arrays below `min_items`.
+
+### `agent` — `[format, dedup, failed_run, mask, extract, cacheinject]`
+Tuned for long agentic sessions (e.g. Claude Code on SWE-bench) where the dominant cost is the
+transcript of old tool outputs re-sent every turn.
+
+- **Fits:** long-running agents with a growing transcript.
+- **Caveat:** **`mask` is the biggest lever here** — age-based GC of tool outputs older than
+ `keep_recent`. In the SWE-bench sweep it delivered ~27% mean content-token savings (up to 93.5%
+ on a long session) with no reward loss ([Benchmarks](../RESULTS.md)). Order matters: lossless
+ first, then offload old-then-large, cache last.
+
+### `summarize` — `[summarize]`
+One LLM component that collapses the middle of the transcript into a single
+`=== History Summary ===` message, keeping the head + last few turns.
+
+- **Fits:** long agentic sessions where the stale middle is the token cost.
+- **Caveat:** **run it alone.** It changes the message count and restructures the whole transcript,
+ so `apply.Body` rebuilds the body — no other component's in-place edits can race that rebuild.
+ It needs a model; with none it no-ops.
+
+!!! warning "LLM presets cost model calls"
+ `aggressive` (via `extract` code/rlm) and `summarize` call a model. Both are gated by a
+ `trigger` and reuse prior compactions per session, so they don't fire every turn. Pick the
+ model with `model.source` (`incoming` reuses the request's own model+key; `config` uses a
+ dedicated cheap model). See [LLM components](../design.md#llm-components).
diff --git a/docs/how-to/custom-dsl-filter.md b/docs/how-to/custom-dsl-filter.md
new file mode 100644
index 0000000..b9106b4
--- /dev/null
+++ b/docs/how-to/custom-dsl-filter.md
@@ -0,0 +1,100 @@
+# Author a custom DSL filter
+
+`components/dsl` is a declarative, user-extensible text-filter engine (adapted from rtk), wrapped by
+the [`cmdfilter`](../components/cmdfilter.md) component. Filters are authored in YAML — **no
+recompile** — matched first-by-sorted-name, and each runs a fixed 8-stage pipeline. Because filters
+drop lines they are lossy, which is why the wrapping `cmdfilter` is an
+[Offload](../components.md#offload-lossy-reversible): it stashes the original first and appends a
+`<>` recovery hint only when the filter was actually lossy.
+
+## The 8-stage pipeline
+
+A matched filter runs its input through these stages in order:
+
+```mermaid
+flowchart LR
+ I[input] --> S1[1 strip_ansi] --> S2["2 replace[]"] --> S3["3 match_output[] + unless"]
+ S3 --> S4[4 strip / keep lines] --> S5[5 truncate_lines_at] --> S6[6 head / tail]
+ S6 --> S7[7 max_lines] --> S8[8 on_empty] --> O[output + Lossiness]
+```
+
+The engine reports `Lossiness` back to `cmdfilter`, which drives the recovery hint: `None` (nothing
+dropped / reversible reformat), `Tail` (a clean contiguous tail dropped), `Whole` (non-contiguous or
+whole-blob loss).
+
+## Filter fields
+
+All optional except `match`.
+
+| Field | Purpose |
+|---|---|
+| `match` | regex tested against the **selector** (the output's first non-empty line) — decides if this filter applies |
+| `strip_ansi` | strip ANSI escape codes |
+| `replace` | chained `pattern` → `replacement` substitutions, `$1` backrefs |
+| `match_output` | whole-blob short-circuit: `pattern` / `message` / `unless` |
+| `strip_lines_matching` **xor** `keep_lines_matching` | drop, or keep-only, lines matching these regexes (mutually exclusive) |
+| `truncate_lines_at` | per-line character cap |
+| `head_lines` / `tail_lines` | keep the first / last N lines |
+| `max_lines` | absolute line cap with an omission marker |
+| `on_empty` | replacement text when the output ends up blank |
+
+!!! warning "strip xor keep"
+ `strip_lines_matching` and `keep_lines_matching` are mutually exclusive — set one or the other,
+ never both.
+
+## A full example (pytest)
+
+Documents load with `schema_version: 1` and strict unknown-field rejection.
+
+```yaml
+schema_version: 1
+filters:
+ pytest:
+ description: keep failures + summary, drop passing noise
+ match: "(pytest|=+ test session starts)"
+ strip_lines_matching: ["^\\s*$", " PASSED", "^\\.+$"]
+ max_lines: 80
+ on_empty: "pytest: all passed"
+tests: # inline; run via dsl.RunTests (a `verify` command)
+ pytest:
+ - name: all-green
+ input: "pytest\n....\n"
+ expected: "pytest: all passed"
+```
+
+### Ship tests with the filter
+
+Inline `tests` (input → expected) run via `dsl.RunTests`, so a filter ships with its own regression
+check. Above, the `all-green` case proves that a passing run collapses to the `on_empty` message —
+if a future edit breaks that, the test fails.
+
+## Load it into `cmdfilter`
+
+Filters are passed to the [`cmdfilter`](../components/cmdfilter.md) component via its `filters`
+config — inline filter YAML docs, added with **no recompile**:
+
+```yaml
+components:
+ cmdfilter:
+ filters:
+ - |
+ schema_version: 1
+ filters:
+ pytest:
+ match: "(pytest|=+ test session starts)"
+ strip_lines_matching: ["^\\s*$", " PASSED", "^\\.+$"]
+ max_lines: 80
+ on_empty: "pytest: all passed"
+ disable_builtins: false # keep the builtin pytest / npm-install / make filters too
+```
+
+- `cmdfilter` is `Enabled` only when ≥1 filter is loaded.
+- It ships builtin `pytest` / `npm-install` / `make` filters; set `disable_builtins: true` to run
+ only your own.
+- The output's first non-empty line is the selector each filter's `match` is tested against, sorted
+ by filter name.
+
+!!! tip
+ Filtering that doesn't shrink the output, or output whose first line matches no filter, is a
+ no-op — `cmdfilter` leaves the message untouched (fail-open, never-worse). See
+ [cmdfilter](../components/cmdfilter.md).
diff --git a/docs/how-to/measure-savings.md b/docs/how-to/measure-savings.md
new file mode 100644
index 0000000..454267f
--- /dev/null
+++ b/docs/how-to/measure-savings.md
@@ -0,0 +1,58 @@
+# Measure savings
+
+context-guru reports what it actually saved through the proxy's `GET /stats` endpoint, backed by
+an in-process metrics aggregator. Savings are measured on **message content text** — what the model
+reads — not the JSON envelope, so a control directive like `cacheinject` never looks "worse".
+
+## `GET /stats`
+
+The proxy exposes `GET /stats` with in-process savings rollups. Savings are **token-weighted**
+(Σ saved / Σ before) — the honest aggregate, not a mean of per-request percentages. It also reports:
+
+| Field | Meaning |
+|---|---|
+| token-weighted savings | Σ saved / Σ before across all requests |
+| `wasted_tokens` | content offloaded then re-served via expand (a premature offload) |
+| `bounces` | how many offloads were re-served (the count behind `wasted_tokens`) |
+| `adjusted_saved` | `saved − wasted` — bounce-adjusted, may be negative |
+| `top_passthrough` | components that ran but never changed a request: dead weight to drop |
+
+!!! tip "Reading top_passthrough"
+ A component in `top_passthrough` isn't necessarily broken. `cacheinject` always lands there —
+ its savings are provider-side KV-cache hits, invisible to content-token counts. But a
+ content-offloader that never fires is a candidate to drop from your pipeline.
+
+## The Emitter interface
+
+The pipeline depends only on the `Emitter` interface (`Component(Report)` + `Run(RunReport)`), so it
+carries no telemetry-backend dependency. Swap implementations to route metrics where you need:
+
+| Emitter | Role |
+|---|---|
+| `Slog` | logs in the `context_engineering.*` vocabulary |
+| `Aggregator` | in-process rollups behind `/stats` |
+| `Tee` | fan-out to several emitters |
+| `NopEmitter` | discard |
+
+## A real session: `scripts/cc-demo.sh`
+
+`scripts/cc-demo.sh` routes a real `claude` CLI session through the proxy and reads `/stats` before
+and after. It builds a tiny repo, starts the proxy with `--preset balanced`, points Claude Code's
+`ANTHROPIC_BASE_URL` at it, runs one `claude -p` task, and prints the stats delta:
+
+```sh
+export ANTHROPIC_BASE_URL=... # upstream Anthropic-compatible endpoint
+export ANTHROPIC_AUTH_TOKEN=...
+scripts/cc-demo.sh
+# == stats before == {...}
+# (claude reads main.go + README.md through the proxy)
+# == stats after == {...} ← token-weighted savings for the session
+```
+
+It's the shortest way to see real savings on your own model without a full benchmark harness.
+
+## Benchmarks
+
+For the full per-component SWE-bench evaluation — where `mask` delivers ~27% content-token savings
+with no reward loss, and how the `/stats` within-run metric is derived — see
+[Benchmarks](../RESULTS.md).
diff --git a/docs/how-to/recover-context.md b/docs/how-to/recover-context.md
new file mode 100644
index 0000000..a746f2c
--- /dev/null
+++ b/docs/how-to/recover-context.md
@@ -0,0 +1,68 @@
+# Recover offloaded context
+
+Every lossy [Offload](../components.md#offload-lossy-reversible) is reversible. When a component
+drops bytes it writes a `<>` marker in place of the dropped content and stashes the
+original in the [store](../design.md#state-the-store) under that hash. Nothing is ever silently
+lost — an Offload that drops content but returns no cache key is treated as a failed offload and
+reverted.
+
+## The marker
+
+```text
+[older tool output masked] <> [full output: call context_guru_expand]
+```
+
+The marker is the recovery handle. The model sees a short hint next to it, and the original bytes
+live in the store keyed by the hash.
+
+## Three ways to recover
+
+### 1. The model-callable `context_guru_expand` tool
+The host injects a `context_guru_expand(id)` tool into the request. When the model needs the full
+content behind a marker, it calls the tool with the hash.
+
+### 2. The host-side expand continuation loop
+The host resolves the hash, appends the original as a tool result, and re-invokes upstream so the
+model finishes with the full content in hand. The marker format, tool definition, response parsing,
+and continuation builder are shared in `expand/`; the loop itself is host glue because it must
+re-invoke upstream.
+
+```mermaid
+sequenceDiagram
+ participant M as Model
+ participant Host
+ participant Store
+ participant Up as Upstream
+ Host->>Up: request (content replaced by <> + expand tool)
+ Up-->>Host: response calls context_guru_expand(id=HASH)
+ Host->>Store: Resolve(HASH)
+ Store-->>Host: original bytes
+ Host->>Up: append assistant tool-call + tool_result(original), re-invoke
+ Up-->>M: final answer with full content in hand
+ Note over Host,Up: capped at 3 rounds — if the model also calls another tool,
the loop bails and returns the response as-is
+```
+
+The loop is **capped at 3 rounds** (`maxExpandRounds = 3`). If the model calls another tool
+alongside the expand call, the loop bails and returns the response as-is. An expired or evicted
+original resolves to an explicit placeholder rather than being omitted — the provider requires one
+`tool_result` per `tool_call_id`. A store miss silently turns a lossless offload lossy: the known
+TTL edge.
+
+### 3. `GET /expand?id=`
+The proxy exposes `GET /expand?id=` to recover an offloaded original directly, out of band
+from the model loop.
+
+## Reversibility requires the store
+
+The store is the whole reversibility mechanism. It defaults to an in-memory TTL+LRU backend —
+**1800s TTL, 1000 entries, 100 sticky sessions** — shared by every host. It holds, per session:
+
+- **Rewind** — `cache_key → original bytes`, what the expand loop resolves.
+- **Sticky** — the set of content ids already reduced on prior turns (byte-stable output across
+ turns).
+
+!!! warning "No store, no recovery"
+ Set `store.enabled: false` and offloads become **one-way** — a `store.Nop` is wired in and
+ nothing is stashed. The [llm-d compaction service](../examples/llm-d-service.md) does this
+ deliberately (with `marker_mode: off`) so `/compact` returns a clean, marker-free,
+ directly-usable request body. Only disable the store when you don't need to recover.
diff --git a/docs/how-to/use-with-claude-code.md b/docs/how-to/use-with-claude-code.md
new file mode 100644
index 0000000..f785bbb
--- /dev/null
+++ b/docs/how-to/use-with-claude-code.md
@@ -0,0 +1,84 @@
+# Use with Claude Code
+
+[Claude Code](https://docs.claude.com/en/docs/claude-code) speaks the **Anthropic**
+dialect and honors the `ANTHROPIC_BASE_URL` override, so routing it through context-guru
+takes one environment variable — no changes to Claude Code itself.
+
+```
+Claude Code ──ANTHROPIC_BASE_URL──▶ context-guru :4000/anthropic ──▶ Anthropic
+ (compacts tool outputs) (or Bedrock/Vertex)
+```
+
+## The one-liner
+
+Start the proxy, then point Claude Code at it:
+
+```sh
+context-guru-proxy --preset agent # long-session preset; mask is the big lever
+
+ANTHROPIC_BASE_URL=http://localhost:4000/anthropic claude
+```
+
+That's the whole integration. Every request Claude Code makes now flows through the
+pipeline; the response path resolves any `context_guru_expand` calls automatically.
+
+!!! tip "Which preset?"
+ Claude Code sessions are long and dominated by re-sent tool outputs (file reads,
+ command logs), so `--preset agent` (`mask` + `dedup` + `failed_run` + `extract`)
+ is the biggest lever — ~27% token savings with no task-reward change in the
+ [benchmarks](../RESULTS.md). Use `coding` if you want `skeleton` to strip function
+ bodies from big source reads. See [Choose a preset](choose-a-preset.md).
+
+## With a wrapper script
+
+[`scripts/with-guru.sh`](https://github.com/rossoctl/context-guru/blob/main/scripts/with-guru.sh)
+starts the proxy, exports the base-URL env, and runs any agent command through it:
+
+```sh
+scripts/with-guru.sh agent -- claude
+```
+
+## Gateway mode (proxy holds the key)
+
+Give the proxy the real key and hand Claude Code a placeholder — the proxy injects the
+real credential on forward, so the key never lives in Claude Code's config:
+
+```sh
+ANTHROPIC_API_KEY=sk-ant-real... context-guru-proxy --preset agent
+
+ANTHROPIC_BASE_URL=http://localhost:4000/anthropic \
+ANTHROPIC_AUTH_TOKEN=placeholder \
+ claude
+```
+
+Leave `ANTHROPIC_API_KEY` unset on the proxy to pass Claude Code's own auth straight
+through instead.
+
+## Project-scoped via `.claude/settings.json`
+
+To make it sticky for a repo without exporting env each time:
+
+```json
+{
+ "env": {
+ "ANTHROPIC_BASE_URL": "http://localhost:4000/anthropic"
+ }
+}
+```
+
+## Verify it's working
+
+```sh
+curl -s localhost:4000/stats | jq
+```
+
+`requests` climbs as Claude Code makes calls, and `savings_pct` / per-component
+`saved_tokens` show what each component removed. See [Measure savings](measure-savings.md).
+
+!!! note "Pin the model (optional)"
+ context-guru forwards whatever `model` Claude Code sends. To force a model
+ regardless (e.g. in eval harnesses), set `FORCE_MODEL` on the proxy. Claude Code's
+ own model env (`ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`) still selects the
+ model it asks for.
+
+See also: [Quickstart: Proxy](../get-started/quickstart-proxy.md) · [Run behind a proxy or gateway](../integrations.md) · [Recover offloaded context](recover-context.md)
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..e3fe47b
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,7 @@
+---
+title: context-guru
+template: home.html
+hide:
+ - navigation
+ - toc
+---
diff --git a/docs/integrations.md b/docs/integrations.md
index 13fb0a7..4314e8c 100644
--- a/docs/integrations.md
+++ b/docs/integrations.md
@@ -45,7 +45,7 @@ Key behaviors (`proxy/proxy.go`):
(`event-stream`) responses skip the loop and pass through with flushing.
- `x-context-guru-*` headers are stripped before forwarding upstream.
-Run it: see the [README](../README.md) flag table and [setup.md](setup.md).
+Run it: see the [README](https://github.com/rossoctl/context-guru/blob/main/README.md) flag table and [setup.md](setup.md).
## Option B — AuthBridge in-process plugin
diff --git a/docs/javascripts/charts.js b/docs/javascripts/charts.js
new file mode 100644
index 0000000..ec30e79
--- /dev/null
+++ b/docs/javascripts/charts.js
@@ -0,0 +1,101 @@
+// Interactive benchmark charts (Chart.js via CDN). Data is inlined here from
+// docs/RESULTS.md (SWE-bench, Claude Code, claude-sonnet-4-6, 10 tasks x 13 configs).
+// A