summaryrefslogtreecommitdiffhomepage
path: root/packages/api/src/routes
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/api/src/routes
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/api/src/routes')
-rw-r--r--packages/api/src/routes/tabs.ts57
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<{