Skip to content

feat: jev-scored context compaction — shadow-first, opt-in, fail-open (reference-faithful port) - #139

Open
Emasoft wants to merge 26 commits into
mlhher:mainfrom
Emasoft:feat/jev-compaction
Open

Emasoft wants to merge 26 commits into
mlhher:mainfrom
Emasoft:feat/jev-compaction

Conversation

@Emasoft

@Emasoft Emasoft commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Update 2 — deferred follow-ups implemented

All four follow-ups deferred at review time are now implemented, in f8fd137 (feat(compaction,tui): implement deferred follow-ups):

  • History compaction honors the same GateConfig protections as the tool-output path. Per-kind score floors resolve through compaction.GateFloorFor instead of a flat threshold; the max-elide-fraction tripwire runs per message (a message whose elided fraction would exceed the gate's fraction keeps everything); tripped messages are recorded in the shadow log when one is threaded, and the run summary reports them as CompactionReport.Tripwires. A nil gate keeps the legacy flat-threshold walk.
  • Expand results are never re-compacted. protectedHistoryTool = ProtectedTool || expand, so a tool result that came back from an expand call is skipped by the history walk exactly like the tool-output path's protected kinds.
  • The auto-compact trigger watches the session it actually compacts. It fires on the ROOT agent's usage (event.ID == m.Root.ID()), not the focused agent's — the compaction pass always rewrites the root session's history, so a focused subagent's usage can neither fire nor suppress the trigger.
  • Fast/slow token counters share one walk. The first-paint estimator and the slow counter go through the same shared calculateHistoryTokens walk over the same message fields, so the streaming estimate is structurally identical to the count the compaction decisions use.

docs/quickstart.md gained one line stating it: history compaction (/jev-compact-context, the auto-trigger, and the 413 recovery pass) honors the same protections — the same keep threshold and protected-kind floors, with the max-elide-fraction tripwire applied per message (a tripped message keeps everything). Full go test ./... -race -count=1 and golangci-lint v2.13.2 are green on the branch head.


Update 2026-09-25

Seven new commits since the original description, at 2fdfbc1:

  • 413 payload-too-large handling + compaction-threshold knob + one-shot recovery (aa8e6cb) — a 413 is now a typed, never-retried error whose text says what to do; a one-shot compaction recovery fires on 413 when compaction is enabled (re-armed by /new); the elision score threshold is settable in config.json as compaction-threshold (flag > config, 0.35 default).
  • Audit fixes (70aafe2) — atomic cut-paragraph elision, prose-only assistant protection, activate_skill 1.0 floor, and a sharper admit question that distinguishes progress noise from non-re-derivable facts.
  • Durable late-errors.log + pathutil.LateDataDir unification (70aafe2, ace1ed3) — critical errors land in a durable late-errors.log (lazy-open hardened in ace1ed3), and all mutable data paths route through one pathutil.LateDataDir.
  • Expiry-aware toasts + pipeline warning sink (397a6a4, 8ac3c7c) — a new DiagnosticMsg renders mid-session warnings as proper warning toasts instead of raw stderr over the alt-screen, stale toast ticks no longer clear newer toasts, and compaction's one-time notices route through the TUI pipeline warning sink.
  • Protected sibling pins cut-group keep (ace1ed3) — the keep decision is deterministic and reproducible by -replay-shadow.
  • Per-model jev-autocompact-percent override (2fdfbc1) — with models[] key validation.
  • Scoped config-reference (131f4b0) — docs/config-reference.md scoped to this branch's schema.

Reference-faithful port of jev-scored context compaction, split out of #133 so the TUI work and compaction can be evaluated separately (per review). This body addresses the review comments head-on.

Separation from the TUI work

The only TUI surface in this PR is one slash command — /jev-compact-context — plus its status text. Every other TUI change from #133 (status bar agent type, todos pane, info bar + timestamps, config error surfacing) lives in separate PRs and is independently evaluable.

No mandatory, no automatic compaction

  • compaction-mode defaults to shadow: tool outputs are segmented and scored, decisions are appended to the shadow log — and nothing else. Zero behavior change; no tool result is ever rewritten or dropped in shadow mode.
  • jev-autocompact defaults to false. Automatic compaction only ever runs if the user turns it on (and tunes jev-autocompact-percent, default 99).
  • enabled mode is strictly opt-in and still fail-open: a scorer outage (or any error) keeps the original output, nothing is ever deleted — elided text stays retrievable byte-for-byte through the expand tool. User messages are never compacted.

Investigation before hype — the tests the review asked for

Preflight, live against the real decisions endpoint. late -check-compaction runs three real stages — questions (a minimal score batch parses, every id returns a numeric score), gate (a real ~2KB tool output actually relocates into pointers + store records), expand (the pointer reconstructs byte for byte) — and exits 0/1. Live run against https://openrouter.ai/api/alpha/decisions, model ~typesafe/jev-latest:

$ late -check-compaction
late compaction preflight
  [ok  ] backend     0.00ms  backend "openrouter" url https://openrouter.ai/api/alpha/decisions model "~typesafe/jev-latest" (api key: env)
  [ok  ] questions    394ms  3/3 questions answered with numeric scores (noul protocol)
  [ok  ] gate         247ms  gate relocated 1 run(s) — 4 of 4 segments, 700 tokens — into 1 store record(s)
  [ok  ] expand      0.01ms  1 pointer(s) expanded back byte for byte (2106 bytes)
result: PASS (4/4 stages ok)
cost: n/a (the decisions client does not track token usage)

Read-only verified: the preflight left config.json and the state dirs byte/mtime-identical.

Replay with false-negative rate. late -replay-shadow 0.10,0.35,0.50 replays the recorded shadow log (5,000+ real scored decisions) at any thresholds — kept/relocated/tokens-saved/still-missed plus the false-negative rate (a segment elided at its own recorded threshold was later expanded). Read-only:

threshold  kept  relocated  tokens saved  still missed
0.10       5991  4          12            0
0.35       5885  110        15529         0
0.50       5821  174        23391         0

false-negative rate: 0.0%

Offline scripted scorer for evaluation. config.json "compaction-backend": "offline" swaps the scorer for a deterministic local scripted scorer — the whole flow and the preflight run with no API key and no network (the model URL is a dead port to prove it):

$ late -check-compaction
late compaction preflight
  [ok  ] backend     0.00ms  backend "offline" url none model "scripted-scorer" (api key: none — deterministic offline scorer; no network)
  [ok  ] questions   0.03ms  3/3 questions answered with numeric scores (noul protocol)
  [ok  ] gate          44ms  gate relocated 1 run(s) — 4 of 4 segments, 700 tokens — into 1 store record(s)
  [ok  ] expand      0.01ms  1 pointer(s) expanded back byte for byte (2106 bytes)
result: PASS (4/4 stages ok)
cost: n/a (the decisions client does not track token usage)

Typed error taxonomy. Scorer failures surface as auth / validation / budget / unavailable — never retried blindly, no retry storms, and always fail-open.

Prompt-cache safety. History compaction works on an append-only frozen prefix with a persisted high-water mark: the anchor is never mutated across runs, and a walk that would violate the prefix fails loudly (ErrFrozenPrefix) and changes nothing.

Durable pointers. A persistent file-backed record store means [[elided …]] pointers survive restarts and are shared with subagents.

Gate tripwires. max-elide-fraction (never elide more than that fraction of a run in one pass) and protected segment kinds — stacktraces and diffs carry a score floor, so a scorer's bad day can't erase the two things you usually need verbatim.

Wire contract. This port follows github.com/Waxmell114514/jev-compaction exactly — including the fix that made live scoring work: an earlier draft sent a score-batch payload the real endpoint rejected (400, missing questions.<id>.instructions); the preflight caught it before any integration use, and the request shape now matches the reference wire contract precisely.

The scorer is swappable by design — backend registry today: typesafe / openrouter / gateway (--api / JEV_API), plus the offline scripted scorer for evaluation.

What's in the box

Segmentation · scoring · shadow log · [[elided …]] pointers + expand tool · /jev-compact-context · jev-autocompact (opt-in) · retrieve() read-side injection (config-gated, off by default) · docs.

Testing

$ go test ./... -race -count=1
ok  	late/cmd/late	2.331s
?  	late/cmd/mcp-run	[no test files]
ok  	late/internal/agent	1.452s
?  	late/internal/assets	[no test files]
ok  	late/internal/client	2.213s
ok  	late/internal/common	6.203s
ok  	late/internal/compaction	5.914s
ok  	late/internal/config	3.224s
ok  	late/internal/executor	13.011s
ok  	late/internal/git	4.802s
ok  	late/internal/mcp	4.430s
ok  	late/internal/orchestrator	4.586s
?  	late/internal/pathutil	[no test files]
ok  	late/internal/plugin	14.096s
ok  	late/internal/session	4.158s
ok  	late/internal/skill	2.641s
ok  	late/internal/tool	5.453s
ok  	late/internal/tool/ast	4.058s
ok  	late/internal/tui	10.594s

exit 0 — all 19 packages green (16 ok, 3 without test files).

  • gofmt -l on the touched files: no output (clean)
  • go vet ./cmd/late ./internal/compaction ./internal/config ./internal/executor ./internal/orchestrator ./internal/session ./internal/tool ./internal/tui: clean
  • Preflight and replay diagnostics quoted above are live runs of this exact branch (build from bc44df9); the offline preflight ran in a scratch home with a dead model URL.

…d tool

Staged rollout of the jev-compaction port (design source:
Waxmell114514/jev-compaction, MIT). New internal/compaction package:
paragraph segmentation, System One scoring backends (resolve via env,
key-file lookup, per-backend URLs), a pipeline that scores tool outputs
and appends decisions to a shadow log
(~/.local/share/late/compaction-shadow.jsonl), and relocation that
elides low-scoring segments into [[elided ...]] pointers backed by an
in-memory store.

Executor integration: SetToolResultCompactor installs the pipeline
process-wide (root agent + subagents); results over
MinCompactToolResultChars (4000) are offered to the stage. Shadow mode
(default) scores and logs without changing any result; enabled mode
additionally relocates and registers the expand tool so originals stay
retrievable. Everything fails open: backend errors, canceled contexts,
and shadow-log failures keep the original tool output. Scoring calls go
through a package-local scoring slot (internal/compaction/llm_slot.go).

Modes resolve as --compaction-mode > config compaction-mode > shadow;
invalid values warn and fall back to shadow. --compaction-threshold
(default 0.35) sets the elision score cutoff in enabled mode.

Docs: quickstart section for context compaction; README feature bullet.
…ev scoring, elision)

