summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--frontend-compaction-handoff.md118
-rw-r--r--packages/conversation-store/src/store.ts14
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts13
-rw-r--r--packages/transport-contract/src/index.ts4
-rw-r--r--packages/transport-http/src/app.ts2
-rw-r--r--packages/transport-ws/src/extension.ts4
-rw-r--r--packages/wire/src/index.ts4
7 files changed, 88 insertions, 71 deletions
diff --git a/frontend-compaction-handoff.md b/frontend-compaction-handoff.md
index e074610..6979c29 100644
--- a/frontend-compaction-handoff.md
+++ b/frontend-compaction-handoff.md
@@ -4,12 +4,41 @@ Courier this to `../dispatch-web`. All changes are ADDITIVE.
## What shipped (backend)
-Conversation compaction: summarize old history and replace it with a summary +
-retain the most recent N messages. Two modes:
+Conversation compaction: summarize old history into a summary + recent N,
+preserving the full pre-compaction history in a separate archive conversation.
+Creates a linked chain of archives you can walk backward.
+
+Two modes:
- **Manual**: `POST /conversations/:id/compact` — triggers immediately.
- **Automatic**: after each turn settles, the backend checks if the last turn's
- input tokens exceeded the per-conversation `compactThreshold`. If so,
- compaction runs automatically (fire-and-forget, non-blocking).
+ input tokens exceeded the per-conversation `compactThreshold` (default 350000).
+ If so, compaction runs automatically (fire-and-forget, non-blocking).
+
+## How compaction works — non-destructive, chained
+
+The compacted conversation **keeps its original ID** (so messaging between
+agents still works). The old full history is **forked** to a new archive
+conversation (new UUID). The archive inherits the source's `compactedFrom`,
+creating a chain:
+
+```
+Compaction 1: A (ID "abc") — full history forked to X (new ID).
+ A's history replaced with [summary + recent N].
+ A.compactedFrom = X
+
+Compaction 2: A (ID "abc") — current history forked to Y (new ID).
+ A's history replaced with [new summary + recent N].
+ A.compactedFrom = Y
+ Y.compactedFrom = X (inherited from A's pre-compaction state)
+
+Chain: A → Y → X (walk compactedFrom backward)
+```
+
+Each archive is an **immutable snapshot** — a complete copy of the conversation
+at the time of that compaction. History is never destroyed.
+
+The FE **does not switch tabs** — the conversation ID doesn't change. Just
+reload the history.
## Bump pinned deps
- `@dispatch/wire` → `0.11.0`
@@ -32,7 +61,7 @@ export interface ConversationMeta {
// @dispatch/wire
export interface CompactionResult {
readonly summary: string;
- readonly archiveId: string; // ID of the archive conversation with full pre-compaction history
+ readonly newConversationId: string; // ID of the archive (old full history)
readonly messagesSummarized: number;
readonly messagesKept: number;
}
@@ -40,8 +69,8 @@ export interface CompactionResult {
// @dispatch/transport-contract — WS message (server → client)
export interface ConversationCompactedMessage {
readonly type: "conversation.compacted";
- readonly conversationId: string;
- readonly archiveId: string; // ID of the archive conversation
+ readonly conversationId: string; // the conversation (ID unchanged)
+ readonly newConversationId: string; // the archive ID (old full history)
readonly messagesSummarized: number;
readonly messagesKept: number;
}
@@ -49,15 +78,15 @@ export interface ConversationCompactedMessage {
// @dispatch/transport-contract — HTTP response types
export interface CompactResponse {
- readonly conversationId: string;
- readonly archiveId: string; // ID of the archive conversation
+ readonly conversationId: string; // the conversation (ID unchanged)
+ readonly newConversationId: string; // the archive ID (old full history)
readonly messagesSummarized: number;
readonly messagesKept: number;
}
export interface CompactThresholdResponse {
readonly conversationId: string;
- readonly threshold: number; // 0 = manual only
+ readonly threshold: number; // 0 = manual only; null = default 350000
}
export interface SetCompactThresholdRequest {
@@ -72,11 +101,13 @@ Triggers compaction on demand. Optional JSON body:
{ "keepLastN": 10, "modelName": "umans/umans-glm-5.2" }
```
- `keepLastN` (default 10): how many recent messages to retain.
-- `modelName`: override the model used for summarization (defaults to the
- conversation's provider).
+- `modelName`: override the model used for summarization.
-200 response: `CompactResponse`
-409 response: `{ error: string }` — conversation is generating, too short, etc.
+200 response: `CompactResponse` — includes `newConversationId` (the archive ID).
+The conversation ID in the response is the same as the request — the ID doesn't
+change. The FE should reload the conversation history.
+
+409: `{ error: string }` — conversation is generating, too short, threshold not exceeded, etc.
503: compaction service not available.
## `GET /conversations/:id/compact-threshold` — read threshold
@@ -95,63 +126,42 @@ Body: `SetCompactThresholdRequest { threshold: number }`
- Any positive number sets the trigger threshold.
- To "reset to default", set it to 350000.
-200: `CompactThresholdResponse`
-
## `conversation.compacted` WS message
Broadcast to all connected WS clients when compaction completes. The FE should
-reload the conversation history via `GET /conversations/:id` to reflect the
-compacted state (the old messages are replaced by a system summary + recent N).
-
-## How compaction works (backend) — non-destructive
-
-1. Load full conversation history.
-2. Split: old messages (to summarize) + recent N (to keep, default 10).
-3. **Fork** the full pre-compaction history to a new archive conversation
- (new UUID). The archive gets `status: "closed"`, title `"Archive: <original>"`,
- and `compactedFrom: <originalId>`.
-4. Call the model with a summarization system prompt + old messages.
-5. **Replace** the original conversation's history with:
- `[system: "Summary of previous conversation: ..."] + recent N messages`.
-6. Set `compactedFrom: <archiveId>` on the original conversation's metadata.
-7. Emit `conversationCompacted` hook → WS broadcast (includes `archiveId`).
-
-**The original history is never destroyed.** The archive conversation is a
-complete copy of the pre-compaction state, accessible via `GET /conversations/:id`
-using the archive ID. The FE can link to it from the compacted conversation.
-
-`ConversationMeta` now has an optional `compactedFrom?: string` field. On a
-compacted conversation, it points to the archive ID. On the archive, it points
-to the original conversation ID. The FE can use this to show a "View full
-history" link.
+**reload the conversation history** via `GET /conversations/:id` (the
+conversation ID hasn't changed — just reload the same ID). The first message
+will now be a system summary.
+
+No tab switching needed — the ID is the same.
## What the FE needs to do
1. **Compact button** in the conversation toolbar → `POST /conversations/:id/compact`.
- Show a loading indicator while waiting for the response.
+ Show a loading indicator while waiting. On success, reload the conversation
+ history (same ID — just re-fetch).
2. **Settings UI** for compact threshold: `PUT /conversations/:id/compact-threshold`
with `{ threshold: number }`. A number input (0 = manual only, default 350000).
Read the current value via `GET /conversations/:id/compact-threshold`.
3. **Handle `conversation.compacted` WS messages**: reload the conversation
- history via `GET /conversations/:id`. The first message will now be a system
- summary instead of the original conversation.
+ history via `GET /conversations/:id` (same ID, no tab switch).
-4. **"View full history" link**: when `ConversationMeta.compactedFrom` is present,
- show a link/badge that opens the archive conversation (the `compactedFrom`
- value is the archive conversation ID). Load it via `GET /conversations/:id`
- with that ID. The archive has `status: "closed"` and title `"Archive: <original>"`.
+4. **"View predecessor" link**: when `ConversationMeta.compactedFrom` is present,
+ show a link that opens the archive conversation in a read-only view (or a new
+ tab). Load it via `GET /conversations/:compactedFrom`. The archive has
+ `status: "closed"` and title `"Archive: <original>"`. Each archive may also
+ have its own `compactedFrom` — walk the chain backward to see every snapshot.
-5. **Archives in conversation list**: archives appear in `GET /conversations?status=closed`.
- They have `compactedFrom` pointing to the original conversation. The FE can
- show them in a history/archive view, distinct from active/idle tabs.
+5. **Archives in conversation list**: archives appear in
+ `GET /conversations?status=closed`. They have `compactedFrom` chaining to
+ the previous archive (if any). The FE can show them in a history view.
-6. **Visual indicator**: show a badge or divider indicating the conversation
- has been compacted (e.g. "N messages summarized" from the WS payload or the
- HTTP response).
+6. **Visual indicator**: show a badge on conversations that have a
+ `compactedFrom` (they've been compacted). E.g. "Compacted" badge or chain icon.
## CLI
`dispatch compact <conversationId>` — triggers manual compaction. Resolves
-short IDs like other commands.
+short IDs like other commands. The response includes the archive ID.
diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts
index d713de3..700be1e 100644
--- a/packages/conversation-store/src/store.ts
+++ b/packages/conversation-store/src/store.ts
@@ -113,7 +113,7 @@ export interface ConversationStore {
* Set the `compactedFrom` field on a conversation's metadata, pointing to
* the archive conversation that holds the pre-compaction history.
*/
- readonly setCompactedFrom: (conversationId: string, archiveId: string) => Promise<void>;
+ readonly setCompactedFrom: (conversationId: string, newConversationId: string) => Promise<void>;
}
export const conversationStoreHandle = defineService<ConversationStore>("conversation-store/store");
@@ -592,7 +592,9 @@ export function createConversationStore(
}
await storage.set(seqKey(targetId), String(Math.max(seq - 1, 0)));
- // Copy metadata with archive title + closed status + compactedFrom.
+ // Copy metadata with archive title + closed status.
+ // Inherit compactedFrom from the source so archives chain:
+ // A → Y → X (each archive points to the previous one).
const metaRaw = await storage.get(metaKey(sourceId));
if (metaRaw !== null) {
const existing = parseMetaRow(metaRaw);
@@ -602,7 +604,9 @@ export function createConversationStore(
lastActivityAt: existing.lastActivityAt,
title: `Archive: ${existing.title}`,
status: "closed",
- compactedFrom: sourceId,
+ ...(existing.compactedFrom !== undefined
+ ? { compactedFrom: existing.compactedFrom }
+ : {}),
};
await storage.set(metaKey(targetId), JSON.stringify(row));
}
@@ -630,7 +634,7 @@ export function createConversationStore(
}
},
- async setCompactedFrom(conversationId, archiveId) {
+ async setCompactedFrom(conversationId, newConversationId) {
const raw = await storage.get(metaKey(conversationId));
const existing = raw !== null ? parseMetaRow(raw) : null;
const ts = now();
@@ -642,7 +646,7 @@ export function createConversationStore(
};
await storage.set(
metaKey(conversationId),
- JSON.stringify({ ...row, compactedFrom: archiveId }),
+ JSON.stringify({ ...row, compactedFrom: newConversationId }),
);
},
};
diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts
index 21c068c..b46ecc1 100644
--- a/packages/session-orchestrator/src/orchestrator.ts
+++ b/packages/session-orchestrator/src/orchestrator.ts
@@ -132,7 +132,7 @@ export const conversationStatusChanged: EventHookDescriptor<ConversationStatusCh
/** Payload for the conversationCompacted bus event. */
export interface ConversationCompactedPayload {
readonly conversationId: string;
- readonly archiveId: string;
+ readonly newConversationId: string;
readonly messagesSummarized: number;
readonly messagesKept: number;
}
@@ -802,8 +802,11 @@ export function createCompactionService(
return { error: "model produced empty summary" };
}
- // Non-destructive: fork the full pre-compaction history to an
- // archive conversation before replacing it.
+ // Non-destructive: fork the full pre-compaction history to a new
+ // archive conversation. The original conversation keeps its ID
+ // (so messaging between agents still works) and gets the compacted
+ // content. The archive inherits the original's compactedFrom,
+ // creating a chain: A → Y → X → ...
const archiveId = crypto.randomUUID();
await deps.conversationStore.forkHistory(conversationId, archiveId);
@@ -823,14 +826,14 @@ export function createCompactionService(
const result: CompactionResult = {
summary,
- archiveId,
+ newConversationId: archiveId,
messagesSummarized: toSummarize.length,
messagesKept: toKeep.length,
};
deps.emit(conversationCompacted, {
conversationId,
- archiveId,
+ newConversationId: archiveId,
messagesSummarized: toSummarize.length,
messagesKept: toKeep.length,
});
diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts
index 2316c96..5745672 100644
--- a/packages/transport-contract/src/index.ts
+++ b/packages/transport-contract/src/index.ts
@@ -521,7 +521,7 @@ export interface ConversationStatusChangedMessage {
export interface ConversationCompactedMessage {
readonly type: "conversation.compacted";
readonly conversationId: string;
- readonly archiveId: string;
+ readonly newConversationId: string;
readonly messagesSummarized: number;
readonly messagesKept: number;
}
@@ -577,7 +577,7 @@ export interface TitleResponse {
*/
export interface CompactResponse {
readonly conversationId: string;
- readonly archiveId: string;
+ readonly newConversationId: string;
readonly messagesSummarized: number;
readonly messagesKept: number;
}
diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts
index 233e30e..64e46fd 100644
--- a/packages/transport-http/src/app.ts
+++ b/packages/transport-http/src/app.ts
@@ -725,7 +725,7 @@ export function createApp(opts: CreateServerOptions): Hono {
const response: CompactResponse = {
conversationId,
- archiveId: result.archiveId,
+ newConversationId: result.newConversationId,
messagesSummarized: result.messagesSummarized,
messagesKept: result.messagesKept,
};
diff --git a/packages/transport-ws/src/extension.ts b/packages/transport-ws/src/extension.ts
index abc69e7..b42f434 100644
--- a/packages/transport-ws/src/extension.ts
+++ b/packages/transport-ws/src/extension.ts
@@ -153,11 +153,11 @@ export function createTransportWsExtension(): Extension {
disposers.push(
host.on(
conversationCompacted,
- ({ conversationId, archiveId, messagesSummarized, messagesKept }) => {
+ ({ conversationId, newConversationId, messagesSummarized, messagesKept }) => {
broadcast({
type: "conversation.compacted",
conversationId,
- archiveId,
+ newConversationId,
messagesSummarized,
messagesKept,
});
diff --git a/packages/wire/src/index.ts b/packages/wire/src/index.ts
index 8c85f89..4ab8825 100644
--- a/packages/wire/src/index.ts
+++ b/packages/wire/src/index.ts
@@ -535,12 +535,12 @@ export interface ConversationMeta {
* Result of a compaction operation. `summary` is the text the model produced;
* `messagesKept` is how many recent messages were retained after the summary;
* `messagesSummarized` is how many old messages were replaced by the summary.
- * `archiveId` is the ID of the new conversation that holds the full
+ * `newConversationId` is the ID of the new conversation that holds the full
* pre-compaction history (non-destructive — the original history is preserved).
*/
export interface CompactionResult {
readonly summary: string;
- readonly archiveId: string;
+ readonly newConversationId: string;
readonly messagesSummarized: number;
readonly messagesKept: number;
}