extensions: add memory-powermem plugin for long-term memory

This commit is contained in:
Teingi 2026-01-29 15:29:52 +08:00
parent 699784dbee
commit 5845d84b4d
10 changed files with 1182 additions and 0 deletions

4
.github/labeler.yml vendored
View File

@ -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:

View File

@ -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 <query> [--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)

View File

@ -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"]
}
}

View File

@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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;
});
});

View File

@ -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<string, unknown>;
};
export type PowerMemAddResult = {
memory_id: number;
content: string;
user_id?: string;
agent_id?: string;
metadata?: Record<string, unknown>;
};
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<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (apiKey) {
headers["X-API-Key"] = apiKey;
}
return headers;
}
async function handleResponse<T>(res: Response, parseJson = true): Promise<T> {
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<T>(
method: string,
path: string,
body?: unknown,
parseJson = true,
): Promise<T> {
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<T>(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<string, unknown> } = {},
): Promise<PowerMemAddResult[]> {
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<PowerMemSearchResult[]> {
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<void> {
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,
);
}
}

View File

@ -0,0 +1,94 @@
/**
* PowerMem memory plugin configuration.
* Validates baseUrl, optional apiKey, and user/agent mapping.
*/
function assertAllowedKeys(
value: Record<string, unknown>,
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<string, unknown>;
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;
}

View File

@ -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<string, unknown[]> = {};
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<unknown> }; 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<unknown> }, 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<unknown> }; 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<unknown> }, 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;
});
});

View File

@ -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("<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;

View File

@ -0,0 +1 @@
../../../../node_modules/.pnpm/@sinclair+typebox@0.34.47/node_modules/@sinclair/typebox

View File

@ -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"
]
}
}