Session.CompactContext walks the whole history (not just tool outputs) and
relocates low-scoring segments into the elide store:

- Frozen prefix: the first max(1, len/4) messages (25%) stay byte-identical,
  so the prompt-cache anchor - the system prompt at index 0 plus the earliest
  exchanges - never moves across compactions.
- User messages are immune; assistant and tool-result contents are compacted
  in place with roles, ToolCalls, and timestamps preserved (ToolCalls are
  structurally required and never touched: only Content shrinks).
- Segments are scored against the ongoing task (the last user message);
  anything scoring strictly below the threshold is replaced by one
  [[elided ...]] pointer line whose original lives in the store for the
  expand tool. A nil store disarms relocation: a pointer whose original
  cannot be stored must never enter history.
- Shadow mode (ShadowOnly) runs the full scoring walk and computes the
  honest would-save report without mutating history or storing originals.
- Fail-open mid-walk: a scorer error stops the walk, already-rewritten
  messages stay valid (their pointers and stored originals resolve), and
  the error reports how far the walk got. Persistence is the callers job
  (SaveHistory), so compaction never breaks a session.
- Token math: CompactionReport carries EstimateMessageTokens before/after,
  tokens saved, and the byte size of what was relocated into the store.
Manual command: /jev-compact-context runs one full-history compaction pass
on the session behind the TUI. It requires compaction-mode other than off
(nil Compactor reports "compaction unavailable"); under shadow mode it runs
report-only. The run executes off the TUI update loop (network scoring can
take seconds) and reports via compactionResultMsg.

