diff options
| author | Adam Malczewski <[email protected]> | 2026-06-03 01:26:16 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-03 01:26:16 +0900 |
| commit | b26821ead97b986f886065b20d3dbde8283daa64 (patch) | |
| tree | 3c41419afb805d88080392e1c97d233af910b79d /packages/api/src/routes | |
| parent | 4b45d33c256cf580a53054078be6fd7148fa6302 (diff) | |
| download | dispatch-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/api/src/routes')
| -rw-r--r-- | packages/api/src/routes/tabs.ts | 57 |
1 files changed, 54 insertions, 3 deletions
diff --git a/packages/api/src/routes/tabs.ts b/packages/api/src/routes/tabs.ts index 28a89f1..2ae60ed 100644 --- a/packages/api/src/routes/tabs.ts +++ b/packages/api/src/routes/tabs.ts @@ -19,11 +19,18 @@ import { Hono } from "hono"; export const tabsRoutes = new Hono(); -let getAgentManager: () => { stopTab(id: string): void; deleteTab(id: string): void } | null = () => - null; +let getAgentManager: () => { + stopTab(id: string): void; + deleteTab(id: string): void; + compactTab(tempTabId: string, sourceTabId: string): Promise<void>; +} | null = () => null; export function setTabsAgentManager( - getter: () => { stopTab(id: string): void; deleteTab(id: string): void } | null, + getter: () => { + stopTab(id: string): void; + deleteTab(id: string): void; + compactTab(tempTabId: string, sourceTabId: string): Promise<void>; + } | null, ): void { getAgentManager = getter; } @@ -64,6 +71,28 @@ tabsRoutes.put("/settings/title-model", async (c) => { return c.json({ success: true }); }); +// Conversation-compaction model (key+model used to generate the summary). +// Mirrors the title-model setting. When unset, compaction falls back to the +// source tab's own key+model. +tabsRoutes.get("/settings/compaction-model", (c) => { + const keyId = getSetting("compaction_model_key_id"); + const modelId = getSetting("compaction_model_id"); + return c.json({ keyId, modelId }); +}); + +tabsRoutes.put("/settings/compaction-model", async (c) => { + const body = await c.req.json<{ keyId?: string | null; modelId?: string | null }>(); + if (body.keyId !== undefined) { + if (body.keyId) setSetting("compaction_model_key_id", body.keyId); + else deleteSetting("compaction_model_key_id"); + } + if (body.modelId !== undefined) { + if (body.modelId) setSetting("compaction_model_id", body.modelId); + else deleteSetting("compaction_model_id"); + } + return c.json({ success: true }); +}); + // Reorder open tabs. Body `{ ids }` is the new left-to-right order of tab ids; // each tab's `position` is rewritten to its index. Must be declared before the // `/:id` routes so "reorder" isn't captured as an id param. @@ -134,6 +163,28 @@ tabsRoutes.get("/:id/chunks", (c) => { return c.json({ chunks, total, oldestSeq }); }); +// Trigger conversation compaction. The `:id` is the TRANSIENT placeholder tab +// hosting the "compacting…" UI; `sourceTabId` (body) is the conversation being +// compacted. Fire-and-forget on the server: progress/outcome is delivered via +// the `compaction-*` WS events. Returns 202 once the run is kicked off. +tabsRoutes.post("/:id/compact", async (c) => { + const tempTabId = c.req.param("id"); + const body = await c.req + .json<{ sourceTabId?: string }>() + .catch(() => ({}) as { sourceTabId?: string }); + const sourceTabId = body.sourceTabId; + if (!sourceTabId || typeof sourceTabId !== "string") { + return c.json({ error: "sourceTabId is required" }, 400); + } + const mgr = getAgentManager(); + if (!mgr) return c.json({ error: "agent manager unavailable" }, 503); + // Run in the background; outcome is emitted over WS. + void mgr.compactTab(tempTabId, sourceTabId).catch((err) => { + console.error(`[dispatch] compactTab error for ${sourceTabId}:`, err); + }); + return c.json({ success: true }, 202); +}); + tabsRoutes.patch("/:id", async (c) => { const id = c.req.param("id"); const body = await c.req.json<{ |
