mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-02 12:51:57 +00:00
125 lines
4.5 KiB
TypeScript
125 lines
4.5 KiB
TypeScript
import crypto from "node:crypto";
|
||
import { lookupContextTokens } from "../../agents/context.js";
|
||
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
||
import { resolveFreshSessionTotalTokens, type SessionEntry } from "../../config/sessions.js";
|
||
|
||
export function resolveMemoryFlushContextWindowTokens(params: {
|
||
modelId?: string;
|
||
agentCfgContextTokens?: number;
|
||
}): number {
|
||
return (
|
||
lookupContextTokens(params.modelId, { allowAsyncLoad: false }) ??
|
||
params.agentCfgContextTokens ??
|
||
DEFAULT_CONTEXT_TOKENS
|
||
);
|
||
}
|
||
|
||
function resolvePositiveTokenCount(value: number | undefined): number | undefined {
|
||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||
? Math.floor(value)
|
||
: undefined;
|
||
}
|
||
|
||
function resolveMemoryFlushGateState<
|
||
TEntry extends Pick<SessionEntry, "totalTokens" | "totalTokensFresh">,
|
||
>(params: {
|
||
entry?: TEntry;
|
||
tokenCount?: number;
|
||
contextWindowTokens: number;
|
||
reserveTokensFloor: number;
|
||
softThresholdTokens: number;
|
||
}): { entry: TEntry; totalTokens: number; threshold: number } | null {
|
||
if (!params.entry) {
|
||
return null;
|
||
}
|
||
|
||
const totalTokens =
|
||
resolvePositiveTokenCount(params.tokenCount) ?? resolveFreshSessionTotalTokens(params.entry);
|
||
if (!totalTokens || totalTokens <= 0) {
|
||
return null;
|
||
}
|
||
|
||
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 null;
|
||
}
|
||
|
||
return { entry: params.entry, totalTokens, threshold };
|
||
}
|
||
|
||
export function shouldRunMemoryFlush(params: {
|
||
entry?: Pick<
|
||
SessionEntry,
|
||
"totalTokens" | "totalTokensFresh" | "compactionCount" | "memoryFlushCompactionCount"
|
||
>;
|
||
/**
|
||
* Optional token count override for flush gating. When provided, this value is
|
||
* treated as a fresh context snapshot and used instead of the cached
|
||
* SessionEntry.totalTokens (which may be stale/unknown).
|
||
*/
|
||
tokenCount?: number;
|
||
contextWindowTokens: number;
|
||
reserveTokensFloor: number;
|
||
softThresholdTokens: number;
|
||
}): boolean {
|
||
const state = resolveMemoryFlushGateState(params);
|
||
if (!state || state.totalTokens < state.threshold) {
|
||
return false;
|
||
}
|
||
|
||
if (hasAlreadyFlushedForCurrentCompaction(state.entry)) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
export function shouldRunPreflightCompaction(params: {
|
||
entry?: Pick<SessionEntry, "totalTokens" | "totalTokensFresh">;
|
||
/**
|
||
* Optional projected token count override for pre-run compaction gating.
|
||
* When provided, this value is treated as a fresh estimate and used instead
|
||
* of any cached SessionEntry total.
|
||
*/
|
||
tokenCount?: number;
|
||
contextWindowTokens: number;
|
||
reserveTokensFloor: number;
|
||
softThresholdTokens: number;
|
||
}): boolean {
|
||
const state = resolveMemoryFlushGateState(params);
|
||
return Boolean(state && state.totalTokens >= state.threshold);
|
||
}
|
||
|
||
/**
|
||
* Returns true when a memory flush has already been performed for the current
|
||
* compaction cycle. This prevents repeated flush runs within the same cycle —
|
||
* important for both the token-based and transcript-size–based trigger paths.
|
||
*/
|
||
export function hasAlreadyFlushedForCurrentCompaction(
|
||
entry: Pick<SessionEntry, "compactionCount" | "memoryFlushCompactionCount">,
|
||
): boolean {
|
||
const compactionCount = entry.compactionCount ?? 0;
|
||
const lastFlushAt = entry.memoryFlushCompactionCount;
|
||
return typeof lastFlushAt === "number" && lastFlushAt === compactionCount;
|
||
}
|
||
|
||
/**
|
||
* Compute a lightweight content hash from the tail of a session transcript.
|
||
* Used for state-based flush deduplication — if the hash hasn't changed since
|
||
* the last flush, the context is effectively the same and flushing again would
|
||
* produce duplicate memory entries.
|
||
*
|
||
* Hash input: `messages.length` + content of the last 3 user/assistant messages.
|
||
* Algorithm: SHA-256 truncated to 16 hex chars (collision-resistant enough for dedup).
|
||
*/
|
||
export function computeContextHash(messages: Array<{ role?: string; content?: unknown }>): string {
|
||
const userAssistant = messages.filter((m) => m.role === "user" || m.role === "assistant");
|
||
const tail = userAssistant.slice(-3);
|
||
const payload = `${messages.length}:${tail.map((m, i) => `[${i}:${m.role ?? ""}]${typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? "")}`).join("\x00")}`;
|
||
const hash = crypto.createHash("sha256").update(payload).digest("hex");
|
||
return hash.slice(0, 16);
|
||
}
|