279 lines
8.6 KiB
JavaScript
279 lines
8.6 KiB
JavaScript
/**
|
|
* Signet Agent Memory Hook
|
|
*
|
|
* Unified memory system for all AI harnesses (OpenClaw, Claude Code, OpenCode, Codex)
|
|
* Features: hybrid search (vector + BM25), auto-embedding, cross-harness persistence
|
|
*
|
|
* Spec: Signet v0.2.1 - https://signetai.sh
|
|
*/
|
|
import { spawn } from "node:child_process";
|
|
import path from "node:path";
|
|
import os from "node:os";
|
|
import fs from "node:fs/promises";
|
|
|
|
const MEMORY_SCRIPT = path.join(os.homedir(), ".agents/memory/scripts/memory.py");
|
|
const MEMORY_MD_PATH = path.join(os.homedir(), ".agents/memory/MEMORY.md");
|
|
|
|
/**
|
|
* Run memory.py command and return stdout
|
|
* @param {string[]} args
|
|
* @returns {Promise<string>}
|
|
*/
|
|
async function runMemoryScript(args) {
|
|
return new Promise((resolve, reject) => {
|
|
const proc = spawn("python3", [MEMORY_SCRIPT, ...args], {
|
|
timeout: 5000,
|
|
});
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
|
|
proc.stdout.on("data", (data) => {
|
|
stdout += data.toString();
|
|
});
|
|
|
|
proc.stderr.on("data", (data) => {
|
|
stderr += data.toString();
|
|
});
|
|
|
|
proc.on("close", (code) => {
|
|
if (code === 0) {
|
|
resolve(stdout.trim());
|
|
} else {
|
|
reject(new Error(stderr || `memory.py exited with code ${code}`));
|
|
}
|
|
});
|
|
|
|
proc.on("error", (err) => {
|
|
reject(err);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Read recent messages from session file for memory extraction
|
|
*/
|
|
async function getRecentSessionContent(sessionFilePath) {
|
|
try {
|
|
const content = await fs.readFile(sessionFilePath, "utf-8");
|
|
const lines = content.trim().split("\n");
|
|
// Get last 20 lines (recent conversation)
|
|
const recentLines = lines.slice(-20);
|
|
|
|
const messages = [];
|
|
for (const line of recentLines) {
|
|
try {
|
|
const entry = JSON.parse(line);
|
|
if (entry.type === "message" && entry.message) {
|
|
const msg = entry.message;
|
|
const role = msg.role;
|
|
if ((role === "user" || role === "assistant") && msg.content) {
|
|
const text = Array.isArray(msg.content)
|
|
? msg.content.find((c) => c.type === "text")?.text
|
|
: msg.content;
|
|
if (text && !text.startsWith("/")) {
|
|
messages.push(`${role}: ${text}`);
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Skip invalid JSON lines
|
|
}
|
|
}
|
|
return messages.join("\n");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle /remember command
|
|
*/
|
|
async function handleRemember(event) {
|
|
const context = event.context || {};
|
|
const args = context.args || "";
|
|
|
|
if (!args.trim()) {
|
|
event.messages.push("🧠 Usage: /remember <content>\n\nPrefixes:\n- `critical:` for pinned memories\n- `[tag1,tag2]:` for tagged memories");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Detect harness from session key or default to openclaw
|
|
const harness = event.sessionKey?.includes("claude") ? "claude-code"
|
|
: event.sessionKey?.includes("opencode") ? "opencode"
|
|
: "openclaw";
|
|
|
|
const result = await runMemoryScript([
|
|
"save",
|
|
"--mode", "explicit",
|
|
"--who", harness,
|
|
"--project", context.cwd || os.homedir(),
|
|
"--content", args.trim()
|
|
]);
|
|
event.messages.push(`🧠 ${result}`);
|
|
} catch (err) {
|
|
event.messages.push(`🧠 Error saving memory: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle /recall command
|
|
*/
|
|
async function handleRecall(event) {
|
|
const context = event.context || {};
|
|
const args = context.args || "";
|
|
|
|
if (!args.trim()) {
|
|
event.messages.push("🧠 Usage: /recall <search query>");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await runMemoryScript(["query", args.trim(), "--limit", "10"]);
|
|
if (result) {
|
|
event.messages.push(`🧠 Memory search results:\n\n${result}`);
|
|
} else {
|
|
event.messages.push("🧠 No memories found matching your query.");
|
|
}
|
|
} catch (err) {
|
|
event.messages.push(`🧠 Error querying memory: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run the sync-memory-context script to update AGENTS.md
|
|
*/
|
|
async function runSyncScript() {
|
|
const syncScript = path.join(os.homedir(), "clawd/scripts/sync-memory-context.sh");
|
|
return new Promise((resolve, reject) => {
|
|
const proc = spawn("bash", [syncScript], { timeout: 5000 });
|
|
let stdout = "";
|
|
let stderr = "";
|
|
proc.stdout.on("data", (data) => { stdout += data.toString(); });
|
|
proc.stderr.on("data", (data) => { stderr += data.toString(); });
|
|
proc.on("close", (code) => {
|
|
if (code === 0) resolve(stdout.trim());
|
|
else reject(new Error(stderr || `sync script exited with code ${code}`));
|
|
});
|
|
proc.on("error", reject);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle /context command - load MEMORY.md + db memories
|
|
* This gives clawdbot the same context that claude code gets at session start
|
|
* Also syncs memory into AGENTS.md for future sessions
|
|
*/
|
|
async function handleContext(event) {
|
|
// First, sync memory to AGENTS.md
|
|
try {
|
|
await runSyncScript();
|
|
console.log("[agent-memory] Synced memory context to AGENTS.md");
|
|
} catch (err) {
|
|
console.warn("[agent-memory] Failed to sync memory to AGENTS.md:", err.message);
|
|
}
|
|
|
|
try {
|
|
const result = await runMemoryScript([
|
|
"load",
|
|
"--mode", "session-start",
|
|
"--project", os.homedir()
|
|
]);
|
|
if (result) {
|
|
event.messages.push(`🧠 **Memory Context Loaded**\n\n${result}`);
|
|
} else {
|
|
event.messages.push("🧠 No memory context available.");
|
|
}
|
|
} catch (err) {
|
|
// fallback: try to read MEMORY.md directly
|
|
try {
|
|
const currentMd = await fs.readFile(MEMORY_MD_PATH, "utf-8");
|
|
if (currentMd.trim()) {
|
|
event.messages.push(`🧠 **Memory Context**\n\n${currentMd.trim()}`);
|
|
} else {
|
|
event.messages.push("🧠 No memory context available.");
|
|
}
|
|
} catch {
|
|
event.messages.push(`🧠 Error loading context: ${err.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle /new command - save session context before reset
|
|
*/
|
|
async function handleNew(event) {
|
|
const context = event.context || {};
|
|
const sessionEntry = context.previousSessionEntry || context.sessionEntry || {};
|
|
const sessionFile = sessionEntry.sessionFile;
|
|
|
|
if (!sessionFile) {
|
|
console.log("[agent-memory] No session file found, skipping auto-save");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const sessionContent = await getRecentSessionContent(sessionFile);
|
|
if (!sessionContent || sessionContent.length < 100) {
|
|
console.log("[agent-memory] Session too short for auto-extraction");
|
|
return;
|
|
}
|
|
|
|
// Save a summary marker that this session ended
|
|
const now = new Date().toISOString();
|
|
const summaryContent = `[clawdbot-session-end]: Session ended at ${now}. Last conversation:\n${sessionContent.slice(0, 500)}`;
|
|
|
|
// Detect harness from session key
|
|
const harness = event.sessionKey?.includes("claude") ? "claude-code"
|
|
: event.sessionKey?.includes("opencode") ? "opencode"
|
|
: "openclaw";
|
|
|
|
await runMemoryScript([
|
|
"save",
|
|
"--mode", "explicit",
|
|
"--who", harness,
|
|
"--project", context.cwd || "global",
|
|
"--content", summaryContent
|
|
]);
|
|
|
|
console.log("[agent-memory] Session context saved to memory database");
|
|
} catch (err) {
|
|
console.error("[agent-memory] Failed to save session memory:", err.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Main hook handler
|
|
*/
|
|
const agentMemoryHandler = async (event) => {
|
|
// Check if memory script exists
|
|
try {
|
|
await fs.access(MEMORY_SCRIPT);
|
|
} catch {
|
|
console.warn("[agent-memory] Memory script not found at", MEMORY_SCRIPT);
|
|
return;
|
|
}
|
|
|
|
if (event.type !== "command") {
|
|
return;
|
|
}
|
|
|
|
switch (event.action) {
|
|
case "remember":
|
|
await handleRemember(event);
|
|
break;
|
|
case "recall":
|
|
await handleRecall(event);
|
|
break;
|
|
case "context":
|
|
await handleContext(event);
|
|
break;
|
|
case "new":
|
|
await handleNew(event);
|
|
break;
|
|
}
|
|
};
|
|
|
|
export default agentMemoryHandler;
|