summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-03 01:26:16 +0900
committerAdam Malczewski <[email protected]>2026-06-03 01:26:16 +0900
commitb26821ead97b986f886065b20d3dbde8283daa64 (patch)
tree3c41419afb805d88080392e1c97d233af910b79d /packages/core/src
parent4b45d33c256cf580a53054078be6fd7148fa6302 (diff)
downloaddispatch-b26821ead97b986f886065b20d3dbde8283daa64.tar.gz
dispatch-b26821ead97b986f886065b20d3dbde8283daa64.zip
feat(compaction): add UI-driven conversation compaction
Summarize a conversation's older "head" into a structured anchored Markdown summary while preserving the most recent turns verbatim, shrinking context size while keeping the information needed to continue coherently. Triggered by a "Compact conversation" button in Chat Settings (not an agent tool). Approach informed by OpenCode's session/compaction.ts: - Ported SUMMARY_TEMPLATE (Goal / Constraints / Progress / Key Decisions / Next Steps / Critical Context / Relevant Files) and the anchored-summary buildPrompt (re-summarizes a prior summary when present). - Ported the TOOL_OUTPUT_MAX_CHARS (2000) cap on tool results in the summary request. - Simplified tail selection to a fixed recent-turn count (DEFAULT_TAIL_TURNS=2) instead of OpenCode's token-budget splitTurn. core: - New src/compaction/ module (pure, DB-free): template, prompt builder, head/tail selection, transcript renderer with tool-output capping, prior summary extraction. Generic over ChatMessage so callers keep turnId/seq. - db/chunks.ts: rekeyChunks(from,to) relocates a tab's full history to a backup tab (reversible — nothing is deleted). - AgentEvent: compaction-started / -complete / -error variants. api: - AgentManager.compactTab(tempTabId, sourceTabId): side-effect-free resolveConnection() for the compactor model (configured compaction_model_*, else the source tab's own key+model), one-shot tool-less summary generation via a transient Agent, then relocate full history to a fresh backup tab and re-seed the canonical source id with [summary turn + preserved tail]. Source tab is locked (messages queue) during the run; queue drains afterward. - Routes: POST /tabs/:id/compact, GET/PUT /tabs/settings/compaction-model. frontend: - "Compact conversation" button in ModelSelector (Chat Settings), between Working Directory and the agent toggle; idle-gated. - Compaction-model key+model selector in Settings, beside the title model. - Transient placeholder tab shows a large, non-faded "Please wait, compacting conversation…" screen; closing it cancels. Source input locked while running. - Handle compaction-* events: reload compacted source, insert backup tab, refocus source, discard placeholder. tests: core compaction unit tests, rekeyChunks DB test, AgentManager.compactTab orchestration tests, and compaction route tests. All green (713 tests), biome clean, all typechecks pass, frontend builds.
Diffstat (limited to 'packages/core/src')
-rw-r--r--packages/core/src/chunks/append.ts3
-rw-r--r--packages/core/src/compaction/index.ts245
-rw-r--r--packages/core/src/db/chunks.ts19
-rw-r--r--packages/core/src/index.ts16
-rw-r--r--packages/core/src/types/index.ts24
5 files changed, 306 insertions, 1 deletions
diff --git a/packages/core/src/chunks/append.ts b/packages/core/src/chunks/append.ts
index 4bc6b5f..4ca6fe1 100644
--- a/packages/core/src/chunks/append.ts
+++ b/packages/core/src/chunks/append.ts
@@ -210,6 +210,9 @@ export function appendEventToChunks(chunks: Chunk[], event: AgentEvent): void {
case "message-queued":
case "message-consumed":
case "message-cancelled":
+ case "compaction-started":
+ case "compaction-complete":
+ case "compaction-error":
return;
default: {
diff --git a/packages/core/src/compaction/index.ts b/packages/core/src/compaction/index.ts
new file mode 100644
index 0000000..663ccb2
--- /dev/null
+++ b/packages/core/src/compaction/index.ts
@@ -0,0 +1,245 @@
+// Conversation compaction — summarize the older "head" of a conversation into
+// a structured anchor while preserving the most recent turns verbatim.
+//
+// Ported from opencode's `session/compaction.ts` (SUMMARY_TEMPLATE, the
+// anchored-summary `buildPrompt`, and the `TOOL_OUTPUT_MAX_CHARS` cap). The
+// turn-budget `splitTurn` tail selection is intentionally simplified to a fixed
+// recent-turn count (`DEFAULT_TAIL_TURNS`) — Dispatch keeps the last N turns
+// verbatim rather than computing a token budget.
+//
+// This module is pure and DB-free so it can be unit-tested in isolation and
+// shared by the API orchestrator.
+
+import type { ChatMessage } from "../types/index.js";
+
+/** Number of trailing turns kept verbatim (opencode's DEFAULT_TAIL_TURNS). */
+export const DEFAULT_TAIL_TURNS = 2;
+
+/** Max characters of a single tool result fed into the summary request. */
+export const TOOL_OUTPUT_MAX_CHARS = 2_000;
+
+/**
+ * Marker prefixing the seeded summary turn in a compacted conversation. Lets a
+ * subsequent compaction detect a prior summary and re-summarize (anchor) it
+ * instead of treating it as ordinary conversation.
+ */
+export const SUMMARY_MARKER = "[CONVERSATION SUMMARY]";
+
+/**
+ * Structured Markdown template the summary must follow. Ported verbatim from
+ * opencode's `SUMMARY_TEMPLATE`.
+ */
+export const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
+<template>
+## Goal
+- [single-sentence task summary]
+
+## Constraints & Preferences
+- [user constraints, preferences, specs, or "(none)"]
+
+## Progress
+### Done
+- [completed work or "(none)"]
+
+### In Progress
+- [current work or "(none)"]
+
+### Blocked
+- [blockers or "(none)"]
+
+## Key Decisions
+- [decision and why, or "(none)"]
+
+## Next Steps
+- [ordered next actions or "(none)"]
+
+## Critical Context
+- [important technical facts, errors, open questions, or "(none)"]
+
+## Relevant Files
+- [file or directory path: why it matters, or "(none)"]
+</template>
+
+Rules:
+- Keep every section, even when empty.
+- Use terse bullets, not prose paragraphs.
+- Preserve exact file paths, commands, error strings, and identifiers when known.
+- Do not mention the summary process or that context was compacted.`;
+
+/**
+ * Build the compaction instruction. When `previousSummary` is provided, the
+ * model is asked to UPDATE the anchored summary rather than create a fresh one
+ * (opencode's `buildPrompt` anchor behaviour).
+ */
+export function buildCompactionPrompt(input: { previousSummary?: string }): string {
+ const anchor = input.previousSummary
+ ? [
+ "Update the anchored summary below using the conversation history above.",
+ "Preserve still-true details, remove stale details, and merge in the new facts.",
+ "<previous-summary>",
+ input.previousSummary,
+ "</previous-summary>",
+ ].join("\n")
+ : "Create a new anchored summary from the conversation history above.";
+ return `${anchor}\n\n${SUMMARY_TEMPLATE}`;
+}
+
+/**
+ * The first text chunk of a message, trimmed (empty → undefined). Used to read
+ * the seeded summary out of a compacted conversation's first user turn.
+ */
+function firstText(message: ChatMessage): string | undefined {
+ for (const chunk of message.chunks) {
+ if (chunk.type === "text") {
+ const t = chunk.text.trim();
+ if (t) return t;
+ }
+ }
+ return undefined;
+}
+
+/**
+ * Extract a prior summary from the conversation head. If the first user message
+ * is a seeded summary (starts with {@link SUMMARY_MARKER}), return its body
+ * (marker stripped) so the next compaction can anchor on it.
+ */
+export function extractPreviousSummary(messages: ChatMessage[]): string | undefined {
+ const first = messages.find((m) => m.role === "user");
+ if (!first) return undefined;
+ const text = firstText(first);
+ if (!text?.startsWith(SUMMARY_MARKER)) return undefined;
+ const body = text.slice(SUMMARY_MARKER.length).trim();
+ return body || undefined;
+}
+
+export interface HeadTailSelection<T extends ChatMessage = ChatMessage> {
+ /** Older messages to be summarized away. */
+ head: T[];
+ /** Recent messages preserved verbatim in the continuation. */
+ tail: T[];
+}
+
+/**
+ * Split a conversation into a summarizable `head` and a preserved `tail` of the
+ * last `tailTurns` turns. A "turn" begins at a user message and runs until the
+ * next user message.
+ *
+ * When the conversation has `tailTurns` or fewer turns, `head` is empty: there
+ * is nothing to compact (the caller should refuse).
+ */
+export function selectHeadTail<T extends ChatMessage>(
+ messages: T[],
+ tailTurns: number = DEFAULT_TAIL_TURNS,
+): HeadTailSelection<T> {
+ if (tailTurns <= 0) return { head: messages, tail: [] };
+ const userIndices: number[] = [];
+ for (let i = 0; i < messages.length; i++) {
+ if (messages[i]?.role === "user") userIndices.push(i);
+ }
+ if (userIndices.length <= tailTurns) return { head: [], tail: messages };
+ const tailStart = userIndices[userIndices.length - tailTurns];
+ if (tailStart === undefined || tailStart <= 0) return { head: [], tail: messages };
+ return { head: messages.slice(0, tailStart), tail: messages.slice(tailStart) };
+}
+
+/** Cap a tool result to `max` chars with a truncation marker. */
+function capToolOutput(result: string, max: number): string {
+ if (result.length <= max) return result;
+ const omitted = result.length - max;
+ return `${result.slice(0, max)}\n…[${omitted} chars truncated for summary]`;
+}
+
+/**
+ * Render conversation messages into a compact, provider-agnostic plain-text
+ * transcript suitable as summary-request context. Tool results are capped at
+ * `toolOutputMaxChars` (opencode's `TOOL_OUTPUT_MAX_CHARS`), and a seeded prior
+ * summary message is skipped (its content is carried by the prompt anchor).
+ * Thinking/error/system chunks are omitted as summary noise.
+ */
+export function renderTranscript(
+ messages: ChatMessage[],
+ toolOutputMaxChars: number = TOOL_OUTPUT_MAX_CHARS,
+): string {
+ const blocks: string[] = [];
+ for (const message of messages) {
+ // Skip a seeded prior-summary user turn — it's represented via the anchor.
+ if (message.role === "user") {
+ const t = firstText(message);
+ if (t?.startsWith(SUMMARY_MARKER)) continue;
+ }
+
+ const lines: string[] = [];
+ for (const chunk of message.chunks) {
+ if (chunk.type === "text") {
+ const t = chunk.text.trim();
+ if (t) lines.push(t);
+ } else if (chunk.type === "tool-batch") {
+ for (const call of chunk.calls) {
+ let args = "";
+ try {
+ args = JSON.stringify(call.arguments ?? {});
+ } catch {
+ args = "{}";
+ }
+ lines.push(`[tool ${call.name} ${args}]`);
+ if (call.result !== undefined) {
+ const tag = call.isError ? "tool-error" : "tool-result";
+ lines.push(`[${tag}] ${capToolOutput(call.result, toolOutputMaxChars)}`);
+ }
+ }
+ }
+ }
+ if (lines.length === 0) continue;
+ const role =
+ message.role === "user" ? "User" : message.role === "assistant" ? "Assistant" : "System";
+ blocks.push(`## ${role}\n${lines.join("\n")}`);
+ }
+ return blocks.join("\n\n");
+}
+
+export interface CompactionRequest<T extends ChatMessage = ChatMessage> {
+ /** Messages selected for summarization (older head). */
+ head: T[];
+ /** Recent messages preserved verbatim (last N turns). */
+ tail: T[];
+ /** Prior summary anchored on, if the conversation was compacted before. */
+ previousSummary?: string;
+ /**
+ * The full user-message content for the summary request: rendered head
+ * transcript followed by the compaction prompt/template. `undefined` when
+ * there is nothing to compact (`head` empty).
+ */
+ prompt?: string;
+}
+
+/**
+ * Assemble everything needed to run a compaction: head/tail split, prior-summary
+ * extraction, and the combined summary-request prompt. Returns `prompt:
+ * undefined` when the conversation is too short to compact.
+ */
+export function buildCompactionRequest<T extends ChatMessage>(input: {
+ messages: T[];
+ tailTurns?: number;
+ toolOutputMaxChars?: number;
+}): CompactionRequest<T> {
+ const tailTurns = input.tailTurns ?? DEFAULT_TAIL_TURNS;
+ const toolMax = input.toolOutputMaxChars ?? TOOL_OUTPUT_MAX_CHARS;
+ const { head, tail } = selectHeadTail(input.messages, tailTurns);
+ const previousSummary = extractPreviousSummary(input.messages);
+ if (head.length === 0) {
+ return { head, tail, previousSummary };
+ }
+ const transcript = renderTranscript(head, toolMax);
+ const instruction = buildCompactionPrompt({ previousSummary });
+ const prompt = `${transcript}\n\n${instruction}`;
+ return { head, tail, previousSummary, prompt };
+}
+
+/**
+ * Wrap a generated summary as the seeded user-turn text for the continuation
+ * conversation. Prefixed with {@link SUMMARY_MARKER} so a later compaction can
+ * anchor on it.
+ */
+export function buildSummaryTurnText(summary: string): string {
+ return `${SUMMARY_MARKER}\n\n${summary.trim()}`;
+}
diff --git a/packages/core/src/db/chunks.ts b/packages/core/src/db/chunks.ts
index e0aadf3..b434a47 100644
--- a/packages/core/src/db/chunks.ts
+++ b/packages/core/src/db/chunks.ts
@@ -225,3 +225,22 @@ export function clearChunksForTab(tabId: string): void {
const db = getDatabase();
db.query("DELETE FROM chunks WHERE tab_id = $tabId").run({ $tabId: tabId });
}
+
+/**
+ * Relocate every chunk row from one tab to another (compaction backup path).
+ *
+ * Used by conversation compaction to move the FULL pre-compaction history off
+ * the canonical tab id (`fromTabId`) onto a freshly-created backup tab id
+ * (`toTabId`), leaving the canonical id free to be re-seeded with the summary +
+ * preserved tail. `seq` values are preserved (they remain per-tab monotonic for
+ * the destination since it starts empty), as are turn ids, so the relocated
+ * history groups identically under its new tab. Returns the number of rows
+ * moved.
+ */
+export function rekeyChunks(fromTabId: string, toTabId: string): number {
+ const db = getDatabase();
+ const result = db
+ .query("UPDATE chunks SET tab_id = $to WHERE tab_id = $from")
+ .run({ $from: fromTabId, $to: toTabId });
+ return Number(result.changes ?? 0);
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 08b426f..2789b2c 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -20,6 +20,21 @@ export {
type IdentifiedMessage,
type SystemEventLike,
} from "./chunks/append.js";
+// Compaction
+export {
+ buildCompactionPrompt,
+ buildCompactionRequest,
+ buildSummaryTurnText,
+ type CompactionRequest,
+ DEFAULT_TAIL_TURNS,
+ extractPreviousSummary,
+ type HeadTailSelection,
+ renderTranscript,
+ SUMMARY_MARKER,
+ SUMMARY_TEMPLATE,
+ selectHeadTail,
+ TOOL_OUTPUT_MAX_CHARS,
+} from "./compaction/index.js";
// Config
export {
configToRuleset,
@@ -40,6 +55,7 @@ export {
getUsageStatsForTab,
groupRowsToMessages,
type MessageRow,
+ rekeyChunks,
} from "./db/chunks.js";
// Database
export { closeDatabase, getDatabase, getDatabasePath } from "./db/index.js";
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
index 607b27d..f7944c9 100644
--- a/packages/core/src/types/index.ts
+++ b/packages/core/src/types/index.ts
@@ -351,7 +351,29 @@ export type AgentEvent =
*/
reason?: "interrupt" | "continuation";
}
- | { type: "message-cancelled"; tabId: string; messageId: string };
+ | { type: "message-cancelled"; tabId: string; messageId: string }
+ /**
+ * Conversation-compaction lifecycle (UI-driven, not an agent tool). A
+ * compaction summarizes a tab's older history into an anchored summary while
+ * preserving the most recent turns verbatim.
+ *
+ * `compaction-started` fires on the temporary placeholder tab when the
+ * summary request begins. `compaction-complete` fires when the summary has
+ * been generated and the history relocated: the compacted continuation now
+ * lives on `sourceTabId` (the canonical id, with its key/model/working-dir
+ * preserved), the FULL pre-compaction history was moved to `backupTabId`, and
+ * `tempTabId` (the placeholder) should be discarded by the frontend.
+ * `compaction-error` reports a failure (or cancellation) on `tempTabId`.
+ */
+ | { type: "compaction-started"; tempTabId: string; sourceTabId: string }
+ | {
+ type: "compaction-complete";
+ tempTabId: string;
+ sourceTabId: string;
+ backupTabId: string;
+ backupTitle: string;
+ }
+ | { type: "compaction-error"; tempTabId: string; sourceTabId: string; error: string };
// ─── Tool Types ──────────────────────────────────────────────────