diff --git a/apps/MemOS-Cloud-OpenClaw-Plugin/.gitignore b/apps/MemOS-Cloud-OpenClaw-Plugin/.gitignore new file mode 100644 index 000000000..b8e97cb2a --- /dev/null +++ b/apps/MemOS-Cloud-OpenClaw-Plugin/.gitignore @@ -0,0 +1,18 @@ +# Dependencies +node_modules + +# Environment variables +.env +.env.* + +# NPM +.npmrc + +# System +.DS_Store +Thumbs.db + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/apps/MemOS-Cloud-OpenClaw-Plugin/LICENSE b/apps/MemOS-Cloud-OpenClaw-Plugin/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/apps/MemOS-Cloud-OpenClaw-Plugin/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/apps/MemOS-Cloud-OpenClaw-Plugin/README.md b/apps/MemOS-Cloud-OpenClaw-Plugin/README.md new file mode 100644 index 000000000..b84a93f24 --- /dev/null +++ b/apps/MemOS-Cloud-OpenClaw-Plugin/README.md @@ -0,0 +1,175 @@ +# MemOS Cloud OpenClaw Plugin (Lifecycle) + +Official plugin maintained by MemTensor. + +A minimal OpenClaw lifecycle plugin that **recalls** memories from MemOS Cloud before each run and **adds** new messages to MemOS Cloud after each run. + +## Features +- **Recall**: `before_agent_start` → `/search/memory` +- **Add**: `agent_end` → `/add/message` +- Uses **Token** auth (`Authorization: Token `) + +## Install + +### Option A — NPM (Recommended) +```bash +openclaw plugins install @memtensor/memos-cloud-openclaw-plugin@latest +openclaw gateway restart +``` + +> **Note for Windows Users**: +> If you encounter `Error: spawn EINVAL`, this is a known issue with OpenClaw's plugin installer on Windows. Please use **Option B** (Manual Install) below. + +Make sure it’s enabled in `~/.openclaw/openclaw.json`: +```json +{ + "plugins": { + "entries": { + "memos-cloud-openclaw-plugin": { "enabled": true } + } + } +} +``` + +### Option B — Manual Install (Workaround for Windows) +1. Download the latest `.tgz` from [NPM](https://www.npmjs.com/package/@memtensor/memos-cloud-openclaw-plugin). +2. Extract it to a local folder (e.g., `C:\Users\YourName\.openclaw\extensions\memos-cloud-openclaw-plugin`). +3. Configure `~/.openclaw/openclaw.json` (or `%USERPROFILE%\.openclaw\openclaw.json`): + +```json +{ + "plugins": { + "entries": { + "memos-cloud-openclaw-plugin": { "enabled": true } + }, + "load": { + "paths": [ + "C:\\Users\\YourName\\.openclaw\\extensions\\memos-cloud-openclaw-plugin\\package" + ] + } + } +} +``` +*Note: The extracted folder usually contains a `package` subfolder. Point to the folder containing `package.json`.* + +Restart the gateway after config changes. + +## Environment Variables +The plugin tries env files in order (**openclaw → moltbot → clawdbot**). For each key, the first file with a value wins. +If none of these files exist (or the key is missing), it falls back to the process environment. + +**Where to configure** +- Files (priority order): + - `~/.openclaw/.env` + - `~/.moltbot/.env` + - `~/.clawdbot/.env` +- Each line is `KEY=value` + +**Quick setup (shell)** +```bash +echo 'export MEMOS_API_KEY="mpg-..."' >> ~/.zshrc +source ~/.zshrc +# or + +echo 'export MEMOS_API_KEY="mpg-..."' >> ~/.bashrc +source ~/.bashrc +``` + +**Quick setup (Windows PowerShell)** +```powershell +[System.Environment]::SetEnvironmentVariable("MEMOS_API_KEY", "mpg-...", "User") +``` + +If `MEMOS_API_KEY` is missing, the plugin will warn with setup instructions and the API key URL. + +**Minimal config** +```env +MEMOS_API_KEY=YOUR_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_USER_ID` (optional; default: `openclaw-user`) +- `MEMOS_CONVERSATION_ID` (optional override) +- `MEMOS_RECALL_GLOBAL` (default: `true`; when true, search does **not** pass conversation_id) +- `MEMOS_MULTI_AGENT_MODE` (default: `false`; enable multi-agent data isolation) +- `MEMOS_CONVERSATION_PREFIX` / `MEMOS_CONVERSATION_SUFFIX` (optional) +- `MEMOS_CONVERSATION_SUFFIX_MODE` (`none` | `counter`, default: `none`) +- `MEMOS_CONVERSATION_RESET_ON_NEW` (default: `true`, requires hooks.internal.enabled) +- `MEMOS_RECALL_FILTER_ENABLED` (default: `false`; run model-based memory filtering before injection) +- `MEMOS_RECALL_FILTER_BASE_URL` (OpenAI-compatible base URL, e.g. `http://127.0.0.1:11434/v1`) +- `MEMOS_RECALL_FILTER_API_KEY` (optional; required if your endpoint needs auth) +- `MEMOS_RECALL_FILTER_MODEL` (model name used to filter recall candidates) +- `MEMOS_RECALL_FILTER_TIMEOUT_MS` (default: `6000`) +- `MEMOS_RECALL_FILTER_RETRIES` (default: `0`) +- `MEMOS_RECALL_FILTER_CANDIDATE_LIMIT` (default: `30` per category) +- `MEMOS_RECALL_FILTER_MAX_ITEM_CHARS` (default: `500`) +- `MEMOS_RECALL_FILTER_FAIL_OPEN` (default: `true`; fallback to unfiltered recall on failure) + +## Optional Plugin Config +In `plugins.entries.memos-cloud-openclaw-plugin.config`: +```json +{ + "baseUrl": "https://memos.memtensor.cn/api/openmem/v1", + "apiKey": "YOUR_API_KEY", + "userId": "memos_user_123", + "conversationId": "openclaw-main", + "queryPrefix": "important user context preferences decisions ", + "recallEnabled": true, + "recallGlobal": true, + "addEnabled": true, + "captureStrategy": "last_turn", + "maxItemChars": 8000, + "includeAssistant": true, + "conversationIdPrefix": "", + "conversationIdSuffix": "", + "conversationSuffixMode": "none", + "resetOnNew": true, + "knowledgebaseIds": [], + "memoryLimitNumber": 6, + "preferenceLimitNumber": 6, + "includePreference": true, + "includeToolMemory": false, + "toolMemoryLimitNumber": 6, + "relativity": 0.45, + "tags": ["openclaw"], + "agentId": "", + "multiAgentMode": false, + "asyncMode": true, + "recallFilterEnabled": false, + "recallFilterBaseUrl": "http://127.0.0.1:11434/v1", + "recallFilterApiKey": "", + "recallFilterModel": "qwen2.5:7b", + "recallFilterTimeoutMs": 6000, + "recallFilterRetries": 0, + "recallFilterCandidateLimit": 30, + "recallFilterMaxItemChars": 500, + "recallFilterFailOpen": true +} +``` + +## How it Works +- **Recall** (`before_agent_start`) + - Builds a `/search/memory` request using `user_id`, `query` (= prompt + optional prefix), and optional filters. + - Default **global recall**: when `recallGlobal=true`, it does **not** pass `conversation_id`. + - Optional second-pass filtering: if `recallFilterEnabled=true`, candidates are sent to your configured model and only returned `keep` items are injected. + - Injects a stable MemOS recall protocol via `appendSystemContext`, while the retrieved `` block remains in `prependContext`. + +- **Add** (`agent_end`) + - Builds a `/add/message` request with the **last turn** by default (user + assistant). + - Sends `messages` with `user_id`, `conversation_id`, and optional `tags/info/agent_id/app_id`. + +## Multi-Agent Support +The plugin provides native support for multi-agent architectures (via the `agent_id` parameter): +- **Enable Mode**: Set `"multiAgentMode": true` in config or `MEMOS_MULTI_AGENT_MODE=true` in env variables (default is `false`). +- **Dynamic Context**: When enabled, it automatically captures `ctx.agentId` during OpenClaw lifecycle hooks. (Note: the default OpenClaw agent `"main"` is ignored to preserve backwards compatibility for single-agent users). +- **Data Isolation**: The `agent_id` is automatically injected into both `/search/memory` and `/add/message` requests. This ensures completely isolated memory and message histories for different agents, even under the same user or session. +- **Static Override**: You can also force a specific agent ID by setting `"agentId": "your_agent_id"` in the plugin's `config`. + +## Notes +- `conversation_id` defaults to OpenClaw `sessionKey` (unless `conversationId` is provided). **TODO**: consider binding to OpenClaw `sessionId` directly. +- Optional **prefix/suffix** via env or config; `conversationSuffixMode=counter` increments on `/new` (requires `hooks.internal.enabled`). + +## Acknowledgements +- Thanks to @anatolykoptev (Contributor) — LinkedIn: https://www.linkedin.com/in/koptev?utm_source=share&utm_campaign=share_via&utm_content=profile&utm_medium=ios_app diff --git a/apps/MemOS-Cloud-OpenClaw-Plugin/README_ZH.md b/apps/MemOS-Cloud-OpenClaw-Plugin/README_ZH.md new file mode 100644 index 000000000..e2e1d8f91 --- /dev/null +++ b/apps/MemOS-Cloud-OpenClaw-Plugin/README_ZH.md @@ -0,0 +1,180 @@ +# MemOS Cloud OpenClaw Plugin(Lifecycle 插件) + +官方维护:MemTensor。 + +这是一个最小可用的 OpenClaw lifecycle 插件,功能是: +- **召回记忆**:在每轮对话前从 MemOS Cloud 检索记忆并注入上下文 +- **添加记忆**:在每轮对话结束后把消息写回 MemOS Cloud + +## 功能 +- **Recall**:`before_agent_start` → `/search/memory` +- **Add**:`agent_end` → `/add/message` +- 使用 **Token** 认证(`Authorization: Token `) + +## 安装 + +### 方式 A — NPM(推荐) +```bash +openclaw plugins install @memtensor/memos-cloud-openclaw-plugin@latest +openclaw gateway restart +``` + +> **Windows 用户注意**: +> 如果遇到 `Error: spawn EINVAL` 报错,这是 OpenClaw Windows 安装器的已知问题。请使用下方的 **方式 B**(手动安装)。 + +确认 `~/.openclaw/openclaw.json` 中已启用: +```json +{ + "plugins": { + "entries": { + "memos-cloud-openclaw-plugin": { "enabled": true } + } + } +} +``` + +### 方式 B — 手动安装(Windows 解决方案) +1. 从 [NPM](https://www.npmjs.com/package/@memtensor/memos-cloud-openclaw-plugin) 下载最新的 `.tgz` 包。 +2. 解压到本地目录(例如 `C:\Users\YourName\.openclaw\extensions\memos-cloud-openclaw-plugin`)。 +3. 修改配置 `~/.openclaw/openclaw.json`(或 `%USERPROFILE%\.openclaw\openclaw.json`): + +```json +{ + "plugins": { + "entries": { + "memos-cloud-openclaw-plugin": { "enabled": true } + }, + "load": { + "paths": [ + "C:\\Users\\YourName\\.openclaw\\extensions\\memos-cloud-openclaw-plugin\\package" + ] + } + } +} +``` +*注意:解压后的文件夹通常包含一个 `package` 子文件夹,请指向包含 `package.json` 的那层目录。* + +修改配置后需要重启 gateway。 + +## 环境变量 +插件按顺序读取 env 文件(**openclaw → moltbot → clawdbot**),每个键优先使用最先匹配到的值。 +若三个文件都不存在(或该键未找到),才会回退到进程环境变量。 + +**配置位置** +- 文件(优先级顺序): + - `~/.openclaw/.env` + - `~/.moltbot/.env` + - `~/.clawdbot/.env` +- 每行格式:`KEY=value` + +**快速配置(Shell)** +```bash +echo 'export MEMOS_API_KEY="mpg-..."' >> ~/.zshrc +source ~/.zshrc +# 或者 + +echo 'export MEMOS_API_KEY="mpg-..."' >> ~/.bashrc +source ~/.bashrc +``` + +**快速配置(Windows PowerShell)** +```powershell +[System.Environment]::SetEnvironmentVariable("MEMOS_API_KEY", "mpg-...", "User") +``` + +若未读取到 `MEMOS_API_KEY`,插件会提示配置方式并附 API Key 获取地址。 + +**最小配置** +```env +MEMOS_API_KEY=YOUR_TOKEN +``` + +**可选配置** +- `MEMOS_BASE_URL`(默认 `https://memos.memtensor.cn/api/openmem/v1`) +- `MEMOS_API_KEY`(必填,Token 认证)—— 获取地址:https://memos-dashboard.openmem.net/cn/apikeys/ +- `MEMOS_USER_ID`(可选,默认 `openclaw-user`) +- `MEMOS_CONVERSATION_ID`(可选覆盖) +- `MEMOS_RECALL_GLOBAL`(默认 `true`;为 true 时检索不传 conversation_id) +- `MEMOS_MULTI_AGENT_MODE`(默认 `false`;是否开启多 Agent 数据隔离模式) +- `MEMOS_CONVERSATION_PREFIX` / `MEMOS_CONVERSATION_SUFFIX`(可选) +- `MEMOS_CONVERSATION_SUFFIX_MODE`(`none` | `counter`,默认 `none`) +- `MEMOS_CONVERSATION_RESET_ON_NEW`(默认 `true`,需 hooks.internal.enabled) +- `MEMOS_RECALL_FILTER_ENABLED`(默认 `false`;开启后先用你指定的模型过滤召回记忆再注入) +- `MEMOS_RECALL_FILTER_BASE_URL`(OpenAI 兼容接口,例如 `http://127.0.0.1:11434/v1`) +- `MEMOS_RECALL_FILTER_API_KEY`(可选,若你的接口需要鉴权) +- `MEMOS_RECALL_FILTER_MODEL`(用于筛选记忆的模型名) +- `MEMOS_RECALL_FILTER_TIMEOUT_MS`(默认 `6000`) +- `MEMOS_RECALL_FILTER_RETRIES`(默认 `0`) +- `MEMOS_RECALL_FILTER_CANDIDATE_LIMIT`(默认每类 `30` 条) +- `MEMOS_RECALL_FILTER_MAX_ITEM_CHARS`(默认 `500`) +- `MEMOS_RECALL_FILTER_FAIL_OPEN`(默认 `true`;筛选失败时回退为“不过滤”) + +## 可选插件配置 +在 `plugins.entries.memos-cloud-openclaw-plugin.config` 中设置: +```json +{ + "baseUrl": "https://memos.memtensor.cn/api/openmem/v1", + "apiKey": "YOUR_API_KEY", + "userId": "memos_user_123", + "conversationId": "openclaw-main", + "queryPrefix": "important user context preferences decisions ", + "recallEnabled": true, + "recallGlobal": true, + "addEnabled": true, + "captureStrategy": "last_turn", + "includeAssistant": true, + "conversationIdPrefix": "", + "conversationIdSuffix": "", + "conversationSuffixMode": "none", + "resetOnNew": true, + "memoryLimitNumber": 6, + "preferenceLimitNumber": 6, + "knowledgebaseIds": [], + "includePreference": true, + "includeToolMemory": false, + "toolMemoryLimitNumber": 6, + "tags": ["openclaw"], + "agentId": "", + "multiAgentMode": false, + "asyncMode": true, + "recallFilterEnabled": false, + "recallFilterBaseUrl": "http://127.0.0.1:11434/v1", + "recallFilterApiKey": "", + "recallFilterModel": "qwen2.5:7b", + "recallFilterTimeoutMs": 6000, + "recallFilterRetries": 0, + "recallFilterCandidateLimit": 30, + "recallFilterMaxItemChars": 500, + "recallFilterFailOpen": true +} +``` + +## 工作原理 +### 1) 召回(before_agent_start) +- 组装 `/search/memory` 请求 + - `user_id`、`query`(= prompt + 可选前缀) + - 默认**全局召回**:`recallGlobal=true` 时不传 `conversation_id` + - 可选 `filter` / `knowledgebase_ids` +- (可选)若开启 `recallFilterEnabled`,会先把 `memory/preference/tool_memory` 候选发给你配置的模型做二次筛选,只保留 `keep` 的条目 +- 将稳定的 MemOS 召回协议通过 `appendSystemContext` 注入,而检索到的 `` 数据块继续通过 `prependContext` 注入 + +### 2) 添加(agent_end) +- 默认只写**最后一轮**(user + assistant) +- 构造 `/add/message` 请求: + - `user_id`、`conversation_id` + - `messages` 列表 + - 可选 `tags / info / agent_id / app_id` + +## 多Agent支持(Multi-Agent) +插件内置对多Agent模式的支持(`agent_id` 参数): +- **开启模式**:需要在配置中设置 `"multiAgentMode": true` 或在环境变量中设置 `MEMOS_MULTI_AGENT_MODE=true`(默认为 `false`)。 +- **动态获取**:开启后,执行生命周期钩子时会自动读取上下文中的 `ctx.agentId`。(注:OpenClaw 的默认 Agent `"main"` 会被自动忽略,以保证老用户的单 Agent 数据兼容性)。 +- **数据隔离**:在调用 `/search/memory`(检索记忆)和 `/add/message`(添加记录)时会自动附带该 `agent_id`,从而保证即使是同一用户下的不同 Agent 之间,记忆和反馈数据也是完全隔离的。 +- **静态配置**:如果需要,也可在上述插件的 `config` 中显式指定 `"agentId": "your_agent_id"` 作为固定值。 + +## 说明 +- 未显式指定 `conversation_id` 时,默认使用 OpenClaw `sessionKey`。**TODO**:后续考虑直接绑定 OpenClaw `sessionId`。 +- 可配置前后缀;`conversationSuffixMode=counter` 时会在 `/new` 递增(需 `hooks.internal.enabled`)。 + +## 致谢 +- 感谢 @anatolykoptev(Contributor)— 领英:https://www.linkedin.com/in/koptev?utm_source=share&utm_campaign=share_via&utm_content=profile&utm_medium=ios_app diff --git a/apps/MemOS-Cloud-OpenClaw-Plugin/clawdbot.plugin.json b/apps/MemOS-Cloud-OpenClaw-Plugin/clawdbot.plugin.json new file mode 100644 index 000000000..33f4b7096 --- /dev/null +++ b/apps/MemOS-Cloud-OpenClaw-Plugin/clawdbot.plugin.json @@ -0,0 +1,169 @@ +{ + "id": "memos-cloud-openclaw-plugin", + "name": "MemOS Cloud OpenClaw Plugin", + "description": "MemOS Cloud recall + add memory via lifecycle hooks", + "version": "0.1.9", + "kind": "lifecycle", + "main": "./index.js", + "configSchema": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string", + "description": "MemOS Cloud base URL" + }, + "apiKey": { + "type": "string", + "description": "MemOS API Key (Token auth; supports ~/.openclaw/.env, ~/.moltbot/.env, ~/.clawdbot/.env; falls back to process env)" + }, + "userId": { + "type": "string", + "description": "MemOS user_id (default: openclaw-user)", + "default": "openclaw-user" + }, + "conversationId": { + "type": "string", + "description": "Override conversation_id" + }, + "conversationIdPrefix": { + "type": "string", + "description": "conversation_id prefix" + }, + "conversationIdSuffix": { + "type": "string", + "description": "conversation_id suffix" + }, + "conversationSuffixMode": { + "type": "string", + "enum": [ + "none", + "counter" + ], + "default": "none" + }, + "resetOnNew": { + "type": "boolean", + "default": true + }, + "queryPrefix": { + "type": "string", + "description": "Prefix added to search queries" + }, + "maxQueryChars": { + "type": "integer", + "description": "Max chars for search query" + }, + "recallEnabled": { + "type": "boolean", + "default": true + }, + "recallGlobal": { + "type": "boolean", + "default": true + }, + "addEnabled": { + "type": "boolean", + "default": true + }, + "captureStrategy": { + "type": "string", + "enum": [ + "last_turn", + "full_session" + ], + "default": "last_turn" + }, + "maxMessageChars": { + "type": "integer", + "description": "Max chars per message when adding", + "default": 20000 + }, + "maxItemChars": { + "type": "integer", + "description": "Max chars per memory item when injecting prompt", + "default": 8000 + }, + "includeAssistant": { + "type": "boolean", + "default": true + }, + "memoryLimitNumber": { + "type": "integer", + "default": 6 + }, + "preferenceLimitNumber": { + "type": "integer", + "default": 6 + }, + "includePreference": { + "type": "boolean", + "default": true + }, + "includeToolMemory": { + "type": "boolean", + "default": false + }, + "toolMemoryLimitNumber": { + "type": "integer", + "default": 6 + }, + "filter": { + "type": "object", + "description": "MemOS search filter" + }, + "knowledgebaseIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "info": { + "type": "object", + "additionalProperties": true + }, + "agentId": { + "type": "string" + }, + "multiAgentMode": { + "type": "boolean", + "default": false + }, + "appId": { + "type": "string" + }, + "allowPublic": { + "type": "boolean", + "default": false + }, + "allowKnowledgebaseIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "asyncMode": { + "type": "boolean", + "default": true + }, + "timeoutMs": { + "type": "integer", + "default": 5000 + }, + "retries": { + "type": "integer", + "default": 1 + }, + "throttleMs": { + "type": "integer", + "default": 0 + } + }, + "additionalProperties": false + } +} diff --git a/apps/MemOS-Cloud-OpenClaw-Plugin/index.js b/apps/MemOS-Cloud-OpenClaw-Plugin/index.js new file mode 100644 index 000000000..191a71e59 --- /dev/null +++ b/apps/MemOS-Cloud-OpenClaw-Plugin/index.js @@ -0,0 +1,483 @@ +#!/usr/bin/env node +import { + addMessage, + buildConfig, + extractResultData, + extractText, + formatRecallHookResult, + USER_QUERY_MARKER, + searchMemory, +} from "./lib/memos-cloud-api.js"; +import { startUpdateChecker } from "./lib/check-update.js"; +let lastCaptureTime = 0; +const conversationCounters = new Map(); +const API_KEY_HELP_URL = "https://memos-dashboard.openmem.net/cn/apikeys/"; +const ENV_FILE_SEARCH_HINTS = ["~/.openclaw/.env", "~/.moltbot/.env", "~/.clawdbot/.env"]; +const MEMOS_SOURCE = "openclaw"; + +function warnMissingApiKey(log, context) { + const heading = "[memos-cloud] Missing MEMOS_API_KEY (Token auth)"; + const header = `${heading}${context ? `; ${context} skipped` : ""}. Configure it with:`; + log.warn?.( + [ + header, + "echo 'export MEMOS_API_KEY=\"mpg-...\"' >> ~/.zshrc", + "source ~/.zshrc", + "or", + "echo 'export MEMOS_API_KEY=\"mpg-...\"' >> ~/.bashrc", + "source ~/.bashrc", + "or", + "[System.Environment]::SetEnvironmentVariable(\"MEMOS_API_KEY\", \"mpg-...\", \"User\")", + `Get API key: ${API_KEY_HELP_URL}`, + ].join("\n"), + ); +} + +function stripPrependedPrompt(content) { + if (!content) return content; + const idx = content.lastIndexOf(USER_QUERY_MARKER); + if (idx === -1) return content; + return content.slice(idx + USER_QUERY_MARKER.length).trimStart(); +} + +function getCounterSuffix(sessionKey) { + if (!sessionKey) return ""; + const current = conversationCounters.get(sessionKey) ?? 0; + return current > 0 ? `#${current}` : ""; +} + +function bumpConversationCounter(sessionKey) { + if (!sessionKey) return; + const current = conversationCounters.get(sessionKey) ?? 0; + conversationCounters.set(sessionKey, current + 1); +} + +function getEffectiveAgentId(cfg, ctx) { + if (!cfg.multiAgentMode) { + return cfg.agentId; + } + const agentId = ctx?.agentId || cfg.agentId; + return agentId === "main" ? undefined : agentId; +} + +function resolveConversationId(cfg, ctx) { + if (cfg.conversationId) return cfg.conversationId; + // TODO: consider binding conversation_id directly to OpenClaw sessionId (prefer ctx.sessionId). + const agentId = getEffectiveAgentId(cfg, ctx); + const base = ctx?.sessionKey || ctx?.sessionId || (agentId ? `openclaw:${agentId}` : ""); + const dynamicSuffix = cfg.conversationSuffixMode === "counter" ? getCounterSuffix(ctx?.sessionKey) : ""; + const prefix = cfg.conversationIdPrefix || ""; + const suffix = cfg.conversationIdSuffix || ""; + if (base) return `${prefix}${base}${dynamicSuffix}${suffix}`; + return `${prefix}openclaw-${Date.now()}${dynamicSuffix}${suffix}`; +} + +function buildSearchPayload(cfg, prompt, ctx) { + const queryRaw = `${cfg.queryPrefix || ""}${prompt}`; + const query = + Number.isFinite(cfg.maxQueryChars) && cfg.maxQueryChars > 0 + ? queryRaw.slice(0, cfg.maxQueryChars) + : queryRaw; + + const payload = { + user_id: cfg.userId, + query, + source: MEMOS_SOURCE, + }; + + if (!cfg.recallGlobal) { + const conversationId = resolveConversationId(cfg, ctx); + if (conversationId) payload.conversation_id = conversationId; + } + + let filterObj = cfg.filter ? JSON.parse(JSON.stringify(cfg.filter)) : null; + const agentId = getEffectiveAgentId(cfg, ctx); + + if (agentId) { + if (filterObj) { + if (Array.isArray(filterObj.and)) { + filterObj.and.push({ agent_id: agentId }); + } else { + filterObj = { and: [filterObj, { agent_id: agentId }] }; + } + } else { + filterObj = { agent_id: agentId }; + } + } + + if (filterObj) payload.filter = filterObj; + + if (cfg.knowledgebaseIds?.length) payload.knowledgebase_ids = 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; + + return payload; +} + +function buildAddMessagePayload(cfg, messages, ctx) { + const payload = { + user_id: cfg.userId, + conversation_id: resolveConversationId(cfg, ctx), + messages, + source: MEMOS_SOURCE, + }; + + 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; + + const info = { + source: "openclaw", + sessionKey: ctx?.sessionKey, + agentId: ctx?.agentId, + ...(cfg.info || {}), + }; + 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; + + return payload; +} + +function pickLastTurnMessages(messages, cfg) { + const lastUserIndex = messages + .map((m, idx) => ({ m, idx })) + .filter(({ m }) => m?.role === "user") + .map(({ idx }) => idx) + .pop(); + + if (lastUserIndex === undefined) return []; + + const slice = messages.slice(lastUserIndex); + const results = []; + + for (const msg of slice) { + if (!msg || !msg.role) continue; + if (msg.role === "user") { + const content = stripPrependedPrompt(extractText(msg.content)); + if (content) results.push({ role: "user", content: truncate(content, cfg.maxMessageChars) }); + continue; + } + if (msg.role === "assistant" && cfg.includeAssistant) { + const content = extractText(msg.content); + if (content) results.push({ role: "assistant", content: truncate(content, cfg.maxMessageChars) }); + } + } + + return results; +} + +function pickFullSessionMessages(messages, cfg) { + const results = []; + for (const msg of messages) { + if (!msg || !msg.role) continue; + if (msg.role === "user") { + const content = stripPrependedPrompt(extractText(msg.content)); + if (content) results.push({ role: "user", content: truncate(content, cfg.maxMessageChars) }); + } + if (msg.role === "assistant" && cfg.includeAssistant) { + const content = extractText(msg.content); + if (content) results.push({ role: "assistant", content: truncate(content, cfg.maxMessageChars) }); + } + } + return results; +} + +function truncate(text, maxLen) { + if (!text) return ""; + if (!maxLen) return text; + return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function parseModelJson(text) { + if (!text || typeof text !== "string") return null; + const trimmed = text.trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed); + } catch { + // Some models wrap JSON in markdown code fences. + } + const fenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); + if (fenceMatch?.[1]) { + try { + return JSON.parse(fenceMatch[1].trim()); + } catch { + return null; + } + } + const first = trimmed.indexOf("{"); + const last = trimmed.lastIndexOf("}"); + if (first >= 0 && last > first) { + try { + return JSON.parse(trimmed.slice(first, last + 1)); + } catch { + return null; + } + } + return null; +} + +function normalizeIndexList(value, maxLen) { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const out = []; + for (const v of value) { + if (!Number.isInteger(v)) continue; + if (v < 0 || v >= maxLen) continue; + if (seen.has(v)) continue; + seen.add(v); + out.push(v); + } + return out; +} + +function buildRecallCandidates(data, cfg) { + const limit = Number.isFinite(cfg.recallFilterCandidateLimit) ? Math.max(0, cfg.recallFilterCandidateLimit) : 30; + const maxChars = Number.isFinite(cfg.recallFilterMaxItemChars) ? Math.max(80, cfg.recallFilterMaxItemChars) : 500; + const memoryList = Array.isArray(data?.memory_detail_list) ? data.memory_detail_list : []; + const preferenceList = Array.isArray(data?.preference_detail_list) ? data.preference_detail_list : []; + const toolList = Array.isArray(data?.tool_memory_detail_list) ? data.tool_memory_detail_list : []; + + const memoryCandidates = memoryList.slice(0, limit).map((item, idx) => ({ + idx, + text: truncate(item?.memory_value || item?.memory_key || "", maxChars), + relativity: item?.relativity, + })); + const preferenceCandidates = preferenceList.slice(0, limit).map((item, idx) => ({ + idx, + text: truncate(item?.preference || "", maxChars), + relativity: item?.relativity, + preference_type: item?.preference_type || "", + })); + const toolCandidates = toolList.slice(0, limit).map((item, idx) => ({ + idx, + text: truncate(item?.tool_value || "", maxChars), + relativity: item?.relativity, + })); + + return { + memoryList, + preferenceList, + toolList, + candidatePayload: { + memory: memoryCandidates, + preference: preferenceCandidates, + tool_memory: toolCandidates, + }, + }; +} + +function applyRecallDecision(data, decision, lists) { + const keep = decision?.keep || {}; + const memoryIdx = normalizeIndexList(keep.memory, lists.memoryList.length); + const preferenceIdx = normalizeIndexList(keep.preference, lists.preferenceList.length); + const toolIdx = normalizeIndexList(keep.tool_memory, lists.toolList.length); + + return { + ...data, + memory_detail_list: memoryIdx.map((idx) => lists.memoryList[idx]), + preference_detail_list: preferenceIdx.map((idx) => lists.preferenceList[idx]), + tool_memory_detail_list: toolIdx.map((idx) => lists.toolList[idx]), + }; +} + +async function callRecallFilterModel(cfg, userPrompt, candidatePayload) { + const headers = { + "Content-Type": "application/json", + }; + if (cfg.recallFilterApiKey) { + headers.Authorization = `Bearer ${cfg.recallFilterApiKey}`; + } + + const modelInput = { + user_query: userPrompt, + candidate_memories: candidatePayload, + output_schema: { + keep: { + memory: ["number index"], + preference: ["number index"], + tool_memory: ["number index"], + }, + reason: "optional short string", + }, + }; + + const body = { + model: cfg.recallFilterModel, + temperature: 0, + messages: [ + { + role: "system", + content: + "You are a strict memory relevance judge. Return JSON only. Keep only items directly useful for answering current user query. If unsure, do not keep.", + }, + { + role: "user", + content: JSON.stringify(modelInput), + }, + ], + }; + + let lastError; + const retries = Number.isFinite(cfg.recallFilterRetries) ? Math.max(0, cfg.recallFilterRetries) : 0; + const timeoutMs = Number.isFinite(cfg.recallFilterTimeoutMs) ? Math.max(1000, cfg.recallFilterTimeoutMs) : 6000; + + for (let attempt = 0; attempt <= retries; attempt += 1) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + const res = await fetch(`${cfg.recallFilterBaseUrl}/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify(body), + signal: controller.signal, + }); + clearTimeout(timeoutId); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const json = await res.json(); + const text = json?.choices?.[0]?.message?.content || ""; + const parsed = parseModelJson(text); + if (!parsed || typeof parsed !== "object") { + throw new Error("invalid JSON output from recall filter model"); + } + return parsed; + } catch (err) { + lastError = err; + if (attempt < retries) { + await sleep(120 * (attempt + 1)); + } + } + } + throw lastError; +} + +async function maybeFilterRecallData(cfg, data, userPrompt, log) { + if (!cfg.recallFilterEnabled) return data; + if (!cfg.recallFilterBaseUrl || !cfg.recallFilterModel) { + log.warn?.("[memos-cloud] recall filter enabled but missing recallFilterBaseUrl/recallFilterModel; skip filter"); + return data; + } + const lists = buildRecallCandidates(data, cfg); + const hasCandidates = + lists.candidatePayload.memory.length > 0 || + lists.candidatePayload.preference.length > 0 || + lists.candidatePayload.tool_memory.length > 0; + if (!hasCandidates) return data; + + try { + const decision = await callRecallFilterModel(cfg, userPrompt, lists.candidatePayload); + return applyRecallDecision(data, decision, lists); + } catch (err) { + log.warn?.(`[memos-cloud] recall filter failed: ${String(err)}`); + return cfg.recallFilterFailOpen ? data : { ...data, memory_detail_list: [], preference_detail_list: [], tool_memory_detail_list: [] }; + } +} + +export default { + id: "memos-cloud-openclaw-plugin", + name: "MemOS Cloud OpenClaw Plugin", + description: "MemOS Cloud recall + add memory via lifecycle hooks", + kind: "lifecycle", + + register(api) { + const cfg = buildConfig(api.pluginConfig); + const log = api.logger ?? console; + + // Start 12-hour background update interval + startUpdateChecker(log); + + if (!cfg.envFileStatus?.found) { + const searchPaths = cfg.envFileStatus?.searchPaths?.join(", ") ?? ENV_FILE_SEARCH_HINTS.join(", "); + log.warn?.(`[memos-cloud] No .env found in ${searchPaths}; falling back to process env or plugin config.`); + } + + if (cfg.conversationSuffixMode === "counter" && cfg.resetOnNew) { + if (api.config?.hooks?.internal?.enabled !== true) { + log.warn?.("[memos-cloud] command:new hook requires hooks.internal.enabled = true"); + } + api.registerHook( + ["command:new"], + (event) => { + if (event?.type === "command" && event?.action === "new") { + bumpConversationCounter(event.sessionKey); + } + }, + { + name: "memos-cloud-conversation-new", + description: "Increment MemOS conversation suffix on /new", + }, + ); + } + + api.on("before_agent_start", async (event, ctx) => { + if (!cfg.recallEnabled) return; + if (!event?.prompt || event.prompt.length < 3) return; + if (!cfg.apiKey) { + warnMissingApiKey(log, "recall"); + return; + } + + try { + const payload = buildSearchPayload(cfg, event.prompt, ctx); + const result = await searchMemory(cfg, payload); + const resultData = extractResultData(result); + if (!resultData) return; + const filteredData = await maybeFilterRecallData(cfg, resultData, event.prompt, log); + const hookResult = formatRecallHookResult({ data: filteredData }, { + wrapTagBlocks: true, + relativity: payload.relativity, + maxItemChars: cfg.maxItemChars, + }); + if (!hookResult.appendSystemContext && !hookResult.prependContext) return; + + return hookResult; + } catch (err) { + log.warn?.(`[memos-cloud] recall failed: ${String(err)}`); + } + }); + + api.on("agent_end", async (event, ctx) => { + if (!cfg.addEnabled) return; + if (!event?.success || !event?.messages?.length) return; + if (!cfg.apiKey) { + warnMissingApiKey(log, "add"); + return; + } + + const now = Date.now(); + if (cfg.throttleMs && now - lastCaptureTime < cfg.throttleMs) { + return; + } + lastCaptureTime = now; + + try { + const messages = + cfg.captureStrategy === "full_session" + ? pickFullSessionMessages(event.messages, cfg) + : pickLastTurnMessages(event.messages, cfg); + + if (!messages.length) return; + + const payload = buildAddMessagePayload(cfg, messages, ctx); + await addMessage(cfg, payload); + } catch (err) { + log.warn?.(`[memos-cloud] add failed: ${String(err)}`); + } + }); + }, +}; diff --git a/apps/MemOS-Cloud-OpenClaw-Plugin/lib/check-update.js b/apps/MemOS-Cloud-OpenClaw-Plugin/lib/check-update.js new file mode 100644 index 000000000..b0feb5f53 --- /dev/null +++ b/apps/MemOS-Cloud-OpenClaw-Plugin/lib/check-update.js @@ -0,0 +1,270 @@ +import https from "https"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { spawn, exec } from "child_process"; +import os from "os"; + +/** + * Kill a spawned child process and its entire process tree. + */ +function killProcessTree(child) { + try { + if (process.platform === "win32") { + exec(`taskkill /pid ${child.pid} /T /F`, () => {}); + } else { + // On Unix, kill the process group + process.kill(-child.pid, "SIGKILL"); + } + } catch (e) { + // Fallback: try the basic kill + try { child.kill("SIGKILL"); } catch (_) {} + } +} + +let isUpdating = false; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const CHECK_INTERVAL = 12 * 60 * 60 * 1000; // 12 hours check interval +const UPDATE_TIMEOUT = 3 * 60 * 1000; // 3 minutes timeout for the CLI update command to finish +const PLUGIN_NAME = "@memtensor/memos-cloud-openclaw-plugin"; +const CHECK_FILE = path.join(os.tmpdir(), "memos_openclaw_update_check.json"); + +const ANSI = { + RESET: "\x1b[0m", + GREEN: "\x1b[32m", + YELLOW: "\x1b[33m", + CYAN: "\x1b[36m", + RED: "\x1b[31m" +}; + + +function getPackageVersion() { + try { + const pkgPath = path.join(__dirname, "..", "package.json"); + const pkgData = fs.readFileSync(pkgPath, "utf-8"); + const pkg = JSON.parse(pkgData); + return pkg.version; + } catch (err) { + return null; + } +} + +function getLatestVersion(log) { + return new Promise((resolve, reject) => { + const req = https.get( + `https://registry.npmjs.org/${PLUGIN_NAME}/latest`, + { timeout: 5000 }, + (res) => { + if (res.statusCode !== 200) { + req.destroy(); + return reject(new Error(`Failed to fetch version, status: ${res.statusCode}`)); + } + + let body = ""; + res.on("data", (chunk) => { + body += chunk; + }); + + res.on("end", () => { + try { + const data = JSON.parse(body); + resolve(data.version); + } catch (err) { + reject(err); + } + }); + } + ); + + req.on("error", (err) => { + reject(err); + }); + + req.on("timeout", () => { + req.destroy(); + reject(new Error("Timeout getting latest version")); + }); + }); +} + +function compareVersions(v1, v2) { + // Split pre-release tags (e.g. 0.1.8-beta.1 -> "0.1.8" and "beta.1") + const split1 = v1.split("-"); + const split2 = v2.split("-"); + const parts1 = split1[0].split(".").map(Number); + const parts2 = split2[0].split(".").map(Number); + + // Compare major.minor.patch + for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { + const p1 = parts1[i] || 0; + const p2 = parts2[i] || 0; + if (p1 > p2) return 1; + if (p1 < p2) return -1; + } + + // If base versions are equal, compare pre-release tags. + // A version WITH a pre-release tag is LOWER than a version WITHOUT one. + // e.g. 0.1.8-beta is less than 0.1.8. 0.1.8 is the final release. + const hasPre1 = split1.length > 1; + const hasPre2 = split2.length > 1; + + if (hasPre1 && !hasPre2) return -1; // v1 is a beta, v2 is a full release + if (!hasPre1 && hasPre2) return 1; // v1 is a full release, v2 is a beta + if (!hasPre1 && !hasPre2) return 0; // both are full releases and equal + + // If both are pre-releases, do a basic string compare on the tag + // "alpha" < "beta" < "rc" + if (split1[1] > split2[1]) return 1; + if (split1[1] < split2[1]) return -1; + + return 0; +} + +export function startUpdateChecker(log) { + // Only start the interval if we are in the gateway + const isGateway = process.argv.includes("gateway"); + if (!isGateway) { + return; + } + + const runCheck = async () => { + if (isUpdating) { + log.info?.(`${ANSI.YELLOW}[memos-cloud] An update sequence is currently in progress, skipping this check.${ANSI.RESET}`); + return; + } + + // TRULY PREVENT LOOPS: The instant we start a check, record the time BEFORE any network or processing happens. + // This absolutely guarantees that even if the network hangs, NPM crashes, or openclaw update causes an immediate hot reload, + // the system has already advanced the 12-hour/1-min clock and will NOT re-enter this function on boot. + try { + fs.writeFileSync(CHECK_FILE, JSON.stringify({ time: Date.now() })); + } catch (e) { + log.warn?.(`${ANSI.RED}[memos-cloud] Failed to write timestamp file: ${e.message}${ANSI.RESET}`); + } + + const currentVersion = getPackageVersion(); + if (!currentVersion) { + log.warn?.(`${ANSI.RED}[memos-cloud] Could not read current version from package.json${ANSI.RESET}`); + return; + } + + try { + const latestVersion = await getLatestVersion(log); + + // Normal version check + if (compareVersions(latestVersion, currentVersion) <= 0) { + return; + } + + log.info?.(`${ANSI.YELLOW}[memos-cloud] Update available: ${currentVersion} -> ${latestVersion}. Updating in background...${ANSI.RESET}`); + + let dotCount = 0; + const progressInterval = setInterval(() => { + dotCount++; + const dots = ".".repeat(dotCount % 4); + log.info?.(`${ANSI.YELLOW}[memos-cloud] Update in progress for memos-cloud-openclaw-plugin${dots}${ANSI.RESET}`); + }, 30000); // Log every 30 seconds to show it's still alive without spamming + + const cliName = (() => { + // Check the full path of the entry script (e.g., .../moltbot/bin/index.js) or the executable + const scriptPath = process.argv[1] ? process.argv[1].toLowerCase() : ""; + const execPath = process.execPath ? process.execPath.toLowerCase() : ""; + + if (scriptPath.includes("moltbot") || execPath.includes("moltbot")) return "moltbot"; + if (scriptPath.includes("clawdbot") || execPath.includes("clawdbot")) return "clawdbot"; + return "openclaw"; + })(); + + isUpdating = true; + const spawnOpts = { shell: true }; + // On Unix, detach the process so we can kill the entire process group on timeout + if (process.platform !== "win32") { + spawnOpts.detached = true; + } + const child = spawn(cliName, ["plugins", "update", "memos-cloud-openclaw-plugin"], spawnOpts); + + // Timeout mechanism: forcefully kill the update process if it hangs for more than the configured timeout + const updateTimeout = setTimeout(() => { + log.warn?.(`${ANSI.RED}[memos-cloud] Update process timed out. Please try manually running: ${cliName} plugins update memos-cloud-openclaw-plugin${ANSI.RESET}`); + killProcessTree(child); + + // Fallback: if kill failed and the close event never fires, forcefully release the lock after 5 seconds + setTimeout(() => { + if (isUpdating) { + clearInterval(progressInterval); + isUpdating = false; + } + }, 5000); + }, UPDATE_TIMEOUT); + + child.stdout.on("data", (data) => { + const outText = data.toString(); + log.info?.(`${ANSI.CYAN}[${cliName}-cli]${ANSI.RESET}\n${outText.trim()}`); + + // Auto-reply to any [y/N] prompts from the CLI + if (outText.toLowerCase().includes("[y/n]")) { + child.stdin.write("y\n"); + } + }); + + child.stderr.on("data", (data) => { + const errText = data.toString(); + log.warn?.(`${ANSI.RED}[${cliName}-cli]${ANSI.RESET}\n${errText.trim()}`); + + // Some CLIs output interactive prompts to stderr instead of stdout + if (errText.toLowerCase().includes("[y/n]")) { + child.stdin.write("y\n"); + } + }); + + child.on("close", (code) => { + clearTimeout(updateTimeout); + clearInterval(progressInterval); + isUpdating = false; + + // Wait for a brief moment to let file system sync if needed + setTimeout(() => { + const postUpdateVersion = getPackageVersion(); + const actuallyUpdated = (postUpdateVersion === latestVersion) && (postUpdateVersion !== currentVersion); + + if (code !== 0 || !actuallyUpdated) { + log.warn?.(`${ANSI.RED}[memos-cloud] Auto-update failed or version did not change. Please refer to the CLI logs above, or run manually: ${cliName} plugins update memos-cloud-openclaw-plugin${ANSI.RESET}`); + } else { + log.info?.(`${ANSI.GREEN}[memos-cloud] Successfully updated to version ${latestVersion}. Please restart the gateway to apply changes.${ANSI.RESET}`); + } + }, 1000); // Small 1-second buffer for file systems + }); + + } catch (error) { + log.warn?.(`${ANSI.RED}[memos-cloud] Update check failed entirely: ${error.message}${ANSI.RESET}`); + } + }; + + // Check when we last ran + let lastCheckTime = 0; + try { + if (fs.existsSync(CHECK_FILE)) { + const data = JSON.parse(fs.readFileSync(CHECK_FILE, "utf-8")); + lastCheckTime = data.time || 0; + } + } catch (e) {} + + const now = Date.now(); + const timeSinceLastCheck = now - lastCheckTime; + + // If the interval has passed, run it IMMEDIATELY without delay. + // The immediate file-write at the top of runCheck() will prevent loop scenarios. + if (timeSinceLastCheck >= CHECK_INTERVAL) { + runCheck(); + setInterval(runCheck, CHECK_INTERVAL); + } else { + // If it hasn't been the full interval yet, wait the remaining time, then trigger interval + const timeUntilNextCheck = CHECK_INTERVAL - timeSinceLastCheck; + setTimeout(() => { + runCheck(); + setInterval(runCheck, CHECK_INTERVAL); + }, timeUntilNextCheck); + } +} diff --git a/apps/MemOS-Cloud-OpenClaw-Plugin/lib/memos-cloud-api.js b/apps/MemOS-Cloud-OpenClaw-Plugin/lib/memos-cloud-api.js new file mode 100644 index 000000000..085a35f74 --- /dev/null +++ b/apps/MemOS-Cloud-OpenClaw-Plugin/lib/memos-cloud-api.js @@ -0,0 +1,501 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { setTimeout as delay } from "node:timers/promises"; + +const DEFAULT_BASE_URL = "https://memos.memtensor.cn/api/openmem/v1"; +export const USER_QUERY_MARKER = "user\u200b原\u200b始\u200bquery\u200b:\u200b\u200b\u200b\u200b"; +const ENV_SOURCES = [ + { name: "openclaw", path: join(homedir(), ".openclaw", ".env") }, + { name: "moltbot", path: join(homedir(), ".moltbot", ".env") }, + { name: "clawdbot", path: join(homedir(), ".clawdbot", ".env") }, +]; + +let envFilesLoaded = false; +const envFileContents = new Map(); +const envFileValues = new Map(); + +function stripQuotes(value) { + if (!value) return value; + const trimmed = value.trim(); + if ( + (trimmed.startsWith("\"") && trimmed.endsWith("\"")) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +export function extractResultData(result) { + if (!result || typeof result !== "object") return null; + return result.data ?? result.data?.data ?? result.data?.result ?? null; +} + +function pad2(value) { + return String(value).padStart(2, "0"); +} + +function formatTime(value) { + if (value === undefined || value === null || value === "") return ""; + if (typeof value === "number") { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2( + date.getHours(), + )}:${pad2(date.getMinutes())}`; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return ""; + if (/^\d+$/.test(trimmed)) return formatTime(Number(trimmed)); + return trimmed; + } + return ""; +} + +function parseEnvFile(content) { + const values = new Map(); + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const idx = trimmed.indexOf("="); + if (idx <= 0) continue; + const key = trimmed.slice(0, idx).trim(); + const rawValue = trimmed.slice(idx + 1); + if (!key) continue; + values.set(key, stripQuotes(rawValue)); + } + return values; +} + +function loadEnvFiles() { + if (envFilesLoaded) return; + envFilesLoaded = true; + for (const source of ENV_SOURCES) { + try { + const content = readFileSync(source.path, "utf-8"); + envFileContents.set(source.name, content); + envFileValues.set(source.name, parseEnvFile(content)); + } catch { + // ignore missing files + } + } +} + +function loadEnvFromFiles(name) { + for (const source of ENV_SOURCES) { + const values = envFileValues.get(source.name); + if (!values) continue; + if (values.has(name)) return values.get(name); + } + return undefined; +} + +function loadEnvVar(name) { + loadEnvFiles(); + const fromFiles = loadEnvFromFiles(name); + if (fromFiles !== undefined) return fromFiles; + if (envFileContents.size === 0) return process.env[name]; + return undefined; +} + +export function getEnvFileStatus() { + loadEnvFiles(); + const sources = ENV_SOURCES.filter((source) => envFileContents.has(source.name)); + return { + found: sources.length > 0, + sources: sources.map((source) => source.name), + paths: sources.map((source) => source.path), + searchPaths: ENV_SOURCES.map((source) => source.path), + }; +} + +function parseBool(value, fallback) { + if (value === undefined || value === null || value === "") return fallback; + if (typeof value === "boolean") return value; + const normalized = String(value).trim().toLowerCase(); + if (["1", "true", "yes", "y", "on"].includes(normalized)) return true; + if (["0", "false", "no", "n", "off"].includes(normalized)) return false; + return fallback; +} + +function parseNumber(value, fallback) { + if (value === undefined || value === null || value === "") return fallback; + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +export function buildConfig(pluginConfig = {}) { + const cfg = pluginConfig ?? {}; + + const baseUrl = cfg.baseUrl || loadEnvVar("MEMOS_BASE_URL") || DEFAULT_BASE_URL; + 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") || ""; + + const recallGlobal = parseBool( + cfg.recallGlobal, + parseBool(loadEnvVar("MEMOS_RECALL_GLOBAL"), true), + ); + + const conversationIdPrefix = cfg.conversationIdPrefix ?? loadEnvVar("MEMOS_CONVERSATION_PREFIX") ?? ""; + const conversationIdSuffix = cfg.conversationIdSuffix ?? loadEnvVar("MEMOS_CONVERSATION_SUFFIX") ?? ""; + const conversationSuffixMode = + cfg.conversationSuffixMode ?? loadEnvVar("MEMOS_CONVERSATION_SUFFIX_MODE") ?? "none"; + const resetOnNew = parseBool( + cfg.resetOnNew, + parseBool(loadEnvVar("MEMOS_CONVERSATION_RESET_ON_NEW"), true), + ); + + const multiAgentMode = parseBool( + cfg.multiAgentMode, + parseBool(loadEnvVar("MEMOS_MULTI_AGENT_MODE"), false), + ); + + const recallFilterEnabled = parseBool( + cfg.recallFilterEnabled, + parseBool(loadEnvVar("MEMOS_RECALL_FILTER_ENABLED"), false), + ); + const recallFilterFailOpen = parseBool( + cfg.recallFilterFailOpen, + parseBool(loadEnvVar("MEMOS_RECALL_FILTER_FAIL_OPEN"), true), + ); + + return { + baseUrl: baseUrl.replace(/\/+$/, ""), + apiKey, + userId, + conversationId, + conversationIdPrefix, + conversationIdSuffix, + conversationSuffixMode, + recallGlobal, + resetOnNew, + envFileStatus: getEnvFileStatus(), + queryPrefix: cfg.queryPrefix ?? "", + maxQueryChars: cfg.maxQueryChars ?? 0, + recallEnabled: cfg.recallEnabled !== false, + addEnabled: cfg.addEnabled !== false, + captureStrategy: cfg.captureStrategy ?? "last_turn", + maxMessageChars: cfg.maxMessageChars ?? 20000, + maxItemChars: cfg.maxItemChars ?? 8000, + includeAssistant: cfg.includeAssistant !== false, + memoryLimitNumber: cfg.memoryLimitNumber ?? 9, + preferenceLimitNumber: cfg.preferenceLimitNumber ?? 6, + includePreference: cfg.includePreference !== false, + includeToolMemory: cfg.includeToolMemory === true, + toolMemoryLimitNumber: cfg.toolMemoryLimitNumber ?? 6, + relativity: cfg.relativity ?? ((() => { + const v = loadEnvVar("MEMOS_RELATIVITY"); + return v ? parseFloat(v) : 0.45; + })()), + filter: cfg.filter, + knowledgebaseIds: cfg.knowledgebaseIds ?? [], + tags: cfg.tags ?? ["openclaw"], + info: cfg.info ?? {}, + agentId: cfg.agentId, + appId: cfg.appId, + allowPublic: cfg.allowPublic ?? false, + allowKnowledgebaseIds: cfg.allowKnowledgebaseIds ?? [], + asyncMode: cfg.asyncMode ?? true, + multiAgentMode, + recallFilterEnabled, + recallFilterBaseUrl: + (cfg.recallFilterBaseUrl ?? loadEnvVar("MEMOS_RECALL_FILTER_BASE_URL") ?? "").replace(/\/+$/, ""), + recallFilterApiKey: cfg.recallFilterApiKey ?? loadEnvVar("MEMOS_RECALL_FILTER_API_KEY") ?? "", + recallFilterModel: cfg.recallFilterModel ?? loadEnvVar("MEMOS_RECALL_FILTER_MODEL") ?? "", + recallFilterTimeoutMs: parseNumber( + cfg.recallFilterTimeoutMs ?? loadEnvVar("MEMOS_RECALL_FILTER_TIMEOUT_MS"), + 6000, + ), + recallFilterRetries: parseNumber(cfg.recallFilterRetries ?? loadEnvVar("MEMOS_RECALL_FILTER_RETRIES"), 0), + recallFilterCandidateLimit: + parseNumber(cfg.recallFilterCandidateLimit ?? loadEnvVar("MEMOS_RECALL_FILTER_CANDIDATE_LIMIT"), 30), + recallFilterMaxItemChars: + parseNumber(cfg.recallFilterMaxItemChars ?? loadEnvVar("MEMOS_RECALL_FILTER_MAX_ITEM_CHARS"), 500), + recallFilterFailOpen, + timeoutMs: cfg.timeoutMs ?? 5000, + retries: cfg.retries ?? 1, + throttleMs: cfg.throttleMs ?? 0, + }; +} + +export async function callApi({ baseUrl, apiKey, timeoutMs = 5000, retries = 1 }, path, body) { + if (!apiKey) { + throw new Error("Missing MEMOS API key (Token auth)"); + } + + const headers = { + "Content-Type": "application/json", + Authorization: `Token ${apiKey}`, + }; + + let lastError; + for (let attempt = 0; attempt <= retries; attempt += 1) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + const res = await fetch(`${baseUrl}${path}`, { + method: "POST", + headers, + body: JSON.stringify(body), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + + return await res.json(); + } catch (err) { + lastError = err; + if (attempt < retries) { + await delay(100 * (attempt + 1)); + } + } + } + + throw lastError; +} + +export async function searchMemory(cfg, payload) { + return callApi(cfg, "/search/memory", payload); +} + +export async function addMessage(cfg, payload) { + return callApi(cfg, "/add/message", payload); +} + +export function extractText(content) { + if (!content) return ""; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .filter((block) => block && typeof block === "object" && block.type === "text") + .map((block) => block.text) + .join(" "); + } + return ""; +} + +function normalizePreferenceType(value) { + if (!value) return ""; + const normalized = String(value).trim().toLowerCase(); + if (!normalized) return ""; + if (normalized.includes("explicit")) return "Explicit Preference"; + if (normalized.includes("implicit")) return "Implicit Preference"; + return String(value) + .replace(/[_-]+/g, " ") + .replace(/\b\w/g, (ch) => ch.toUpperCase()); +} + +function sanitizeInlineText(text) { + if (text === undefined || text === null) return ""; + return String(text).replace(/\r?\n+/g, " ").trim(); +} + +function formatMemoryLine(item, text, options = {}) { + const cleaned = sanitizeInlineText(text); + if (!cleaned) return ""; + const maxChars = options.maxItemChars; + const truncated = truncate(cleaned, maxChars); + const time = formatTime(item?.create_time); + if (time) return ` -[${time}] ${truncated}`; + return ` - ${truncated}`; +} + +function formatPreferenceLine(item, text, options = {}) { + const cleaned = sanitizeInlineText(text); + if (!cleaned) return ""; + const maxChars = options.maxItemChars; + const truncated = truncate(cleaned, maxChars); + const time = formatTime(item?.create_time); + const type = normalizePreferenceType(item?.preference_type); + const typeLabel = type ? ` [${type}]` : ""; + if (time) return ` -[${time}]${typeLabel} ${truncated}`; + return ` -${typeLabel} ${truncated}`; +} + +function wrapCodeBlock(lines, options = {}) { + if (!options.wrapTagBlocks) return lines; + return ["```text", ...lines, "```"]; +} + +function buildMemorySections(data, options = {}) { + const memoryList = data?.memory_detail_list ?? []; + const preferenceList = data?.preference_detail_list ?? []; + + const memoryLines = memoryList + .filter((item) => { + const score = item?.relativity ?? 1; + const threshold = options.relativity ?? 0; + return score > threshold; + }) + .map((item) => { + const text = item?.memory_value || item?.memory_key || ""; + return formatMemoryLine(item, text, options); + }) + .filter(Boolean); + + const preferenceLines = preferenceList + .filter((item) => { + const score = item?.relativity ?? 1; + const threshold = options.relativity ?? 0; + return score > threshold; + }) + .map((item) => { + const text = item?.preference || ""; + return formatPreferenceLine(item, text, options); + }) + .filter(Boolean); + + return { memoryLines, preferenceLines }; +} + +const STATIC_RECALL_SYSTEM_PROMPT = [ + "# Role", + "", + "You are an intelligent assistant with long-term memory capabilities (MemOS Assistant). Your goal is to combine retrieved memory fragments to provide highly personalized, accurate, and logically rigorous responses.", + "", + "# System Context", + "", + "* Current Time: Use the runtime-provided current time as the baseline for freshness checks.", + "* Additional memory context for the current turn may be prepended before the original user query as a structured `` block.", + "", + "# Memory Data", + "", + 'Below is the information retrieved by MemOS, categorized into "Facts" and "Preferences".', + "* **Facts**: May include user attributes, historical conversations, or third-party details.", + "* **Special Note**: Content tagged with '[assistant观点]' or '[模型总结]' represents **past AI inference**, **not** direct user statements.", + "* **Preferences**: The user's explicit or implicit requirements on response style, format, or reasoning.", + "", + "# Critical Protocol: Memory Safety", + "", + "Retrieved memories may contain **AI speculation**, **irrelevant noise**, or **wrong subject attribution**. You must strictly apply the **Four-Step Verdict**. If any step fails, **discard the memory**:", + "", + "1. **Source Verification**:", + "* **Core**: Distinguish direct user statements from AI inference.", + "* If a memory has tags like '[assistant观点]' or '[模型总结]', treat it as a **hypothesis**, not a user-grounded fact.", + "* *Counterexample*: If memory says '[assistant观点] User loves mangoes' but the user never said that, do not assume it as fact.", + "* **Principle: AI summaries are reference-only and have much lower authority than direct user statements.**", + "", + "2. **Attribution Check**:", + "* Is the subject in memory definitely the user?", + "* If the memory describes a **third party** (e.g., candidate, interviewee, fictional character, case data), never attribute it to the user.", + "", + "3. **Strong Relevance Check**:", + "* Does the memory directly help answer the current 'Original Query'?", + "* If it is only a keyword overlap with different context, ignore it.", + "", + "4. **Freshness Check**:", + "* If memory conflicts with the user's latest intent, prioritize the current 'Original Query' as the highest source of truth.", + "", + "# Instructions", + "", + "1. **Review**: Read '' first and apply the Four-Step Verdict to remove noise and unreliable AI inference.", + "2. **Execute**:", + " - Use only memories that pass filtering as context.", + " - Strictly follow style requirements from ''.", + "3. **Output**: Answer directly. Never mention internal terms such as \"memory store\", \"retrieval\", or \"AI opinions\".", + "4. **Attention**: Additional memory context may already be provided before the original user query. Do not read from or write to local `MEMORY.md` or `memory/*` files for reference, as they may be outdated or irrelevant to the current query.", +].join("\n"); + +function buildMemoryPrependBlock(data, options = {}) { + const { memoryLines, preferenceLines } = buildMemorySections(data, options); + const hasContent = memoryLines.length > 0 || preferenceLines.length > 0; + if (!hasContent) return ""; + + const memoriesBlock = [ + "", + " ", + ...memoryLines, + " ", + " ", + ...preferenceLines, + " ", + "", + ]; + + return [...wrapCodeBlock(memoriesBlock, options), "", USER_QUERY_MARKER].join("\n"); +} + +export function formatPromptBlockFromData(data, options = {}) { + if (!data || typeof data !== "object") return ""; + return buildMemoryPrependBlock(data, options); +} + +export function formatPromptBlock(result, options = {}) { + const data = extractResultData(result); + return formatPromptBlockFromData(data, options); +} + +export function formatContextBlock(result, options = {}) { + const data = extractResultData(result); + if (!data) return ""; + + const memoryList = data.memory_detail_list ?? []; + const prefList = data.preference_detail_list ?? []; + const toolList = data.tool_memory_detail_list ?? []; + const preferenceNote = data.preference_note; + + const lines = []; + if (memoryList.length > 0) { + lines.push("Facts:"); + for (const item of memoryList) { + const text = item?.memory_value || item?.memory_key || ""; + if (!text) continue; + lines.push(`- ${truncate(text, options.maxItemChars)}`); + } + } + + if (prefList.length > 0) { + lines.push("Preferences:"); + for (const item of prefList) { + const pref = item?.preference || ""; + const type = item?.preference_type ? `(${item.preference_type}) ` : ""; + if (!pref) continue; + lines.push(`- ${type}${truncate(pref, options.maxItemChars)}`); + } + } + + if (toolList.length > 0) { + lines.push("Tool Memories:"); + for (const item of toolList) { + const value = item?.tool_value || ""; + if (!value) continue; + lines.push(`- ${truncate(value, options.maxItemChars)}`); + } + } + + if (preferenceNote) { + lines.push(`Preference Note: ${truncate(preferenceNote, options.maxItemChars)}`); + } + + return lines.length > 0 ? lines.join("\n") : ""; +} + +export function formatRecallHookResult(result, options = {}) { + const data = extractResultData(result); + if (!data) { + return { + appendSystemContext: "", + prependContext: "", + }; + } + + return { + // Keep this system addendum byte-stable across turns so provider-side prefix caching can hit. + appendSystemContext: STATIC_RECALL_SYSTEM_PROMPT, + prependContext: buildMemoryPrependBlock(data, options), + }; +} + +function truncate(text, maxLen) { + if (!text) return ""; + const limit = maxLen || 10000; + return text.length > limit ? `${text.slice(0, limit)}...` : text; +} diff --git a/apps/MemOS-Cloud-OpenClaw-Plugin/moltbot.plugin.json b/apps/MemOS-Cloud-OpenClaw-Plugin/moltbot.plugin.json new file mode 100644 index 000000000..33f4b7096 --- /dev/null +++ b/apps/MemOS-Cloud-OpenClaw-Plugin/moltbot.plugin.json @@ -0,0 +1,169 @@ +{ + "id": "memos-cloud-openclaw-plugin", + "name": "MemOS Cloud OpenClaw Plugin", + "description": "MemOS Cloud recall + add memory via lifecycle hooks", + "version": "0.1.9", + "kind": "lifecycle", + "main": "./index.js", + "configSchema": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string", + "description": "MemOS Cloud base URL" + }, + "apiKey": { + "type": "string", + "description": "MemOS API Key (Token auth; supports ~/.openclaw/.env, ~/.moltbot/.env, ~/.clawdbot/.env; falls back to process env)" + }, + "userId": { + "type": "string", + "description": "MemOS user_id (default: openclaw-user)", + "default": "openclaw-user" + }, + "conversationId": { + "type": "string", + "description": "Override conversation_id" + }, + "conversationIdPrefix": { + "type": "string", + "description": "conversation_id prefix" + }, + "conversationIdSuffix": { + "type": "string", + "description": "conversation_id suffix" + }, + "conversationSuffixMode": { + "type": "string", + "enum": [ + "none", + "counter" + ], + "default": "none" + }, + "resetOnNew": { + "type": "boolean", + "default": true + }, + "queryPrefix": { + "type": "string", + "description": "Prefix added to search queries" + }, + "maxQueryChars": { + "type": "integer", + "description": "Max chars for search query" + }, + "recallEnabled": { + "type": "boolean", + "default": true + }, + "recallGlobal": { + "type": "boolean", + "default": true + }, + "addEnabled": { + "type": "boolean", + "default": true + }, + "captureStrategy": { + "type": "string", + "enum": [ + "last_turn", + "full_session" + ], + "default": "last_turn" + }, + "maxMessageChars": { + "type": "integer", + "description": "Max chars per message when adding", + "default": 20000 + }, + "maxItemChars": { + "type": "integer", + "description": "Max chars per memory item when injecting prompt", + "default": 8000 + }, + "includeAssistant": { + "type": "boolean", + "default": true + }, + "memoryLimitNumber": { + "type": "integer", + "default": 6 + }, + "preferenceLimitNumber": { + "type": "integer", + "default": 6 + }, + "includePreference": { + "type": "boolean", + "default": true + }, + "includeToolMemory": { + "type": "boolean", + "default": false + }, + "toolMemoryLimitNumber": { + "type": "integer", + "default": 6 + }, + "filter": { + "type": "object", + "description": "MemOS search filter" + }, + "knowledgebaseIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "info": { + "type": "object", + "additionalProperties": true + }, + "agentId": { + "type": "string" + }, + "multiAgentMode": { + "type": "boolean", + "default": false + }, + "appId": { + "type": "string" + }, + "allowPublic": { + "type": "boolean", + "default": false + }, + "allowKnowledgebaseIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "asyncMode": { + "type": "boolean", + "default": true + }, + "timeoutMs": { + "type": "integer", + "default": 5000 + }, + "retries": { + "type": "integer", + "default": 1 + }, + "throttleMs": { + "type": "integer", + "default": 0 + } + }, + "additionalProperties": false + } +} diff --git a/apps/MemOS-Cloud-OpenClaw-Plugin/openclaw.plugin.json b/apps/MemOS-Cloud-OpenClaw-Plugin/openclaw.plugin.json new file mode 100644 index 000000000..33f4b7096 --- /dev/null +++ b/apps/MemOS-Cloud-OpenClaw-Plugin/openclaw.plugin.json @@ -0,0 +1,169 @@ +{ + "id": "memos-cloud-openclaw-plugin", + "name": "MemOS Cloud OpenClaw Plugin", + "description": "MemOS Cloud recall + add memory via lifecycle hooks", + "version": "0.1.9", + "kind": "lifecycle", + "main": "./index.js", + "configSchema": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string", + "description": "MemOS Cloud base URL" + }, + "apiKey": { + "type": "string", + "description": "MemOS API Key (Token auth; supports ~/.openclaw/.env, ~/.moltbot/.env, ~/.clawdbot/.env; falls back to process env)" + }, + "userId": { + "type": "string", + "description": "MemOS user_id (default: openclaw-user)", + "default": "openclaw-user" + }, + "conversationId": { + "type": "string", + "description": "Override conversation_id" + }, + "conversationIdPrefix": { + "type": "string", + "description": "conversation_id prefix" + }, + "conversationIdSuffix": { + "type": "string", + "description": "conversation_id suffix" + }, + "conversationSuffixMode": { + "type": "string", + "enum": [ + "none", + "counter" + ], + "default": "none" + }, + "resetOnNew": { + "type": "boolean", + "default": true + }, + "queryPrefix": { + "type": "string", + "description": "Prefix added to search queries" + }, + "maxQueryChars": { + "type": "integer", + "description": "Max chars for search query" + }, + "recallEnabled": { + "type": "boolean", + "default": true + }, + "recallGlobal": { + "type": "boolean", + "default": true + }, + "addEnabled": { + "type": "boolean", + "default": true + }, + "captureStrategy": { + "type": "string", + "enum": [ + "last_turn", + "full_session" + ], + "default": "last_turn" + }, + "maxMessageChars": { + "type": "integer", + "description": "Max chars per message when adding", + "default": 20000 + }, + "maxItemChars": { + "type": "integer", + "description": "Max chars per memory item when injecting prompt", + "default": 8000 + }, + "includeAssistant": { + "type": "boolean", + "default": true + }, + "memoryLimitNumber": { + "type": "integer", + "default": 6 + }, + "preferenceLimitNumber": { + "type": "integer", + "default": 6 + }, + "includePreference": { + "type": "boolean", + "default": true + }, + "includeToolMemory": { + "type": "boolean", + "default": false + }, + "toolMemoryLimitNumber": { + "type": "integer", + "default": 6 + }, + "filter": { + "type": "object", + "description": "MemOS search filter" + }, + "knowledgebaseIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "info": { + "type": "object", + "additionalProperties": true + }, + "agentId": { + "type": "string" + }, + "multiAgentMode": { + "type": "boolean", + "default": false + }, + "appId": { + "type": "string" + }, + "allowPublic": { + "type": "boolean", + "default": false + }, + "allowKnowledgebaseIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "asyncMode": { + "type": "boolean", + "default": true + }, + "timeoutMs": { + "type": "integer", + "default": 5000 + }, + "retries": { + "type": "integer", + "default": 1 + }, + "throttleMs": { + "type": "integer", + "default": 0 + } + }, + "additionalProperties": false + } +} diff --git a/apps/MemOS-Cloud-OpenClaw-Plugin/package.json b/apps/MemOS-Cloud-OpenClaw-Plugin/package.json new file mode 100644 index 000000000..82a4f9f03 --- /dev/null +++ b/apps/MemOS-Cloud-OpenClaw-Plugin/package.json @@ -0,0 +1,46 @@ +{ + "name": "@memtensor/memos-cloud-openclaw-plugin", + "version": "0.1.9", + "description": "OpenClaw lifecycle plugin for MemOS Cloud (add + recall memory)", + "scripts": { + "sync-version": "node scripts/sync-version.js", + "version": "npm run sync-version && git add openclaw.plugin.json moltbot.plugin.json clawdbot.plugin.json", + "publish-beta": "npm publish --tag beta", + "publish-beta-patch": "npm version prepatch --preid=beta && npm publish --tag beta", + "publish-latest": "npm version $(node -p \"require('./package.json').version.split('-')[0]\") && npm publish", + "publish-latest-patch": "npm version patch && npm publish" + }, + "keywords": [ + "memos", + "memos-cloud", + "openclaw", + "plugin", + "memory" + ], + "homepage": "https://github.com/MemTensor/MemOS-Cloud-OpenClaw-Plugin#readme", + "bugs": { + "url": "https://github.com/MemTensor/MemOS-Cloud-OpenClaw-Plugin/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/MemTensor/MemOS-Cloud-OpenClaw-Plugin.git" + }, + "type": "module", + "author": "MemTensor", + "license": "MIT", + "openclaw": { + "extensions": [ + "./index.js" + ] + }, + "clawdbot": { + "extensions": [ + "./index.js" + ] + }, + "moltbot": { + "extensions": [ + "./index.js" + ] + } +} diff --git a/apps/MemOS-Cloud-OpenClaw-Plugin/scripts/sync-version.js b/apps/MemOS-Cloud-OpenClaw-Plugin/scripts/sync-version.js new file mode 100644 index 000000000..f98150d89 --- /dev/null +++ b/apps/MemOS-Cloud-OpenClaw-Plugin/scripts/sync-version.js @@ -0,0 +1,45 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Read the updated package.json to get the new version +const packageJsonPath = path.resolve(__dirname, '../package.json'); +const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); +const newVersion = packageJson.version; + +console.log(`Syncing version to ${newVersion}...`); + +const filesToUpdate = [ + 'openclaw.plugin.json', + 'moltbot.plugin.json', + 'clawdbot.plugin.json' +]; + +filesToUpdate.forEach(fileName => { + const filePath = path.resolve(__dirname, '..', fileName); + + if (fs.existsSync(filePath)) { + try { + const content = JSON.parse(fs.readFileSync(filePath, 'utf8')); + + if (content.version !== newVersion) { + content.version = newVersion; + // Write back with 2 spaces indentation and a newline at the end + fs.writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n', 'utf8'); + console.log(`Updated ${fileName} to version ${newVersion}`); + } else { + console.log(`${fileName} is already at version ${newVersion}`); + } + } catch (error) { + console.error(`Error updating ${fileName}:`, error.message); + process.exit(1); + } + } else { + console.warn(`Warning: ${fileName} not found, skipping.`); + } +}); + +console.log('Version sync complete.');