openclaw/src/auto-reply/reply/memory-flush.ts
Claire ecb351b901 feat(memoryFlush): add hard threshold for auto-execute backup command
Adds two new config options to memoryFlush:
- hardThresholdTokens: token count that triggers auto-execution
- hardThresholdCommand: shell command to run (e.g., kernle checkpoint save)

Flow:
1. Soft threshold (existing): prompts agent to save with context
2. Hard threshold (new): auto-executes command without agent involvement

This provides a safety net - agents get a chance to save with context at
soft threshold, but if they miss it, the hard threshold ensures state is
preserved before compaction.

Example config:
```json
{
  "memoryFlush": {
    "softThresholdTokens": 100000,
    "prompt": "Save your state to Kernle...",
    "hardThresholdTokens": 120000,
    "hardThresholdCommand": "kernle -a agent checkpoint save 'auto-backup'"
  }
}
```
2026-01-27 16:50:03 -08:00

132 lines
5.1 KiB
TypeScript

import { lookupContextTokens } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import { DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR } from "../../agents/pi-settings.js";
import type { MoltbotConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
export const DEFAULT_MEMORY_FLUSH_SOFT_TOKENS = 4000;
export const DEFAULT_MEMORY_FLUSH_PROMPT = [
"Pre-compaction memory flush.",
"Store durable memories now (use memory/YYYY-MM-DD.md; create memory/ if needed).",
`If nothing to store, reply with ${SILENT_REPLY_TOKEN}.`,
].join(" ");
export const DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT = [
"Pre-compaction memory flush turn.",
"The session is near auto-compaction; capture durable memories to disk.",
`You may reply, but usually ${SILENT_REPLY_TOKEN} is correct.`,
].join(" ");
export type MemoryFlushSettings = {
enabled: boolean;
softThresholdTokens: number;
prompt: string;
systemPrompt: string;
reserveTokensFloor: number;
hardThresholdTokens: number | null;
hardThresholdCommand: string | null;
};
const normalizeNonNegativeInt = (value: unknown): number | null => {
if (typeof value !== "number" || !Number.isFinite(value)) return null;
const int = Math.floor(value);
return int >= 0 ? int : null;
};
export function resolveMemoryFlushSettings(cfg?: MoltbotConfig): MemoryFlushSettings | null {
const defaults = cfg?.agents?.defaults?.compaction?.memoryFlush;
const enabled = defaults?.enabled ?? true;
if (!enabled) return null;
const softThresholdTokens =
normalizeNonNegativeInt(defaults?.softThresholdTokens) ?? DEFAULT_MEMORY_FLUSH_SOFT_TOKENS;
const prompt = defaults?.prompt?.trim() || DEFAULT_MEMORY_FLUSH_PROMPT;
const systemPrompt = defaults?.systemPrompt?.trim() || DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT;
const reserveTokensFloor =
normalizeNonNegativeInt(cfg?.agents?.defaults?.compaction?.reserveTokensFloor) ??
DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR;
const hardThresholdTokens = normalizeNonNegativeInt(defaults?.hardThresholdTokens);
const hardThresholdCommand = defaults?.hardThresholdCommand?.trim() || null;
return {
enabled,
softThresholdTokens,
prompt: ensureNoReplyHint(prompt),
systemPrompt: ensureNoReplyHint(systemPrompt),
reserveTokensFloor,
hardThresholdTokens,
hardThresholdCommand,
};
}
function ensureNoReplyHint(text: string): string {
if (text.includes(SILENT_REPLY_TOKEN)) return text;
return `${text}\n\nIf no user-visible reply is needed, start with ${SILENT_REPLY_TOKEN}.`;
}
export function resolveMemoryFlushContextWindowTokens(params: {
modelId?: string;
agentCfgContextTokens?: number;
}): number {
return (
lookupContextTokens(params.modelId) ?? params.agentCfgContextTokens ?? DEFAULT_CONTEXT_TOKENS
);
}
export function shouldRunMemoryFlush(params: {
entry?: Pick<SessionEntry, "totalTokens" | "compactionCount" | "memoryFlushCompactionCount">;
contextWindowTokens: number;
reserveTokensFloor: number;
softThresholdTokens: number;
}): boolean {
const totalTokens = params.entry?.totalTokens;
if (!totalTokens || totalTokens <= 0) return false;
const contextWindow = Math.max(1, Math.floor(params.contextWindowTokens));
const reserveTokens = Math.max(0, Math.floor(params.reserveTokensFloor));
const softThreshold = Math.max(0, Math.floor(params.softThresholdTokens));
const threshold = Math.max(0, contextWindow - reserveTokens - softThreshold);
if (threshold <= 0) return false;
if (totalTokens < threshold) return false;
const compactionCount = params.entry?.compactionCount ?? 0;
const lastFlushAt = params.entry?.memoryFlushCompactionCount;
if (typeof lastFlushAt === "number" && lastFlushAt === compactionCount) {
return false;
}
return true;
}
/**
* Check if the hard threshold has been reached and the command should auto-execute.
* Returns true if totalTokens exceeds the hard threshold.
*/
export function shouldRunHardThresholdCommand(params: {
entry?: Pick<SessionEntry, "totalTokens" | "compactionCount" | "memoryFlushCompactionCount">;
contextWindowTokens: number;
reserveTokensFloor: number;
hardThresholdTokens: number | null;
}): boolean {
if (!params.hardThresholdTokens) return false;
const totalTokens = params.entry?.totalTokens;
if (!totalTokens || totalTokens <= 0) return false;
const contextWindow = Math.max(1, Math.floor(params.contextWindowTokens));
const reserveTokens = Math.max(0, Math.floor(params.reserveTokensFloor));
const hardThreshold = Math.max(0, Math.floor(params.hardThresholdTokens));
const threshold = Math.max(0, contextWindow - reserveTokens - hardThreshold);
if (threshold <= 0) return false;
if (totalTokens < threshold) return false;
// Check if we already ran the hard flush this compaction cycle
const compactionCount = params.entry?.compactionCount ?? 0;
const lastFlushAt = params.entry?.memoryFlushCompactionCount;
if (typeof lastFlushAt === "number" && lastFlushAt === compactionCount) {
return false;
}
return true;
}