|
/** |
|
* Memory Guardian Plugin for OpenClaw |
|
* ==================================== |
|
* |
|
* Fixes the "my bot keeps forgetting stuff" problem through active enforcement, |
|
* not passive suggestions. AGENTS.md alone doesn't work under attention pressure. |
|
* |
|
* INSTALLATION |
|
* ------------ |
|
* 1. Create directory: mkdir -p <your-workspace>/.openclaw/extensions/memory-guardian |
|
* 2. Copy this file to: <your-workspace>/.openclaw/extensions/memory-guardian/index.js |
|
* 3. Restart OpenClaw — plugin auto-discovered, no config needed |
|
* |
|
* LICENSE: MIT — do whatever you want with it |
|
*/ |
|
|
|
import fs from "node:fs"; |
|
import path from "node:path"; |
|
|
|
const CONFIG = { |
|
maxDailyNotesChars: 4000, |
|
maxSharedStateLines: 20, |
|
memorySearchReminderTurns: 8, |
|
breadcrumbInterval: 10, |
|
checkpointGentle: 12, |
|
checkpointFirm: 20, |
|
checkpointUrgent: 30, |
|
}; |
|
|
|
const STATE_FILE = ".memory-guardian-state.json"; |
|
const COMPACTION_MARKER = ".compaction-pending"; |
|
const RESET_MARKER = ".reset-pending"; |
|
const SHARED_STATE_FILE = "shared-state.md"; |
|
|
|
const todayStr = () => new Date().toISOString().slice(0, 10); |
|
const yesterdayStr = () => { |
|
const d = new Date(); |
|
d.setDate(d.getDate() - 1); |
|
return d.toISOString().slice(0, 10); |
|
}; |
|
const timeStr = () => new Date().toISOString().slice(11, 16); |
|
const memoryDir = (workspaceDir) => path.join(workspaceDir, "memory"); |
|
|
|
function ensureMemoryDir(workspaceDir) { |
|
const dir = memoryDir(workspaceDir); |
|
try { fs.mkdirSync(dir, { recursive: true }); } catch { } |
|
return dir; |
|
} |
|
|
|
function readFileSafe(filePath, maxChars) { |
|
try { |
|
if (!fs.existsSync(filePath)) return null; |
|
let content = fs.readFileSync(filePath, "utf-8"); |
|
if (maxChars && content.length > maxChars) { |
|
content = content.slice(0, maxChars) + "\n\n[... truncated at " + maxChars + " chars ...]"; |
|
} |
|
return content; |
|
} catch { return null; } |
|
} |
|
|
|
function writeJsonSafe(filePath, data) { |
|
try { fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); return true; } |
|
catch { return false; } |
|
} |
|
|
|
function readJsonSafe(filePath) { |
|
try { |
|
if (!fs.existsSync(filePath)) return null; |
|
return JSON.parse(fs.readFileSync(filePath, "utf-8")); |
|
} catch { return null; } |
|
} |
|
|
|
function unlinkSafe(filePath) { |
|
try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch { } |
|
} |
|
|
|
function appendToFile(filePath, text) { |
|
try { fs.appendFileSync(filePath, text); return true; } |
|
catch { return false; } |
|
} |
|
|
|
function getWorkspaceDir(ctx, config) { |
|
return ctx?.workspaceDir || config?.agents?.defaults?.workspaceDir || process.cwd(); |
|
} |
|
|
|
function defaultState() { |
|
return { |
|
turnsSinceMemorySearch: 0, |
|
lastMemorySearchTime: null, |
|
lastCheckpointTime: null, |
|
totalTurns: 0, |
|
toolCallsSinceCheckpoint: 0, |
|
toolCallsSinceBreadcrumb: 0, |
|
recentTools: [], |
|
}; |
|
} |
|
|
|
function readState(workspaceDir) { |
|
const statePath = path.join(memoryDir(workspaceDir), STATE_FILE); |
|
return { ...defaultState(), ...readJsonSafe(statePath) }; |
|
} |
|
|
|
function writeState(workspaceDir, state) { |
|
const dir = ensureMemoryDir(workspaceDir); |
|
writeJsonSafe(path.join(dir, STATE_FILE), state); |
|
} |
|
|
|
export default function memoryGuardian(api) { |
|
const log = api.logger; |
|
|
|
api.on("before_agent_start", (event, ctx) => { |
|
const workspaceDir = getWorkspaceDir(ctx, api.config); |
|
const memDir = memoryDir(workspaceDir); |
|
const parts = []; |
|
|
|
const compactionData = readJsonSafe(path.join(memDir, COMPACTION_MARKER)); |
|
if (compactionData) { |
|
parts.push( |
|
`⚠️ MEMORY ALERT: Session was compacted at ${compactionData.timestamp}. ` + |
|
`Run memory_search and check memory/${todayStr()}.md for gaps.` |
|
); |
|
unlinkSafe(path.join(memDir, COMPACTION_MARKER)); |
|
} |
|
|
|
const resetData = readJsonSafe(path.join(memDir, RESET_MARKER)); |
|
if (resetData) { |
|
parts.push( |
|
`⚠️ MEMORY ALERT: Session was reset at ${resetData.timestamp}. ` + |
|
`Review memory/${todayStr()}.md for gaps.` |
|
); |
|
unlinkSafe(path.join(memDir, RESET_MARKER)); |
|
} |
|
|
|
const state = readState(workspaceDir); |
|
if (state.turnsSinceMemorySearch >= CONFIG.memorySearchReminderTurns) { |
|
parts.push( |
|
`📝 MEMORY REMINDER: You haven't called memory_search in ${state.turnsSinceMemorySearch} turns. ` + |
|
`If you're about to answer a question about past work, decisions, or preferences — search first.` |
|
); |
|
} |
|
|
|
const toolsSinceCP = state.toolCallsSinceCheckpoint || 0; |
|
if (toolsSinceCP >= CONFIG.checkpointUrgent) { |
|
parts.push( |
|
`🚨 CHECKPOINT OVERDUE (${toolsSinceCP} tool calls without writing to memory):\n` + |
|
`STOP. Before doing ANYTHING else, write a checkpoint to memory/${todayStr()}.md.\n` + |
|
`Include: what you've been working on, key decisions, current state, next steps.\n` + |
|
`This is not optional. Write the checkpoint NOW.` |
|
); |
|
} else if (toolsSinceCP >= CONFIG.checkpointFirm) { |
|
parts.push(`⚠️ CHECKPOINT NEEDED (${toolsSinceCP} tool calls without writing to memory):\nWrite a checkpoint to memory/${todayStr()}.md soon.`); |
|
} else if (toolsSinceCP >= CONFIG.checkpointGentle) { |
|
parts.push(`📋 Consider checkpointing — ${toolsSinceCP} tool calls since your last memory write.`); |
|
} |
|
|
|
const todayNotes = readFileSafe(path.join(memDir, `${todayStr()}.md`), CONFIG.maxDailyNotesChars); |
|
if (todayNotes) parts.push(`## Today's Notes (${todayStr()})\n${todayNotes}`); |
|
|
|
const yesterdayNotes = readFileSafe(path.join(memDir, `${yesterdayStr()}.md`), CONFIG.maxDailyNotesChars); |
|
if (yesterdayNotes) parts.push(`## Yesterday's Notes (${yesterdayStr()})\n${yesterdayNotes}`); |
|
|
|
const sharedState = readFileSafe(path.join(memDir, SHARED_STATE_FILE), 2000); |
|
if (sharedState) parts.push(`## Cross-Session State\n${sharedState}`); |
|
|
|
state.totalTurns += 1; |
|
state.turnsSinceMemorySearch += 1; |
|
writeState(workspaceDir, state); |
|
|
|
if (parts.length === 0) return; |
|
return { prependContext: "# Memory Guardian Context\n\n" + parts.join("\n\n") }; |
|
}, { priority: 50 }); |
|
|
|
api.on("before_compaction", (event, ctx) => { |
|
const workspaceDir = getWorkspaceDir(ctx, api.config); |
|
const dir = ensureMemoryDir(workspaceDir); |
|
const state = readState(workspaceDir); |
|
|
|
const data = { |
|
timestamp: new Date().toISOString(), |
|
sessionKey: ctx?.sessionKey || "unknown", |
|
messageCount: event?.messageCount || 0, |
|
toolCallsSinceCheckpoint: state.toolCallsSinceCheckpoint || 0, |
|
}; |
|
writeJsonSafe(path.join(dir, COMPACTION_MARKER), data); |
|
|
|
const breadcrumb = `\n## Auto-Breadcrumb: Pre-Compaction (${timeStr()} UTC)\n` + |
|
`- Messages: ${data.messageCount}\n` + |
|
`- Tool calls since checkpoint: ${data.toolCallsSinceCheckpoint}\n` + |
|
`- ⚠️ Compaction happening — context about to be compressed\n`; |
|
appendToFile(path.join(dir, `${todayStr()}.md`), breadcrumb); |
|
|
|
log.info(`memory-guardian: compaction marker written`); |
|
}, { priority: 100 }); |
|
|
|
api.on("before_reset", (event, ctx) => { |
|
const workspaceDir = getWorkspaceDir(ctx, api.config); |
|
const dir = ensureMemoryDir(workspaceDir); |
|
const state = readState(workspaceDir); |
|
|
|
const data = { |
|
timestamp: new Date().toISOString(), |
|
sessionKey: ctx?.sessionKey || "unknown", |
|
reason: event?.reason || "manual", |
|
toolCallsSinceCheckpoint: state.toolCallsSinceCheckpoint || 0, |
|
}; |
|
writeJsonSafe(path.join(dir, RESET_MARKER), data); |
|
|
|
const breadcrumb = `\n## Auto-Breadcrumb: Pre-Reset (${timeStr()} UTC)\n` + |
|
`- Reason: ${data.reason}\n` + |
|
`- Tool calls since checkpoint: ${data.toolCallsSinceCheckpoint}\n` + |
|
`- ⚠️ Session being reset — all context will be lost\n`; |
|
appendToFile(path.join(dir, `${todayStr()}.md`), breadcrumb); |
|
|
|
log.info(`memory-guardian: reset marker written`); |
|
}, { priority: 100 }); |
|
|
|
api.on("after_tool_call", (event, ctx) => { |
|
const toolName = event?.toolName || event?.name || ""; |
|
if (toolName !== "memory_search") return; |
|
|
|
const workspaceDir = getWorkspaceDir(ctx, api.config); |
|
const state = readState(workspaceDir); |
|
state.turnsSinceMemorySearch = 0; |
|
state.lastMemorySearchTime = new Date().toISOString(); |
|
writeState(workspaceDir, state); |
|
}, { priority: 10 }); |
|
|
|
api.on("after_tool_call", (event, ctx) => { |
|
const toolName = event?.toolName || event?.name || ""; |
|
if (!toolName) return; |
|
|
|
const workspaceDir = getWorkspaceDir(ctx, api.config); |
|
const state = readState(workspaceDir); |
|
const params = event?.params || event?.arguments || {}; |
|
const target = params?.file_path || params?.path || ""; |
|
|
|
state.toolCallsSinceCheckpoint = (state.toolCallsSinceCheckpoint || 0) + 1; |
|
state.toolCallsSinceBreadcrumb = (state.toolCallsSinceBreadcrumb || 0) + 1; |
|
|
|
if (!Array.isArray(state.recentTools)) state.recentTools = []; |
|
const shortTarget = typeof target === "string" ? target.split("/").slice(-1)[0] : ""; |
|
state.recentTools.push(`${toolName}${shortTarget ? ":" + shortTarget : ""}`); |
|
if (state.recentTools.length > 10) state.recentTools = state.recentTools.slice(-10); |
|
|
|
const isMemoryWrite = |
|
["Write", "write", "Edit", "edit"].includes(toolName) && |
|
typeof target === "string" && |
|
target.includes("memory/") && |
|
target.endsWith(".md"); |
|
|
|
if (isMemoryWrite) { |
|
state.toolCallsSinceCheckpoint = 0; |
|
state.toolCallsSinceBreadcrumb = 0; |
|
state.lastCheckpointTime = new Date().toISOString(); |
|
writeState(workspaceDir, state); |
|
return; |
|
} |
|
|
|
if (["write", "Write", "edit", "Edit"].includes(toolName)) { |
|
const dir = ensureMemoryDir(workspaceDir); |
|
const sharedStatePath = path.join(dir, SHARED_STATE_FILE); |
|
const sessionLabel = (ctx?.sessionKey || "unknown").split(":").slice(-2).join(":"); |
|
const line = `- ${timeStr()} [${sessionLabel}] ${toolName}: ${shortTarget}`; |
|
|
|
try { |
|
let lines = fs.existsSync(sharedStatePath) |
|
? fs.readFileSync(sharedStatePath, "utf-8").split("\n").filter(Boolean) |
|
: []; |
|
lines.push(line); |
|
if (lines.length > CONFIG.maxSharedStateLines) { |
|
lines = lines.slice(-CONFIG.maxSharedStateLines); |
|
} |
|
fs.writeFileSync(sharedStatePath, lines.join("\n") + "\n"); |
|
} catch { } |
|
} |
|
|
|
if (state.toolCallsSinceBreadcrumb >= CONFIG.breadcrumbInterval) { |
|
const dir = ensureMemoryDir(workspaceDir); |
|
const sessionLabel = (ctx?.sessionKey || "unknown").split(":").slice(-2).join(":"); |
|
const breadcrumb = |
|
`\n### Auto-Breadcrumb (${timeStr()} UTC)\n` + |
|
`- Session: ${sessionLabel}\n` + |
|
`- Tool calls since checkpoint: ${state.toolCallsSinceCheckpoint}\n` + |
|
`- Recent: ${state.recentTools.slice(-5).join(", ")}\n`; |
|
appendToFile(path.join(dir, `${todayStr()}.md`), breadcrumb); |
|
state.toolCallsSinceBreadcrumb = 0; |
|
} |
|
|
|
writeState(workspaceDir, state); |
|
}, { priority: 8 }); |
|
|
|
log.info("memory-guardian: loaded — context injection + checkpoint gate + tracking"); |
|
} |
|
|