extensions: add memory-powermem plugin for long-term memory
This commit is contained in:
parent
699784dbee
commit
5845d84b4d
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
@ -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:
|
||||
|
||||
82
extensions/memory-powermem/README.md
Normal file
82
extensions/memory-powermem/README.md
Normal 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)
|
||||
69
extensions/memory-powermem/clawdbot.plugin.json
Normal file
69
extensions/memory-powermem/clawdbot.plugin.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
148
extensions/memory-powermem/client.test.ts
Normal file
148
extensions/memory-powermem/client.test.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
157
extensions/memory-powermem/client.ts
Normal file
157
extensions/memory-powermem/client.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
94
extensions/memory-powermem/config.ts
Normal file
94
extensions/memory-powermem/config.ts
Normal 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;
|
||||
}
|
||||
196
extensions/memory-powermem/index.test.ts
Normal file
196
extensions/memory-powermem/index.test.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
417
extensions/memory-powermem/index.ts
Normal file
417
extensions/memory-powermem/index.ts
Normal 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;
|
||||
1
extensions/memory-powermem/node_modules/@sinclair/typebox
generated
vendored
Symbolic link
1
extensions/memory-powermem/node_modules/@sinclair/typebox
generated
vendored
Symbolic link
@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/@sinclair+typebox@0.34.47/node_modules/@sinclair/typebox
|
||||
14
extensions/memory-powermem/package.json
Normal file
14
extensions/memory-powermem/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user