Auto-trigger: with jev-autocompact on (config.ResolveAutocompact; default
false) and the focused agents context usage crossing jev-autocompact-percent
(default 99, valid 1-100) of the context window, the same compaction runs
automatically. It fires once per crossing - the agent stays disarmed until
usage falls below (percent-9)% (typically right after a compaction shrank
the history) or /new starts a fresh conversation.

Shared CompactionRunning guard: exactly one CompactContext run (manual or
auto) may be in flight at a time; both paths set it on dispatch and the
result message clears it.

Store id-space unification: the elide-id counter moves into compaction.Store
(NextID/Put exported), so tool-output and history compaction mint pointers
from one id space and "elide-<n>" always names exactly one original.

Pipeline-to-TUI plumbing: Pipeline.HistoryScorer exposes the decision client
as the session.HistoryScorer; cmd/late wires historyCompactionRunner
(session.CompactContext with the pipelines threshold, persisting mutated
history via SaveHistory - skipped for shadow runs, and with a fresh private
store there since shadow mints pointer ids without an expand tool to read
them). NewModel resolves the autocompact settings; late -h documents the
feature under "Context compaction".
quickstart.md: extend the Context Compaction section with /jev-compact-context
(manual full-history compaction - frozen prefix, Jev scoring of every
post-prefix message, elision into pointers the expand tool resolves;
requires compaction-mode other than off, report-only under shadow) and the
jev-autocompact / jev-autocompact-percent config keys (auto-trigger at the
configured context-usage percent, once per crossing, re-arms after
compaction). README.md: one feature bullet next to the tool-output
compaction entry.
ScoreBatch fail-opens with a complete score map; CompactContext now
continues the walk with those scores and surfaces the scorer errors at
the end. Only incomplete score maps abort (stopped after N messages).
Adds CompactionReport.MessagesScored.
…ow log

