flowctx
July 12, 2026 · View on GitHub
A context engine for OpenClaw: makes the live context window ever cheaper to carry, while staying losslessly reversible.
中文文档:见
README.zh.md
flowctx is an OpenClaw ContextEngine plugin. Its premise is simple: memory
should fade with distance, not snap off. Context isn't a "fill-then-truncate" buffer —
it's memory with depth: sharper up close, more distilled far away, yet never truly
forgotten. The common move on overflow is to hard-truncate earlier turns, throwing
away their working knowledge and escape routes; flowctx makes a different trade-off:
fidelity by distance to the current task — the farther away, the harder it's
compressed; the closer, the more it's kept intact; whatever you're working on right now
stays untouched.
Three tiers, three compression strengths:
| Tier | Treatment | Notes |
|---|---|---|
| Older history | Summary compression (LLM · background · threshold-gated) | Folded into a layered handoff note that preserves failed approaches and key identifiers verbatim |
| Recent past turns | Structured compression (zero LLM · deterministic) | Content-type-aware, reversible byte-exact by hash |
| Current turn | Zero semantic loss | Raw content into the model as-is: no compression, no summary |
This layer is bounded, reversible, and token-budgeted, and happens only in the
read-time assemble() projection — the host session on disk is always the
uncompressed source of truth, and every lossy view round-trips byte-exact via
flowctx_retrieve. It also holds a byte-stable KV-cache prefix, so prefix cache,
reasoning performance, and task completion all score at once rather than being traded
against one another.
The full progressive flow (raw → structured compression → summary compression → leaf folding → stable steady state) is in the flow demo below.
Flow demo: docs/flow-demo-en.html
(中文: docs/flow-demo-zh.html) — a scroll-driven walkthrough
of the engine: structured compression → byte-stable KV-cache prefix → fresh tail →
backgrounded handoff-note summary → growing Summary DAG → SQLite persistence with
byte-exact restore.
Install
# from this directory
npm install # one-time: fetch dev deps (esbuild, typescript, vitest)
./install.sh # build, link, enable, AND activate flowctx as the context engine
./install.sh --no-engine # ...same but leave the contextEngine slot untouched
install.sh builds dist/index.js (esbuild), then either links via
openclaw plugins install <dir> --link or patches ~/.openclaw/openclaw.json
directly. By default it sets plugins.slots.contextEngine = "flowctx" so the
engine is actually active — registering a context engine does NOT activate it; the
slot must point at it. Flags: --no-engine (don't touch the slot), --engine <id>
(pick another, e.g. projecting/legacy), --no-build, --config-only,
--uninstall. Restart the gateway afterwards (openclaw gateway restart).
Architecture
index.ts
└─ src/plugin/index.ts register(): registerContextEngine + tools + hooks
├─ src/openclaw-bridge.ts local ContextEngine contract shim (SDK types lag)
├─ src/engine.ts FlowCtxContextEngine implements ContextEngine
└─ src/plugin/shared-init.ts process-global singleton guard
Activate by selecting the slot in openclaw.json:
{ "plugins": { "slots": { "contextEngine": "flowctx" } } }
How the six constraints land
| Constraint | How |
|---|---|
| C1 non-blocking | assemble() is a light, pure-ish call; heavy work (continuity flush, GC, background summary) runs in maintain(). The engine declares turnMaintenanceMode: "background", so the host schedules that work on a background lane and never stalls the interactive turn. |
| C2 append-only | Originals live in theCompressionStore, never overwritten in place; the engine never mutates the host's session transcript. The store and the scratchpad persist durably to SQLite (stateDir/memory/flowctx.sqlite) via a vendored connection + transaction-mutex stack (src/vendor/, src/db/kv-store.ts) — upsert-by-key, TTL-aware, survives process restart. Continuity stays an append-only flowctx-sessions.jsonl. |
| C3 reversible / auditable | Every compressed block stores its original by SHA-256[:24] hash; the model canretrieve it byte-exact by hash via the flowctx_retrieve tool (the store is published to a process-global registry). |
| C4 LLM summary — backgrounded & gated | Two layers. (1)assemble() does deterministic structured compression every turn (no LLM), keeping token fill low so a summary is needed less often. (2) A summary compact is performed only when needed, and it runs on the deferred (background) lane of maintain() — non-blocking, fire-and-forget (a speculative pre-compaction pattern), on a configurable trigger (summaryTriggerRatio, default 0.2 of the window), via the host LLM (runtimeContext.llm.complete, capability runtime-llm-complete), with an "engineer handoff note" prompt that preserves identifiers / file paths / failed-vs-working approaches verbatim (counters post-compaction knowledge loss). The trigger is judged against the assembled token estimate (post structural-compression + post history-fold, i.e. what actually reaches the LLM), not the raw transcript — so once a pass has compressed the window below the ratio it stops firing (natural convergence), and reopens as raw history grows back. Once the gate opens, one maintain() serially drains every foldable chunk in that pass (leaf after leaf, strictly sequential — never parallel — then one condense) instead of one chunk per turn. The note is incremental and re-injected as <flowctx-handoff-note> on later turns; a generation guard cancels/supersedes stale in-flight jobs on a new turn, and only the current generation's result is committed. compact() itself stays a fast pass-through (ownsCompaction:false), with the host Pi summarizer as the synchronous last resort. Toggle via backgroundSummary. |
| C5 KV-cache stable prefix | assemble() keeps the stable prefix byte-identical (prefix messages passed through by reference); oversized tool-results are rewritten everywhere except the last freshTailWindow messages (which stay verbatim). Rewrites are idempotent (content hash → same bytes across turns), so the prefix stays byte-stable and provider prefix caching keeps hitting. No prompt-aware reordering. |
| C6 structured compression | projectBlock() (src/structured.ts) routes oversized tool-results by detected content type through deterministic reducers (no LLM): structural JSON/CSV/XML/YAML/code summaries (vendor/structural-explorers.ts); JSON lexical minify, hashed middle-clip, git-diff hunk compaction (vendor/json-compress.ts); a CLI rule engine — outputMatches short-circuit, skip/keep patterns, head/tail windowing with success/failure windows (vendor/cli-rules.ts); plus a log adjacent-duplicate collapse. Routes: json → minify/summary, diff → hunk compaction, cli-output$ → \text{rule} \text{engine} (5–20 \times + \text{on} \text{logs}), $code → structural extraction, log → collapse. Two guards (line-identity + strict byte-shrink) reject no-op compressions; everything else falls back to head60%+tail30%. The original is always stored by hash first, so every lossy view round-trips byte-exact via flowctx_retrieve. (Vendored reducers are MIT — see Acknowledgements.) |
Config (all keys optional, safe defaults)
All keys live under plugins.entries.flowctx.config in ~/.openclaw/openclaw.json
(schema in openclaw.plugin.json). Every key is optional; out-of-range numbers are
clamped to the range shown (not rejected), unknown keys are ignored. The keys below
are the ones you are most likely to tune; the remaining keys (recallTokenBudget,
projection, scratchpad, savingsTracking, inputCostPerMTok, …) keep their
safe defaults.
| Key | Default | Range | What it does |
|---|---|---|---|
shortTermMemory | true | bool | Master switch.false disables the whole engine (and tears down any prior wiring). |
debug | false | bool | Emit the[flowctx:engine] debug-level lines. Headline [flowctx:trigger] events are always at info regardless. |
dumpSummaryRequests | false | bool | Diagnostic. Dump each background-summary LLM request+response (and the assembled view) to stateDir/memory/*.jsonl for local inspection. Leave off in production. |
contextWindowTokens | 1000000 | 8000–2000000 | Fallback only. At runtime the engineprefers the host-resolved active-model context window (runtimeContext.tokenBudget); this default applies only when the host provides none. |
projectionThreshold | 1000 | 200–100000 | A tool-result larger than this many (estimated) tokens gets structure-compressed inassemble(). Smaller = more aggressive compression. |
freshTailWindow | 64 | 0–64 | The most-recentN messages (array elements — user/assistant/tool-result each count; not user turns) are kept verbatim, exempt from structured compression. Purpose: keep just-read originals directly usable so the model does not have to call flowctx_retrieve to get them back. (Formerly kvCacheTailWindow — a misnomer; the KV prefix cache is actually kept stable by the idempotence of compression, not by this tail window. The old key is still accepted.) |
projectionKeepRecentTurns | 1 | 0–100 | Turn-based projection protection: keep the last N user turns — the current task — exempt from structured compression, since the current task's raw tool-results drive this turn's success rate. freshTailWindow is a cap on this (not a peer): protected messages = min(current-task message count, freshTailWindow), so a short task is fully protected but a long task is capped at freshTailWindow recent messages (its own older messages are compressed, still reversible via flowctx_retrieve) so it can't exhaust the window. Boundary: frozenStart = max(len − freshTailWindow, start-of-kept-turns). Priority ladder: current task never compressed (up to the cap) → older turns eligible for compression → older still eligible for summary. Clamped to ≤ summaryKeepRecentTurns. 0 = turn protection off (cap only). |
summaryTriggerRatio | 0.2 | 0.05–0.95 | Thegate for entering the compressible state: a background summary fires when assembled tokens (post-compression, what actually reaches the LLM) ÷ context window ≥ this ratio. Judged on the assembled view, not the raw transcript. Lower = summarize sooner. Distinct from leafChunkTokens, which sizes one chunk (measured on raw messages) and does not decide whether to fire. |
summaryMinTurns | 6 | 1–1000 | Don't summarize sessions shorter than this many user turns. |
summaryKeepRecentTurns | 2 | 0–100 | User turns kept verbatimbefore the folded/summarized prefix (the fresh tail is always kept on top of this). |
summaryReplacesHistory | true | bool | When a summary exists,replace the older messages it covers with one retrievable placeholder in the assembled view (this is what actually shrinks context), vs. merely appending the note. |
incrementalSummary | true | bool | Feed the summarizer only the messages added since the last summary (with the prior summary as continuity) instead of re-reading the whole transcript. |
layeredSummary | true | bool | Freeze earlier history into independent never-rewritten leaf nodes (depth 0); oncecondenseFanout leaves accumulate they condense into a depth-1 overview. false = single rolling note. |
leafChunkTokens | 40000 | 2000–64000 | Raw-message tokens one depth-0 leaf node covers. |
condenseFanout | 6 | 2–32 | How many same-depth nodes accumulate before they condense into the next depth. |
maxSummaryDepth | 1 | 0–3 | 0 = leaves only; 1 = leaf + one condensed layer. |
Other notable defaults (rarely tuned): projectionTtlSeconds=604800 (7 days — the
retrievable original outlives the marker for the whole session), summaryMaxTokens=0
(uncapped — a small cap truncates reasoning models to an empty note),
compactionTriggerRatio=0.9, scratchpadMaxChars=8000, captureFieldMaxChars=16000,
sessionContinuity=true, stripInjectionOnWrite=true, savingsTracking=true,
inputCostPerMTok=3.0.
The config in the block above (
contextWindowTokens=30000,summaryTriggerRatio=0.3,summaryMinTurns=2,projectionThreshold=800,leafChunkTokens=4000,condenseFanout=3,dumpSummaryRequests=true, …) is an aggressive debugging profile: it forces compression and summarization to fire early on a small window so you can watch the engine work. For production, dropdumpSummaryRequests/debugand setcontextWindowTokensto your model's real window.
Design trade-offs
- Token budgeting is pure local estimation, no dependency on the SDK
usagefield. The estimator is Unicode-code-point-aware (CJK ≈ 1.5, emoji ≈ 2, ASCII ≈ 0.25 tok/char); the naivelength/4under-counts CJK ~6× and fires compaction far too late on non-English sessions. - The background summary runs on the agent's primary model — the engine never hard-codes a model and disallows overrides (
allowModelOverride=false). - Persistence is SQLite (vendored connection + transaction-mutex stack); refs / scratchpad / summary all survive restart.
- The host transcript is never rewritten: the summary is an additive
<flowctx-handoff-note>,compact()is a pass-through (ownsCompaction:false), and the host Pi summarizer remains the synchronous last resort.
Evaluation: SWE-bench Verified
A deterministic 40-task slice of SWE-bench Verified (across 12 repos, 3 difficulty
tiers), run with bench-openai-xiaomi-mimo-v2-5-pro and judged by a strong model (claude-opus-4-8)
comparing the candidate patch against the golden patch. The key comparison is flowctx
on/off in a shared session (context accumulates across tasks):
| Config | Resolved (/40) | Avg score | KV hit rate | Total tokens/task |
|---|---|---|---|---|
| flowctxoff · shared | 68% (27/40) | 71.1 | 96.1% | 288k |
| flowctxon · shared (mean) | 68% (27/40) | 71.8 | 93.9% | 127k |
- Same solve quality — resolved 68% ↔ 68%, avg score 71.1 ↔ 71.8: compression does not cost problem-solving ability.
- Large token drop — same accumulated-context basis, 288k → 127k per task (−56%), ~162k saved per task.
- KV cache still efficient — 96.1% ↔ 93.9%; rewriting the summary node on a fold costs only ~2pct. If you weight prefix cache more heavily, lower
freshTailWindow(shrink the verbatim tail) and raisesummaryTriggerRatio(fold later, so summary nodes are rewritten less often) to push the hit rate higher. - The uncompressed shared session is the cautionary case: prompt tokens climb monotonically (49k→374k); flowctx's leaf/condense folding pins the assembled size back inside the 100k gate.
Full method, the four raw run artifacts, and an interactive chart are in
data/bench/README.MD and
data/bench/flowctx_bench.html.
Dev, logs & debug
npm install # dev deps (esbuild, typescript, vitest)
npm run typecheck # tsc --noEmit
npm test # vitest (live-LLM test auto-skips without ANTHROPIC creds)
npm run build # esbuild bundle → dist/index.js
All runtime output is prefixed for easy grep in a busy gateway log:
[flowctx:trigger]— headline "an action actually fired" events, always atinfo(visible without debug):structured compression fired,background summary compaction: triggered/: done.[flowctx:engine]— fine-grained engine detail (per-tool-result projection, every summary-skip reason, compact pass-through, store readiness). Mostlydebug.[flowctx]— plugin registration (context engine registered,disabled, …).
Enable the debug-level lines by setting debug: true in the plugin config — they
are off by default to keep normal runs quiet:
{ "plugins": { "entries": { "flowctx": { "config": { "debug": true } } } } }
Then restart the gateway. Quick greps: grep "\[flowctx:trigger\]" (what fired),
grep "background summary compaction" (summary timing), grep "\[flowctx" (all).
License
Source code released under the MIT License.
Acknowledgements
flowctx builds on ideas from several prior context-management projects and vendors
code from the following open-source projects (under src/vendor/, each with per-file attribution).
- lossless-claw (MIT) — a SQLite connection stack and deterministic structural content explorers.
- tokenjuice (MIT) — deterministic JSON / diff / search output reducers.
- opensquilla (Apache-2.0) — a CLI tool-result rule-engine algorithm.