From 2e13f4dae993ca9d5da6bd46269dcbaa1fc2726f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B0=81=E5=9C=A8=E5=90=B5=E7=9D=80=E5=90=83=E7=B3=96?= Date: Thu, 26 Mar 2026 20:54:55 +0800 Subject: [PATCH] Compatible with the open-source version --- README.md | 21 ++++++++-- README_ZH.md | 21 ++++++++-- index.js | 92 +++++++++++++++++++++++++++++++++--------- lib/memos-cloud-api.js | 68 ++++++++++++++++++++++++++++--- 4 files changed, 170 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 9d284e4..cc3ceac 100644 --- a/README.md +++ b/README.md @@ -88,9 +88,21 @@ If `MEMOS_API_KEY` is missing, the plugin will warn with setup instructions and MEMOS_API_KEY=YOUR_TOKEN ``` +**Dual Channel Support (Cloud vs Open Source)** +This plugin supports connecting to both MemOS Cloud and the MemOS Open Source project. +- **Configure for Cloud (Default)**: + - `MEMOS_BASE_URL`: Leave default or set to `https://memos.memtensor.cn/api/openmem/v1` + - `MEMOS_API_KEY`: Required. Get from MemOS dashboard. + - `MEMOS_API_TYPE`: Optional, defaults to `cloud`. +- **Configure for Open Source**: + - `MEMOS_BASE_URL`: Set to your local/private open source deployment API (e.g. `http://127.0.0.1:8008`) + - `MEMOS_API_TYPE`: Set to `open_source`. (Will be auto-detected if `BASE_URL` doesn't contain `memos.memtensor.cn`) + - `MEMOS_API_KEY`: Optional. Open source does not strictly require auth. If provided, it will be sent as a `Bearer Token`. + **Optional config** - `MEMOS_BASE_URL` (default: `https://memos.memtensor.cn/api/openmem/v1`) -- `MEMOS_API_KEY` (required; Token auth) — get it at https://memos-dashboard.openmem.net/cn/apikeys/ +- `MEMOS_API_TYPE` (`cloud` or `open_source`, auto-inferred from baseUrl) +- `MEMOS_API_KEY` (required for Cloud; optional for Open Source) — get it at https://memos-dashboard.openmem.net/cn/apikeys/ - `MEMOS_USER_ID` (optional; default: `openclaw-user`) - `MEMOS_USE_DIRECT_SESSION_USER_ID` (default: `false`; when enabled, direct session keys like `agent:main::direct:` use `` as MemOS `user_id`) - `MEMOS_CONVERSATION_ID` (optional override) @@ -120,6 +132,7 @@ In `plugins.entries.memos-cloud-openclaw-plugin.config`: ```json { "baseUrl": "https://memos.memtensor.cn/api/openmem/v1", + "apiType": "cloud", "apiKey": "YOUR_API_KEY", "userId": "memos_user_123", "useDirectSessionUserId": false, @@ -136,6 +149,7 @@ In `plugins.entries.memos-cloud-openclaw-plugin.config`: "conversationSuffixMode": "none", "resetOnNew": true, "knowledgebaseIds": [], + "allowKnowledgebaseIds": [], "memoryLimitNumber": 6, "preferenceLimitNumber": 6, "includePreference": true, @@ -233,6 +247,7 @@ Beyond simple on/off toggles, you can configure **different memory parameters fo "multiAgentMode": true, "allowedAgents": ["default", "research-agent", "coding-agent"], "knowledgebaseIds": [], + "allowKnowledgebaseIds": [], "memoryLimitNumber": 6, "relativity": 0.45, @@ -268,7 +283,7 @@ Beyond simple on/off toggles, you can configure **different memory parameters fo | Field | Description | |-------|-------------| -| `knowledgebaseIds` | Knowledge base IDs for `/search/memory` | +| `knowledgebaseIds` | Knowledge base IDs for `/search/memory` (where to recall from) | | `memoryLimitNumber` | Max memory items to recall | | `preferenceLimitNumber` | Max preference items to recall | | `includePreference` | Enable preference recall | @@ -287,7 +302,7 @@ Beyond simple on/off toggles, you can configure **different memory parameters fo | `recallFilterModel` | Model for recall filtering | | `recallFilterBaseUrl` | Base URL for recall filter model | | `recallFilterApiKey` | API key for recall filter | -| `allowKnowledgebaseIds` | Knowledge bases for `/add/message` | +| `allowKnowledgebaseIds` | Knowledge bases for `/add/message` (where to save new memories) | | `tags` | Tags for `/add/message` | | `throttleMs` | Throttle interval | diff --git a/README_ZH.md b/README_ZH.md index 6d50dfd..bb8a25d 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -90,9 +90,21 @@ source ~/.bashrc MEMOS_API_KEY=YOUR_TOKEN ``` +**双渠道支持 (云服务 vs 开源版本)** +本插件同时支持对接 MemOS 云服务和 MemOS 开源项目。 +- **配置为云服务(默认)**: + - `MEMOS_BASE_URL`:默认或设置为 `https://memos.memtensor.cn/api/openmem/v1` + - `MEMOS_API_KEY`:必填,从云平台获取的 Token。 + - `MEMOS_API_TYPE`:可选,默认为 `cloud`。 +- **配置为开源项目**: + - `MEMOS_BASE_URL`:设置为你本地或私有部署的开源项目 API 地址(例如:`http://127.0.0.1:8008`) + - `MEMOS_API_TYPE`:必须显式设置为 `open_source`,或者你的 `BASE_URL` 不包含 `memos.memtensor.cn` 时会自动识别。 + - `MEMOS_API_KEY`:选填。开源项目默认不强制要求鉴权,如果你配置了此项,将以 `Bearer Token` 的形式发送。 + **可选配置** - `MEMOS_BASE_URL`(默认 `https://memos.memtensor.cn/api/openmem/v1`) -- `MEMOS_API_KEY`(必填,Token 认证)—— 获取地址:https://memos-dashboard.openmem.net/cn/apikeys/ +- `MEMOS_API_TYPE`(`cloud` 或 `open_source`,根据 baseUrl 自动推断) +- `MEMOS_API_KEY`(云服务必填;开源版本选填)—— 获取地址:https://memos-dashboard.openmem.net/cn/apikeys/ - `MEMOS_USER_ID`(可选,默认 `openclaw-user`) - `MEMOS_USE_DIRECT_SESSION_USER_ID`(默认 `false`;开启后,对 `agent:main::direct:` 这类私聊 sessionKey,会把 `` 作为 MemOS `user_id`) - `MEMOS_CONVERSATION_ID`(可选覆盖) @@ -122,6 +134,7 @@ MEMOS_API_KEY=YOUR_TOKEN ```json { "baseUrl": "https://memos.memtensor.cn/api/openmem/v1", + "apiType": "cloud", "apiKey": "YOUR_API_KEY", "userId": "memos_user_123", "useDirectSessionUserId": false, @@ -139,6 +152,7 @@ MEMOS_API_KEY=YOUR_TOKEN "memoryLimitNumber": 6, "preferenceLimitNumber": 6, "knowledgebaseIds": [], + "allowKnowledgebaseIds": [], "includePreference": true, "includeToolMemory": false, "toolMemoryLimitNumber": 6, @@ -238,6 +252,7 @@ MEMOS_ALLOWED_AGENTS="agent1,agent2" "multiAgentMode": true, "allowedAgents": ["default", "research-agent", "coding-agent"], "knowledgebaseIds": [], + "allowKnowledgebaseIds": [], "memoryLimitNumber": 6, "relativity": 0.45, @@ -273,7 +288,7 @@ MEMOS_ALLOWED_AGENTS="agent1,agent2" | 字段 | 说明 | |------|------| -| `knowledgebaseIds` | `/search/memory` 使用的知识库 ID 列表 | +| `knowledgebaseIds` | `/search/memory` 使用的知识库 ID 列表(从哪些知识库召回记忆) | | `memoryLimitNumber` | 召回的事实记忆最大条数 | | `preferenceLimitNumber` | 召回的偏好记忆最大条数 | | `includePreference` | 是否启用偏好记忆召回 | @@ -292,7 +307,7 @@ MEMOS_ALLOWED_AGENTS="agent1,agent2" | `recallFilterModel` | 过滤模型名 | | `recallFilterBaseUrl` | 过滤模型接口地址 | | `recallFilterApiKey` | 过滤模型鉴权密钥 | -| `allowKnowledgebaseIds` | `/add/message` 允许写入的知识库 | +| `allowKnowledgebaseIds` | `/add/message` 允许写入的知识库 ID 列表(配置后,新记忆会被保存到这些指定的知识库中) | | `tags` | `/add/message` 标签 | | `throttleMs` | 请求节流间隔 | diff --git a/index.js b/index.js index 09bf1d4..495303e 100644 --- a/index.js +++ b/index.js @@ -83,6 +83,7 @@ function resolveConversationId(cfg, ctx) { } export function buildSearchPayload(cfg, prompt, ctx) { + const isCloud = cfg.apiType === "cloud"; const cleanPrompt = stripOpenClawInjectedPrefix(prompt); const queryRaw = `${cfg.queryPrefix || ""}${cleanPrompt}`; const query = @@ -96,52 +97,99 @@ export function buildSearchPayload(cfg, prompt, ctx) { source: MEMOS_SOURCE, }; + if (!isCloud) { + payload.mode = "mixture"; + payload.internet_search = false; + } + if (!cfg.recallGlobal) { const conversationId = resolveConversationId(cfg, ctx); - if (conversationId) payload.conversation_id = conversationId; + if (conversationId) { + if (isCloud) { + payload.conversation_id = conversationId; + } else { + payload.session_id = conversationId; + } + } } let filterObj = cfg.filter ? JSON.parse(JSON.stringify(cfg.filter)) : null; const agentId = getEffectiveAgentId(cfg, ctx); if (agentId) { + const agentFilter = isCloud + ? { agent_id: agentId } + : { info: { agentId: `${agentId}` } }; + if (filterObj) { if (Array.isArray(filterObj.and)) { - filterObj.and.push({ agent_id: agentId }); + filterObj.and.push(agentFilter); } else { - filterObj = { and: [filterObj, { agent_id: agentId }] }; + filterObj = { and: [filterObj, agentFilter] }; } } else { - filterObj = { agent_id: agentId }; + filterObj = { and: [agentFilter] }; } } if (filterObj) payload.filter = filterObj; - if (cfg.knowledgebaseIds?.length) payload.knowledgebase_ids = cfg.knowledgebaseIds; + if (cfg.knowledgebaseIds?.length) { + if (isCloud) { + payload.knowledgebase_ids = cfg.knowledgebaseIds; + } else { + payload.readable_cube_ids = Array.from(new Set([payload.user_id, ...cfg.knowledgebaseIds])); + } + } - payload.memory_limit_number = cfg.memoryLimitNumber; - payload.include_preference = cfg.includePreference; - payload.preference_limit_number = cfg.preferenceLimitNumber; - payload.include_tool_memory = cfg.includeToolMemory; - payload.tool_memory_limit_number = cfg.toolMemoryLimitNumber; - payload.relativity = cfg.relativity; + if (isCloud) { + payload.memory_limit_number = cfg.memoryLimitNumber; + payload.include_preference = cfg.includePreference; + payload.preference_limit_number = cfg.preferenceLimitNumber; + payload.include_tool_memory = cfg.includeToolMemory; + payload.tool_memory_limit_number = cfg.toolMemoryLimitNumber; + payload.relativity = cfg.relativity; + } else { + payload.top_k = cfg.memoryLimitNumber; + payload.include_preference = cfg.includePreference; + payload.pref_top_k = cfg.preferenceLimitNumber; + payload.search_tool_memory = cfg.includeToolMemory; + payload.tool_mem_top_k = cfg.toolMemoryLimitNumber; + payload.threshold = cfg.relativity; + } return payload; } export function buildAddMessagePayload(cfg, messages, ctx) { + const isCloud = cfg.apiType === "cloud"; + const conversationId = resolveConversationId(cfg, ctx); + const payload = { user_id: resolveMemosUserId(cfg, ctx), - conversation_id: resolveConversationId(cfg, ctx), messages, source: MEMOS_SOURCE, }; + if (isCloud) { + payload.conversation_id = conversationId; + payload.async_mode = cfg.asyncMode; + payload.allow_public = cfg.allowPublic; + } else { + payload.session_id = conversationId; + payload.async_mode = cfg.asyncMode === false ? "sync" : "async"; + } + const agentId = getEffectiveAgentId(cfg, ctx); - if (agentId) payload.agent_id = agentId; - if (cfg.appId) payload.app_id = cfg.appId; - if (cfg.tags?.length) payload.tags = cfg.tags; + + if (isCloud) { + if (agentId) payload.agent_id = agentId; + if (cfg.appId) payload.app_id = cfg.appId; + if (cfg.tags?.length) payload.tags = cfg.tags; + } else { + const customTags = [...(cfg.tags || [])]; + if (customTags.length) payload.custom_tags = customTags; + } const info = { source: "openclaw", @@ -151,9 +199,13 @@ export function buildAddMessagePayload(cfg, messages, ctx) { }; if (Object.keys(info).length > 0) payload.info = info; - payload.allow_public = cfg.allowPublic; - if (cfg.allowKnowledgebaseIds?.length) payload.allow_knowledgebase_ids = cfg.allowKnowledgebaseIds; - payload.async_mode = cfg.asyncMode; + if (cfg.allowKnowledgebaseIds?.length) { + if (isCloud) { + payload.allow_knowledgebase_ids = cfg.allowKnowledgebaseIds; + } else { + payload.writable_cube_ids = Array.from(new Set([payload.user_id, ...cfg.allowKnowledgebaseIds])); + } + } return payload; } @@ -466,7 +518,7 @@ export default { if (!agentCfg.recallEnabled) return; const userPrompt = stripOpenClawInjectedPrefix(event?.prompt || ""); if (!userPrompt || userPrompt.length < 3) return; - if (!agentCfg.apiKey) { + if (agentCfg.apiType === "cloud" && !agentCfg.apiKey) { warnMissingApiKey(log, "recall"); return; } @@ -498,7 +550,7 @@ export default { const agentCfg = resolveAgentConfig(cfg, ctx?.agentId); if (!agentCfg.addEnabled) return; if (!event?.success || !event?.messages?.length) return; - if (!agentCfg.apiKey) { + if (agentCfg.apiType === "cloud" && !agentCfg.apiKey) { warnMissingApiKey(log, "add"); return; } diff --git a/lib/memos-cloud-api.js b/lib/memos-cloud-api.js index 5d9b9e6..29e7a19 100644 --- a/lib/memos-cloud-api.js +++ b/lib/memos-cloud-api.js @@ -60,7 +60,51 @@ function stripQuotes(value) { export function extractResultData(result) { if (!result || typeof result !== "object") return null; - return result.data ?? result.data?.data ?? result.data?.result ?? null; + const rawData = result.data ?? result.data?.data ?? result.data?.result ?? null; + if (!rawData) return null; + + // Cloud API format check + if (Array.isArray(rawData.memory_detail_list) || Array.isArray(rawData.preference_detail_list)) { + return rawData; + } + + // Open Source API format check + const openData = Array.isArray(rawData) ? rawData[0] : rawData; + if (openData && (openData.text_mem || openData.fact_mem || openData.pref_mem || openData.tool_mem || openData.skill_mem)) { + const normalizeMemories = (memGroup) => { + if (!Array.isArray(memGroup)) return []; + let allMems = []; + for (const group of memGroup) { + if (Array.isArray(group.memories)) { + allMems = allMems.concat(group.memories); + } + } + return allMems.map((m) => { + const meta = m.metadata || {}; + return { + id: m.id || meta.id, + memory_key: meta.key || m.memory, + memory_value: m.memory || meta.memory, + relativity: meta.relativity ?? m.relativity, + preference: meta.preference || m.memory, + preference_type: meta.preference_type, + tool_value: m.memory || meta.memory, + create_time: meta.created_at ? new Date(meta.created_at).getTime() : Date.now(), + update_time: meta.updated_at ? new Date(meta.updated_at).getTime() : Date.now(), + }; + }); + }; + + return { + memory_detail_list: normalizeMemories(openData.text_mem || openData.fact_mem), + preference_detail_list: normalizeMemories(openData.pref_mem), + tool_memory_detail_list: normalizeMemories(openData.tool_mem), + skill_detail_list: normalizeMemories(openData.skill_mem), + preference_note: openData.pref_note || openData.pref_string || "", + }; + } + + return rawData; } function pad2(value) { @@ -170,6 +214,7 @@ export function buildConfig(pluginConfig = {}) { const cfg = pluginConfig ?? {}; const baseUrl = cfg.baseUrl || loadEnvVar("MEMOS_BASE_URL") || DEFAULT_BASE_URL; + const apiType = cfg.apiType || loadEnvVar("MEMOS_API_TYPE") || (baseUrl.includes("memos.memtensor.cn") ? "cloud" : "open_source"); const apiKey = cfg.apiKey || loadEnvVar("MEMOS_API_KEY") || ""; const userId = cfg.userId || loadEnvVar("MEMOS_USER_ID") || "openclaw-user"; const conversationId = cfg.conversationId || loadEnvVar("MEMOS_CONVERSATION_ID") || ""; @@ -220,6 +265,7 @@ export function buildConfig(pluginConfig = {}) { return { baseUrl: baseUrl.replace(/\/+$/, ""), + apiType, apiKey, userId, conversationId, @@ -305,15 +351,23 @@ export function resolveAgentConfig(baseCfg, agentId) { return merged; } -export async function callApi({ baseUrl, apiKey, timeoutMs = 5000, retries = 1 }, path, body) { - if (!apiKey) { +export async function callApi({ baseUrl, apiType, apiKey, timeoutMs = 5000, retries = 1 }, path, body) { + const isCloud = apiType === "cloud"; + + if (isCloud && !apiKey) { throw new Error("Missing MEMOS API key (Token auth)"); } const headers = { "Content-Type": "application/json", - Authorization: `Token ${apiKey}`, }; + + if (isCloud) { + headers.Authorization = `Token ${apiKey}`; + } else if (apiKey) { + // For open source, it doesn't require Authorization but if provided, send it + headers.Authorization = `Bearer ${apiKey}`; + } let lastError; for (let attempt = 0; attempt <= retries; attempt += 1) { @@ -370,7 +424,8 @@ export function isAgentAllowed(cfg, ctx) { } export async function searchMemory(cfg, payload) { - return callApi(cfg, "/search/memory", sanitizeSearchPayload(payload)); + const path = cfg.apiType === "cloud" ? "/search/memory" : "/product/search"; + return callApi(cfg, path, sanitizeSearchPayload(payload)); } export async function addMessage(cfg, payload) { @@ -381,7 +436,8 @@ export async function addMessage(cfg, payload) { // Fail open: if sanitization throws unexpectedly, send original payload. finalPayload = payload; } - return callApi(cfg, "/add/message", finalPayload); + const path = cfg.apiType === "cloud" ? "/add/message" : "/product/add"; + return callApi(cfg, path, finalPayload); } function isInboundMetaSentinelLine(line) {