Compaction statuses now report scored/scanned message counts; every
history-compaction run appends one type=history-run summary line
(scanned/scored/elided/tokens/error) to the shadow log; replay skips
summary lines.
Ports the reference's safety nets: per-kind elide floors (stacktrace/diff
protected at 0.05), max-elide-fraction tripwire (distrust the scorer, keep
everything), min-gate token floor (no backend call for small outputs),
segment kinds (text/json/log/stacktrace/table/code/diff), and config knobs
compaction-max-elide-percent / compaction-protected-floor.
…e reconstruct

Pointers are now [[elided id=r:<8hex> lines=a-b tokens=N "summary"]] with
sha256 content-addressed ids, line ranges, 120-char escaped summaries,
and a Reconstruct that substitutes pointer+newline with the stored
originals byte for byte. Consecutive elided segments merge into one
record per run; legacy elide-N ids still parse and expand.
Records (text, kind, origin, tokens, summary, segment_ids, expand/hit
counts) persist to ~/.local/share/late/compaction-store.jsonl as an
append-only JSONL log, loaded on startup — [[elided]] pointers no longer
dangle after a restart. Enabled mode shares one store across the
pipeline, the expand tool, and history compaction.
… -replay-shadow

Every expand records an outcome for the record and each contributing
segment (the reference's false-negative attribution); decision entries
record the threshold consulted; ShadowLog grows Stats/FalseNegativeRate/
ReplayTable (kept, relocated, tokens saved, still missed per threshold);
new read-only CLI: late -replay-shadow=0.10,0.35,0.50.
A persisted, monotonic compaction high-water mark makes the frozen
prefix append-only: never re-scores pointer-bearing messages, fails
loudly with ErrFrozenPrefix on any below-mark mutation, advances only on
a completing mutating walk (shadow never advances), and rewinds/rolls
with /new, Rewind and PopLastUserMessage.
Auth (401/403, poisons the client — zero further requests), Validation
(4xx, never retried), Budget (pre-send oversized item), Unavailable
(retried, then fail-open). Pipeline warns once and disables scoring on
auth; history compaction stops immediately on auth; oversized items keep
per-item keepScore with a typed cause.
late -check-compaction runs the reference's three-stage verification
against the real backend (questions parse, gate relocates, pointer
expands byte-for-byte) and names the broken stage; startup probes the
backend once after the TUI is up and disables scoring on typed auth.
Scores the store's digest against the current task (reference
RETRIEVE_QUESTION) and appends the top-k relevant records as an
ephemeral, unpersisted system block at the end of the work area.
Default off; compaction-retrieval enables it; retrieve decisions are
kind-tagged and excluded from the admit false-negative rate.
compaction-backend: "offline" runs the full gate→pointer→expand flow
with a deterministic scripted scorer (no key, no network, reference
testing.py parity); Pipeline scores through a Scorer interface; docs
cover the whole shipped surface and credit the reference repo.
Shared BuildElidedRun for both relocation surfaces (was duplicated);
stale package/tool docs corrected; deliberate -replay-shadow-before-
config position documented; store no-fsync debt documented.
The Go client invented {type,prompt} questions and map-keyed state
items; the real endpoint rejected every scoring request with 400
invalid_union at questions.<id>.instructions. Ported the reference
shape verbatim: state.items is an array of {ref,text}; each question is
{noul, 'Considering item <ref> only: ...', criteria{true,false}} with
the task only in state.task; answers parse as {type:'noul',noul:x}
objects (bare numbers tolerated for local gateways).

