From 13ab7db4e895b4aae9e15c846cd21eebd1ee201f Mon Sep 17 00:00:00 2001 From: Benedikt Koehler Date: Sun, 12 Apr 2026 15:43:25 +0200 Subject: [PATCH] feat: add mem0 memory plugin --- CHANGELOG.md | 7 + README.md | 1 + docs/development/agents.md | 1 + docs/development/extensibility/README.md | 4 +- .../extensibility/honcho-memory-plugin.md | 2 +- .../extensibility/mem0-memory-plugin.md | 160 ++++++ .../extensibility/mempalace-memory-plugin.md | 34 ++ docs/development/extensibility/plugins.md | 4 + .../extensibility/qmd-memory-plugin.md | 19 + docs/static/docs.js | 4 + plugins/mem0-memory/hybridclaw.plugin.yaml | 71 +++ plugins/mem0-memory/package.json | 13 + plugins/mem0-memory/src/config.js | 113 +++++ plugins/mem0-memory/src/index.js | 127 +++++ plugins/mem0-memory/src/mem0-client.js | 167 +++++++ plugins/mem0-memory/src/mem0-controls.js | 179 +++++++ plugins/mem0-memory/src/mem0-runtime.js | 360 ++++++++++++++ src/cli/help.ts | 1 + tests/mem0-memory-plugin.test.ts | 458 ++++++++++++++++++ 19 files changed, 1722 insertions(+), 3 deletions(-) create mode 100644 docs/development/extensibility/mem0-memory-plugin.md create mode 100644 plugins/mem0-memory/hybridclaw.plugin.yaml create mode 100644 plugins/mem0-memory/package.json create mode 100644 plugins/mem0-memory/src/config.js create mode 100644 plugins/mem0-memory/src/index.js create mode 100644 plugins/mem0-memory/src/mem0-client.js create mode 100644 plugins/mem0-memory/src/mem0-controls.js create mode 100644 plugins/mem0-memory/src/mem0-runtime.js create mode 100644 tests/mem0-memory-plugin.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aa09e85a..54faefe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Coming up] +### Added + +- **Mem0 memory plugin**: Added a bundled `mem0-memory` plugin so local + HybridClaw installs can mirror turns into Mem0 cloud memory, inject + prompt-time Mem0 recall, expose `mem0_*` tools, and mirror explicit native + memory writes back into Mem0. + ## [0.12.3](https://github.com/HybridAIOne/hybridclaw/tree/v0.12.3) ### Added diff --git a/README.md b/README.md index 3bc30c54..61ea62ee 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,7 @@ Browse the full manual at [Bundled Skills](https://www.hybridclaw.io/docs/guides/bundled-skills), [Plugin System](https://www.hybridclaw.io/docs/extensibility/plugins), [GBrain Plugin](https://www.hybridclaw.io/docs/extensibility/gbrain-plugin), + [Mem0 Memory Plugin](https://www.hybridclaw.io/docs/extensibility/mem0-memory-plugin), [Honcho Memory Plugin](https://www.hybridclaw.io/docs/extensibility/honcho-memory-plugin), and [MemPalace Memory Plugin](https://www.hybridclaw.io/docs/extensibility/mempalace-memory-plugin) - Configuration: diff --git a/docs/development/agents.md b/docs/development/agents.md index 6d64b2b9..b2d79ea2 100644 --- a/docs/development/agents.md +++ b/docs/development/agents.md @@ -44,6 +44,7 @@ Main docs landing pages: - [Adaptive Skills](./extensibility/adaptive-skills.md) - [Agent Packages](./extensibility/agent-packages.md) - [Honcho Memory Plugin](./extensibility/honcho-memory-plugin.md) +- [Mem0 Memory Plugin](./extensibility/mem0-memory-plugin.md) - [MemPalace Memory Plugin](./extensibility/mempalace-memory-plugin.md) - [OTEL Plugin](./extensibility/otel-plugin.md) - [Plugins](./extensibility/plugins.md) diff --git a/docs/development/extensibility/README.md b/docs/development/extensibility/README.md index 65f18922..e51a50c8 100644 --- a/docs/development/extensibility/README.md +++ b/docs/development/extensibility/README.md @@ -20,7 +20,7 @@ operate at different layers, and are designed to complement each other. | **Install** | Ship with the codebase | Drop a `SKILL.md` file | `hybridclaw plugin install` or drop a directory | | **Hot reload** | Requires rebuild | Immediate (loaded per turn) | `/plugin reload` in session | | **Config needed** | Code change | None | `hybridclaw.plugin.yaml` manifest | -| **Example** | `read`, `write`, `web_fetch`, `bash` | `pdf`, `github-pr-workflow`, `notion` | `gbrain`, `honcho-memory` | +| **Example** | `read`, `write`, `web_fetch`, `bash` | `pdf`, `github-pr-workflow`, `notion` | `gbrain`, `honcho-memory`, `mem0-memory` | ## Tools (Container Runtime) @@ -100,7 +100,7 @@ register runtime surfaces through the `HybridClawPluginApi`: **When to use:** - You need a tool that calls an external API (no sandbox restrictions needed) - You want to integrate an external memory or context system (e.g., Honcho, - LanceDB) + Mem0, LanceDB) - You need to react to lifecycle events (session start/end, tool calls, compaction) - You want to distribute an extension as a standalone package diff --git a/docs/development/extensibility/honcho-memory-plugin.md b/docs/development/extensibility/honcho-memory-plugin.md index b43b72d8..a7783802 100644 --- a/docs/development/extensibility/honcho-memory-plugin.md +++ b/docs/development/extensibility/honcho-memory-plugin.md @@ -447,7 +447,7 @@ The usual first-run flow is: If you already have useful context in `SOUL.md`, `IDENTITY.md`, `USER.md`, or `MEMORY.md`, the setup step will seed that data into Honcho on demand. -## Tips And Tricks +## Tips & Tricks - Use the defaults first. In most cases, `/secret set HONCHO_API_KEY ...` plus `/honcho setup` is enough to get started. diff --git a/docs/development/extensibility/mem0-memory-plugin.md b/docs/development/extensibility/mem0-memory-plugin.md new file mode 100644 index 00000000..b3998e68 --- /dev/null +++ b/docs/development/extensibility/mem0-memory-plugin.md @@ -0,0 +1,160 @@ +--- +title: Mem0 Memory Plugin +description: Setup and behavior for the bundled `mem0-memory` plugin. +sidebar_position: 3 +--- + +# Mem0 Memory Plugin + +HybridClaw ships a bundled Mem0 memory provider at +[`plugins/mem0-memory`](https://github.com/HybridAIOne/hybridclaw/tree/main/plugins/mem0-memory). + +The plugin keeps HybridClaw's built-in memory active and layers Mem0 cloud +recall on top. It can: + +- inject prompt-time Mem0 profile and semantic-search context +- expose direct `mem0_profile`, `mem0_search`, and `mem0_conclude` tools +- expose a `/mem0 ...` command surface in local sessions +- mirror completed turns into Mem0 under the active HybridClaw user and agent +- mirror explicit native memory writes back into Mem0 as durable conclusions + +## Requirements + +- a Mem0 API key from [app.mem0.ai](https://app.mem0.ai/dashboard/api-keys) +- local plugin install so the plugin-local `mem0ai` dependency is available + +## Install + +```bash +hybridclaw plugin install ./plugins/mem0-memory --yes +hybridclaw plugin enable mem0-memory +``` + +Then configure the API key: + +```text +/secret set MEM0_API_KEY your-mem0-key +/plugin reload +``` + +## Minimal Config + +```json +{ + "plugins": { + "list": [ + { + "id": "mem0-memory", + "enabled": true, + "config": { + "host": "https://api.mem0.ai", + "apiVersion": "v2", + "searchLimit": 5, + "profileLimit": 10 + } + } + ] + } +} +``` + +Useful optional keys: + +- `organizationId`: pin the plugin to a specific Mem0 organization +- `projectId`: pin the plugin to a specific Mem0 project +- `userId`: override HybridClaw's per-session user id +- `agentId`: override HybridClaw's active agent id +- `appId`: defaults to `hybridclaw` +- `prefetchRerank`: rerank prompt-time Mem0 searches +- `syncTurns`: disable automatic turn mirroring when set to `false` +- `mirrorNativeMemoryWrites`: disable explicit native-memory mirroring when set + to `false` + +## Commands + +The plugin registers `/mem0` with these subcommands: + +- `/mem0 status` +- `/mem0 profile` +- `/mem0 search ` +- `/mem0 conclude ` + +Examples: + +```text +/mem0 status +/mem0 profile +/mem0 search dark mode +/mem0 conclude User prefers short status updates. +``` + +## Tools + +The plugin registers these tools: + +- `mem0_profile` +- `mem0_search` +- `mem0_conclude` + +Use `mem0_search` for targeted recall, `mem0_profile` for a broader snapshot, +and `mem0_conclude` only for durable facts or corrections worth keeping across +sessions. + +## Runtime Behavior + +When enabled with a configured `MEM0_API_KEY`: + +1. The plugin runs a Mem0 health check on startup. +2. Before prompts, it fetches a profile snapshot and searches Mem0 using the + latest user message. +3. After each completed turn, it mirrors user and assistant messages into Mem0. +4. When HybridClaw writes native memory files such as `USER.md`, it mirrors the + explicit write into Mem0 as a durable conclusion. + +Read-side Mem0 recall is scoped to the current HybridClaw user id by default. +Write-side sync uses the current HybridClaw user id plus the active agent id so +Mem0 keeps attribution data. + +## Verification + +1. Install and enable the plugin. +2. Set `MEM0_API_KEY`. +3. Run `/mem0 status` and confirm `Connection: ok`. +4. Chat for a turn, then run `/mem0 search `. +5. Save an explicit native memory fact, then run `/mem0 search `. + +Expected result: prompt-time recall includes Mem0 context, `/mem0 search ...` +returns stored memories, and explicit native memory writes appear in later Mem0 +search results. + +## Tips & Tricks + +- Leave `userId` and `agentId` unset unless you have a deliberate cross-session + routing plan. The defaults follow HybridClaw's active user and agent scope. +- Keep `apiVersion: v2` unless you have a concrete compatibility reason to use + `v1`; the plugin is tuned around Mem0's newer filtered read path. +- Use `mem0_profile` first when you want a broad snapshot, and `mem0_search` + for narrower questions. That keeps prompt and tool usage more predictable. +- Keep `syncTurns` enabled for normal operation, but temporarily disable it if + you want read-only Mem0 recall during debugging or rollout. +- Keep `mirrorNativeMemoryWrites` enabled when you want explicit `USER.md` or + `MEMORY.md` saves to become durable Mem0 facts without extra manual steps. + +## Troubleshooting + +- `/mem0 status` reports a missing API key: + set `MEM0_API_KEY` through `/secret`, then reload the plugin. The plugin does + not read plaintext API keys from plugin config. +- The plugin loads but prompt recall is empty: + verify the scoped Mem0 user id, then run `/mem0 profile` and `/mem0 search` + manually to determine whether the issue is missing stored memories or prompt + injection. +- Search results look too broad or unrelated: + confirm you are using the intended HybridClaw user scope, and override + `userId` only when you intentionally want shared memory across sessions. +- New turns are not showing up in Mem0: + check that `syncTurns` is still enabled and that `/mem0 status` reports a + healthy connection before assuming retrieval is broken. +- Repo edits do not affect the installed plugin: + run `/plugin reinstall ./plugins/mem0-memory` and then `/plugin reload` + because reload alone uses the installed copy under `~/.hybridclaw/plugins/`. diff --git a/docs/development/extensibility/mempalace-memory-plugin.md b/docs/development/extensibility/mempalace-memory-plugin.md index 4f784bf6..9dbebd73 100644 --- a/docs/development/extensibility/mempalace-memory-plugin.md +++ b/docs/development/extensibility/mempalace-memory-plugin.md @@ -505,6 +505,40 @@ Supported config keys: - `timeoutMs`: timeout for `status`, `wake-up`, and automatic search calls. The plugin gives `mine` a longer minimum timeout automatically. +## Tips & Tricks + +- Start with CLI recall first. Run `mempalace status`, `mempalace wake-up`, and + `mempalace search ...` outside HybridClaw before tuning plugin config. +- Keep `palacePath`, the CLI you test manually, and any optional MemPalace MCP + server pointed at the same palace or results will look inconsistent. +- Use a smaller `saveEveryMessages` while testing so new turns show up in + MemPalace faster, then raise it again for normal usage. +- Use `wakeUpWing`, `searchWing`, and `updateWing` when one palace contains + multiple projects or domains and broad recall starts pulling the wrong facts. +- Let HybridClaw built-in memory keep operational scratch state. Use MemPalace + for broader external recall rather than forcing every short-lived note into + the palace. + +## Troubleshooting + +- `/mempalace status` works in your shell but the plugin still reports it as + missing: + point `plugin config mempalace-memory command /absolute/path/to/mempalace` + at the exact executable you tested manually. +- Prompt recall is empty: + verify whether the plugin is using CLI fallback or an enabled MemPalace MCP + server, then test the corresponding path directly with `/mempalace search ...` + or `/mcp list`. +- New chat turns are not appearing in MemPalace: + lower `saveEveryMessages`, confirm `sessionExportDir` is writable, and check + whether `mempalace mine ... --mode convos` succeeds for exported transcripts. +- Retrieval seems to come from the wrong project: + tighten `wakeUpWing`, `searchWing`, `searchRoom`, or `updateWing` so the + plugin stops recalling across unrelated palace areas. +- Repo edits do not affect the active plugin: + reinstall the repo-shipped copy with `/plugin reinstall ./plugins/mempalace-memory` + and then reload the plugin. + ## What This Plugin Does Not Do This plugin is intentionally narrow. It does not: diff --git a/docs/development/extensibility/plugins.md b/docs/development/extensibility/plugins.md index f1a81353..12bc5361 100644 --- a/docs/development/extensibility/plugins.md +++ b/docs/development/extensibility/plugins.md @@ -19,6 +19,7 @@ hybridclaw plugin config example-plugin workspaceId workspace-a hybridclaw plugin install ./plugins/example-plugin hybridclaw plugin install ./plugins/gbrain hybridclaw plugin install ./plugins/honcho-memory +hybridclaw plugin install ./plugins/mem0-memory hybridclaw plugin install ./plugins/mempalace-memory hybridclaw plugin install ./plugins/qmd-memory hybridclaw plugin install ./plugins/brevo-email @@ -73,6 +74,9 @@ or change one top-level `plugins.list[].config` key without editing tools - `honcho-memory` mirrors HybridClaw turns into Honcho, injects prompt-time recall, and exposes direct Honcho tools while keeping built-in memory active +- `mem0-memory` mirrors HybridClaw turns into Mem0 cloud memory, injects + prompt-time recall, and exposes direct `mem0_*` tools while keeping + built-in memory active - `mempalace-memory` layers MemPalace recall on top of native memory, mirrors turns back into MemPalace, and can route prompt-time retrieval through CLI helpers or an active `mempalace` MCP server diff --git a/docs/development/extensibility/qmd-memory-plugin.md b/docs/development/extensibility/qmd-memory-plugin.md index 2adf335a..ccab281e 100644 --- a/docs/development/extensibility/qmd-memory-plugin.md +++ b/docs/development/extensibility/qmd-memory-plugin.md @@ -149,6 +149,25 @@ alone does not pick up repo edits. - `/qmd embed` is an explicit passthrough command and can run much longer than the short background-search timeout. +## Troubleshooting + +- QMD loads but prompt recall stays empty: + confirm the target docs are actually indexed, then inspect + `~/.hybridclaw/data/last_prompt.jsonl` to distinguish "plugin loaded but no + hits" from "plugin never ran". +- `/qmd status` works but retrieval quality is poor: + switch between `query`, `search`, and `vsearch` deliberately instead of + assuming the default mode fits the collection you built. +- Background retrieval times out: + increase `timeoutMs`, reduce the size of the indexed corpus, or use explicit + `/qmd ...` commands for longer-running operations. +- Repo edits do not affect the installed plugin: + run `/plugin reinstall ./plugins/qmd-memory` and then `/plugin reload`; + reload alone only reuses the installed copy under `~/.hybridclaw/plugins/`. +- Searches miss exact terms you know exist: + try `/qmd search ` first. If lexical search fails too, the issue is + likely indexing rather than prompt wording. + ## Verifying Retrieval To verify that the plugin is both loaded and actively contributing context: diff --git a/docs/static/docs.js b/docs/static/docs.js index f45fe0d6..9a6d081b 100644 --- a/docs/static/docs.js +++ b/docs/static/docs.js @@ -43,6 +43,10 @@ export const DEVELOPMENT_DOCS_SECTIONS = [ title: 'Honcho Memory Plugin', path: 'extensibility/honcho-memory-plugin.md', }, + { + title: 'Mem0 Memory Plugin', + path: 'extensibility/mem0-memory-plugin.md', + }, { title: 'MemPalace Memory Plugin', path: 'extensibility/mempalace-memory-plugin.md', diff --git a/plugins/mem0-memory/hybridclaw.plugin.yaml b/plugins/mem0-memory/hybridclaw.plugin.yaml new file mode 100644 index 00000000..61d1c711 --- /dev/null +++ b/plugins/mem0-memory/hybridclaw.plugin.yaml @@ -0,0 +1,71 @@ +id: mem0-memory +name: Mem0 Memory +version: 0.1.0 +kind: memory +memoryProvider: true +description: Mirror HybridClaw turns into Mem0 cloud memory, inject prompt-time recall, and expose direct Mem0 memory tools. +entrypoint: src/index.js +credentials: + - MEM0_API_KEY +configSchema: + type: object + additionalProperties: false + properties: + host: + type: string + default: https://api.mem0.ai + organizationId: + type: string + projectId: + type: string + appId: + type: string + default: hybridclaw + userId: + type: string + agentId: + type: string + apiVersion: + type: string + enum: [v1, v2] + default: v2 + searchLimit: + type: number + default: 5 + minimum: 1 + maximum: 20 + profileLimit: + type: number + default: 10 + minimum: 1 + maximum: 50 + maxInjectedChars: + type: number + default: 4000 + minimum: 500 + maximum: 20000 + messageMaxChars: + type: number + default: 4000 + minimum: 200 + maximum: 20000 + timeoutMs: + type: number + default: 15000 + minimum: 1000 + maximum: 60000 + prefetchRerank: + type: boolean + default: true + includeProfile: + type: boolean + default: true + includeSearch: + type: boolean + default: true + syncTurns: + type: boolean + default: true + mirrorNativeMemoryWrites: + type: boolean + default: true diff --git a/plugins/mem0-memory/package.json b/plugins/mem0-memory/package.json new file mode 100644 index 00000000..6acd40f4 --- /dev/null +++ b/plugins/mem0-memory/package.json @@ -0,0 +1,13 @@ +{ + "name": "hybridclaw-plugin-mem0-memory", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "HybridClaw memory plugin for Mem0 cloud recall and sync", + "engines": { + "node": "22.x" + }, + "dependencies": { + "mem0ai": "^2.4.6" + } +} diff --git a/plugins/mem0-memory/src/config.js b/plugins/mem0-memory/src/config.js new file mode 100644 index 00000000..c9103497 --- /dev/null +++ b/plugins/mem0-memory/src/config.js @@ -0,0 +1,113 @@ +const MEM0_API_VERSIONS = new Set(['v1', 'v2']); + +function normalizeString(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function normalizeInteger(value, key, fallback, bounds = {}) { + if (value == null) return fallback; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`mem0-memory plugin config.${key} must be a number.`); + } + const normalized = Math.trunc(value); + if ( + typeof bounds.minimum === 'number' && + normalized < Math.trunc(bounds.minimum) + ) { + throw new Error( + `mem0-memory plugin config.${key} must be >= ${Math.trunc(bounds.minimum)}.`, + ); + } + if ( + typeof bounds.maximum === 'number' && + normalized > Math.trunc(bounds.maximum) + ) { + throw new Error( + `mem0-memory plugin config.${key} must be <= ${Math.trunc(bounds.maximum)}.`, + ); + } + return normalized; +} + +function normalizeApiVersion(value) { + const normalized = normalizeString(value) || 'v2'; + if (!MEM0_API_VERSIONS.has(normalized)) { + throw new Error( + `mem0-memory plugin config.apiVersion must be one of: ${[...MEM0_API_VERSIONS].join(', ')}.`, + ); + } + return normalized; +} + +function normalizeAbsoluteUrl(value, key, fallback) { + const normalized = normalizeString(value) || fallback; + try { + const url = new URL(normalized); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('unsupported'); + } + return url.toString().replace(/\/$/, ''); + } catch { + throw new Error( + `mem0-memory plugin config.${key} must be a valid absolute URL.`, + ); + } +} + +export function resolveMem0PluginConfig(params) { + const pluginConfig = params?.pluginConfig || {}; + return Object.freeze({ + apiKey: normalizeString(params?.credentialApiKey), + host: normalizeAbsoluteUrl( + pluginConfig.host, + 'host', + 'https://api.mem0.ai', + ), + organizationId: normalizeString(pluginConfig.organizationId), + projectId: normalizeString(pluginConfig.projectId), + appId: normalizeString(pluginConfig.appId) || 'hybridclaw', + userId: normalizeString(pluginConfig.userId), + agentId: normalizeString(pluginConfig.agentId), + apiVersion: normalizeApiVersion(pluginConfig.apiVersion), + searchLimit: normalizeInteger(pluginConfig.searchLimit, 'searchLimit', 5, { + minimum: 1, + maximum: 20, + }), + profileLimit: normalizeInteger( + pluginConfig.profileLimit, + 'profileLimit', + 10, + { + minimum: 1, + maximum: 50, + }, + ), + maxInjectedChars: normalizeInteger( + pluginConfig.maxInjectedChars, + 'maxInjectedChars', + 4000, + { + minimum: 500, + maximum: 20000, + }, + ), + messageMaxChars: normalizeInteger( + pluginConfig.messageMaxChars, + 'messageMaxChars', + 4000, + { + minimum: 200, + maximum: 20000, + }, + ), + timeoutMs: normalizeInteger(pluginConfig.timeoutMs, 'timeoutMs', 15000, { + minimum: 1000, + maximum: 60000, + }), + prefetchRerank: pluginConfig.prefetchRerank !== false, + includeProfile: pluginConfig.includeProfile !== false, + includeSearch: pluginConfig.includeSearch !== false, + syncTurns: pluginConfig.syncTurns !== false, + mirrorNativeMemoryWrites: pluginConfig.mirrorNativeMemoryWrites !== false, + }); +} diff --git a/plugins/mem0-memory/src/index.js b/plugins/mem0-memory/src/index.js new file mode 100644 index 00000000..45eea640 --- /dev/null +++ b/plugins/mem0-memory/src/index.js @@ -0,0 +1,127 @@ +import { resolveMem0PluginConfig } from './config.js'; +import { Mem0Controls } from './mem0-controls.js'; +import { Mem0Runtime } from './mem0-runtime.js'; + +function registerMem0Tools(api, controls) { + api.registerTool({ + name: 'mem0_profile', + description: 'Retrieve a broad Mem0 memory snapshot for the current user.', + parameters: { + type: 'object', + properties: {}, + required: [], + }, + handler(args, context) { + return controls.handleToolProfile(args, context); + }, + }); + + api.registerTool({ + name: 'mem0_search', + description: + 'Search Mem0 semantic memory for the current user with optional reranking.', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'What to search for in Mem0 memory.', + }, + top_k: { + type: 'number', + description: 'Maximum number of results to return.', + }, + rerank: { + type: 'boolean', + description: + 'Whether to rerank search results before returning them.', + }, + }, + required: ['query'], + }, + handler(args, context) { + return controls.handleToolSearch(args, context); + }, + }); + + api.registerTool({ + name: 'mem0_conclude', + description: + 'Store an explicit durable fact or correction in Mem0 for the current user.', + parameters: { + type: 'object', + properties: { + conclusion: { + type: 'string', + description: 'The durable fact, preference, or correction to store.', + }, + }, + required: ['conclusion'], + }, + handler(args, context) { + return controls.handleToolConclude(args, context); + }, + }); +} + +export default { + id: 'mem0-memory', + kind: 'memory', + register(api) { + const config = resolveMem0PluginConfig({ + pluginConfig: api.pluginConfig, + runtime: api.runtime, + credentialApiKey: api.getCredential('MEM0_API_KEY'), + }); + const runtime = new Mem0Runtime(api, config); + const controls = new Mem0Controls(runtime); + + api.registerMemoryLayer({ + id: 'mem0-memory-layer', + priority: 45, + start() { + return runtime.start(); + }, + getContextForPrompt(params) { + return runtime.getContextForPrompt(params); + }, + onTurnComplete(params) { + return runtime.onTurnComplete(params); + }, + }); + + api.registerPromptHook({ + id: 'mem0-memory-guide', + priority: 45, + render() { + return runtime.renderPromptGuide(); + }, + }); + + api.on('memory_write', (context) => runtime.onMemoryWrite(context), { + priority: 45, + }); + + api.registerCommand({ + name: 'mem0', + description: + 'Inspect Mem0 sync state, search saved memories, and store explicit conclusions.', + handler(args, context) { + return controls.handleCommand(args, context); + }, + }); + + registerMem0Tools(api, controls); + + api.logger.info( + { + host: config.host, + apiVersion: config.apiVersion, + searchLimit: config.searchLimit, + profileLimit: config.profileLimit, + syncTurns: config.syncTurns, + }, + 'Mem0 memory plugin registered', + ); + }, +}; diff --git a/plugins/mem0-memory/src/mem0-client.js b/plugins/mem0-memory/src/mem0-client.js new file mode 100644 index 00000000..d9848e91 --- /dev/null +++ b/plugins/mem0-memory/src/mem0-client.js @@ -0,0 +1,167 @@ +function normalizeString(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function buildMissingDependencyError(error) { + const message = + error instanceof Error ? error.message : String(error || 'Unknown error'); + if ( + message.includes("Cannot find package 'mem0ai'") || + message.includes("Cannot find module 'mem0ai'") || + message.includes('ERR_MODULE_NOT_FOUND') + ) { + return new Error( + 'Mem0 SDK is not installed for plugin `mem0-memory`. Run `hybridclaw plugin install mem0-memory --yes` or `hybridclaw plugin reinstall ./plugins/mem0-memory --yes`.', + ); + } + return error instanceof Error ? error : new Error(message); +} + +let mem0ModulePromise = null; + +async function loadMem0Module() { + if (!mem0ModulePromise) { + if (!normalizeString(process.env.MEM0_TELEMETRY)) { + process.env.MEM0_TELEMETRY = 'false'; + } + mem0ModulePromise = import('mem0ai').catch((error) => { + mem0ModulePromise = null; + throw buildMissingDependencyError(error); + }); + } + return await mem0ModulePromise; +} + +function buildReadOptions(config, userId, extra = {}) { + if (config.apiVersion === 'v2') { + return { + api_version: 'v2', + filters: { user_id: userId }, + ...extra, + }; + } + return { + api_version: 'v1', + user_id: userId, + ...extra, + }; +} + +function buildWriteOptions(config, userId, agentId, extra = {}) { + return { + api_version: config.apiVersion, + user_id: userId, + agent_id: agentId, + app_id: config.appId, + ...extra, + }; +} + +export function normalizeMem0Results(response) { + if (Array.isArray(response)) return response; + if (response && typeof response === 'object') { + if (Array.isArray(response.results)) return response.results; + if (Array.isArray(response.memories)) return response.memories; + } + return []; +} + +export function extractMemoryText(entry) { + if (!entry || typeof entry !== 'object') return ''; + if (typeof entry.memory === 'string' && entry.memory.trim()) { + return entry.memory.trim(); + } + if ( + entry.data && + typeof entry.data === 'object' && + typeof entry.data.memory === 'string' && + entry.data.memory.trim() + ) { + return entry.data.memory.trim(); + } + if (typeof entry.text === 'string' && entry.text.trim()) { + return entry.text.trim(); + } + return ''; +} + +export class Mem0PluginClient { + constructor(config) { + this.config = config; + this.clientPromise = null; + } + + async getClient() { + if (!this.clientPromise) { + this.clientPromise = (async () => { + const { MemoryClient } = await loadMem0Module(); + const client = new MemoryClient({ + apiKey: this.config.apiKey, + host: this.config.host, + ...(this.config.organizationId + ? { organizationId: this.config.organizationId } + : {}), + ...(this.config.projectId + ? { projectId: this.config.projectId } + : {}), + }); + if (client?.client?.defaults) { + client.client.defaults.timeout = this.config.timeoutMs; + } + return client; + })(); + } + return await this.clientPromise; + } + + async ping() { + const client = await this.getClient(); + return await client.ping(); + } + + async getProfile(userId, config = {}) { + const client = await this.getClient(); + const response = await client.getAll( + buildReadOptions(this.config, userId, { + page: 1, + page_size: config.pageSize || this.config.profileLimit, + }), + ); + return normalizeMem0Results(response); + } + + async search(userId, query, config = {}) { + const client = await this.getClient(); + const response = await client.search( + query, + buildReadOptions(this.config, userId, { + top_k: config.topK || this.config.searchLimit, + rerank: + typeof config.rerank === 'boolean' + ? config.rerank + : this.config.prefetchRerank, + }), + ); + return normalizeMem0Results(response); + } + + async syncMessages(userId, agentId, messages, metadata = {}) { + if (!Array.isArray(messages) || messages.length === 0) return []; + const client = await this.getClient(); + return await client.add( + messages, + buildWriteOptions(this.config, userId, agentId, { metadata }), + ); + } + + async storeConclusion(userId, agentId, conclusion, metadata = {}) { + const client = await this.getClient(); + return await client.add( + [{ role: 'user', content: conclusion }], + buildWriteOptions(this.config, userId, agentId, { + metadata, + infer: false, + }), + ); + } +} diff --git a/plugins/mem0-memory/src/mem0-controls.js b/plugins/mem0-memory/src/mem0-controls.js new file mode 100644 index 00000000..82f740bf --- /dev/null +++ b/plugins/mem0-memory/src/mem0-controls.js @@ -0,0 +1,179 @@ +import { extractMemoryText } from './mem0-client.js'; + +function normalizeString(value) { + return String(value || '').trim(); +} + +function formatMemoryList(title, entries, options = {}) { + if (!Array.isArray(entries) || entries.length === 0) { + return `${title}\nNo Mem0 memories matched.`; + } + return [ + title, + ...entries.map((entry, index) => { + const lines = [ + `[${index + 1}] ${extractMemoryText(entry) || '(empty memory)'}`, + ]; + if (options.includeScore && typeof entry?.score === 'number') { + lines.push(`score=${entry.score.toFixed(3)}`); + } + if (typeof entry?.id === 'string' && entry.id.trim()) { + lines.push(`id=${entry.id.trim()}`); + } + return lines.join('\n'); + }), + ].join('\n\n'); +} + +function buildToolMemoryResult(entries) { + return entries + .map((entry) => ({ + id: normalizeString(entry?.id), + memory: extractMemoryText(entry), + score: typeof entry?.score === 'number' ? entry.score : undefined, + categories: Array.isArray(entry?.categories) ? entry.categories : [], + user_id: normalizeString(entry?.user_id), + agent_id: normalizeString(entry?.agent_id), + })) + .filter((entry) => entry.memory); +} + +export class Mem0Controls { + constructor(runtime) { + this.runtime = runtime; + } + + async handleCommand(args, context) { + const normalizedArgs = (args || []) + .map((arg) => normalizeString(arg)) + .filter(Boolean); + const subcommand = normalizeString( + normalizedArgs[0] || 'status', + ).toLowerCase(); + try { + if (subcommand === 'profile') { + const result = await this.runtime.fetchProfile( + context.sessionId, + context.userId, + ); + return formatMemoryList( + `Mem0 profile for ${result.userId}`, + result.entries, + ); + } + if (subcommand === 'search') { + const query = normalizeString(normalizedArgs.slice(1).join(' ')); + if (!query) return 'Usage: /mem0 search '; + const result = await this.runtime.search( + context.sessionId, + context.userId, + query, + ); + return formatMemoryList(`Mem0 search for "${query}"`, result.entries, { + includeScore: true, + }); + } + if (subcommand === 'conclude') { + const conclusion = normalizeString(normalizedArgs.slice(1).join(' ')); + if (!conclusion) return 'Usage: /mem0 conclude '; + const result = await this.runtime.storeConclusion( + context.sessionId, + context.userId, + null, + conclusion, + ); + return [ + 'Saved conclusion to Mem0.', + `User scope: ${result.userId}`, + `Agent scope: ${result.agentId}`, + `Conclusion: ${conclusion}`, + ].join('\n'); + } + return await this.runtime.buildStatusText( + context.sessionId, + context.userId, + null, + ); + } catch (error) { + const message = + error instanceof Error + ? error.message + : String(error || 'Unknown error'); + return ['Mem0 command failed.', '', message].join('\n'); + } + } + + async handleToolProfile(_args, context) { + const result = await this.runtime.fetchProfile(context.sessionId, ''); + return JSON.stringify( + { + userId: result.userId, + count: result.entries.length, + results: buildToolMemoryResult(result.entries), + }, + null, + 2, + ); + } + + async handleToolSearch(args, context) { + const query = normalizeString(args.query); + if (!query) { + return JSON.stringify( + { + ok: false, + error: 'mem0_search requires a query.', + }, + null, + 2, + ); + } + const result = await this.runtime.search(context.sessionId, '', query, { + topK: + typeof args.top_k === 'number' && Number.isFinite(args.top_k) + ? Math.trunc(args.top_k) + : undefined, + rerank: typeof args.rerank === 'boolean' ? args.rerank : undefined, + }); + return JSON.stringify( + { + userId: result.userId, + query, + count: result.entries.length, + results: buildToolMemoryResult(result.entries), + }, + null, + 2, + ); + } + + async handleToolConclude(args, context) { + const conclusion = normalizeString(args.conclusion); + if (!conclusion) { + return JSON.stringify( + { + ok: false, + error: 'mem0_conclude requires a conclusion.', + }, + null, + 2, + ); + } + const result = await this.runtime.storeConclusion( + context.sessionId, + '', + '', + conclusion, + ); + return JSON.stringify( + { + ok: true, + userId: result.userId, + agentId: result.agentId, + conclusion, + }, + null, + 2, + ); + } +} diff --git a/plugins/mem0-memory/src/mem0-runtime.js b/plugins/mem0-memory/src/mem0-runtime.js new file mode 100644 index 00000000..947e08b0 --- /dev/null +++ b/plugins/mem0-memory/src/mem0-runtime.js @@ -0,0 +1,360 @@ +import path from 'node:path'; +import { + extractMemoryText, + Mem0PluginClient, + normalizeMem0Results, +} from './mem0-client.js'; + +function normalizeString(value) { + return String(value || '').trim(); +} + +function truncateText(value, maxChars) { + const normalized = normalizeString(value); + if (normalized.length <= maxChars) return normalized; + return `${normalized.slice(0, Math.max(0, maxChars - 1)).trimEnd()}...`; +} + +function getLatestUserQuery(recentMessages) { + let latest = null; + for (const message of recentMessages || []) { + if (normalizeString(message?.role).toLowerCase() !== 'user') continue; + if (!latest) { + latest = message; + continue; + } + const currentTime = Date.parse(String(message.created_at || '')); + const latestTime = Date.parse(String(latest.created_at || '')); + if ( + (Number.isFinite(currentTime) ? currentTime : Number.NEGATIVE_INFINITY) >= + (Number.isFinite(latestTime) ? latestTime : Number.NEGATIVE_INFINITY) + ) { + latest = message; + } + } + return normalizeString(latest?.content); +} + +function formatMemoryBullet(entry, index, includeScore = false) { + const text = extractMemoryText(entry); + if (!text) return ''; + const lines = [`- ${truncateText(text, 600)}`]; + if (includeScore && typeof entry?.score === 'number') { + lines.push(` score=${entry.score.toFixed(3)}`); + } + if (Array.isArray(entry?.categories) && entry.categories.length > 0) { + lines.push(` categories=${entry.categories.join(', ')}`); + } + if (typeof entry?.id === 'string' && entry.id.trim()) { + lines.push(` id=${entry.id.trim()}`); + } else { + lines.push(` result=${index + 1}`); + } + return lines.join('\n'); +} + +function buildPromptContext(params) { + const sections = ['Mem0 memory context:']; + + if (params.profileEntries.length > 0) { + sections.push( + '', + 'Mem0 profile overview:', + ...params.profileEntries + .map((entry, index) => formatMemoryBullet(entry, index)) + .filter(Boolean), + ); + } + + if (params.query && params.searchEntries.length > 0) { + sections.push( + '', + `Mem0 search results for the latest user question: ${params.query}`, + ...params.searchEntries + .map((entry, index) => formatMemoryBullet(entry, index, true)) + .filter(Boolean), + ); + } + + const body = truncateText( + sections.filter(Boolean).join('\n'), + params.maxInjectedChars, + ); + return body === 'Mem0 memory context:' ? null : body; +} + +function toMem0Messages(messages, maxChars) { + const out = []; + for (const message of messages || []) { + const role = normalizeString(message?.role).toLowerCase(); + if (role !== 'user' && role !== 'assistant') continue; + const content = truncateText(message?.content, maxChars); + if (!content) continue; + out.push({ + role, + content, + }); + } + return out; +} + +function buildMemoryWriteText(context) { + const memoryFile = path.basename(normalizeString(context.memoryFilePath)); + const content = + normalizeString(context.newText) || + normalizeString(context.content) || + normalizeString(context.oldText); + if (context.action === 'remove') { + return `HybridClaw removed saved memory from ${memoryFile}.`; + } + if (!content) { + return `HybridClaw updated ${memoryFile} with action ${context.action}.`; + } + return [ + `HybridClaw saved explicit memory in ${memoryFile}.`, + truncateText(content, 1200), + ].join('\n\n'); +} + +export class Mem0Runtime { + constructor(api, config) { + this.api = api; + this.config = config; + this.client = new Mem0PluginClient(config); + } + + hasApiKey() { + return normalizeString(this.config.apiKey).length > 0; + } + + resolveUserId(inputUserId, sessionId) { + const configured = normalizeString(this.config.userId); + if (configured) return configured; + const direct = normalizeString(inputUserId); + if (direct) return direct; + const sessionUserId = normalizeString( + this.api.getSessionInfo(String(sessionId || '').trim()).userId, + ); + return sessionUserId || 'hybridclaw-user'; + } + + resolveAgentId(inputAgentId, sessionId) { + const configured = normalizeString(this.config.agentId); + if (configured) return configured; + const direct = normalizeString(inputAgentId); + if (direct) return direct; + return normalizeString(this.api.resolveSessionAgentId(sessionId)) || 'main'; + } + + async start() { + if (!this.hasApiKey()) { + this.api.logger.warn( + 'Mem0 memory plugin is enabled but MEM0_API_KEY is not configured.', + ); + return; + } + try { + await this.client.ping(); + this.api.logger.debug( + { host: this.config.host, apiVersion: this.config.apiVersion }, + 'Mem0 startup health-check passed', + ); + } catch (error) { + this.api.logger.warn( + { error, host: this.config.host }, + 'Mem0 startup health-check failed', + ); + } + } + + async getContextForPrompt(params) { + if (!this.hasApiKey()) return null; + const userId = this.resolveUserId(params.userId, params.sessionId); + const query = getLatestUserQuery(params.recentMessages); + try { + const [profileEntries, searchEntries] = await Promise.all([ + this.config.includeProfile + ? this.client.getProfile(userId) + : Promise.resolve([]), + this.config.includeSearch && query + ? this.client.search(userId, query) + : Promise.resolve([]), + ]); + const promptContext = buildPromptContext({ + query, + profileEntries, + searchEntries, + maxInjectedChars: this.config.maxInjectedChars, + }); + this.api.logger.debug( + { + query, + userId, + profileCount: profileEntries.length, + searchCount: searchEntries.length, + }, + promptContext + ? 'Mem0 prompt context injected' + : 'Mem0 prompt search returned no matches', + ); + return promptContext; + } catch (error) { + this.api.logger.warn( + { + error, + host: this.config.host, + userId, + query, + }, + 'Mem0 prompt context fetch failed', + ); + return null; + } + } + + async onTurnComplete(params) { + if (!this.hasApiKey() || !this.config.syncTurns) return; + const messages = toMem0Messages( + params.messages, + this.config.messageMaxChars, + ); + if (messages.length === 0) return; + const userId = this.resolveUserId(params.userId, params.sessionId); + const agentId = this.resolveAgentId(params.agentId, params.sessionId); + try { + await this.client.syncMessages(userId, agentId, messages, { + source: 'hybridclaw-turn', + session_id: params.sessionId, + workspace_path: normalizeString(params.workspacePath), + }); + this.api.logger.debug( + { + sessionId: params.sessionId, + userId, + agentId, + syncedMessageCount: messages.length, + }, + 'Mem0 turn synced', + ); + } catch (error) { + this.api.logger.warn( + { + error, + sessionId: params.sessionId, + userId, + agentId, + }, + 'Mem0 turn sync failed', + ); + } + } + + async onMemoryWrite(context) { + if (!this.hasApiKey() || !this.config.mirrorNativeMemoryWrites) return; + const userId = this.resolveUserId('', context.sessionId); + const agentId = this.resolveAgentId(context.agentId, context.sessionId); + try { + await this.client.storeConclusion( + userId, + agentId, + buildMemoryWriteText(context), + { + source: 'hybridclaw-memory-write', + session_id: context.sessionId, + action: context.action, + memory_file_path: context.memoryFilePath, + }, + ); + this.api.logger.debug( + { + sessionId: context.sessionId, + userId, + agentId, + action: context.action, + memoryFilePath: context.memoryFilePath, + }, + 'Mem0 native memory write mirrored', + ); + } catch (error) { + this.api.logger.warn( + { + error, + sessionId: context.sessionId, + action: context.action, + memoryFilePath: context.memoryFilePath, + }, + 'Mem0 native memory write mirror failed', + ); + } + } + + async fetchProfile(sessionId, inputUserId) { + const userId = this.resolveUserId(inputUserId, sessionId); + const entries = await this.client.getProfile(userId); + return { userId, entries }; + } + + async search(sessionId, inputUserId, query, options = {}) { + const userId = this.resolveUserId(inputUserId, sessionId); + const entries = await this.client.search(userId, query, options); + return { userId, entries }; + } + + async storeConclusion(sessionId, inputUserId, inputAgentId, conclusion) { + const userId = this.resolveUserId(inputUserId, sessionId); + const agentId = this.resolveAgentId(inputAgentId, sessionId); + const response = await this.client.storeConclusion( + userId, + agentId, + conclusion, + { + source: 'hybridclaw-conclusion', + session_id: sessionId, + }, + ); + return { userId, agentId, response: normalizeMem0Results(response) }; + } + + async buildStatusText(sessionId, inputUserId, inputAgentId) { + const userId = this.resolveUserId(inputUserId, sessionId); + const agentId = this.resolveAgentId(inputAgentId, sessionId); + const lines = [ + 'Mem0 status', + `Host: ${this.config.host}`, + `API version: ${this.config.apiVersion}`, + `User scope: ${userId}`, + `Agent scope: ${agentId}`, + `App scope: ${this.config.appId}`, + `Search limit: ${this.config.searchLimit}`, + `Profile limit: ${this.config.profileLimit}`, + `Sync turns: ${this.config.syncTurns ? 'enabled' : 'disabled'}`, + `Native memory mirroring: ${this.config.mirrorNativeMemoryWrites ? 'enabled' : 'disabled'}`, + `API key: ${this.hasApiKey() ? 'configured' : 'missing'}`, + ]; + if (!this.hasApiKey()) { + lines.push('', 'Set MEM0_API_KEY before using the Mem0 memory plugin.'); + return lines.join('\n'); + } + try { + await this.client.ping(); + lines.push('Connection: ok'); + } catch (error) { + const message = + error instanceof Error + ? error.message + : String(error || 'Unknown error'); + lines.push('Connection: failed', '', message); + } + return lines.join('\n'); + } + + renderPromptGuide() { + if (!this.hasApiKey()) return null; + return [ + 'Mem0 memory guide:', + '- Use `mem0_search` for specific facts, preferences, or project context that may already be stored for this user.', + '- Use `mem0_profile` when you need a broader snapshot of stored memories before making assumptions.', + '- Use `mem0_conclude` only for durable facts, preferences, or corrections worth keeping across sessions.', + ].join('\n'); + } +} diff --git a/src/cli/help.ts b/src/cli/help.ts index e97043b0..05f6fd4c 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -542,6 +542,7 @@ Examples: hybridclaw plugin disable qmd-memory hybridclaw plugin enable qmd-memory hybridclaw plugin install ./plugins/example-plugin --yes + hybridclaw plugin install mem0-memory --yes hybridclaw plugin install mempalace-memory --yes hybridclaw plugin install @scope/hybridclaw-plugin-example --yes hybridclaw plugin reinstall ./plugins/example-plugin --yes diff --git a/tests/mem0-memory-plugin.test.ts b/tests/mem0-memory-plugin.test.ts new file mode 100644 index 00000000..3fe0482b --- /dev/null +++ b/tests/mem0-memory-plugin.test.ts @@ -0,0 +1,458 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, expect, test, vi } from 'vitest'; + +import type { RuntimeConfig } from '../src/config/runtime-config.js'; + +const tempDirs: string[] = []; +const originalMem0ApiKey = process.env.MEM0_API_KEY; +const originalMem0Telemetry = process.env.MEM0_TELEMETRY; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function loadRuntimeConfig(): RuntimeConfig { + return JSON.parse( + fs.readFileSync(path.join(process.cwd(), 'config.example.json'), 'utf-8'), + ) as RuntimeConfig; +} + +function installBundledPlugin(cwd: string): string { + const sourceDir = path.join(process.cwd(), 'plugins', 'mem0-memory'); + const targetDir = path.join(cwd, '.hybridclaw', 'plugins', 'mem0-memory'); + fs.mkdirSync(path.dirname(targetDir), { recursive: true }); + fs.cpSync(sourceDir, targetDir, { recursive: true }); + return targetDir; +} + +function installMem0Stub( + pluginDir: string, + responses: Record, +) { + const nodeModuleDir = path.join(pluginDir, 'node_modules', 'mem0ai'); + const logPath = path.join(pluginDir, 'mem0-stub-log.jsonl'); + const responsePath = path.join(pluginDir, 'mem0-stub-responses.json'); + fs.mkdirSync(nodeModuleDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModuleDir, 'package.json'), + JSON.stringify( + { + name: 'mem0ai', + version: '0.0.0-test', + type: 'module', + exports: { + '.': './index.js', + }, + }, + null, + 2, + ), + 'utf-8', + ); + fs.writeFileSync(responsePath, JSON.stringify(responses, null, 2), 'utf-8'); + fs.writeFileSync( + path.join(nodeModuleDir, 'index.js'), + [ + 'import fs from "node:fs";', + `const logPath = ${JSON.stringify(logPath)};`, + `const responsePath = ${JSON.stringify(responsePath)};`, + 'function append(entry) {', + ' fs.appendFileSync(logPath, JSON.stringify(entry) + "\\n", "utf8");', + '}', + 'function readResponses() {', + ' return JSON.parse(fs.readFileSync(responsePath, "utf8"));', + '}', + 'export class MemoryClient {', + ' constructor(options) {', + ' this.options = options;', + ' this.client = { defaults: {} };', + ' append({ method: "constructor", options });', + ' }', + ' async ping() {', + ' append({ method: "ping" });', + ' return { status: "ok", org_id: "org-test", project_id: "proj-test" };', + ' }', + ' async getAll(options = {}) {', + ' append({ method: "getAll", options });', + ' return readResponses().getAll ?? [];', + ' }', + ' async search(query, options = {}) {', + ' append({ method: "search", query, options });', + ' return readResponses().search ?? [];', + ' }', + ' async add(messages, options = {}) {', + ' append({ method: "add", messages, options });', + ' return readResponses().add ?? [];', + ' }', + '}', + '', + ].join('\n'), + 'utf-8', + ); + return logPath; +} + +function readStubLog(logPath: string): Array> { + if (!fs.existsSync(logPath)) return []; + return fs + .readFileSync(logPath, 'utf-8') + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + vi.restoreAllMocks(); + vi.resetModules(); + process.env.MEM0_API_KEY = originalMem0ApiKey; + process.env.MEM0_TELEMETRY = originalMem0Telemetry; +}); + +test('resolveMem0PluginConfig only accepts MEM0_API_KEY from credentials', async () => { + const { resolveMem0PluginConfig } = await import( + '../plugins/mem0-memory/src/config.js' + ); + + const config = resolveMem0PluginConfig({ + pluginConfig: { + apiKey: 'plaintext-config-key', + host: 'https://api.mem0.ai', + }, + runtime: { + cwd: '/tmp/hybridclaw', + }, + credentialApiKey: 'secret-store-key', + processEnvApiKey: 'env-key-should-be-ignored', + }); + + expect(config.apiKey).toBe('secret-store-key'); + + const withoutCredential = resolveMem0PluginConfig({ + pluginConfig: { + apiKey: 'plaintext-config-key', + host: 'https://api.mem0.ai', + }, + runtime: { + cwd: '/tmp/hybridclaw', + }, + processEnvApiKey: 'env-key-should-be-ignored', + }); + + expect(withoutCredential.apiKey).toBe(''); +}); + +test('resolveMem0PluginConfig rejects invalid host values', async () => { + const { resolveMem0PluginConfig } = await import( + '../plugins/mem0-memory/src/config.js' + ); + + expect(() => + resolveMem0PluginConfig({ + pluginConfig: { + host: 'not-a-url', + }, + runtime: { + cwd: '/tmp/hybridclaw', + }, + }), + ).toThrow('mem0-memory plugin config.host must be a valid absolute URL.'); +}); + +test('mem0-memory injects prompt context, registers tools, and exposes command helpers', async () => { + const homeDir = makeTempDir('hybridclaw-mem0-home-'); + const cwd = makeTempDir('hybridclaw-mem0-project-'); + const pluginDir = installBundledPlugin(cwd); + const logPath = installMem0Stub(pluginDir, { + getAll: { + results: [ + { id: 'mem-profile-1', memory: 'User prefers dark mode.' }, + { id: 'mem-profile-2', memory: 'Project uses SQLite for local state.' }, + ], + }, + search: { + results: [ + { + id: 'mem-search-1', + memory: 'Project uses SQLite for local state.', + score: 0.91, + }, + ], + }, + add: [{ id: 'mem-added-1', memory: 'stored' }], + }); + + process.env.MEM0_API_KEY = 'mem0-test-key'; + delete process.env.MEM0_TELEMETRY; + + const config = loadRuntimeConfig(); + config.plugins.list = [ + { + id: 'mem0-memory', + enabled: true, + config: { + host: 'https://api.mem0.ai', + searchLimit: 2, + profileLimit: 2, + maxInjectedChars: 2000, + messageMaxChars: 1000, + }, + }, + ]; + + const { PluginManager } = await import('../src/plugins/plugin-manager.js'); + const manager = new PluginManager({ + homeDir, + cwd, + getRuntimeConfig: () => config, + }); + + await manager.ensureInitialized(); + + expect(manager.getToolDefinitions().map((tool) => tool.name)).toEqual([ + 'mem0_conclude', + 'mem0_profile', + 'mem0_search', + ]); + + const promptContext = await manager.collectPromptContext({ + sessionId: 'session-1', + userId: 'user-1', + agentId: 'main', + channelId: 'web', + recentMessages: [ + { + id: 1, + session_id: 'session-1', + user_id: 'user-1', + username: 'alice', + role: 'user', + content: 'What database does this project use?', + created_at: '2026-04-11T10:00:00.000Z', + }, + ], + }); + + expect( + promptContext.some((section) => section.includes('Mem0 memory guide:')), + ).toBe(true); + expect( + promptContext.some((section) => section.includes('Mem0 profile overview:')), + ).toBe(true); + expect( + promptContext.some((section) => + section.includes('User prefers dark mode.'), + ), + ).toBe(true); + expect( + promptContext.some((section) => + section.includes( + 'Mem0 search results for the latest user question: What database does this project use?', + ), + ), + ).toBe(true); + expect( + promptContext.some((section) => + section.includes('Project uses SQLite for local state.'), + ), + ).toBe(true); + + const statusCommand = manager.findCommand('mem0'); + expect(statusCommand).toBeDefined(); + await expect( + statusCommand?.handler([], { + sessionId: 'session-1', + channelId: 'web', + userId: 'user-1', + }), + ).resolves.toContain('Connection: ok'); + await expect( + statusCommand?.handler(['search', 'SQLite'], { + sessionId: 'session-1', + channelId: 'web', + userId: 'user-1', + }), + ).resolves.toContain('Project uses SQLite for local state.'); + + const toolResult = await manager.executeTool({ + toolName: 'mem0_search', + args: { query: 'SQLite', top_k: 2, rerank: false }, + sessionId: 'session-1', + channelId: 'web', + }); + expect(JSON.parse(toolResult)).toMatchObject({ + userId: 'user-1', + query: 'SQLite', + count: 1, + results: [ + { + id: 'mem-search-1', + memory: 'Project uses SQLite for local state.', + score: 0.91, + }, + ], + }); + + const calls = readStubLog(logPath); + expect(calls.some((entry) => entry.method === 'ping')).toBe(true); + expect(calls).toContainEqual( + expect.objectContaining({ + method: 'getAll', + options: expect.objectContaining({ + api_version: 'v2', + filters: { user_id: 'user-1' }, + page: 1, + page_size: 2, + }), + }), + ); + expect(calls).toContainEqual( + expect.objectContaining({ + method: 'search', + query: 'SQLite', + options: expect.objectContaining({ + api_version: 'v2', + filters: { user_id: 'user-1' }, + top_k: 2, + rerank: false, + }), + }), + ); + expect(process.env.MEM0_TELEMETRY).toBe('false'); +}); + +test('mem0-memory syncs turns and mirrors native memory writes', async () => { + const homeDir = makeTempDir('hybridclaw-mem0-home-'); + const cwd = makeTempDir('hybridclaw-mem0-project-'); + const pluginDir = installBundledPlugin(cwd); + const logPath = installMem0Stub(pluginDir, { + getAll: { results: [] }, + search: { results: [] }, + add: [{ id: 'mem-added-1', memory: 'stored' }], + }); + + process.env.MEM0_API_KEY = 'mem0-test-key'; + + const config = loadRuntimeConfig(); + config.plugins.list = [ + { + id: 'mem0-memory', + enabled: true, + config: { + syncTurns: true, + mirrorNativeMemoryWrites: true, + messageMaxChars: 200, + }, + }, + ]; + + const { PluginManager } = await import('../src/plugins/plugin-manager.js'); + const manager = new PluginManager({ + homeDir, + cwd, + getRuntimeConfig: () => config, + }); + + await manager.ensureInitialized(); + await manager.notifyTurnComplete({ + sessionId: 'session-1', + userId: 'user-1', + agentId: 'main', + workspacePath: cwd, + messages: [ + { + id: 1, + session_id: 'session-1', + user_id: 'user-1', + username: 'alice', + role: 'user', + content: 'Remember that I prefer dark mode.', + created_at: '2026-04-11T10:00:00.000Z', + }, + { + id: 2, + session_id: 'session-1', + user_id: 'user-1', + username: 'hybridclaw', + role: 'assistant', + content: 'Understood. I will keep dark mode in mind.', + created_at: '2026-04-11T10:00:01.000Z', + }, + { + id: 3, + session_id: 'session-1', + user_id: 'user-1', + username: 'system', + role: 'system', + content: 'ignored', + created_at: '2026-04-11T10:00:02.000Z', + }, + ], + }); + + await manager.notifyMemoryWrites({ + sessionId: 'session-1', + agentId: 'main', + channelId: 'web', + toolExecutions: [ + { + name: 'memory', + arguments: + '{"action":"append","target":"user","content":"User prefers dark mode."}', + result: 'Appended 23 chars to USER.md', + durationMs: 4, + }, + ], + }); + + const addCalls = readStubLog(logPath).filter( + (entry) => entry.method === 'add', + ); + expect(addCalls).toHaveLength(2); + expect(addCalls[0]).toMatchObject({ + messages: [ + { role: 'user', content: 'Remember that I prefer dark mode.' }, + { + role: 'assistant', + content: 'Understood. I will keep dark mode in mind.', + }, + ], + options: expect.objectContaining({ + api_version: 'v2', + user_id: 'user-1', + agent_id: 'main', + app_id: 'hybridclaw', + metadata: expect.objectContaining({ + source: 'hybridclaw-turn', + session_id: 'session-1', + }), + }), + }); + expect(addCalls[1]).toMatchObject({ + messages: [ + { + role: 'user', + content: expect.stringContaining( + 'HybridClaw saved explicit memory in USER.md.', + ), + }, + ], + options: expect.objectContaining({ + infer: false, + user_id: 'user-1', + agent_id: 'main', + metadata: expect.objectContaining({ + source: 'hybridclaw-memory-write', + action: 'append', + memory_file_path: 'USER.md', + }), + }), + }); +});