From c1bfd62b0c734484efcff09d6cd521acdbab2640 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 22 Jun 2026 00:36:52 +0900 Subject: 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 command FE handoff: frontend-compaction-handoff.md --- packages/conversation-store/src/keys.ts | 4 +++ packages/conversation-store/src/store.ts | 42 ++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) (limited to 'packages/conversation-store/src') diff --git a/packages/conversation-store/src/keys.ts b/packages/conversation-store/src/keys.ts index 9b7c2cc..a609f82 100644 --- a/packages/conversation-store/src/keys.ts +++ b/packages/conversation-store/src/keys.ts @@ -54,6 +54,10 @@ export function reasoningEffortKey(conversationId: string): string { return `conv:${conversationId}:reasoning-effort`; } +export function compactThresholdKey(conversationId: string): string { + return `conv:${conversationId}:compact-threshold`; +} + export function metaKey(conversationId: string): string { return `conv:${conversationId}:meta`; } diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts index 8d78df8..2f9f475 100644 --- a/packages/conversation-store/src/store.ts +++ b/packages/conversation-store/src/store.ts @@ -15,6 +15,7 @@ import { CONVERSATION_INDEX_KEY, chunkKey, chunkPrefix, + compactThresholdKey, cwdKey, metaKey, metricsKey, @@ -86,6 +87,20 @@ export interface ConversationStore { conversationId: string, status: ConversationStatus, ) => Promise; + /** + * Replace the entire conversation history with the given messages. Deletes + * all existing chunks, resets the seq counter, and appends the new messages. + * Used by compaction to replace old history with a summary + recent messages. + * Metadata (createdAt, title, status) is preserved. + */ + readonly replaceHistory: ( + conversationId: string, + messages: readonly ChatMessage[], + ) => Promise; + /** Get the compact threshold (token count, 0 = manual only), or null if unset. */ + readonly getCompactThreshold: (conversationId: string) => Promise; + /** Set the compact threshold (token count, 0 = manual only). */ + readonly setCompactThreshold: (conversationId: string, threshold: number) => Promise; } export const conversationStoreHandle = defineService("conversation-store/store"); @@ -529,5 +544,32 @@ export function createConversationStore( }; await storage.set(metaKey(conversationId), JSON.stringify(row)); }, + + async replaceHistory(conversationId, messages) { + // Delete all existing chunks. + const keys = await storage.keys(chunkPrefix(conversationId)); + for (const k of keys) { + await storage.delete(k); + } + // Reset the seq counter so the new messages start from seq 1. + await storage.set(seqKey(conversationId), "0"); + // Append the new messages (re-uses the append logic for seq + // numbering + metadata upsert). + await this.append(conversationId, messages); + }, + + async getCompactThreshold(conversationId) { + const raw = await storage.get(compactThresholdKey(conversationId)); + if (raw === null) return null; + const n = Number.parseInt(raw, 10); + return Number.isNaN(n) ? null : n; + }, + + async setCompactThreshold(conversationId, threshold) { + await storage.set(compactThresholdKey(conversationId), String(threshold)); + if (logger !== undefined) { + logger.debug("compact-threshold set", { conversationId, threshold }); + } + }, }; } -- cgit v1.2.3