Live proof: late -check-compaction → PASS 4/4 stages against the real
OpenRouter decisions endpoint.
@mlhher

mlhher commented Sep 24, 2026

Copy link
Copy Markdown
Owner

Thank you for doing it this way! I will take a careful look at this and test it a bit.

@Emasoft

Emasoft commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for doing it this way! I will take a careful look at this and test it a bit.

wait to merge, there are some bugs i'm fixing right now.. i will update this and other PRs with the fixes..

…tics

Compaction's mid-session warnings (the pipeline's one-time auth-poison
note and the retrieval-skip notice) must not paint raw
fmt.Fprintf(os.Stderr, ...) text over the bubbletea alt-screen, where it
garbles the footer and displaces the agent-name row. This adds the TUI
surface they route through: a DiagnosticMsg that renders as a WARNING
toast with a 6s expiry and the standard clear tick, truncating long text
to the terminal width with the bar's ellipsis helper. Empty text is
ignored.

Adapted from the broader mid-session diagnostics commit: the plugin-
manager, orchestrator, and tool-package sinks and their wiring stay with
the TUI diagnostics PR; only the toast surface compaction needs lands
here. stderr remains the fallback for headless/CLI flows.
…d config

413 is now a typed, never-retried error whose text tells the user exactly
what to do; a one-shot compaction recovery fires on 413 when compaction
is enabled (re-armed by /new). The elision score threshold is now settable
in config.json (compaction-threshold, flag > config > 0.35 default), and
the post-compaction status says the context bar is an estimate until the
next request's usage arrives.

Port note (feat/jev-compaction): main.go detects the explicitly passed
-compaction-threshold flag with a local flag.Visit check — the
explicitFlags map from the config-CLI-parity work belongs to the config
PR and is not part of this branch.
…harper prompt, error log

Real-output audit of the first production compaction found: oversized
paragraphs were cut into pieces and elided piecemeal (corruption risk),
8 pure-prose assistant messages were compacted (one losing the
assistant's closing question), and activate_skill results were elidable.
Cut pieces now share one atomic elide decision (min sibling score),
prose-only assistant messages are never candidates, activate_skill tool
results get a 1.0 protection floor, and the admit question distinguishes
progress noise from concrete non-re-derivable facts. Critical errors
(walk aborts, save failures, auth poisoning) now append to a durable
late-errors.log; all data paths route through one pathutil.LateDataDir.

Port note (feat/jev-compaction): main.go's diag closure (with its
LogError routing) lands with the warning-sink wiring commit, where it is
first used — the plugin/orchestrator/tool sink wiring it served on the
integration branch stays with the TUI diagnostics PR.
docs/config-reference.md documents every config.json key this build
reads (provider/subagent settings, supervision, tools, and the whole
context-compaction block: mode, the compaction-threshold score cutoff
with its flag > config > 0.35 precedence, the info-bar percentage, gate
knobs, offline backend, autocompact, retrieval), the file location, how
values are parsed and validated (fatal type errors, the located
did-you-mean error for unknown keys inside models[] entries, warn-
and-fall-back for value-range problems), starter and fuller examples,
and the nested schemas. A reflection test keeps the tables in lockstep
with the Config struct in both directions.

