diff options
| author | Adam Malczewski <[email protected]> | 2026-06-22 00:36:52 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-22 00:36:52 +0900 |
| commit | c1bfd62b0c734484efcff09d6cd521acdbab2640 (patch) | |
| tree | f9194ea3214d5f15be6fed659a230997a1309ab9 /packages/transport-http/src | |
| parent | 7ff9f94c41a9870e124a50133cd74b42295ab9ac (diff) | |
| download | dispatch-c1bfd62b0c734484efcff09d6cd521acdbab2640.tar.gz dispatch-c1bfd62b0c734484efcff09d6cd521acdbab2640.zip | |
feat: conversation compacting (manual + automatic)
Implement roadmap item 10: conversation compaction to reclaim context
window without losing the thread.
Wire (0.11.0):
- Add CompactionResult type
- Add ConversationCompactedMessage WS event
Transport-contract (0.15.0):
- Add CompactResponse, CompactThresholdResponse, SetCompactThresholdRequest
- Add ConversationCompactedMessage to WsServerMessage union
- Re-export CompactionResult
Conversation-store:
- replaceHistory: delete all chunks, reset seq, append new messages
- getCompactThreshold / setCompactThreshold (per-conversation setting)
- compactThresholdKey added to keys.ts
Session-orchestrator:
- CompactionService interface + compactionHandle
- conversationCompacted hook descriptor
- createCompactionService: load history, split old/recent, call provider
to summarize, replaceHistory with [system: summary] + recent N
- Auto-trigger: resolveCompaction lazy dep, fires after turn settles
(checks threshold, non-blocking)
- Hook declared in manifest contributes.hooks + services
Transport-http:
- POST /conversations/:id/compact (manual trigger)
- GET /conversations/:id/compact-threshold (read setting)
- PUT /conversations/:id/compact-threshold (set setting)
Transport-ws:
- Subscribe to conversationCompacted hook
- Broadcast conversation.compacted WS message
CLI:
- dispatch compact <conversationId> command
FE handoff: frontend-compaction-handoff.md
Diffstat (limited to 'packages/transport-http/src')
| -rw-r--r-- | packages/transport-http/src/app.test.ts | 25 | ||||
| -rw-r--r-- | packages/transport-http/src/app.ts | 78 | ||||
| -rw-r--r-- | packages/transport-http/src/extension.ts | 5 | ||||
| -rw-r--r-- | packages/transport-http/src/seam.ts | 7 | ||||
| -rw-r--r-- | packages/transport-http/src/server.bun.test.ts | 5 |
5 files changed, 119 insertions, 1 deletions
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts index 789efce..51f791f 100644 --- a/packages/transport-http/src/app.test.ts +++ b/packages/transport-http/src/app.test.ts @@ -138,6 +138,11 @@ function createFakeConversationStore( return null; }, async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactThreshold() { + return null; + }, + async setCompactThreshold() {}, }; } @@ -859,6 +864,11 @@ describe("GET /conversations/:id", () => { return null; }, async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactThreshold() { + return null; + }, + async setCompactThreshold() {}, }; const app = createApp({ conversationStore: store, @@ -927,6 +937,11 @@ describe("GET /conversations/:id", () => { return null; }, async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactThreshold() { + return null; + }, + async setCompactThreshold() {}, }; const app = createApp({ conversationStore: store, @@ -1064,6 +1079,11 @@ describe("GET /conversations/:id/metrics", () => { return null; }, async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactThreshold() { + return null; + }, + async setCompactThreshold() {}, }; const app = createApp({ conversationStore: brokenStore, @@ -2034,6 +2054,11 @@ describe("PUT /conversations/:id/reasoning-effort", () => { return null; }, async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactThreshold() { + return null; + }, + async setCompactThreshold() {}, }; const app = createApp({ conversationStore: store, diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index e9f56c9..fd78f3e 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -1,6 +1,8 @@ import type { AgentEvent, HostAPI, Logger } from "@dispatch/kernel"; import type { CloseConversationResponse, + CompactResponse, + CompactThresholdResponse, ConversationHistoryResponse, ConversationListResponse, ConversationMetricsResponse, @@ -12,6 +14,7 @@ import type { OpenConversationResponse, QueueResponse, ReasoningEffortResponse, + SetCompactThresholdRequest, ThroughputResponse, TitleResponse, WarmResponse, @@ -36,6 +39,7 @@ import { serializeEventLine, } from "./logic.js"; import { + type CompactionService, type ConversationStore, type CredentialStore, conversationOpened, @@ -52,6 +56,7 @@ export interface CreateServerOptions { readonly orchestrator: SessionOrchestrator; readonly credentialStore: CredentialStore; readonly warmService?: WarmService; + readonly compactionService?: CompactionService; readonly lspService?: LspService; /** Optional — defaults to a no-op store (recording disabled, empty reports). */ readonly throughputStore?: ThroughputStore; @@ -683,6 +688,79 @@ export function createApp(opts: CreateServerOptions): Hono { } }); + // ─── Compaction ────────────────────────────────────────────────────────── + + app.post("/conversations/:id/compact", async (c) => { + if (opts.compactionService === undefined) { + return c.json({ error: "Compaction service not available" }, 503); + } + const conversationId = c.req.param("id"); + let body: unknown = {}; + try { + body = await c.req.json(); + } catch { + // No body is fine — use defaults. + } + const obj = body as Record<string, unknown>; + const keepLastN = + typeof obj.keepLastN === "number" && Number.isFinite(obj.keepLastN) && obj.keepLastN > 0 + ? Math.floor(obj.keepLastN) + : undefined; + const modelName = typeof obj.modelName === "string" ? obj.modelName : undefined; + + log.info("conversations: compact request", { conversationId }); + + const result = await opts.compactionService.compact(conversationId, { + ...(keepLastN !== undefined ? { keepLastN } : {}), + ...(modelName !== undefined ? { modelName } : {}), + }); + + if ("error" in result) { + log.warn("conversations: compact returned error", { + conversationId, + error: result.error, + }); + return c.json({ error: result.error }, 409); + } + + const response: CompactResponse = { + conversationId, + messagesSummarized: result.messagesSummarized, + messagesKept: result.messagesKept, + }; + return c.json(response, 200); + }); + + app.get("/conversations/:id/compact-threshold", async (c) => { + const conversationId = c.req.param("id"); + const threshold = (await opts.conversationStore.getCompactThreshold(conversationId)) ?? 0; + const response: CompactThresholdResponse = { conversationId, threshold }; + return c.json(response, 200); + }); + + app.put("/conversations/:id/compact-threshold", async (c) => { + const conversationId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + const parsed = body as SetCompactThresholdRequest; + if ( + typeof parsed.threshold !== "number" || + !Number.isFinite(parsed.threshold) || + parsed.threshold < 0 + ) { + return c.json({ error: "threshold must be a non-negative number" }, 400); + } + const threshold = Math.floor(parsed.threshold); + await opts.conversationStore.setCompactThreshold(conversationId, threshold); + log.info("conversations: compact-threshold set", { conversationId, threshold }); + const response: CompactThresholdResponse = { conversationId, threshold }; + return c.json(response, 200); + }); + // ─── Static frontend serving (catch-all, API routes take precedence) ────── if (opts.webDir !== undefined) { const webDir = opts.webDir; diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts index 3555f8e..351dd4a 100644 --- a/packages/transport-http/src/extension.ts +++ b/packages/transport-http/src/extension.ts @@ -2,6 +2,7 @@ import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; import { createApp } from "./app.js"; import { cacheWarmHandle, + compactionHandle, conversationStoreHandle, credentialStoreHandle, lspServiceHandle, @@ -30,6 +31,8 @@ export const manifest: Manifest = { "/conversations", "/conversations/:id", "/conversations/:id/close", + "/conversations/:id/compact", + "/conversations/:id/compact-threshold", "/conversations/:id/cwd", "/conversations/:id/last", "/conversations/:id/lsp", @@ -61,6 +64,7 @@ export function createTransportHttpExtension(): Extension & { const credentialStore = host.getService(credentialStoreHandle); const throughputStore = host.getService(throughputStoreHandle); const warmService = host.getService(cacheWarmHandle); + const compactionService = host.getService(compactionHandle); const lspService = host.getService(lspServiceHandle); const logger = host.logger; @@ -70,6 +74,7 @@ export function createTransportHttpExtension(): Extension & { credentialStore, throughputStore, warmService, + compactionService, lspService, logger, emit: host.emit.bind(host), diff --git a/packages/transport-http/src/seam.ts b/packages/transport-http/src/seam.ts index 1c89a34..3c507dc 100644 --- a/packages/transport-http/src/seam.ts +++ b/packages/transport-http/src/seam.ts @@ -4,9 +4,14 @@ export type { CredentialStore } from "@dispatch/credential-store"; export { credentialStoreHandle } from "@dispatch/credential-store"; export type { LspServerStatus, LspService } from "@dispatch/lsp"; export { lspServiceHandle } from "@dispatch/lsp"; -export type { SessionOrchestrator, WarmService } from "@dispatch/session-orchestrator"; +export type { + CompactionService, + SessionOrchestrator, + WarmService, +} from "@dispatch/session-orchestrator"; export { cacheWarmHandle, + compactionHandle, conversationOpened, sessionOrchestratorHandle, } from "@dispatch/session-orchestrator"; diff --git a/packages/transport-http/src/server.bun.test.ts b/packages/transport-http/src/server.bun.test.ts index a15a2c7..e770867 100644 --- a/packages/transport-http/src/server.bun.test.ts +++ b/packages/transport-http/src/server.bun.test.ts @@ -65,6 +65,11 @@ function fakeConversationStore(): ConversationStore { return null; }, async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactThreshold() { + return null; + }, + async setCompactThreshold() {}, }; } |
