Repository navigation
Description
Feature hasn't been suggested before.
- I have verified this feature I'm about to request hasn't been suggested before.
Describe the enhancement you want to request
Why
ctx.session.hook("compaction") lets a plugin skip the summarizer model call, but it can only hand back prose. On v2 (cc8886c8b):
// packages/plugin/src/promise/session.ts:38
export interface SessionCompactionResult {
summary: string
providerState?: SessionMessage.ProviderState
metadata?: Record<string, unknown>
tokens?: TokenUsage.Info
}There's no messages. So a plugin that has worked out exactly which parts of the transcript still matter has to flatten that answer into a string and hand it to the same lossy channel it was trying to avoid. A file path, an exact error, a constraint the user stated forty turns ago — all of it survives or dies depending on how well it renders as prose.
Core already has the mechanism. NativeResult at packages/core/src/session/compaction.ts:104 is readonly replacement: ReadonlyArray<Message>, persisted through SessionProviderContext.encode(provenance, result.replacement) at :570, re-expanded on read via isCheckpoint. That's precisely "replace the window with these messages, verbatim". It's just not reachable from outside. #49575 says so explicitly:
The public plugin API (
ctx.session.hook("compaction")) is untouched. The seam lives on the CoreSessionCompactionservice and is reachable by internal plugins only.
I read that as a deliberate boundary rather than an oversight, which is why this is an issue and not a PR. I'd like to ask whether it can move.
The motivating case: compaction that scores each tool call and result, drops the stale ones, and leaves everything else byte-identical. Nothing is rewritten, so nothing is hallucinated in the rewrite. That approach exists for Claude Code (tamaratran/fast-jev-compaction, whose hook returns a message list) and several people have tried to port it here — nrdz-labs/fast-jev-opencode, christian-taillon/opencode-jev-compactor, hoshinodis/opencode-context-pruner, and PR #71 on the upstream repo. Every one of them hits the same wall and picks one of two workarounds:
- Render the kept transcript into the summary string. It survives, but as prose in a slot meant for prose, with a size ceiling and no message structure.
- Prune in
ctx.session.hook("context")instead. That only edits the outgoing request, andrequired()reads durable history, so the host still compacts on its own schedule over the unpruned transcript. The plugin isn't replacing compaction, it's racing it.
Neither is the thing. Both are the same missing capability worked around twice.
More generally: summarization is one compaction strategy, not the definition of compaction. Retention policy, tool-output pruning, semantic dedup, and memory-backed recall are all "decide what the next window contains", and none of them are naturally a paragraph of English. The hook already runs at the right moment with the right data — messages is right there on SessionContext. It's the return type that forces everything back through prose.
Outcome
A plugin can hand compaction a replacement window instead of a summary, and the messages it keeps arrive at the model unchanged.
Acceptance criteria
- A plugin can supply
Message[]from thecompactionhook and have those messages persist as the compacted window, not as rendered text. - Kept messages are byte-identical on the next request — same roles, same parts, same tool call/result pairing.
- A plugin returning nothing, or garbage, cannot corrupt the session. Worth noting:
SessionProviderContext.encodeonly proves valid JSON, and a bad window then dies indecodeMessageRowunderEffect.orDieinhistory.ts, which takes out the whole history load. Validation has to happen at install time, not read time. - Existing summary-only behaviour is unchanged when a plugin doesn't set the new field, and unchanged for plugins that only set
summary. - Whatever the replay semantics end up being — provenance-bound like the native path, or model-neutral — they're stated, because those two differ sharply on a model switch.
Where to look
packages/plugin/src/promise/session.ts:38and the identicalpackages/plugin/src/effect/session.ts—SessionCompactionResult.Messageis already imported at:2and already crosses this boundary mutably asSessionRequest.messagesat:29, so it isn't a new type leak.packages/core/src/session/compaction.ts:429—supplied(), where a hook result becomes a persisted compaction.packages/core/src/session/compaction.ts:104and:570—NativeResult.replacementand how it's persisted. The machinery a plugin window would reuse.packages/core/src/plugin/compaction.ts—opencode.compaction.native, the one registrant ofeditor.native, and the gate atPluginInternal.requirementsthat keeps external plugins out.
What I'm actually asking
Two shapes would both work, and I don't have a strong preference — I'd rather defer to whoever drew the boundary in #49575:
- Add
messages?: ReadonlyArray<Message>toSessionCompactionResultand consume it insupplied(), encoding through the existingSessionProviderContext. Small, no schema change, and it inherits the provenance gating that already stops a window being replayed to the wrong provider. The cost is that the window becomes route-scoped. - Expose strategy registration to external plugins, i.e. some public form of
editor.native. Bigger surface, but it's the seam that already exists rather than a second one alongside it.
Happy to do the implementation and tests if there's a shape you'd accept. Mostly I want to know whether this is a boundary you're open to moving, or one that's there for reasons I can't see from outside — I'd rather hear "no, and here's why" than keep watching people render transcripts into summary strings.
Related
- refactor(core): move native compaction mechanisms into a plugin #49575 — moved the native mechanism into an internal plugin; the source of the current boundary.
- feat(plugin): add session compaction and generate hooks #48212 — added the
compactionhook with its summary-onlyresult. - [FEATURE]: Five session capabilities that exist in core but are unreachable from a plugin #49389, [FEATURE]: expose more v2 session APIs to plugins #47229, [v2] No idle-time compaction: a large session is re-sent uncached after its prompt cache expires #50777 — plugins can't trigger a compaction. Adjacent gap, same surface, different half.
- [FEATURE]: Compaction: deterministic retention of user messages and a configurable compaction prompt #49463, [FEATURE]: allow user to inject custom instruction when compacting a session #42574 — retention fidelity and per-compaction instructions. Both work within summarization; this asks whether a plugin can step outside it.
Activity
github-actions commentedon Sep 23, 2026
This issue might be a duplicate of existing issues. Please check:
jutaz commentedon Sep 23, 2026
Checked #47810. It's close, and I'd missed it — but I think it's the other half of the same wall rather than the same ask.
#47810 is about the trigger: builtin compaction runs before the request hook or during overflow recovery, so a plugin can't own the transition.
required()reads durable history, which is why pruning inctx.session.hook("context")doesn't stop it.This one is about the return value: when the hook does fire,
SessionCompactionResult(packages/plugin/src/promise/session.ts:38) only carriessummary: string, so a plugin that knows which messages to keep has to render them into prose.They bite together. Suppressing automatic compaction still leaves you writing a summary; returning messages still leaves the host compacting on its own schedule. I described #47810's problem in the body here without realising it had an issue.
Happy to have these consolidated — #47810 is older and the broader framing, so if one should carry both, it's that one. I'd just want the summary-only return type named explicitly in whichever survives, since it's the part no existing issue states.
everest-an commentedon Oct 3, 2026
Following this closely — we hit the identical wall from the memory side, and I'd like to add a real-world data point in favour of option 1.
Who we are. Awareness is an open-source (Apache-2.0), local-first persistent-memory library for coding agents: Markdown + SQLite FTS5/hybrid retrieval, works offline, no account. We ship
@awareness.market/opencode-pluginplus a local daemon (MCP + REST). https://awareness.market · https://github.com/everest-an/Awareness-SDKThe wall, from our side. A memory plugin's whole job is to keep the active window small by holding long-lived project knowledge outside the transcript and pulling only what is relevant back in. In
experimental.session.compactingwe currently do exactly what you describe: we inject a structured handoff intooutput.context[](objective / decisions / open tasks / constraints / files), becausesummary: stringis the only return channel. It works — but:Message[], the host keeps compacting on its own schedule against the durable transcript — the same failure mode you note for thectx.session.hook("context")workaround.So any memory backend is currently capped at "supply a better paragraph". That is exactly the ceiling #50862 names.
+1 for option 1 — add
messages?: ReadonlyArray<Message>toSessionCompactionResultand consume it insupplied()via the existingSessionProviderContext. It's the smallest change that lets a memory plugin be the compaction strategy rather than decorate the summarizer, and it inherits the provenance gating for free.Happy to help test. We maintain a working plugin + local daemon, so we can be a concrete integration test for whatever lands — including the "plugin returns nothing/garbage" edge you flagged (validating at install time, not read time, sounds right).
Adjacent: #47810 is the trigger half — even with
messages,required()can still fire first. Together they are the full picture.everest-an commentedon Oct 3, 2026
Small addendum from the memory-backend side, to make the case concrete:
Our local-first memory library benchmarks at LongMemEval R@5 = 96.0% through its real retrieval pipeline (production E5 embeddings + live SQLite, zero LLM calls on the retrieval side). Open source, reproducible on any machine —
benchmarks/longmemeval/run_f053_daemon_path.mjsin https://github.com/everest-an/Awareness-Market (site: https://awareness.market).So the blocker here isn't retrieval quality — it's purely that the compaction return channel only accepts prose. That's what makes option 1 (
messages?: Message[]) an unlock rather than an optimisation: a memory backend could actually be the window strategy instead of rendering its answer into a paragraph.opencodejr commentedon Oct 28, 2026
Thanks for the detailed write-up. You're asking for the
compactionhook to return a replacement window ofMessage[]that the next request uses unchanged, instead of only a summary string.Most of this has shipped since you opened the issue. Since v2.1.0 (#57260),
SessionCompactionResulthas an optionalnativefield, andnative.messagesis treated as a replacement window:On current
dev, the code does the following:fromHookinpackages/core/src/session/compaction.tsstoresnativeon the compaction message.packages/core/src/session/runner/to-llm-message.tsreturns the decodedMessage[]as-is in place of the<conversation-checkpoint>text.replayableinpackages/core/src/session/history.tsonly uses the window when the provider ID and model ID match the compaction's. After a model switch, the window isn't replayed.Message[]is skipped when history is read, instead of failing the history load.The docs for these fields are in #57276.
Gaps that remain, as far as I can tell:
Maintainers will decide whether those gaps are worth pursuing or whether this can be closed as done. Please check whether
native.messageson v2.1+ covers your pruning use case.Related issues:
native.messages/metadata.keepIDin the hook result