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:

TierTreatmentNotes
Older historySummary compression (LLM · background · threshold-gated)Folded into a layered handoff note that preserves failed approaches and key identifiers verbatim
Recent past turnsStructured compression (zero LLM · deterministic)Content-type-aware, reversible byte-exact by hash
Current turnZero semantic lossRaw 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

ConstraintHow
C1 non-blockingassemble() 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-onlyOriginals 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 / auditableEvery 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 & gatedTwo 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 prefixassemble() 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 compressionprojectBlock() (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.

KeyDefaultRangeWhat it does
shortTermMemorytrueboolMaster switch.false disables the whole engine (and tears down any prior wiring).
debugfalseboolEmit the[flowctx:engine] debug-level lines. Headline [flowctx:trigger] events are always at info regardless.
dumpSummaryRequestsfalseboolDiagnostic. Dump each background-summary LLM request+response (and the assembled view) to stateDir/memory/*.jsonl for local inspection. Leave off in production.
contextWindowTokens100000080002000000Fallback only. At runtime the engineprefers the host-resolved active-model context window (runtimeContext.tokenBudget); this default applies only when the host provides none.
projectionThreshold1000200100000A tool-result larger than this many (estimated) tokens gets structure-compressed inassemble(). Smaller = more aggressive compression.
freshTailWindow64064The 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.)
projectionKeepRecentTurns10100Turn-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).
summaryTriggerRatio0.20.050.95Thegate 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.
summaryMinTurns611000Don't summarize sessions shorter than this many user turns.
summaryKeepRecentTurns20100User turns kept verbatimbefore the folded/summarized prefix (the fresh tail is always kept on top of this).
summaryReplacesHistorytrueboolWhen 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.
incrementalSummarytrueboolFeed the summarizer only the messages added since the last summary (with the prior summary as continuity) instead of re-reading the whole transcript.
layeredSummarytrueboolFreeze earlier history into independent never-rewritten leaf nodes (depth 0); oncecondenseFanout leaves accumulate they condense into a depth-1 overview. false = single rolling note.
leafChunkTokens40000200064000Raw-message tokens one depth-0 leaf node covers.
condenseFanout6232How many same-depth nodes accumulate before they condense into the next depth.
maxSummaryDepth1030 = 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, drop dumpSummaryRequests/debug and set contextWindowTokens to your model's real window.

Design trade-offs

  • Token budgeting is pure local estimation, no dependency on the SDK usage field. The estimator is Unicode-code-point-aware (CJK ≈ 1.5, emoji ≈ 2, ASCII ≈ 0.25 tok/char); the naive length/4 under-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):

ConfigResolved (/40)Avg scoreKV hit rateTotal tokens/task
flowctxoff · shared68% (27/40)71.196.1%288k
flowctxon · shared (mean)68% (27/40)71.893.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 raise summaryTriggerRatio (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 at info (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). Mostly debug.
  • [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.