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 in the markdown gets rendered here. +// ponytail: inline data, not wired to the results CSV — swap to a fetch of +// deploy/eval-containers/results/*.csv if the numbers should track the sweep automatically. + +const CG_COMPONENTS = [ + { name: "mask", resolved: 26.8, all: 30.9 }, + { name: "balanced", resolved: 12.2, all: 9.0 }, + { name: "extract", resolved: 11.1, all: 11.4 }, + { name: "failed_run", resolved: 5.8, all: 7.3 }, + { name: "collapse", resolved: 0.0, all: 0.6 }, + { name: "dedup", resolved: 0.2, all: 0.3 }, + { name: "format", resolved: 0.0, all: 0.0 }, + { name: "cmdfilter", resolved: 0.0, all: 0.0 }, + { name: "cacheinject", resolved: 0.0, all: 0.0 }, + { name: "skeleton", resolved: 0.0, all: 0.0 }, + { name: "smartcrush", resolved: 0.0, all: 0.0 }, + { name: "phi_evict", resolved: 0.0, all: 0.0 }, +]; + +function cgTeal(alpha) { + return `rgba(0, 150, 136, ${alpha})`; +} + +function cgGridColor() { + const dark = + document.body.getAttribute("data-md-color-scheme") === "slate"; + return dark ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.08)"; +} + +function cgTickColor() { + const dark = + document.body.getAttribute("data-md-color-scheme") === "slate"; + return dark ? "rgba(255,255,255,0.72)" : "rgba(0,0,0,0.72)"; +} + +function renderSavePerComponent(canvas) { + if (canvas._cgChart) canvas._cgChart.destroy(); + const labels = CG_COMPONENTS.map((c) => c.name); + canvas._cgChart = new Chart(canvas, { + type: "bar", + data: { + labels, + datasets: [ + { + label: "Resolved tasks (6)", + data: CG_COMPONENTS.map((c) => c.resolved), + backgroundColor: cgTeal(0.85), + borderRadius: 4, + }, + { + label: "All tasks (10)", + data: CG_COMPONENTS.map((c) => c.all), + backgroundColor: cgTeal(0.35), + borderRadius: 4, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { labels: { color: cgTickColor() } }, + title: { + display: true, + text: "Mean content-token savings % per component", + color: cgTickColor(), + }, + tooltip: { + callbacks: { label: (i) => ` ${i.dataset.label}: ${i.parsed.y}%` }, + }, + }, + scales: { + y: { + beginAtZero: true, + title: { display: true, text: "% tokens saved", color: cgTickColor() }, + grid: { color: cgGridColor() }, + ticks: { color: cgTickColor() }, + }, + x: { grid: { display: false }, ticks: { color: cgTickColor() } }, + }, + }, + }); +} + +function cgRenderAll() { + document.querySelectorAll("canvas[data-cg-chart]").forEach((canvas) => { + if (typeof Chart === "undefined") return; + if (canvas.dataset.cgChart === "save-per-component") { + renderSavePerComponent(canvas); + } + }); +} + +// Render on load and after Material instant-nav swaps + palette changes. +if (typeof document$ !== "undefined") { + document$.subscribe(() => setTimeout(cgRenderAll, 0)); +} else { + window.addEventListener("DOMContentLoaded", cgRenderAll); +} diff --git a/docs/javascripts/mathjax.js b/docs/javascripts/mathjax.js new file mode 100644 index 0000000..7759c28 --- /dev/null +++ b/docs/javascripts/mathjax.js @@ -0,0 +1,17 @@ +// MathJax config for pymdownx.arithmatex (generic mode) — renders the phi_evict Φ formula. +window.MathJax = { + tex: { + inlineMath: [["\\(", "\\)"]], + displayMath: [["\\[", "\\]"], ["$$", "$$"]], + processEscapes: true, + processEnvironments: true, + }, + options: { ignoreHtmlClass: ".*|", processHtmlClass: "arithmatex" }, +}; + +// Re-typeset after Material's instant navigation swaps the page body. +document$.subscribe(() => { + if (window.MathJax && window.MathJax.typesetPromise) { + window.MathJax.typesetPromise(); + } +}); diff --git a/docs/reference/config.md b/docs/reference/config.md new file mode 100644 index 0000000..e377bfe --- /dev/null +++ b/docs/reference/config.md @@ -0,0 +1,56 @@ +# Config & environment + +One strict YAML struct serves both hosts (the proxy loads a file; the AuthBridge +plugin hands its `config:` subtree to the same loader). A `preset` expands to a +default pipeline; explicit fields override it. + +## Config shape + +The document has four top-level fields (from the `Config` struct in +`config/config.go`): + +| Field | Type | Role | +|---|---|---| +| `preset` | string | Named default pipeline (see [Presets](presets.md)). | +| `pipeline` | `[]string` | Ordered component names — controls **order + enablement**. Overrides the preset's pipeline when present. | +| `components:` | map | Each component's typed config block, handed to its constructor verbatim. | +| `store` | object | State store options (`enabled`, `ttl_seconds`, `max_entries`, …). | + +!!! warning "Strict: unknown keys are rejected" + The YAML loader runs with `KnownFields(true)`, so a typo'd key fails loudly + at load time rather than being silently ignored. + +## Example + +```yaml +preset: balanced +pipeline: [format, dedup, failed_run, cmdfilter, cacheinject] # order + enable +components: + collapse: { max_tokens: 2000, head_lines: 20, tail_lines: 20 } + smartcrush: { min_items: 5, keep_first: 3, keep_last: 2 } +store: { ttl_seconds: 1800, max_entries: 1000 } +``` + +A component registers its constructor + config type via `init()`, so adding one +makes it YAML-configurable with no core edit. See [Components](../components.md) +for every component's config block. + +## Flags & environment + +| Flag / env | Default | Purpose | +|---|---|---| +| `--preset` / `PRESET` | `balanced` | Pipeline preset when no `--config`. | +| `--config` / `CONFIG` | — | YAML config file (overrides preset). | +| `LISTEN_ADDR` | `:4000` | Listen address. | +| `--openai-upstream` / `OPENAI_UPSTREAM` | `https://api.openai.com` | OpenAI upstream base. | +| `--anthropic-upstream` / `ANTHROPIC_UPSTREAM` | `https://api.anthropic.com` | Anthropic upstream base. | +| `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` | — | Real key injected on forward (gateway mode); empty = pass client auth through. | +| `FORCE_MODEL` | — | Overwrite the request `model` (eval-containers uses `EVAL_MODEL`). | +| `--store` / `STORE` | on | Enable/disable the state store; `--store=false` disables offload reversibility. Wins over the file's `store:` block. | + +## Diagnostics + +| Env | Effect | +|---|---| +| `CONTEXT_GURU_DEBUG=1` | Logs each tool output's token count + first line. | +| `CONTEXT_GURU_DUMP=` | Appends a before → after JSON record per rewritten message. | diff --git a/docs/reference/presets.md b/docs/reference/presets.md new file mode 100644 index 0000000..8311136 --- /dev/null +++ b/docs/reference/presets.md @@ -0,0 +1,25 @@ +# Presets + +A preset is a named default pipeline — an ordered list of component names. +Selecting one (`--preset ` / `PRESET`, or `preset:` in YAML) expands to +its pipeline; explicit config fields always override it. Pipelines below are +taken exactly from the `presets` map in `config/config.go`. + +| Preset | Ordered pipeline | When to use | +|---|---|---| +| `off` | *(empty)* | Passthrough — no components. The baseline / A-B control. | +| `safe` | `format` → `cacheinject` | Lossless only: repack JSON compactly and add `cache_control`. Zero risk of dropping content. | +| `balanced` | `format` → `dedup` → `failed_run` → `cmdfilter` → `cacheinject` | The default. Lossless repack + conservative offloads (dedupe, drop superseded/failed runs, filter command noise) + cache. | +| `aggressive` | `format` → `dedup` → `failed_run` → `cmdfilter` → `smartcrush` → `extract` → `cacheinject` | `balanced` plus `smartcrush` (crush long homogeneous arrays) and `extract` (LLM relevance filter) for deeper savings. | +| `coding` | `format` → `skeleton` → `cmdfilter` → `cacheinject` | Coding agents: `skeleton` reduces big source-file reads to their structure via tree-sitter. | +| `mcp` | `format` → `smartcrush` → `cacheinject` | Tool/MCP servers returning long homogeneous JSON arrays (list endpoints, search hits). | +| `agent` | `format` → `dedup` → `failed_run` → `mask` → `extract` → `cacheinject` | Long agentic sessions (e.g. Claude Code on SWE-bench) where re-sent tool outputs dominate cost. `mask` is the biggest lever — ~27% content-token savings with no task-reward loss (see [Benchmarks](../RESULTS.md)). | +| `summarize` | `summarize` | Long trajectories where the transcript itself is the cost. **Runs alone** — it restructures the whole transcript (changes the message count), so no other component's in-place edits race the rebuild. | + +!!! tip "Order matters" + Components run in pipeline order: lossless repack first, then offloads + (old-then-large), with `cacheinject` last so it keeps the reduced prefix + cacheable. + +Not sure which to pick? See [Choose a preset](../how-to/choose-a-preset.md). +Every component's config lives in [Components](../components.md). diff --git a/docs/reference/routes.md b/docs/reference/routes.md new file mode 100644 index 0000000..9c8730e --- /dev/null +++ b/docs/reference/routes.md @@ -0,0 +1,30 @@ +# Routes & headers + +The proxy serves both provider dialects on one port (default `:4000`). + +## Routes + +| Route | Purpose | +|---|---| +| `POST /openai/v1/chat/completions` | OpenAI chat dialect — runs the pipeline, forwards to the OpenAI upstream. | +| `POST /anthropic/v1/messages` | Anthropic Messages dialect — runs the pipeline, forwards to the Anthropic upstream. | +| `GET /healthz` | Liveness check. | +| `GET /stats` | Savings rollups (token-weighted `Σ saved / Σ before`, plus `wasted_tokens`/`bounces` and per-component breakdown). | +| `GET /expand?id=` | Recover an offloaded original by its `<>` id. | + +!!! note "`POST /compact` (compaction-service mode)" + The [llm-d compaction service example](../examples/llm-d-service.md) adds a + stateless `POST /compact` route: it runs the pipeline and returns the + rewritten body directly (`200` + JSON) with no upstream call, no store, and + no markers. See [Quickstart: Compaction service](../get-started/quickstart-compaction.md). + +## Per-request headers + +| Header | Effect | +|---|---| +| `x-context-guru-session: ` | Sets the session key explicitly. Otherwise a stable content hash (`sha256(system + firstUser)`) keys the session. | +| `x-context-guru-bypass: true` | Skips the pipeline entirely for this request (tokens unchanged). | +| `x-context-guru-pipeline: ` | Runs exactly these components, in order, for this request. | + +See [Config & environment](config.md) for flags and env vars, and +[Presets](presets.md) for the built-in pipelines. diff --git a/docs/setup.md b/docs/setup.md index 085d352..82541ab 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -43,7 +43,7 @@ curl -s localhost:4000/stats | jq # token-weighted savings rollup ## Example: SWE-bench + Claude Code through the gateway This uses the committed compose override -[`deploy/eval-containers/compose.contextguru.yaml`](../deploy/eval-containers/compose.contextguru.yaml), +[`deploy/eval-containers/compose.contextguru.yaml`](https://github.com/rossoctl/context-guru/blob/main/deploy/eval-containers/compose.contextguru.yaml), which swaps the eval-containers gateway for `context-guru:local` and wires it to an Anthropic-native upstream (IBM litellm). Model: **`anthropic/claude-sonnet-4-6`**. @@ -95,7 +95,7 @@ docker compose \ ### 3. Sweep many task × config cells -[`deploy/eval-containers/sweep.py`](../deploy/eval-containers/sweep.py) automates the matrix +[`deploy/eval-containers/sweep.py`](https://github.com/rossoctl/context-guru/blob/main/deploy/eval-containers/sweep.py) automates the matrix (baseline vs each component alone vs the `balanced` preset vs competitors) over a task list. It runs each cell, waits for the runner to exit, and appends reward + wall-clock + `/stats` savings to `deploy/eval-containers/sweep-results.csv`. It is **resumable** — cells already in the CSV are diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000..68978db --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,217 @@ +/* context-guru docs — brand accent + adk.dev-style marketing homepage. */ + +:root { + --md-primary-fg-color: #009688; + --md-primary-fg-color--light: #4db6ac; + --md-primary-fg-color--dark: #00695c; + --md-accent-fg-color: #00bfa5; + --cg-hero-grad: radial-gradient( + 1200px 600px at 15% -10%, + rgba(0, 150, 136, 0.18), + transparent 60% + ), + radial-gradient(900px 500px at 95% 0%, rgba(0, 191, 165, 0.14), transparent 55%); +} + +[data-md-color-scheme="slate"] { + --cg-hero-grad: radial-gradient( + 1200px 600px at 15% -10%, + rgba(0, 150, 136, 0.28), + transparent 60% + ), + radial-gradient(900px 500px at 95% 0%, rgba(0, 191, 165, 0.18), transparent 55%); +} + +/* ---- Hero ---- */ +.cg-hero { + background: var(--cg-hero-grad); + padding: 4.5rem 1rem 3rem; + text-align: center; +} +.cg-hero__inner { + max-width: 52rem; + margin: 0 auto; +} +.cg-hero__eyebrow { + display: inline-block; + font-size: 0.72rem; + letter-spacing: 0.14em; + text-transform: uppercase; + font-weight: 700; + color: var(--md-primary-fg-color); + border: 1px solid var(--md-primary-fg-color--light); + border-radius: 999px; + padding: 0.25rem 0.8rem; + margin-bottom: 1.25rem; +} +.cg-hero h1 { + font-size: 2.9rem; + line-height: 1.08; + font-weight: 800; + margin: 0 0 1rem; + letter-spacing: -0.02em; +} +.cg-hero p.cg-hero__lede { + font-size: 1.12rem; + color: var(--md-default-fg-color--light); + max-width: 42rem; + margin: 0 auto 2rem; +} +.cg-cta { + display: flex; + gap: 0.75rem; + justify-content: center; + flex-wrap: wrap; + margin-bottom: 2.5rem; +} +.cg-btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.7rem 1.4rem; + border-radius: 8px; + font-weight: 700; + font-size: 0.9rem; + transition: transform 0.12s ease, box-shadow 0.12s ease; +} +.cg-btn:hover { + transform: translateY(-1px); +} +.cg-btn--primary { + background: var(--md-primary-fg-color); + color: #fff !important; + box-shadow: 0 6px 20px rgba(0, 150, 136, 0.35); +} +.cg-btn--ghost { + border: 1px solid var(--md-default-fg-color--lighter); + color: var(--md-default-fg-color) !important; +} + +/* ---- Guarantee pills ---- */ +.cg-guarantees { + display: flex; + gap: 0.6rem; + justify-content: center; + flex-wrap: wrap; +} +.cg-pill { + font-size: 0.82rem; + font-weight: 600; + padding: 0.35rem 0.9rem; + border-radius: 999px; + background: color-mix(in srgb, var(--md-primary-fg-color) 12%, transparent); + color: var(--md-primary-fg-color--dark); +} +[data-md-color-scheme="slate"] .cg-pill { + color: var(--md-primary-fg-color--light); +} + +/* ---- Section shell ---- */ +.cg-section { + max-width: 60rem; + margin: 0 auto; + padding: 2.75rem 1rem; +} +.cg-section h2 { + text-align: center; + font-weight: 800; + letter-spacing: -0.01em; +} +.cg-section__sub { + text-align: center; + color: var(--md-default-fg-color--light); + margin: -0.5rem auto 2rem; + max-width: 40rem; +} + +/* ---- Feature cards ---- */ +.cg-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: 1rem; +} +.cg-card { + border: 1px solid var(--md-default-fg-color--lightest); + border-radius: 12px; + padding: 1.3rem; + background: var(--md-default-bg-color); + transition: border-color 0.15s ease, transform 0.15s ease; +} +.cg-card:hover { + border-color: var(--md-primary-fg-color--light); + transform: translateY(-2px); +} +.cg-card__icon { + font-size: 1.6rem; + line-height: 1; +} +.cg-card h3 { + margin: 0.6rem 0 0.35rem; + font-size: 1.02rem; +} +.cg-card p { + margin: 0; + font-size: 0.86rem; + color: var(--md-default-fg-color--light); +} + +/* ---- Before/after demo ---- */ +.cg-demo { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 1rem; + align-items: center; +} +@media (max-width: 44rem) { + .cg-demo { + grid-template-columns: 1fr; + } + .cg-demo__arrow { + transform: rotate(90deg); + } +} +.cg-demo__panel { + border: 1px solid var(--md-default-fg-color--lightest); + border-radius: 10px; + padding: 1rem 1.1rem; +} +.cg-demo__panel h4 { + margin: 0 0 0.4rem; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--md-default-fg-color--light); +} +.cg-demo__big { + font-size: 1.9rem; + font-weight: 800; +} +.cg-demo__arrow { + font-size: 1.6rem; + color: var(--md-primary-fg-color); + text-align: center; +} + +/* ---- Stat strip ---- */ +.cg-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); + gap: 1rem; + text-align: center; +} +.cg-stat__num { + font-size: 2.2rem; + font-weight: 800; + color: var(--md-primary-fg-color); +} +.cg-stat__label { + font-size: 0.82rem; + color: var(--md-default-fg-color--light); +} + +/* Charts need an explicit box (Chart.js maintainAspectRatio:false). */ +.cg-chart-box { + position: relative; + height: 26rem; + margin: 1.5rem 0; +} diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..617b4d4 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,152 @@ +site_name: context-guru +site_description: >- + Provider-agnostic context engineering for LLM agents — shrink the tokens a + request carries, losslessly or lossy-but-reversibly, without touching the agent. +site_url: https://rossoctl.github.io/context-guru/ +repo_url: https://github.com/rossoctl/context-guru +repo_name: rossoctl/context-guru +edit_uri: edit/main/docs/ +copyright: >- + Apache-2.0 · a Rossoctl platform component + +theme: + name: material + custom_dir: overrides + language: en + icon: + repo: fontawesome/brands/github + logo: material/scissors-cutting + palette: + - media: "(prefers-color-scheme)" + toggle: + icon: material/brightness-auto + name: Switch to light mode + - media: "(prefers-color-scheme: light)" + scheme: default + primary: teal + accent: teal + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: teal + accent: teal + toggle: + icon: material/brightness-4 + name: Switch to system preference + font: + text: Inter + code: JetBrains Mono + features: + - navigation.tabs + - navigation.tabs.sticky + - navigation.sections + - navigation.top + - navigation.footer + - navigation.indexes + - navigation.instant + - navigation.instant.progress + - toc.follow + - search.suggest + - search.highlight + - content.code.copy + - content.code.annotate + - content.tabs.link + - content.action.edit + +markdown_extensions: + - abbr + - admonition + - attr_list + - def_list + - footnotes + - md_in_html + - tables + - toc: + permalink: true + - pymdownx.arithmatex: + generic: true + - pymdownx.details + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.keys + - pymdownx.tabbed: + alternate_style: true + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + +extra_css: + - stylesheets/extra.css + +extra_javascript: + - javascripts/mathjax.js + - https://unpkg.com/mathjax@3/es5/tex-mml-chtml.js + - https://cdn.jsdelivr.net/npm/chart.js@4 + - javascripts/charts.js + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/rossoctl/context-guru + - icon: fontawesome/brands/golang + link: https://pkg.go.dev/github.com/rossoctl/context-guru + +# Internal implementation plans live under docs/ but must never publish. +exclude_docs: | + superpowers/ + +nav: + - Home: index.md + - Get Started: + - Technical Overview: get-started/overview.md + - "Quickstart: Proxy": get-started/quickstart-proxy.md + - "Quickstart: Compaction service": get-started/quickstart-compaction.md + - Setup & evaluation: setup.md + - Concepts: + - Architecture (deep dive): design.md + - Components: + - Overview: components.md + - Reformat: + - format: components/format.md + - toon: components/toon.md + - cacheinject: components/cacheinject.md + - "Offload (reversible)": + - skeleton: components/skeleton.md + - dedup: components/dedup.md + - collapse: components/collapse.md + - failed_run: components/failed_run.md + - cmdfilter: components/cmdfilter.md + - extract: components/extract.md + - smartcrush: components/smartcrush.md + - mask: components/mask.md + - phi_evict: components/phi_evict.md + - summarize: components/summarize.md + - The DSL filter engine: components/dsl.md + - How-to Guides: + - Use with Claude Code: how-to/use-with-claude-code.md + - Choose a preset: how-to/choose-a-preset.md + - Run behind a proxy or gateway: integrations.md + - Integrate as a bifrost plugin: how-to/bifrost-plugin.md + - Write a custom DSL filter: how-to/custom-dsl-filter.md + - Recover offloaded context: how-to/recover-context.md + - Measure savings: how-to/measure-savings.md + - Examples: + - llm-d compaction service: examples/llm-d-service.md + - Before → After showcase: examples/before-after.md + - Benchmarks: RESULTS.md + - Reference: + - Routes & headers: reference/routes.md + - Config & environment: reference/config.md + - Presets: reference/presets.md + - Community: community.md diff --git a/overrides/home.html b/overrides/home.html new file mode 100644 index 0000000..e929a85 --- /dev/null +++ b/overrides/home.html @@ -0,0 +1,126 @@ +{% extends "main.html" %} + + + +{% block tabs %} + {{ super() }} + +
+
+ Context engineering for LLM agents +

Shrink agent context.
Don’t touch the agent.

+

+ A provider-agnostic Go core that cuts the tokens every request carries — + losslessly, or lossy-but-reversibly — as an HTTP proxy or an in-process + plugin. Same request in, a smaller request of the exact same shape out. +

+ +
+ Fail open, always + Never worse + Reversible +
+
+
+ +
+

Run it in one line

+

Point any agent at the proxy — one port serves both dialects.

+
+
context-guru-proxy --preset balanced          # or --config cg.yaml
+
+ANTHROPIC_BASE_URL=http://localhost:4000/anthropic
+OPENAI_BASE_URL=http://localhost:4000/openai/v1
+
+
+ +
+

What it does

+
+
+
🛟
+

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. Token cost is measured on message content, so you never pay to compact.

+
+
+
↩️
+

Reversible

+

Every lossy drop leaves a <<cg:HASH>> marker and stashes the original — recoverable on demand.

+
+
+
🔌
+

Three ways to run

+

Proxy / gateway, in-process AuthBridge plugin, or a bifrost LLMPlugin — one core, unchanged.

+
+
+
🧩
+

13 stackable components

+

Lossless reformatters and reversible offloaders, ordered by config. Presets for coding, MCP, and long agent sessions.

+
+
+
📉
+

Measured, not hand-wavy

