Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions documentor/FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ 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 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 `<retained>` 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 `<conversation>` 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 |
| `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 |
Expand Down
14 changes: 13 additions & 1 deletion packages/core/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <conversation> and stay in the context verbatim, right after your summary:\n\n<retained>\n${retained}\n</retained>\n\n${RETAINED_INSTRUCTIONS}`
const RETAINED_INSTRUCTIONS = `Do not summarise the <retained> 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<conversation>\n${input.context.join("\n\n")}\n</conversation>`
const retained = input.retained ? [retainedSection(input.retained)] : []
if (!input.previousSummary)
return [
conversation,
...retained,
"Create a new anchored summary from the conversation history in the <conversation> 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 <conversation> above:\n\n<prior-summary>\n${input.previousSummary}\n</prior-summary>`,
...retained,
SUMMARY_UPDATE_INSTRUCTIONS,
SUMMARY_TEMPLATE,
].join("\n\n")
Expand Down
48 changes: 40 additions & 8 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,29 +374,43 @@ 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,
})
// Allow plugins to inject context or replace compaction prompt.
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 },
{ context: [], prompt: undefined },
{ sessionID: input.sessionID, messages: leaving },
{ context: [], prompt: undefined, carry: [] as string[] },
)
const msgs = structuredClone(selected.head)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const conversation = msgs
.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,
]
Expand Down Expand Up @@ -478,6 +492,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
Expand Down
Loading
Loading