summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/compaction
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
committerAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
commit394f1ed37ce860da6fdc385769bf29f9737105cd (patch)
tree4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/core/src/compaction
parent81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff)
downloaddispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.tar.gz
dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.zip
chore: genesis — remove all files to rebuild from scratch (arch rewrite)
Diffstat (limited to 'packages/core/src/compaction')
-rw-r--r--packages/core/src/compaction/index.ts245
1 files changed, 0 insertions, 245 deletions
diff --git a/packages/core/src/compaction/index.ts b/packages/core/src/compaction/index.ts
deleted file mode 100644
index 663ccb2..0000000
--- a/packages/core/src/compaction/index.ts
+++ /dev/null
@@ -1,245 +0,0 @@
-// 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()}`;
-}