421 lines
12 KiB
JavaScript

/**
* Signet Agent Memory Hook
*
* Unified memory system for all AI harnesses (OpenClaw, Claude Code, OpenCode, Codex)
* Uses the Signet daemon API for memory operations - no Python dependency.
*
* Features:
* - Hybrid search (vector + BM25)
* - Auto-embedding via daemon
* - Cross-harness persistence
*
* Spec: Signet v0.2.1 - https://signetai.sh
*/
import path from "node:path";
import os from "node:os";
import fs from "node:fs/promises";
const DAEMON_URL = process.env.SIGNET_DAEMON_URL || "http://localhost:3850";
const MEMORY_MD_PATH = path.join(os.homedir(), ".agents/MEMORY.md");
const AGENTS_DIR = path.join(os.homedir(), ".agents");
/**
* Check if the Signet daemon is running
*/
async function isDaemonRunning() {
try {
const res = await fetch(`${DAEMON_URL}/health`, {
signal: AbortSignal.timeout(1000),
});
return res.ok;
} catch {
return false;
}
}
/**
* Call the Signet daemon remember API
*/
async function daemonRemember(content, options = {}) {
const res = await fetch(`${DAEMON_URL}/api/memory/remember`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content,
type: options.type,
importance: options.importance,
tags: options.tags,
who: options.who || "openclaw",
}),
signal: AbortSignal.timeout(10000),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Daemon remember failed: ${res.status} - ${text}`);
}
return res.json();
}
/**
* Call the Signet daemon recall API
*/
async function daemonRecall(query, options = {}) {
const res = await fetch(`${DAEMON_URL}/api/memory/recall`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query,
limit: options.limit || 10,
type: options.type,
min_score: options.minScore,
}),
signal: AbortSignal.timeout(10000),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Daemon recall failed: ${res.status} - ${text}`);
}
return res.json();
}
/**
* Call the session-start hook to get context
*/
async function daemonSessionStart(harness, options = {}) {
const res = await fetch(`${DAEMON_URL}/api/hooks/session-start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
harness,
sessionKey: options.sessionKey,
context: options.context,
}),
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
return null;
}
return res.json();
}
/**
* Detect harness name from session key
*/
function detectHarness(sessionKey) {
if (!sessionKey) return "openclaw";
if (sessionKey.includes("claude")) return "claude-code";
if (sessionKey.includes("opencode")) return "opencode";
return "openclaw";
}
/**
* 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");
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;
}
}
/**
* Parse remember content for prefixes
* - `critical:` for pinned memories
* - `[tag1,tag2]:` for tagged memories
*/
function parseRememberContent(content) {
let type = "explicit";
let importance = 0.8;
let tags = [];
let cleanContent = content.trim();
// Check for critical prefix
if (cleanContent.toLowerCase().startsWith("critical:")) {
type = "critical";
importance = 1.0;
cleanContent = cleanContent.slice(9).trim();
}
// Check for tag prefix [tag1,tag2]:
else if (cleanContent.startsWith("[") && cleanContent.includes("]:")) {
const tagMatch = cleanContent.match(/^\[([^\]]+)\]:\s*/);
if (tagMatch) {
tags = tagMatch[1].split(",").map((t) => t.trim());
cleanContent = cleanContent.slice(tagMatch[0].length).trim();
}
}
return { content: cleanContent, type, importance, tags };
}
/**
* 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\n" +
"Prefixes:\n" +
"- `critical:` for pinned memories\n" +
"- `[tag1,tag2]:` for tagged memories"
);
return;
}
const harness = detectHarness(event.sessionKey);
const parsed = parseRememberContent(args);
try {
const result = await daemonRemember(parsed.content, {
type: parsed.type,
importance: parsed.importance,
tags: parsed.tags,
who: harness,
});
event.messages.push(`🧠 Memory saved (id: ${result.id || result.memoryId})`);
} 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 daemonRecall(args.trim(), { limit: 10 });
const memories = result.results || [];
if (memories.length === 0) {
event.messages.push("🧠 No memories found matching your query.");
return;
}
const formatted = memories
.map((m, i) => {
const date = new Date(m.created_at).toLocaleDateString();
const score = (m.score * 100).toFixed(0);
return `${i + 1}. [${date}] (${score}%) ${m.content}`;
})
.join("\n");
event.messages.push(`🧠 Memory search results:\n\n${formatted}`);
} catch (err) {
event.messages.push(`🧠 Error querying memory: ${err.message}`);
}
}
/**
* Handle /context command - load memory context for current session
*/
async function handleContext(event) {
const harness = detectHarness(event.sessionKey);
try {
const result = await daemonSessionStart(harness, {
sessionKey: event.sessionKey,
});
if (result && result.inject) {
event.messages.push(`🧠 **Memory Context Loaded**\n\n${result.inject}`);
} else {
// Fallback to reading MEMORY.md directly
try {
const memoryMd = await fs.readFile(MEMORY_MD_PATH, "utf-8");
if (memoryMd.trim()) {
event.messages.push(`🧠 **Memory Context**\n\n${memoryMd.trim()}`);
} else {
event.messages.push("🧠 No memory context available.");
}
} catch {
event.messages.push("🧠 No memory context available.");
}
}
} catch (err) {
// Fallback to MEMORY.md
try {
const memoryMd = await fs.readFile(MEMORY_MD_PATH, "utf-8");
if (memoryMd.trim()) {
event.messages.push(`🧠 **Memory Context**\n\n${memoryMd.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;
}
const harness = detectHarness(event.sessionKey);
const now = new Date().toISOString();
const summaryContent = `[session-end]: Session ended at ${now}. Recent conversation:\n${sessionContent.slice(0, 500)}`;
await daemonRemember(summaryContent, {
type: "session",
importance: 0.6,
who: harness,
});
console.log("[agent-memory] Session context saved to Signet");
} catch (err) {
console.error("[agent-memory] Failed to save session memory:", err.message);
}
}
/**
* Handle agent:bootstrap event - inject memories into bootstrap context
*/
async function handleBootstrap(event) {
const harness = detectHarness(event.sessionKey);
try {
const running = await isDaemonRunning();
if (!running) {
console.log("[agent-memory] Signet daemon not running, skipping memory injection");
return;
}
const result = await daemonSessionStart(harness, {
sessionKey: event.sessionKey,
});
if (!result || !result.memories || result.memories.length === 0) {
console.log("[agent-memory] No memories to inject");
return;
}
// Add a SIGNET.md file to bootstrap context with recent memories
const context = event.context || {};
const bootstrapFiles = context.bootstrapFiles || [];
// Build memory content
const memoryContent = [
"# Recent Memories (Signet)",
"",
...result.memories.slice(0, 10).map((m) => {
const date = new Date(m.created_at).toLocaleDateString();
return `- [${date}] ${m.content}`;
}),
].join("\n");
// Add SIGNET.md to bootstrap files
bootstrapFiles.push({
name: "SIGNET.md",
content: memoryContent,
required: false,
});
context.bootstrapFiles = bootstrapFiles;
console.log(`[agent-memory] Injected ${result.memories.length} memories into bootstrap context`);
} catch (err) {
console.warn("[agent-memory] Failed to inject memories:", err.message);
}
}
/**
* Main hook handler
*/
const agentMemoryHandler = async (event) => {
// Handle agent:bootstrap event for memory injection
if (event.type === "agent" && event.action === "bootstrap") {
await handleBootstrap(event);
return;
}
// Check if daemon is running for memory commands
if (event.type === "command" && ["remember", "recall", "context"].includes(event.action)) {
const running = await isDaemonRunning();
if (!running) {
console.warn("[agent-memory] Signet daemon not running at", DAEMON_URL);
event.messages.push(
"🧠 Signet daemon not running. Start it with: `signet start`"
);
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;