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:
|
- changed-files:
|
||||||
- any-glob-to-any-file:
|
- any-glob-to-any-file:
|
||||||
- "extensions/memory-lancedb/**"
|
- "extensions/memory-lancedb/**"
|
||||||
|
"extensions: memory-powermem":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/memory-powermem/**"
|
||||||
"extensions: open-prose":
|
"extensions: open-prose":
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file:
|
- 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