feat: add client-side sleeptime settings + compaction reflection triggers (#923)
This commit is contained in:
138
src/cli/App.tsx
138
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<string | undefined | null>
|
||||
): 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" && (
|
||||
<SleeptimeSelector
|
||||
initialSettings={getReflectionSettings()}
|
||||
memfsEnabled={settingsManager.isMemfsEnabled(agentId)}
|
||||
onSave={handleSleeptimeModeSelect}
|
||||
onCancel={closeOverlay}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Provider Selector - for connecting BYOK providers */}
|
||||
{activeOverlay === "connect" && (
|
||||
<ProviderSelector
|
||||
|
||||
@@ -60,6 +60,14 @@ export const commands: Record<string, Command> = {
|
||||
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]",
|
||||
|
||||
372
src/cli/components/SleeptimeSelector.tsx
Normal file
372
src/cli/components/SleeptimeSelector.tsx
Normal file
@@ -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<T extends string>(
|
||||
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<ReflectionTrigger>(() => {
|
||||
if (!memfsEnabled && initialState.trigger === "compaction-event") {
|
||||
return "step-count";
|
||||
}
|
||||
return initialState.trigger;
|
||||
});
|
||||
const [behavior, setBehavior] = useState<ReflectionBehavior>(
|
||||
initialState.behavior,
|
||||
);
|
||||
const [stepCountInput, setStepCountInput] = useState(initialState.stepCount);
|
||||
const [focusRow, setFocusRow] = useState<FocusRow>("trigger");
|
||||
const [validationError, setValidationError] = useState<string | null>(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 (
|
||||
<Box flexDirection="column">
|
||||
<Text dimColor>{"> /sleeptime"}</Text>
|
||||
<Text dimColor>{solidLine}</Text>
|
||||
|
||||
<Box height={1} />
|
||||
|
||||
<Text bold color={colors.selector.title}>
|
||||
Configure your sleeptime (reflection) settings
|
||||
</Text>
|
||||
|
||||
<Box height={1} />
|
||||
|
||||
{memfsEnabled ? (
|
||||
<>
|
||||
<Box flexDirection="row">
|
||||
<Text>{focusRow === "trigger" ? "> " : " "}</Text>
|
||||
<Text bold>Trigger:</Text>
|
||||
<Text>{" "}</Text>
|
||||
<Text
|
||||
backgroundColor={
|
||||
trigger === "off" ? colors.selector.itemHighlighted : undefined
|
||||
}
|
||||
color={trigger === "off" ? "black" : undefined}
|
||||
bold={trigger === "off"}
|
||||
>
|
||||
{" Off "}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text
|
||||
backgroundColor={
|
||||
trigger === "step-count"
|
||||
? colors.selector.itemHighlighted
|
||||
: undefined
|
||||
}
|
||||
color={trigger === "step-count" ? "black" : undefined}
|
||||
bold={trigger === "step-count"}
|
||||
>
|
||||
{" Step count "}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text
|
||||
backgroundColor={
|
||||
trigger === "compaction-event"
|
||||
? colors.selector.itemHighlighted
|
||||
: undefined
|
||||
}
|
||||
color={trigger === "compaction-event" ? "black" : undefined}
|
||||
bold={trigger === "compaction-event"}
|
||||
>
|
||||
{" Compaction event "}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{trigger !== "off" && (
|
||||
<>
|
||||
<Box height={1} />
|
||||
<Box flexDirection="row">
|
||||
<Text>{focusRow === "behavior" ? "> " : " "}</Text>
|
||||
<Text bold>Trigger behavior:</Text>
|
||||
<Text>{" "}</Text>
|
||||
<Text
|
||||
backgroundColor={
|
||||
behavior === "reminder"
|
||||
? colors.selector.itemHighlighted
|
||||
: undefined
|
||||
}
|
||||
color={behavior === "reminder" ? "black" : undefined}
|
||||
bold={behavior === "reminder"}
|
||||
>
|
||||
{" Reminder "}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text
|
||||
backgroundColor={
|
||||
behavior === "auto-launch"
|
||||
? colors.selector.itemHighlighted
|
||||
: undefined
|
||||
}
|
||||
color={behavior === "auto-launch" ? "black" : undefined}
|
||||
bold={behavior === "auto-launch"}
|
||||
>
|
||||
{" Auto-launch "}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{trigger === "step-count" && (
|
||||
<>
|
||||
<Box height={1} />
|
||||
<Box flexDirection="row">
|
||||
<Text>{focusRow === "step-count" ? "> " : " "}</Text>
|
||||
<Text bold>Step count: </Text>
|
||||
<Text>{stepCountInput}</Text>
|
||||
{isEditingStepCount && <Text>█</Text>}
|
||||
{validationError && (
|
||||
<Text color={colors.error.text}>
|
||||
{` (error: ${validationError})`}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Box flexDirection="row">
|
||||
<Text>{focusRow === "trigger" ? "> " : " "}</Text>
|
||||
<Text bold>Trigger:</Text>
|
||||
<Text>{" "}</Text>
|
||||
<Text
|
||||
backgroundColor={
|
||||
trigger === "off" ? colors.selector.itemHighlighted : undefined
|
||||
}
|
||||
color={trigger === "off" ? "black" : undefined}
|
||||
bold={trigger === "off"}
|
||||
>
|
||||
{" Off "}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text
|
||||
backgroundColor={
|
||||
trigger === "step-count"
|
||||
? colors.selector.itemHighlighted
|
||||
: undefined
|
||||
}
|
||||
color={trigger === "step-count" ? "black" : undefined}
|
||||
bold={trigger === "step-count"}
|
||||
>
|
||||
{" Step count "}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{trigger === "step-count" && (
|
||||
<>
|
||||
<Box height={1} />
|
||||
<Box flexDirection="row">
|
||||
<Text>{focusRow === "step-count" ? "> " : " "}</Text>
|
||||
<Text bold>Step count: </Text>
|
||||
<Text>{stepCountInput}</Text>
|
||||
{isEditingStepCount && <Text>█</Text>}
|
||||
{validationError && (
|
||||
<Text color={colors.error.text}>
|
||||
{` (error: ${validationError})`}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box height={1} />
|
||||
<Text dimColor>
|
||||
{" Enter to save · ↑↓ rows · ←→/Tab options · Esc cancel"}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
return buildMemfsAwareMemoryReminder(agentId, "compaction");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,28 +216,22 @@ export async function buildMemoryReminder(
|
||||
turnCount: number,
|
||||
agentId: string,
|
||||
): Promise<string> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
14
src/index.ts
14
src/index.ts
@@ -77,7 +77,6 @@ OPTIONS
|
||||
Emit stream_event wrappers for each chunk (stream-json only)
|
||||
--from-agent <id> Inject agent-to-agent system reminder (headless mode)
|
||||
--skills <path> Custom path to skills directory (default: .skills in current directory)
|
||||
--sleeptime Enable sleeptime memory management (only for new agents)
|
||||
--import <path> 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<void> {
|
||||
"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<void> {
|
||||
(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<void> {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<string, string>; // DEPRECATED: kept for backwards compat
|
||||
profiles?: Record<string, string>; // 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<string, string>; // 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<string, SessionRef>; // key = normalized base URL
|
||||
pinnedAgentsByServer?: Record<string, string[]>; // 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: {},
|
||||
};
|
||||
|
||||
|
||||
@@ -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<string, number | null | undefined>,
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
28
src/tests/cli/contextTracker.test.ts
Normal file
28
src/tests/cli/contextTracker.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
147
src/tests/cli/memoryReminder.test.ts
Normal file
147
src/tests/cli/memoryReminder.test.ts
Normal file
@@ -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<typeof settingsManager.getLocalProjectSettings>;
|
||||
(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<typeof settingsManager.getLocalProjectSettings>;
|
||||
(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<typeof settingsManager.getLocalProjectSettings>;
|
||||
(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<typeof settingsManager.getLocalProjectSettings>;
|
||||
(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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user