mirror of
https://github.com/dtzp555-max/memory-continuity.git
synced 2026-07-21 21:15:07 +00:00
846 lines
31 KiB
JavaScript
846 lines
31 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)) {
|
|
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 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;
|
|
|
|
// 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 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`;
|
|
}
|
|
|
|
// ── 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 "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}` };
|
|
}
|
|
},
|
|
});
|
|
}
|