421 lines
12 KiB
JavaScript
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;
|