From b26821ead97b986f886065b20d3dbde8283daa64 Mon Sep 17 00:00:00 2001
From: Adam Malczewski <github@tradam.dev>
Date: Wed, 3 Jun 2026 01:26:16 +0900
Subject: feat(compaction): add UI-driven conversation compaction
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

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.
---
 .../src/lib/components/ModelSelector.svelte        | 24 ++++++++++++++++++++++
 1 file changed, 24 insertions(+)

(limited to 'packages/frontend/src/lib/components/ModelSelector.svelte')

diff --git a/packages/frontend/src/lib/components/ModelSelector.svelte b/packages/frontend/src/lib/components/ModelSelector.svelte
index 8601795..3b43ff8 100644
--- a/packages/frontend/src/lib/components/ModelSelector.svelte
+++ b/packages/frontend/src/lib/components/ModelSelector.svelte
@@ -63,6 +63,9 @@ const modelCache = new Map<string, string[]>();
 		onReasoningChange,
 		onAgentChange = (_agent: AgentInfo | null) => {},
 		onWorkingDirectoryChange = (_dir: string | null) => {},
+		onCompact = () => {},
+		canCompact = false,
+		compacting = false,
 	}: {
 		keys?: KeyInfo[];
 		activeKeyId?: string | null;
@@ -77,6 +80,9 @@ const modelCache = new Map<string, string[]>();
 		onReasoningChange: (effort: string) => void;
 		onAgentChange?: (agent: AgentInfo | null) => void;
 		onWorkingDirectoryChange?: (dir: string | null) => void;
+		onCompact?: () => void;
+		canCompact?: boolean;
+		compacting?: boolean;
 	} = $props();
 
 	let showKeyModal = $state(false);
@@ -224,6 +230,24 @@ const modelCache = new Map<string, string[]>();
 		</div>
 	</div>
 
+	<!-- Compact conversation -->
+	<div class="mb-3">
+		<button
+			type="button"
+			class="btn btn-sm btn-outline w-full"
+			disabled={!canCompact || compacting}
+			onclick={onCompact}
+			title="Summarize older turns into a compact anchor, preserving the most recent turns. Opens a new tab while it works; the conversation continues here once done."
+		>
+			{#if compacting}
+				<span class="loading loading-spinner loading-xs"></span>
+				Compacting…
+			{:else}
+				Compact conversation
+			{/if}
+		</button>
+	</div>
+
 	<!-- Toggle -->
 	<div class="flex items-center gap-2 mb-3">
 		<button
-- 
cgit v1.2.3

