From 37a237ad0f17bb5a3215cecbdeb01899642ed58b Mon Sep 17 00:00:00 2001 From: jnjpng Date: Fri, 30 Jan 2026 18:19:51 -0800 Subject: [PATCH] feat: include compaction messages and handle new summary message types (#756) --- bun.lock | 4 +- package.json | 2 +- src/agent/check-approval.ts | 5 +- src/agent/message.ts | 4 +- src/cli/App.tsx | 15 ++ src/cli/components/CompactingAnimation.tsx | 174 +++++++++++++++++++++ src/cli/components/EventMessage.tsx | 122 +++++++++++++++ src/cli/helpers/accumulator.ts | 109 +++++++++++-- src/cli/helpers/backfill.ts | 87 +++++++++-- src/constants.ts | 2 +- 10 files changed, 497 insertions(+), 27 deletions(-) create mode 100644 src/cli/components/CompactingAnimation.tsx create mode 100644 src/cli/components/EventMessage.tsx diff --git a/bun.lock b/bun.lock index 9099c73..3143a5b 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@letta-ai/letta-code", "dependencies": { - "@letta-ai/letta-client": "^1.7.6", + "@letta-ai/letta-client": "^1.7.7", "glob": "^13.0.0", "ink-link": "^5.0.0", "open": "^10.2.0", @@ -91,7 +91,7 @@ "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="], - "@letta-ai/letta-client": ["@letta-ai/letta-client@1.7.6", "", {}, "sha512-C/f03uE3TJdgfHk/8rRBxzWvY0YHCYAlrePHcTd0CRHMo++0TA1OTcgiCF+EFVDVYGzfPSeMpqgAZTNvD9r9GQ=="], + "@letta-ai/letta-client": ["@letta-ai/letta-client@1.7.7", "", {}, "sha512-jPq/hpctvN7DGnOg+1yRWAMdPhWeVxh89qOhQMzhO55q6/ai/yXHJ0hoXiPbQOZWHb2kO+WwjsLROVkb5Hj8ow=="], "@types/bun": ["@types/bun@1.3.7", "", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="], diff --git a/package.json b/package.json index fea3f6f..cb1f6be 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "access": "public" }, "dependencies": { - "@letta-ai/letta-client": "^1.7.6", + "@letta-ai/letta-client": "^1.7.7", "glob": "^13.0.0", "ink-link": "^5.0.0", "open": "^10.2.0", diff --git a/src/agent/check-approval.ts b/src/agent/check-approval.ts index 4965127..311ca85 100644 --- a/src/agent/check-approval.ts +++ b/src/agent/check-approval.ts @@ -159,7 +159,10 @@ export async function getResumeData( try { const backfill = await client.conversations.messages.list( conversationId, - { limit: MESSAGE_HISTORY_LIMIT, order: "desc" }, + { + limit: MESSAGE_HISTORY_LIMIT, + order: "desc", + }, ); return { pendingApproval: null, diff --git a/src/agent/message.ts b/src/agent/message.ts index 6b7bb51..0385588 100644 --- a/src/agent/message.ts +++ b/src/agent/message.ts @@ -72,6 +72,7 @@ export async function sendMessageStream( stream_tokens: opts.streamTokens ?? true, background: opts.background ?? true, client_tools: getClientToolsFromRegistry(), + include_compaction_messages: true, }, requestOptions, ); @@ -81,10 +82,11 @@ export async function sendMessageStream( conversationId, { messages: messages, - stream: true, + streaming: true, stream_tokens: opts.streamTokens ?? true, background: opts.background ?? true, client_tools: getClientToolsFromRegistry(), + include_compaction_messages: true, }, requestOptions, ); diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 6d6cb0e..70b681f 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -117,6 +117,7 @@ import { ConversationSelector } from "./components/ConversationSelector"; import { colors } from "./components/colors"; // EnterPlanModeDialog removed - now using InlineEnterPlanModeApproval import { ErrorMessage } from "./components/ErrorMessageRich"; +import { EventMessage } from "./components/EventMessage"; import { FeedbackDialog } from "./components/FeedbackDialog"; import { HelpDialog } from "./components/HelpDialog"; import { HooksManager } from "./components/HooksManager"; @@ -1473,6 +1474,12 @@ export default function App({ newlyCommitted.push({ ...ln }); continue; } + // Events only commit when finished (they have running/finished phases) + if (ln.kind === "event" && ln.phase === "finished") { + emittedIdsRef.current.add(id); + newlyCommitted.push({ ...ln }); + continue; + } // Commands with phase should only commit when finished if (ln.kind === "command" || ln.kind === "bash_command") { if (!ln.phase || ln.phase === "finished") { @@ -9394,6 +9401,10 @@ Plan file path: ${planFilePath}`; // Always show other tool calls in progress return ln.phase !== "finished"; } + // Events (like compaction) show while running + if (ln.kind === "event") { + return ln.phase === "running"; + } if (!tokenStreamingEnabled && ln.phase === "streaming") return false; return ln.phase === "streaming"; }); @@ -9577,6 +9588,8 @@ Plan file path: ${planFilePath}`; ) : item.kind === "status" ? ( + ) : item.kind === "event" ? ( + ) : item.kind === "separator" ? ( {"─".repeat(columns)} @@ -9722,6 +9735,8 @@ Plan file path: ${planFilePath}`; ) : ln.kind === "status" ? ( + ) : ln.kind === "event" ? ( + ) : ln.kind === "command" ? ( ) : ln.kind === "bash_command" ? ( diff --git a/src/cli/components/CompactingAnimation.tsx b/src/cli/components/CompactingAnimation.tsx new file mode 100644 index 0000000..df7af0e --- /dev/null +++ b/src/cli/components/CompactingAnimation.tsx @@ -0,0 +1,174 @@ +import { Text } from "ink"; +import { memo, useEffect, useState } from "react"; + +// Default configuration +const DEFAULT_GARBAGE_CHARS = "._"; +const DEFAULT_TICK_MS = 30; +const DEFAULT_MIN_GARBAGE = 1; +const DEFAULT_MAX_GARBAGE = 2; +const DEFAULT_CURSOR = "█"; + +// Generate random garbage string +function generateGarbage(count: number, chars: string): string { + let result = ""; + for (let i = 0; i < count; i++) { + result += chars[Math.floor(Math.random() * chars.length)]; + } + return result; +} + +export interface FanOutAnimationOptions { + /** Characters to use for garbage/noise before revealing real chars */ + garbageChars?: string; + /** Milliseconds between animation frames */ + tickMs?: number; + /** Minimum garbage characters before each reveal (default 1) */ + minGarbage?: number; + /** Maximum garbage characters before each reveal (default 2) */ + maxGarbage?: number; + /** Cursor character shown at the end (default █) */ + cursor?: string; + /** Whether to show cursor after animation completes */ + showCursorOnComplete?: boolean; +} + +export interface FanOutAnimationProps extends FanOutAnimationOptions { + /** The text to animate */ + text: string; + /** Called when animation completes */ + onComplete?: () => void; + /** Text styling */ + bold?: boolean; + dimColor?: boolean; +} + +/** + * Pre-generate all animation frames at initialization. + * Follows 3-state cycle: + * 1. Cursor flush against revealed text (no garbage) + * 2. Garbage characters appear + * 3. Garbage replaced with same number of real characters + */ +function generateFrames( + text: string, + garbageChars: string, + minGarbage: number, + maxGarbage: number, + cursor: string, +): string[] { + const frames: string[] = []; + let position = 0; + + // State 1: Initial frame - just cursor + frames.push(cursor); + + while (position < text.length) { + const remaining = text.length - position; + const range = maxGarbage - minGarbage + 1; + const count = Math.min( + Math.floor(Math.random() * range) + minGarbage, + remaining, + ); + + // State 2: Garbage appears + const revealed = text.slice(0, position); + const garbage = generateGarbage(count, garbageChars); + frames.push(`${revealed}${garbage}${cursor}`); + + // State 3: Garbage replaced with real chars (same count) + position += count; + const newRevealed = text.slice(0, position); + frames.push(`${newRevealed}${cursor}`); + } + + // Final frame: complete text without cursor + frames.push(text); + + return frames; +} + +/** + * Hook for fan-out animation logic. + * Pre-computes all frames, then cycles through with a simple index. + */ +export function useFanOutAnimation( + text: string, + options: FanOutAnimationOptions = {}, + onComplete?: () => void, +): { display: string; isComplete: boolean } { + const { + garbageChars = DEFAULT_GARBAGE_CHARS, + tickMs = DEFAULT_TICK_MS, + minGarbage = DEFAULT_MIN_GARBAGE, + maxGarbage = DEFAULT_MAX_GARBAGE, + cursor = DEFAULT_CURSOR, + showCursorOnComplete = false, + } = options; + + // Pre-generate frames once on mount + const [frames] = useState(() => + generateFrames(text, garbageChars, minGarbage, maxGarbage, cursor), + ); + + // Simple index state - just increment each tick + const [frameIndex, setFrameIndex] = useState(0); + + const isComplete = frameIndex >= frames.length - 1; + + useEffect(() => { + if (isComplete) { + onComplete?.(); + return; + } + + const timer = setInterval(() => { + setFrameIndex((prev) => Math.min(prev + 1, frames.length - 1)); + }, tickMs); + + return () => clearInterval(timer); + }, [isComplete, frames.length, tickMs, onComplete]); + + const display = frames[frameIndex] ?? text; + const finalDisplay = + isComplete && !showCursorOnComplete + ? text + : isComplete && showCursorOnComplete + ? `${text}${cursor}` + : display; + + return { display: finalDisplay, isComplete }; +} + +/** + * Generic fan-out animation component. + * Characters reveal left-to-right with random garbage chars before each reveal. + */ +export const FanOutAnimation = memo( + ({ + text, + onComplete, + bold = false, + dimColor = false, + ...options + }: FanOutAnimationProps) => { + const { display } = useFanOutAnimation(text, options, onComplete); + + return ( + + {display} + + ); + }, +); + +FanOutAnimation.displayName = "FanOutAnimation"; + +/** + * Animated "Compacting..." text with cursor block effect. + * Convenience wrapper around FanOutAnimation. + */ +export const CompactingAnimation = memo(() => { + return ; +}); + +CompactingAnimation.displayName = "CompactingAnimation"; diff --git a/src/cli/components/EventMessage.tsx b/src/cli/components/EventMessage.tsx new file mode 100644 index 0000000..d677f42 --- /dev/null +++ b/src/cli/components/EventMessage.tsx @@ -0,0 +1,122 @@ +import { Box, Text } from "ink"; +import { memo } from "react"; +import { COMPACTION_SUMMARY_HEADER } from "../../constants"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; +import { BlinkDot } from "./BlinkDot.js"; +import { CompactingAnimation } from "./CompactingAnimation"; +import { colors } from "./colors.js"; + +type EventLine = { + kind: "event"; + id: string; + eventType: string; + eventData: Record; + phase: "running" | "finished"; + summary?: string; + stats?: { + trigger?: string; + contextTokensBefore?: number; + contextTokensAfter?: number; + contextWindow?: number; + messagesCountBefore?: number; + messagesCountAfter?: number; + }; +}; + +/** + * EventMessage - Displays compaction events like a tool call + * + * When running: Shows blinking dot with "Compacting..." + * When finished: Shows completed dot with summary + */ +export const EventMessage = memo(({ line }: { line: EventLine }) => { + const columns = useTerminalWidth(); + const rightWidth = Math.max(0, columns - 2); + + // Only handle compaction events for now + if (line.eventType !== "compaction") { + return ( + + + + + + Event: {line.eventType} + + + ); + } + + const isRunning = line.phase === "running"; + + // Dot indicator based on phase + const dotElement = isRunning ? ( + + ) : ( + + ); + + // Format the args display (message count or fallback) + const formatArgs = (): string => { + const stats = line.stats; + if ( + stats?.messagesCountBefore !== undefined && + stats?.messagesCountAfter !== undefined + ) { + return `${stats.messagesCountBefore} → ${stats.messagesCountAfter} messages`; + } + return "..."; + }; + + const argsDisplay = formatArgs(); + + return ( + + {/* Main tool call line */} + + + {dotElement} + + + {isRunning ? ( + + ) : ( + Compact({argsDisplay}) + )} + + + + {/* Result section (only when finished) - matches CollapsedOutputDisplay format */} + {!isRunning && line.summary && ( + <> + {/* Header line with L-bracket */} + + + {" ⎿ "} + + + {COMPACTION_SUMMARY_HEADER} + + + {/* Empty line for separation */} + + + + {/* Summary text - indented with 5 spaces to align */} + + + {" "} + + + + {line.summary} + + + + + )} + + ); +}); + +EventMessage.displayName = "EventMessage"; diff --git a/src/cli/helpers/accumulator.ts b/src/cli/helpers/accumulator.ts index ecc0214..ebb86c2 100644 --- a/src/cli/helpers/accumulator.ts +++ b/src/cli/helpers/accumulator.ts @@ -5,10 +5,7 @@ // - Exposes `onChunk` to feed SDK events and `toLines` to render. import type { LettaStreamingResponse } from "@letta-ai/letta-client/resources/agents/messages"; -import { - COMPACTION_SUMMARY_HEADER, - INTERRUPTED_BY_USER, -} from "../../constants"; +import { INTERRUPTED_BY_USER } from "../../constants"; import { runPostToolUseHooks, runPreToolUseHooks } from "../../hooks"; import { extractCompactionSummary } from "./backfill"; import { findLastSafeSplitPoint } from "./markdownSplit"; @@ -136,6 +133,23 @@ export type Line = streaming?: StreamingState; } | { kind: "error"; id: string; text: string } + | { + kind: "event"; + id: string; + eventType: string; + eventData: Record; + // Compaction events have additional fields populated when summary_message arrives + phase: "running" | "finished"; + summary?: string; + stats?: { + trigger?: string; + contextTokensBefore?: number; + contextTokensAfter?: number; + contextWindow?: number; + messagesCountBefore?: number; + messagesCountAfter?: number; + }; + } | { kind: "command"; id: string; @@ -522,14 +536,17 @@ export function onChunk(b: Buffers, chunk: LettaStreamingResponse) { const rawText = extractTextPart(chunk.content); if (!rawText) break; - // Check if this is a compaction summary message + // Check if this is a compaction summary message (old format embedded in user_message) const compactionSummary = extractCompactionSummary(rawText); if (compactionSummary) { - // Render as a user message with context header and summary + // Render as a finished compaction event ensure(b, id, () => ({ - kind: "user", + kind: "event", id, - text: `${COMPACTION_SUMMARY_HEADER}\n\n${compactionSummary}`, + eventType: "compaction", + eventData: {}, + phase: "finished", + summary: compactionSummary, })); } // If not a summary, ignore it (user messages aren't rendered during streaming) @@ -804,8 +821,80 @@ export function onChunk(b: Buffers, chunk: LettaStreamingResponse) { break; } - default: - break; // ignore ping/etc + default: { + // Handle new compaction message types (when include_compaction_messages=true) + // These are not yet in the SDK types, so we handle them via string comparison + const msgType = chunk.message_type as string | undefined; + + if (msgType === "summary_message") { + // Use otid if available, fall back to id + const summaryChunk = chunk as LettaStreamingResponse & { + id?: string; + otid?: string; + summary?: string; + compaction_stats?: { + trigger?: string; + context_tokens_before?: number; + context_tokens_after?: number; + context_window?: number; + messages_count_before?: number; + messages_count_after?: number; + }; + }; + const summaryText = summaryChunk.summary || ""; + const stats = summaryChunk.compaction_stats; + + // Find the most recent compaction event line and update it with summary and stats + for (let i = b.order.length - 1; i >= 0; i--) { + const orderId = b.order[i]; + if (!orderId) continue; + const line = b.byId.get(orderId); + if (line?.kind === "event" && line.eventType === "compaction") { + line.phase = "finished"; + line.summary = summaryText; + if (stats) { + line.stats = { + trigger: stats.trigger, + contextTokensBefore: stats.context_tokens_before, + contextTokensAfter: stats.context_tokens_after, + contextWindow: stats.context_window, + messagesCountBefore: stats.messages_count_before, + messagesCountAfter: stats.messages_count_after, + }; + } + break; + } + } + break; + } + + if (msgType === "event_message") { + // Use otid if available, fall back to id + const eventChunk = chunk as LettaStreamingResponse & { + id?: string; + otid?: string; + event_type?: string; + event_data?: Record; + }; + const id = eventChunk.otid || eventChunk.id; + if (!id) break; + + // Handle otid transition (mark previous line as finished) + handleOtidTransition(b, id); + + ensure(b, id, () => ({ + kind: "event", + id, + eventType: eventChunk.event_type || "unknown", + eventData: eventChunk.event_data || {}, + phase: "running", + })); + break; + } + + // ignore ping/etc + break; + } } } diff --git a/src/cli/helpers/backfill.ts b/src/cli/helpers/backfill.ts index 2e55e64..0f10b55 100644 --- a/src/cli/helpers/backfill.ts +++ b/src/cli/helpers/backfill.ts @@ -5,11 +5,7 @@ import type { Message, TextContent, } from "@letta-ai/letta-client/resources/agents/messages"; -import { - COMPACTION_SUMMARY_HEADER, - SYSTEM_REMINDER_CLOSE, - SYSTEM_REMINDER_OPEN, -} from "../../constants"; +import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "../../constants"; import type { Buffers } from "./accumulator"; /** @@ -183,15 +179,18 @@ export function backfillBuffers(buffers: Buffers, history: Message[]): void { case "user_message": { const rawText = renderUserContentParts(msg.content); - // Check if this is a compaction summary message (system_alert with summary) + // Check if this is a compaction summary message (old format embedded in user_message) const compactionSummary = extractCompactionSummary(rawText); if (compactionSummary) { - // Render as a user message with context header and summary + // Render as a finished compaction event const exists = buffers.byId.has(lineId); buffers.byId.set(lineId, { - kind: "user", + kind: "event", id: lineId, - text: `${COMPACTION_SUMMARY_HEADER}\n\n${compactionSummary}`, + eventType: "compaction", + eventData: {}, + phase: "finished", + summary: compactionSummary, }); if (!exists) buffers.order.push(lineId); break; @@ -336,8 +335,74 @@ export function backfillBuffers(buffers: Buffers, history: Message[]): void { break; } - default: - break; // ignore other message types + default: { + // Handle new compaction message types (when include_compaction_messages=true) + // These are not yet in the SDK types, so we handle them via string comparison + const msgType = msg.message_type as string | undefined; + + if (msgType === "summary_message") { + // SummaryMessage has: summary (str), compaction_stats (optional) + const summaryMsg = msg as Message & { + summary?: string; + compaction_stats?: { + trigger?: string; + context_tokens_before?: number; + context_tokens_after?: number; + context_window?: number; + messages_count_before?: number; + messages_count_after?: number; + }; + }; + + const summaryText = summaryMsg.summary || ""; + const stats = summaryMsg.compaction_stats; + + // Find the most recent compaction event line and update it with summary and stats + for (let i = buffers.order.length - 1; i >= 0; i--) { + const orderId = buffers.order[i]; + if (!orderId) continue; + const line = buffers.byId.get(orderId); + if (line?.kind === "event" && line.eventType === "compaction") { + line.phase = "finished"; + line.summary = summaryText; + if (stats) { + line.stats = { + trigger: stats.trigger, + contextTokensBefore: stats.context_tokens_before, + contextTokensAfter: stats.context_tokens_after, + contextWindow: stats.context_window, + messagesCountBefore: stats.messages_count_before, + messagesCountAfter: stats.messages_count_after, + }; + } + break; + } + } + break; + } + + if (msgType === "event_message") { + // EventMessage has: event_type (str), event_data (dict) + const eventMsg = msg as Message & { + event_type?: string; + event_data?: Record; + }; + + const exists = buffers.byId.has(lineId); + buffers.byId.set(lineId, { + kind: "event", + id: lineId, + eventType: eventMsg.event_type || "unknown", + eventData: eventMsg.event_data || {}, + phase: "finished", // In backfill, events are always finished (summary already processed) + }); + if (!exists) buffers.order.push(lineId); + break; + } + + // ignore other message types + break; + } } } diff --git a/src/constants.ts b/src/constants.ts index dad6023..1d675a9 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -34,7 +34,7 @@ export const MEMFS_CONFLICT_CHECK_INTERVAL = 5; * Header displayed before compaction summary when conversation context is truncated */ export const COMPACTION_SUMMARY_HEADER = - "This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation."; + "(Earlier messages in this conversation have been compacted to free up context, summarized below)"; /** * Status bar thresholds - only show indicators when values exceed these