Files
memory-continuity/mc-plugin/index.js
T
taodengandClaude Opus 4.6 3d74a0b4e2 fix: v5.0.1 — critical bug fixes from code review
- C1: Remove regex g flag in cmdSearch (skipped matches due to lastIndex)
- C2: Escape user input in RegExp constructor (crash on special chars)
- C3: Convert STATE_TEMPLATE to stateTemplate() function (stale timestamp)
- C4: Validate ignorePatterns length to prevent ReDoS
- W3: Path traversal guard on agentId in resolveAgentWorkspace + discoverAgents
- W7: Correct ISO 8601 week number calculation at year boundaries
- W8: Fix false tag dedup with section-specific regex extraction
- Add context-engine-evaluation.md (decision: hooks-only, no CE variant)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 17:51:52 +10:00

884 lines
32 KiB
JavaScript

/**
* 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)) {
if (!/^[\w-]+$/.test(d)) continue;
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 <N> [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 <keyword> [agent]\nSearches state, archives, sessions, and summaries.";
const parts = args.trim().split(/\s+/);
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;
}
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(escaped, "i");
const results = [];
const searchAgents = agent ? [{ name: agent, memDir: resolveMemDir(agent) }] : agents;
for (const { name, memDir } of searchAgents) {
if (!memDir) continue;
// 1. Search current state
const state = readFile(path.join(memDir, "CURRENT_STATE.md"));
if (state && re.test(state)) {
re.lastIndex = 0;
const lines = state.split("\n").filter(l => re.test(l));
results.push({ agent: name, source: "CURRENT_STATE", type: "state", matches: lines.slice(0, 3) });
}
// 2. Search archives (most recent 30)
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)) {
re.lastIndex = 0;
const content = readFile(path.join(archiveDir, f));
if (content && re.test(content)) {
re.lastIndex = 0;
const lines = content.split("\n").filter(l => re.test(l));
results.push({ agent: name, source: f.replace(".md", ""), type: "archive", matches: lines.slice(0, 2) });
}
}
} catch {}
// 3. Search session logs (most recent 14 days)
const sessionsDir = path.join(memDir, "sessions");
try {
const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".md")).sort().reverse();
for (const f of files.slice(0, 14)) {
re.lastIndex = 0;
const content = readFile(path.join(sessionsDir, f));
if (content && re.test(content)) {
re.lastIndex = 0;
const lines = content.split("\n").filter(l => re.test(l));
results.push({ agent: name, source: f.replace(".md", ""), type: "session", matches: lines.slice(0, 2) });
}
}
} catch {}
// 4. Search summaries (daily + weekly)
for (const sub of ["daily", "weekly"]) {
const sumDir = path.join(memDir, "summaries", sub);
try {
const files = fs.readdirSync(sumDir).filter(f => f.endsWith(".md")).sort().reverse();
for (const f of files.slice(0, 10)) {
re.lastIndex = 0;
const content = readFile(path.join(sumDir, f));
if (content && re.test(content)) {
re.lastIndex = 0;
const lines = content.split("\n").filter(l => re.test(l));
results.push({ agent: name, source: f.replace(".md", ""), type: sub, matches: lines.slice(0, 2) });
}
}
} catch {}
}
}
if (!results.length) return `No matches for "${keyword}".`;
// Group by type for clearer output
let out = `Search: "${keyword}" (${results.length} hits)\n`;
out += "─────────────────────────────\n";
const typeOrder = ["state", "archive", "session", "daily", "weekly"];
const typeLabels = { state: "State", archive: "Archive", session: "Session", daily: "Daily", weekly: "Weekly" };
for (const type of typeOrder) {
const group = results.filter(r => r.type === type);
if (group.length === 0) continue;
out += `\n[${typeLabels[type]}]\n`;
for (const r of group.slice(0, 5)) {
out += ` ${r.agent}/${r.source}\n`;
for (const line of r.matches) {
out += ` ${truncate(line.trim(), 75)}\n`;
}
}
if (group.length > 5) out += ` ... +${group.length - 5} more\n`;
}
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 <key> <value>\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 parts = (args || "").trim().split(/\s+/);
// Parse args: [agent|all] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--tag #tag]
let agent = "all";
let fromDate = null;
let toDate = null;
let filterTag = null;
for (let i = 0; i < parts.length; i++) {
if (parts[i] === "--from" && parts[i + 1]) { fromDate = parts[++i]; continue; }
if (parts[i] === "--to" && parts[i + 1]) { toDate = parts[++i]; continue; }
if (parts[i] === "--tag" && parts[i + 1]) { filterTag = parts[++i].toLowerCase(); continue; }
if (parts[i] && !parts[i].startsWith("-")) agent = parts[i];
}
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`;
if (fromDate || toDate) content += `> Date range: ${fromDate || "start"} to ${toDate || "now"}\n`;
if (filterTag) content += `> Tag filter: ${filterTag}\n`;
content += "\n";
const matchesDateRange = (filename) => {
if (!fromDate && !toDate) return true;
const dateMatch = filename.match(/(\d{4}-\d{2}-\d{2})/);
if (!dateMatch) return true; // Include files without dates
const fileDate = dateMatch[1];
if (fromDate && fileDate < fromDate) return false;
if (toDate && fileDate > toDate) return false;
return true;
};
const matchesTag = (text) => {
if (!filterTag) return true;
return text.toLowerCase().includes(filterTag);
};
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 && matchesTag(state)) {
content += `## Current State\n\n${state}\n\n`;
}
// Archives (filtered)
const archiveDir = path.join(memDir, "session_archive");
try {
const files = fs.readdirSync(archiveDir).filter(f => f.endsWith(".md") && matchesDateRange(f)).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 && matchesTag(ac)) content += `### ${f.replace(".md", "")}\n\n${ac}\n\n`;
}
}
} catch {}
// Session logs (filtered)
const sessionsDir = path.join(memDir, "sessions");
try {
const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".md") && matchesDateRange(f)).sort().reverse();
if (files.length) {
content += `## Session Logs (${files.length} days)\n\n`;
for (const f of files) {
const sc = readFile(path.join(sessionsDir, f));
if (sc && matchesTag(sc)) content += `### ${f.replace(".md", "")}\n\n${sc}\n\n`;
}
}
} catch {}
// Summaries (filtered)
for (const sub of ["daily", "weekly"]) {
const sumDir = path.join(memDir, "summaries", sub);
try {
const files = fs.readdirSync(sumDir).filter(f => f.endsWith(".md") && matchesDateRange(f)).sort().reverse();
if (files.length) {
content += `## ${sub.charAt(0).toUpperCase() + sub.slice(1)} Summaries (${files.length})\n\n`;
for (const f of files) {
const sm = readFile(path.join(sumDir, f));
if (sm && matchesTag(sm)) content += `### ${f.replace(".md", "")}\n\n${sm}\n\n`;
}
}
} catch {}
}
}
fs.writeFileSync(exportFile, content, "utf8");
const sizeKB = (Buffer.byteLength(content) / 1024).toFixed(1);
return `✓ Exported to:\n ${exportFile}\n ${agents.length} agent(s), ${sizeKB} KB`;
}
function cmdSessions(args) {
const parts = (args || "").trim().split(/\s+/);
const agent = parts.find(p => !p.startsWith("-") && !p.startsWith("2")) || "main";
const dateArg = parts.find(p => /^\d{4}-\d{2}-\d{2}$/.test(p));
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
const sessionsDir = path.join(memDir, "sessions");
let files;
try { files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".md")).sort().reverse(); }
catch { return `No session logs for "${agent}".`; }
if (!files.length) return `No session logs for "${agent}".`;
// If date specified, show that day's log
if (dateArg) {
const target = `${dateArg}.md`;
const content = readFile(path.join(sessionsDir, target));
if (!content) return `No session log for ${dateArg}.`;
// Truncate to last 50 lines to stay compact
const lines = content.split("\n");
const shown = lines.length > 50 ? lines.slice(-50) : lines;
let out = shown.join("\n");
if (lines.length > 50) out = `... (${lines.length - 50} earlier lines omitted)\n\n` + out;
return out;
}
// List recent session logs
let out = `Session Logs: ${agent} (${files.length} days)\n`;
out += "─────────────────────────────\n";
for (const f of files.slice(0, 14)) {
const date = f.replace(".md", "");
const content = readFile(path.join(sessionsDir, f));
// Count session entries (### HH:MM headers)
const sessionCount = content ? (content.match(/^### \d{2}:\d{2}/gm) || []).length : 0;
out += `${date} ${String(sessionCount).padStart(3)} session(s)\n`;
}
if (files.length > 14) out += `\n ... and ${files.length - 14} more days`;
out += `\n\nUse /mc sessions <YYYY-MM-DD> to view a specific day.`;
return out.trimEnd();
}
function cmdTags(args) {
const agent = args || "main";
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
const tagsFile = path.join(memDir, "tags.md");
const content = readFile(tagsFile);
if (!content) return `No tags for "${agent}".`;
// Parse tags and their date counts
const tagSections = content.split(/^## /gm).filter(s => s.trim());
if (tagSections.length === 0) return `No tags for "${agent}".`;
let out = `Tags: ${agent} (${tagSections.length} tags)\n`;
out += "─────────────────────────────\n";
const tags = [];
for (const section of tagSections) {
const lines = section.trim().split("\n");
const tag = lines[0].trim();
if (!tag.startsWith("#")) continue;
const dates = lines.filter(l => l.startsWith("- "));
tags.push({ tag, count: dates.length, latest: dates[0]?.replace("- ", "") || "?" });
}
// Sort by count descending
tags.sort((a, b) => b.count - a.count);
for (const { tag, count, latest } of tags.slice(0, 30)) {
out += `${tag.padEnd(25)} ${String(count).padStart(3)} days latest: ${latest}\n`;
}
if (tags.length > 30) out += `\n ... and ${tags.length - 30} more tags`;
return out.trimEnd();
}
function cmdSummary(args) {
const parts = (args || "").trim().split(/\s+/);
const agent = parts.find(p => !p.startsWith("-") && !/^\d{4}/.test(p) && p !== "daily" && p !== "weekly") || "main";
const typeArg = parts.find(p => p === "daily" || p === "weekly");
const dateArg = parts.find(p => /^\d{4}/.test(p));
const memDir = resolveMemDir(agent);
if (!memDir) return `Agent "${agent}" not found.`;
// If specific date given, show that summary
if (dateArg) {
const type = typeArg || (dateArg.includes("W") ? "weekly" : "daily");
const sumFile = path.join(memDir, "summaries", type, `${dateArg}.md`);
const content = readFile(sumFile);
if (!content) return `No ${type} summary for ${dateArg}.`;
return content;
}
// List available summaries
const types = typeArg ? [typeArg] : ["daily", "weekly"];
let out = `Summaries: ${agent}\n`;
out += "─────────────────────────────\n";
for (const type of types) {
const sumDir = path.join(memDir, "summaries", type);
let files;
try { files = fs.readdirSync(sumDir).filter(f => f.endsWith(".md")).sort().reverse(); }
catch { continue; }
if (files.length === 0) continue;
out += `\n[${type.charAt(0).toUpperCase() + type.slice(1)}] (${files.length})\n`;
for (const f of files.slice(0, 10)) {
const name = f.replace(".md", "");
const content = readFile(path.join(sumDir, f));
const sessMatch = content?.match(/\*\*(?:Total s|S)essions:\*\*\s*(\d+)/);
const sessions = sessMatch ? sessMatch[1] : "?";
out += ` ${name} ${sessions} session(s)\n`;
}
if (files.length > 10) out += ` ... +${files.length - 10} more\n`;
}
out += `\nUse /mc summary <YYYY-MM-DD> or /mc summary <YYYY-Www> to view details.`;
return out.trimEnd();
}
function cmdRecall(args) {
if (!args) return "Usage: /mc recall <topic>\nSearches history and returns the most relevant entries for context injection.";
const topic = args.trim();
const agents = discoverAgents();
if (agents.length === 0) return "No agents with memory found.";
// Search across all agents' session logs and summaries
const words = topic
.replace(/[^\w\u4e00-\u9fff\u3400-\u4dbf-]/g, " ")
.split(/\s+/)
.filter(w => w.length > 2)
.map(w => w.toLowerCase());
if (words.length === 0) return "Need more specific topic keywords.";
const scored = [];
for (const { name, memDir } of agents) {
if (!memDir) continue;
// Search session logs
const sessionsDir = path.join(memDir, "sessions");
try {
const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".md")).sort().reverse();
for (const f of files.slice(0, 14)) {
const content = readFile(path.join(sessionsDir, f));
if (!content) continue;
const entries = content.split(/^### /gm).filter(e => e.trim());
for (const entry of entries) {
let score = 0;
const lower = entry.toLowerCase();
for (const w of words) {
if (lower.includes(w)) score++;
}
if (score >= 2) {
const topicMatch = entry.match(/\*\*Topic:\*\*\s*(.+)/);
const time = entry.match(/^(\d{2}:\d{2})/)?.[1] || "";
scored.push({
agent: name,
date: `${f.replace(".md", "")} ${time}`,
score,
text: topicMatch?.[1] || entry.split("\n")[0].slice(0, 80)
});
}
}
}
} catch {}
// Search daily summaries
const dailyDir = path.join(memDir, "summaries", "daily");
try {
const files = fs.readdirSync(dailyDir).filter(f => f.endsWith(".md")).sort().reverse();
for (const f of files.slice(0, 14)) {
const content = readFile(path.join(dailyDir, f));
if (!content) continue;
let score = 0;
const lower = content.toLowerCase();
for (const w of words) {
if (lower.includes(w)) score++;
}
if (score >= 2) {
const topicsMatch = content.match(/## (?:Key )?Topics\n([\s\S]*?)(?:\n##|$)/);
const topics = topicsMatch ? topicsMatch[1].trim().split("\n").slice(0, 3).join("; ") : "";
scored.push({
agent: name,
date: f.replace(".md", ""),
score,
text: topics || "(daily summary)"
});
}
}
} catch {}
// 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, 20)) {
const content = readFile(path.join(archiveDir, f));
if (!content) continue;
let score = 0;
const lower = content.toLowerCase();
for (const w of words) {
if (lower.includes(w)) score++;
}
if (score >= 2) {
const objMatch = content.match(/## Objective\n(.+)/);
scored.push({
agent: name,
date: f.replace(".md", "").replace(/_/g, " "),
score,
text: objMatch?.[1]?.slice(0, 80) || "(archived state)"
});
}
}
} catch {}
}
if (scored.length === 0) return `No relevant history found for "${topic}".`;
scored.sort((a, b) => b.score - a.score);
const top = scored.slice(0, 10);
let out = `Recall: "${topic}" (${scored.length} matches, showing top ${top.length})\n`;
out += "─────────────────────────────\n";
for (const item of top) {
out += `[${item.agent}] ${item.date} (score: ${item.score})\n`;
out += ` ${truncate(item.text, 80)}\n\n`;
}
return out.trimEnd();
}
function cmdSubagents(args) {
const agents = discoverAgents();
if (agents.length === 0) return "No agents with memory found.";
let out = "Subagent State Overview\n";
out += "═══════════════════════\n\n";
for (const { name, memDir } of agents) {
const statePath = path.join(memDir, "CURRENT_STATE.md");
const content = readFile(statePath);
if (!content) {
out += `[${name}] No state file\n\n`;
continue;
}
const objMatch = content.match(/## Objective\n([\s\S]*?)(?=\n## )/);
const unsurfMatch = content.match(/## Unsurfaced Results\n([\s\S]*?)(?=\n## |$)/);
const updatedMatch = content.match(/^> Last updated:\s*(.+)$/m);
const objective = objMatch?.[1]?.trim() || "None";
const unsurfaced = unsurfMatch?.[1]?.trim() || "None";
const updated = updatedMatch?.[1]?.trim() || "unknown";
out += `[${name}] Updated: ${updated}\n`;
out += ` Objective: ${truncate(objective.split("\n")[0], 80)}\n`;
if (unsurfaced !== "None") {
out += ` Unsurfaced: ${truncate(unsurfaced.split("\n")[0], 80)}\n`;
}
out += "\n";
}
return out.trimEnd();
}
function cmdHelp() {
return `MC Commands (Memory Continuity)
─────────────────────────────
/mc state [agent] View current state (default: main)
/mc state --all Overview of all agents
/mc sessions [date] Session logs (daily activity)
/mc summary [daily|weekly] List or view summaries
/mc tags [agent] View tag index
/mc history [agent] List archived sessions
/mc restore <N> [agent] Restore archive #N
/mc clear [agent] Clear state (archives first)
/mc search <keyword> Search across all memory
/mc settings View MC settings
/mc settings <k> <v> Update a setting
/mc compact [agent] Compress state file
/mc export [agent|all] Export (--from/--to/--tag)
/mc recall <topic> Find relevant history by topic
/mc subagents Subagent state overview across workspaces`;
}
// ── 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 "sessions": text = cmdSessions(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 "tags": text = cmdTags(subargs || null); break;
case "summary": text = cmdSummary(subargs || null); break;
case "recall": text = cmdRecall(subargs); break;
case "subagents": text = cmdSubagents(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}` };
}
},
});
}