Skip to content

[FEATURE]: Compaction hook can only return a summary string, so a plugin cannot replace the window with messages #50862

Open

Description

@jutaz

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 Core SessionCompaction service 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, and required() 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 the compaction hook 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.encode only proves valid JSON, and a bad window then dies in decodeMessageRow under Effect.orDie in history.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:38 and the identical packages/plugin/src/effect/session.ts — SessionCompactionResult. Message is already imported at :2 and already crosses this boundary mutably as SessionRequest.messages at :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:104 and :570 — NativeResult.replacement and how it's persisted. The machinery a plugin window would reuse.
  • packages/core/src/plugin/compaction.ts — opencode.compaction.native, the one registrant of editor.native, and the gate at PluginInternal.requirements that 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:

  1. Add messages?: ReadonlyArray<Message> to SessionCompactionResult and consume it in supplied(), encoding through the existing SessionProviderContext. 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.
  2. 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

Activity

  1. github-actions commented on Sep 23, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

  2. jutaz commented on Sep 23, 2026

    @jutaz
    Author

    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 in ctx.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 carries summary: 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.

  3. everest-an commented on Oct 3, 2026

    @everest-an

    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-plugin plus a local daemon (MCP + REST). https://awareness.market · https://github.com/everest-an/Awareness-SDK

    The 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.compacting we currently do exactly what you describe: we inject a structured handoff into output.context[] (objective / decisions / open tasks / constraints / files), because summary: string is the only return channel. It works — but:

    1. It's prose. The things we most need to survive — an exact constraint, a file path, a specific error string — survive only as well as they render into a paragraph.
    2. We're racing compaction, not performing it. Because we can't hand back the kept window as Message[], the host keeps compacting on its own schedule against the durable transcript — the same failure mode you note for the ctx.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> to SessionCompactionResult and consume it in supplied() via the existing SessionProviderContext. 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.

  4. everest-an commented on Oct 3, 2026

    @everest-an

    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.mjs in 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.

  5. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the detailed write-up. You're asking for the compaction hook to return a replacement window of Message[] 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), SessionCompactionResult has an optional native field, and native.messages is treated as a replacement window:

    ctx.session.hook("compaction", async (event) => {
      const kept = pick(event.messages) // your scoring / pruning
      event.result = {
        summary: "…", // still required; shown in the UI and used where the window can't replay
        native: { messages: kept }, // replayed verbatim instead of `summary`
        // metadata: { keepID }, tokens: …  (optional)
      }
    })

    On current dev, the code does the following:

    • fromHook in packages/core/src/session/compaction.ts stores native on the compaction message.
    • packages/core/src/session/runner/to-llm-message.ts returns the decoded Message[] as-is in place of the <conversation-checkpoint> text.
    • Replay depends on the model: replayable in packages/core/src/session/history.ts only uses the window when the provider ID and model ID match the compaction's. After a model switch, the window isn't replayed.
    • A malformed window doesn't break the session: one that fails to decode as 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:

    • A bad window is caught when history is read, not when it's installed. It's dropped silently, the plugin gets no error, and the compaction is still recorded as completed.
    • There is no model-neutral option for a window from a hook.
    • Plugins still can't control when compaction runs.

    Maintainers will decide whether those gaps are worth pursuing or whether this can be closed as done. Please check whether native.messages on v2.1+ covers your pruning use case.

    Related issues:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions