summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-http/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-22 00:36:52 +0900
committerAdam Malczewski <[email protected]>2026-06-22 00:36:52 +0900
commitc1bfd62b0c734484efcff09d6cd521acdbab2640 (patch)
treef9194ea3214d5f15be6fed659a230997a1309ab9 /packages/transport-http/src
parent7ff9f94c41a9870e124a50133cd74b42295ab9ac (diff)
downloaddispatch-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.ts25
-rw-r--r--packages/transport-http/src/app.ts78
-rw-r--r--packages/transport-http/src/extension.ts5
-rw-r--r--packages/transport-http/src/seam.ts7
-rw-r--r--packages/transport-http/src/server.bun.test.ts5
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() {},
};
}