Files
openclaw/extensions/memory-core/src/flush-plan.ts
T
Peter Steinberger 20668eed03 fix(memory): quarantine writes from tainted agent turns (#115818)
* feat(memory): taint writes after network tools

* fix(memory): keep provenance mutations recoverable

* fix(memory): roll back failed provenance writes

* fix(memory): serialize provenance mutations

* docs(memory): explain flush provenance boundary

* refactor(memory): share provenance mutation wrapper

* docs(memory): clarify flush provenance fallback

* fix(memory): canonicalize provenance paths
2026-07-29 06:02:06 -04:00

218 lines
7.5 KiB
TypeScript

// Memory Core plugin module implements flush plan behavior.
import { createHash } from "node:crypto";
import {
DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR,
parseNonNegativeByteSize,
resolveCronStyleNow,
SILENT_REPLY_TOKEN,
type MemoryFlushPlan,
type OpenClawConfig,
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import {
DREAMING_DAILY_PROVENANCE_NAMESPACE,
deleteMemoryCoreWorkspaceEntry,
readMemoryCoreWorkspaceEntry,
writeMemoryCoreWorkspaceEntry,
} from "./dreaming-state.js";
import { resolveMemoryCoreNowMs } from "./time.js";
const DEFAULT_MEMORY_FLUSH_SOFT_TOKENS = 4000;
const DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES = 2 * 1024 * 1024;
const MEMORY_FLUSH_TARGET_HINT =
"Store durable memories only in memory/YYYY-MM-DD.md (create memory/ if needed).";
const MEMORY_FLUSH_APPEND_ONLY_HINT =
"If memory/YYYY-MM-DD.md already exists, APPEND new content only and do not overwrite existing entries.";
const MEMORY_FLUSH_READ_ONLY_HINT =
"Treat workspace bootstrap/reference files such as MEMORY.md, DREAMS.md, SOUL.md, and AGENTS.md as read-only during this flush; never overwrite, replace, or edit them.";
const MEMORY_FLUSH_REQUIRED_HINTS = [
MEMORY_FLUSH_TARGET_HINT,
MEMORY_FLUSH_APPEND_ONLY_HINT,
MEMORY_FLUSH_READ_ONLY_HINT,
];
function normalizeAgentMemoryPath(relativePath: string): string | undefined {
const normalized = relativePath.replaceAll("\\", "/").replace(/^\.\//u, "");
if (["MEMORY.md", "memory.md", "USER.md"].includes(normalized)) {
return normalized;
}
if (
!normalized.startsWith("memory/") ||
!normalized.endsWith(".md") ||
normalized.startsWith("memory/dreaming/") ||
normalized.startsWith("memory/.dreams/")
) {
return undefined;
}
return normalized;
}
const DEFAULT_MEMORY_FLUSH_PROMPT = [
"Pre-compaction memory flush.",
MEMORY_FLUSH_TARGET_HINT,
MEMORY_FLUSH_READ_ONLY_HINT,
MEMORY_FLUSH_APPEND_ONLY_HINT,
"Do NOT create timestamped variant files (e.g., YYYY-MM-DD-HHMM.md); always use the canonical YYYY-MM-DD.md filename.",
`If nothing to store, reply with ${SILENT_REPLY_TOKEN}.`,
].join(" ");
const DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT = [
"Pre-compaction memory flush turn.",
"The session is near auto-compaction; capture durable memories to disk.",
MEMORY_FLUSH_TARGET_HINT,
MEMORY_FLUSH_READ_ONLY_HINT,
MEMORY_FLUSH_APPEND_ONLY_HINT,
`You may reply, but usually ${SILENT_REPLY_TOKEN} is correct.`,
].join(" ");
function formatDateStampInTimezone(nowMs: number, timezone: string): string {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date(nowMs));
const year = parts.find((part) => part.type === "year")?.value;
const month = parts.find((part) => part.type === "month")?.value;
const day = parts.find((part) => part.type === "day")?.value;
if (year && month && day) {
return `${year}-${month}-${day}`;
}
return new Date(resolveMemoryCoreNowMs(nowMs)).toISOString().slice(0, 10);
}
function normalizeNonNegativeInt(value: unknown): number | null {
if (typeof value !== "number" || !Number.isFinite(value)) {
return null;
}
const int = Math.floor(value);
return int >= 0 ? int : null;
}
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}.`;
}
function ensureMemoryFlushSafetyHints(text: string): string {
let next = text.trim();
for (const hint of MEMORY_FLUSH_REQUIRED_HINTS) {
if (!next.includes(hint)) {
next = next ? `${next}\n\n${hint}` : hint;
}
}
return next;
}
function appendCurrentTimeLine(text: string, timeLine: string): string {
const trimmed = text.trimEnd();
if (!trimmed) {
return timeLine;
}
if (trimmed.includes("Current time:")) {
return trimmed;
}
return `${trimmed}\n${timeLine}`;
}
export function buildMemoryFlushPlan(
params: {
cfg?: OpenClawConfig;
nowMs?: number;
} = {},
): MemoryFlushPlan | null {
const resolved = params;
const nowMs = resolveMemoryCoreNowMs(resolved.nowMs);
const cfg = resolved.cfg;
const defaults = cfg?.agents?.defaults?.compaction?.memoryFlush;
if (defaults?.enabled === false) {
return null;
}
const softThresholdTokens =
normalizeNonNegativeInt(defaults?.softThresholdTokens) ?? DEFAULT_MEMORY_FLUSH_SOFT_TOKENS;
const forceFlushTranscriptBytes =
parseNonNegativeByteSize(defaults?.forceFlushTranscriptBytes) ??
DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES;
const reserveTokensFloor = DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR;
const { timeLine, userTimezone } = resolveCronStyleNow(cfg ?? {}, nowMs);
const dateStamp = formatDateStampInTimezone(nowMs, userTimezone);
const relativePath = `memory/${dateStamp}.md`;
const promptBase = ensureNoReplyHint(ensureMemoryFlushSafetyHints(DEFAULT_MEMORY_FLUSH_PROMPT));
const systemPrompt = ensureNoReplyHint(
ensureMemoryFlushSafetyHints(DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT),
);
return {
softThresholdTokens,
forceFlushTranscriptBytes,
reserveTokensFloor,
model: defaults?.model?.trim() || undefined,
prompt: appendCurrentTimeLine(promptBase.replaceAll("YYYY-MM-DD", dateStamp), timeLine),
systemPrompt: systemPrompt.replaceAll("YYYY-MM-DD", dateStamp),
relativePath,
recordWriteProvenance: async (write) => {
const writtenPath = normalizeAgentMemoryPath(write.relativePath);
if (!writtenPath) {
return undefined;
}
const hash = (value: string) => createHash("sha256").update(value).digest("hex");
const existing = await readMemoryCoreWorkspaceEntry<{
fileHash: string;
originClass: "agent" | "untrusted";
observedAt: number;
}>({
namespace: DREAMING_DAILY_PROVENANCE_NAMESPACE,
workspaceDir: write.workspaceDir,
key: writtenPath,
});
const originClass =
write.originClass === "agent" &&
(!existing ||
(existing?.originClass === "agent" && existing.fileHash === hash(write.contentBefore)))
? "agent"
: "untrusted";
// Provenance is file-level and therefore collapses to the least-trusted
// content in the file. Trusted lines in a downgraded file lose promotion
// eligibility; untrusted content must never ride an agent-trusted hash.
await writeMemoryCoreWorkspaceEntry({
namespace: DREAMING_DAILY_PROVENANCE_NAMESPACE,
workspaceDir: write.workspaceDir,
key: writtenPath,
value: { fileHash: hash(write.contentAfter), originClass, observedAt: write.observedAt },
});
return async () => {
if (existing) {
await writeMemoryCoreWorkspaceEntry({
namespace: DREAMING_DAILY_PROVENANCE_NAMESPACE,
workspaceDir: write.workspaceDir,
key: writtenPath,
value: existing,
});
return;
}
await deleteMemoryCoreWorkspaceEntry({
namespace: DREAMING_DAILY_PROVENANCE_NAMESPACE,
workspaceDir: write.workspaceDir,
key: writtenPath,
});
};
},
clearWriteProvenance: async ({ workspaceDir, relativePath: writtenPath }) => {
const normalized = normalizeAgentMemoryPath(writtenPath);
if (!normalized) {
return;
}
await deleteMemoryCoreWorkspaceEntry({
namespace: DREAMING_DAILY_PROVENANCE_NAMESPACE,
workspaceDir,
key: normalized,
});
},
};
}