From 5845d84b4d5038079c5b5963b4cd7a3c1a12c5d6 Mon Sep 17 00:00:00 2001 From: Teingi Date: Thu, 29 Jan 2026 15:29:52 +0800 Subject: [PATCH] extensions: add memory-powermem plugin for long-term memory --- .github/labeler.yml | 4 + extensions/memory-powermem/README.md | 82 ++++ .../memory-powermem/clawdbot.plugin.json | 69 +++ extensions/memory-powermem/client.test.ts | 148 +++++++ extensions/memory-powermem/client.ts | 157 +++++++ extensions/memory-powermem/config.ts | 94 ++++ extensions/memory-powermem/index.test.ts | 196 ++++++++ extensions/memory-powermem/index.ts | 417 ++++++++++++++++++ .../node_modules/@sinclair/typebox | 1 + extensions/memory-powermem/package.json | 14 + 10 files changed, 1182 insertions(+) create mode 100644 extensions/memory-powermem/README.md create mode 100644 extensions/memory-powermem/clawdbot.plugin.json create mode 100644 extensions/memory-powermem/client.test.ts create mode 100644 extensions/memory-powermem/client.ts create mode 100644 extensions/memory-powermem/config.ts create mode 100644 extensions/memory-powermem/index.test.ts create mode 100644 extensions/memory-powermem/index.ts create mode 120000 extensions/memory-powermem/node_modules/@sinclair/typebox create mode 100644 extensions/memory-powermem/package.json diff --git a/.github/labeler.yml b/.github/labeler.yml index 5c19fa418..3b8a84b26 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -212,6 +212,10 @@ - changed-files: - any-glob-to-any-file: - "extensions/memory-lancedb/**" +"extensions: memory-powermem": + - changed-files: + - any-glob-to-any-file: + - "extensions/memory-powermem/**" "extensions: open-prose": - changed-files: - any-glob-to-any-file: diff --git a/extensions/memory-powermem/README.md b/extensions/memory-powermem/README.md new file mode 100644 index 000000000..22d460ea5 --- /dev/null +++ b/extensions/memory-powermem/README.md @@ -0,0 +1,82 @@ +# Memory (PowerMem) Plugin + +Moltbot long-term memory plugin backed by [PowerMem](https://github.com/oceanbase/powermem) via its HTTP API. Provides intelligent memory extraction, Ebbinghaus forgetting curve, and multi-agent isolation without running Python inside Moltbot. + +## Requirements + +- A running **PowerMem HTTP API server**. Start it separately, for example: + + ```bash + pip install powermem + powermem-server --host 0.0.0.0 --port 8000 + ``` + + Or with Docker: + + ```bash + docker run -d -p 8000:8000 --env-file .env oceanbase/powermem-server:latest + ``` + +- Configure PowerMem itself (embeddings, storage, etc.) via its `.env`; see [PowerMem configuration](https://github.com/oceanbase/powermem#quick-start). + +## Moltbot configuration + +1. Set the memory slot to this plugin: + + ```yaml + plugins: + slots: + memory: memory-powermem + config: + memory-powermem: + baseUrl: "http://localhost:8000" + # apiKey: "optional-if-auth-enabled" + autoCapture: true + autoRecall: true + inferOnAdd: true + # userId: "optional-override" + # agentId: "optional-override" + ``` + +2. Ensure PowerMem server is running before starting the gateway. + +## Options + +| Option | Required | Description | +|---------------|----------|-----------------------------------------------------------------------------| +| `baseUrl` | Yes | PowerMem API base URL (e.g. `http://localhost:8000`), no `/api/v1` suffix. | +| `apiKey` | No | Set if PowerMem server has API key authentication enabled. | +| `userId` | No | PowerMem `user_id` for isolation; default `moltbot-user`. | +| `agentId` | No | PowerMem `agent_id` for isolation; default `moltbot-agent`. | +| `autoCapture` | No | Auto-store from conversations after agent ends; default `true`. | +| `autoRecall` | No | Auto-inject relevant memories before agent starts; default `true`. | +| `inferOnAdd` | No | Use PowerMem intelligent extraction when adding; default `true`. | + +## Tools + +- **memory_recall** — Search long-term memories by query. +- **memory_store** — Save information (with optional infer). +- **memory_forget** — Delete by memory ID or by search query. + +## CLI + +- `moltbot ltm search [--limit n]` — Search memories. +- `moltbot ltm health` — Check PowerMem server health. + +## Running tests + +From the repo root: + +```bash +# Run all tests (includes extensions) +pnpm test + +# Run only this plugin's tests +pnpm exec vitest run --config vitest.extensions.config.ts extensions/memory-powermem +``` + +## Docs + +- [PowerMem](https://github.com/oceanbase/powermem) +- [PowerMem HTTP API](https://github.com/oceanbase/powermem/blob/master/docs/api/0005-api_server.md) +- [Moltbot long-term memory design](/docs/design/memory-powermem-integration.md) diff --git a/extensions/memory-powermem/clawdbot.plugin.json b/extensions/memory-powermem/clawdbot.plugin.json new file mode 100644 index 000000000..6f342cab9 --- /dev/null +++ b/extensions/memory-powermem/clawdbot.plugin.json @@ -0,0 +1,69 @@ +{ + "id": "memory-powermem", + "kind": "memory", + "uiHints": { + "baseUrl": { + "label": "PowerMem API URL", + "placeholder": "http://localhost:8000", + "help": "Base URL of PowerMem HTTP API (no /api/v1). Start server with: powermem-server --port 8000" + }, + "apiKey": { + "label": "API Key", + "sensitive": true, + "placeholder": "", + "help": "Optional; required if PowerMem server has authentication enabled" + }, + "userId": { + "label": "User ID", + "placeholder": "moltbot-user", + "advanced": true, + "help": "PowerMem user_id for memory isolation (optional)" + }, + "agentId": { + "label": "Agent ID", + "placeholder": "moltbot-agent", + "advanced": true, + "help": "PowerMem agent_id for memory isolation (optional)" + }, + "autoCapture": { + "label": "Auto-Capture", + "help": "Automatically store important information from conversations" + }, + "autoRecall": { + "label": "Auto-Recall", + "help": "Automatically inject relevant memories into context" + }, + "inferOnAdd": { + "label": "Infer on Add", + "help": "Use PowerMem intelligent extraction when adding (infer=true)" + } + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "baseUrl": { + "type": "string" + }, + "apiKey": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "agentId": { + "type": "string" + }, + "autoCapture": { + "type": "boolean" + }, + "autoRecall": { + "type": "boolean" + }, + "inferOnAdd": { + "type": "boolean" + } + }, + "required": ["baseUrl"] + } +} diff --git a/extensions/memory-powermem/client.test.ts b/extensions/memory-powermem/client.test.ts new file mode 100644 index 000000000..54e0b87b7 --- /dev/null +++ b/extensions/memory-powermem/client.test.ts @@ -0,0 +1,148 @@ +/** + * PowerMem HTTP client tests (mocked fetch). + */ + +import { describe, test, expect, beforeEach, vi } from "vitest"; +import { PowerMemClient } from "./client.js"; + +describe("PowerMemClient", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + test("health returns status", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ success: true, data: { status: "healthy" } }), + ), + } as Response); + + const client = new PowerMemClient({ + baseUrl: "http://localhost:8000", + userId: "u1", + agentId: "a1", + }); + const h = await client.health(); + expect(h.status).toBe("healthy"); + expect((globalThis.fetch as ReturnType).mock.calls[0][0]).toContain( + "/api/v1/system/health", + ); + + globalThis.fetch = originalFetch; + }); + + test("search returns results", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + success: true, + data: { + results: [ + { memory_id: 1, content: "User likes tea", score: 0.9 }, + ], + }, + }), + ), + } as Response); + + const client = new PowerMemClient({ + baseUrl: "http://localhost:8000", + userId: "u1", + agentId: "a1", + }); + const results = await client.search("tea", 5); + expect(results).toHaveLength(1); + expect(results[0].content).toBe("User likes tea"); + expect(results[0].memory_id).toBe(1); + + const call = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(call[0]).toContain("/api/v1/memories/search"); + expect(call[1]?.method).toBe("POST"); + const body = JSON.parse((call[1]?.body as string) ?? "{}"); + expect(body.query).toBe("tea"); + expect(body.limit).toBe(5); + expect(body.user_id).toBe("u1"); + expect(body.agent_id).toBe("a1"); + + globalThis.fetch = originalFetch; + }); + + test("add sends content and infer", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + success: true, + data: [ + { memory_id: 100, content: "User likes coffee", user_id: "u1", agent_id: "a1" }, + ], + }), + ), + } as Response); + + const client = new PowerMemClient({ + baseUrl: "http://localhost:8000", + userId: "u1", + agentId: "a1", + }); + const created = await client.add("User likes coffee", { infer: true }); + expect(created).toHaveLength(1); + expect(created[0].memory_id).toBe(100); + expect(created[0].content).toBe("User likes coffee"); + + const call = (globalThis.fetch as ReturnType).mock.calls[0]; + const body = JSON.parse((call[1]?.body as string) ?? "{}"); + expect(body.content).toBe("User likes coffee"); + expect(body.infer).toBe(true); + expect(body.user_id).toBe("u1"); + expect(body.agent_id).toBe("a1"); + + globalThis.fetch = originalFetch; + }); + + test("delete calls correct URL with query params", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(""), + } as Response); + + const client = new PowerMemClient({ + baseUrl: "http://localhost:8000", + userId: "u1", + agentId: "a1", + }); + await client.delete(12345); + + const call = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(call[0]).toContain("/api/v1/memories/12345"); + expect(call[0]).toContain("user_id=u1"); + expect(call[0]).toContain("agent_id=a1"); + expect(call[1]?.method).toBe("DELETE"); + + globalThis.fetch = originalFetch; + }); + + test("throws on API error", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + text: () => Promise.resolve(JSON.stringify({ message: "Invalid API key" })), + } as Response); + + const client = new PowerMemClient({ + baseUrl: "http://localhost:8000", + apiKey: "bad", + }); + await expect(client.health()).rejects.toThrow("Invalid API key"); + + globalThis.fetch = originalFetch; + }); +}); diff --git a/extensions/memory-powermem/client.ts b/extensions/memory-powermem/client.ts new file mode 100644 index 000000000..06e282203 --- /dev/null +++ b/extensions/memory-powermem/client.ts @@ -0,0 +1,157 @@ +/** + * PowerMem HTTP API client. + * Calls POST /api/v1/memories, POST /api/v1/memories/search, DELETE /api/v1/memories/:id, GET /api/v1/system/health. + */ + +import type { PowerMemConfig } from "./config.js"; + +export type PowerMemSearchResult = { + memory_id: number; + content: string; + score: number; + metadata?: Record; +}; + +export type PowerMemAddResult = { + memory_id: number; + content: string; + user_id?: string; + agent_id?: string; + metadata?: Record; +}; + +function buildUrl(baseUrl: string, path: string): string { + const base = baseUrl.replace(/\/+$/, ""); + const p = path.startsWith("/") ? path : `/${path}`; + return `${base}${p}`; +} + +function buildHeaders(apiKey?: string): Record { + const headers: Record = { + "Content-Type": "application/json", + }; + if (apiKey) { + headers["X-API-Key"] = apiKey; + } + return headers; +} + +async function handleResponse(res: Response, parseJson = true): Promise { + const text = await res.text(); + if (!res.ok) { + let message = `PowerMem API ${res.status}: ${res.statusText}`; + try { + const body = text ? JSON.parse(text) : null; + if (body?.message) message = body.message; + else if (body?.detail) message = Array.isArray(body.detail) ? body.detail.map((d: { msg?: string }) => d.msg ?? String(d)).join("; ") : String(body.detail); + } catch { + if (text) message = text.slice(0, 200); + } + throw new Error(message); + } + if (!parseJson) return undefined as T; + if (!text) return undefined as T; + return JSON.parse(text) as T; +} + +export type PowerMemClientOptions = { + baseUrl: string; + apiKey?: string; + userId?: string; + agentId?: string; +}; + +export class PowerMemClient { + private readonly baseUrl: string; + private readonly apiKey?: string; + private readonly userId: string; + private readonly agentId: string; + + constructor(options: PowerMemClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/+$/, ""); + this.apiKey = options.apiKey; + this.userId = options.userId ?? "moltbot-user"; + this.agentId = options.agentId ?? "moltbot-agent"; + } + + static fromConfig(cfg: PowerMemConfig, userId: string, agentId: string): PowerMemClient { + return new PowerMemClient({ + baseUrl: cfg.baseUrl, + apiKey: cfg.apiKey, + userId, + agentId, + }); + } + + private async request( + method: string, + path: string, + body?: unknown, + parseJson = true, + ): Promise { + const url = buildUrl(this.baseUrl, path); + const res = await fetch(url, { + method, + headers: buildHeaders(this.apiKey), + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + return handleResponse(res, parseJson); + } + + /** GET /api/v1/system/health */ + async health(): Promise<{ status: string }> { + const data = await this.request<{ data?: { status?: string } }>( + "GET", + "/api/v1/system/health", + undefined, + ); + return { status: data?.data?.status ?? "unknown" }; + } + + /** POST /api/v1/memories */ + async add( + content: string, + options: { infer?: boolean; metadata?: Record } = {}, + ): Promise { + const body = { + content, + user_id: this.userId, + agent_id: this.agentId, + infer: options.infer ?? true, + ...(options.metadata && { metadata: options.metadata }), + }; + const res = await this.request<{ success: boolean; data?: PowerMemAddResult[] }>( + "POST", + "/api/v1/memories", + body, + ); + if (!res?.data) return []; + return res.data; + } + + /** POST /api/v1/memories/search */ + async search(query: string, limit = 5): Promise { + const body = { + query, + user_id: this.userId, + agent_id: this.agentId, + limit, + }; + const res = await this.request<{ + success: boolean; + data?: { results?: PowerMemSearchResult[] }; + }>("POST", "/api/v1/memories/search", body); + return res?.data?.results ?? []; + } + + /** DELETE /api/v1/memories/:memory_id */ + async delete(memoryId: number | string): Promise { + const id = typeof memoryId === "string" ? memoryId : String(memoryId); + await this.request( + "DELETE", + `/api/v1/memories/${id}?user_id=${encodeURIComponent(this.userId)}&agent_id=${encodeURIComponent(this.agentId)}`, + undefined, + false, + ); + } +} diff --git a/extensions/memory-powermem/config.ts b/extensions/memory-powermem/config.ts new file mode 100644 index 000000000..49daea6ee --- /dev/null +++ b/extensions/memory-powermem/config.ts @@ -0,0 +1,94 @@ +/** + * PowerMem memory plugin configuration. + * Validates baseUrl, optional apiKey, and user/agent mapping. + */ + +function assertAllowedKeys( + value: Record, + allowed: string[], + label: string, +) { + const unknown = Object.keys(value).filter((key) => !allowed.includes(key)); + if (unknown.length === 0) return; + throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`); +} + +function resolveEnvVars(value: string): string { + return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => { + const envValue = process.env[envVar]; + if (!envValue) { + throw new Error(`Environment variable ${envVar} is not set`); + } + return envValue; + }); +} + +export type PowerMemConfig = { + baseUrl: string; + apiKey?: string; + userId?: string; + agentId?: string; + autoCapture: boolean; + autoRecall: boolean; + inferOnAdd: boolean; +}; + +const ALLOWED_KEYS = [ + "baseUrl", + "apiKey", + "userId", + "agentId", + "autoCapture", + "autoRecall", + "inferOnAdd", +] as const; + +export const powerMemConfigSchema = { + parse(value: unknown): PowerMemConfig { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("memory-powermem config required"); + } + const cfg = value as Record; + assertAllowedKeys(cfg, [...ALLOWED_KEYS], "memory-powermem config"); + + const baseUrlRaw = cfg.baseUrl; + if (typeof baseUrlRaw !== "string" || !baseUrlRaw.trim()) { + throw new Error("memory-powermem baseUrl is required"); + } + const baseUrl = resolveEnvVars(baseUrlRaw.trim()).replace(/\/+$/, ""); + + const apiKeyRaw = cfg.apiKey; + const apiKey = + typeof apiKeyRaw === "string" && apiKeyRaw.trim() + ? resolveEnvVars(apiKeyRaw.trim()) + : undefined; + + return { + baseUrl, + apiKey, + userId: + typeof cfg.userId === "string" && cfg.userId.trim() + ? cfg.userId.trim() + : undefined, + agentId: + typeof cfg.agentId === "string" && cfg.agentId.trim() + ? cfg.agentId.trim() + : undefined, + autoCapture: cfg.autoCapture !== false, + autoRecall: cfg.autoRecall !== false, + inferOnAdd: cfg.inferOnAdd !== false, + }; + }, +}; + +/** Default user/agent IDs when not configured (single-tenant style). */ +export const DEFAULT_USER_ID = "moltbot-user"; +export const DEFAULT_AGENT_ID = "moltbot-agent"; + +export function resolveUserId(cfg: PowerMemConfig): string { + return cfg.userId ?? DEFAULT_USER_ID; +} + +export function resolveAgentId(cfg: PowerMemConfig): string { + return cfg.agentId ?? DEFAULT_AGENT_ID; +} diff --git a/extensions/memory-powermem/index.test.ts b/extensions/memory-powermem/index.test.ts new file mode 100644 index 000000000..c7ce96696 --- /dev/null +++ b/extensions/memory-powermem/index.test.ts @@ -0,0 +1,196 @@ +/** + * Memory (PowerMem) plugin tests. + * Config parsing, plugin registration, and tool behavior with mocked fetch. + */ + +import { describe, test, expect, beforeEach, vi } from "vitest"; +import { powerMemConfigSchema } from "./config.js"; +import { default as memoryPlugin } from "./index.js"; + +describe("memory-powermem plugin", () => { + test("plugin metadata", () => { + expect(memoryPlugin.id).toBe("memory-powermem"); + expect(memoryPlugin.name).toBe("Memory (PowerMem)"); + expect(memoryPlugin.kind).toBe("memory"); + expect(memoryPlugin.configSchema).toBeDefined(); + expect(memoryPlugin.register).toBeInstanceOf(Function); + }); + + test("config schema parses valid config", () => { + const config = powerMemConfigSchema.parse({ + baseUrl: "http://localhost:8000", + autoCapture: true, + autoRecall: true, + inferOnAdd: true, + }); + expect(config.baseUrl).toBe("http://localhost:8000"); + expect(config.autoCapture).toBe(true); + expect(config.autoRecall).toBe(true); + expect(config.inferOnAdd).toBe(true); + }); + + test("config schema strips trailing slash from baseUrl", () => { + const config = powerMemConfigSchema.parse({ + baseUrl: "http://localhost:8000/", + }); + expect(config.baseUrl).toBe("http://localhost:8000"); + }); + + test("config schema rejects missing baseUrl", () => { + expect(() => powerMemConfigSchema.parse({})).toThrow("baseUrl is required"); + expect(() => powerMemConfigSchema.parse({ baseUrl: "" })).toThrow( + "baseUrl is required", + ); + }); + + test("config schema resolves env vars", () => { + process.env.TEST_POWERMEM_URL = "http://127.0.0.1:8000"; + const config = powerMemConfigSchema.parse({ + baseUrl: "${TEST_POWERMEM_URL}", + }); + expect(config.baseUrl).toBe("http://127.0.0.1:8000"); + delete process.env.TEST_POWERMEM_URL; + }); + + test("config schema uses default user/agent when not set", () => { + const config = powerMemConfigSchema.parse({ baseUrl: "http://localhost:8000" }); + expect(config.userId).toBeUndefined(); + expect(config.agentId).toBeUndefined(); + }); + + test("plugin registers tools, CLI, service, and hooks", async () => { + const registeredTools: { tool: unknown; opts: unknown }[] = []; + const registeredClis: { registrar: unknown; opts: unknown }[] = []; + const registeredServices: unknown[] = []; + const registeredHooks: Record = {}; + + const mockApi = { + pluginConfig: { + baseUrl: "http://localhost:8000", + autoCapture: false, + autoRecall: false, + }, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + registerTool: (tool: unknown, opts: unknown) => { + registeredTools.push({ tool, opts }); + }, + registerCli: (registrar: unknown, opts: unknown) => { + registeredClis.push({ registrar, opts }); + }, + registerService: (service: unknown) => { + registeredServices.push(service); + }, + on: (hookName: string, handler: unknown) => { + if (!registeredHooks[hookName]) registeredHooks[hookName] = []; + registeredHooks[hookName].push(handler); + }, + resolvePath: (p: string) => p, + }; + + await memoryPlugin.register(mockApi as never); + + expect(registeredTools.length).toBe(3); + expect(registeredTools.map((t) => (t.opts as { name?: string })?.name)).toContain( + "memory_recall", + ); + expect(registeredTools.map((t) => (t.opts as { name?: string })?.name)).toContain( + "memory_store", + ); + expect(registeredTools.map((t) => (t.opts as { name?: string })?.name)).toContain( + "memory_forget", + ); + expect(registeredClis.length).toBe(1); + expect(registeredServices.length).toBe(1); + }); + + test("memory_recall returns error when fetch fails", async () => { + const registeredTools: { tool: { execute: (id: string, params: unknown) => Promise }; opts: unknown }[] = []; + const mockApi = { + pluginConfig: { baseUrl: "http://localhost:8000" }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerTool: (tool: unknown, opts: unknown) => { + registeredTools.push({ tool: tool as { execute: (id: string, params: unknown) => Promise }, opts }); + }, + registerCli: () => {}, + registerService: () => {}, + on: () => {}, + resolvePath: (p: string) => p, + }; + await memoryPlugin.register(mockApi as never); + + const recallTool = registeredTools.find( + (t) => (t.opts as { name?: string })?.name === "memory_recall", + )?.tool; + expect(recallTool).toBeDefined(); + + // No fetch mock: will fail (or hit real localhost). Use mock to force error. + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn().mockRejectedValue(new Error("Network error")); + + const result = await recallTool!.execute("call-1", { + query: "user preferences", + limit: 5, + }); + + expect(result).toBeDefined(); + const content = (result as { content?: { text?: string }[] })?.content; + expect(Array.isArray(content)).toBe(true); + expect((content as { text: string }[])?.[0]?.text).toContain("Memory search failed"); + + globalThis.fetch = originalFetch; + }); + + test("memory_recall returns memories when fetch returns results", async () => { + const registeredTools: { tool: { execute: (id: string, params: unknown) => Promise }; opts: unknown }[] = []; + const mockApi = { + pluginConfig: { baseUrl: "http://localhost:8000" }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerTool: (tool: unknown, opts: unknown) => { + registeredTools.push({ tool: tool as { execute: (id: string, params: unknown) => Promise }, opts }); + }, + registerCli: () => {}, + registerService: () => {}, + on: () => {}, + resolvePath: (p: string) => p, + }; + await memoryPlugin.register(mockApi as never); + + const recallTool = registeredTools.find( + (t) => (t.opts as { name?: string })?.name === "memory_recall", + )?.tool; + expect(recallTool).toBeDefined(); + + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + success: true, + data: { + results: [ + { memory_id: 1, content: "User likes coffee", score: 0.95 }, + ], + }, + }), + ), + } as Response); + + const result = await recallTool!.execute("call-1", { + query: "coffee", + limit: 5, + }); + + expect((result as { details?: { count: number } })?.details?.count).toBe(1); + expect((result as { details?: { memories?: { text: string }[] } })?.details?.memories?.[0]?.text).toBe( + "User likes coffee", + ); + + globalThis.fetch = originalFetch; + }); +}); diff --git a/extensions/memory-powermem/index.ts b/extensions/memory-powermem/index.ts new file mode 100644 index 000000000..d88b9cce9 --- /dev/null +++ b/extensions/memory-powermem/index.ts @@ -0,0 +1,417 @@ +/** + * 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("")) return false; + if (text.startsWith("<") && text.includes(" 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("", "Search query") + .option("--limit ", "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: `\nThe following memories may be relevant to this conversation:\n${memoryContext}\n`, + }; + } 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; + 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).type === "text" && + "text" in block && + typeof (block as Record).text === "string" + ) { + texts.push((block as Record).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; diff --git a/extensions/memory-powermem/node_modules/@sinclair/typebox b/extensions/memory-powermem/node_modules/@sinclair/typebox new file mode 120000 index 000000000..e0ac1dea2 --- /dev/null +++ b/extensions/memory-powermem/node_modules/@sinclair/typebox @@ -0,0 +1 @@ +../../../../node_modules/.pnpm/@sinclair+typebox@0.34.47/node_modules/@sinclair/typebox \ No newline at end of file diff --git a/extensions/memory-powermem/package.json b/extensions/memory-powermem/package.json new file mode 100644 index 000000000..50c660ba8 --- /dev/null +++ b/extensions/memory-powermem/package.json @@ -0,0 +1,14 @@ +{ + "name": "@moltbot/memory-powermem", + "version": "2026.1.27-beta.1", + "type": "module", + "description": "Moltbot PowerMem long-term memory plugin (HTTP API). Intelligent extraction, Ebbinghaus forgetting curve.", + "dependencies": { + "@sinclair/typebox": "0.34.47" + }, + "moltbot": { + "extensions": [ + "./index.ts" + ] + } +}