feat: include compaction messages and handle new summary message types (#756)
This commit is contained in:
4
bun.lock
4
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=="],
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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}`;
|
||||
<ErrorMessage line={item} />
|
||||
) : item.kind === "status" ? (
|
||||
<StatusMessage line={item} />
|
||||
) : item.kind === "event" ? (
|
||||
<EventMessage line={item} />
|
||||
) : item.kind === "separator" ? (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{"─".repeat(columns)}</Text>
|
||||
@@ -9722,6 +9735,8 @@ Plan file path: ${planFilePath}`;
|
||||
<ErrorMessage line={ln} />
|
||||
) : ln.kind === "status" ? (
|
||||
<StatusMessage line={ln} />
|
||||
) : ln.kind === "event" ? (
|
||||
<EventMessage line={ln} />
|
||||
) : ln.kind === "command" ? (
|
||||
<CommandMessage line={ln} />
|
||||
) : ln.kind === "bash_command" ? (
|
||||
|
||||
174
src/cli/components/CompactingAnimation.tsx
Normal file
174
src/cli/components/CompactingAnimation.tsx
Normal file
@@ -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 (
|
||||
<Text bold={bold} dimColor={dimColor} wrap="truncate">
|
||||
{display}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
FanOutAnimation.displayName = "FanOutAnimation";
|
||||
|
||||
/**
|
||||
* Animated "Compacting..." text with cursor block effect.
|
||||
* Convenience wrapper around FanOutAnimation.
|
||||
*/
|
||||
export const CompactingAnimation = memo(() => {
|
||||
return <FanOutAnimation text="Compacting..." bold />;
|
||||
});
|
||||
|
||||
CompactingAnimation.displayName = "CompactingAnimation";
|
||||
122
src/cli/components/EventMessage.tsx
Normal file
122
src/cli/components/EventMessage.tsx
Normal file
@@ -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<string, unknown>;
|
||||
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 (
|
||||
<Box flexDirection="row">
|
||||
<Box width={2} flexShrink={0}>
|
||||
<Text dimColor>◆</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={rightWidth}>
|
||||
<Text dimColor>Event: {line.eventType}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const isRunning = line.phase === "running";
|
||||
|
||||
// Dot indicator based on phase
|
||||
const dotElement = isRunning ? (
|
||||
<BlinkDot color={colors.tool.running} />
|
||||
) : (
|
||||
<Text color={colors.tool.completed}>●</Text>
|
||||
);
|
||||
|
||||
// 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 (
|
||||
<Box flexDirection="column">
|
||||
{/* Main tool call line */}
|
||||
<Box flexDirection="row">
|
||||
<Box width={2} flexShrink={0}>
|
||||
{dotElement}
|
||||
</Box>
|
||||
<Box flexGrow={1} width={rightWidth}>
|
||||
{isRunning ? (
|
||||
<CompactingAnimation />
|
||||
) : (
|
||||
<Text bold>Compact({argsDisplay})</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Result section (only when finished) - matches CollapsedOutputDisplay format */}
|
||||
{!isRunning && line.summary && (
|
||||
<>
|
||||
{/* Header line with L-bracket */}
|
||||
<Box flexDirection="row">
|
||||
<Box width={5} flexShrink={0}>
|
||||
<Text dimColor>{" ⎿ "}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={Math.max(0, rightWidth - 3)}>
|
||||
<Text dimColor>{COMPACTION_SUMMARY_HEADER}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{/* Empty line for separation */}
|
||||
<Box flexDirection="row">
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
{/* Summary text - indented with 5 spaces to align */}
|
||||
<Box flexDirection="row">
|
||||
<Box width={5} flexShrink={0}>
|
||||
<Text>{" "}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={Math.max(0, rightWidth - 3)}>
|
||||
<Text dimColor wrap="wrap">
|
||||
{line.summary}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
EventMessage.displayName = "EventMessage";
|
||||
@@ -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<string, unknown>;
|
||||
// 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<string, unknown>;
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user