+

Benchmarked on SWE-bench with Claude Code: real token savings, zero task-reward change.

+
+
+
+ +
+

Before → After

+

The summarize config on the sample 11-message transcript — same shape, far fewer tokens.

+
+
+

Before

+
11 msgs
+
2,667 bytes
+
+
+
+

After

+
4 msgs
+
1,298 bytes
+
+
+

+ See all three configs → +

+
+ +
+

The numbers

+

SWE-bench · Claude Code · claude-sonnet-4-6 · 10 tasks × 13 configs.

+
+
+
~27%
+
mask mean token savings (resolved tasks)
+
+
+
0
+
tasks broken — reward preserved 6/6
+
+
+
<10ms
+
component overhead per request
+
+
+
93.5%
+
peak savings on a long session
+
+
+

+ Explore the benchmarks → +

+
+{% endblock %} + +{% block content %}{% endblock %} +{% block footer %}{{ super() }}{% endblock %} diff --git a/overrides/main.html b/overrides/main.html new file mode 100644 index 0000000..b5542a5 --- /dev/null +++ b/overrides/main.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + + +{% block announce %} + + context-guru is a Rossoctl platform component · shrink agent context, don’t touch the agent → + +{% endblock %} diff --git a/proxy/proxy.go b/proxy/proxy.go index 8c7b873..af487a8 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -18,6 +18,7 @@ import ( "strings" "time" + bschemas "github.com/maximhq/bifrost/core/schemas" "github.com/rossoctl/context-guru/apply" "github.com/rossoctl/context-guru/components" "github.com/rossoctl/context-guru/expand" @@ -25,7 +26,6 @@ import ( "github.com/rossoctl/context-guru/metrics" "github.com/rossoctl/context-guru/schema" "github.com/rossoctl/context-guru/store" - bschemas "github.com/maximhq/bifrost/core/schemas" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 0000000..fd413e0 --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,4 @@ +# Documentation site — Material for MkDocs (same generator as adk.dev). +# Build: pip install -r requirements-docs.txt && mkdocs serve +mkdocs-material>=9.5 +pymdown-extensions>=10.7 diff --git a/schema/schema.go b/schema/schema.go index 4e1ad35..6cf1485 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -13,8 +13,8 @@ package schema import ( "encoding/json" - "github.com/rossoctl/context-guru/internal/tokens" "github.com/maximhq/bifrost/core/schemas" + "github.com/rossoctl/context-guru/internal/tokens" ) // Provider identifies the wire dialect a request arrived in. It drives diff --git a/scripts/cc-demo.sh b/scripts/cc-demo.sh index 1a39b4f..e462613 100755 --- a/scripts/cc-demo.sh +++ b/scripts/cc-demo.sh @@ -1,36 +1,41 @@ #!/usr/bin/env bash -# Real Claude Code integration demo: route `claude` through lab-cx and read /stats. -# Requires: lab-cx built (make build), and ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN in -# the env (an Anthropic-compatible endpoint). Uses claude-haiku-4-5 as the agent model. +# Real Claude Code integration demo: route `claude` through context-guru and read /stats. +# Requires: proxy built (CGO_ENABLED=1 go build -o bin/context-guru-proxy ./cmd/context-guru-proxy), +# and ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN in the env (an Anthropic-compatible endpoint). # # Usage: ANTHROPIC_BASE_URL=... ANTHROPIC_AUTH_TOKEN=... scripts/cc-demo.sh set -euo pipefail PORT="${PORT:-8090}" -MODEL="${LCX_MODEL:-claude-haiku-4-5}" -BIN="${BIN:-./bin/lab-cx}" +MODEL="${CG_MODEL:-claude-haiku-4-5}" +BIN="${BIN:-./bin/context-guru-proxy}" : "${ANTHROPIC_BASE_URL:?set ANTHROPIC_BASE_URL to the upstream model endpoint}" : "${ANTHROPIC_AUTH_TOKEN:?set ANTHROPIC_AUTH_TOKEN}" demo=$(mktemp -d) printf 'package main\nimport "fmt"\nfunc main(){ fmt.Println(greet("world")) }\nfunc greet(n string) string { return "hello "+n }\n' > "$demo/main.go" -printf '# cc-demo\nTiny repo for the lab-cx Claude Code integration demo.\n' > "$demo/README.md" +printf '# cc-demo\nTiny repo for the context-guru Claude Code integration demo.\n' > "$demo/README.md" -"$BIN" proxy --addr "127.0.0.1:$PORT" --preset balanced \ - --upstream "$ANTHROPIC_BASE_URL" \ - --extract-model "$MODEL" --extract-provider anthropic --extract-auth bearer \ - --extract-base "$ANTHROPIC_BASE_URL" >/tmp/cc_proxy.log 2>&1 & +# agent preset: mask/dedup/failed_run/extract — the levers for long Claude Code sessions. +# Upstream + cheap model (for extract) both point at the same Anthropic-compatible endpoint. +LISTEN_ADDR="127.0.0.1:$PORT" \ +ANTHROPIC_UPSTREAM="$ANTHROPIC_BASE_URL" \ +CHEAP_MODEL="$MODEL" CHEAP_MODEL_PROVIDER=anthropic \ +CHEAP_MODEL_BASE="$ANTHROPIC_BASE_URL" CHEAP_MODEL_KEY="$ANTHROPIC_AUTH_TOKEN" CHEAP_MODEL_AUTH=bearer \ + "$BIN" --preset agent >/tmp/cc_proxy.log 2>&1 & proxy=$! trap 'kill $proxy 2>/dev/null' EXIT -sleep 2 +for _ in $(seq 1 30); do curl -sf "http://127.0.0.1:$PORT/healthz" >/dev/null 2>&1 && break; sleep 0.3; done settings=$(mktemp) cat > "$settings" <&2; exit 1; } + +LISTEN_ADDR=":$PORT" "$BIN" --preset "$preset" >/tmp/context-guru.log 2>&1 & +proxy=$! +trap 'kill $proxy 2>/dev/null' EXIT +for _ in $(seq 1 30); do curl -sf "localhost:$PORT/healthz" >/dev/null 2>&1 && break; sleep 0.3; done + +export ANTHROPIC_BASE_URL="http://localhost:$PORT/anthropic" +export OPENAI_BASE_URL="http://localhost:$PORT/openai/v1" +echo "context-guru --preset $preset on :$PORT (log: /tmp/context-guru.log)" >&2 +echo " ANTHROPIC_BASE_URL=$ANTHROPIC_BASE_URL" >&2 +echo " OPENAI_BASE_URL=$OPENAI_BASE_URL" >&2 + +if [[ $# -gt 0 ]]; then + "$@" # run the agent through the proxy + echo "== savings ==" >&2; curl -s "localhost:$PORT/stats" >&2 || true; echo >&2 +else + echo "no command given — proxy running; Ctrl-C to stop." >&2 + wait "$proxy" +fi