218 lines
7.3 KiB
JavaScript
218 lines
7.3 KiB
JavaScript
/**
|
|
* agentmemory plugin for OpenClaw
|
|
*
|
|
* Deeper integration than raw MCP:
|
|
* - claims the plugins.slots.memory slot via api.registerMemoryCapability({ promptBuilder })
|
|
* - recalls relevant memories before the agent starts (before_agent_start hook)
|
|
* - captures completed conversation turns after the agent finishes (agent_end hook)
|
|
*
|
|
* Requires the agentmemory server on localhost:3111.
|
|
* Start it with: npx @agentmemory/agentmemory
|
|
*/
|
|
|
|
const DEFAULT_BASE_URL = "http://localhost:3111";
|
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
|
|
const configSchema = {
|
|
type: "object",
|
|
additionalProperties: false,
|
|
properties: {
|
|
enabled: { type: "boolean" },
|
|
base_url: { type: "string" },
|
|
token_budget: { type: "number" },
|
|
min_confidence: { type: "number" },
|
|
fallback_on_error: { type: "boolean" },
|
|
timeout_ms: { type: "number" },
|
|
},
|
|
};
|
|
|
|
function extractText(content) {
|
|
if (typeof content === "string") return content;
|
|
if (!Array.isArray(content)) return "";
|
|
return content
|
|
.flatMap((block) => {
|
|
if (!block || typeof block !== "object") return [];
|
|
if (block.type === "text" && typeof block.text === "string") return [block.text];
|
|
return [];
|
|
})
|
|
.join("\n")
|
|
.trim();
|
|
}
|
|
|
|
function lastAssistantText(messages) {
|
|
for (const message of [...messages].reverse()) {
|
|
if (!message || typeof message !== "object") continue;
|
|
if (message.role !== "assistant") continue;
|
|
const text = extractText(message.content);
|
|
if (text) return text;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function latestUserText(messages) {
|
|
for (const message of [...messages].reverse()) {
|
|
if (!message || typeof message !== "object") continue;
|
|
if (message.role !== "user") continue;
|
|
const text = extractText(message.content);
|
|
if (text) return text;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function formatResults(results) {
|
|
if (!Array.isArray(results) || results.length === 0) return "";
|
|
return results
|
|
.slice(0, 5)
|
|
.map((result, index) => {
|
|
const obs = result?.observation ?? result ?? {};
|
|
const title = (obs.title || `Memory ${index + 1}`).trim();
|
|
const narrative = (obs.narrative || "").trim();
|
|
const type = (obs.type || "memory").trim();
|
|
return `- ${title} (${type})${narrative ? `: ${narrative}` : ""}`;
|
|
})
|
|
.join("\n");
|
|
}
|
|
|
|
function normalizedHostname(hostname) {
|
|
return hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
}
|
|
|
|
function usesPlaintextBearerAuth(baseUrl, secret) {
|
|
if (!secret) return false;
|
|
try {
|
|
const parsed = new URL(baseUrl);
|
|
return parsed.protocol === "http:" && !LOOPBACK_HOSTS.has(normalizedHostname(parsed.hostname));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function plaintextBearerAuthMessage(baseUrl) {
|
|
return `agentmemory: AGENTMEMORY_SECRET is configured for plaintext HTTP to ${baseUrl}. Bearer tokens and memory payloads can be observed on the network; use HTTPS or an SSH tunnel.`;
|
|
}
|
|
|
|
export function createPlaintextBearerAuthGuard(warn, env) {
|
|
let warned = false;
|
|
return function guardPlaintextBearerAuth(baseUrl, secret) {
|
|
if (!usesPlaintextBearerAuth(baseUrl, secret)) return;
|
|
const message = plaintextBearerAuthMessage(baseUrl);
|
|
if ((env || process.env).AGENTMEMORY_REQUIRE_HTTPS === "1") throw new Error(message);
|
|
if (!warned) {
|
|
warned = true;
|
|
warn(message);
|
|
}
|
|
};
|
|
}
|
|
|
|
function createClient(cfg, api) {
|
|
const baseUrl = String(cfg.base_url || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
const timeoutMs = Number(cfg.timeout_ms || DEFAULT_TIMEOUT_MS);
|
|
const fallbackOnError = cfg.fallback_on_error !== false;
|
|
const secret = process.env.AGENTMEMORY_SECRET;
|
|
const guardPlaintextBearerAuth = createPlaintextBearerAuthGuard(
|
|
(message) => api.logger.warn?.(message),
|
|
);
|
|
if (process.env.AGENTMEMORY_REQUIRE_HTTPS === "1") {
|
|
guardPlaintextBearerAuth(baseUrl, secret);
|
|
}
|
|
|
|
async function postJson(path, payload) {
|
|
guardPlaintextBearerAuth(baseUrl, secret);
|
|
const headers = { "Content-Type": "application/json" };
|
|
if (secret) headers.Authorization = `Bearer ${secret}`;
|
|
try {
|
|
const res = await fetch(`${baseUrl}${path}`, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(payload),
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
if (!res.ok) {
|
|
if (fallbackOnError) return null;
|
|
const body = await res.text().catch(() => "");
|
|
throw new Error(`agentmemory ${path} failed: ${res.status} ${body}`);
|
|
}
|
|
return await res.json();
|
|
} catch (error) {
|
|
if (!fallbackOnError) throw error;
|
|
api.logger.warn?.(`agentmemory: ${String(error)}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return { postJson, baseUrl };
|
|
}
|
|
|
|
const plugin = {
|
|
id: "agentmemory",
|
|
name: "agentmemory",
|
|
description: "Shared cross-session memory via the local agentmemory server.",
|
|
configSchema,
|
|
register(api) {
|
|
const cfg = {
|
|
enabled: api.pluginConfig?.enabled !== false,
|
|
base_url: api.pluginConfig?.base_url || DEFAULT_BASE_URL,
|
|
token_budget: api.pluginConfig?.token_budget || 2000,
|
|
min_confidence: api.pluginConfig?.min_confidence || 0.5,
|
|
fallback_on_error: api.pluginConfig?.fallback_on_error !== false,
|
|
timeout_ms: api.pluginConfig?.timeout_ms || DEFAULT_TIMEOUT_MS,
|
|
};
|
|
const client = createClient(cfg, api);
|
|
|
|
if (typeof api.registerMemoryCapability === "function") {
|
|
api.registerMemoryCapability({
|
|
// OpenClaw passes { availableTools: Set<string>, citationsMode? }. We
|
|
// don't currently branch on tool availability, but accept the params
|
|
// object so the signature matches MemoryPromptSectionBuilder exactly.
|
|
promptBuilder: (_params) => [
|
|
"Long-term memory provider: agentmemory (external REST service on " +
|
|
client.baseUrl +
|
|
").",
|
|
"agentmemory recalls relevant prior observations before each turn via the before_agent_start hook and captures completed turns via agent_end.",
|
|
"Treat recalled context as background, not authoritative — prefer current workspace state and explicit user instructions when they conflict.",
|
|
],
|
|
});
|
|
}
|
|
|
|
api.on("before_agent_start", async (event) => {
|
|
if (!cfg.enabled) return;
|
|
const prompt = typeof event?.prompt === "string" ? event.prompt.trim() : "";
|
|
if (!prompt) return;
|
|
const result = await client.postJson("/agentmemory/smart-search", {
|
|
query: prompt,
|
|
limit: 5,
|
|
});
|
|
const block = formatResults(result?.results || []);
|
|
if (!block) return;
|
|
return {
|
|
prependContext: `Relevant long-term memory from agentmemory:\n${block}`,
|
|
};
|
|
});
|
|
|
|
api.on("agent_end", async (event) => {
|
|
if (!cfg.enabled || !event?.success || !Array.isArray(event.messages)) return;
|
|
const userText = latestUserText(event.messages);
|
|
const assistantText = lastAssistantText(event.messages);
|
|
if (!userText || !assistantText) return;
|
|
const sessionId =
|
|
event.sessionId ||
|
|
event.sessionKey ||
|
|
event.runId ||
|
|
`openclaw-${Date.now()}`;
|
|
await client.postJson("/agentmemory/observe", {
|
|
hookType: "post_tool_use",
|
|
sessionId,
|
|
timestamp: new Date().toISOString(),
|
|
data: {
|
|
tool_name: "conversation",
|
|
tool_input: userText.slice(0, 1000),
|
|
tool_output: assistantText.slice(0, 4000),
|
|
},
|
|
});
|
|
});
|
|
},
|
|
};
|
|
|
|
export default plugin;
|