From 9ea10bf2ffd6a84dc2581b605bc90c355866f350 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Wed, 11 Feb 2026 19:18:22 -0800 Subject: [PATCH] feat: add client-side sleeptime settings + compaction reflection triggers (#923) --- src/cli/App.tsx | 138 +++++++++ src/cli/commands/registry.ts | 8 + src/cli/components/SleeptimeSelector.tsx | 372 +++++++++++++++++++++++ src/cli/helpers/accumulator.ts | 31 +- src/cli/helpers/contextTracker.ts | 6 + src/cli/helpers/memoryReminder.ts | 221 ++++++++++++-- src/headless.ts | 3 - src/index.ts | 14 +- src/settings-manager.ts | 15 +- src/tests/cli/accumulator-usage.test.ts | 33 ++ src/tests/cli/contextTracker.test.ts | 28 ++ src/tests/cli/memoryReminder.test.ts | 147 +++++++++ 12 files changed, 975 insertions(+), 41 deletions(-) create mode 100644 src/cli/components/SleeptimeSelector.tsx create mode 100644 src/tests/cli/contextTracker.test.ts create mode 100644 src/tests/cli/memoryReminder.test.ts diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 55da497..f4c1388 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -130,6 +130,7 @@ import { PinDialog, validateAgentName } from "./components/PinDialog"; import { ProviderSelector } from "./components/ProviderSelector"; import { ReasoningMessage } from "./components/ReasoningMessageRich"; import { formatDuration, formatUsageStats } from "./components/SessionStats"; +import { SleeptimeSelector } from "./components/SleeptimeSelector"; // InlinePlanApproval kept for easy rollback if needed // import { InlinePlanApproval } from "./components/InlinePlanApproval"; import { StatusMessage } from "./components/StatusMessage"; @@ -177,8 +178,12 @@ import { import { formatCompact } from "./helpers/format"; import { parsePatchOperations } from "./helpers/formatArgsDisplay"; import { + buildCompactionMemoryReminder, buildMemoryReminder, + getReflectionSettings, parseMemoryPreference, + type ReflectionSettings, + reflectionSettingsToLegacyMode, } from "./helpers/memoryReminder"; import { type QueuedMessage, @@ -310,6 +315,7 @@ const INTERACTIVE_SLASH_COMMANDS = new Set([ "/system", "/subagents", "/memory", + "/sleeptime", "/mcp", "/help", "/agents", @@ -712,6 +718,18 @@ function stripSystemReminders(text: string): string { .trim(); } +function formatReflectionSettings(settings: ReflectionSettings): string { + if (settings.trigger === "off") { + return "Off"; + } + const behaviorLabel = + settings.behavior === "auto-launch" ? "auto-launch" : "reminder"; + if (settings.trigger === "compaction-event") { + return `Compaction event (${behaviorLabel})`; + } + return `Step count (every ${settings.stepCount} turns, ${behaviorLabel})`; +} + function buildTextParts( ...parts: Array ): Array<{ type: "text"; text: string }> { @@ -1122,6 +1140,7 @@ export default function App({ // Overlay/selector state - only one can be open at a time type ActiveOverlay = | "model" + | "sleeptime" | "toolset" | "system" | "agent" @@ -1180,6 +1199,11 @@ export default function App({ type QueuedOverlayAction = | { type: "switch_agent"; agentId: string; commandId?: string } | { type: "switch_model"; modelId: string; commandId?: string } + | { + type: "set_sleeptime"; + settings: ReflectionSettings; + commandId?: string; + } | { type: "switch_conversation"; conversationId: string; @@ -5566,6 +5590,18 @@ export default function App({ return { submitted: true }; } + // Special handling for /sleeptime command - opens reflection settings + if (trimmed === "/sleeptime") { + startOverlayCommand( + "sleeptime", + "/sleeptime", + "Opening sleeptime settings...", + "Sleeptime settings dismissed", + ); + setActiveOverlay("sleeptime"); + return { submitted: true }; + } + // Special handling for /toolset command - opens selector if (trimmed === "/toolset") { startOverlayCommand( @@ -6430,6 +6466,11 @@ export default function App({ // Update command with success cmd.finish(outputLines.join("\n"), true); + + // Manual /compact bypasses stream compaction events, so trigger + // post-compaction reminder/skills reinjection on the next user turn. + contextTrackerRef.current.pendingReflectionTrigger = true; + contextTrackerRef.current.pendingSkillsReinject = true; } catch (error) { let errorOutput: string; @@ -7760,6 +7801,7 @@ ${SYSTEM_REMINDER_CLOSE} turnCountRef.current, agentId, ); + const reflectionSettings = getReflectionSettings(); // Increment turn count for next iteration turnCountRef.current += 1; @@ -7854,6 +7896,17 @@ ${SYSTEM_REMINDER_CLOSE} pushReminder(bashCommandPrefix); pushReminder(userPromptSubmitHookFeedback); pushReminder(memoryReminderContent); + + // Consume compaction-triggered reflection/check reminder on next user turn. + if (contextTrackerRef.current.pendingReflectionTrigger) { + contextTrackerRef.current.pendingReflectionTrigger = false; + if (reflectionSettings.trigger === "compaction-event") { + const compactionReminderContent = + await buildCompactionMemoryReminder(agentId); + pushReminder(compactionReminderContent); + } + } + pushReminder(memoryGitReminder); const messageContent = reminderParts.length > 0 @@ -9373,6 +9426,79 @@ ${SYSTEM_REMINDER_CLOSE} ], ); + const handleSleeptimeModeSelect = useCallback( + async ( + reflectionSettings: ReflectionSettings, + commandId?: string | null, + ) => { + const overlayCommand = commandId + ? commandRunner.getHandle(commandId, "/sleeptime") + : consumeOverlayCommand("sleeptime"); + + if (isAgentBusy()) { + setActiveOverlay(null); + const cmd = + overlayCommand ?? + commandRunner.start( + "/sleeptime", + "Sleeptime settings update queued – will apply after current task completes", + ); + cmd.update({ + output: + "Sleeptime settings update queued – will apply after current task completes", + phase: "running", + }); + setQueuedOverlayAction({ + type: "set_sleeptime", + settings: reflectionSettings, + commandId: cmd.id, + }); + return; + } + + await withCommandLock(async () => { + const cmd = + overlayCommand ?? + commandRunner.start("/sleeptime", "Saving sleeptime settings..."); + cmd.update({ + output: "Saving sleeptime settings...", + phase: "running", + }); + + try { + const legacyMode = reflectionSettingsToLegacyMode(reflectionSettings); + settingsManager.updateLocalProjectSettings({ + memoryReminderInterval: legacyMode, + reflectionTrigger: reflectionSettings.trigger, + reflectionBehavior: reflectionSettings.behavior, + reflectionStepCount: reflectionSettings.stepCount, + }); + settingsManager.updateSettings({ + memoryReminderInterval: legacyMode, + reflectionTrigger: reflectionSettings.trigger, + reflectionBehavior: reflectionSettings.behavior, + reflectionStepCount: reflectionSettings.stepCount, + }); + + cmd.finish( + `Updated sleeptime settings to: ${formatReflectionSettings(reflectionSettings)}`, + true, + ); + } catch (error) { + const errorDetails = formatErrorDetails(error, agentId); + cmd.fail(`Failed to save sleeptime settings: ${errorDetails}`); + } + }); + }, + [ + agentId, + commandRunner, + consumeOverlayCommand, + isAgentBusy, + withCommandLock, + ], + ); + const handleToolsetSelect = useCallback( async ( toolsetId: @@ -9462,6 +9588,8 @@ ${SYSTEM_REMINDER_CLOSE} } else if (action.type === "switch_model") { // Call handleModelSelect - it will see isAgentBusy() as false now handleModelSelect(action.modelId, action.commandId); + } else if (action.type === "set_sleeptime") { + handleSleeptimeModeSelect(action.settings, action.commandId); } else if (action.type === "switch_conversation") { const cmd = action.commandId ? commandRunner.getHandle(action.commandId, "/resume") @@ -9531,6 +9659,7 @@ ${SYSTEM_REMINDER_CLOSE} queuedOverlayAction, handleAgentSelect, handleModelSelect, + handleSleeptimeModeSelect, handleToolsetSelect, handleSystemPromptSelect, agentId, @@ -10603,6 +10732,15 @@ Plan file path: ${planFilePath}`; /> )} + {activeOverlay === "sleeptime" && ( + + )} + {/* Provider Selector - for connecting BYOK providers */} {activeOverlay === "connect" && ( = { return "Opening memory viewer..."; }, }, + "/sleeptime": { + desc: "Configure reflection reminder trigger settings", + order: 15.5, + handler: () => { + // Handled specially in App.tsx to open sleeptime settings + return "Opening sleeptime settings..."; + }, + }, "/memfs": { desc: "Manage filesystem-backed memory (/memfs [enable|disable|sync|reset])", args: "[enable|disable|sync|reset]", diff --git a/src/cli/components/SleeptimeSelector.tsx b/src/cli/components/SleeptimeSelector.tsx new file mode 100644 index 0000000..0e70c04 --- /dev/null +++ b/src/cli/components/SleeptimeSelector.tsx @@ -0,0 +1,372 @@ +import { Box, useInput } from "ink"; +import { useEffect, useMemo, useState } from "react"; +import type { + ReflectionBehavior, + ReflectionSettings, + ReflectionTrigger, +} from "../helpers/memoryReminder"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; +import { colors } from "./colors"; +import { Text } from "./Text"; + +const SOLID_LINE = "─"; +const DEFAULT_STEP_COUNT = "25"; +const BEHAVIOR_OPTIONS: ReflectionBehavior[] = ["reminder", "auto-launch"]; + +type FocusRow = "trigger" | "behavior" | "step-count"; + +interface SleeptimeSelectorProps { + initialSettings: ReflectionSettings; + memfsEnabled: boolean; + onSave: (settings: ReflectionSettings) => void; + onCancel: () => void; +} + +function getTriggerOptions(memfsEnabled: boolean): ReflectionTrigger[] { + return memfsEnabled + ? ["off", "step-count", "compaction-event"] + : ["off", "step-count"]; +} + +function cycleOption( + options: readonly T[], + current: T, + direction: -1 | 1, +): T { + if (options.length === 0) { + return current; + } + const currentIndex = options.indexOf(current); + const safeIndex = currentIndex >= 0 ? currentIndex : 0; + const nextIndex = (safeIndex + direction + options.length) % options.length; + return options[nextIndex] ?? current; +} + +function parseInitialState(initialSettings: ReflectionSettings): { + trigger: ReflectionTrigger; + behavior: ReflectionBehavior; + stepCount: string; +} { + return { + trigger: + initialSettings.trigger === "off" || + initialSettings.trigger === "step-count" || + initialSettings.trigger === "compaction-event" + ? initialSettings.trigger + : "step-count", + behavior: + initialSettings.behavior === "auto-launch" ? "auto-launch" : "reminder", + stepCount: String( + Number.isInteger(initialSettings.stepCount) && + initialSettings.stepCount > 0 + ? initialSettings.stepCount + : Number(DEFAULT_STEP_COUNT), + ), + }; +} + +function parseStepCount(raw: string): number | null { + const trimmed = raw.trim(); + if (!/^\d+$/.test(trimmed)) return null; + const parsed = Number.parseInt(trimmed, 10); + if (!Number.isInteger(parsed) || parsed <= 0) return null; + return parsed; +} + +export function SleeptimeSelector({ + initialSettings, + memfsEnabled, + onSave, + onCancel, +}: SleeptimeSelectorProps) { + const terminalWidth = useTerminalWidth(); + const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10)); + const initialState = useMemo( + () => parseInitialState(initialSettings), + [initialSettings], + ); + + const [trigger, setTrigger] = useState(() => { + if (!memfsEnabled && initialState.trigger === "compaction-event") { + return "step-count"; + } + return initialState.trigger; + }); + const [behavior, setBehavior] = useState( + initialState.behavior, + ); + const [stepCountInput, setStepCountInput] = useState(initialState.stepCount); + const [focusRow, setFocusRow] = useState("trigger"); + const [validationError, setValidationError] = useState(null); + const triggerOptions = useMemo( + () => getTriggerOptions(memfsEnabled), + [memfsEnabled], + ); + const visibleRows = useMemo(() => { + const rows: FocusRow[] = ["trigger"]; + if (memfsEnabled && trigger !== "off") { + rows.push("behavior"); + } + if (trigger === "step-count") { + rows.push("step-count"); + } + return rows; + }, [memfsEnabled, trigger]); + const isEditingStepCount = + focusRow === "step-count" && trigger === "step-count"; + + useEffect(() => { + if (!visibleRows.includes(focusRow)) { + setFocusRow(visibleRows[visibleRows.length - 1] ?? "trigger"); + } + }, [focusRow, visibleRows]); + + const saveSelection = () => { + if (trigger === "step-count") { + const stepCount = parseStepCount(stepCountInput); + if (stepCount === null) { + setValidationError("must be a positive integer"); + return; + } + onSave({ + trigger, + behavior: memfsEnabled ? behavior : "reminder", + stepCount, + }); + return; + } + + const fallbackStepCount = + parseStepCount(stepCountInput) ?? Number(DEFAULT_STEP_COUNT); + onSave({ + trigger, + behavior: memfsEnabled ? behavior : "reminder", + stepCount: fallbackStepCount, + }); + }; + + useInput((input, key) => { + if (key.ctrl && input === "c") { + onCancel(); + return; + } + + if (key.escape) { + onCancel(); + return; + } + + if (key.return) { + saveSelection(); + return; + } + + if (key.upArrow || key.downArrow) { + if (visibleRows.length === 0) return; + setValidationError(null); + const direction = key.downArrow ? 1 : -1; + const currentIndex = visibleRows.indexOf(focusRow); + const safeIndex = currentIndex >= 0 ? currentIndex : 0; + const nextIndex = + (safeIndex + direction + visibleRows.length) % visibleRows.length; + const nextRow = visibleRows[nextIndex] ?? "trigger"; + setFocusRow(nextRow); + return; + } + + if (key.leftArrow || key.rightArrow || key.tab) { + setValidationError(null); + const direction: -1 | 1 = key.leftArrow ? -1 : 1; + if (focusRow === "trigger") { + setTrigger((prev) => cycleOption(triggerOptions, prev, direction)); + } else if (focusRow === "behavior" && memfsEnabled && trigger !== "off") { + setBehavior((prev) => cycleOption(BEHAVIOR_OPTIONS, prev, direction)); + } + return; + } + + if (!isEditingStepCount) return; + + if (key.backspace || key.delete) { + setStepCountInput((prev) => prev.slice(0, -1)); + setValidationError(null); + return; + } + + // Allow arbitrary typing and validate only when saving. + if ( + input && + input.length > 0 && + !key.ctrl && + !key.meta && + !key.tab && + !key.upArrow && + !key.downArrow && + !key.leftArrow && + !key.rightArrow + ) { + setStepCountInput((prev) => `${prev}${input}`); + setValidationError(null); + } + }); + + return ( + + {"> /sleeptime"} + {solidLine} + + + + + Configure your sleeptime (reflection) settings + + + + + {memfsEnabled ? ( + <> + + {focusRow === "trigger" ? "> " : " "} + Trigger: + {" "} + + {" Off "} + + + + {" Step count "} + + + + {" Compaction event "} + + + + {trigger !== "off" && ( + <> + + + {focusRow === "behavior" ? "> " : " "} + Trigger behavior: + {" "} + + {" Reminder "} + + + + {" Auto-launch "} + + + + )} + + {trigger === "step-count" && ( + <> + + + {focusRow === "step-count" ? "> " : " "} + Step count: + {stepCountInput} + {isEditingStepCount && } + {validationError && ( + + {` (error: ${validationError})`} + + )} + + + )} + + ) : ( + <> + + {focusRow === "trigger" ? "> " : " "} + Trigger: + {" "} + + {" Off "} + + + + {" Step count "} + + + + {trigger === "step-count" && ( + <> + + + {focusRow === "step-count" ? "> " : " "} + Step count: + {stepCountInput} + {isEditingStepCount && } + {validationError && ( + + {` (error: ${validationError})`} + + )} + + + )} + + )} + + + + {" Enter to save · ↑↓ rows · ←→/Tab options · Esc cancel"} + + + ); +} diff --git a/src/cli/helpers/accumulator.ts b/src/cli/helpers/accumulator.ts index 340dec8..f454b74 100644 --- a/src/cli/helpers/accumulator.ts +++ b/src/cli/helpers/accumulator.ts @@ -18,6 +18,34 @@ import { MAX_CONTEXT_HISTORY } from "./contextTracker"; import { findLastSafeSplitPoint } from "./markdownSplit"; import { isShellTool } from "./toolNameMapping"; +type CompactionSummaryMessageChunk = { + message_type: "summary_message"; + 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; + }; +}; + +type CompactionEventMessageChunk = { + message_type: "event_message"; + id?: string; + otid?: string; + event_type?: string; + event_data?: Record; +}; + +type StreamingChunk = + | LettaStreamingResponse + | CompactionSummaryMessageChunk + | CompactionEventMessageChunk; + // Constants for streaming output const MAX_TAIL_LINES = 5; const MAX_BUFFER_SIZE = 100_000; // 100KB @@ -473,7 +501,7 @@ function trySplitContent( // Feed one SDK chunk; mutate buffers in place. export function onChunk( b: Buffers, - chunk: LettaStreamingResponse, + chunk: StreamingChunk, ctx?: ContextTracker, ) { // Skip processing if stream was interrupted mid-turn. handleInterrupt already @@ -920,6 +948,7 @@ export function onChunk( if (ctx) { ctx.pendingCompaction = true; ctx.pendingSkillsReinject = true; + ctx.pendingReflectionTrigger = true; } break; } diff --git a/src/cli/helpers/contextTracker.ts b/src/cli/helpers/contextTracker.ts index 80fd0fb..f7bcf6d 100644 --- a/src/cli/helpers/contextTracker.ts +++ b/src/cli/helpers/contextTracker.ts @@ -18,6 +18,8 @@ export type ContextTracker = { pendingCompaction: boolean; /** Set when compaction happens; consumed by the next user message to reinject skills reminder */ pendingSkillsReinject: boolean; + /** Set when compaction happens; consumed by the next user message to trigger memory reminder/spawn */ + pendingReflectionTrigger: boolean; }; export function createContextTracker(): ContextTracker { @@ -27,6 +29,7 @@ export function createContextTracker(): ContextTracker { currentTurnId: 0, // simple in-memory counter for now pendingCompaction: false, pendingSkillsReinject: false, + pendingReflectionTrigger: false, }; } @@ -34,4 +37,7 @@ export function createContextTracker(): ContextTracker { export function resetContextHistory(ct: ContextTracker): void { ct.lastContextTokens = 0; ct.contextTokensHistory = []; + ct.pendingCompaction = false; + ct.pendingSkillsReinject = false; + ct.pendingReflectionTrigger = false; } diff --git a/src/cli/helpers/memoryReminder.ts b/src/cli/helpers/memoryReminder.ts index 94f9852..07493c8 100644 --- a/src/cli/helpers/memoryReminder.ts +++ b/src/cli/helpers/memoryReminder.ts @@ -7,24 +7,197 @@ import { debugLog } from "../../utils/debug"; // Memory reminder interval presets const MEMORY_INTERVAL_FREQUENT = 5; const MEMORY_INTERVAL_OCCASIONAL = 10; +const DEFAULT_STEP_COUNT = 25; + +export type MemoryReminderMode = + | number + | null + | "compaction" + | "auto-compaction"; + +export type ReflectionTrigger = "off" | "step-count" | "compaction-event"; +export type ReflectionBehavior = "reminder" | "auto-launch"; + +export interface ReflectionSettings { + trigger: ReflectionTrigger; + behavior: ReflectionBehavior; + stepCount: number; +} + +const DEFAULT_REFLECTION_SETTINGS: ReflectionSettings = { + trigger: "step-count", + behavior: "reminder", + stepCount: DEFAULT_STEP_COUNT, +}; + +function isValidStepCount(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isFinite(value) && + Number.isInteger(value) && + value > 0 + ); +} + +function normalizeStepCount(value: unknown, fallback: number): number { + return isValidStepCount(value) ? value : fallback; +} + +function normalizeTrigger( + value: unknown, + fallback: ReflectionTrigger, +): ReflectionTrigger { + if ( + value === "off" || + value === "step-count" || + value === "compaction-event" + ) { + return value; + } + return fallback; +} + +function normalizeBehavior( + value: unknown, + fallback: ReflectionBehavior, +): ReflectionBehavior { + if (value === "reminder" || value === "auto-launch") { + return value; + } + return fallback; +} + +function applyExplicitReflectionOverrides( + base: ReflectionSettings, + raw: { + reflectionTrigger?: unknown; + reflectionBehavior?: unknown; + reflectionStepCount?: unknown; + }, +): ReflectionSettings { + return { + trigger: normalizeTrigger(raw.reflectionTrigger, base.trigger), + behavior: normalizeBehavior(raw.reflectionBehavior, base.behavior), + stepCount: normalizeStepCount(raw.reflectionStepCount, base.stepCount), + }; +} + +function legacyModeToReflectionSettings( + mode: MemoryReminderMode | undefined, +): ReflectionSettings { + if (typeof mode === "number") { + return { + trigger: "step-count", + behavior: "reminder", + stepCount: normalizeStepCount(mode, DEFAULT_STEP_COUNT), + }; + } + + if (mode === null) { + return { + trigger: "off", + behavior: DEFAULT_REFLECTION_SETTINGS.behavior, + stepCount: DEFAULT_REFLECTION_SETTINGS.stepCount, + }; + } + + if (mode === "compaction") { + return { + trigger: "compaction-event", + behavior: "reminder", + stepCount: DEFAULT_REFLECTION_SETTINGS.stepCount, + }; + } + + if (mode === "auto-compaction") { + return { + trigger: "compaction-event", + behavior: "auto-launch", + stepCount: DEFAULT_REFLECTION_SETTINGS.stepCount, + }; + } + + return { ...DEFAULT_REFLECTION_SETTINGS }; +} + +export function reflectionSettingsToLegacyMode( + settings: ReflectionSettings, +): MemoryReminderMode { + if (settings.trigger === "off") { + return null; + } + if (settings.trigger === "compaction-event") { + return settings.behavior === "auto-launch" + ? "auto-compaction" + : "compaction"; + } + return normalizeStepCount(settings.stepCount, DEFAULT_STEP_COUNT); +} /** - * Get the effective memory reminder interval (local setting takes precedence over global) - * @returns The memory interval setting, or null if disabled + * Get effective reflection settings (local overrides global with legacy fallback). */ -function getMemoryInterval(): number | null { +export function getReflectionSettings(): ReflectionSettings { + const globalSettings = settingsManager.getSettings(); + let resolved = legacyModeToReflectionSettings( + globalSettings.memoryReminderInterval, + ); + resolved = applyExplicitReflectionOverrides(resolved, globalSettings); + // Check local settings first (may not be loaded, so catch errors) try { const localSettings = settingsManager.getLocalProjectSettings(); if (localSettings.memoryReminderInterval !== undefined) { - return localSettings.memoryReminderInterval; + resolved = legacyModeToReflectionSettings( + localSettings.memoryReminderInterval, + ); } + resolved = applyExplicitReflectionOverrides(resolved, localSettings); } catch { // Local settings not loaded, fall through to global } - // Fall back to global setting - return settingsManager.getSetting("memoryReminderInterval"); + return resolved; +} + +/** + * Legacy mode view used by existing call sites while migrating to split fields. + */ +export function getMemoryReminderMode(): MemoryReminderMode { + return reflectionSettingsToLegacyMode(getReflectionSettings()); +} + +async function buildMemfsAwareMemoryReminder( + agentId: string, + trigger: "interval" | "compaction", +): Promise { + if (settingsManager.isMemfsEnabled(agentId)) { + debugLog( + "memory", + `Reflection reminder fired (${trigger}, agent ${agentId})`, + ); + const { MEMORY_REFLECTION_REMINDER } = await import( + "../../agent/promptAssets.js" + ); + return MEMORY_REFLECTION_REMINDER; + } + + debugLog( + "memory", + `Memory check reminder fired (${trigger}, agent ${agentId})`, + ); + const { MEMORY_CHECK_REMINDER } = await import("../../agent/promptAssets.js"); + return MEMORY_CHECK_REMINDER; +} + +/** + * Build a compaction-triggered memory reminder. Uses the same memfs-aware + * selection as interval reminders. + */ +export async function buildCompactionMemoryReminder( + agentId: string, +): Promise { + return buildMemfsAwareMemoryReminder(agentId, "compaction"); } /** @@ -43,28 +216,22 @@ export async function buildMemoryReminder( turnCount: number, agentId: string, ): Promise { - const memoryInterval = getMemoryInterval(); - - if (memoryInterval && turnCount > 0 && turnCount % memoryInterval === 0) { - if (settingsManager.isMemfsEnabled(agentId)) { - debugLog( - "memory", - `Reflection reminder fired (turn ${turnCount}, agent ${agentId})`, - ); - const { MEMORY_REFLECTION_REMINDER } = await import( - "../../agent/promptAssets.js" - ); - return MEMORY_REFLECTION_REMINDER; - } + const reflectionSettings = getReflectionSettings(); + if (reflectionSettings.trigger !== "step-count") { + return ""; + } + if ( + turnCount > 0 && + turnCount % + normalizeStepCount(reflectionSettings.stepCount, DEFAULT_STEP_COUNT) === + 0 + ) { debugLog( "memory", - `Memory check reminder fired (turn ${turnCount}, agent ${agentId})`, + `Turn-based memory reminder fired (turn ${turnCount}, interval ${reflectionSettings.stepCount}, agent ${agentId})`, ); - const { MEMORY_CHECK_REMINDER } = await import( - "../../agent/promptAssets.js" - ); - return MEMORY_CHECK_REMINDER; + return buildMemfsAwareMemoryReminder(agentId, "interval"); } return ""; @@ -103,11 +270,17 @@ export function parseMemoryPreference( if (answer.includes("frequent")) { settingsManager.updateLocalProjectSettings({ memoryReminderInterval: MEMORY_INTERVAL_FREQUENT, + reflectionTrigger: "step-count", + reflectionBehavior: "reminder", + reflectionStepCount: MEMORY_INTERVAL_FREQUENT, }); return true; } else if (answer.includes("occasional")) { settingsManager.updateLocalProjectSettings({ memoryReminderInterval: MEMORY_INTERVAL_OCCASIONAL, + reflectionTrigger: "step-count", + reflectionBehavior: "reminder", + reflectionStepCount: MEMORY_INTERVAL_OCCASIONAL, }); return true; } diff --git a/src/headless.ts b/src/headless.ts index aceecaa..1e8c5ec 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -119,7 +119,6 @@ export async function handleHeadlessCommand( yolo: { type: "boolean" }, skills: { type: "string" }, "pre-load-skills": { type: "string" }, - sleeptime: { type: "boolean" }, "init-blocks": { type: "string" }, "base-tools": { type: "string" }, "from-af": { type: "string" }, @@ -261,7 +260,6 @@ export async function handleHeadlessCommand( const blockValueArgs = values["block-value"] as string[] | undefined; const initBlocksRaw = values["init-blocks"] as string | undefined; const baseToolsRaw = values["base-tools"] as string | undefined; - const sleeptimeFlag = (values.sleeptime as boolean | undefined) ?? undefined; const memfsFlag = values.memfs as boolean | undefined; const noMemfsFlag = values["no-memfs"] as boolean | undefined; const fromAfFile = values["from-af"] as string | undefined; @@ -573,7 +571,6 @@ export async function handleHeadlessCommand( updateArgs, skillsDirectory, parallelToolCalls: true, - enableSleeptime: sleeptimeFlag ?? settings.enableSleeptime, systemPromptPreset, systemPromptCustom: systemCustom, systemPromptAppend: systemAppend, diff --git a/src/index.ts b/src/index.ts index 092d1a9..4bacbf8 100755 --- a/src/index.ts +++ b/src/index.ts @@ -77,7 +77,6 @@ OPTIONS Emit stream_event wrappers for each chunk (stream-json only) --from-agent Inject agent-to-agent system reminder (headless mode) --skills Custom path to skills directory (default: .skills in current directory) - --sleeptime Enable sleeptime memory management (only for new agents) --import Create agent from an AgentFile (.af) template Use @author/name to import from the agent registry --memfs Enable memory filesystem for this agent @@ -438,7 +437,6 @@ async function main(): Promise { "from-agent": { type: "string" }, skills: { type: "string" }, "pre-load-skills": { type: "string" }, - sleeptime: { type: "boolean" }, "from-af": { type: "string" }, import: { type: "string" }, @@ -553,7 +551,6 @@ async function main(): Promise { (values["memory-blocks"] as string | undefined) ?? undefined; const specifiedToolset = (values.toolset as string | undefined) ?? undefined; const skillsDirectory = (values.skills as string | undefined) ?? undefined; - const sleeptimeFlag = (values.sleeptime as boolean | undefined) ?? undefined; const memfsFlag = values.memfs as boolean | undefined; const noMemfsFlag = values["no-memfs"] as boolean | undefined; const fromAfFile = @@ -1597,18 +1594,15 @@ async function main(): Promise { } const updateArgs = getModelUpdateArgs(effectiveModel); - const result = await createAgent( - undefined, - effectiveModel, - undefined, + const result = await createAgent({ + model: effectiveModel, updateArgs, skillsDirectory, - true, // parallelToolCalls always enabled - sleeptimeFlag ?? settings.enableSleeptime, + parallelToolCalls: true, systemPromptPreset, initBlocks, baseTools, - ); + }); agent = result.agent; setAgentProvenance(result.provenance); } diff --git a/src/settings-manager.ts b/src/settings-manager.ts index d090a23..23b9bcd 100644 --- a/src/settings-manager.ts +++ b/src/settings-manager.ts @@ -55,7 +55,10 @@ export interface Settings { showCompactions?: boolean; enableSleeptime: boolean; sessionContextEnabled: boolean; // Send device/agent context on first message of each session - memoryReminderInterval: number | null; // null = disabled, number = prompt memory check every N turns + memoryReminderInterval: number | null | "compaction" | "auto-compaction"; // DEPRECATED: use reflection* fields + reflectionTrigger: "off" | "step-count" | "compaction-event"; + reflectionBehavior: "reminder" | "auto-launch"; + reflectionStepCount: number; globalSharedBlockIds: Record; // DEPRECATED: kept for backwards compat profiles?: Record; // DEPRECATED: old format, kept for migration pinnedAgents?: string[]; // DEPRECATED: kept for backwards compat, use pinnedAgentsByServer @@ -99,7 +102,10 @@ export interface LocalProjectSettings { statusLine?: StatusLineConfig; // Local project-specific status line command profiles?: Record; // DEPRECATED: old format, kept for migration pinnedAgents?: string[]; // DEPRECATED: kept for backwards compat, use pinnedAgentsByServer - memoryReminderInterval?: number | null; // null = disabled, number = overrides global + memoryReminderInterval?: number | null | "compaction" | "auto-compaction"; // DEPRECATED: use reflection* fields + reflectionTrigger?: "off" | "step-count" | "compaction-event"; + reflectionBehavior?: "reminder" | "auto-launch"; + reflectionStepCount?: number; // Server-indexed settings (agent IDs are server-specific) sessionsByServer?: Record; // key = normalized base URL pinnedAgentsByServer?: Record; // key = normalized base URL @@ -111,7 +117,10 @@ const DEFAULT_SETTINGS: Settings = { showCompactions: false, enableSleeptime: false, sessionContextEnabled: true, - memoryReminderInterval: 5, // number = prompt memory check every N turns + memoryReminderInterval: 25, // DEPRECATED: use reflection* fields + reflectionTrigger: "step-count", + reflectionBehavior: "reminder", + reflectionStepCount: 25, globalSharedBlockIds: {}, }; diff --git a/src/tests/cli/accumulator-usage.test.ts b/src/tests/cli/accumulator-usage.test.ts index 45da7d9..16a7b22 100644 --- a/src/tests/cli/accumulator-usage.test.ts +++ b/src/tests/cli/accumulator-usage.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { LettaStreamingResponse } from "@letta-ai/letta-client/resources/agents/messages"; import { createBuffers, onChunk } from "../../cli/helpers/accumulator"; +import { createContextTracker } from "../../cli/helpers/contextTracker"; function usageChunk( fields: Record, @@ -72,4 +73,36 @@ describe("accumulator usage statistics", () => { expect(buffers.usage.reasoningTokens).toBe(0); expect(buffers.usage.contextTokens).toBeUndefined(); }); + + test("sets reflection trigger only after compaction summary message", () => { + const buffers = createBuffers("agent-1"); + const tracker = createContextTracker(); + + onChunk( + buffers, + { + message_type: "event_message", + otid: "evt-compaction-1", + event_type: "compaction", + event_data: {}, + }, + tracker, + ); + + expect(tracker.pendingReflectionTrigger).toBe(false); + + onChunk( + buffers, + { + message_type: "summary_message", + otid: "evt-compaction-1", + summary: "Compaction completed", + }, + tracker, + ); + + expect(tracker.pendingCompaction).toBe(true); + expect(tracker.pendingSkillsReinject).toBe(true); + expect(tracker.pendingReflectionTrigger).toBe(true); + }); }); diff --git a/src/tests/cli/contextTracker.test.ts b/src/tests/cli/contextTracker.test.ts new file mode 100644 index 0000000..d4af250 --- /dev/null +++ b/src/tests/cli/contextTracker.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { + createContextTracker, + resetContextHistory, +} from "../../cli/helpers/contextTracker"; + +describe("contextTracker", () => { + test("resetContextHistory clears token history and pending compaction flags", () => { + const tracker = createContextTracker(); + tracker.lastContextTokens = 123; + tracker.contextTokensHistory = [ + { timestamp: 1, tokens: 111, turnId: 1, compacted: true }, + ]; + tracker.pendingCompaction = true; + tracker.pendingSkillsReinject = true; + tracker.pendingReflectionTrigger = true; + tracker.currentTurnId = 9; + + resetContextHistory(tracker); + + expect(tracker.lastContextTokens).toBe(0); + expect(tracker.contextTokensHistory).toEqual([]); + expect(tracker.pendingCompaction).toBe(false); + expect(tracker.pendingSkillsReinject).toBe(false); + expect(tracker.pendingReflectionTrigger).toBe(false); + expect(tracker.currentTurnId).toBe(9); + }); +}); diff --git a/src/tests/cli/memoryReminder.test.ts b/src/tests/cli/memoryReminder.test.ts new file mode 100644 index 0000000..04fb26d --- /dev/null +++ b/src/tests/cli/memoryReminder.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + MEMORY_CHECK_REMINDER, + MEMORY_REFLECTION_REMINDER, +} from "../../agent/promptAssets"; +import { + buildCompactionMemoryReminder, + buildMemoryReminder, + getReflectionSettings, + reflectionSettingsToLegacyMode, +} from "../../cli/helpers/memoryReminder"; +import { settingsManager } from "../../settings-manager"; + +const originalGetLocalProjectSettings = settingsManager.getLocalProjectSettings; +const originalGetSettings = settingsManager.getSettings; +const originalIsMemfsEnabled = settingsManager.isMemfsEnabled; + +afterEach(() => { + (settingsManager as typeof settingsManager).getLocalProjectSettings = + originalGetLocalProjectSettings; + (settingsManager as typeof settingsManager).getSettings = originalGetSettings; + (settingsManager as typeof settingsManager).isMemfsEnabled = + originalIsMemfsEnabled; +}); + +describe("memoryReminder", () => { + test("prefers local reflection settings over global", () => { + (settingsManager as typeof settingsManager).getLocalProjectSettings = () => + ({ + reflectionTrigger: "compaction-event", + reflectionBehavior: "auto-launch", + reflectionStepCount: 33, + }) as ReturnType; + (settingsManager as typeof settingsManager).getSettings = (() => + ({ + memoryReminderInterval: 5, + reflectionTrigger: "step-count", + reflectionBehavior: "reminder", + reflectionStepCount: 25, + }) as ReturnType< + typeof settingsManager.getSettings + >) as typeof settingsManager.getSettings; + + expect(getReflectionSettings()).toEqual({ + trigger: "compaction-event", + behavior: "auto-launch", + stepCount: 33, + }); + }); + + test("falls back to legacy local mode when split fields are absent", () => { + (settingsManager as typeof settingsManager).getLocalProjectSettings = () => + ({ + memoryReminderInterval: "compaction", + }) as ReturnType; + (settingsManager as typeof settingsManager).getSettings = (() => + ({ + memoryReminderInterval: 5, + reflectionTrigger: "step-count", + reflectionBehavior: "reminder", + reflectionStepCount: 25, + }) as ReturnType< + typeof settingsManager.getSettings + >) as typeof settingsManager.getSettings; + + expect(getReflectionSettings()).toEqual({ + trigger: "compaction-event", + behavior: "reminder", + stepCount: 25, + }); + }); + + test("disables turn-based reminders for non-step-count trigger", async () => { + (settingsManager as typeof settingsManager).getLocalProjectSettings = () => + ({ + reflectionTrigger: "compaction-event", + reflectionBehavior: "reminder", + }) as ReturnType; + (settingsManager as typeof settingsManager).getSettings = (() => + ({ + memoryReminderInterval: 5, + reflectionTrigger: "step-count", + reflectionBehavior: "reminder", + reflectionStepCount: 25, + }) as ReturnType< + typeof settingsManager.getSettings + >) as typeof settingsManager.getSettings; + + const reminder = await buildMemoryReminder(10, "agent-1"); + expect(reminder).toBe(""); + }); + + test("keeps existing numeric interval behavior", async () => { + (settingsManager as typeof settingsManager).getLocalProjectSettings = () => + ({ + reflectionTrigger: "step-count", + reflectionBehavior: "auto-launch", + reflectionStepCount: 5, + }) as ReturnType; + (settingsManager as typeof settingsManager).getSettings = (() => + ({ + memoryReminderInterval: 10, + reflectionTrigger: "step-count", + reflectionBehavior: "reminder", + reflectionStepCount: 25, + }) as ReturnType< + typeof settingsManager.getSettings + >) as typeof settingsManager.getSettings; + (settingsManager as typeof settingsManager).isMemfsEnabled = (() => + false) as typeof settingsManager.isMemfsEnabled; + + const reminder = await buildMemoryReminder(10, "agent-1"); + expect(reminder).toBe(MEMORY_CHECK_REMINDER); + }); + + test("maps split reflection settings back to legacy mode", () => { + expect( + reflectionSettingsToLegacyMode({ + trigger: "off", + behavior: "reminder", + stepCount: 25, + }), + ).toBeNull(); + expect( + reflectionSettingsToLegacyMode({ + trigger: "step-count", + behavior: "auto-launch", + stepCount: 30, + }), + ).toBe(30); + expect( + reflectionSettingsToLegacyMode({ + trigger: "compaction-event", + behavior: "auto-launch", + stepCount: 25, + }), + ).toBe("auto-compaction"); + }); + + test("builds compaction reminder with memfs-aware reflection content", async () => { + (settingsManager as typeof settingsManager).isMemfsEnabled = (() => + true) as typeof settingsManager.isMemfsEnabled; + + const reminder = await buildCompactionMemoryReminder("agent-1"); + expect(reminder).toBe(MEMORY_REFLECTION_REMINDER); + }); +});