418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
/**
|
|
* Moltbot Memory (PowerMem) Plugin
|
|
*
|
|
* Long-term memory via PowerMem HTTP API: intelligent extraction,
|
|
* Ebbinghaus forgetting curve, multi-agent isolation. Requires a running
|
|
* PowerMem server (e.g. powermem-server --port 8000).
|
|
*/
|
|
|
|
import { Type } from "@sinclair/typebox";
|
|
import type { MoltbotPluginApi } from "clawdbot/plugin-sdk";
|
|
|
|
import {
|
|
powerMemConfigSchema,
|
|
resolveUserId,
|
|
resolveAgentId,
|
|
type PowerMemConfig,
|
|
} from "./config.js";
|
|
import { PowerMemClient } from "./client.js";
|
|
|
|
// ============================================================================
|
|
// Rule-based capture filter (same idea as memory-lancedb)
|
|
// ============================================================================
|
|
|
|
const MEMORY_TRIGGERS = [
|
|
/zapamatuj si|pamatuj|remember/i,
|
|
/preferuji|radši|nechci|prefer/i,
|
|
/rozhodli jsme|budeme používat/i,
|
|
/\+\d{10,}/,
|
|
/[\w.-]+@[\w.-]+\.\w+/,
|
|
/můj\s+\w+\s+je|je\s+můj/i,
|
|
/my\s+\w+\s+is|is\s+my/i,
|
|
/i (like|prefer|hate|love|want|need)/i,
|
|
/always|never|important/i,
|
|
];
|
|
|
|
function shouldCapture(text: string): boolean {
|
|
if (text.length < 10 || text.length > 500) return false;
|
|
if (text.includes("<relevant-memories>")) return false;
|
|
if (text.startsWith("<") && text.includes("</")) return false;
|
|
if (text.includes("**") && text.includes("\n-")) return false;
|
|
const emojiCount = (text.match(/[\u{1F300}-\u{1F9FF}]/gu) || []).length;
|
|
if (emojiCount > 3) return false;
|
|
return MEMORY_TRIGGERS.some((r) => r.test(text));
|
|
}
|
|
|
|
// ============================================================================
|
|
// Plugin Definition
|
|
// ============================================================================
|
|
|
|
const memoryPlugin = {
|
|
id: "memory-powermem",
|
|
name: "Memory (PowerMem)",
|
|
description:
|
|
"PowerMem-backed long-term memory (intelligent extraction, forgetting curve). Requires PowerMem server.",
|
|
kind: "memory" as const,
|
|
configSchema: powerMemConfigSchema,
|
|
|
|
register(api: MoltbotPluginApi) {
|
|
const cfg = powerMemConfigSchema.parse(api.pluginConfig) as PowerMemConfig;
|
|
const userId = resolveUserId(cfg);
|
|
const agentId = resolveAgentId(cfg);
|
|
const client = PowerMemClient.fromConfig(cfg, userId, agentId);
|
|
|
|
api.logger.info?.(
|
|
`memory-powermem: plugin registered (baseUrl: ${cfg.baseUrl}, user: ${userId}, agent: ${agentId})`,
|
|
);
|
|
|
|
// ========================================================================
|
|
// Tools
|
|
// ========================================================================
|
|
|
|
api.registerTool(
|
|
{
|
|
name: "memory_recall",
|
|
label: "Memory Recall",
|
|
description:
|
|
"Search through long-term memories. Use when you need context about user preferences, past decisions, or previously discussed topics.",
|
|
parameters: Type.Object({
|
|
query: Type.String({ description: "Search query" }),
|
|
limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })),
|
|
}),
|
|
async execute(_toolCallId, params) {
|
|
const { query, limit = 5 } = params as { query: string; limit?: number };
|
|
|
|
try {
|
|
const results = await client.search(query, limit);
|
|
|
|
if (results.length === 0) {
|
|
return {
|
|
content: [{ type: "text", text: "No relevant memories found." }],
|
|
details: { count: 0 },
|
|
};
|
|
}
|
|
|
|
const text = results
|
|
.map(
|
|
(r, i) =>
|
|
`${i + 1}. ${r.content} (${((r.score ?? 0) * 100).toFixed(0)}%)`,
|
|
)
|
|
.join("\n");
|
|
|
|
const sanitizedResults = results.map((r) => ({
|
|
id: String(r.memory_id),
|
|
text: r.content,
|
|
score: r.score,
|
|
}));
|
|
|
|
return {
|
|
content: [
|
|
{ type: "text", text: `Found ${results.length} memories:\n\n${text}` },
|
|
],
|
|
details: { count: results.length, memories: sanitizedResults },
|
|
};
|
|
} catch (err) {
|
|
api.logger.warn?.(`memory-powermem: recall failed: ${String(err)}`);
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Memory search failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
},
|
|
],
|
|
details: { error: String(err) },
|
|
};
|
|
}
|
|
},
|
|
},
|
|
{ name: "memory_recall" },
|
|
);
|
|
|
|
api.registerTool(
|
|
{
|
|
name: "memory_store",
|
|
label: "Memory Store",
|
|
description:
|
|
"Save important information in long-term memory. Use for preferences, facts, decisions.",
|
|
parameters: Type.Object({
|
|
text: Type.String({ description: "Information to remember" }),
|
|
importance: Type.Optional(
|
|
Type.Number({ description: "Importance 0-1 (default: 0.7)" }),
|
|
),
|
|
}),
|
|
async execute(_toolCallId, params) {
|
|
const { text, importance = 0.7 } = params as {
|
|
text: string;
|
|
importance?: number;
|
|
};
|
|
|
|
try {
|
|
const created = await client.add(text, {
|
|
infer: cfg.inferOnAdd,
|
|
metadata: { importance },
|
|
});
|
|
|
|
if (created.length === 0) {
|
|
return {
|
|
content: [{ type: "text", text: "Stored (no inferred items)." }],
|
|
details: { action: "created" },
|
|
};
|
|
}
|
|
|
|
const summary =
|
|
created.length === 1
|
|
? created[0].content.slice(0, 80)
|
|
: `${created.length} items stored`;
|
|
return {
|
|
content: [
|
|
{ type: "text", text: `Stored: ${summary}${summary.length >= 80 ? "..." : ""}` },
|
|
],
|
|
details: {
|
|
action: "created",
|
|
count: created.length,
|
|
ids: created.map((c) => String(c.memory_id)),
|
|
},
|
|
};
|
|
} catch (err) {
|
|
api.logger.warn?.(`memory-powermem: store failed: ${String(err)}`);
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Failed to store memory: ${err instanceof Error ? err.message : String(err)}`,
|
|
},
|
|
],
|
|
details: { error: String(err) },
|
|
};
|
|
}
|
|
},
|
|
},
|
|
{ name: "memory_store" },
|
|
);
|
|
|
|
api.registerTool(
|
|
{
|
|
name: "memory_forget",
|
|
label: "Memory Forget",
|
|
description: "Delete specific memories. GDPR-compliant.",
|
|
parameters: Type.Object({
|
|
query: Type.Optional(Type.String({ description: "Search to find memory" })),
|
|
memoryId: Type.Optional(Type.String({ description: "Specific memory ID" })),
|
|
}),
|
|
async execute(_toolCallId, params) {
|
|
const { query, memoryId } = params as { query?: string; memoryId?: string };
|
|
|
|
try {
|
|
if (memoryId) {
|
|
await client.delete(memoryId);
|
|
return {
|
|
content: [{ type: "text", text: `Memory ${memoryId} forgotten.` }],
|
|
details: { action: "deleted", id: memoryId },
|
|
};
|
|
}
|
|
|
|
if (query) {
|
|
const results = await client.search(query, 5);
|
|
if (results.length === 0) {
|
|
return {
|
|
content: [{ type: "text", text: "No matching memories found." }],
|
|
details: { found: 0 },
|
|
};
|
|
}
|
|
if (results.length === 1 && (results[0].score ?? 0) > 0.9) {
|
|
await client.delete(results[0].memory_id);
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Forgotten: "${results[0].content.slice(0, 60)}..."`,
|
|
},
|
|
],
|
|
details: { action: "deleted", id: String(results[0].memory_id) },
|
|
};
|
|
}
|
|
const list = results
|
|
.map(
|
|
(r) =>
|
|
`- [${String(r.memory_id).slice(0, 8)}] ${r.content.slice(0, 60)}...`,
|
|
)
|
|
.join("\n");
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Found ${results.length} candidates. Specify memoryId:\n${list}`,
|
|
},
|
|
],
|
|
details: {
|
|
action: "candidates",
|
|
candidates: results.map((r) => ({
|
|
id: String(r.memory_id),
|
|
text: r.content,
|
|
score: r.score,
|
|
})),
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
content: [{ type: "text", text: "Provide query or memoryId." }],
|
|
details: { error: "missing_param" },
|
|
};
|
|
} catch (err) {
|
|
api.logger.warn?.(`memory-powermem: forget failed: ${String(err)}`);
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Failed to forget: ${err instanceof Error ? err.message : String(err)}`,
|
|
},
|
|
],
|
|
details: { error: String(err) },
|
|
};
|
|
}
|
|
},
|
|
},
|
|
{ name: "memory_forget" },
|
|
);
|
|
|
|
// ========================================================================
|
|
// CLI Commands
|
|
// ========================================================================
|
|
|
|
api.registerCli(
|
|
({ program }) => {
|
|
const ltm = program
|
|
.command("ltm")
|
|
.description("PowerMem long-term memory plugin commands");
|
|
|
|
ltm
|
|
.command("search")
|
|
.description("Search memories")
|
|
.argument("<query>", "Search query")
|
|
.option("--limit <n>", "Max results", "5")
|
|
.action(async (query: string, opts: { limit?: string }) => {
|
|
const limit = parseInt(opts.limit ?? "5", 10);
|
|
const results = await client.search(query, limit);
|
|
console.log(JSON.stringify(results, null, 2));
|
|
});
|
|
|
|
ltm
|
|
.command("health")
|
|
.description("Check PowerMem server health")
|
|
.action(async () => {
|
|
try {
|
|
const h = await client.health();
|
|
console.log("PowerMem:", h.status);
|
|
} catch (err) {
|
|
console.error("PowerMem health check failed:", err);
|
|
process.exitCode = 1;
|
|
}
|
|
});
|
|
},
|
|
{ commands: ["ltm"] },
|
|
);
|
|
|
|
// ========================================================================
|
|
// Lifecycle Hooks
|
|
// ========================================================================
|
|
|
|
if (cfg.autoRecall) {
|
|
api.on("before_agent_start", async (event) => {
|
|
if (!event.prompt || event.prompt.length < 5) return;
|
|
|
|
try {
|
|
const results = await client.search(event.prompt, 3);
|
|
if (results.length === 0) return;
|
|
|
|
const memoryContext = results.map((r) => `- ${r.content}`).join("\n");
|
|
api.logger.info?.(
|
|
`memory-powermem: injecting ${results.length} memories into context`,
|
|
);
|
|
return {
|
|
prependContext: `<relevant-memories>\nThe following memories may be relevant to this conversation:\n${memoryContext}\n</relevant-memories>`,
|
|
};
|
|
} catch (err) {
|
|
api.logger.warn?.(`memory-powermem: recall failed: ${String(err)}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (cfg.autoCapture) {
|
|
api.on("agent_end", async (event) => {
|
|
if (!event.success || !event.messages || event.messages.length === 0) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const texts: string[] = [];
|
|
for (const msg of event.messages) {
|
|
if (!msg || typeof msg !== "object") continue;
|
|
const msgObj = msg as Record<string, unknown>;
|
|
const role = msgObj.role;
|
|
if (role !== "user" && role !== "assistant") continue;
|
|
const content = msgObj.content;
|
|
if (typeof content === "string") {
|
|
texts.push(content);
|
|
continue;
|
|
}
|
|
if (Array.isArray(content)) {
|
|
for (const block of content) {
|
|
if (
|
|
block &&
|
|
typeof block === "object" &&
|
|
"type" in block &&
|
|
(block as Record<string, unknown>).type === "text" &&
|
|
"text" in block &&
|
|
typeof (block as Record<string, unknown>).text === "string"
|
|
) {
|
|
texts.push((block as Record<string, unknown>).text as string);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const toCapture = texts.filter((t) => t && shouldCapture(t));
|
|
if (toCapture.length === 0) return;
|
|
|
|
let stored = 0;
|
|
for (const text of toCapture.slice(0, 3)) {
|
|
const created = await client.add(text, { infer: cfg.inferOnAdd });
|
|
stored += created.length;
|
|
}
|
|
if (stored > 0) {
|
|
api.logger.info?.(`memory-powermem: auto-captured ${stored} memories`);
|
|
}
|
|
} catch (err) {
|
|
api.logger.warn?.(`memory-powermem: capture failed: ${String(err)}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ========================================================================
|
|
// Service
|
|
// ========================================================================
|
|
|
|
api.registerService({
|
|
id: "memory-powermem",
|
|
start: async () => {
|
|
try {
|
|
const h = await client.health();
|
|
api.logger.info?.(
|
|
`memory-powermem: initialized (${cfg.baseUrl}, health: ${h.status})`,
|
|
);
|
|
} catch (err) {
|
|
api.logger.warn?.(
|
|
`memory-powermem: health check failed (is PowerMem server running?): ${String(err)}`,
|
|
);
|
|
}
|
|
},
|
|
stop: () => {
|
|
api.logger.info?.("memory-powermem: stopped");
|
|
},
|
|
});
|
|
},
|
|
};
|
|
|
|
export default memoryPlugin;
|