From 93bd4ce85d26421bbe0cc0df61cee3e26d6dbd77 Mon Sep 17 00:00:00 2001 From: Csaba Fabian Date: Thu, 22 Oct 2026 21:43:38 +0000 Subject: [PATCH 1/2] Compaction carries plugin text verbatim on the compaction message experimental.session.compacting now receives the messages a compaction takes out of the model's view and may return `carry` texts. Once the summary succeeds, each is stored as a synthetic text part on the compaction's user message: the model reads it before the summary from the auto-continue turn on, and no later summariser is shown it. DocuMentor uses this to keep its project files (PROJECT.md, MEMORY.md, the knowledge-base index, WORKSPACE.md), injected once per chat, verbatim across compactions instead of paraphrased by the summary. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0148RPMT9kUkZ6BL4TtebZJe --- documentor/FORK.md | 2 + packages/opencode/src/session/compaction.ts | 27 +++++++- .../opencode/test/session/compaction.test.ts | 69 +++++++++++++++++++ packages/plugin/src/index.ts | 16 ++++- 4 files changed, 109 insertions(+), 5 deletions(-) diff --git a/documentor/FORK.md b/documentor/FORK.md index 576053c66c56..ccdc570801ae 100644 --- a/documentor/FORK.md +++ b/documentor/FORK.md @@ -34,6 +34,8 @@ what a rebase meets. | `packages/opencode/src/session/message-v2.ts` | `filterCompactedEffect` reads pages newest first through `scanNewestFirst` and stops where `filterCompacted` stops; both run the same walk, `compactedScanner` | The prompt loop calls it on every step. It read the whole session and then kept only what follows the newest compaction: a chat with 65 MB of history peaked above 1 GB per prompt | | `packages/opencode/src/session/compaction.ts` | `serialize` prefixes an authored user message with `Name: ` when the session is a discussion (its `process` passes `MessageV2.isDiscussion(session.metadata)`); `prune` and `turns()` count only user messages whose `answerable` is not `false` | A compacted discussion keeps who said what; side remarks do not shrink the protected or retained window | | `packages/opencode/src/session/compaction.ts` | `prune` walks the session through `Session.scanNewestFirst` instead of loading it whole | The walk stops at the newest summary or the first cleared output, but every message was loaded after each turn | +| `packages/opencode/src/session/compaction.ts` | `process` passes `experimental.session.compacting` the messages it takes out of the model's view (`messages`: everything before the retained tail) and, once the summary succeeds, stores each text of its `carry` output as a synthetic text part (`metadata.compaction_carry`) on the compaction's user message | DocuMentor injects its project files once per chat. Carried on the compaction message, they reach the model verbatim before the summary, from the auto-continue turn on, and no later summariser is shown them (`completedCompactions` hides that message) | +| `packages/plugin/src/index.ts` | `experimental.session.compacting` takes `messages` and returns `carry` | The hook contract of the row above | | `packages/opencode/src/session/session.ts` | `CreateInput` and `create` take an optional `id`; `create` refuses an id that names an existing session. `saveMessage` publishes a message and its parts in one transaction. `fork` publishes its copy with `publishMany` and removes the new session again if the copy does not commit | DocuMentor shows a new chat under its final id before opencode answers `session/new`. Each part was its own commit: on a network database a reader between them saw the context part of a user message without its text. A fork of a long chat was one transaction per message and per part: tens of seconds against the remote database | | `packages/opencode/src/session/session.ts` | `scanNewestFirst` hands messages to a visitor newest first, a page at a time, until it answers false | What `prune` reads, without loading a long chat in full | | `packages/core/src/event.ts` | Adds `Publication`, `publication` and `publishMany`: durable events of one aggregate projected, written and notified in one transaction. A copy of the non-replay path of `commitDurableEvent`, not a refactor of it | Each `publish` is about nine round trips; `Session.fork` copies a whole chat. Kept as a separate copy so upstream changes to `commitDurableEvent` rebase cleanly | diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 8a71a6ba0a28..5d4c2c19601c 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -379,11 +379,14 @@ const layer = Layer.effect( cfg, model, }) - // Allow plugins to inject context or replace compaction prompt. + const tailIndex = selected.tail_start_id + ? history.findIndex((m) => m.info.id === selected.tail_start_id) + : -1 + // Allow plugins to inject context, replace the compaction prompt, or carry text past it. const compacting = yield* plugin.trigger( "experimental.session.compacting", - { sessionID: input.sessionID }, - { context: [], prompt: undefined }, + { sessionID: input.sessionID, messages: tailIndex >= 0 ? history.slice(0, tailIndex) : history }, + { context: [], prompt: undefined, carry: [] as string[] }, ) const msgs = structuredClone(selected.head) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) @@ -478,6 +481,24 @@ const layer = Layer.effect( }) } + // documentor: carried text sits on the compaction's user message, which the model reads before the + // summary and which no later summariser is shown, so it survives every compaction verbatim. + if (result === "continue" && compactionPart) { + for (const text of compacting.carry ?? []) { + if (!text) continue + yield* session.updatePart({ + id: PartID.ascending(), + messageID: input.parentID, + sessionID: input.sessionID, + type: "text", + synthetic: true, + metadata: { compaction_carry: true }, + text, + time: { start: Date.now(), end: Date.now() }, + }) + } + } + if (result === "continue" && input.auto) { if (replay) { const original = replay.info diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 98b2ea301ea4..a8bf3c49f1f8 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -408,6 +408,21 @@ function compactionContext(context: string) { }) } +function compactionCarry(carry: string, seen: SessionV1.WithParts[][]) { + return Layer.mock(Plugin.Service)({ + trigger: (name: Name, input: Input, output: Output) => { + if (name !== "experimental.session.compacting") return Effect.succeed(output) + return Effect.sync(() => { + seen.push((input as { messages: SessionV1.WithParts[] }).messages) + ;(output as { carry: string[] }).carry.push(carry) + return output + }) + }, + list: () => Effect.succeed([]), + init: () => Effect.void, + }) +} + describe("session.compaction.isOverflow", () => { it.live( "returns true when token count exceeds usable context", @@ -1635,6 +1650,60 @@ describe("session.compaction.process", () => { { git: true }, ) + itCompaction.instance( + "documentor: carries plugin text verbatim on the compaction message, out of every later summariser's view", + () => { + const stub = llm() + const captured: string[] = [] + stub.push(reply("summary one", (input) => captured.push(JSON.stringify(input.messages)))) + stub.push(reply("summary two", (input) => captured.push(JSON.stringify(input.messages)))) + const seen: SessionV1.WithParts[][] = [] + + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const older = yield* createUserMessage(session.id, "older context") + yield* createCompactionMarker(session.id) + + let msgs = MessageV2.filterCompacted(yield* MessageV2.stream(session.id)) + const first = msgs.at(-1)!.info.id + yield* SessionCompaction.use.process({ parentID: first, messages: msgs, sessionID: session.id, auto: false }) + + expect(seen[0].map((m) => m.info.id)).toContain(older.id) + const carried = (yield* ssn.messages({ sessionID: session.id })) + .find((m) => m.info.id === first)! + .parts.filter((p): p is SessionV1.TextPart => p.type === "text") + expect(carried.map((p) => [p.text, p.synthetic, p.metadata?.compaction_carry])).toEqual([ + ["CARRIED PROJECT FILE", true, true], + ]) + + yield* createUserMessage(session.id, "latest turn") + yield* createCompactionMarker(session.id) + msgs = MessageV2.filterCompacted(yield* MessageV2.stream(session.id)) + const view = JSON.stringify( + yield* MessageV2.toModelMessagesEffect(msgs, createModel({ context: 100_000, output: 32_000 })), + ) + expect(view).toContain("CARRIED PROJECT FILE") + expect(view.indexOf("CARRIED PROJECT FILE")).toBeLessThan(view.indexOf("summary one")) + + yield* SessionCompaction.use.process({ + parentID: msgs.at(-1)!.info.id, + messages: msgs, + sessionID: session.id, + auto: false, + }) + expect(captured[1]).toContain("summary one") + expect(captured[1]).not.toContain("CARRIED PROJECT FILE") + expect( + seen[1].some( + (m) => m.info.id === first && m.parts.some((p) => p.type === "text" && p.text === "CARRIED PROJECT FILE"), + ), + ).toBe(true) + }).pipe(withCompaction({ llm: stub.llmLayer, plugin: compactionCarry("CARRIED PROJECT FILE", seen) })) + }, + { git: true }, + ) + for (const [title, metadata, expected] of [ [ "documentor: a discussion's compaction request names who said what", diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index edfa0139dfca..31e1ca452bc3 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -299,12 +299,24 @@ export interface Hooks { * Called before session compaction starts. Allows plugins to customize * the compaction prompt. * + * - `messages`: The messages this compaction takes out of the model's view, + * in the order the model saw them. The tail the compaction keeps is not + * among them. * - `context`: Additional context strings appended to the default prompt * - `prompt`: If set, replaces the default compaction prompt entirely + * - `carry`: Texts the model keeps verbatim after compaction. They are added + * to the compaction's user message once the summary succeeds, so the + * summariser never paraphrases them and every later turn sees them. */ "experimental.session.compacting"?: ( - input: { sessionID: string }, - output: { context: string[]; prompt?: string }, + input: { + sessionID: string + messages: { + info: Message + parts: Part[] + }[] + }, + output: { context: string[]; prompt?: string; carry: string[] }, ) => Promise /** * Called after compaction succeeds and before a synthetic user From bccd374343cc754bfe70eac79c6eb7a5e8e91616 Mon Sep 17 00:00:00 2001 From: Csaba Fabian Date: Thu, 22 Oct 2026 22:00:09 +0000 Subject: [PATCH 2/2] Compaction: the summariser reads the retained tail; carry skips what stays in view The summariser was shown only the head, so anything that happened in the retained tail - a background task finishing, an action retried - left the summary stating the older state, contradicting the messages that follow it. It now also reads the tail, in , with the instruction not to summarise it and to write Work State and Next Move as the state after it. The compacting hook's `messages` are now the messages created before the tail starts. A tail can reach back past an earlier compaction message, which then stays in view; handing it to the hook carried its text twice. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0148RPMT9kUkZ6BL4TtebZJe --- documentor/FORK.md | 3 +- packages/core/src/session/compaction.ts | 14 ++++++++- packages/opencode/src/session/compaction.ts | 29 +++++++++++++------ .../opencode/test/session/compaction.test.ts | 13 +++++++-- 4 files changed, 45 insertions(+), 14 deletions(-) diff --git a/documentor/FORK.md b/documentor/FORK.md index ccdc570801ae..a3ce74ed757a 100644 --- a/documentor/FORK.md +++ b/documentor/FORK.md @@ -34,7 +34,8 @@ what a rebase meets. | `packages/opencode/src/session/message-v2.ts` | `filterCompactedEffect` reads pages newest first through `scanNewestFirst` and stops where `filterCompacted` stops; both run the same walk, `compactedScanner` | The prompt loop calls it on every step. It read the whole session and then kept only what follows the newest compaction: a chat with 65 MB of history peaked above 1 GB per prompt | | `packages/opencode/src/session/compaction.ts` | `serialize` prefixes an authored user message with `Name: ` when the session is a discussion (its `process` passes `MessageV2.isDiscussion(session.metadata)`); `prune` and `turns()` count only user messages whose `answerable` is not `false` | A compacted discussion keeps who said what; side remarks do not shrink the protected or retained window | | `packages/opencode/src/session/compaction.ts` | `prune` walks the session through `Session.scanNewestFirst` instead of loading it whole | The walk stops at the newest summary or the first cleared output, but every message was loaded after each turn | -| `packages/opencode/src/session/compaction.ts` | `process` passes `experimental.session.compacting` the messages it takes out of the model's view (`messages`: everything before the retained tail) and, once the summary succeeds, stores each text of its `carry` output as a synthetic text part (`metadata.compaction_carry`) on the compaction's user message | DocuMentor injects its project files once per chat. Carried on the compaction message, they reach the model verbatim before the summary, from the auto-continue turn on, and no later summariser is shown them (`completedCompactions` hides that message) | +| `packages/opencode/src/session/compaction.ts` | `process` passes `experimental.session.compacting` the messages it takes out of the model's view (`messages`: everything created before the retained tail starts, so an earlier compaction message still inside the tail is not among them) and, once the summary succeeds, stores each text of its `carry` output as a synthetic text part (`metadata.compaction_carry`) on the compaction's user message | DocuMentor injects its project files once per chat. Carried on the compaction message, they reach the model verbatim before the summary, from the auto-continue turn on, and no later summariser is shown them (`completedCompactions` hides that message) | +| `packages/core/src/session/compaction.ts`, `packages/opencode/src/session/compaction.ts` | `buildPrompt` takes `retained`; v1 `process` passes the serialized retained tail, which the prompt shows in `` after the conversation with the instruction not to summarise it and to write Work State and Next Move as the state after it. Upstream's tail tests assert the tail stays out of `` instead of out of the request | The summariser saw only the head, so a helper finishing or an action retried in the tail left the summary stating the older state, contradicting the messages right after it | | `packages/plugin/src/index.ts` | `experimental.session.compacting` takes `messages` and returns `carry` | The hook contract of the row above | | `packages/opencode/src/session/session.ts` | `CreateInput` and `create` take an optional `id`; `create` refuses an id that names an existing session. `saveMessage` publishes a message and its parts in one transaction. `fork` publishes its copy with `publishMany` and removes the new session again if the copy does not commit | DocuMentor shows a new chat under its final id before opencode answers `session/new`. Each part was its own commit: on a network database a reader between them saw the context part of a user message without its text. A fork of a long chat was one transaction per message and per part: tens of seconds against the remote database | | `packages/opencode/src/session/session.ts` | `scanNewestFirst` hands messages to a visitor newest first, a page at a time, until it answers false | What `prune` reads, without loading a long chat in full | diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index f714633295a5..b0ab538672e5 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -157,17 +157,29 @@ const select = ( } } -export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) => { +// documentor: the retained tail is shown, not summarised, so the summary's state is the state after it. +const retainedSection = (retained: string) => + `These messages come after the and stay in the context verbatim, right after your summary:\n\n\n${retained}\n\n\n${RETAINED_INSTRUCTIONS}` +const RETAINED_INSTRUCTIONS = `Do not summarise the messages. Where they change the state the summary describes - a background task finished, an action was retried or failed, a decision was made - write the summary's "Work State" and "Next Move" as the state after them.` + +export const buildPrompt = (input: { + readonly previousSummary?: string + readonly context: readonly string[] + readonly retained?: string +}) => { const conversation = `Here is the conversation so far:\n\n\n${input.context.join("\n\n")}\n` + const retained = input.retained ? [retainedSection(input.retained)] : [] if (!input.previousSummary) return [ conversation, + ...retained, "Create a new anchored summary from the conversation history in the tags above so another coding agent can continue the work.", SUMMARY_TEMPLATE, ].join("\n\n") return [ conversation, `Here is the summary of the conversation before the above:\n\n\n${input.previousSummary}\n`, + ...retained, SUMMARY_UPDATE_INSTRUCTIONS, SUMMARY_TEMPLATE, ].join("\n\n") diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 5d4c2c19601c..68b1d9d1983b 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -374,18 +374,22 @@ const layer = Layer.effect( const prior = completedCompactions(history) const hidden = new Set(prior.flatMap((item) => [item.userIndex, item.assistantIndex])) const previousSummary = prior.at(-1)?.summary - const selected = yield* select({ - messages: history.filter((_, index) => !hidden.has(index)), - cfg, - model, - }) - const tailIndex = selected.tail_start_id - ? history.findIndex((m) => m.info.id === selected.tail_start_id) - : -1 + const visible = history.filter((_, index) => !hidden.has(index)) + const selected = yield* select({ messages: visible, cfg, model }) + // The retained tail is every message created from its start on, an earlier compaction's message + // included, though the model sees that one before the tail; only the rest leaves the model's view. + const tail = selected.tail_start_id ? history.find((m) => m.info.id === selected.tail_start_id) : undefined + const leaving = tail + ? history.filter( + (m) => + m.info.time.created < tail.info.time.created || + (m.info.time.created === tail.info.time.created && m.info.id < tail.info.id), + ) + : history // Allow plugins to inject context, replace the compaction prompt, or carry text past it. const compacting = yield* plugin.trigger( "experimental.session.compacting", - { sessionID: input.sessionID, messages: tailIndex >= 0 ? history.slice(0, tailIndex) : history }, + { sessionID: input.sessionID, messages: leaving }, { context: [], prompt: undefined, carry: [] as string[] }, ) const msgs = structuredClone(selected.head) @@ -394,12 +398,19 @@ const layer = Layer.effect( .map((m) => serialize(m, attributeAuthors)) .filter(Boolean) .join("\n\n") + // documentor: the summariser also reads the tail it keeps, so its state never predates that tail. + const retained = visible + .slice(selected.head.length) + .map((m) => serialize(m, attributeAuthors)) + .filter(Boolean) + .join("\n\n") const nextPrompt = compacting.prompt ?? [ buildPrompt({ previousSummary, context: [conversation], + retained, }), ...compacting.context, ] diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index a8bf3c49f1f8..cafa9baf88d6 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -408,6 +408,11 @@ function compactionContext(context: string) { }) } +/** The summariser request's section: what it summarises. */ +const conversationOf = (captured: string) => captured.split("")[1]?.split("")[0] ?? "" +/** The summariser request's section: the tail it reads but keeps verbatim. */ +const retainedOf = (captured: string) => captured.split("")[1]?.split("")[0] ?? "" + function compactionCarry(carry: string, seen: SessionV1.WithParts[][]) { return Layer.mock(Plugin.Service)({ trigger: (name: Name, input: Input, output: Output) => { @@ -1231,7 +1236,8 @@ describe("session.compaction.process", () => { expect(part?.type).toBe("compaction") expect(part?.tail_start_id).toBe(keep.id) expect(captured).toContain("zzzz") - expect(captured).not.toContain("keep tail") + expect(conversationOf(captured)).not.toContain("keep tail") + expect(retainedOf(captured)).toContain("keep tail") const filtered = MessageV2.filterCompacted(yield* MessageV2.stream(session.id)) expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id]) @@ -1550,8 +1556,9 @@ describe("session.compaction.process", () => { captured.indexOf("Create a new anchored summary"), ) expect(captured).toContain("[User]: older context") - expect(captured).not.toContain("keep this turn") - expect(captured).not.toContain("and this one too") + expect(conversationOf(captured)).not.toContain("keep this turn") + expect(conversationOf(captured)).not.toContain("and this one too") + expect(retainedOf(captured)).toContain("keep this turn") expect(captured).not.toContain("What did we do so far?") }).pipe( withCompaction({