From e12f3a7a73036542f7cf8a5867588499f8bd5996 Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Tue, 24 Mar 2026 19:26:53 +1000 Subject: [PATCH] v3.0.0: Add /mc interactive commands for Telegram/Discord/CLI New companion plugin (mc-plugin/) registers /mc as a native OpenClaw slash command with 10 subcommands: state, history, restore, clear, search, settings, compact, export. All commands work across multiple agent workspaces and return instant responses (direct file reads, no model invocation). Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 37 +++ README.md | 58 +++- mc-plugin/index.js | 493 +++++++++++++++++++++++++++++++++ mc-plugin/openclaw.plugin.json | 17 ++ mc-plugin/package.json | 14 + package.json | 2 +- 6 files changed, 619 insertions(+), 2 deletions(-) create mode 100644 mc-plugin/index.js create mode 100644 mc-plugin/openclaw.plugin.json create mode 100644 mc-plugin/package.json diff --git a/CHANGELOG.md b/CHANGELOG.md index ae091c5..9ead7fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ # Changelog +## v3.0.0 — 2026-03-24 + +### Summary +Major release: **interactive `/mc` commands** for Telegram, Discord, and CLI. You can now inspect, search, restore, and manage agent memory directly from chat. + +### Added +- **`/mc` slash command plugin** — 10 subcommands registered as a native OpenClaw gateway command + - `/mc state [agent]` — view current working state + - `/mc state --all` — overview of all agents' memory + - `/mc history [agent]` — list archived sessions + - `/mc restore [agent]` — restore from archive + - `/mc clear [agent]` — clear state (auto-archives first) + - `/mc search ` — full-text search across all memory and archives + - `/mc settings` — view/update plugin configuration + - `/mc compact [agent]` — compress oversized state files + - `/mc export [agent|all]` — export memory to markdown file + - `/mc --help` — command reference +- **Multi-agent support** — all commands work across main + sub-agent workspaces +- **Monospace formatting** — output wrapped in code blocks for aligned display in Telegram/Discord +- **Auto-archive on clear** — clearing state automatically archives first, preventing data loss +- **Restore with backup** — restoring an archive backs up current state to `.bak` + +### Changed +- Bumped version to 3.0.0 +- Plugin now ships as two components: `memory-continuity` (lifecycle hooks) + `mc` (slash commands) + +--- + +## v2.7.0 — 2026-03-22 + +### Changed +- Memory directory cleanup: auto-remove legacy `STATE_ARCHIVE_*.md` files +- Max memory files limit (500) with auto-cleanup of oldest files +- Archive count limit configurable via `maxArchiveCount` + +--- + ## v2.3.0 — 2026-03-16 ### Summary diff --git a/README.md b/README.md index 9343aeb..f659af3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # memory-continuity -**Current release:** `v2.7.0` +**Current release:** `v3.0.0` OpenClaw **lifecycle plugin** for short-term working continuity. Preserves structured in-flight work state across `/new`, reset, gateway restarts, model fallback, and context compaction. @@ -92,6 +92,62 @@ openclaw gateway restart 2>&1 | grep memory-continuity 3. Ask "what was the secret?" or "我们刚才聊到哪了" 4. The agent should immediately surface the recovered state +## Interactive Commands (`/mc`) + +v3.0.0 adds a companion plugin that registers `/mc` as a native slash command. Works in Telegram, Discord, and anywhere OpenClaw commands are supported. + +``` +/mc state View main agent's working state +/mc state --all Overview of all agents +/mc state tech_geek View a specific agent's state +/mc history List archived sessions +/mc restore 3 Restore archive #3 +/mc search auth Search "auth" across all memory +/mc settings View plugin settings +/mc settings maxArchiveCount 30 Update a setting +/mc compact Compress oversized state file +/mc export all Export all agents' memory to file +/mc --help Command reference +``` + +The `/mc` plugin reads memory files directly — no HTTP endpoints, no model invocation. Responses are instant. + +### Install the command plugin + +The `/mc` command plugin is separate from the lifecycle plugin. To install: + +```bash +# Clone (if you haven't already) +cd ~/.openclaw/projects +git clone https://github.com/dtzp555-max/memory-continuity.git + +# The mc-plugin is bundled in the mc-plugin/ directory +cp -r memory-continuity/mc-plugin ~/.openclaw/projects/mc-plugin +mkdir -p ~/.openclaw/extensions/mc +cp ~/.openclaw/projects/mc-plugin/* ~/.openclaw/extensions/mc/ +``` + +Then add to `openclaw.json`: +```json +{ + "plugins": { + "allow": ["memory-continuity", "mc"], + "entries": { + "mc": { "enabled": true } + }, + "installs": { + "mc": { + "source": "path", + "sourcePath": "~/.openclaw/projects/mc-plugin", + "installPath": "~/.openclaw/extensions/mc" + } + } + } +} +``` + +Restart the gateway and `/mc --help` should work. + ## Configuration The plugin works with zero configuration. Optional settings in `openclaw.json`: diff --git a/mc-plugin/index.js b/mc-plugin/index.js new file mode 100644 index 0000000..e0c4bc8 --- /dev/null +++ b/mc-plugin/index.js @@ -0,0 +1,493 @@ +/** + * MC Plugin — registers /mc as a native slash command in OpenClaw gateway. + * Provides interactive commands for the Memory Continuity plugin. + */ + +import fs from "node:fs"; +import path from "node:path"; + +// ── Helpers ───────────────────────────────────────────────────────────── + +function mono(text) { return "```\n" + text + "\n```"; } + +const BASE = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "/tmp", ".openclaw"); +const MAIN_WS = path.join(BASE, "workspace", "main", "memory"); +const EXTRA_WS = path.join(BASE, "workspaces"); + +/** Discover all agent workspaces that have memory directories */ +function discoverAgents() { + const agents = []; + + // Main workspace + if (fs.existsSync(path.join(MAIN_WS, "CURRENT_STATE.md"))) { + agents.push({ name: "main", memDir: MAIN_WS }); + } + + // Sub-agent workspaces + try { + for (const d of fs.readdirSync(EXTRA_WS)) { + const memDir = path.join(EXTRA_WS, d, "memory"); + if (fs.existsSync(path.join(memDir, "CURRENT_STATE.md"))) { + agents.push({ name: d, memDir }); + } + } + } catch {} + + return agents; +} + +function resolveMemDir(agentName) { + if (!agentName || agentName === "main") return MAIN_WS; + const p = path.join(EXTRA_WS, agentName, "memory"); + return fs.existsSync(p) ? p : null; +} + +function readFile(fp) { + try { return fs.readFileSync(fp, "utf8"); } catch { return null; } +} + +function extractSection(md, heading) { + const re = new RegExp(`^##\\s+${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, "m"); + return (md.match(re)?.[1] ?? "").trim(); +} + +function relTime(dateStr) { + try { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ${mins % 60}m ago`; + return `${Math.floor(hrs / 24)}d ago`; + } catch { return dateStr || "unknown"; } +} + +function truncate(s, max = 120) { + if (!s) return "(empty)"; + const line = s.split("\n")[0]; + return line.length > max ? line.slice(0, max) + "…" : line; +} + +// ── Subcommand handlers ───────────────────────────────────────────────── + +function cmdState(args) { + const agent = args || "main"; + const memDir = resolveMemDir(agent); + if (!memDir) return `Agent "${agent}" not found.`; + + const md = readFile(path.join(memDir, "CURRENT_STATE.md")); + if (!md) return `No state file for "${agent}".`; + + const updated = md.match(/^> Last updated:\s*(.+)$/m)?.[1]?.trim(); + + let out = `State: ${agent}\n`; + out += `Updated: ${relTime(updated)}\n`; + out += "─────────────────────────────\n"; + for (const section of ["Objective", "Current Step", "Key Decisions", "Next Action", "Blockers"]) { + const val = extractSection(md, section); + // Compact multi-line values + const compact = val.split("\n").slice(0, 3).join("\n"); + out += `${section}:\n ${compact || "(empty)"}\n\n`; + } + return out.trimEnd(); +} + +function cmdStateAll() { + const agents = discoverAgents(); + if (!agents.length) return "No agents with memory found."; + + let out = `Memory States (${agents.length} agents)\n`; + out += "─────────────────────────────\n"; + + for (const { name, memDir } of agents) { + const md = readFile(path.join(memDir, "CURRENT_STATE.md")); + if (!md) { out += `${name.padEnd(20)} (no state)\n`; continue; } + + const updated = md.match(/^> Last updated:\s*(.+)$/m)?.[1]?.trim(); + const objective = extractSection(md, "Objective"); + + out += `${name.padEnd(20)} ${relTime(updated).padEnd(10)} ${truncate(objective, 50)}\n`; + } + return out.trimEnd(); +} + +function cmdHistory(args) { + const agent = args || "main"; + const memDir = resolveMemDir(agent); + if (!memDir) return `Agent "${agent}" not found.`; + + const archiveDir = path.join(memDir, "session_archive"); + let files; + try { files = fs.readdirSync(archiveDir).filter(f => f.endsWith(".md")).sort().reverse(); } + catch { return `No archive for "${agent}".`; } + + if (!files.length) return `No archived sessions for "${agent}".`; + + let out = `Session Archive: ${agent} (${files.length} entries)\n`; + out += "─────────────────────────────\n"; + out += `${"#".padStart(3)} ${"Timestamp".padEnd(20)} Objective\n`; + out += "─".repeat(60) + "\n"; + + for (let i = 0; i < Math.min(files.length, 20); i++) { + const f = files[i]; + const ts = f.replace(".md", "").replace(/_/g, " "); + const md = readFile(path.join(archiveDir, f)); + const obj = md ? truncate(extractSection(md, "Objective"), 35) : "?"; + out += `${String(i + 1).padStart(3)} ${ts.padEnd(20)} ${obj}\n`; + } + + if (files.length > 20) out += `\n ... and ${files.length - 20} more`; + return out.trimEnd(); +} + +function cmdRestore(args) { + const parts = (args || "").trim().split(/\s+/); + const idx = parseInt(parts[0]); + const agent = parts[1] || "main"; + + if (isNaN(idx) || idx < 1) return "Usage: /mc restore [agent]\nN = archive number from /mc history"; + + const memDir = resolveMemDir(agent); + if (!memDir) return `Agent "${agent}" not found.`; + + const archiveDir = path.join(memDir, "session_archive"); + let files; + try { files = fs.readdirSync(archiveDir).filter(f => f.endsWith(".md")).sort().reverse(); } + catch { return `No archive for "${agent}".`; } + + if (idx > files.length) return `Only ${files.length} archives available.`; + + const target = files[idx - 1]; + const content = readFile(path.join(archiveDir, target)); + if (!content) return `Failed to read archive ${target}.`; + + // Backup current state first + const statePath = path.join(memDir, "CURRENT_STATE.md"); + const current = readFile(statePath); + if (current) { + const bak = path.join(memDir, "CURRENT_STATE.md.bak"); + fs.writeFileSync(bak, current, "utf8"); + } + + fs.writeFileSync(statePath, content, "utf8"); + return `✓ Restored archive #${idx} (${target.replace(".md", "")})\n Previous state backed up to CURRENT_STATE.md.bak`; +} + +function cmdClear(args) { + const agent = args || "main"; + const memDir = resolveMemDir(agent); + if (!memDir) return `Agent "${agent}" not found.`; + + const statePath = path.join(memDir, "CURRENT_STATE.md"); + const current = readFile(statePath); + + if (current) { + // Archive before clearing + const archiveDir = path.join(memDir, "session_archive"); + fs.mkdirSync(archiveDir, { recursive: true }); + const now = new Date(); + const pad = (n) => String(n).padStart(2, "0"); + const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}`; + fs.writeFileSync(path.join(archiveDir, `${stamp}.md`), current, "utf8"); + } + + const template = `# Current State +> Last updated: ${new Date().toISOString()} + +## Objective +None + +## Current Step +None + +## Key Decisions +- None + +## Next Action +None + +## Blockers +None + +## Unsurfaced Results +None +`; + fs.writeFileSync(statePath, template, "utf8"); + return `✓ State cleared for "${agent}"\n Previous state archived.`; +} + +function cmdSearch(args) { + if (!args) return "Usage: /mc search [agent]\nSearches current state + archives."; + + const parts = args.trim().split(/\s+/); + // Last part might be an agent name + let keyword, agent; + const agents = discoverAgents(); + const agentNames = new Set(agents.map(a => a.name)); + + if (parts.length > 1 && agentNames.has(parts[parts.length - 1])) { + agent = parts.pop(); + keyword = parts.join(" "); + } else { + keyword = parts.join(" "); + agent = null; // search all + } + + const re = new RegExp(keyword, "gi"); + const results = []; + + const searchAgents = agent ? [{ name: agent, memDir: resolveMemDir(agent) }] : agents; + + for (const { name, memDir } of searchAgents) { + if (!memDir) continue; + + // Search current state + const state = readFile(path.join(memDir, "CURRENT_STATE.md")); + if (state && re.test(state)) { + const lines = state.split("\n").filter(l => re.test(l)); + results.push({ agent: name, source: "CURRENT_STATE", matches: lines.slice(0, 3) }); + } + + // Search archives + const archiveDir = path.join(memDir, "session_archive"); + try { + const files = fs.readdirSync(archiveDir).filter(f => f.endsWith(".md")).sort().reverse(); + for (const f of files.slice(0, 30)) { + const content = readFile(path.join(archiveDir, f)); + if (content && re.test(content)) { + const lines = content.split("\n").filter(l => re.test(l)); + results.push({ agent: name, source: f.replace(".md", ""), matches: lines.slice(0, 2) }); + } + } + } catch {} + } + + if (!results.length) return `No matches for "${keyword}".`; + + let out = `Search: "${keyword}" (${results.length} hits)\n`; + out += "─────────────────────────────\n"; + for (const r of results.slice(0, 15)) { + out += `[${r.agent}] ${r.source}\n`; + for (const line of r.matches) { + out += ` ${truncate(line.trim(), 80)}\n`; + } + out += "\n"; + } + if (results.length > 15) out += ` ... and ${results.length - 15} more hits`; + return out.trimEnd(); +} + +function cmdSettings(args) { + // Read memory-continuity config from openclaw.json + const configPath = path.join(BASE, "openclaw.json"); + const raw = readFile(configPath); + if (!raw) return "Cannot read openclaw.json"; + + let config; + try { config = JSON.parse(raw); } catch { return "Cannot parse openclaw.json"; } + + const mcConfig = config?.plugins?.entries?.["memory-continuity"]?.config + || config?.entries?.["memory-continuity"]?.config + || {}; + + if (!args) { + const defaults = { + maxStateLines: { value: mcConfig.maxStateLines ?? 50, desc: "Max lines before compress warning" }, + archiveOnNew: { value: mcConfig.archiveOnNew ?? true, desc: "Archive state on /new" }, + autoExtract: { value: mcConfig.autoExtract ?? true, desc: "Auto-extract state at agent_end" }, + maxArchiveCount: { value: mcConfig.maxArchiveCount ?? 20, desc: "Max archive files kept" }, + }; + + let out = "MC Settings\n─────────────────────────────\n"; + for (const [k, v] of Object.entries(defaults)) { + out += `${k.padEnd(18)} ${String(v.value).padStart(6)} ${v.desc}\n`; + } + + // Show stats + const agents = discoverAgents(); + out += "\nStats:\n"; + out += ` Agents with memory: ${agents.length}\n`; + let totalArchives = 0; + for (const { memDir } of agents) { + try { + totalArchives += fs.readdirSync(path.join(memDir, "session_archive")).filter(f => f.endsWith(".md")).length; + } catch {} + } + out += ` Total archives: ${totalArchives}\n`; + return out.trimEnd(); + } + + // Parse "key value" for settings update + const parts = args.trim().split(/\s+/); + if (parts.length < 2 || parts[0] === "--help" || parts[0] === "-h") { + return "Usage: /mc settings \nKeys: maxStateLines, archiveOnNew, autoExtract, maxArchiveCount"; + } + + const [key, val] = parts; + const validKeys = ["maxStateLines", "archiveOnNew", "autoExtract", "maxArchiveCount"]; + if (!validKeys.includes(key)) return `Unknown key: ${key}\nValid: ${validKeys.join(", ")}`; + + let parsed; + if (val === "true") parsed = true; + else if (val === "false") parsed = false; + else if (!isNaN(Number(val))) parsed = Number(val); + else return `Cannot parse value: ${val}`; + + // Update openclaw.json + try { + // Ensure path exists + if (!config.entries) config.entries = {}; + if (!config.entries["memory-continuity"]) config.entries["memory-continuity"] = { enabled: true }; + if (!config.entries["memory-continuity"].config) config.entries["memory-continuity"].config = {}; + config.entries["memory-continuity"].config[key] = parsed; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8"); + return `✓ ${key} = ${parsed}\n Restart gateway to apply.`; + } catch (e) { + return `✗ Failed to write config: ${e.message}`; + } +} + +function cmdCompact(args) { + const agent = args || "main"; + const memDir = resolveMemDir(agent); + if (!memDir) return `Agent "${agent}" not found.`; + + const statePath = path.join(memDir, "CURRENT_STATE.md"); + const md = readFile(statePath); + if (!md) return `No state for "${agent}".`; + + const lines = md.split("\n"); + const originalLen = lines.length; + + // Keep headers and first meaningful line of each section + const compacted = []; + let inSection = false; + let sectionLines = 0; + + for (const line of lines) { + if (line.startsWith("# ") || line.startsWith("> Last updated")) { + compacted.push(line); + inSection = false; + } else if (line.startsWith("## ")) { + compacted.push(line); + inSection = true; + sectionLines = 0; + } else if (inSection) { + sectionLines++; + if (sectionLines <= 3 || line.startsWith("- ")) { + compacted.push(line); + } + } else { + compacted.push(line); + } + } + + // Update timestamp + const result = compacted.join("\n") + .replace(/^> Last updated:.*$/m, `> Last updated: ${new Date().toISOString()}`); + + fs.writeFileSync(statePath, result, "utf8"); + return `✓ Compacted "${agent}" state: ${originalLen} → ${compacted.length} lines`; +} + +function cmdExport(args) { + const agent = args || "all"; + const agents = agent === "all" ? discoverAgents() : [{ name: agent, memDir: resolveMemDir(agent) }]; + + if (agents.length === 0 || !agents[0]?.memDir) return `Agent "${agent}" not found.`; + + const exportDir = path.join(BASE, "exports"); + fs.mkdirSync(exportDir, { recursive: true }); + + const now = new Date(); + const pad = (n) => String(n).padStart(2, "0"); + const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}`; + const exportFile = path.join(exportDir, `mc-export-${stamp}.md`); + + let content = `# Memory Continuity Export\n> Exported: ${now.toISOString()}\n\n`; + + for (const { name, memDir } of agents) { + if (!memDir) continue; + content += `---\n# Agent: ${name}\n\n`; + + // Current state + const state = readFile(path.join(memDir, "CURRENT_STATE.md")); + if (state) { + content += `## Current State\n\n${state}\n\n`; + } + + // Archives + const archiveDir = path.join(memDir, "session_archive"); + try { + const files = fs.readdirSync(archiveDir).filter(f => f.endsWith(".md")).sort().reverse(); + if (files.length) { + content += `## Archives (${files.length})\n\n`; + for (const f of files) { + const ac = readFile(path.join(archiveDir, f)); + if (ac) content += `### ${f.replace(".md", "")}\n\n${ac}\n\n`; + } + } + } catch {} + } + + fs.writeFileSync(exportFile, content, "utf8"); + const sizeMB = (Buffer.byteLength(content) / 1024).toFixed(1); + return `✓ Exported to:\n ${exportFile}\n ${agents.length} agent(s), ${sizeMB} KB`; +} + +function cmdHelp() { + return `MC Commands (Memory Continuity) +───────────────────────────── +/mc state [agent] View current state (default: main) +/mc state --all Overview of all agents +/mc history [agent] List archived sessions +/mc restore [agent] Restore archive #N +/mc clear [agent] Clear state (archives first) +/mc search Search across all memory +/mc settings View MC settings +/mc settings Update a setting +/mc compact [agent] Compress state file +/mc export [agent|all] Export memory to file`; +} + +// ── Plugin entry point ────────────────────────────────────────────────── + +export default function (api) { + console.log("[mc] MC plugin loading, registering /mc command..."); + api.registerCommand({ + name: "mc", + description: "Memory Continuity commands — state, history, search, settings, etc.", + acceptsArgs: true, + requireAuth: true, + handler: async (ctx) => { + const raw = (ctx.args || "").trim(); + const spaceIdx = raw.indexOf(" "); + const subcmd = spaceIdx === -1 ? raw : raw.slice(0, spaceIdx); + const subargs = spaceIdx === -1 ? "" : raw.slice(spaceIdx + 1).trim(); + + try { + let text; + switch (subcmd) { + case "state": + text = subargs === "--all" ? cmdStateAll() : cmdState(subargs || null); + break; + case "history": text = cmdHistory(subargs || null); break; + case "restore": text = cmdRestore(subargs); break; + case "clear": text = cmdClear(subargs || null); break; + case "search": text = cmdSearch(subargs); break; + case "settings": text = cmdSettings(subargs || null); break; + case "compact": text = cmdCompact(subargs || null); break; + case "export": text = cmdExport(subargs || null); break; + case "help": case "--help": case "-h": case "": + text = cmdHelp(); break; + default: + text = `Unknown subcommand: ${subcmd}\n\n${cmdHelp()}`; + } + return { text: mono(text) }; + } catch (err) { + return { text: `MC error: ${err.message}` }; + } + }, + }); +} diff --git a/mc-plugin/openclaw.plugin.json b/mc-plugin/openclaw.plugin.json new file mode 100644 index 0000000..c3e93a4 --- /dev/null +++ b/mc-plugin/openclaw.plugin.json @@ -0,0 +1,17 @@ +{ + "id": "mc", + "name": "Memory Continuity Commands", + "description": "Slash commands for Memory Continuity — /mc state, /mc history, /mc search, /mc settings, etc.", + "version": "1.0.0", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "memoryDir": { + "type": "string", + "default": "", + "description": "Override memory directory path (auto-detected from workspace if empty)" + } + } + } +} diff --git a/mc-plugin/package.json b/mc-plugin/package.json new file mode 100644 index 0000000..df5b70f --- /dev/null +++ b/mc-plugin/package.json @@ -0,0 +1,14 @@ +{ + "name": "mc-plugin", + "version": "1.0.0", + "description": "Slash commands for Memory Continuity — /mc state, /mc history, /mc search, etc.", + "main": "index.js", + "type": "module", + "keywords": ["openclaw", "plugin", "memory", "continuity"], + "license": "MIT", + "openclaw": { + "type": "plugin", + "id": "mc", + "pluginManifest": "openclaw.plugin.json" + } +} diff --git a/package.json b/package.json index bba4d55..12654d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "memory-continuity", - "version": "2.7.0", + "version": "3.0.0", "description": "Zero-dependency memory continuity for OpenClaw — plain markdown, lifecycle hooks, no vector DB.", "main": "index.js", "type": "module",