Port note (feat/jev-compaction): scoped to the keys this branch's Config
struct actually has — the CLI-parity keys, boolean-synonym table, strict
top-level parsing sections, and flag-precedence rules from the config PR
are left for that PR's version of the doc, which extends this one.
… routing

Stale toast ticks no longer clear newer toasts (expiry-aware clear: a
tick scheduled by a PREVIOUS toast lands while a newer one is alive and
must not kill it early; the toast-set path schedules its own real clear
tick instead of clearing on the next loop iteration). Rewind re-arms the
one-shot 413 payload-recovery compaction exactly like /new does. The
compaction pipeline's one-time auth-poison warning and the retrieval-
skip warning route through the TUI diagnostic sink (Pipeline
.SetWarningSink + main's diag closure, which also appends every
diagnostic to the durable late-errors.log) instead of raw stderr;
headless flows keep the stderr fallback. The end-to-end compaction test
now annotates the assistant message with a tool call (pure-prose
assistant messages are never compacted).

Port note (feat/jev-compaction): taken from the TUI review-fixes and
strict-parser-hardening commits — only their compaction-relevant parts.
The KeyReleaseMsg rework, todo-pane rune release, ANSI-aware truncation,
and the orchestrator/plugin/tool diagnostic-sink propagation stay with
their own PRs.
…ening

The group-min reduce could elide a protected piece inside a cut
paragraph (min-floor inversion): a sibling at the unelidable ceiling now
pins the whole group to keep, with the shadow entry recording the same
decision so -replay-shadow reproduces it. Error log: lazy-open no longer
clobbers an explicit install; oversized messages truncate rune-safely
with a marker; rotation absence documented. Group id scoping documented.
jev-autocompact-percent is now also valid inside each models[] entry;
an agent's trigger resolves as its model entry's value (1-100) > the
global > 99 — different context sizes need different triggers.
Out-of-range per-model values warn at startup and fall back to the
global. Strictness extends to models[] entries too: unknown keys inside
an entry are located errors with did-you-mean suggestions (the typed
decode would silently drop the typo), enforced by a models[] key walk
so a mistyped per-model override key cannot silently no-op. The TUI's
autocompact trigger resolves the watched (focused) agent's model entry
through agent_models before falling back to the global.

Port note (feat/jev-compaction): the models[] entry key walk is inlined
here with minimal private helpers (known-key set, positioned rendering,
Levenshtein did-you-mean) — the full positioned strict parser for every
top-level key belongs to the config PR, as do the Degraded
defense-in-depth tests this commit's test file touched on the
integration branch.
…e.Until

golangci-lint v2.13.2 findings on feat/jev-compaction:

- relocate.go:439 (ineffassign): elidedTokens = 0 after the tripwire
  resets elide[] is never read again; drop the assignment.
- relocate.go:489 (staticcheck SA4010): kept is appended but never
  read — kept text is written to the builder directly; drop the slice
  and the append.
- client.go:754 (staticcheck S1024): date.Sub(time.Now()) ->
  time.Until(date).

Reproduced locally with golangci-lint v2.13.2; full
go test ./... -race -count=1 green.
History compaction now honors the same GateConfig protections as the
tool-output path (per-kind floors, per-message max-elide-fraction
tripwire with shadow entries), expand results are never re-compacted,
the auto-compact trigger watches the session it actually compacts (root)
instead of the focused agent, and the fast/slow token counters share one
walk so first-paint estimates are structurally identical.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants