refactor: consolidate agent/editor plugins under integrations/ (#5491)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
package-lock.json
|
||||
*.db
|
||||
.claude/
|
||||
.claude-*/
|
||||
@@ -0,0 +1,2 @@
|
||||
package-manager-strict-version=false
|
||||
approve-builds=esbuild
|
||||
@@ -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 [2023] [Taranjeet Singh]
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,175 @@
|
||||
# ============================================================================
|
||||
# Agentic Memory Skills — OpenClaw Plugin Installer
|
||||
# ============================================================================
|
||||
#
|
||||
# Usage:
|
||||
# make install # Interactive — prompts for API key
|
||||
# make install MEM0_API_KEY=m0-xxx # Non-interactive
|
||||
# make uninstall # Revert to stock plugin
|
||||
# make restart # Rebuild + restart gateway
|
||||
# make status # Check everything is working
|
||||
# make logs # Tail gateway logs filtered to mem0
|
||||
# make clean # Full teardown
|
||||
#
|
||||
# ============================================================================
|
||||
|
||||
SHELL := /bin/bash
|
||||
.PHONY: install uninstall restart status logs clean build check-deps configure help
|
||||
|
||||
# Defaults
|
||||
MEM0_API_KEY ?=
|
||||
MEM0_USER_ID ?= $(shell whoami)
|
||||
OPENCLAW_CONFIG := $(HOME)/.openclaw/openclaw.json
|
||||
PLUGIN_DIR := $(shell pwd)
|
||||
|
||||
help: ## Show this help
|
||||
@echo ""
|
||||
@echo " Agentic Memory Skills — OpenClaw Plugin"
|
||||
@echo " ========================================"
|
||||
@echo ""
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
|
||||
@echo ""
|
||||
|
||||
# ============================================================================
|
||||
# Dependency checks
|
||||
# ============================================================================
|
||||
|
||||
check-deps:
|
||||
@echo "Checking dependencies..."
|
||||
@command -v node >/dev/null 2>&1 || { echo "Error: Node.js is required. Install from https://nodejs.org"; exit 1; }
|
||||
@NODE_MAJOR=$$(node -v | sed 's/v//' | cut -d. -f1); \
|
||||
if [ "$$NODE_MAJOR" -lt 18 ]; then echo "Error: Node.js >= 18 required (found $$(node -v))"; exit 1; fi
|
||||
@command -v npm >/dev/null 2>&1 || { echo "Error: npm is required"; exit 1; }
|
||||
@if ! command -v openclaw >/dev/null 2>&1; then \
|
||||
echo "OpenClaw not found. Installing..."; \
|
||||
npm install -g openclaw || { echo "Error: Failed to install OpenClaw. Try: sudo npm install -g openclaw"; exit 1; }; \
|
||||
echo "OpenClaw installed: $$(openclaw --version)"; \
|
||||
else \
|
||||
echo "OpenClaw: $$(openclaw --version 2>/dev/null || echo 'installed')"; \
|
||||
fi
|
||||
@echo "Node.js: $$(node -v)"
|
||||
@echo "All dependencies OK."
|
||||
|
||||
# ============================================================================
|
||||
# Build
|
||||
# ============================================================================
|
||||
|
||||
build: ## Build the plugin from source
|
||||
@echo "Installing npm dependencies..."
|
||||
@npm install --silent 2>/dev/null
|
||||
@echo "Building plugin..."
|
||||
@npm run build --silent 2>/dev/null
|
||||
@echo "Build OK: $$(ls -lh dist/index.js | awk '{print $$5}') ESM bundle"
|
||||
|
||||
# ============================================================================
|
||||
# Install
|
||||
# ============================================================================
|
||||
|
||||
install: check-deps build ## Full install — build, link, configure, restart
|
||||
@# Remove existing plugin (suppress errors if not installed)
|
||||
@echo "Removing existing openclaw-mem0 plugin (if any)..."
|
||||
@echo "y" | openclaw plugins uninstall openclaw-mem0 >/dev/null 2>&1 || true
|
||||
@# Link local build
|
||||
@echo "Linking local plugin build..."
|
||||
@openclaw plugins install "$(PLUGIN_DIR)" --link 2>&1 | grep -v "plugins.allow"
|
||||
@# Configure AFTER link (link may overwrite config)
|
||||
@$(MAKE) --no-print-directory configure
|
||||
@# Restart gateway
|
||||
@echo "Restarting gateway..."
|
||||
@kill -9 $$(lsof -ti:18789) 2>/dev/null || true
|
||||
@sleep 2
|
||||
@openclaw gateway >/dev/null 2>&1 &
|
||||
@sleep 4
|
||||
@echo ""
|
||||
@echo "============================================"
|
||||
@echo " Installation complete!"
|
||||
@echo "============================================"
|
||||
@echo ""
|
||||
@echo " Plugin: openclaw-mem0 (skills mode)"
|
||||
@echo " User: $(MEM0_USER_ID)"
|
||||
@echo " Web UI: http://127.0.0.1:18789"
|
||||
@echo ""
|
||||
@echo " Verify with: make status"
|
||||
@echo " View logs: make logs"
|
||||
@echo ""
|
||||
|
||||
# ============================================================================
|
||||
# Configure — patch openclaw.json with required settings
|
||||
# ============================================================================
|
||||
|
||||
configure:
|
||||
@if [ -z "$(MEM0_API_KEY)" ]; then \
|
||||
echo ""; \
|
||||
echo " Enter your Mem0 API key (from https://app.mem0.ai/dashboard/api-keys):"; \
|
||||
echo -n " > "; \
|
||||
read -r key; \
|
||||
if [ -z "$$key" ]; then echo "Error: API key is required."; exit 1; fi; \
|
||||
MEM0_API_KEY="$$key" MEM0_USER_ID="$(MEM0_USER_ID)" python3 scripts/configure.py; \
|
||||
else \
|
||||
MEM0_API_KEY="$(MEM0_API_KEY)" MEM0_USER_ID="$(MEM0_USER_ID)" python3 scripts/configure.py; \
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Status & Logs
|
||||
# ============================================================================
|
||||
|
||||
status: ## Check plugin health and skills registration
|
||||
@echo "Plugin health:"
|
||||
@openclaw plugins doctor 2>&1 | grep -v "plugins.allow"
|
||||
@echo ""
|
||||
@echo "Skills:"
|
||||
@openclaw skills list 2>&1 | grep -E "memory|Status|---" | head -10
|
||||
@echo ""
|
||||
@echo "Last gateway registration:"
|
||||
@tail -50 /tmp/openclaw/openclaw-$$(date +%Y-%m-%d).log 2>/dev/null | grep "openclaw-mem0: registered" | tail -1 | grep -o '"1":"[^"]*"' || echo " No gateway log found. Is the gateway running?"
|
||||
|
||||
logs: ## Tail gateway logs (mem0 activity only)
|
||||
@tail -f /tmp/openclaw/openclaw-$$(date +%Y-%m-%d).log 2>/dev/null | grep --line-buffered -o '"1":"[^"]*"' | grep --line-buffered -i "mem0\|skills-mode\|stored\|inject\|recall"
|
||||
|
||||
# ============================================================================
|
||||
# Restart
|
||||
# ============================================================================
|
||||
|
||||
restart: build ## Rebuild plugin and restart gateway
|
||||
@echo "Restarting gateway..."
|
||||
@kill -9 $$(lsof -ti:18789) 2>/dev/null || true
|
||||
@sleep 2
|
||||
@openclaw gateway >/dev/null 2>&1 &
|
||||
@sleep 4
|
||||
@echo "Gateway restarted. Verify: make status"
|
||||
|
||||
# ============================================================================
|
||||
# Uninstall — revert to stock plugin
|
||||
# ============================================================================
|
||||
|
||||
uninstall: ## Revert to stock openclaw-mem0 from npm
|
||||
@echo "Uninstalling local plugin..."
|
||||
@echo "y" | openclaw plugins uninstall openclaw-mem0 >/dev/null 2>&1 || true
|
||||
@echo "Installing stock plugin from npm..."
|
||||
@openclaw plugins install @mem0/openclaw-mem0 2>&1 | grep -v "plugins.allow"
|
||||
@# Restore config backup if it exists
|
||||
@if [ -f "$(OPENCLAW_CONFIG).pre-skills-backup" ]; then \
|
||||
cp "$(OPENCLAW_CONFIG).pre-skills-backup" "$(OPENCLAW_CONFIG)"; \
|
||||
echo "Restored config from backup."; \
|
||||
else \
|
||||
echo "Note: No config backup found. You may need to manually revert openclaw.json changes."; \
|
||||
fi
|
||||
@echo "Restarting gateway..."
|
||||
@kill -9 $$(lsof -ti:18789) 2>/dev/null || true
|
||||
@sleep 2
|
||||
@openclaw gateway >/dev/null 2>&1 &
|
||||
@sleep 4
|
||||
@echo "Reverted to stock openclaw-mem0."
|
||||
|
||||
# ============================================================================
|
||||
# Clean
|
||||
# ============================================================================
|
||||
|
||||
clean: ## Full teardown — stop gateway, remove plugin, clean build
|
||||
@echo "Stopping gateway..."
|
||||
@kill -9 $$(lsof -ti:18789) 2>/dev/null || true
|
||||
@echo "Removing plugin..."
|
||||
@echo "y" | openclaw plugins uninstall openclaw-mem0 >/dev/null 2>&1 || true
|
||||
@echo "Cleaning build artifacts..."
|
||||
@rm -rf dist/ node_modules/
|
||||
@echo "Clean complete."
|
||||
@@ -0,0 +1,384 @@
|
||||
# @mem0/openclaw-mem0
|
||||
|
||||
Long-term memory for [OpenClaw](https://github.com/openclaw/openclaw) agents, powered by [Mem0](https://mem0.ai).
|
||||
|
||||
Your agent forgets everything between sessions. This plugin fixes that — it stores conversations, extracts what matters, and brings it back when relevant.
|
||||
|
||||
By default, the plugin runs in **skills mode**: the agent controls what to remember (triage), how to recall (recall), and periodic cleanup (dream). Skills mode, `autoRecall`, and `autoCapture` are all enabled by default during `openclaw mem0 init`.
|
||||
|
||||
## Requirements
|
||||
|
||||
Check your OpenClaw version:
|
||||
|
||||
```bash
|
||||
openclaw --version
|
||||
# OpenClaw 2026.4.25 (aa36ee6)
|
||||
```
|
||||
|
||||
| OpenClaw Version | Plugin Support |
|
||||
|------------------|----------------|
|
||||
| `>= 2026.4.25` | Fully supported |
|
||||
|
||||
## Quick path for agents
|
||||
|
||||
If you're an AI agent setting up Mem0 autonomously (no human dashboard signup), mint an evaluation API key in <5 seconds:
|
||||
|
||||
```bash
|
||||
mem0 init --agent --json
|
||||
```
|
||||
|
||||
No email, no OTP, no browser. The key is written to your Mem0 CLI config and exported as `MEM0_API_KEY` — pass it to `openclaw mem0 init --api-key $MEM0_API_KEY --user-id <id>` to wire OpenClaw up immediately. The human owner can later run `mem0 init --email <their-email>` to claim ownership: memories transfer, same API key keeps working, no agent disruption.
|
||||
|
||||
Humans should follow the Quick Start below.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Platform (Mem0 Cloud)
|
||||
|
||||
#### Install and Configure
|
||||
|
||||
1. **Install the plugin via the OpenClaw CLI:**
|
||||
|
||||
```bash
|
||||
openclaw plugins install @mem0/openclaw-mem0
|
||||
```
|
||||
|
||||
2. **Get your API key** from [app.mem0.ai](https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=openclaw-readme).
|
||||
|
||||
3. **Select the plugin as your memory backend in `openclaw.json`.** Either initialize via the CLI:
|
||||
|
||||
```bash
|
||||
openclaw mem0 init --api-key <your-key> --user-id <your-user-id>
|
||||
```
|
||||
|
||||
Or add the full config to your `openclaw.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
"plugins": {
|
||||
"slots": {
|
||||
"memory": "openclaw-mem0"
|
||||
},
|
||||
"entries": {
|
||||
"openclaw-mem0": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"apiKey": "${MEM0_API_KEY}",
|
||||
"userId": "alice",
|
||||
"skills": {
|
||||
"triage": { "enabled": true },
|
||||
"recall": {
|
||||
"enabled": true,
|
||||
"tokenBudget": 1500,
|
||||
"rerank": true,
|
||||
"keywordSearch": true,
|
||||
"identityAlwaysInclude": true
|
||||
},
|
||||
"dream": { "enabled": true },
|
||||
"domain": "companion"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** OpenClaw memory plugins load through an exclusive slot, so install alone does not activate the plugin. You must set `plugins.slots.memory` as shown above.
|
||||
|
||||
### Updating the plugin to get the latest features and fixes:
|
||||
|
||||
```bash
|
||||
openclaw plugins update openclaw-mem0
|
||||
```
|
||||
|
||||
### Open-Source (Self-hosted)
|
||||
|
||||
No Mem0 key needed. Vectors are stored locally in SQLite at `~/.mem0/vector_store.db` — no external database required.
|
||||
|
||||
Defaults: `text-embedding-3-small` (OpenAI) for embeddings, `gpt-5-mini` (OpenAI) for fact extraction — requires `OPENAI_API_KEY`. For a fully local setup, use Ollama for both LLM and embeddings.
|
||||
|
||||
#### Interactive Setup (Recommended)
|
||||
|
||||
Run the guided 4-step wizard:
|
||||
|
||||
```bash
|
||||
openclaw mem0 init --mode open-source
|
||||
```
|
||||
|
||||
The wizard walks you through:
|
||||
1. **LLM provider** — OpenAI (`gpt-5-mini`), Ollama (`llama3.1:8b`, local), or Anthropic (`claude-sonnet-4-5-20250514`)
|
||||
2. **Embedding provider** — OpenAI (`text-embedding-3-small`) or Ollama (`nomic-embed-text`, local)
|
||||
3. **Vector store** — Qdrant (`http://localhost:6333`) or PGVector (PostgreSQL)
|
||||
4. **User ID** — your memory namespace identifier
|
||||
|
||||
Each step tests connectivity (Ollama, Qdrant, PGVector) before proceeding.
|
||||
|
||||
#### Non-Interactive Setup
|
||||
|
||||
For CI/CD, scripts, or agent-driven setup — pass all options as flags:
|
||||
|
||||
```bash
|
||||
# Fully local with Ollama + Qdrant
|
||||
openclaw mem0 init --mode open-source \
|
||||
--oss-llm ollama --oss-embedder ollama --oss-vector qdrant
|
||||
|
||||
# OpenAI + Qdrant
|
||||
openclaw mem0 init --mode open-source \
|
||||
--oss-llm openai --oss-llm-key <key> \
|
||||
--oss-embedder openai --oss-embedder-key <key> \
|
||||
--oss-vector qdrant
|
||||
|
||||
# Anthropic LLM + OpenAI embeddings + PGVector
|
||||
openclaw mem0 init --mode open-source \
|
||||
--oss-llm anthropic --oss-llm-key <key> \
|
||||
--oss-embedder openai --oss-embedder-key <key> \
|
||||
--oss-vector pgvector --oss-vector-user postgres --oss-vector-password secret
|
||||
|
||||
# JSON output (for LLM agents)
|
||||
openclaw mem0 init --mode open-source --oss-llm ollama --oss-embedder ollama --oss-vector qdrant --json
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>All <code>--oss-*</code> flags</summary>
|
||||
|
||||
| Flag | Description |
|
||||
| ---- | ----------- |
|
||||
| `--oss-llm <provider>` | `openai`, `ollama`, or `anthropic` |
|
||||
| `--oss-llm-key <key>` | API key for LLM provider |
|
||||
| `--oss-llm-model <model>` | Override default LLM model |
|
||||
| `--oss-llm-url <url>` | Base URL (Ollama only) |
|
||||
| `--oss-embedder <provider>` | `openai` or `ollama` |
|
||||
| `--oss-embedder-key <key>` | API key for embedder |
|
||||
| `--oss-embedder-model <model>` | Override default embedder model |
|
||||
| `--oss-embedder-url <url>` | Base URL (Ollama only) |
|
||||
| `--oss-vector <provider>` | `qdrant` or `pgvector` |
|
||||
| `--oss-vector-url <url>` | Qdrant server URL (default: `http://localhost:6333`) |
|
||||
| `--oss-vector-host <host>` | PGVector host |
|
||||
| `--oss-vector-port <port>` | PGVector port |
|
||||
| `--oss-vector-user <user>` | PGVector user |
|
||||
| `--oss-vector-password <pw>` | PGVector password |
|
||||
| `--oss-vector-dbname <db>` | PGVector database name |
|
||||
| `--oss-vector-dims <n>` | Override embedding dimensions |
|
||||
|
||||
</details>
|
||||
|
||||
#### Manual Config
|
||||
|
||||
Minimal config — uses OpenAI defaults:
|
||||
|
||||
```json5
|
||||
{
|
||||
"plugins": {
|
||||
"slots": {
|
||||
"memory": "openclaw-mem0"
|
||||
},
|
||||
"entries": {
|
||||
"openclaw-mem0": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"mode": "open-source",
|
||||
"userId": "alice"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Customize the embedder, vector store, or LLM via the `oss` block:
|
||||
|
||||
```json5
|
||||
"config": {
|
||||
"mode": "open-source",
|
||||
"userId": "alice",
|
||||
"oss": {
|
||||
"embedder": { "provider": "openai", "config": { "model": "text-embedding-3-small" } },
|
||||
"vectorStore": { "provider": "qdrant", "config": { "url": "http://localhost:6333" } },
|
||||
"llm": { "provider": "openai", "config": { "model": "gpt-5-mini" } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All `oss` fields are optional. See the [Mem0 OSS docs](https://docs.mem0.ai/open-source/node-quickstart) for supported providers.
|
||||
|
||||
## How It Works
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/mem0ai/mem0/main/docs/images/openclaw-architecture.png" alt="Architecture" width="800" />
|
||||
</p>
|
||||
|
||||
### Skills Mode (Default)
|
||||
|
||||
Enabled automatically during `openclaw mem0 init`. The agent controls memory through three skills:
|
||||
|
||||
- **Triage** — Extracts durable facts from conversations using a structured protocol. Categories, importance gates, and domain overlays control what gets stored.
|
||||
- **Recall** — Before each turn, rewrites the user message into search queries, retrieves relevant memories with reranking, and injects them into context.
|
||||
- **Dream** — Periodic memory consolidation: merges duplicates, resolves conflicts, and prunes stale entries.
|
||||
|
||||
When skills mode is active, the skills handle memory operations. `autoRecall` and `autoCapture` remain `true` by default alongside skills mode. The built-in `session-memory` hook is disabled to avoid conflicts.
|
||||
|
||||
### Auto-Recall & Auto-Capture
|
||||
|
||||
When skills mode is not configured, the plugin uses `autoRecall` and `autoCapture` (both enabled by default):
|
||||
|
||||
- **Auto-Recall** — Before the agent responds, the plugin searches Mem0 for relevant memories and injects them into context.
|
||||
- **Auto-Capture** — After the agent responds, the conversation is filtered through a noise-removal pipeline and sent to Mem0. New facts get stored, stale ones updated, duplicates merged.
|
||||
|
||||
Set `autoRecall: false` or `autoCapture: false` to disable individually. The agent can also use memory tools (`memory_add`, `memory_search`, etc.) explicitly regardless of these settings.
|
||||
|
||||
### Memory Scopes
|
||||
|
||||
- **Session (short-term)** — Scoped to the current conversation via `run_id`. Recalled alongside long-term memories.
|
||||
- **User (long-term)** — Persistent across all sessions. Default for `memory_add`.
|
||||
|
||||
### Multi-Agent Isolation
|
||||
|
||||
Each agent gets its own memory namespace automatically via session key routing (`agent:<name>:<uuid>` maps to `userId:agent:<name>`). Single-agent setups are unaffected.
|
||||
|
||||
## Agent Tools
|
||||
|
||||
Eight tools are registered for agent use:
|
||||
|
||||
| Tool | Description |
|
||||
| ---- | ----------- |
|
||||
| `memory_search` | Search by natural language query. Supports `scope` (`session`, `long-term`, `all`), `categories`, `filters`, and `agentId`. |
|
||||
| `memory_add` | Store facts. Accepts `text` or `facts` array, `category`, `importance`, `longTerm`, `metadata`. |
|
||||
| `memory_get` | Retrieve a single memory by ID. |
|
||||
| `memory_list` | List all memories. Filter by `userId`, `agentId`, `scope`. |
|
||||
| `memory_update` | Update a memory's text in place. Preserves history. |
|
||||
| `memory_delete` | Delete by `memoryId`, `query` (search-and-delete), or `all: true` (requires `confirm: true`). |
|
||||
| `memory_event_list` | List recent background processing events. Platform mode only. |
|
||||
| `memory_event_status` | Get status of a specific event by ID. Platform mode only. |
|
||||
|
||||
## CLI
|
||||
|
||||
All commands: `openclaw mem0 <command>`. All commands support `--json` for machine-readable output (for LLM agents).
|
||||
|
||||
```bash
|
||||
# Memory operations
|
||||
openclaw mem0 add "User prefers TypeScript over JavaScript"
|
||||
openclaw mem0 search "what languages does the user know"
|
||||
openclaw mem0 search "preferences" --scope long-term
|
||||
openclaw mem0 get <memory_id>
|
||||
openclaw mem0 list --user-id alice --top-k 20
|
||||
openclaw mem0 update <memory_id> "Updated preference text"
|
||||
openclaw mem0 delete <memory_id>
|
||||
openclaw mem0 delete --all --user-id alice --confirm
|
||||
openclaw mem0 import memories.json
|
||||
|
||||
# Management
|
||||
openclaw mem0 init # interactive setup
|
||||
openclaw mem0 init --mode open-source --oss-llm ollama # non-interactive OSS
|
||||
openclaw mem0 init --api-key <key> --user-id alice # non-interactive platform
|
||||
openclaw mem0 status
|
||||
openclaw mem0 config show
|
||||
openclaw mem0 config get api_key
|
||||
openclaw mem0 config set user_id alice
|
||||
|
||||
# Events (platform only)
|
||||
openclaw mem0 event list
|
||||
openclaw mem0 event status <event_id>
|
||||
|
||||
# Memory consolidation
|
||||
openclaw mem0 dream
|
||||
openclaw mem0 dream --dry-run
|
||||
|
||||
# JSON output (any command)
|
||||
openclaw mem0 search "preferences" --json
|
||||
openclaw mem0 list --json
|
||||
openclaw mem0 status --json
|
||||
openclaw mem0 help --json # discover all commands + flags
|
||||
```
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### General
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| --- | ---- | ------- | ----------- |
|
||||
| `mode` | `"platform"` \| `"open-source"` | `"platform"` | Backend mode |
|
||||
| `userId` | `string` | OS username | User identifier. All memories scoped to this value. |
|
||||
| `autoRecall` | `boolean` | `true` | Inject relevant memories before each turn. Ignored when `skills` is set. |
|
||||
| `autoCapture` | `boolean` | `true` | Extract and store facts after each turn. Ignored when `skills` is set. |
|
||||
| `topK` | `number` | `5` | Max memories returned per recall |
|
||||
| `searchThreshold` | `number` | `0.1` | Minimum similarity score (0-1) |
|
||||
|
||||
### Skills Mode (Recommended)
|
||||
|
||||
Enabled by default during `openclaw mem0 init`. `autoRecall` and `autoCapture` are also `true` by default and work alongside skills mode.
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| --- | ---- | ------- | ----------- |
|
||||
| `skills.triage.enabled` | `boolean` | `true` | Enable fact extraction from conversations |
|
||||
| `skills.recall.enabled` | `boolean` | `true` | Enable memory recall before each turn |
|
||||
| `skills.recall.tokenBudget` | `number` | `1500` | Max tokens for injected memories |
|
||||
| `skills.recall.rerank` | `boolean` | `true` | Rerank search results for relevance |
|
||||
| `skills.recall.keywordSearch` | `boolean` | `true` | Augment with keyword-based search |
|
||||
| `skills.recall.identityAlwaysInclude` | `boolean` | `true` | Always include identity memories |
|
||||
| `skills.dream.enabled` | `boolean` | `true` | Enable periodic memory consolidation |
|
||||
| `skills.domain` | `string` | `"companion"` | Domain overlay for triage rules |
|
||||
|
||||
### Platform Mode
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| --- | ---- | ------- | ----------- |
|
||||
| `apiKey` | `string` | — | **Required.** Mem0 API key (supports `${MEM0_API_KEY}`) |
|
||||
| `customInstructions` | `string` | *(built-in)* | Custom extraction rules |
|
||||
| `customCategories` | `object` | *(12 defaults)* | Category name to description map |
|
||||
|
||||
### Open-Source Mode
|
||||
|
||||
All fields optional. Defaults: `text-embedding-3-small` embeddings, local SQLite vector store (`~/.mem0/vector_store.db`), `gpt-5-mini` LLM.
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| --- | ---- | ------- | ----------- |
|
||||
| `customPrompt` | `string` | *(built-in)* | Extraction prompt |
|
||||
| `oss.embedder.provider` | `string` | `"openai"` | Embedding provider |
|
||||
| `oss.embedder.config` | `object` | — | Provider config (`apiKey`, `model`, `baseURL`) |
|
||||
| `oss.vectorStore.provider` | `string` | `"memory"` | Vector store provider (see list above) |
|
||||
| `oss.vectorStore.config` | `object` | — | Provider config (`host`, `port`, `collectionName`, `dbPath`) |
|
||||
| `oss.llm.provider` | `string` | `"openai"` | LLM provider |
|
||||
| `oss.llm.config` | `object` | — | Provider config (`apiKey`, `model`, `baseURL`) |
|
||||
| `oss.historyDbPath` | `string` | — | SQLite path for edit history |
|
||||
|
||||
## Privacy & Security
|
||||
|
||||
### Data Flow
|
||||
|
||||
| Mode | Where data goes | Credentials needed |
|
||||
|------|----------------|-------------------|
|
||||
| **Platform** | Conversations sent to `api.mem0.ai` for memory extraction and retrieval | `MEM0_API_KEY` |
|
||||
| **Open-Source (OpenAI)** | LLM/embedding calls to OpenAI API; vectors stored locally at `~/.mem0/vector_store.db` | `OPENAI_API_KEY` |
|
||||
| **Open-Source (Ollama)** | Fully local — LLM, embeddings, and vectors all on your machine | None |
|
||||
|
||||
### Credential Storage
|
||||
|
||||
The plugin stores configuration in `~/.openclaw/openclaw.json`. If you use the chat setup flow or `openclaw mem0 init`, your API key and user ID are written to this file.
|
||||
|
||||
To avoid plaintext credentials:
|
||||
- Use env var references: `"apiKey": "${MEM0_API_KEY}"`
|
||||
- Use SecretRef: `"apiKey": {"source": "env", "provider": "default", "id": "MEM0_API_KEY"}`
|
||||
|
||||
### Memory Processing
|
||||
|
||||
In **skills mode** (default after `openclaw mem0 init`), the agent uses structured protocols (triage, recall, dream) to decide what to store and recall. The built-in `session-memory` hook is disabled to avoid conflicts.
|
||||
|
||||
Without skills, `autoCapture` and `autoRecall` are both enabled by default:
|
||||
- `autoCapture`: sends conversation content to your configured backend after each agent turn
|
||||
- `autoRecall`: queries your memory store before each agent turn and injects results into context
|
||||
|
||||
In platform mode, conversation content is sent to `api.mem0.ai` for processing. Do not use with sensitive data you do not want stored on Mem0 cloud.
|
||||
|
||||
### Persistence Locations
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `~/.openclaw/openclaw.json` | Plugin configuration (API keys, user ID, settings) |
|
||||
| `~/.mem0/vector_store.db` | Local vector store (open-source mode only) |
|
||||
| `~/.mem0/history.db` | Memory edit history (open-source mode only) |
|
||||
| `<pluginStateDir>/dream-state.json` | Memory consolidation state |
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0](LICENSE)
|
||||
@@ -0,0 +1,118 @@
|
||||
// Mirrored from cli/node/src/backend/base.ts — DO NOT DIVERGE
|
||||
|
||||
/**
|
||||
* Abstract backend interface and error classes.
|
||||
*/
|
||||
|
||||
export interface AddOptions {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
infer?: boolean;
|
||||
categories?: string[];
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
topK?: number;
|
||||
threshold?: number;
|
||||
rerank?: boolean;
|
||||
filters?: Record<string, unknown>;
|
||||
fields?: string[];
|
||||
}
|
||||
|
||||
export interface ListOptions {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
category?: string;
|
||||
after?: string;
|
||||
before?: string;
|
||||
}
|
||||
|
||||
export interface DeleteOptions {
|
||||
all?: boolean;
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
}
|
||||
|
||||
export interface EntityIds {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
}
|
||||
|
||||
export interface Backend {
|
||||
add(
|
||||
content?: string,
|
||||
messages?: Record<string, unknown>[],
|
||||
opts?: AddOptions,
|
||||
): Promise<Record<string, unknown>>;
|
||||
|
||||
search(
|
||||
query: string,
|
||||
opts?: SearchOptions,
|
||||
): Promise<Record<string, unknown>[]>;
|
||||
|
||||
get(memoryId: string): Promise<Record<string, unknown>>;
|
||||
|
||||
listMemories(opts?: ListOptions): Promise<Record<string, unknown>[]>;
|
||||
|
||||
update(
|
||||
memoryId: string,
|
||||
content?: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>>;
|
||||
|
||||
delete(
|
||||
memoryId?: string,
|
||||
opts?: DeleteOptions,
|
||||
): Promise<Record<string, unknown>>;
|
||||
|
||||
deleteEntities(opts: EntityIds): Promise<Record<string, unknown>>;
|
||||
|
||||
status(opts?: {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
}): Promise<Record<string, unknown>>;
|
||||
|
||||
entities(entityType: string): Promise<Record<string, unknown>[]>;
|
||||
|
||||
listEvents(): Promise<Record<string, unknown>[]>;
|
||||
|
||||
getEvent(eventId: string): Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor(
|
||||
message = "Authentication failed. Your API key may be invalid or expired.",
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AuthError";
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends Error {
|
||||
constructor(path: string) {
|
||||
super(`Resource not found: ${path}`);
|
||||
this.name = "NotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class APIError extends Error {
|
||||
constructor(path: string, detail: string) {
|
||||
super(`Bad request to ${path}: ${detail}`);
|
||||
this.name = "APIError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export { PlatformBackend } from "./platform.ts";
|
||||
export {
|
||||
type Backend,
|
||||
type AddOptions,
|
||||
type SearchOptions,
|
||||
type ListOptions,
|
||||
type DeleteOptions,
|
||||
type EntityIds,
|
||||
AuthError,
|
||||
NotFoundError,
|
||||
APIError,
|
||||
} from "./base.ts";
|
||||
@@ -0,0 +1,350 @@
|
||||
// Mirrored from cli/node/src/backend/platform.ts — DO NOT DIVERGE
|
||||
|
||||
/**
|
||||
* Platform (SaaS) backend — communicates with api.mem0.ai.
|
||||
*/
|
||||
|
||||
import { PLUGIN_VERSION } from "../telemetry.ts";
|
||||
import {
|
||||
APIError,
|
||||
type AddOptions,
|
||||
AuthError,
|
||||
type Backend,
|
||||
type DeleteOptions,
|
||||
type EntityIds,
|
||||
type ListOptions,
|
||||
NotFoundError,
|
||||
type SearchOptions,
|
||||
} from "./base.ts";
|
||||
|
||||
export class PlatformBackend implements Backend {
|
||||
private baseUrl: string;
|
||||
private headers: Record<string, string>;
|
||||
|
||||
constructor(config: { apiKey: string; baseUrl: string }) {
|
||||
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
||||
this.headers = {
|
||||
Authorization: `Token ${config.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-Mem0-Source": "OPENCLAW",
|
||||
"X-Mem0-Client-Language": "node",
|
||||
"X-Mem0-Client-Version": PLUGIN_VERSION,
|
||||
"X-Mem0-Caller-Type": "plugin",
|
||||
};
|
||||
}
|
||||
|
||||
private async _request(
|
||||
method: string,
|
||||
path: string,
|
||||
opts?: { json?: unknown; params?: Record<string, string> },
|
||||
): Promise<unknown> {
|
||||
let url = `${this.baseUrl}${path}`;
|
||||
if (opts?.params) {
|
||||
const qs = new URLSearchParams(opts.params).toString();
|
||||
url += `?${qs}`;
|
||||
}
|
||||
|
||||
const fetchOpts: RequestInit = {
|
||||
method,
|
||||
headers: this.headers,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
};
|
||||
if (opts?.json) {
|
||||
fetchOpts.body = JSON.stringify(opts.json);
|
||||
}
|
||||
|
||||
const resp = await fetch(url, fetchOpts);
|
||||
|
||||
if (resp.status === 401) {
|
||||
throw new AuthError();
|
||||
}
|
||||
if (resp.status === 404) {
|
||||
throw new NotFoundError(path);
|
||||
}
|
||||
if (resp.status === 400) {
|
||||
let detail: string;
|
||||
try {
|
||||
const body = (await resp.json()) as Record<string, unknown>;
|
||||
detail =
|
||||
((body.detail ?? body.message ?? JSON.stringify(body)) as string) ??
|
||||
resp.statusText;
|
||||
} catch {
|
||||
detail = resp.statusText;
|
||||
}
|
||||
throw new APIError(path, detail);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
let detail: string = resp.statusText;
|
||||
try {
|
||||
const body = (await resp.json()) as Record<string, unknown>;
|
||||
detail = (body.detail ?? body.message ?? resp.statusText) as string;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(`HTTP ${resp.status}: ${detail}`);
|
||||
}
|
||||
if (resp.status === 204) {
|
||||
return {};
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async add(
|
||||
content?: string,
|
||||
messages?: Record<string, unknown>[],
|
||||
opts: AddOptions = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const payload: Record<string, unknown> = {};
|
||||
|
||||
if (messages) {
|
||||
payload.messages = messages;
|
||||
} else if (content) {
|
||||
payload.messages = [{ role: "user", content }];
|
||||
}
|
||||
|
||||
if (opts.userId) payload.user_id = opts.userId;
|
||||
if (opts.agentId) payload.agent_id = opts.agentId;
|
||||
if (opts.appId) payload.app_id = opts.appId;
|
||||
if (opts.runId) payload.run_id = opts.runId;
|
||||
if (opts.metadata) payload.metadata = opts.metadata;
|
||||
if (opts.infer === false) payload.infer = false;
|
||||
if (opts.categories) payload.categories = opts.categories;
|
||||
|
||||
return (await this._request("POST", "/v1/memories/", {
|
||||
json: payload,
|
||||
})) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
private _buildFilters(opts: {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
appId?: string;
|
||||
runId?: string;
|
||||
extraFilters?: Record<string, unknown>;
|
||||
}): Record<string, unknown> | undefined {
|
||||
// If caller passed a pre-built filter structure, use it directly
|
||||
if (
|
||||
opts.extraFilters &&
|
||||
("AND" in opts.extraFilters || "OR" in opts.extraFilters)
|
||||
) {
|
||||
return opts.extraFilters;
|
||||
}
|
||||
|
||||
const andConditions: Record<string, unknown>[] = [];
|
||||
if (opts.userId) andConditions.push({ user_id: opts.userId });
|
||||
if (opts.agentId) andConditions.push({ agent_id: opts.agentId });
|
||||
if (opts.appId) andConditions.push({ app_id: opts.appId });
|
||||
if (opts.runId) andConditions.push({ run_id: opts.runId });
|
||||
|
||||
if (opts.extraFilters) {
|
||||
for (const [k, v] of Object.entries(opts.extraFilters)) {
|
||||
andConditions.push({ [k]: v });
|
||||
}
|
||||
}
|
||||
|
||||
if (andConditions.length === 1) return andConditions[0];
|
||||
if (andConditions.length > 1) return { AND: andConditions };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async search(
|
||||
query: string,
|
||||
opts: SearchOptions = {},
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const payload: Record<string, unknown> = {
|
||||
query,
|
||||
top_k: opts.topK ?? 10,
|
||||
threshold: opts.threshold ?? 0.3,
|
||||
};
|
||||
|
||||
const apiFilters = this._buildFilters({
|
||||
userId: opts.userId,
|
||||
agentId: opts.agentId,
|
||||
appId: opts.appId,
|
||||
runId: opts.runId,
|
||||
extraFilters: opts.filters,
|
||||
});
|
||||
if (apiFilters) payload.filters = apiFilters;
|
||||
if (opts.rerank) payload.rerank = true;
|
||||
if (opts.fields) payload.fields = opts.fields;
|
||||
|
||||
const result = (await this._request("POST", "/v2/memories/search/", {
|
||||
json: payload,
|
||||
})) as unknown;
|
||||
if (Array.isArray(result)) return result;
|
||||
const obj = result as Record<string, unknown>;
|
||||
return (obj.results ?? obj.memories ?? []) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
async get(memoryId: string): Promise<Record<string, unknown>> {
|
||||
return (await this._request("GET", `/v1/memories/${memoryId}/`)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
|
||||
async listMemories(
|
||||
opts: ListOptions = {},
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const payload: Record<string, unknown> = {};
|
||||
const params: Record<string, string> = {
|
||||
page: String(opts.page ?? 1),
|
||||
page_size: String(opts.pageSize ?? 100),
|
||||
};
|
||||
|
||||
const extra: Record<string, unknown> = {};
|
||||
if (opts.category) {
|
||||
extra.categories = { contains: opts.category };
|
||||
}
|
||||
if (opts.after) {
|
||||
extra.created_at = {
|
||||
...(extra.created_at as Record<string, unknown> | undefined),
|
||||
gte: opts.after,
|
||||
};
|
||||
}
|
||||
if (opts.before) {
|
||||
extra.created_at = {
|
||||
...(extra.created_at as Record<string, unknown> | undefined),
|
||||
lte: opts.before,
|
||||
};
|
||||
}
|
||||
|
||||
const apiFilters = this._buildFilters({
|
||||
userId: opts.userId,
|
||||
agentId: opts.agentId,
|
||||
appId: opts.appId,
|
||||
runId: opts.runId,
|
||||
extraFilters: Object.keys(extra).length > 0 ? extra : undefined,
|
||||
});
|
||||
if (apiFilters) payload.filters = apiFilters;
|
||||
|
||||
const result = (await this._request("POST", "/v2/memories/", {
|
||||
json: payload,
|
||||
params,
|
||||
})) as unknown;
|
||||
if (Array.isArray(result)) return result;
|
||||
const obj = result as Record<string, unknown>;
|
||||
return (obj.results ?? obj.memories ?? []) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
async update(
|
||||
memoryId: string,
|
||||
content?: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (content) payload.text = content;
|
||||
if (metadata) payload.metadata = metadata;
|
||||
return (await this._request("PUT", `/v1/memories/${memoryId}/`, {
|
||||
json: payload,
|
||||
})) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async delete(
|
||||
memoryId?: string,
|
||||
opts: DeleteOptions = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (opts.all) {
|
||||
const params: Record<string, string> = {};
|
||||
if (opts.userId) params.user_id = opts.userId;
|
||||
if (opts.agentId) params.agent_id = opts.agentId;
|
||||
if (opts.appId) params.app_id = opts.appId;
|
||||
if (opts.runId) params.run_id = opts.runId;
|
||||
return (await this._request("DELETE", "/v1/memories/", {
|
||||
params,
|
||||
})) as Record<string, unknown>;
|
||||
}
|
||||
if (memoryId) {
|
||||
return (await this._request(
|
||||
"DELETE",
|
||||
`/v1/memories/${memoryId}/`,
|
||||
)) as Record<string, unknown>;
|
||||
}
|
||||
throw new Error("Either memoryId or --all is required");
|
||||
}
|
||||
|
||||
async deleteEntities(opts: EntityIds): Promise<Record<string, unknown>> {
|
||||
// v2 endpoint: DELETE /v2/entities/{entity_type}/{entity_id}/
|
||||
const typeMap: [string, string | undefined][] = [
|
||||
["user", opts.userId],
|
||||
["agent", opts.agentId],
|
||||
["app", opts.appId],
|
||||
["run", opts.runId],
|
||||
];
|
||||
const entities = typeMap.filter(([, v]) => v) as [string, string][];
|
||||
if (entities.length === 0) {
|
||||
throw new Error("At least one entity ID is required for deleteEntities.");
|
||||
}
|
||||
// Delete each provided entity via the v2 path-based endpoint
|
||||
let result: Record<string, unknown> = {};
|
||||
for (const [entityType, entityId] of entities) {
|
||||
result = (await this._request(
|
||||
"DELETE",
|
||||
`/v2/entities/${entityType}/${entityId}/`,
|
||||
)) as Record<string, unknown>;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async ping(): Promise<Record<string, unknown>> {
|
||||
return (await this._request("GET", "/v1/ping/")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async status(
|
||||
_opts: { userId?: string; agentId?: string } = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
await this.ping();
|
||||
return { connected: true, backend: "platform", base_url: this.baseUrl };
|
||||
} catch (e) {
|
||||
return {
|
||||
connected: false,
|
||||
backend: "platform",
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async entities(entityType: string): Promise<Record<string, unknown>[]> {
|
||||
const result = (await this._request("GET", "/v1/entities/")) as unknown;
|
||||
let items: Record<string, unknown>[];
|
||||
if (Array.isArray(result)) {
|
||||
items = result;
|
||||
} else {
|
||||
items = ((result as Record<string, unknown>).results ?? []) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
}
|
||||
|
||||
const typeMap: Record<string, string> = {
|
||||
users: "user",
|
||||
agents: "agent",
|
||||
apps: "app",
|
||||
runs: "run",
|
||||
};
|
||||
const targetType = typeMap[entityType];
|
||||
if (targetType) {
|
||||
items = items.filter(
|
||||
(e) => (e.type as string | undefined)?.toLowerCase() === targetType,
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
async listEvents(): Promise<Record<string, unknown>[]> {
|
||||
const result = (await this._request("GET", "/v1/events/")) as unknown;
|
||||
if (Array.isArray(result)) return result;
|
||||
return ((result as Record<string, unknown>).results ?? []) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
}
|
||||
|
||||
async getEvent(eventId: string): Promise<Record<string, unknown>> {
|
||||
return (await this._request("GET", `/v1/event/${eventId}/`)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* File-based config helpers for the OpenClaw Mem0 plugin.
|
||||
*
|
||||
* Plugin auth and settings are stored in ~/.openclaw/openclaw.json under
|
||||
* plugins.entries.openclaw-mem0.config — the single source of truth.
|
||||
*
|
||||
* Uses fs-safe.ts for all filesystem operations to pass the OpenClaw
|
||||
* code_safety scanner.
|
||||
*/
|
||||
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { readText, exists, writeText, mkdirp } from "../fs-safe.ts";
|
||||
|
||||
// OpenClaw config — source of truth for plugin settings
|
||||
export const OPENCLAW_CONFIG_DIR = join(homedir(), ".openclaw");
|
||||
export const OPENCLAW_CONFIG_FILE = join(OPENCLAW_CONFIG_DIR, "openclaw.json");
|
||||
|
||||
export const DEFAULT_BASE_URL = "https://api.mem0.ai";
|
||||
|
||||
const PLUGIN_ID = "openclaw-mem0";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/** Fields stored in the plugin config section of openclaw.json */
|
||||
export interface PluginAuthConfig {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
userId?: string;
|
||||
userEmail?: string;
|
||||
mode?: string;
|
||||
autoRecall?: boolean;
|
||||
autoCapture?: boolean;
|
||||
topK?: number;
|
||||
anonymousTelemetryId?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OpenClaw config read/write
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Read the full ~/.openclaw/openclaw.json.
|
||||
*
|
||||
* Returns {} only when the file doesn't exist (first-time setup).
|
||||
* Throws on parse errors to prevent writes from destroying existing config.
|
||||
*/
|
||||
function readFullConfig(): Record<string, unknown> {
|
||||
if (!exists(OPENCLAW_CONFIG_FILE)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const text = readText(OPENCLAW_CONFIG_FILE);
|
||||
|
||||
// Handle empty or whitespace-only files as first-time setup
|
||||
if (!text.trim()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Config is not a JSON object");
|
||||
}
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
// Fail closed: throw so writes don't proceed with empty config
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`[openclaw-mem0] Failed to parse ${OPENCLAW_CONFIG_FILE}: ${msg}\n` +
|
||||
`Fix the JSON syntax error manually before running config commands.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the full ~/.openclaw/openclaw.json.
|
||||
*
|
||||
* Re-reads the file immediately before writing and deep-merges the
|
||||
* `plugins` section so that fields written by other processes (e.g.
|
||||
* OpenClaw gateway adding `installs`, `slots`) are not clobbered.
|
||||
*/
|
||||
function writeFullConfig(config: Record<string, unknown>): void {
|
||||
if (!exists(OPENCLAW_CONFIG_DIR)) {
|
||||
mkdirp(OPENCLAW_CONFIG_DIR, 0o700);
|
||||
}
|
||||
|
||||
if (exists(OPENCLAW_CONFIG_FILE)) {
|
||||
try {
|
||||
const diskText = readText(OPENCLAW_CONFIG_FILE);
|
||||
if (diskText.trim()) {
|
||||
const disk = JSON.parse(diskText) as Record<string, unknown>;
|
||||
const diskPlugins = disk.plugins as Record<string, unknown> | undefined;
|
||||
const ourPlugins = config.plugins as Record<string, unknown> | undefined;
|
||||
if (diskPlugins && ourPlugins) {
|
||||
const OPENCLAW_MANAGED = ["installs", "slots"];
|
||||
for (const key of OPENCLAW_MANAGED) {
|
||||
if (key in diskPlugins) {
|
||||
ourPlugins[key] = diskPlugins[key];
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(diskPlugins)) {
|
||||
if (!(key in ourPlugins)) {
|
||||
ourPlugins[key] = diskPlugins[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// disk unreadable — write our version as-is
|
||||
}
|
||||
}
|
||||
|
||||
writeText(
|
||||
OPENCLAW_CONFIG_FILE,
|
||||
JSON.stringify(config, null, 2),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Read plugin auth/identity config from openclaw.json's plugin section */
|
||||
export function readPluginAuth(): PluginAuthConfig {
|
||||
const full = readFullConfig() as any;
|
||||
const cfg = full?.plugins?.entries?.[PLUGIN_ID]?.config;
|
||||
if (!cfg || typeof cfg !== "object") return {};
|
||||
return {
|
||||
apiKey: (cfg.apiKey ?? cfg.api_key) as string | undefined,
|
||||
baseUrl: (cfg.baseUrl ?? cfg.base_url) as string | undefined,
|
||||
userId: (cfg.userId ?? cfg.user_id) as string | undefined,
|
||||
userEmail: (cfg.userEmail ?? cfg.user_email) as string | undefined,
|
||||
mode: cfg.mode as string | undefined,
|
||||
autoRecall: cfg.autoRecall as boolean | undefined,
|
||||
autoCapture: cfg.autoCapture as boolean | undefined,
|
||||
topK: cfg.topK as number | undefined,
|
||||
anonymousTelemetryId: cfg.anonymousTelemetryId as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Write auth/identity fields into the plugin section of openclaw.json */
|
||||
export function writePluginAuth(auth: PluginAuthConfig): void {
|
||||
const full = readFullConfig() as any;
|
||||
|
||||
ensurePluginStructure(full);
|
||||
|
||||
const cfg = full.plugins.entries[PLUGIN_ID].config;
|
||||
|
||||
// Write all defined fields into the config section
|
||||
for (const [key, value] of Object.entries(auth)) {
|
||||
if (value !== undefined) cfg[key] = value;
|
||||
}
|
||||
|
||||
writeFullConfig(full);
|
||||
}
|
||||
|
||||
|
||||
/** Ensure the nested plugin entry structure exists in the config object. */
|
||||
function ensurePluginStructure(full: any): void {
|
||||
if (!full.plugins) full.plugins = {};
|
||||
if (!full.plugins.entries) full.plugins.entries = {};
|
||||
if (!full.plugins.entries[PLUGIN_ID]) {
|
||||
full.plugins.entries[PLUGIN_ID] = { enabled: true, config: {} };
|
||||
}
|
||||
if (!full.plugins.entries[PLUGIN_ID].config) {
|
||||
full.plugins.entries[PLUGIN_ID].config = {};
|
||||
}
|
||||
}
|
||||
|
||||
export function writePluginConfigField(
|
||||
path: string[],
|
||||
value: unknown,
|
||||
): void {
|
||||
const full = readFullConfig() as any;
|
||||
|
||||
ensurePluginStructure(full);
|
||||
|
||||
let target = full.plugins.entries[PLUGIN_ID].config;
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
if (!target[path[i]] || typeof target[path[i]] !== "object") {
|
||||
target[path[i]] = {};
|
||||
}
|
||||
target = target[path[i]];
|
||||
}
|
||||
target[path[path.length - 1]] = value;
|
||||
|
||||
writeFullConfig(full);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default skills configuration — matches configure.py output.
|
||||
* Enables triage, recall (with reranking), and dream consolidation.
|
||||
*/
|
||||
const DEFAULT_SKILLS_CONFIG = {
|
||||
triage: { enabled: true },
|
||||
recall: {
|
||||
enabled: true,
|
||||
tokenBudget: 1500,
|
||||
rerank: true,
|
||||
keywordSearch: true,
|
||||
identityAlwaysInclude: true,
|
||||
},
|
||||
dream: { enabled: true },
|
||||
domain: "companion",
|
||||
};
|
||||
|
||||
/**
|
||||
* Enable skills-mode config after onboarding.
|
||||
*
|
||||
* Sets skills config on the plugin entry, tools.profile = "full",
|
||||
* and disables the built-in session-memory hook to avoid conflicts.
|
||||
* Preserves any existing skills config if already set.
|
||||
*/
|
||||
export function enableSkillsConfig(userId: string): void {
|
||||
const full = readFullConfig() as any;
|
||||
ensurePluginStructure(full);
|
||||
|
||||
const cfg = full.plugins.entries[PLUGIN_ID].config;
|
||||
if (!cfg.skills) {
|
||||
cfg.skills = { ...DEFAULT_SKILLS_CONFIG };
|
||||
}
|
||||
|
||||
if (!full.tools) full.tools = {};
|
||||
full.tools.profile = "full";
|
||||
|
||||
if (!full.hooks) full.hooks = {};
|
||||
if (!full.hooks.internal) full.hooks.internal = {};
|
||||
if (!full.hooks.internal.entries) full.hooks.internal.entries = {};
|
||||
full.hooks.internal.entries["session-memory"] = { enabled: false };
|
||||
|
||||
writeFullConfig(full);
|
||||
}
|
||||
|
||||
/** Get the configured base URL from openclaw.json or default */
|
||||
export function getBaseUrl(): string {
|
||||
const auth = readPluginAuth();
|
||||
return auth.baseUrl || DEFAULT_BASE_URL;
|
||||
}
|
||||
|
||||
/** Remove anonymousTelemetryId from config (after PostHog aliasing) */
|
||||
export function clearAnonymousTelemetryId(): void {
|
||||
const full = readFullConfig() as any;
|
||||
const cfg = full?.plugins?.entries?.[PLUGIN_ID]?.config;
|
||||
if (cfg && "anonymousTelemetryId" in cfg) {
|
||||
delete cfg.anonymousTelemetryId;
|
||||
writeFullConfig(full);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* JSON output helpers for agent-friendly CLI commands.
|
||||
*/
|
||||
|
||||
function writeStdout(data: Record<string, unknown>): void {
|
||||
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
||||
}
|
||||
|
||||
export function jsonOut(
|
||||
opts: { json?: boolean },
|
||||
data: Record<string, unknown>,
|
||||
): boolean {
|
||||
if (!opts.json) return false;
|
||||
writeStdout(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function jsonErr(
|
||||
opts: { json?: boolean },
|
||||
error: string,
|
||||
): boolean {
|
||||
if (!opts.json) return false;
|
||||
writeStdout({ ok: false, error });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function redactSecrets(
|
||||
obj: Record<string, unknown>,
|
||||
secretKeys: Set<string>,
|
||||
): Record<string, unknown> {
|
||||
const result = { ...obj };
|
||||
for (const key of secretKeys) {
|
||||
const val = result[key];
|
||||
if (typeof val !== "string") continue;
|
||||
result[key] = val.length <= 8
|
||||
? val.slice(0, 2) + "***"
|
||||
: val.slice(0, 4) + "..." + val.slice(-4);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* OSS provider wizard — provider definitions, config builders, and validation.
|
||||
*
|
||||
* Used by the init command for both interactive wizard and non-interactive
|
||||
* --oss-* flag paths.
|
||||
*/
|
||||
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
// ============================================================================
|
||||
// Provider definitions
|
||||
// ============================================================================
|
||||
|
||||
export interface ProviderDef {
|
||||
id: string;
|
||||
label: string;
|
||||
needsApiKey: boolean;
|
||||
needsUrl: boolean;
|
||||
envVar?: string;
|
||||
defaultModel: string;
|
||||
defaultUrl?: string;
|
||||
}
|
||||
|
||||
export const LLM_PROVIDERS: ProviderDef[] = [
|
||||
{ id: "openai", label: "OpenAI (requires API key)", needsApiKey: true, needsUrl: false, envVar: "OPENAI_API_KEY", defaultModel: "gpt-5-mini" },
|
||||
{ id: "ollama", label: "Ollama (local, no API key)", needsApiKey: false, needsUrl: true, defaultModel: "llama3.1:8b", defaultUrl: "http://localhost:11434" },
|
||||
{ id: "anthropic", label: "Anthropic (requires API key)", needsApiKey: true, needsUrl: false, envVar: "ANTHROPIC_API_KEY", defaultModel: "claude-sonnet-4-5-20250514" },
|
||||
];
|
||||
|
||||
export interface EmbedderDef extends ProviderDef {
|
||||
defaultDims: number;
|
||||
}
|
||||
|
||||
export const EMBEDDER_PROVIDERS: EmbedderDef[] = [
|
||||
{ id: "openai", label: "OpenAI (requires API key)", needsApiKey: true, needsUrl: false, envVar: "OPENAI_API_KEY", defaultModel: "text-embedding-3-small", defaultDims: 1536 },
|
||||
{ id: "ollama", label: "Ollama (local, no API key)", needsApiKey: false, needsUrl: true, defaultModel: "nomic-embed-text", defaultUrl: "http://localhost:11434", defaultDims: 768 },
|
||||
];
|
||||
|
||||
export interface VectorDef {
|
||||
id: string;
|
||||
label: string;
|
||||
needsConnection: boolean;
|
||||
defaultUrl?: string;
|
||||
defaultPort?: number;
|
||||
setupHint?: string;
|
||||
}
|
||||
|
||||
export const VECTOR_PROVIDERS: VectorDef[] = [
|
||||
{ id: "qdrant", label: "Qdrant (requires server — Docker or cloud)", needsConnection: true, defaultUrl: "http://localhost:6333", defaultPort: 6333, setupHint: "docker run -d -p 6333:6333 qdrant/qdrant" },
|
||||
{ id: "pgvector", label: "PGVector (requires PostgreSQL + pgvector extension)", needsConnection: true, defaultPort: 5432, setupHint: "docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres pgvector/pgvector:pg17" },
|
||||
];
|
||||
|
||||
export const KNOWN_EMBEDDER_DIMS: Record<string, number> = {
|
||||
"text-embedding-3-small": 1536,
|
||||
"text-embedding-3-large": 3072,
|
||||
"text-embedding-ada-002": 1536,
|
||||
"nomic-embed-text": 768,
|
||||
"mxbai-embed-large": 1024,
|
||||
"all-minilm": 384,
|
||||
"snowflake-arctic-embed": 1024,
|
||||
};
|
||||
|
||||
export function collectionNameForDims(dims: number): string {
|
||||
return `mem0_${dims}d`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Config builders
|
||||
// ============================================================================
|
||||
|
||||
export interface LlmConfigInput {
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export function buildOssLlmConfig(
|
||||
providerId: string,
|
||||
input: LlmConfigInput,
|
||||
): { provider: string; config: Record<string, unknown> } {
|
||||
const def = LLM_PROVIDERS.find((p) => p.id === providerId);
|
||||
if (!def) throw new Error(`Unknown LLM provider: ${providerId}`);
|
||||
|
||||
const config: Record<string, unknown> = {
|
||||
model: input.model || def.defaultModel,
|
||||
};
|
||||
if (input.apiKey) config.apiKey = input.apiKey;
|
||||
if (providerId === "ollama") {
|
||||
config.url = input.url || def.defaultUrl;
|
||||
}
|
||||
return { provider: providerId, config };
|
||||
}
|
||||
|
||||
export interface EmbedderConfigInput {
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export function buildOssEmbedderConfig(
|
||||
providerId: string,
|
||||
input: EmbedderConfigInput,
|
||||
): { provider: string; config: Record<string, unknown>; dims: number | undefined } {
|
||||
const def = EMBEDDER_PROVIDERS.find((p) => p.id === providerId);
|
||||
if (!def) throw new Error(`Unknown embedder provider: ${providerId}`);
|
||||
|
||||
const model = input.model || def.defaultModel;
|
||||
const config: Record<string, unknown> = { model };
|
||||
if (input.apiKey) config.apiKey = input.apiKey;
|
||||
if (providerId === "ollama") {
|
||||
config.url = input.url || def.defaultUrl;
|
||||
}
|
||||
|
||||
const dims = KNOWN_EMBEDDER_DIMS[model] ?? def.defaultDims;
|
||||
if (dims) config.embeddingDims = dims;
|
||||
return { provider: providerId, config, dims };
|
||||
}
|
||||
|
||||
export interface VectorConfigInput {
|
||||
url?: string;
|
||||
host?: string;
|
||||
port?: string;
|
||||
user?: string;
|
||||
password?: string;
|
||||
dbname?: string;
|
||||
apiKey?: string;
|
||||
dims?: number;
|
||||
}
|
||||
|
||||
export function buildOssVectorConfig(
|
||||
providerId: string,
|
||||
input: VectorConfigInput,
|
||||
): { provider: string; config: Record<string, unknown> } {
|
||||
const config: Record<string, unknown> = {};
|
||||
|
||||
if (providerId === "qdrant") {
|
||||
config.url = input.url || "http://localhost:6333";
|
||||
config.onDisk = true;
|
||||
if (input.apiKey) config.apiKey = input.apiKey;
|
||||
} else if (providerId === "pgvector") {
|
||||
config.host = input.host || "localhost";
|
||||
config.port = parseInt(input.port || "5432", 10);
|
||||
if (input.user) config.user = input.user;
|
||||
if (input.password) config.password = input.password;
|
||||
config.dbname = input.dbname || "postgres";
|
||||
}
|
||||
|
||||
if (input.dims) {
|
||||
config.dimension = input.dims;
|
||||
config.embeddingModelDims = input.dims;
|
||||
config.collectionName = collectionNameForDims(input.dims);
|
||||
}
|
||||
return { provider: providerId, config };
|
||||
}
|
||||
|
||||
export async function checkOllamaConnectivity(url: string): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
const resp = await fetch(`${url.replace(/\/+$/, "")}/api/tags`, { signal: AbortSignal.timeout(3000) });
|
||||
if (resp.ok) return { ok: true };
|
||||
return { ok: false, error: `Ollama returned HTTP ${resp.status}` };
|
||||
} catch {
|
||||
return { ok: false, error: `Cannot reach Ollama at ${url}. Install: https://ollama.com/download` };
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkPgConnectivity(host: string, port: number): Promise<{ ok: boolean; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
import("node:net").then(({ createConnection }) => {
|
||||
const sock = createConnection({ host, port, timeout: 3000 });
|
||||
sock.once("connect", () => { sock.destroy(); resolve({ ok: true }); });
|
||||
sock.once("timeout", () => { sock.destroy(); resolve({ ok: false, error: `PostgreSQL not reachable at ${host}:${port}` }); });
|
||||
sock.once("error", () => { sock.destroy(); resolve({ ok: false, error: `PostgreSQL not reachable at ${host}:${port}. Ensure PostgreSQL with pgvector extension is running.` }); });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkQdrantConnectivity(url: string): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
const resp = await fetch(`${url.replace(/\/+$/, "")}/healthz`, { signal: AbortSignal.timeout(3000) });
|
||||
if (resp.ok) return { ok: true };
|
||||
return { ok: false, error: `Qdrant returned HTTP ${resp.status}` };
|
||||
} catch (err) {
|
||||
return { ok: false, error: `Cannot reach Qdrant at ${url}. Start it with: docker run -d -p 6333:6333 qdrant/qdrant` };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Non-interactive flag validation
|
||||
// ============================================================================
|
||||
|
||||
export interface OssFlags {
|
||||
ossLlm?: string;
|
||||
ossLlmKey?: string;
|
||||
ossLlmModel?: string;
|
||||
ossLlmUrl?: string;
|
||||
ossEmbedder?: string;
|
||||
ossEmbedderKey?: string;
|
||||
ossEmbedderModel?: string;
|
||||
ossEmbedderUrl?: string;
|
||||
ossVector?: string;
|
||||
ossVectorUrl?: string;
|
||||
ossVectorHost?: string;
|
||||
ossVectorPort?: string;
|
||||
ossVectorUser?: string;
|
||||
ossVectorPassword?: string;
|
||||
ossVectorDbname?: string;
|
||||
ossVectorDims?: string;
|
||||
}
|
||||
|
||||
export function validateOssFlags(
|
||||
flags: OssFlags,
|
||||
): { error?: string } {
|
||||
const llmId = flags.ossLlm || "openai";
|
||||
const llmDef = LLM_PROVIDERS.find((p) => p.id === llmId);
|
||||
if (!llmDef) return { error: `Unknown LLM provider: ${llmId}. Valid: ${LLM_PROVIDERS.map((p) => p.id).join(", ")}` };
|
||||
|
||||
if (llmDef.needsApiKey && !flags.ossLlmKey) {
|
||||
return { error: `--oss-llm-key required when --oss-llm is ${llmId}` };
|
||||
}
|
||||
|
||||
const embId = flags.ossEmbedder || "openai";
|
||||
const embDef = EMBEDDER_PROVIDERS.find((p) => p.id === embId);
|
||||
if (!embDef) return { error: `Unknown embedder provider: ${embId}. Valid: ${EMBEDDER_PROVIDERS.map((p) => p.id).join(", ")}` };
|
||||
|
||||
if (embDef.needsApiKey && !flags.ossEmbedderKey && !flags.ossLlmKey) {
|
||||
return { error: `--oss-embedder-key required when --oss-embedder is ${embId}` };
|
||||
}
|
||||
|
||||
const vecId = flags.ossVector || "qdrant";
|
||||
const vecDef = VECTOR_PROVIDERS.find((p) => p.id === vecId);
|
||||
if (!vecDef) return { error: `Unknown vector store provider: ${vecId}. Valid: ${VECTOR_PROVIDERS.map((p) => p.id).join(", ")}` };
|
||||
|
||||
if (vecId === "pgvector" && !flags.ossVectorUser) {
|
||||
return { error: "--oss-vector-user required when --oss-vector is pgvector" };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Configuration parsing and default instructions/categories.
|
||||
*
|
||||
* NOTE: This module must NOT import from `node:fs` or `node:fs/promises`.
|
||||
* All filesystem operations are centralized in fs-safe.ts.
|
||||
*/
|
||||
|
||||
import { userInfo } from "node:os";
|
||||
import type { Mem0Config, Mem0Mode } from "./types.ts";
|
||||
|
||||
// NOTE: The gateway resolves ${VAR} syntax in openclaw.json before passing
|
||||
// pluginConfig to register(). No plugin-side variable resolution needed.
|
||||
|
||||
// ============================================================================
|
||||
// Login config fallback type — read from openclaw.json plugin section
|
||||
// ============================================================================
|
||||
|
||||
/** Shape accepted by parse() for the openclaw.json plugin auth fallback. */
|
||||
export interface FileConfig {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default Custom Instructions & Categories
|
||||
// ============================================================================
|
||||
|
||||
export const DEFAULT_CUSTOM_INSTRUCTIONS = `Your Task: Extract durable, actionable facts from conversations between a user and an AI assistant. Only store information that would be useful to an agent in a FUTURE session, days or weeks later.
|
||||
|
||||
Before storing any fact, ask: "Would a new agent — with no prior context — benefit from knowing this?" If the answer is no, do not store it.
|
||||
|
||||
Information to Extract (in priority order):
|
||||
|
||||
1. Configuration & System State Changes:
|
||||
- Tools/services configured, installed, or removed (with versions/dates)
|
||||
- Model assignments for agents, API keys configured (NEVER the key itself — see Exclude)
|
||||
- Cron schedules, automation pipelines, deployment configurations
|
||||
- Architecture decisions (agent hierarchy, system design, deployment strategy)
|
||||
- Specific identifiers: file paths, sheet IDs, channel IDs, user IDs, folder IDs
|
||||
|
||||
2. Standing Rules & Policies:
|
||||
- Explicit user directives about behavior ("never create accounts without consent")
|
||||
- Workflow policies ("each agent must review model selection before completing a task")
|
||||
- Security constraints, permission boundaries, access patterns
|
||||
|
||||
3. Identity & Demographics:
|
||||
- Name, location, timezone, language preferences
|
||||
- Occupation, employer, job role, industry
|
||||
|
||||
4. Preferences & Opinions:
|
||||
- Communication style preferences
|
||||
- Tool and technology preferences (with specifics: versions, configs)
|
||||
- Strong opinions or values explicitly stated
|
||||
- The WHY behind preferences when stated
|
||||
|
||||
5. Goals, Projects & Milestones:
|
||||
- Active projects (name, description, current status)
|
||||
- Completed setup milestones ("ElevenLabs fully configured as of 2026-02-20")
|
||||
- Deadlines, roadmaps, and progress tracking
|
||||
- Problems actively being solved
|
||||
|
||||
6. Technical Context:
|
||||
- Tech stack, tools, development environment
|
||||
- Agent ecosystem structure (names, roles, relationships)
|
||||
- Skill levels in different areas
|
||||
|
||||
7. Relationships & People:
|
||||
- Names and roles of people mentioned (colleagues, family, clients)
|
||||
- Team structure, key contacts
|
||||
|
||||
8. Decisions & Lessons:
|
||||
- Important decisions made and their reasoning
|
||||
- Lessons learned, strategies that worked or failed
|
||||
|
||||
Guidelines:
|
||||
|
||||
TEMPORAL ANCHORING (critical):
|
||||
- ALWAYS include temporal context for time-sensitive facts using "As of YYYY-MM-DD, ..."
|
||||
- Extract dates from message timestamps, dates mentioned in the text, or the system-provided current date
|
||||
- If no date is available, note "date unknown" rather than omitting temporal context
|
||||
- Examples: "As of 2026-02-20, ElevenLabs setup is complete" NOT "ElevenLabs setup is complete"
|
||||
|
||||
CONCISENESS:
|
||||
- Use third person ("User prefers..." not "I prefer...")
|
||||
- Keep related facts together in a single memory to preserve context
|
||||
- "User's Tailscale machine 'mac' (IP 100.71.135.41) is configured under beau@rizedigital.io (as of 2026-02-20)"
|
||||
- NOT a paragraph retelling the whole conversation
|
||||
|
||||
OUTCOMES OVER INTENT:
|
||||
- When an assistant message summarizes completed work, extract the durable OUTCOMES
|
||||
- "Call scripts sheet (ID: 146Qbb...) was updated with truth-based templates" NOT "User wants to update call scripts"
|
||||
- Extract what WAS DONE, not what was requested
|
||||
|
||||
DEDUPLICATION:
|
||||
- Before creating a new memory, check if a substantially similar fact already exists
|
||||
- If so, UPDATE the existing memory with any new details rather than creating a duplicate
|
||||
|
||||
LANGUAGE:
|
||||
- ALWAYS preserve the original language of the conversation
|
||||
- If the user speaks Spanish, store the memory in Spanish; do not translate
|
||||
|
||||
Exclude (NEVER store):
|
||||
- Passwords, API keys, tokens, secrets, or any credentials — even when embedded in configuration blocks, setup logs, or tool output. This includes strings starting with sk-, m0-, ak_, ghp_, bot tokens (digits followed by colon and alphanumeric string), bearer tokens, webhook URLs containing tokens, pairing codes, and any long alphanumeric strings that appear in config/env contexts. Never include the actual secret value in a memory. Instead, record that the credential was configured:
|
||||
WRONG: "User's API key is sk-abc123..." or "Bot token is 12345:AABcd..."
|
||||
RIGHT: "API key was configured for the service (as of YYYY-MM-DD)" or "Telegram bot token was set up"
|
||||
- One-time commands or instructions ("stop the script", "continue where you left off")
|
||||
- Acknowledgments or emotional reactions ("ok", "sounds good", "you're right", "sir")
|
||||
- Transient UI/navigation states ("user is in the admin panel", "relay is attached")
|
||||
- Ephemeral process status ("download at 50%", "daemon not running", "still syncing")
|
||||
- Cron heartbeat outputs, NO_REPLY responses, compaction flush directives
|
||||
- The current date/time as a standalone fact — timestamps are conversation context, not durable knowledge. "User indicates current time is 3:25 PM" is NEVER worth storing. However, DO use timestamps to anchor other facts: "User installed Ollama on 2026-03-21" is correct.
|
||||
- System routing metadata (message IDs, sender IDs, channel routing info)
|
||||
- Generic small talk with no informational content
|
||||
- Raw code snippets (capture the intent/decision, not the code itself)
|
||||
- Information the user explicitly asks not to remember`;
|
||||
|
||||
export const DEFAULT_CUSTOM_CATEGORIES: Record<string, string> = {
|
||||
identity:
|
||||
"Personal identity information: name, age, location, timezone, occupation, employer, education, demographics",
|
||||
preferences:
|
||||
"Explicitly stated likes, dislikes, preferences, opinions, and values across any domain",
|
||||
goals:
|
||||
"Current and future goals, aspirations, objectives, targets the user is working toward",
|
||||
projects:
|
||||
"Specific projects, initiatives, or endeavors the user is working on, including status and details",
|
||||
technical:
|
||||
"Technical skills, tools, tech stack, development environment, programming languages, frameworks",
|
||||
decisions:
|
||||
"Important decisions made, reasoning behind choices, strategy changes, and their outcomes",
|
||||
relationships:
|
||||
"People mentioned by the user: colleagues, family, friends, their roles and relevance",
|
||||
routines:
|
||||
"Daily habits, work patterns, schedules, productivity routines, health and wellness habits",
|
||||
life_events:
|
||||
"Significant life events, milestones, transitions, upcoming plans and changes",
|
||||
lessons:
|
||||
"Lessons learned, insights gained, mistakes acknowledged, changed opinions or beliefs",
|
||||
work: "Work-related context: job responsibilities, workplace dynamics, career progression, professional challenges",
|
||||
health:
|
||||
"Health-related information voluntarily shared: conditions, medications, fitness, wellness goals",
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Config Schema
|
||||
// ============================================================================
|
||||
|
||||
const ALLOWED_KEYS = [
|
||||
"mode",
|
||||
"apiKey",
|
||||
"anonymousTelemetryId",
|
||||
"baseUrl",
|
||||
"userId",
|
||||
"userEmail",
|
||||
"autoCapture",
|
||||
"autoRecall",
|
||||
"customInstructions",
|
||||
"customCategories",
|
||||
"customPrompt",
|
||||
"searchThreshold",
|
||||
"topK",
|
||||
"oss",
|
||||
"skills",
|
||||
];
|
||||
|
||||
function assertAllowedKeys(
|
||||
value: Record<string, unknown>,
|
||||
allowed: string[],
|
||||
label: string,
|
||||
) {
|
||||
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
||||
if (unknown.length === 0) return;
|
||||
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
|
||||
}
|
||||
|
||||
export const mem0ConfigSchema = {
|
||||
parse(value: unknown, fileConfig?: FileConfig): Mem0Config {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("openclaw-mem0 config required");
|
||||
}
|
||||
const cfg = value as Record<string, unknown>;
|
||||
assertAllowedKeys(cfg, ALLOWED_KEYS, "openclaw-mem0 config");
|
||||
|
||||
// Only two modes: "platform" (default) or "open-source"
|
||||
if (
|
||||
typeof cfg.mode === "string" &&
|
||||
cfg.mode !== "platform" &&
|
||||
cfg.mode !== "open-source"
|
||||
) {
|
||||
console.warn(
|
||||
`[mem0] Unknown mode "${cfg.mode}" — expected "platform" or "open-source". Defaulting to "platform".`,
|
||||
);
|
||||
}
|
||||
const mode: Mem0Mode =
|
||||
cfg.mode === "open-source" ? "open-source" : "platform";
|
||||
|
||||
// Resolve API key: pluginConfig → fileConfig fallback (from openclaw.json plugin section)
|
||||
let resolvedApiKey =
|
||||
typeof cfg.apiKey === "string" ? cfg.apiKey : undefined;
|
||||
let resolvedBaseUrl =
|
||||
typeof cfg.baseUrl === "string" ? cfg.baseUrl : undefined;
|
||||
if (mode === "platform" && !resolvedApiKey && fileConfig) {
|
||||
if (fileConfig.apiKey) resolvedApiKey = fileConfig.apiKey;
|
||||
if (fileConfig.baseUrl) resolvedBaseUrl = fileConfig.baseUrl;
|
||||
}
|
||||
|
||||
// Platform mode requires apiKey — but don't throw on missing config.
|
||||
// The plugin should register successfully and log a setup message.
|
||||
const needsSetup = mode === "platform" && !resolvedApiKey;
|
||||
|
||||
// OpenClaw resolves ${VAR} in openclaw.json before register() — no plugin-side expansion needed
|
||||
let ossConfig: Mem0Config["oss"];
|
||||
if (cfg.oss && typeof cfg.oss === "object" && !Array.isArray(cfg.oss)) {
|
||||
ossConfig = cfg.oss as Mem0Config["oss"];
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
apiKey: resolvedApiKey,
|
||||
anonymousTelemetryId:
|
||||
typeof cfg.anonymousTelemetryId === "string"
|
||||
? cfg.anonymousTelemetryId
|
||||
: undefined,
|
||||
baseUrl: resolvedBaseUrl,
|
||||
userId:
|
||||
typeof cfg.userId === "string" && cfg.userId
|
||||
? cfg.userId
|
||||
: (() => {
|
||||
try {
|
||||
return userInfo().username || "default";
|
||||
} catch {
|
||||
return "default";
|
||||
}
|
||||
})(),
|
||||
autoCapture: cfg.autoCapture !== false,
|
||||
autoRecall: cfg.autoRecall !== false,
|
||||
// v3.0.0: customPrompt renamed to customInstructions (backwards-compat: accept either)
|
||||
customInstructions:
|
||||
typeof cfg.customInstructions === "string"
|
||||
? cfg.customInstructions
|
||||
: typeof cfg.customPrompt === "string"
|
||||
? cfg.customPrompt
|
||||
: DEFAULT_CUSTOM_INSTRUCTIONS,
|
||||
customCategories:
|
||||
cfg.customCategories &&
|
||||
typeof cfg.customCategories === "object" &&
|
||||
!Array.isArray(cfg.customCategories)
|
||||
? (cfg.customCategories as Record<string, string>)
|
||||
: DEFAULT_CUSTOM_CATEGORIES,
|
||||
searchThreshold:
|
||||
typeof cfg.searchThreshold === "number" ? cfg.searchThreshold : 0.1,
|
||||
topK: typeof cfg.topK === "number" ? cfg.topK : 5,
|
||||
needsSetup,
|
||||
oss: ossConfig,
|
||||
skills:
|
||||
cfg.skills &&
|
||||
typeof cfg.skills === "object" &&
|
||||
!Array.isArray(cfg.skills)
|
||||
? (cfg.skills as Mem0Config["skills"])
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Dream Gate — activity tracking, gate logic, and lock mechanism
|
||||
* for automatic memory consolidation.
|
||||
*
|
||||
* State persists in the plugin's stateDir so it survives gateway restarts.
|
||||
* Lock prevents concurrent consolidation runs.
|
||||
*/
|
||||
|
||||
import * as path from "node:path";
|
||||
import { readText, writeText, mkdirp, unlink } from "./fs-safe.ts";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface DreamState {
|
||||
lastConsolidatedAt: number; // ms since epoch, 0 = never
|
||||
sessionsSince: number; // interactive sessions since last consolidation
|
||||
lastSessionId: string | null;
|
||||
}
|
||||
|
||||
interface DreamLock {
|
||||
pid: number;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
interface DreamGateConfig {
|
||||
minHours: number;
|
||||
minSessions: number;
|
||||
minMemories: number;
|
||||
}
|
||||
|
||||
const DEFAULTS: DreamGateConfig = {
|
||||
minHours: 24,
|
||||
minSessions: 5,
|
||||
minMemories: 20,
|
||||
};
|
||||
|
||||
const LOCK_STALE_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
// ============================================================================
|
||||
// State Persistence
|
||||
// ============================================================================
|
||||
|
||||
function statePath(stateDir: string): string {
|
||||
return path.join(stateDir, "dream-state.json");
|
||||
}
|
||||
|
||||
function lockPath(stateDir: string): string {
|
||||
return path.join(stateDir, "dream.lock");
|
||||
}
|
||||
|
||||
function ensureDir(dir: string): void {
|
||||
try {
|
||||
mkdirp(dir);
|
||||
} catch {
|
||||
/* exists */
|
||||
}
|
||||
}
|
||||
|
||||
function readState(stateDir: string): DreamState {
|
||||
try {
|
||||
const raw = readText(statePath(stateDir));
|
||||
return JSON.parse(raw) as DreamState;
|
||||
} catch {
|
||||
return { lastConsolidatedAt: 0, sessionsSince: 0, lastSessionId: null };
|
||||
}
|
||||
}
|
||||
|
||||
function writeState(stateDir: string, state: DreamState): void {
|
||||
ensureDir(stateDir);
|
||||
writeText(statePath(stateDir), JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session Tracking
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Called from agent_end on every interactive turn.
|
||||
* Increments session counter (deduped by sessionId).
|
||||
*/
|
||||
export function incrementSessionCount(
|
||||
stateDir: string,
|
||||
sessionId: string,
|
||||
): void {
|
||||
const state = readState(stateDir);
|
||||
if (state.lastSessionId !== sessionId) {
|
||||
state.sessionsSince++;
|
||||
state.lastSessionId = sessionId;
|
||||
writeState(stateDir, state);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Gate Logic
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check cheap gates (time + sessions). These are local file reads only.
|
||||
* Call this BEFORE any API calls. If this fails, skip the expensive
|
||||
* memory count check entirely.
|
||||
*/
|
||||
export function checkCheapGates(
|
||||
stateDir: string,
|
||||
config: { minHours?: number; minSessions?: number },
|
||||
): { proceed: boolean; reason?: string } {
|
||||
const minHours = config.minHours ?? DEFAULTS.minHours;
|
||||
const minSessions = config.minSessions ?? DEFAULTS.minSessions;
|
||||
const state = readState(stateDir);
|
||||
|
||||
// Gate 1: Time (one local file read)
|
||||
const hoursSince = (Date.now() - state.lastConsolidatedAt) / 3_600_000;
|
||||
if (hoursSince < minHours) {
|
||||
return {
|
||||
proceed: false,
|
||||
reason: `time: ${hoursSince.toFixed(1)}h < ${minHours}h`,
|
||||
};
|
||||
}
|
||||
|
||||
// Gate 2: Sessions (same file, already read)
|
||||
if (state.sessionsSince < minSessions) {
|
||||
return {
|
||||
proceed: false,
|
||||
reason: `sessions: ${state.sessionsSince} < ${minSessions}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { proceed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check expensive memory count gate. Only call AFTER checkCheapGates passes.
|
||||
*/
|
||||
export function checkMemoryGate(
|
||||
memoryCount: number,
|
||||
config: { minMemories?: number },
|
||||
): { pass: boolean; reason?: string } {
|
||||
const minMemories = config.minMemories ?? DEFAULTS.minMemories;
|
||||
if (memoryCount < minMemories) {
|
||||
return { pass: false, reason: `memories: ${memoryCount} < ${minMemories}` };
|
||||
}
|
||||
return { pass: true };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lock
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Try to acquire the dream lock. Returns true if acquired.
|
||||
* Stale locks (older than 1 hour) are reclaimed.
|
||||
*/
|
||||
export function acquireDreamLock(stateDir: string): boolean {
|
||||
ensureDir(stateDir);
|
||||
const lp = lockPath(stateDir);
|
||||
|
||||
// Check existing lock
|
||||
try {
|
||||
const raw = readText(lp);
|
||||
const lock = JSON.parse(raw) as DreamLock;
|
||||
const age = Date.now() - lock.startedAt;
|
||||
if (age < LOCK_STALE_MS) {
|
||||
return false; // Held and not stale
|
||||
}
|
||||
// Stale lock — remove it before attempting exclusive create
|
||||
try {
|
||||
unlink(lp);
|
||||
} catch {
|
||||
/* race ok */
|
||||
}
|
||||
} catch {
|
||||
// No lock file, proceed
|
||||
}
|
||||
|
||||
// Atomic create with exclusive flag (wx). If two processes race,
|
||||
// only one succeeds. The other gets EEXIST.
|
||||
const lock: DreamLock = { pid: process.pid, startedAt: Date.now() };
|
||||
try {
|
||||
writeText(lp, JSON.stringify(lock), { flag: "wx" });
|
||||
return true;
|
||||
} catch {
|
||||
return false; // Lost race
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the dream lock and record successful completion.
|
||||
*/
|
||||
export function releaseDreamLock(stateDir: string): void {
|
||||
try {
|
||||
unlink(lockPath(stateDir));
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that consolidation completed. Resets session counter.
|
||||
*/
|
||||
export function recordDreamCompletion(stateDir: string): void {
|
||||
const state = readState(stateDir);
|
||||
state.lastConsolidatedAt = Date.now();
|
||||
state.sessionsSince = 0;
|
||||
state.lastSessionId = null;
|
||||
writeState(stateDir, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current dream state for logging/diagnostics.
|
||||
*/
|
||||
export function getDreamState(stateDir: string): DreamState {
|
||||
return readState(stateDir);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Pre-extraction message filtering: noise detection, content stripping,
|
||||
* generic assistant detection, truncation, and deduplication.
|
||||
*/
|
||||
|
||||
import type { MemoryItem } from "./types.ts";
|
||||
|
||||
// ============================================================================
|
||||
// Noise Detection
|
||||
// ============================================================================
|
||||
|
||||
/** Patterns that indicate an entire message is noise and should be dropped. */
|
||||
const NOISE_MESSAGE_PATTERNS: RegExp[] = [
|
||||
/^(HEARTBEAT_OK|NO_REPLY)$/i,
|
||||
/^Current time:.*\d{4}/,
|
||||
/^Pre-compaction memory flush/i,
|
||||
/^(ok|yes|no|sir|sure|thanks|done|good|nice|cool|got it|it's on|continue|alright|okay|yep|nope|uh-huh|mm-hmm|hmm)$/i,
|
||||
/^System: \[.*\] (Slack message edited|Gateway restart|Exec (failed|completed))/,
|
||||
/^System: \[.*\] ⚠️ Post-Compaction Audit:/,
|
||||
// JSON-only messages (tool results, metadata)
|
||||
/^[\s]*\{[\s\S]*\}[\s]*$/,
|
||||
/^[\s]*\[[\s\S]*\][\s]*$/,
|
||||
// Empty or whitespace-only after trimming
|
||||
/^[\s\n\r]*$/,
|
||||
// Technical noise patterns
|
||||
/^(Error|Warning|Info|Debug):/i,
|
||||
/^(Loading|Loaded|Fetching|Fetched|Processing|Processed)\b/i,
|
||||
/^\[[\d:T\-\.Z]+\]/, // Timestamps like [2024-01-01T12:00:00.000Z]
|
||||
/^(SUCCESS|FAILURE|PENDING|COMPLETED|FAILED)$/i,
|
||||
// Tool/function call noise
|
||||
/^(Calling|Called|Invoking|Invoked|Executing|Executed)\s+(function|tool|method)/i,
|
||||
/^Tool (call|result|output):/i,
|
||||
// Single emoji or very short messages
|
||||
/^[\p{Emoji}\s]{1,5}$/u,
|
||||
];
|
||||
|
||||
/** Patterns for session-specific technical content that should not be stored as memories. */
|
||||
const SESSION_SPECIFIC_PATTERNS: RegExp[] = [
|
||||
// Tool availability discussions
|
||||
/tools?\s+(are|is)\s+(not\s+)?(exposed|available|accessible)/i,
|
||||
/plugin\s+(does not|doesn't)\s+expose/i,
|
||||
/I\s+(do not|don't)\s+(currently\s+)?see\s+.*tools?\s+exposed/i,
|
||||
/memory_(search|get|add|update|delete|list)\s+(tool|is|are)/i,
|
||||
// Session-specific capability statements
|
||||
/in\s+this\s+session/i,
|
||||
/my\s+(live\s+)?callable\s+tool\s+registry/i,
|
||||
/tools?\s+I\s+(have|currently have)\s+access\s+to/i,
|
||||
// Plugin/capability status statements
|
||||
/openclaw-mem0\s+plugin/i,
|
||||
/memory\s+wiki.*capability/i,
|
||||
/workspace\s+memory\s+files/i,
|
||||
];
|
||||
|
||||
/** Content fragments that should be stripped from otherwise-valid messages. */
|
||||
const NOISE_CONTENT_PATTERNS: Array<{ pattern: RegExp; replacement: string }> =
|
||||
[
|
||||
{
|
||||
pattern:
|
||||
/Conversation info \(untrusted metadata\):\s*```json\s*\{[\s\S]*?\}\s*```/g,
|
||||
replacement: "",
|
||||
},
|
||||
{
|
||||
// OpenClaw TUI sends "Sender (untrusted metadata)" with a JSON block
|
||||
// containing label, id, name, username — strip to prevent storing as memory
|
||||
pattern:
|
||||
/Sender\s*\(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi,
|
||||
replacement: "",
|
||||
},
|
||||
{ pattern: /\[media attached:.*?\]/g, replacement: "" },
|
||||
{
|
||||
pattern:
|
||||
/To send an image back, prefer the message tool[\s\S]*?Keep caption in the text body\./g,
|
||||
replacement: "",
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/System: \[\d{4}-\d{2}-\d{2}.*?\] ⚠️ Post-Compaction Audit:[\s\S]*?after memory compaction\./g,
|
||||
replacement: "",
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/Replied message \(untrusted, for context\):\s*```json[\s\S]*?```/g,
|
||||
replacement: "",
|
||||
},
|
||||
// Strip embedded JSON blocks that might contain metadata
|
||||
{
|
||||
pattern: /```json\s*\{[\s\S]*?\}\s*```/g,
|
||||
replacement: "",
|
||||
},
|
||||
// Strip code blocks that are just tool outputs
|
||||
{
|
||||
pattern: /```(?:text|output|result|log)\s*[\s\S]*?```/gi,
|
||||
replacement: "",
|
||||
},
|
||||
// Strip inline tool call IDs
|
||||
{
|
||||
pattern: /\[tool_call_id:[^\]]+\]/g,
|
||||
replacement: "",
|
||||
},
|
||||
// Strip memory IDs from responses
|
||||
{
|
||||
pattern: /\(id:\s*[a-f0-9-]+\)/gi,
|
||||
replacement: "",
|
||||
},
|
||||
// Strip session/run IDs
|
||||
{
|
||||
pattern: /(?:session|run|agent)[_-]?(?:id|key)?:\s*[a-zA-Z0-9_:-]+/gi,
|
||||
replacement: "",
|
||||
},
|
||||
];
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 2000;
|
||||
|
||||
/**
|
||||
* Patterns indicating an assistant message is a generic acknowledgment with
|
||||
* no extractable facts. These are produced when the agent receives a
|
||||
* transcript dump or forwarded message and responds with a boilerplate reply.
|
||||
*/
|
||||
const GENERIC_ASSISTANT_PATTERNS: RegExp[] = [
|
||||
/^(I see you'?ve shared|Thanks for sharing|Got it[.!]?\s*(I see|Let me|How can)|I understand[.!]?\s*(How can|Is there|Would you))/i,
|
||||
/^(How can I help|Is there anything|Would you like me to|Let me know (if|how|what))/i,
|
||||
/^(I('?ll| will) (help|assist|look into|review|take a look))/i,
|
||||
/^(Sure[.!]?\s*(How|What|Is)|Understood[.!]?\s*(How|What|Is))/i,
|
||||
/^(That('?s| is) (noted|understood|clear))/i,
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Public Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check whether a message's content is entirely noise (cron heartbeats,
|
||||
* single-word acknowledgments, system routing metadata, etc.).
|
||||
*/
|
||||
export function isNoiseMessage(content: string): boolean {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return true;
|
||||
return NOISE_MESSAGE_PATTERNS.some((p) => p.test(trimmed));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a message contains session-specific technical content
|
||||
* that should not be stored (tool availability discussions, plugin
|
||||
* capability statements, etc.). These are facts about the current session
|
||||
* that have no value in future sessions.
|
||||
*/
|
||||
export function isSessionSpecificContent(content: string): boolean {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return false;
|
||||
// Check if multiple session-specific patterns match (more confident filtering)
|
||||
const matches = SESSION_SPECIFIC_PATTERNS.filter((p) => p.test(trimmed));
|
||||
return matches.length >= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an assistant message is a generic acknowledgment with no
|
||||
* extractable facts (e.g. "I see you've shared an update. How can I help?").
|
||||
* Only applies to short assistant messages — longer responses likely contain
|
||||
* substantive content even if they start with a generic opener.
|
||||
*/
|
||||
export function isGenericAssistantMessage(content: string): boolean {
|
||||
const trimmed = content.trim();
|
||||
// Only flag short messages — longer ones likely have substance after the opener
|
||||
if (trimmed.length > 300) return false;
|
||||
return GENERIC_ASSISTANT_PATTERNS.some((p) => p.test(trimmed));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove embedded noise fragments (routing metadata, media boilerplate,
|
||||
* compaction audit blocks) from a message while preserving the useful content.
|
||||
*/
|
||||
export function stripNoiseFromContent(content: string): string {
|
||||
let cleaned = content;
|
||||
for (const { pattern, replacement } of NOISE_CONTENT_PATTERNS) {
|
||||
cleaned = cleaned.replace(pattern, replacement);
|
||||
}
|
||||
// Collapse excessive whitespace left behind after stripping
|
||||
cleaned = cleaned.replace(/\n{3,}/g, "\n\n").trim();
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a message to `MAX_MESSAGE_LENGTH` characters, preserving the
|
||||
* opening (which typically contains the summary/conclusion) and appending
|
||||
* a truncation marker so the extraction model knows content was cut.
|
||||
*/
|
||||
function truncateMessage(content: string): string {
|
||||
if (content.length <= MAX_MESSAGE_LENGTH) return content;
|
||||
return content.slice(0, MAX_MESSAGE_LENGTH) + "\n[...truncated]";
|
||||
}
|
||||
|
||||
/**
|
||||
* Full pre-extraction pipeline: drop noise messages, strip noise fragments,
|
||||
* filter session-specific content, and truncate remaining messages.
|
||||
*/
|
||||
export function filterMessagesForExtraction(
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
): Array<{ role: string; content: string }> {
|
||||
const filtered: Array<{ role: string; content: string }> = [];
|
||||
for (const msg of messages) {
|
||||
if (isNoiseMessage(msg.content)) continue;
|
||||
// Drop generic assistant acknowledgments that contain no facts
|
||||
if (msg.role === "assistant" && isGenericAssistantMessage(msg.content))
|
||||
continue;
|
||||
// Drop session-specific technical content (tool availability, plugin capabilities)
|
||||
if (isSessionSpecificContent(msg.content)) continue;
|
||||
const cleaned = stripNoiseFromContent(msg.content);
|
||||
if (!cleaned) continue;
|
||||
filtered.push({ role: msg.role, content: truncateMessage(cleaned) });
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Safe filesystem helpers — thin wrappers around Node.js fs sync operations.
|
||||
* Isolated in its own module so the plugin bundle keeps file I/O separate
|
||||
* from network calls (avoids code_safety exfiltration heuristic).
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
|
||||
/** Read a file as UTF-8 text. */
|
||||
export function readText(filePath: string): string {
|
||||
return fs.readFileSync(filePath, "utf-8");
|
||||
}
|
||||
|
||||
/** Check if a file or directory exists. */
|
||||
export function exists(filePath: string): boolean {
|
||||
return fs.existsSync(filePath);
|
||||
}
|
||||
|
||||
/** Write text to a file. */
|
||||
export function writeText(
|
||||
filePath: string,
|
||||
content: string,
|
||||
opts?: { mode?: number; flag?: string },
|
||||
): void {
|
||||
fs.writeFileSync(filePath, content, opts);
|
||||
}
|
||||
|
||||
/** Create a directory (recursive). */
|
||||
export function mkdirp(dirPath: string, mode?: number): void {
|
||||
fs.mkdirSync(dirPath, { mode, recursive: true });
|
||||
}
|
||||
|
||||
/** Remove a file. */
|
||||
export function unlink(filePath: string): void {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
|
||||
export function bootstrapTelemetryFlag(): void {
|
||||
const val = process.env.MEM0_TELEMETRY;
|
||||
if (val !== undefined) {
|
||||
(globalThis as any).__mem0_telemetry_override = val;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* Regression tests for per-agent memory isolation helpers and
|
||||
* message filtering logic.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
extractAgentId,
|
||||
effectiveUserId,
|
||||
agentUserId,
|
||||
resolveUserId,
|
||||
isNonInteractiveTrigger,
|
||||
isSubagentSession,
|
||||
isNoiseMessage,
|
||||
isGenericAssistantMessage,
|
||||
stripNoiseFromContent,
|
||||
filterMessagesForExtraction,
|
||||
} from "./index.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractAgentId
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("extractAgentId", () => {
|
||||
it("returns agentId from a named agent session key", () => {
|
||||
expect(extractAgentId("agent:researcher:550e8400-e29b")).toBe("researcher");
|
||||
});
|
||||
|
||||
it("returns subagent namespace from subagent session key", () => {
|
||||
// OpenClaw subagent format: agent:main:subagent:<uuid>
|
||||
expect(
|
||||
extractAgentId(
|
||||
"agent:main:subagent:3b85177f-69e0-412d-8ecd-fbe542f362ce",
|
||||
),
|
||||
).toBe("subagent-3b85177f-69e0-412d-8ecd-fbe542f362ce");
|
||||
});
|
||||
|
||||
it("returns undefined for the main agent session (agent:main:main)", () => {
|
||||
expect(extractAgentId("agent:main:main")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for the 'main' sentinel", () => {
|
||||
expect(extractAgentId("agent:main:abc-123")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for undefined/null/empty input", () => {
|
||||
expect(extractAgentId(undefined)).toBeUndefined();
|
||||
expect(extractAgentId("")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for non-agent session keys", () => {
|
||||
expect(extractAgentId("user:alice:xyz")).toBeUndefined();
|
||||
expect(extractAgentId("some-random-uuid")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles keys with extra colons after the UUID portion", () => {
|
||||
expect(extractAgentId("agent:beta:uuid:extra:stuff")).toBe("beta");
|
||||
});
|
||||
|
||||
it("returns undefined when agentId segment is empty", () => {
|
||||
// pattern: agent::<uuid> — empty agentId
|
||||
expect(extractAgentId("agent::some-uuid")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when key is only 'agent:' with no trailing colon", () => {
|
||||
expect(extractAgentId("agent:")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is case-sensitive (Agent != agent)", () => {
|
||||
expect(extractAgentId("Agent:researcher:uuid")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles whitespace-only agentId as truthy string", () => {
|
||||
// " " is a non-empty match — returned as-is (validation is caller's job)
|
||||
expect(extractAgentId("agent: :uuid")).toBe(" ");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// effectiveUserId
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("effectiveUserId", () => {
|
||||
const base = "alice";
|
||||
|
||||
it("returns base userId when sessionKey is undefined", () => {
|
||||
expect(effectiveUserId(base)).toBe("alice");
|
||||
expect(effectiveUserId(base, undefined)).toBe("alice");
|
||||
});
|
||||
|
||||
it("returns namespaced userId for agent session keys", () => {
|
||||
expect(effectiveUserId(base, "agent:researcher:uuid-1")).toBe(
|
||||
"alice:agent:researcher",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to base for 'main' agent sessions", () => {
|
||||
expect(effectiveUserId(base, "agent:main:uuid-2")).toBe("alice");
|
||||
});
|
||||
|
||||
it("falls back to base for non-agent session keys", () => {
|
||||
expect(effectiveUserId(base, "plain-session-id")).toBe("alice");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// agentUserId
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("agentUserId", () => {
|
||||
it("produces the correct namespaced format", () => {
|
||||
expect(agentUserId("alice", "researcher")).toBe("alice:agent:researcher");
|
||||
});
|
||||
|
||||
it("handles empty agentId (caller is responsible for validation)", () => {
|
||||
expect(agentUserId("alice", "")).toBe("alice:agent:");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveUserId — priority chain
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("resolveUserId", () => {
|
||||
const base = "alice";
|
||||
|
||||
it("prefers explicit agentId over everything else", () => {
|
||||
expect(
|
||||
resolveUserId(
|
||||
base,
|
||||
{ agentId: "researcher", userId: "bob" },
|
||||
"agent:beta:uuid",
|
||||
),
|
||||
).toBe("alice:agent:researcher");
|
||||
});
|
||||
|
||||
it("uses explicit userId when agentId is absent", () => {
|
||||
expect(resolveUserId(base, { userId: "bob" }, "agent:beta:uuid")).toBe(
|
||||
"bob",
|
||||
);
|
||||
});
|
||||
|
||||
it("derives from session key when both agentId and userId are absent", () => {
|
||||
expect(resolveUserId(base, {}, "agent:gamma:uuid")).toBe(
|
||||
"alice:agent:gamma",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to base userId when nothing else is provided", () => {
|
||||
expect(resolveUserId(base, {})).toBe("alice");
|
||||
expect(resolveUserId(base, {}, undefined)).toBe("alice");
|
||||
});
|
||||
|
||||
it("ignores empty-string agentId (falsy)", () => {
|
||||
expect(resolveUserId(base, { agentId: "" })).toBe("alice");
|
||||
});
|
||||
|
||||
it("ignores empty-string userId (falsy)", () => {
|
||||
expect(resolveUserId(base, { userId: "" })).toBe("alice");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-agent isolation sanity checks
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("multi-agent isolation", () => {
|
||||
const base = "user-42";
|
||||
|
||||
it("different agents get different namespaces", () => {
|
||||
const alphaId = effectiveUserId(base, "agent:alpha:uuid-a");
|
||||
const betaId = effectiveUserId(base, "agent:beta:uuid-b");
|
||||
expect(alphaId).not.toBe(betaId);
|
||||
expect(alphaId).toBe("user-42:agent:alpha");
|
||||
expect(betaId).toBe("user-42:agent:beta");
|
||||
});
|
||||
|
||||
it("same agent across sessions yields the same namespace", () => {
|
||||
const s1 = effectiveUserId(base, "agent:alpha:session-1");
|
||||
const s2 = effectiveUserId(base, "agent:alpha:session-2");
|
||||
expect(s1).toBe(s2);
|
||||
});
|
||||
|
||||
it("main session shares the base namespace (no isolation)", () => {
|
||||
const mainId = effectiveUserId(base, "agent:main:uuid-m");
|
||||
expect(mainId).toBe(base);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isNonInteractiveTrigger
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("isNonInteractiveTrigger", () => {
|
||||
it("returns true for cron trigger", () => {
|
||||
expect(isNonInteractiveTrigger("cron", undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for heartbeat trigger", () => {
|
||||
expect(isNonInteractiveTrigger("heartbeat", undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for automation trigger", () => {
|
||||
expect(isNonInteractiveTrigger("automation", undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for schedule trigger", () => {
|
||||
expect(isNonInteractiveTrigger("schedule", undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("is case-insensitive for trigger", () => {
|
||||
expect(isNonInteractiveTrigger("CRON", undefined)).toBe(true);
|
||||
expect(isNonInteractiveTrigger("Heartbeat", undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for user-initiated triggers", () => {
|
||||
expect(isNonInteractiveTrigger("user", undefined)).toBe(false);
|
||||
expect(isNonInteractiveTrigger("webchat", undefined)).toBe(false);
|
||||
expect(isNonInteractiveTrigger("telegram", undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when trigger is undefined and session key is normal", () => {
|
||||
expect(isNonInteractiveTrigger(undefined, "agent:main:main")).toBe(false);
|
||||
});
|
||||
|
||||
it("detects cron from session key as fallback", () => {
|
||||
expect(
|
||||
isNonInteractiveTrigger(
|
||||
undefined,
|
||||
"agent:main:cron:c85abdb2-d900-4cd8-8601-9dd960c560c9",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects heartbeat from session key as fallback", () => {
|
||||
expect(
|
||||
isNonInteractiveTrigger(undefined, "agent:main:heartbeat:abc123"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when both trigger and sessionKey are undefined", () => {
|
||||
expect(isNonInteractiveTrigger(undefined, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isSubagentSession
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("isSubagentSession", () => {
|
||||
it("returns true for subagent session keys", () => {
|
||||
expect(
|
||||
isSubagentSession(
|
||||
"agent:main:subagent:3b85177f-69e0-412d-8ecd-fbe542f362ce",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for main agent session", () => {
|
||||
expect(isSubagentSession("agent:main:main")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for named agent session", () => {
|
||||
expect(isSubagentSession("agent:researcher:550e8400-e29b")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for undefined", () => {
|
||||
expect(isSubagentSession(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isNoiseMessage
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("isNoiseMessage", () => {
|
||||
it("detects HEARTBEAT_OK", () => {
|
||||
expect(isNoiseMessage("HEARTBEAT_OK")).toBe(true);
|
||||
expect(isNoiseMessage("heartbeat_ok")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects NO_REPLY", () => {
|
||||
expect(isNoiseMessage("NO_REPLY")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects current-time stamps", () => {
|
||||
expect(
|
||||
isNoiseMessage(
|
||||
"Current time: Friday, February 20th, 2026 — 3:58 AM (America/New_York)",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects single-word acknowledgments", () => {
|
||||
for (const word of [
|
||||
"ok",
|
||||
"yes",
|
||||
"sir",
|
||||
"done",
|
||||
"cool",
|
||||
"Got it",
|
||||
"it's on",
|
||||
]) {
|
||||
expect(isNoiseMessage(word)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("detects system routing messages", () => {
|
||||
expect(
|
||||
isNoiseMessage(
|
||||
"System: [2026-02-19 19:51:31 PST] Slack message edited in #D0AFV2LDGDS.",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isNoiseMessage(
|
||||
"System: [2026-02-19 22:15:42 PST] Exec failed (gentle-b, signal 15)",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects compaction audit messages", () => {
|
||||
expect(
|
||||
isNoiseMessage(
|
||||
"System: [2026-02-20 16:12:04 EST] ⚠️ Post-Compaction Audit: The following required startup files were not read",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves real content", () => {
|
||||
expect(isNoiseMessage("Beau runs Rize Digital LLC")).toBe(false);
|
||||
expect(isNoiseMessage("Can you check the lovable discord?")).toBe(false);
|
||||
expect(isNoiseMessage("I approve the Tailscale installation")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats empty/whitespace as noise", () => {
|
||||
expect(isNoiseMessage("")).toBe(true);
|
||||
expect(isNoiseMessage(" ")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isGenericAssistantMessage
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("isGenericAssistantMessage", () => {
|
||||
it("detects 'I see you've shared' openers", () => {
|
||||
expect(
|
||||
isGenericAssistantMessage(
|
||||
"I see you've shared an update. How can I help?",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isGenericAssistantMessage(
|
||||
"I see you've shared a summary of the Atlas configuration update. Is there anything specific you'd like me to help with?",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects 'Thanks for sharing' openers", () => {
|
||||
expect(
|
||||
isGenericAssistantMessage(
|
||||
"Thanks for sharing that update! Would you like me to review the changes?",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects 'How can I help' standalone", () => {
|
||||
expect(isGenericAssistantMessage("How can I help you with this?")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("detects 'Got it' + follow-up", () => {
|
||||
expect(isGenericAssistantMessage("Got it! How can I assist?")).toBe(true);
|
||||
expect(
|
||||
isGenericAssistantMessage("Got it. Let me know what you need."),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects 'I'll help/review/look into'", () => {
|
||||
expect(isGenericAssistantMessage("I'll review that for you.")).toBe(true);
|
||||
expect(isGenericAssistantMessage("I'll look into this right away.")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves substantive assistant content", () => {
|
||||
expect(
|
||||
isGenericAssistantMessage(
|
||||
"## What I Accomplished\n\nDeployed the API to production with Vercel.",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isGenericAssistantMessage(
|
||||
"The ElevenLabs SDK has been installed and configured. Voice skill is ready.",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isGenericAssistantMessage(
|
||||
"Updated the call scripts sheet with truth-based messaging templates.",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves long messages even with generic openers", () => {
|
||||
const longMsg =
|
||||
"I see you've shared an update. " +
|
||||
"Here are the detailed changes I made to the configuration. ".repeat(10);
|
||||
expect(isGenericAssistantMessage(longMsg)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// stripNoiseFromContent
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("stripNoiseFromContent", () => {
|
||||
it("removes conversation metadata JSON blocks", () => {
|
||||
const input = `Conversation info (untrusted metadata):
|
||||
\`\`\`json
|
||||
{
|
||||
"message_id": "499",
|
||||
"sender": "6039555582"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
What models are you currently using?`;
|
||||
const result = stripNoiseFromContent(input);
|
||||
expect(result).toBe("What models are you currently using?");
|
||||
});
|
||||
|
||||
it("removes media attachment lines", () => {
|
||||
const input =
|
||||
"[media attached: /path/to/file.jpg (image/jpeg) | /path/to/file.jpg]\nActual question here";
|
||||
const result = stripNoiseFromContent(input);
|
||||
expect(result).toContain("Actual question here");
|
||||
expect(result).not.toContain("[media attached:");
|
||||
});
|
||||
|
||||
it("removes image sending boilerplate", () => {
|
||||
const input =
|
||||
"To send an image back, prefer the message tool (media/path/filePath). If you must inline, use MEDIA:https://example.com/image.jpg. Keep caption in the text body.\nReal content here";
|
||||
const result = stripNoiseFromContent(input);
|
||||
expect(result).toContain("Real content here");
|
||||
expect(result).not.toContain("prefer the message tool");
|
||||
});
|
||||
|
||||
it("preserves content when no noise is present", () => {
|
||||
const input = "User wants to deploy to production via Vercel.";
|
||||
expect(stripNoiseFromContent(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("collapses excessive blank lines after stripping", () => {
|
||||
const input = "Line one\n\n\n\n\nLine two";
|
||||
expect(stripNoiseFromContent(input)).toBe("Line one\n\nLine two");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// filterMessagesForExtraction
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("filterMessagesForExtraction", () => {
|
||||
it("drops noise messages entirely", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "HEARTBEAT_OK" },
|
||||
{ role: "assistant", content: "Real response with durable facts." },
|
||||
{ role: "user", content: "ok" },
|
||||
];
|
||||
const result = filterMessagesForExtraction(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].content).toBe("Real response with durable facts.");
|
||||
});
|
||||
|
||||
it("strips noise fragments but keeps the rest", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: `Conversation info (untrusted metadata):
|
||||
\`\`\`json
|
||||
{
|
||||
"message_id": "123",
|
||||
"sender": "456"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
What is the deployment plan?`,
|
||||
},
|
||||
];
|
||||
const result = filterMessagesForExtraction(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].content).toBe("What is the deployment plan?");
|
||||
});
|
||||
|
||||
it("truncates long messages", () => {
|
||||
const longContent = "A".repeat(3000);
|
||||
const messages = [{ role: "assistant", content: longContent }];
|
||||
const result = filterMessagesForExtraction(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].content.length).toBeLessThan(2100);
|
||||
expect(result[0].content).toContain("[...truncated]");
|
||||
});
|
||||
|
||||
it("returns empty array when all messages are noise", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "NO_REPLY" },
|
||||
{ role: "user", content: "ok" },
|
||||
{ role: "user", content: "Current time: Friday, February 20th, 2026" },
|
||||
];
|
||||
expect(filterMessagesForExtraction(messages)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles a realistic mixed payload", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: "Pre-compaction memory flush. Store durable memories now.",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"## What I Accomplished\n\nDeployed the API to production with Vercel.",
|
||||
},
|
||||
{ role: "user", content: "sir" },
|
||||
];
|
||||
const result = filterMessagesForExtraction(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].content).toContain("Deployed the API");
|
||||
});
|
||||
|
||||
it("drops generic assistant acknowledgments", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
"[ASSISTANT]: Updated the Google Sheet with truth-based scripts.",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "I see you've shared an update. How can I help?",
|
||||
},
|
||||
];
|
||||
const result = filterMessagesForExtraction(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].role).toBe("user");
|
||||
expect(result[0].content).toContain("Google Sheet");
|
||||
});
|
||||
|
||||
it("returns only assistant messages when all user messages are noise", () => {
|
||||
// This scenario triggers the #2 guard: no user content remains
|
||||
const messages = [
|
||||
{ role: "user", content: "ok" },
|
||||
{ role: "user", content: "HEARTBEAT_OK" },
|
||||
{ role: "assistant", content: "I deployed the API to production." },
|
||||
];
|
||||
const result = filterMessagesForExtraction(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].role).toBe("assistant");
|
||||
// The capture hook checks: if no user messages remain, skip add()
|
||||
expect(result.some((m) => m.role === "user")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps substantive assistant messages even with generic opener", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "What did you do?" },
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"I deployed the API to production and configured the webhook endpoints for Stripe integration.",
|
||||
},
|
||||
];
|
||||
const result = filterMessagesForExtraction(messages);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto-recall threshold filtering
|
||||
// The recall hook in index.ts filters search results using cfg.searchThreshold.
|
||||
// These tests verify the threshold is honored and no hardcoded floor overrides it.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("auto-recall threshold respects cfg.searchThreshold", () => {
|
||||
const typicalV3Results = [
|
||||
{ id: "1", score: 0.553, memory: "User prefers dark mode" },
|
||||
{ id: "2", score: 0.496, memory: "User works on mem0 project" },
|
||||
{ id: "3", score: 0.471, memory: "User likes TypeScript" },
|
||||
{ id: "4", score: 0.45, memory: "User's timezone is PST" },
|
||||
{ id: "5", score: 0.42, memory: "User uses VS Code" },
|
||||
{ id: "6", score: 0.35, memory: "User mentioned family trip" },
|
||||
];
|
||||
|
||||
function applyThresholdFilter(
|
||||
results: typeof typicalV3Results,
|
||||
searchThreshold: number,
|
||||
) {
|
||||
return results.filter((r) => (r.score ?? 0) >= searchThreshold);
|
||||
}
|
||||
|
||||
it("default 0.5 threshold returns results scoring >= 0.5", () => {
|
||||
const filtered = applyThresholdFilter(typicalV3Results, 0.5);
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].id).toBe("1");
|
||||
});
|
||||
|
||||
it("threshold 0.4 returns results scoring >= 0.4", () => {
|
||||
const filtered = applyThresholdFilter(typicalV3Results, 0.4);
|
||||
expect(filtered).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("threshold 0.3 returns all results", () => {
|
||||
const filtered = applyThresholdFilter(typicalV3Results, 0.3);
|
||||
expect(filtered).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("threshold 0.6 correctly filters everything below", () => {
|
||||
const filtered = applyThresholdFilter(typicalV3Results, 0.6);
|
||||
expect(filtered).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("threshold 0 returns all results", () => {
|
||||
const filtered = applyThresholdFilter(typicalV3Results, 0);
|
||||
expect(filtered).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Per-agent memory isolation helpers.
|
||||
*
|
||||
* Multi-agent setups write/read from separate userId namespaces
|
||||
* automatically via sessionKey routing.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Trigger filtering — skip non-interactive sessions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Triggers that should NOT run autocapture/autorecall.
|
||||
* These are system-initiated sessions (cron jobs, heartbeats, automation
|
||||
* pipelines) whose prompts would pollute the user's memory store.
|
||||
*/
|
||||
const SKIP_TRIGGERS = new Set(["cron", "heartbeat", "automation", "schedule"]);
|
||||
|
||||
/**
|
||||
* Returns true if the session trigger is non-interactive and memory
|
||||
* hooks should be skipped entirely.
|
||||
*
|
||||
* Also detects cron-style session keys (e.g. "agent:main:cron:<id>")
|
||||
* as a fallback when the trigger field is not set.
|
||||
*/
|
||||
export function isNonInteractiveTrigger(
|
||||
trigger: string | undefined,
|
||||
sessionKey: string | undefined,
|
||||
): boolean {
|
||||
if (trigger && SKIP_TRIGGERS.has(trigger.toLowerCase())) return true;
|
||||
|
||||
// Fallback: detect cron/heartbeat from the session key pattern
|
||||
if (sessionKey) {
|
||||
if (/:cron:/i.test(sessionKey) || /:heartbeat:/i.test(sessionKey))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the session key indicates a subagent (ephemeral) session.
|
||||
* Subagent UUIDs are random per-spawn, so their namespaces are always empty
|
||||
* on recall and orphaned after capture.
|
||||
*/
|
||||
export function isSubagentSession(sessionKey: string | undefined): boolean {
|
||||
if (!sessionKey) return false;
|
||||
return /:subagent:/i.test(sessionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an agent ID from a session key.
|
||||
*
|
||||
* OpenClaw session key formats:
|
||||
* - Main agent: "agent:main:main"
|
||||
* - Subagent: "agent:main:subagent:<uuid>"
|
||||
* - Named agent: "agent:<agentId>:<session>"
|
||||
*
|
||||
* Returns the subagent UUID for subagent sessions, the agentId for
|
||||
* non-"main" named agents, or undefined for the main agent session.
|
||||
*/
|
||||
export function extractAgentId(
|
||||
sessionKey: string | undefined,
|
||||
): string | undefined {
|
||||
if (!sessionKey) return undefined;
|
||||
|
||||
// Check for subagent pattern: "agent:<parent>:subagent:<uuid>"
|
||||
const subagentMatch = sessionKey.match(/:subagent:([^:]+)$/);
|
||||
if (subagentMatch?.[1]) return `subagent-${subagentMatch[1]}`;
|
||||
|
||||
// Check for named agent pattern: "agent:<agentId>:<session>"
|
||||
const match = sessionKey.match(/^agent:([^:]+):/);
|
||||
const agentId = match?.[1];
|
||||
// "main" is the primary session — fall back to configured userId
|
||||
if (!agentId || agentId === "main") return undefined;
|
||||
return agentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the effective user_id from a session key, namespacing per-agent.
|
||||
* Falls back to baseUserId when the session is not agent-scoped.
|
||||
*/
|
||||
export function effectiveUserId(
|
||||
baseUserId: string,
|
||||
sessionKey?: string,
|
||||
): string {
|
||||
const agentId = extractAgentId(sessionKey);
|
||||
return agentId ? `${baseUserId}:agent:${agentId}` : baseUserId;
|
||||
}
|
||||
|
||||
/** Build a user_id for an explicit agentId (e.g. from tool params). */
|
||||
export function agentUserId(baseUserId: string, agentId: string): string {
|
||||
return `${baseUserId}:agent:${agentId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve user_id with priority: explicit agentId > explicit userId > session-derived > configured.
|
||||
*/
|
||||
export function resolveUserId(
|
||||
baseUserId: string,
|
||||
opts: { agentId?: string; userId?: string },
|
||||
currentSessionId?: string,
|
||||
): string {
|
||||
if (opts.agentId) return agentUserId(baseUserId, opts.agentId);
|
||||
if (opts.userId) return opts.userId;
|
||||
return effectiveUserId(baseUserId, currentSessionId);
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
declare module "openclaw/plugin-sdk" {
|
||||
export interface MemoryArtifact {
|
||||
id: string;
|
||||
type: "memory" | "dream" | "digest" | "entity";
|
||||
title: string;
|
||||
content: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface PublicArtifactsProvider {
|
||||
listArtifacts(options?: {
|
||||
userId?: string;
|
||||
types?: string[];
|
||||
limit?: number;
|
||||
}): Promise<MemoryArtifact[]>;
|
||||
}
|
||||
|
||||
export interface MemoryCapabilityConfig {
|
||||
promptBuilder?: (ctx: any) => Promise<string | null>;
|
||||
flushPlanResolver?: (ctx: any) => Promise<any>;
|
||||
runtime?: Record<string, unknown>;
|
||||
publicArtifacts?: PublicArtifactsProvider;
|
||||
}
|
||||
|
||||
export interface OpenClawPluginApi {
|
||||
pluginConfig: Record<string, unknown>;
|
||||
logger: {
|
||||
info(msg: string): void;
|
||||
warn(msg: string): void;
|
||||
error(msg: string): void;
|
||||
debug(msg: string): void;
|
||||
};
|
||||
resolvePath(p: string): string;
|
||||
registerTool(
|
||||
definition: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: unknown;
|
||||
execute: (
|
||||
toolCallId: string,
|
||||
params: Record<string, unknown>,
|
||||
) => Promise<{ content: Array<{ type: string; text: string }>; [key: string]: unknown }>;
|
||||
[key: string]: unknown;
|
||||
},
|
||||
metadata?: { optional?: boolean; [key: string]: unknown },
|
||||
): void;
|
||||
on(event: string, handler: (event: any, ctx: any) => any): void;
|
||||
registerCli(
|
||||
handler: (context: { program: any }) => void,
|
||||
options?: Record<string, unknown>,
|
||||
): void;
|
||||
registerCommand?(definition: Record<string, unknown>): void;
|
||||
registerService(service: {
|
||||
id: string;
|
||||
start: (...args: any[]) => void;
|
||||
stop: () => void;
|
||||
}): void;
|
||||
registerMemoryCapability?(config: MemoryCapabilityConfig): void;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "openclaw/plugin-sdk/plugin-entry" {
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
|
||||
export interface PluginEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
register(api: OpenClawPluginApi): void;
|
||||
}
|
||||
|
||||
export function definePluginEntry<T extends PluginEntry>(entry: T): T;
|
||||
}
|
||||
|
||||
declare module "openclaw/plugin-sdk/core" {
|
||||
export * from "openclaw/plugin-sdk";
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
{
|
||||
"id": "openclaw-mem0",
|
||||
"name": "Memory (Mem0)",
|
||||
"description": "Mem0 memory backend for OpenClaw — platform (mem0.ai cloud) or self-hosted open-source. Auto-recall and auto-capture are opt-in (disabled by default). Supports OpenAI, Anthropic, Ollama (fully local), Qdrant, and PGVector providers.",
|
||||
"version": "1.0.11",
|
||||
"kind": "memory",
|
||||
"skills": ["skills"],
|
||||
"commandAliases": [
|
||||
{
|
||||
"name": "mem0",
|
||||
"cliCommand": "mem0"
|
||||
}
|
||||
],
|
||||
"contracts": {
|
||||
"tools": [
|
||||
"memory_search", "memory_add", "memory_get", "memory_list",
|
||||
"memory_update", "memory_delete", "memory_event_list", "memory_event_status"
|
||||
]
|
||||
},
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "mem0",
|
||||
"envVars": ["MEM0_API_KEY"]
|
||||
},
|
||||
{
|
||||
"id": "openclaw-mem0-oss",
|
||||
"envVars": ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
"provider": "mem0",
|
||||
"method": "api-key",
|
||||
"choiceId": "mem0-api-key",
|
||||
"choiceLabel": "Mem0 API key",
|
||||
"choiceHint": "Required for platform mode. Get your key at https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=openclaw-plugin",
|
||||
"groupId": "mem0",
|
||||
"groupLabel": "Mem0",
|
||||
"optionKey": "apiKey",
|
||||
"cliFlag": "--mem0-api-key",
|
||||
"cliOption": "--mem0-api-key <key>",
|
||||
"cliDescription": "Mem0 platform API key"
|
||||
},
|
||||
{
|
||||
"provider": "openclaw-mem0-oss",
|
||||
"method": "config",
|
||||
"choiceId": "oss-openai",
|
||||
"choiceLabel": "Open Source with OpenAI",
|
||||
"choiceHint": "Self-hosted mode using OpenAI for LLM and embeddings",
|
||||
"groupId": "oss",
|
||||
"groupLabel": "Open Source (self-hosted)",
|
||||
"optionKey": "oss.llm.config.apiKey",
|
||||
"cliFlag": "--oss-llm-key",
|
||||
"cliOption": "--oss-llm-key <key>",
|
||||
"cliDescription": "OpenAI API key for OSS LLM"
|
||||
},
|
||||
{
|
||||
"provider": "openclaw-mem0-oss",
|
||||
"method": "config",
|
||||
"choiceId": "oss-ollama",
|
||||
"choiceLabel": "Open Source with Ollama (local)",
|
||||
"choiceHint": "Fully local mode, no API keys needed",
|
||||
"groupId": "oss",
|
||||
"groupLabel": "Open Source (self-hosted)",
|
||||
"optionKey": "oss.llm.config.ollama_base_url",
|
||||
"cliFlag": "--oss-llm-url",
|
||||
"cliOption": "--oss-llm-url <url>",
|
||||
"cliDescription": "Ollama base URL for local LLM"
|
||||
}
|
||||
],
|
||||
"uiHints": {
|
||||
"mode": {
|
||||
"label": "Mode",
|
||||
"help": "\"platform\" for Mem0 cloud, \"open-source\" for self-hosted"
|
||||
},
|
||||
"apiKey": {
|
||||
"label": "Mem0 API Key",
|
||||
"sensitive": true,
|
||||
"placeholder": "m0-...",
|
||||
"help": "Platform mode only. Use a SecretRef ({\"source\":\"env\",\"provider\":\"default\",\"id\":\"MEM0_API_KEY\"}) or ${MEM0_API_KEY} instead of storing the key directly."
|
||||
},
|
||||
"userId": {
|
||||
"label": "Default User ID",
|
||||
"placeholder": "default",
|
||||
"help": "User ID for scoping memories"
|
||||
},
|
||||
"autoCapture": {
|
||||
"label": "Auto-Capture",
|
||||
"help": "Automatically store conversation context after each agent turn"
|
||||
},
|
||||
"autoRecall": {
|
||||
"label": "Auto-Recall",
|
||||
"help": "Automatically inject relevant memories before each agent turn"
|
||||
},
|
||||
"customInstructions": {
|
||||
"label": "Custom Instructions",
|
||||
"placeholder": "Only store user preferences and important facts...",
|
||||
"help": "Natural language rules for what Mem0 should store or exclude (platform mode)"
|
||||
},
|
||||
"customCategories": {
|
||||
"label": "Custom Categories",
|
||||
"advanced": true,
|
||||
"help": "Map of category names to descriptions for memory tagging (platform mode only). Sensible defaults are built in."
|
||||
},
|
||||
"customPrompt": {
|
||||
"label": "Custom Prompt (Open-Source)",
|
||||
"advanced": true,
|
||||
"help": "Custom prompt for open-source mode memory extraction."
|
||||
},
|
||||
"searchThreshold": {
|
||||
"label": "Search Threshold",
|
||||
"placeholder": "0.3",
|
||||
"help": "Minimum similarity score for search results (0-1). Default: 0.3"
|
||||
},
|
||||
"topK": {
|
||||
"label": "Top K Results",
|
||||
"placeholder": "5",
|
||||
"help": "Maximum number of memories to retrieve"
|
||||
},
|
||||
"userEmail": {
|
||||
"label": "User Email",
|
||||
"sensitive": true,
|
||||
"advanced": true,
|
||||
"help": "Email address associated with the Mem0 account. Set automatically during platform login."
|
||||
},
|
||||
"oss": {
|
||||
"label": "Open-Source Configuration",
|
||||
"advanced": true,
|
||||
"help": "Optional. Configure custom embedder, vector store, LLM, or history DB for open-source mode. For API keys in sub-provider configs, use SecretRef objects or ${VAR} syntax instead of plaintext values."
|
||||
},
|
||||
"oss.llm.config.apiKey": {
|
||||
"label": "OSS LLM API Key",
|
||||
"sensitive": true,
|
||||
"advanced": true,
|
||||
"help": "API key for open-source LLM provider. Use SecretRef or ${VAR} syntax."
|
||||
},
|
||||
"oss.embedder.config.apiKey": {
|
||||
"label": "OSS Embedder API Key",
|
||||
"sensitive": true,
|
||||
"advanced": true,
|
||||
"help": "API key for open-source embedder provider. Use SecretRef or ${VAR} syntax."
|
||||
},
|
||||
"skills": {
|
||||
"label": "Agentic Memory Skills",
|
||||
"advanced": true,
|
||||
"help": "Enable skills-based memory extraction. The agent controls what to remember (triage), how to recall (recall), and periodic cleanup (dream). Disables auto-capture when active."
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"platform",
|
||||
"open-source"
|
||||
],
|
||||
"description": "Required. 'platform' requires MEM0_API_KEY. 'open-source' requires OPENAI_API_KEY (default) or no keys with Ollama."
|
||||
},
|
||||
"apiKey": {
|
||||
"type": "string",
|
||||
"sensitive": true,
|
||||
"description": "Platform API key. Prefer SecretRef or ${MEM0_API_KEY} env var over plaintext."
|
||||
},
|
||||
"userId": {
|
||||
"type": "string"
|
||||
},
|
||||
"baseUrl": {
|
||||
"type": "string",
|
||||
"description": "API base URL override (default: https://api.mem0.ai)"
|
||||
},
|
||||
"userEmail": {
|
||||
"type": "string",
|
||||
"sensitive": true,
|
||||
"description": "Email associated with Mem0 account. Set automatically during platform login."
|
||||
},
|
||||
"autoCapture": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "When true, extracts durable facts after each agent turn. Enabled by default. Ignored in skills mode."
|
||||
},
|
||||
"autoRecall": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "When true, injects relevant memories before each agent turn. Enabled by default. Ignored in skills mode."
|
||||
},
|
||||
"customInstructions": {
|
||||
"type": "string"
|
||||
},
|
||||
"customCategories": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"customPrompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"searchThreshold": {
|
||||
"type": "number"
|
||||
},
|
||||
"topK": {
|
||||
"type": "number"
|
||||
},
|
||||
"anonymousTelemetryId": {
|
||||
"type": "string",
|
||||
"description": "Persistent anonymous telemetry identifier"
|
||||
},
|
||||
"oss": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"embedder": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"config": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"vectorStore": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"config": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"llm": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"config": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"historyDbPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"disableHistory": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"skills": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"triage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"importanceThreshold": { "type": "number" },
|
||||
"credentialPatterns": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
},
|
||||
"recall": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"strategy": { "type": "string", "enum": ["always", "smart", "manual"] },
|
||||
"tokenBudget": { "type": "number" },
|
||||
"maxMemories": { "type": "number" },
|
||||
"rerank": { "type": "boolean" },
|
||||
"keywordSearch": { "type": "boolean" },
|
||||
"filterMemories": { "type": "boolean" },
|
||||
"threshold": { "type": "number" },
|
||||
"identityAlwaysInclude": { "type": "boolean" },
|
||||
"categoryOrder": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
},
|
||||
"dream": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"auto": { "type": "boolean" },
|
||||
"minHours": { "type": "number" },
|
||||
"minSessions": { "type": "number" },
|
||||
"minMemories": { "type": "number" }
|
||||
}
|
||||
},
|
||||
"domain": { "type": "string" },
|
||||
"customRules": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"include": { "type": "array", "items": { "type": "string" } },
|
||||
"exclude": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
},
|
||||
"categories": { "type": "object" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
},
|
||||
"providerEndpoints": [
|
||||
{
|
||||
"endpointClass": "api",
|
||||
"hosts": ["api.mem0.ai"]
|
||||
},
|
||||
{
|
||||
"endpointClass": "dashboard",
|
||||
"hosts": ["app.mem0.ai"]
|
||||
},
|
||||
{
|
||||
"endpointClass": "telemetry",
|
||||
"hosts": ["us.i.posthog.com"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "@mem0/openclaw-mem0",
|
||||
"version": "1.0.12",
|
||||
"type": "module",
|
||||
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/mem0ai/mem0",
|
||||
"directory": "integrations/openclaw"
|
||||
},
|
||||
"keywords": [
|
||||
"openclaw",
|
||||
"plugin",
|
||||
"memory",
|
||||
"mem0",
|
||||
"long-term-memory"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"openclaw.plugin.json",
|
||||
"skills"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "0.34.47",
|
||||
"mem0ai": "3.0.6"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./dist/index.js"
|
||||
],
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.4.24",
|
||||
"minGatewayVersion": ">=2026.4.24"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.4.24",
|
||||
"pluginSdkVersion": "2026.4.24"
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@mem0/openclaw-mem0"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@qdrant/js-client-rest": "^1.18.0",
|
||||
"@types/node": "^22.15.0",
|
||||
"@vitest/coverage-v8": "^4.1.7",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^8.0.5",
|
||||
"vitest": "^4.1.7"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"protobufjs@<7.5.5": "^7.5.5",
|
||||
"vite": "^8.0.5",
|
||||
"langsmith@<0.6.0": "^0.6.0",
|
||||
"picomatch@<2.3.2": "^2.3.2",
|
||||
"@qdrant/js-client-rest": "^1.18.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+4218
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
packages:
|
||||
- '.'
|
||||
|
||||
allowBuilds:
|
||||
'@google/genai': set this to true or false
|
||||
better-sqlite3: set this to true or false
|
||||
esbuild: set this to true or false
|
||||
protobufjs: set this to true or false
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- better-sqlite3
|
||||
- esbuild
|
||||
- protobufjs
|
||||
|
||||
overrides:
|
||||
"protobufjs@<7.5.5": "^7.5.5"
|
||||
"vite": "^8.0.5"
|
||||
"langsmith@<0.6.0": "^0.6.0"
|
||||
"picomatch@<2.3.2": "^2.3.2"
|
||||
"@qdrant/js-client-rest": "^1.18.0"
|
||||
@@ -0,0 +1,631 @@
|
||||
/**
|
||||
* Mem0 provider implementations: Platform (cloud) and OSS (self-hosted).
|
||||
*/
|
||||
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import type {
|
||||
Mem0Config,
|
||||
Mem0Provider,
|
||||
AddOptions,
|
||||
SearchOptions,
|
||||
ListOptions,
|
||||
MemoryItem,
|
||||
AddResult,
|
||||
} from "./types.ts";
|
||||
|
||||
// ============================================================================
|
||||
// Result Normalizers
|
||||
// ============================================================================
|
||||
|
||||
function normalizeMemoryItem(raw: any): MemoryItem {
|
||||
return {
|
||||
id: raw.id ?? raw.memory_id ?? "",
|
||||
memory: raw.memory ?? raw.text ?? raw.content ?? "",
|
||||
// Handle both platform (user_id, created_at) and OSS (userId, createdAt) field names
|
||||
user_id: raw.user_id ?? raw.userId,
|
||||
score: raw.score,
|
||||
categories: raw.categories,
|
||||
metadata: raw.metadata,
|
||||
created_at: raw.created_at ?? raw.createdAt,
|
||||
updated_at: raw.updated_at ?? raw.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSearchResults(raw: any): MemoryItem[] {
|
||||
// Platform API returns flat array, OSS returns { results: [...] }
|
||||
if (Array.isArray(raw)) return raw.map(normalizeMemoryItem);
|
||||
if (raw?.results && Array.isArray(raw.results))
|
||||
return raw.results.map(normalizeMemoryItem);
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeAddResult(raw: any): AddResult {
|
||||
// Handle { results: [...] } shape (both platform and OSS)
|
||||
if (raw?.results && Array.isArray(raw.results)) {
|
||||
return {
|
||||
results: raw.results.map((r: any) => ({
|
||||
id: r.id ?? r.memory_id ?? "",
|
||||
memory: r.memory ?? r.text ?? "",
|
||||
// Platform API may return PENDING status (async processing)
|
||||
// OSS stores event in metadata.event
|
||||
event:
|
||||
r.event ??
|
||||
r.metadata?.event ??
|
||||
(r.status === "PENDING" ? "ADD" : "ADD"),
|
||||
})),
|
||||
};
|
||||
}
|
||||
// Platform API without output_format returns flat array
|
||||
if (Array.isArray(raw)) {
|
||||
return {
|
||||
results: raw.map((r: any) => ({
|
||||
id: r.id ?? r.memory_id ?? "",
|
||||
memory: r.memory ?? r.text ?? "",
|
||||
event:
|
||||
r.event ??
|
||||
r.metadata?.event ??
|
||||
(r.status === "PENDING" ? "ADD" : "ADD"),
|
||||
})),
|
||||
};
|
||||
}
|
||||
return { results: [] };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Platform Provider (Mem0 Cloud)
|
||||
// ============================================================================
|
||||
|
||||
class PlatformProvider implements Mem0Provider {
|
||||
private client: any; // MemoryClient from mem0ai
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly apiKey: string,
|
||||
private readonly baseUrl?: string,
|
||||
) {}
|
||||
|
||||
private async ensureClient(): Promise<void> {
|
||||
if (this.client) return;
|
||||
if (this.initPromise) return this.initPromise;
|
||||
this.initPromise = this._init().catch((err) => {
|
||||
this.initPromise = null;
|
||||
throw err;
|
||||
});
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private async _init(): Promise<void> {
|
||||
const { default: MemoryClient } = await import("mem0ai");
|
||||
const opts: {
|
||||
apiKey: string;
|
||||
host?: string;
|
||||
} = {
|
||||
apiKey: this.apiKey,
|
||||
};
|
||||
if (this.baseUrl) opts.host = this.baseUrl;
|
||||
this.client = new MemoryClient(opts);
|
||||
}
|
||||
|
||||
async add(
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
options: AddOptions,
|
||||
): Promise<AddResult> {
|
||||
await this.ensureClient();
|
||||
// v3.0.0: SDK uses camelCase (userId, runId, etc.) - it converts to snake_case internally
|
||||
const opts: Record<string, unknown> = { userId: options.user_id };
|
||||
if (options.run_id) opts.runId = options.run_id;
|
||||
if (options.custom_instructions)
|
||||
opts.customInstructions = options.custom_instructions;
|
||||
if (options.custom_categories)
|
||||
opts.customCategories = options.custom_categories;
|
||||
if (options.source) opts.source = options.source;
|
||||
// Agentic harness: direct storage bypass
|
||||
if (options.infer !== undefined) opts.infer = options.infer;
|
||||
if (options.deduced_memories)
|
||||
opts.deducedMemories = options.deduced_memories;
|
||||
if (options.metadata) opts.metadata = options.metadata;
|
||||
|
||||
const result = await this.client.add(messages, opts);
|
||||
return normalizeAddResult(result);
|
||||
}
|
||||
|
||||
async search(query: string, options: SearchOptions): Promise<MemoryItem[]> {
|
||||
await this.ensureClient();
|
||||
// v3.0.0: SDK uses camelCase options, userId must be in filters
|
||||
const opts: Record<string, unknown> = {};
|
||||
if (options.top_k != null) opts.topK = options.top_k;
|
||||
if (options.threshold != null) opts.threshold = options.threshold;
|
||||
if (options.categories != null) opts.categories = options.categories;
|
||||
|
||||
// Build filters with user_id/run_id inside (v3.0.0 requirement)
|
||||
// Filters use snake_case as they're passed directly to the API
|
||||
// Note: source is NOT a valid filter field - only used when adding
|
||||
const baseFilters: Record<string, unknown> = { user_id: options.user_id };
|
||||
if (options.run_id) baseFilters.run_id = options.run_id;
|
||||
|
||||
if (options.filters) {
|
||||
opts.filters = { AND: [baseFilters, options.filters] };
|
||||
} else {
|
||||
opts.filters = baseFilters;
|
||||
}
|
||||
|
||||
const results = await this.client.search(query, opts);
|
||||
return normalizeSearchResults(results);
|
||||
}
|
||||
|
||||
async get(memoryId: string): Promise<MemoryItem> {
|
||||
await this.ensureClient();
|
||||
const result = await this.client.get(memoryId);
|
||||
return normalizeMemoryItem(result);
|
||||
}
|
||||
|
||||
async getAll(options: ListOptions): Promise<MemoryItem[]> {
|
||||
await this.ensureClient();
|
||||
// v3.0.0: SDK uses camelCase options, userId must be in filters
|
||||
// Filters use snake_case as they're passed directly to the API
|
||||
// Note: source is NOT a valid filter field - only used when adding
|
||||
const filters: Record<string, unknown> = { user_id: options.user_id };
|
||||
if (options.run_id) filters.run_id = options.run_id;
|
||||
|
||||
const opts: Record<string, unknown> = { filters };
|
||||
if (options.page_size != null) opts.pageSize = options.page_size;
|
||||
|
||||
const results = await this.client.getAll(opts);
|
||||
if (Array.isArray(results)) return results.map(normalizeMemoryItem);
|
||||
// Some versions return { results: [...] }
|
||||
if (results?.results && Array.isArray(results.results))
|
||||
return results.results.map(normalizeMemoryItem);
|
||||
return [];
|
||||
}
|
||||
|
||||
async update(memoryId: string, text: string): Promise<void> {
|
||||
await this.ensureClient();
|
||||
await this.client.update(memoryId, { text });
|
||||
}
|
||||
|
||||
async delete(memoryId: string): Promise<void> {
|
||||
await this.ensureClient();
|
||||
await this.client.delete(memoryId);
|
||||
}
|
||||
|
||||
async deleteAll(userId: string): Promise<void> {
|
||||
await this.ensureClient();
|
||||
// v3.0.0: SDK uses camelCase
|
||||
await this.client.deleteAll({ userId });
|
||||
}
|
||||
|
||||
async history(memoryId: string): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
old_memory: string;
|
||||
new_memory: string;
|
||||
event: string;
|
||||
created_at: string;
|
||||
}>
|
||||
> {
|
||||
await this.ensureClient();
|
||||
const result = await this.client.history(memoryId);
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Open-Source Provider (Self-hosted)
|
||||
// ============================================================================
|
||||
|
||||
class OSSProvider implements Mem0Provider {
|
||||
private static _warnPatched = false;
|
||||
private memory: any; // Memory from mem0ai/oss
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly ossConfig?: Mem0Config["oss"],
|
||||
private readonly customInstructions?: string,
|
||||
private readonly resolvePath?: (p: string) => string,
|
||||
) {}
|
||||
|
||||
private async ensureMemory(): Promise<void> {
|
||||
if (this.memory) return;
|
||||
if (this.initPromise) return this.initPromise;
|
||||
this.initPromise = this._init().catch((err) => {
|
||||
this.initPromise = null;
|
||||
throw err;
|
||||
});
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private _buildConfig(disableHistory = false): Record<string, unknown> {
|
||||
// v3.0.0: removed version field
|
||||
const config: Record<string, unknown> = {};
|
||||
|
||||
const defaultEmbedder = {
|
||||
provider: "openai",
|
||||
config: { model: "text-embedding-3-small" },
|
||||
};
|
||||
const defaultLlm = { provider: "openai", config: { model: "gpt-5-mini" } };
|
||||
|
||||
const stripEmpty = (obj: Record<string, unknown>) => {
|
||||
const out = { ...obj };
|
||||
for (const k of Object.keys(out)) {
|
||||
if (out[k] === "") delete out[k];
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
if (this.ossConfig?.embedder) {
|
||||
const ec = stripEmpty(this.ossConfig.embedder.config ?? {});
|
||||
if (ec.host && !ec.url) {
|
||||
ec.url = ec.host;
|
||||
delete ec.host;
|
||||
}
|
||||
config.embedder = {
|
||||
provider: this.ossConfig.embedder.provider || defaultEmbedder.provider,
|
||||
config: { ...defaultEmbedder.config, ...ec },
|
||||
};
|
||||
} else {
|
||||
config.embedder = defaultEmbedder;
|
||||
}
|
||||
|
||||
if (this.ossConfig?.llm) {
|
||||
const lc = stripEmpty(this.ossConfig.llm.config ?? {});
|
||||
if (lc.host && !lc.url) {
|
||||
lc.url = lc.host;
|
||||
delete lc.host;
|
||||
}
|
||||
config.llm = {
|
||||
provider: this.ossConfig.llm.provider || defaultLlm.provider,
|
||||
config: { ...defaultLlm.config, ...lc },
|
||||
};
|
||||
} else {
|
||||
config.llm = defaultLlm;
|
||||
}
|
||||
|
||||
if (this.ossConfig?.vectorStore) {
|
||||
const vs = { ...this.ossConfig.vectorStore } as Record<string, unknown>;
|
||||
const vsCfg = (vs.config ?? {}) as Record<string, unknown>;
|
||||
// Resolve dims from embedder config if vector store doesn't have them
|
||||
const embedderDims = (config.embedder as any)?.config?.embeddingDims;
|
||||
if (!vsCfg.dimension && embedderDims) {
|
||||
vsCfg.dimension = embedderDims;
|
||||
}
|
||||
// Sync both dimension fields — Qdrant reads dimension, PGVector reads embeddingModelDims
|
||||
if (vsCfg.dimension && !vsCfg.embeddingModelDims) {
|
||||
vsCfg.embeddingModelDims = vsCfg.dimension;
|
||||
} else if (vsCfg.embeddingModelDims && !vsCfg.dimension) {
|
||||
vsCfg.dimension = vsCfg.embeddingModelDims;
|
||||
}
|
||||
vs.config = vsCfg;
|
||||
config.vectorStore = vs;
|
||||
}
|
||||
|
||||
if (this.ossConfig?.historyDbPath) {
|
||||
const raw = this.ossConfig.historyDbPath;
|
||||
const isAbsolute = raw.startsWith("/") || /^[A-Za-z]:[/\\]/.test(raw);
|
||||
const dbPath =
|
||||
isAbsolute || !this.resolvePath ? raw : this.resolvePath(raw);
|
||||
config.historyDbPath = dbPath;
|
||||
}
|
||||
|
||||
if (disableHistory || this.ossConfig?.disableHistory) {
|
||||
config.disableHistory = true;
|
||||
}
|
||||
|
||||
// v3.0.0: customPrompt renamed to customInstructions
|
||||
if (this.customInstructions) config.customInstructions = this.customInstructions;
|
||||
return config;
|
||||
}
|
||||
|
||||
private async _init(): Promise<void> {
|
||||
const mod = await import("mem0ai/oss");
|
||||
const Memory = mod.Memory;
|
||||
for (const cls of ["PGVector", "RedisDB", "Qdrant"]) {
|
||||
const VectorCls = (mod as any)[cls];
|
||||
if (!VectorCls || VectorCls.prototype.__patched) continue;
|
||||
const origInit = VectorCls.prototype.initialize;
|
||||
VectorCls.prototype.initialize = function (this: any) {
|
||||
if (!this.config?.embeddingModelDims && this.config?.dimension) {
|
||||
this.config.embeddingModelDims = this.config.dimension;
|
||||
}
|
||||
// Qdrant reads this.dimension directly
|
||||
if (!this.dimension && this.config?.dimension) {
|
||||
this.dimension = this.config.dimension;
|
||||
}
|
||||
// Skip premature constructor call when dimensions unknown
|
||||
const dims = this.config?.embeddingModelDims ?? this.dimension;
|
||||
if (!dims) return Promise.resolve();
|
||||
// Run the real initialize only once
|
||||
if (!this._initializePromise) {
|
||||
this._initializePromise = origInit.call(this);
|
||||
}
|
||||
return this._initializePromise;
|
||||
};
|
||||
VectorCls.prototype.__patched = true;
|
||||
}
|
||||
|
||||
// Proactively detect broken better-sqlite3 native binding (e.g. Node
|
||||
// version mismatch) and skip history to avoid noisy constructor failures.
|
||||
let sqliteOk = true;
|
||||
if (!this.ossConfig?.disableHistory) {
|
||||
try {
|
||||
// @ts-ignore — better-sqlite3 is a transitive dep; no types in this package
|
||||
const bs3Mod = await import("better-sqlite3");
|
||||
const BS3 = bs3Mod.default ?? bs3Mod;
|
||||
const testDb = new (BS3 as any)(":memory:");
|
||||
(testDb as any).close();
|
||||
} catch {
|
||||
sqliteOk = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!OSSProvider._warnPatched) {
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
if (typeof args[0] === "string" && args[0].includes("checkCompatibility")) return;
|
||||
origWarn.apply(console, args);
|
||||
};
|
||||
OSSProvider._warnPatched = true;
|
||||
}
|
||||
|
||||
let mem: any;
|
||||
try {
|
||||
mem = new Memory(this._buildConfig(!sqliteOk));
|
||||
} catch (err) {
|
||||
if (!this.ossConfig?.disableHistory && sqliteOk) {
|
||||
console.warn(
|
||||
"[mem0] Memory initialization failed, retrying with history disabled:",
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
mem = new Memory(this._buildConfig(true));
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// v3.0.0: entity IDs must be in filters, not top-level
|
||||
await mem.getAll({ filters: { user_id: "__mem0_warmup__" } });
|
||||
|
||||
this.memory = mem;
|
||||
}
|
||||
|
||||
async add(
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
options: AddOptions,
|
||||
): Promise<AddResult> {
|
||||
await this.ensureMemory();
|
||||
// OSS SDK uses camelCase: userId/runId, not user_id/run_id
|
||||
const addOpts: Record<string, unknown> = { userId: options.user_id };
|
||||
if (options.run_id) addOpts.runId = options.run_id;
|
||||
if (options.source) addOpts.source = options.source;
|
||||
// Agentic harness: direct storage bypass
|
||||
if (options.infer !== undefined) addOpts.infer = options.infer;
|
||||
if (options.metadata) addOpts.metadata = options.metadata;
|
||||
// v3.0.0: removed expiration_date, immutable
|
||||
|
||||
// OSS SDK doesn't support deduced_memories — when infer=false, it stores
|
||||
// raw message content directly. Rewrite messages to contain the facts so
|
||||
// OSS stores the right text.
|
||||
let effectiveMessages = messages;
|
||||
if (options.infer === false && options.deduced_memories?.length) {
|
||||
effectiveMessages = options.deduced_memories.map((fact) => ({
|
||||
role: "user",
|
||||
content: fact,
|
||||
}));
|
||||
}
|
||||
|
||||
const result = await this.memory.add(effectiveMessages, addOpts);
|
||||
return normalizeAddResult(result);
|
||||
}
|
||||
|
||||
async search(query: string, options: SearchOptions): Promise<MemoryItem[]> {
|
||||
await this.ensureMemory();
|
||||
// v3.0.0: entity IDs must be in filters, not top-level; limit renamed to topK
|
||||
const opts: Record<string, unknown> = {};
|
||||
if (options.top_k != null) opts.topK = options.top_k;
|
||||
if (options.threshold != null) opts.threshold = options.threshold;
|
||||
|
||||
// Build filters with user_id/run_id inside (v3.0.0 requirement)
|
||||
// Filters use snake_case as they're passed directly to the vector store
|
||||
// Note: source is NOT a valid filter field - only used when adding
|
||||
const baseFilters: Record<string, unknown> = { user_id: options.user_id };
|
||||
if (options.run_id) baseFilters.run_id = options.run_id;
|
||||
|
||||
// Merge with any additional user-provided filters
|
||||
if (options.filters) {
|
||||
opts.filters = { AND: [baseFilters, options.filters] };
|
||||
} else {
|
||||
opts.filters = baseFilters;
|
||||
}
|
||||
|
||||
const results = await this.memory.search(query, opts);
|
||||
const normalized = normalizeSearchResults(results);
|
||||
|
||||
// Filter results by threshold if specified (client-side filtering as fallback)
|
||||
if (options.threshold != null) {
|
||||
return normalized.filter(
|
||||
(item) => (item.score ?? 0) >= options.threshold!,
|
||||
);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async get(memoryId: string): Promise<MemoryItem> {
|
||||
await this.ensureMemory();
|
||||
const result = await this.memory.get(memoryId);
|
||||
return normalizeMemoryItem(result);
|
||||
}
|
||||
|
||||
async getAll(options: ListOptions): Promise<MemoryItem[]> {
|
||||
await this.ensureMemory();
|
||||
// v3.0.0: entity IDs must be in filters, not top-level
|
||||
// Filters use snake_case as they're passed directly to the vector store
|
||||
// Note: source is NOT a valid filter field - only used when adding
|
||||
const filters: Record<string, unknown> = { user_id: options.user_id };
|
||||
if (options.run_id) filters.run_id = options.run_id;
|
||||
|
||||
// OSS SDK uses topK for limiting results (not pageSize like Platform)
|
||||
const getAllOpts: Record<string, unknown> = { filters };
|
||||
if (options.page_size != null) getAllOpts.topK = options.page_size;
|
||||
|
||||
const results = await this.memory.getAll(getAllOpts);
|
||||
if (Array.isArray(results)) return results.map(normalizeMemoryItem);
|
||||
if (results?.results && Array.isArray(results.results))
|
||||
return results.results.map(normalizeMemoryItem);
|
||||
return [];
|
||||
}
|
||||
|
||||
async update(memoryId: string, text: string): Promise<void> {
|
||||
await this.ensureMemory();
|
||||
await this.memory.update(memoryId, text);
|
||||
}
|
||||
|
||||
async delete(memoryId: string): Promise<void> {
|
||||
await this.ensureMemory();
|
||||
await this.memory.delete(memoryId);
|
||||
}
|
||||
|
||||
async deleteAll(userId: string): Promise<void> {
|
||||
await this.ensureMemory();
|
||||
await this.memory.deleteAll({ userId });
|
||||
}
|
||||
|
||||
async history(memoryId: string): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
old_memory: string;
|
||||
new_memory: string;
|
||||
event: string;
|
||||
created_at: string;
|
||||
}>
|
||||
> {
|
||||
await this.ensureMemory();
|
||||
try {
|
||||
const result = await this.memory.history(memoryId);
|
||||
return Array.isArray(result) ? result : [];
|
||||
} catch (err) {
|
||||
// OSS may not support history depending on config (e.g. disableHistory)
|
||||
console.warn(
|
||||
"[mem0] OSS history() failed:",
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Provider Factory
|
||||
// ============================================================================
|
||||
|
||||
export function createProvider(
|
||||
cfg: Mem0Config,
|
||||
api: OpenClawPluginApi,
|
||||
): Mem0Provider {
|
||||
if (cfg.mode === "open-source") {
|
||||
// v3.0.0: use customInstructions (was customPrompt)
|
||||
return new OSSProvider(cfg.oss, cfg.customInstructions, (p) =>
|
||||
api.resolvePath(p),
|
||||
);
|
||||
}
|
||||
|
||||
return new PlatformProvider(cfg.apiKey!, cfg.baseUrl);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Provider-to-Backend Adapter
|
||||
// ============================================================================
|
||||
|
||||
import type { Backend } from "./backend/base.ts";
|
||||
|
||||
/**
|
||||
* Wraps an existing Mem0Provider as the Backend interface.
|
||||
* Used in OSS mode where PlatformBackend cannot be used.
|
||||
* Platform-only methods (entities, events) throw clear errors.
|
||||
*/
|
||||
export function providerToBackend(
|
||||
provider: Mem0Provider,
|
||||
userId: string,
|
||||
): Backend {
|
||||
return {
|
||||
async add(content, messages, opts = {}) {
|
||||
const msgs = messages ?? (content ? [{ role: "user", content }] : []);
|
||||
// v3.0.0: removed immutable, expiration_date
|
||||
const result = await provider.add(
|
||||
msgs as Array<{ role: string; content: string }>,
|
||||
{
|
||||
user_id: opts.userId ?? userId,
|
||||
source: "OPENCLAW",
|
||||
...(opts.runId && { run_id: opts.runId }),
|
||||
...(opts.metadata && { metadata: opts.metadata }),
|
||||
...(opts.infer === false && { infer: false }),
|
||||
},
|
||||
);
|
||||
return result as unknown as Record<string, unknown>;
|
||||
},
|
||||
|
||||
async search(query, opts = {}) {
|
||||
// v3.0.0: removed keyword_search, reranking
|
||||
const results = await provider.search(query, {
|
||||
user_id: opts.userId ?? userId,
|
||||
top_k: opts.topK,
|
||||
threshold: opts.threshold,
|
||||
filters: opts.filters,
|
||||
source: "OPENCLAW",
|
||||
});
|
||||
return results as unknown as Record<string, unknown>[];
|
||||
},
|
||||
|
||||
async get(memoryId) {
|
||||
const item = await provider.get(memoryId);
|
||||
return item as unknown as Record<string, unknown>;
|
||||
},
|
||||
|
||||
async listMemories(opts = {}) {
|
||||
const items = await provider.getAll({
|
||||
user_id: opts.userId ?? userId,
|
||||
page_size: opts.pageSize,
|
||||
source: "OPENCLAW",
|
||||
});
|
||||
return items as unknown as Record<string, unknown>[];
|
||||
},
|
||||
|
||||
async update(memoryId, content, metadata) {
|
||||
if (content) await provider.update(memoryId, content);
|
||||
if (metadata) {
|
||||
// OSS provider doesn't support metadata-only updates — log warning
|
||||
console.warn(
|
||||
"providerToBackend: metadata updates are not supported in OSS mode, only text updates are applied",
|
||||
);
|
||||
}
|
||||
return { id: memoryId, updated: true };
|
||||
},
|
||||
|
||||
async delete(memoryId, opts = {}) {
|
||||
if (opts.all) {
|
||||
await provider.deleteAll(opts.userId ?? userId);
|
||||
return { deleted: "all" };
|
||||
}
|
||||
if (memoryId) {
|
||||
await provider.delete(memoryId);
|
||||
return { deleted: memoryId };
|
||||
}
|
||||
throw new Error("Either memoryId or all is required");
|
||||
},
|
||||
|
||||
async deleteEntities() {
|
||||
throw new Error("Entity management is only available in platform mode.");
|
||||
},
|
||||
async status() {
|
||||
return { connected: true, backend: "oss" };
|
||||
},
|
||||
async entities() {
|
||||
throw new Error("Entity management is only available in platform mode.");
|
||||
},
|
||||
async listEvents() {
|
||||
throw new Error("Event management is only available in platform mode.");
|
||||
},
|
||||
async getEvent() {
|
||||
throw new Error("Event management is only available in platform mode.");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Public Artifacts Provider for OpenClaw memory-wiki bridge mode.
|
||||
*
|
||||
* Exposes Mem0 memories and dream state as artifacts that can be
|
||||
* consumed by other plugins (e.g., memory-wiki in bridge mode).
|
||||
*/
|
||||
|
||||
import type { Mem0Provider, MemoryItem, Mem0Config } from "./types.ts";
|
||||
import type { MemoryArtifact } from "openclaw/plugin-sdk";
|
||||
import { getDreamState } from "./dream-gate.ts";
|
||||
|
||||
export interface PublicArtifactsContext {
|
||||
provider: Mem0Provider;
|
||||
cfg: Mem0Config;
|
||||
stateDir?: string;
|
||||
effectiveUserId: (sessionKey?: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a publicArtifacts provider that exposes Mem0 data to other plugins.
|
||||
*/
|
||||
export function createPublicArtifactsProvider(ctx: PublicArtifactsContext) {
|
||||
return {
|
||||
async listArtifacts(options?: {
|
||||
userId?: string;
|
||||
types?: string[];
|
||||
limit?: number;
|
||||
}): Promise<MemoryArtifact[]> {
|
||||
const artifacts: MemoryArtifact[] = [];
|
||||
const userId = options?.userId ?? ctx.effectiveUserId();
|
||||
const types = options?.types ?? ["memory", "dream", "entity"];
|
||||
const limit = options?.limit ?? 100;
|
||||
|
||||
try {
|
||||
// Memory artifacts
|
||||
if (types.includes("memory")) {
|
||||
const memories = await ctx.provider.getAll({
|
||||
user_id: userId,
|
||||
page_size: limit,
|
||||
});
|
||||
|
||||
for (const mem of memories) {
|
||||
artifacts.push(memoryToArtifact(mem));
|
||||
}
|
||||
}
|
||||
|
||||
// Dream state artifact (if dream enabled and stateDir available)
|
||||
if (types.includes("dream") && ctx.stateDir && ctx.cfg.skills?.dream?.enabled) {
|
||||
const dreamArtifact = getDreamArtifact(ctx.stateDir, userId);
|
||||
if (dreamArtifact) {
|
||||
artifacts.push(dreamArtifact);
|
||||
}
|
||||
}
|
||||
|
||||
// Entity artifacts (grouped memories by category)
|
||||
if (types.includes("entity")) {
|
||||
const entityArtifacts = extractEntityArtifacts(artifacts.filter(a => a.type === "memory"));
|
||||
artifacts.push(...entityArtifacts);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[mem0] publicArtifacts.listArtifacts failed:",
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
|
||||
return artifacts.slice(0, limit);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a MemoryItem to a MemoryArtifact.
|
||||
*/
|
||||
function memoryToArtifact(mem: MemoryItem): MemoryArtifact {
|
||||
return {
|
||||
id: `mem0:memory:${mem.id}`,
|
||||
type: "memory",
|
||||
title: mem.memory.slice(0, 80) + (mem.memory.length > 80 ? "..." : ""),
|
||||
content: mem.memory,
|
||||
metadata: {
|
||||
score: mem.score,
|
||||
categories: mem.categories,
|
||||
user_id: mem.user_id,
|
||||
...mem.metadata,
|
||||
},
|
||||
createdAt: mem.created_at,
|
||||
updatedAt: mem.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dream consolidation state as an artifact.
|
||||
*/
|
||||
function getDreamArtifact(stateDir: string, userId: string): MemoryArtifact | null {
|
||||
try {
|
||||
const state = getDreamState(stateDir);
|
||||
if (state.lastConsolidatedAt === 0) {
|
||||
return null; // No consolidation has occurred yet
|
||||
}
|
||||
|
||||
const lastDate = new Date(state.lastConsolidatedAt).toISOString();
|
||||
return {
|
||||
id: `mem0:dream:${userId}:state`,
|
||||
type: "dream",
|
||||
title: `Dream State (last: ${lastDate.split("T")[0]})`,
|
||||
content: [
|
||||
`Last consolidation: ${lastDate}`,
|
||||
`Sessions since: ${state.sessionsSince}`,
|
||||
`Last session: ${state.lastSessionId ?? "none"}`,
|
||||
].join("\n"),
|
||||
metadata: {
|
||||
lastConsolidatedAt: state.lastConsolidatedAt,
|
||||
sessionsSince: state.sessionsSince,
|
||||
lastSessionId: state.lastSessionId,
|
||||
user_id: userId,
|
||||
},
|
||||
updatedAt: lastDate,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract entity artifacts from memories (grouped by category).
|
||||
*/
|
||||
function extractEntityArtifacts(memoryArtifacts: MemoryArtifact[]): MemoryArtifact[] {
|
||||
const byCategory = new Map<string, MemoryArtifact[]>();
|
||||
|
||||
for (const artifact of memoryArtifacts) {
|
||||
const categories = (artifact.metadata?.categories as string[]) ?? ["uncategorized"];
|
||||
for (const cat of categories) {
|
||||
const existing = byCategory.get(cat) ?? [];
|
||||
existing.push(artifact);
|
||||
byCategory.set(cat, existing);
|
||||
}
|
||||
}
|
||||
|
||||
const entities: MemoryArtifact[] = [];
|
||||
for (const [category, mems] of byCategory) {
|
||||
if (mems.length >= 2) {
|
||||
entities.push({
|
||||
id: `mem0:entity:${category}`,
|
||||
type: "entity",
|
||||
title: `${category.charAt(0).toUpperCase() + category.slice(1)} (${mems.length} memories)`,
|
||||
content: mems.map(m => `- ${m.content}`).join("\n"),
|
||||
metadata: {
|
||||
category,
|
||||
memoryCount: mems.length,
|
||||
memoryIds: mems.map(m => m.id),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Token-budgeted, category-ranked recall engine.
|
||||
*
|
||||
* Replaces naive "dump all search results" with:
|
||||
* 1. Search memories with threshold filtering
|
||||
* 2. Rank by category priority (identity first)
|
||||
* 3. Token-budget the results
|
||||
* 4. Format by category with importance scores
|
||||
*/
|
||||
|
||||
import type {
|
||||
Mem0Provider,
|
||||
MemoryItem,
|
||||
SkillsConfig,
|
||||
SearchOptions,
|
||||
} from "./types.ts";
|
||||
|
||||
// ============================================================================
|
||||
// Defaults
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_TOKEN_BUDGET = 1500;
|
||||
const DEFAULT_MAX_MEMORIES = 15;
|
||||
const DEFAULT_THRESHOLD = 0.4;
|
||||
const DEFAULT_CATEGORY_ORDER = [
|
||||
"identity",
|
||||
"configuration",
|
||||
"rule",
|
||||
"preference",
|
||||
"decision",
|
||||
"technical",
|
||||
"relationship",
|
||||
"project",
|
||||
"operational",
|
||||
];
|
||||
|
||||
// Rough token estimate: ~4 chars per token for English text
|
||||
const CHARS_PER_TOKEN = 4;
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface RecallResult {
|
||||
/** Formatted context string for injection */
|
||||
context: string;
|
||||
/** Raw memories retrieved */
|
||||
memories: MemoryItem[];
|
||||
/** Token count estimate */
|
||||
tokenEstimate: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Category Detection
|
||||
// ============================================================================
|
||||
|
||||
function getMemoryCategory(memory: MemoryItem): string {
|
||||
// Check metadata first (skill-stored memories have explicit category)
|
||||
if (
|
||||
memory.metadata?.category &&
|
||||
typeof memory.metadata.category === "string"
|
||||
) {
|
||||
return memory.metadata.category;
|
||||
}
|
||||
// Check categories array (mem0-extracted memories)
|
||||
if (memory.categories?.length) {
|
||||
return memory.categories[0];
|
||||
}
|
||||
return "uncategorized";
|
||||
}
|
||||
|
||||
function getMemoryImportance(memory: MemoryItem): number {
|
||||
if (
|
||||
memory.metadata?.importance &&
|
||||
typeof memory.metadata.importance === "number"
|
||||
) {
|
||||
return memory.metadata.importance;
|
||||
}
|
||||
// Default importance by category
|
||||
const cat = getMemoryCategory(memory);
|
||||
const defaults: Record<string, number> = {
|
||||
identity: 0.95,
|
||||
configuration: 0.95,
|
||||
rule: 0.9,
|
||||
preference: 0.85,
|
||||
decision: 0.8,
|
||||
technical: 0.8,
|
||||
relationship: 0.75,
|
||||
project: 0.75,
|
||||
operational: 0.6,
|
||||
};
|
||||
return defaults[cat] ?? 0.5;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Token Estimation
|
||||
// ============================================================================
|
||||
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Memory Ranking
|
||||
// ============================================================================
|
||||
|
||||
function rankMemories(
|
||||
memories: MemoryItem[],
|
||||
categoryOrder: string[],
|
||||
): MemoryItem[] {
|
||||
const orderMap = new Map(categoryOrder.map((cat, i) => [cat, i]));
|
||||
|
||||
return [...memories].sort((a, b) => {
|
||||
const catA = getMemoryCategory(a);
|
||||
const catB = getMemoryCategory(b);
|
||||
const orderA = orderMap.get(catA) ?? 999;
|
||||
const orderB = orderMap.get(catB) ?? 999;
|
||||
|
||||
// Primary sort: category priority
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
|
||||
// Secondary sort: importance (higher first)
|
||||
const impA = getMemoryImportance(a);
|
||||
const impB = getMemoryImportance(b);
|
||||
if (impA !== impB) return impB - impA;
|
||||
|
||||
// Tertiary sort: search relevance score
|
||||
return (b.score ?? 0) - (a.score ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Token Budgeting
|
||||
// ============================================================================
|
||||
|
||||
function budgetMemories(
|
||||
rankedMemories: MemoryItem[],
|
||||
tokenBudget: number,
|
||||
maxMemories: number,
|
||||
identityAlwaysInclude: boolean,
|
||||
): MemoryItem[] {
|
||||
const selected: MemoryItem[] = [];
|
||||
let usedTokens = 0;
|
||||
|
||||
for (const memory of rankedMemories) {
|
||||
if (selected.length >= maxMemories) break;
|
||||
|
||||
const memTokens = estimateTokens(memory.memory);
|
||||
const isIdentity =
|
||||
getMemoryCategory(memory) === "identity" ||
|
||||
getMemoryCategory(memory) === "configuration";
|
||||
|
||||
// Identity/config always included if flag is set
|
||||
if (identityAlwaysInclude && isIdentity) {
|
||||
selected.push(memory);
|
||||
usedTokens += memTokens;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Budget check for non-identity memories
|
||||
if (usedTokens + memTokens > tokenBudget) continue;
|
||||
|
||||
selected.push(memory);
|
||||
usedTokens += memTokens;
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Formatting
|
||||
// ============================================================================
|
||||
|
||||
function formatRecalledMemories(
|
||||
memories: MemoryItem[],
|
||||
userId: string,
|
||||
): string {
|
||||
if (memories.length === 0) {
|
||||
return `<recalled-memories>\nNo stored memories found for "${userId}".\n</recalled-memories>`;
|
||||
}
|
||||
|
||||
// Group by category
|
||||
const grouped = new Map<string, MemoryItem[]>();
|
||||
for (const mem of memories) {
|
||||
const cat = getMemoryCategory(mem);
|
||||
const existing = grouped.get(cat) || [];
|
||||
existing.push(mem);
|
||||
grouped.set(cat, existing);
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
`<recalled-memories>`,
|
||||
`Stored memories for "${userId}" (${memories.length} total, ranked by importance):`,
|
||||
"",
|
||||
];
|
||||
|
||||
// Format each category group
|
||||
for (const [category, mems] of grouped.entries()) {
|
||||
const label = category.charAt(0).toUpperCase() + category.slice(1);
|
||||
lines.push(`${label}:`);
|
||||
for (const mem of mems) {
|
||||
const imp = getMemoryImportance(mem);
|
||||
const cats = mem.categories?.length
|
||||
? ` [${mem.categories.join(", ")}]`
|
||||
: "";
|
||||
lines.push(`- ${mem.memory}${cats} (${Math.round(imp * 100)}%)`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("</recalled-memories>");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Query Sanitization
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Strip OpenClaw metadata prefix from event.prompt before using as search query.
|
||||
* This only removes framework noise (sender metadata, timestamps) — NOT
|
||||
* conversational rewriting. Query rewriting is the agent's responsibility
|
||||
* via the skill protocol (the agent formulates search queries with context).
|
||||
*/
|
||||
export function sanitizeQuery(raw: string): string {
|
||||
let cleaned = raw.replace(
|
||||
/Sender\s*\(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi,
|
||||
"",
|
||||
);
|
||||
cleaned = cleaned.replace(/^\[.*?\]\s*/g, "");
|
||||
cleaned = cleaned.trim();
|
||||
return cleaned || raw;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Perform token-budgeted, category-ranked recall.
|
||||
*/
|
||||
export async function recall(
|
||||
provider: Mem0Provider,
|
||||
query: string,
|
||||
userId: string,
|
||||
config: SkillsConfig = {},
|
||||
sessionId?: string,
|
||||
): Promise<RecallResult> {
|
||||
const recallConfig = config.recall ?? {};
|
||||
const tokenBudget = recallConfig.tokenBudget ?? DEFAULT_TOKEN_BUDGET;
|
||||
const maxMemories = recallConfig.maxMemories ?? DEFAULT_MAX_MEMORIES;
|
||||
const threshold = recallConfig.threshold ?? DEFAULT_THRESHOLD;
|
||||
const categoryOrder = recallConfig.categoryOrder ?? DEFAULT_CATEGORY_ORDER;
|
||||
const identityAlwaysInclude = recallConfig.identityAlwaysInclude !== false;
|
||||
|
||||
// Build search options (v3.0.0: keyword_search, reranking, filter_memories removed)
|
||||
const searchOpts: SearchOptions = {
|
||||
user_id: userId,
|
||||
top_k: maxMemories * 2, // Over-fetch for ranking
|
||||
threshold,
|
||||
source: "OPENCLAW",
|
||||
};
|
||||
|
||||
// Sanitize query: strip OpenClaw metadata prefix before searching
|
||||
const cleanQuery = sanitizeQuery(query);
|
||||
|
||||
// Search long-term memories
|
||||
let longTermMemories: MemoryItem[] = [];
|
||||
try {
|
||||
longTermMemories = await provider.search(cleanQuery, searchOpts);
|
||||
} catch (err) {
|
||||
// Graceful degradation — recall failure shouldn't block the agent
|
||||
console.warn(
|
||||
"[mem0] Recall search failed:",
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
|
||||
// Search session memories if we have a session
|
||||
let sessionMemories: MemoryItem[] = [];
|
||||
if (sessionId) {
|
||||
try {
|
||||
sessionMemories = await provider.search(cleanQuery, {
|
||||
...searchOpts,
|
||||
run_id: sessionId,
|
||||
top_k: 5,
|
||||
});
|
||||
} catch {
|
||||
// Session search failure is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate: session memories that are also in long-term
|
||||
const longTermIds = new Set(longTermMemories.map((m) => m.id));
|
||||
const uniqueSession = sessionMemories.filter((m) => !longTermIds.has(m.id));
|
||||
|
||||
// Combine and rank
|
||||
const allMemories = [...longTermMemories, ...uniqueSession];
|
||||
const ranked = rankMemories(allMemories, categoryOrder);
|
||||
const budgeted = budgetMemories(
|
||||
ranked,
|
||||
tokenBudget,
|
||||
maxMemories,
|
||||
identityAlwaysInclude,
|
||||
);
|
||||
|
||||
// Format for injection
|
||||
const context = formatRecalledMemories(budgeted, userId);
|
||||
const tokenEstimate = estimateTokens(context);
|
||||
|
||||
return { context, memories: budgeted, tokenEstimate };
|
||||
}
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Patch ~/.openclaw/openclaw.json with skills-mode settings."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
api_key = os.environ.get("MEM0_API_KEY", "")
|
||||
user_id = os.environ.get("MEM0_USER_ID", os.environ.get("USER", "default"))
|
||||
config_path = os.path.expanduser("~/.openclaw/openclaw.json")
|
||||
|
||||
if not api_key:
|
||||
print("Error: MEM0_API_KEY not set.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.exists(config_path):
|
||||
print(f"Error: {config_path} not found. Run 'openclaw configure' first.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Backup original config
|
||||
backup_path = config_path + ".pre-skills-backup"
|
||||
if not os.path.exists(backup_path):
|
||||
shutil.copy2(config_path, backup_path)
|
||||
print(f" Backed up config to {backup_path}")
|
||||
else:
|
||||
print(f" Backup already exists at {backup_path}")
|
||||
|
||||
with open(config_path) as f:
|
||||
cfg = json.load(f)
|
||||
|
||||
# 1. Tools profile = full (exposes plugin tools to the model)
|
||||
cfg["tools"] = {"profile": "full"}
|
||||
|
||||
# 2. Disable built-in session-memory hook
|
||||
cfg.setdefault("hooks", {}).setdefault("internal", {}).setdefault("entries", {})
|
||||
cfg["hooks"]["internal"]["entries"]["session-memory"] = {"enabled": False}
|
||||
|
||||
# 3. Plugin config with skills enabled
|
||||
entries = cfg.setdefault("plugins", {}).setdefault("entries", {})
|
||||
entries["openclaw-mem0"] = {
|
||||
"enabled": True,
|
||||
"config": {
|
||||
"apiKey": api_key,
|
||||
"userId": user_id,
|
||||
"skills": {
|
||||
"triage": {"enabled": True},
|
||||
"recall": {
|
||||
"enabled": True,
|
||||
"tokenBudget": 1500,
|
||||
"rerank": True,
|
||||
"keywordSearch": True,
|
||||
"identityAlwaysInclude": True,
|
||||
},
|
||||
"dream": {"enabled": True},
|
||||
"domain": "companion",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
|
||||
print(" Config updated:")
|
||||
print(" tools.profile = full")
|
||||
print(" session-memory = disabled")
|
||||
print(f" skills = enabled (user: {user_id}, domain: companion)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Tests for path traversal prevention in skill-loader.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { safePath, loadSkill } from "./skill-loader.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// safePath — path containment
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("safePath", () => {
|
||||
it("rejects parent directory traversal", () => {
|
||||
expect(safePath("../../etc/passwd")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects deep traversal", () => {
|
||||
expect(safePath("../../../etc/shadow")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects traversal in nested segment", () => {
|
||||
expect(safePath("valid", "../../etc")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects bare '..' as segment", () => {
|
||||
expect(safePath("..")).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts valid skill paths", () => {
|
||||
expect(safePath("memory-triage", "SKILL.md")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("accepts valid domain overlay paths", () => {
|
||||
expect(safePath("memory-triage", "domains", "companion.md")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty segments that resolve to skills root with subpath escape", () => {
|
||||
// path.resolve("skills", "", "../../etc") still escapes
|
||||
expect(safePath("", "../../etc")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects traversal disguised with valid prefix", () => {
|
||||
expect(safePath("memory-triage/../../etc/passwd")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// loadSkill — integration tests for traversal prevention
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("loadSkill path traversal", () => {
|
||||
it("returns null for traversal skillName", () => {
|
||||
expect(loadSkill("../../etc/passwd")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for deep traversal skillName", () => {
|
||||
expect(loadSkill("../../../..")).toBeNull();
|
||||
});
|
||||
|
||||
it("loads a valid skill", () => {
|
||||
const result = loadSkill("memory-triage");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.prompt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("blocks domain traversal while loading valid skill", () => {
|
||||
// Valid skill name, malicious domain — should load skill but skip the overlay
|
||||
const result = loadSkill("memory-triage", { domain: "../../etc/passwd" });
|
||||
// Should still succeed (skill itself is valid), domain overlay is just skipped
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.prompt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,487 @@
|
||||
/**
|
||||
* Skill Loader — reads skill markdown files, merges domain overlays,
|
||||
* injects user config, and produces the final injectable prompt string.
|
||||
*/
|
||||
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { SkillsConfig, CategoryConfig } from "./types.ts";
|
||||
import { readText, exists } from "./fs-safe.ts";
|
||||
|
||||
// ============================================================================
|
||||
// Defaults
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = {
|
||||
configuration: { importance: 0.95, ttl: null },
|
||||
rule: { importance: 0.9, ttl: null },
|
||||
identity: { importance: 0.95, ttl: null, immutable: true },
|
||||
preference: { importance: 0.85, ttl: null },
|
||||
decision: { importance: 0.8, ttl: null },
|
||||
technical: { importance: 0.8, ttl: null },
|
||||
relationship: { importance: 0.75, ttl: null },
|
||||
project: { importance: 0.75, ttl: "90d" },
|
||||
operational: { importance: 0.6, ttl: "7d" },
|
||||
};
|
||||
|
||||
const DEFAULT_CREDENTIAL_PATTERNS = [
|
||||
"sk-",
|
||||
"m0-",
|
||||
"ghp_",
|
||||
"AKIA",
|
||||
"ak_",
|
||||
"Bearer ",
|
||||
"bot\\d+:AA",
|
||||
"password=",
|
||||
"token=",
|
||||
"secret=",
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Skill File Reader
|
||||
// ============================================================================
|
||||
|
||||
interface SkillFrontmatter {
|
||||
name: string;
|
||||
description?: string;
|
||||
"user-invocable"?: boolean;
|
||||
metadata?: string;
|
||||
applies_to?: string;
|
||||
}
|
||||
|
||||
interface ParsedSkill {
|
||||
frontmatter: SkillFrontmatter;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function parseSkillFile(content: string): ParsedSkill {
|
||||
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
||||
if (!fmMatch) {
|
||||
return {
|
||||
frontmatter: { name: "unknown" },
|
||||
body: content,
|
||||
};
|
||||
}
|
||||
|
||||
const fmBlock = fmMatch[1];
|
||||
const body = fmMatch[2].trim();
|
||||
|
||||
// Simple YAML-like parsing (no dependency needed)
|
||||
const fm: Record<string, any> = {};
|
||||
for (const line of fmBlock.split("\n")) {
|
||||
const colonIdx = line.indexOf(":");
|
||||
if (colonIdx === -1) continue;
|
||||
const key = line.slice(0, colonIdx).trim();
|
||||
let value: any = line.slice(colonIdx + 1).trim();
|
||||
if (value === "false") value = false;
|
||||
else if (value === "true") value = true;
|
||||
fm[key] = value;
|
||||
}
|
||||
|
||||
return {
|
||||
frontmatter: fm as SkillFrontmatter,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Skill Loader
|
||||
// ============================================================================
|
||||
|
||||
// Resolve skills directory with multiple fallback strategies.
|
||||
// OpenClaw may load the plugin via jiti or custom loaders that break
|
||||
// import.meta.url, so we try several paths.
|
||||
function resolveSkillsDir(): string {
|
||||
const candidates: string[] = [];
|
||||
|
||||
// Strategy 1: import.meta.url (works in native ESM)
|
||||
try {
|
||||
const metaDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
candidates.push(path.join(metaDir, "skills"));
|
||||
candidates.push(path.join(metaDir, "..", "skills"));
|
||||
} catch {
|
||||
/* import.meta.url may not be available */
|
||||
}
|
||||
|
||||
// Strategy 2: __dirname (works in CJS / jiti)
|
||||
if (typeof __dirname !== "undefined") {
|
||||
candidates.push(path.join(__dirname, "skills"));
|
||||
candidates.push(path.join(__dirname, "..", "skills"));
|
||||
}
|
||||
|
||||
// Validate: must contain the expected subdirectory structure
|
||||
for (const dir of candidates) {
|
||||
if (exists(path.join(dir, "memory-triage", "SKILL.md"))) {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
return candidates[0] ?? "skills"; // Will fail gracefully in readSkillFile
|
||||
}
|
||||
|
||||
const SKILLS_DIR = resolveSkillsDir();
|
||||
const RESOLVED_SKILLS_DIR = path.resolve(SKILLS_DIR);
|
||||
|
||||
/**
|
||||
* Resolve path segments under SKILLS_DIR and verify the result doesn't escape.
|
||||
* Returns null if the resolved path is outside the skills directory (path traversal).
|
||||
* Note: path.resolve follows symlinks lexically; the skills directory is
|
||||
* package-owned so symlink escape is not a practical concern.
|
||||
*/
|
||||
export function safePath(...segments: string[]): string | null {
|
||||
const resolved = path.resolve(SKILLS_DIR, ...segments);
|
||||
if (
|
||||
resolved !== RESOLVED_SKILLS_DIR &&
|
||||
!resolved.startsWith(RESOLVED_SKILLS_DIR + path.sep)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function readSkillFile(skillName: string): string | null {
|
||||
const filePath = safePath(skillName, "SKILL.md");
|
||||
if (!filePath) return null;
|
||||
try {
|
||||
return readText(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a domain overlay, scoped to a specific skill.
|
||||
* Domain overlays live inside the skill directory: <skill>/domains/<domain>.md
|
||||
* The `applies_to` frontmatter field is checked for backward compatibility.
|
||||
*/
|
||||
function readDomainOverlay(domain: string, targetSkill: string): string | null {
|
||||
const filePath = safePath(targetSkill, "domains", `${domain}.md`);
|
||||
if (!filePath) return null;
|
||||
try {
|
||||
const content = readText(filePath);
|
||||
const parsed = parseSkillFile(content);
|
||||
// Check applies_to for backward compat (skip if targeting a different skill)
|
||||
const appliesTo = parsed.frontmatter.applies_to;
|
||||
if (appliesTo && appliesTo !== targetSkill) {
|
||||
return null;
|
||||
}
|
||||
return parsed.body;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Config Injection — render user-configured knobs into prompt text
|
||||
// ============================================================================
|
||||
|
||||
function renderCategoriesBlock(
|
||||
categories: Record<string, CategoryConfig>,
|
||||
): string {
|
||||
const lines: string[] = [
|
||||
"\n## Active Category Configuration (overrides defaults above)\n",
|
||||
];
|
||||
for (const [name, cat] of Object.entries(categories)) {
|
||||
const ttlLabel = cat.ttl ? `expires: ${cat.ttl}` : "permanent";
|
||||
const immLabel = cat.immutable ? ", immutable" : "";
|
||||
lines.push(
|
||||
`- **${name.toUpperCase()}** (importance: ${cat.importance} | ${ttlLabel}${immLabel})`,
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderTriageKnobs(config: SkillsConfig): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (config.triage?.importanceThreshold !== undefined) {
|
||||
lines.push(
|
||||
`- Only store facts with importance >= ${config.triage.importanceThreshold}`,
|
||||
);
|
||||
}
|
||||
|
||||
const patterns = resolveCredentialPatterns(config);
|
||||
lines.push(`- Credential patterns to scan: ${patterns.map((p) => `\`${p}\``).join(", ")}`);
|
||||
|
||||
if (lines.length === 0) return "";
|
||||
return "\n## Active Configuration Overrides\n\n" + lines.join("\n");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TTL Helpers
|
||||
// ============================================================================
|
||||
|
||||
/** Convert TTL string like "7d", "90d" to ISO date from today */
|
||||
export function ttlToExpirationDate(ttl: string | null): string | null {
|
||||
if (!ttl) return null;
|
||||
const match = ttl.match(/^(\d+)d$/);
|
||||
if (!match) return null;
|
||||
const days = parseInt(match[1], 10);
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + days);
|
||||
return date.toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
export interface LoadedSkill {
|
||||
name: string;
|
||||
prompt: string;
|
||||
frontmatter: SkillFrontmatter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a skill by name, merge domain overlays and user config,
|
||||
* and return the final injectable prompt string.
|
||||
*/
|
||||
export function loadSkill(
|
||||
skillName: string,
|
||||
config: SkillsConfig = {},
|
||||
): LoadedSkill | null {
|
||||
const raw = readSkillFile(skillName);
|
||||
if (!raw) return null;
|
||||
|
||||
const parsed = parseSkillFile(raw);
|
||||
const parts: string[] = [parsed.body];
|
||||
|
||||
// Domain overlays only apply to the skill they target (checked via applies_to)
|
||||
if (config.domain) {
|
||||
const overlay = readDomainOverlay(config.domain, skillName);
|
||||
if (overlay) {
|
||||
parts.push("\n" + overlay);
|
||||
}
|
||||
}
|
||||
|
||||
// Inject user-configured categories into triage skill prompt
|
||||
if (skillName === "memory-triage" && config.categories) {
|
||||
const mergedCats = resolveCategories(config);
|
||||
parts.push(renderCategoriesBlock(mergedCats));
|
||||
}
|
||||
|
||||
// Inject triage knobs (importanceThreshold, credentialPatterns)
|
||||
if (skillName === "memory-triage" || skillName === "memory-dream") {
|
||||
const knobs = renderTriageKnobs(config);
|
||||
if (knobs) parts.push(knobs);
|
||||
}
|
||||
|
||||
// Append user custom rules (triage-only — extraction rules don't apply to recall/dream)
|
||||
if (skillName === "memory-triage" && config.customRules) {
|
||||
const rulesBlock: string[] = ["\n## User Custom Rules\n"];
|
||||
if (config.customRules.include?.length) {
|
||||
rulesBlock.push("Additionally extract:");
|
||||
for (const rule of config.customRules.include) {
|
||||
rulesBlock.push(`- ${rule}`);
|
||||
}
|
||||
}
|
||||
if (config.customRules.exclude?.length) {
|
||||
rulesBlock.push("\nAdditionally skip:");
|
||||
for (const rule of config.customRules.exclude) {
|
||||
rulesBlock.push(`- ${rule}`);
|
||||
}
|
||||
}
|
||||
parts.push(rulesBlock.join("\n"));
|
||||
}
|
||||
|
||||
return {
|
||||
name: skillName,
|
||||
prompt: parts.join("\n"),
|
||||
frontmatter: parsed.frontmatter,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the memory system prompt for injection via prependSystemContext.
|
||||
*
|
||||
* Primary path: load the full SKILL.md via loadSkill(), which merges
|
||||
* domain overlays, category overrides, custom rules, and triage knobs.
|
||||
* This ensures the config surface (skills.domain, customRules, categories)
|
||||
* is what the live before_prompt_build path actually sends.
|
||||
*
|
||||
* Fallback: if SKILL.md cannot be read (missing file, broken path), use
|
||||
* a minimal inline protocol so memory still functions.
|
||||
*/
|
||||
export function loadTriagePrompt(config: SkillsConfig = {}): string {
|
||||
// Try to load the full skill with all config-driven overlays
|
||||
const triage = loadSkill("memory-triage", config);
|
||||
|
||||
if (triage) {
|
||||
// Full SKILL.md loaded with domain overlays, categories, custom rules, knobs merged.
|
||||
// Wrap in <memory-system> and append the operational instructions that
|
||||
// are not part of the SKILL.md (tool format, batching, search protocol).
|
||||
const parts: string[] = [];
|
||||
parts.push("<memory-system>");
|
||||
parts.push(
|
||||
"IMPORTANT: Use `memory_add` tool for ALL user facts. NEVER write user info to workspace files (USER.md, memory/).",
|
||||
);
|
||||
parts.push("");
|
||||
parts.push(triage.prompt);
|
||||
parts.push("");
|
||||
parts.push("## Tool Usage");
|
||||
parts.push("");
|
||||
parts.push(
|
||||
"Batch facts by CATEGORY. All facts in one memory_add call must share the same category because category determines retention policy (TTL, immutability). If a turn has facts in different categories, make one call per category.",
|
||||
);
|
||||
parts.push("");
|
||||
parts.push("FORMAT (single category):");
|
||||
parts.push(
|
||||
' memory_add(facts: ["User is Alex, backend engineer at Stripe, PST timezone"], category: "identity")',
|
||||
);
|
||||
parts.push("FORMAT (mixed categories in one turn, separate calls):");
|
||||
parts.push(
|
||||
' memory_add(facts: ["User is Alex, backend engineer at Stripe, PST timezone"], category: "identity")',
|
||||
);
|
||||
parts.push(
|
||||
' memory_add(facts: ["As of 2026-04-01, migrating from Postgres to CockroachDB"], category: "decision")',
|
||||
);
|
||||
// Only include search instructions if recall is enabled
|
||||
if (config.recall?.enabled !== false) {
|
||||
const strategy = config.recall?.strategy ?? "smart";
|
||||
parts.push("");
|
||||
parts.push("## Searching Memory");
|
||||
parts.push("");
|
||||
|
||||
// In manual mode, the agent is fully responsible for all search
|
||||
if (strategy === "manual") {
|
||||
parts.push(
|
||||
"You control all memory search. No automatic recall happens. Use memory_search proactively:",
|
||||
);
|
||||
parts.push(
|
||||
"- At the start of a new conversation, search for user identity and context.",
|
||||
);
|
||||
parts.push(
|
||||
"- When the user references something you do not have context for.",
|
||||
);
|
||||
parts.push("- When the conversation topic shifts to a new domain.");
|
||||
parts.push(
|
||||
"- Before updating a memory, search to find the existing version.",
|
||||
);
|
||||
parts.push("");
|
||||
}
|
||||
|
||||
parts.push(
|
||||
"When calling memory_search, ALWAYS rewrite the query. NEVER pass the user's raw message.",
|
||||
);
|
||||
parts.push(
|
||||
"Stored memories are third-person factual statements. Write a query that matches storage language, not conversation language.",
|
||||
);
|
||||
parts.push(
|
||||
"Process: (1) Name your target. (2) Extract signal: proper nouns, technical terms, domain concepts. (3) Bridge to storage language: add terms the stored memory contains (user, decided, prefers, rule, configured, based in). (4) Compose 3-6 keywords.",
|
||||
);
|
||||
parts.push(
|
||||
'WRONG: memory_search("Who was that nutritionist my wife recommended?")',
|
||||
);
|
||||
parts.push(
|
||||
'RIGHT: memory_search("nutritionist wife recommended relationship")',
|
||||
);
|
||||
parts.push('WRONG: memory_search("What timezone am I in?")');
|
||||
parts.push('RIGHT: memory_search("user timezone location based")');
|
||||
parts.push("");
|
||||
parts.push(
|
||||
"ENTITY SCOPING: Memories are scoped by user_id, agent_id, and run_id. You do not need to pass these in most cases. The plugin handles scoping automatically based on the current session.",
|
||||
);
|
||||
parts.push(
|
||||
"- Default behavior: all memory operations use the configured userId and current session. You do not need to pass userId or agentId.",
|
||||
);
|
||||
parts.push(
|
||||
"- Use agentId only when you need to read or write memories for a DIFFERENT agent (e.g., querying what the 'researcher' agent knows). This accesses a separate namespace.",
|
||||
);
|
||||
parts.push(
|
||||
"- Use userId only when explicitly instructed to operate on a different user's memories.",
|
||||
);
|
||||
parts.push(
|
||||
"- Do not pass run_id directly. The plugin manages session scoping through the scope parameter.",
|
||||
);
|
||||
parts.push(
|
||||
"- In multi-agent setups, each agent has isolated memory. The main agent's memories are separate from subagent memories.",
|
||||
);
|
||||
parts.push("");
|
||||
parts.push("SEARCH SCOPE: Choose the right scope for each search:");
|
||||
parts.push(
|
||||
'- scope: "long-term" for user context, identity, preferences, decisions (default, most common)',
|
||||
);
|
||||
parts.push('- scope: "session" for facts from this conversation only');
|
||||
parts.push(
|
||||
'- scope: "all" only when you truly need both scopes combined',
|
||||
);
|
||||
parts.push("Using a specific scope avoids unnecessary backend fan-out.");
|
||||
parts.push("");
|
||||
parts.push(
|
||||
"SEARCH FILTERS: When the user's intent implies a time range or category constraint, pass a `filters` object alongside your rewritten query.",
|
||||
);
|
||||
parts.push(
|
||||
'- Time: "last week" -> filters: {"created_at": {"gte": "2026-03-24"}}',
|
||||
);
|
||||
parts.push('- Category: "my preferences" -> categories: ["preference"]');
|
||||
parts.push(
|
||||
"- Available operators: eq, ne, gt, gte, lt, lte, in, contains. Logical: AND, OR, NOT.",
|
||||
);
|
||||
}
|
||||
parts.push("</memory-system>");
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
// Fallback: SKILL.md not found. Minimal inline protocol.
|
||||
const parts: string[] = [];
|
||||
parts.push("<memory-system>");
|
||||
parts.push(
|
||||
"You have persistent long-term memory via mem0. After EVERY response, evaluate the turn for facts worth storing.",
|
||||
);
|
||||
parts.push(
|
||||
"Use `memory_add` tool for ALL user facts. NEVER write user info to workspace files (USER.md, memory/).",
|
||||
);
|
||||
parts.push("Most turns produce ZERO memory operations. That is correct.");
|
||||
parts.push(
|
||||
"Only store facts a new agent would need days later: identity, preferences, decisions, rules, projects, configs.",
|
||||
);
|
||||
parts.push(
|
||||
"Batch facts by CATEGORY. All facts in one call must share the same category.",
|
||||
);
|
||||
parts.push(
|
||||
'Format: memory_add(facts: ["fact text"], category: "identity")',
|
||||
);
|
||||
parts.push(
|
||||
"NEVER store credentials (sk-, m0-, ghp_, AKIA, Bearer tokens, passwords).",
|
||||
);
|
||||
if (config.recall?.enabled !== false) {
|
||||
parts.push(
|
||||
"When searching, rewrite queries for retrieval. Do not pass raw user messages.",
|
||||
);
|
||||
}
|
||||
parts.push("</memory-system>");
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the dream skill prompt for consolidation sessions.
|
||||
*/
|
||||
export function loadDreamPrompt(config: SkillsConfig = {}): string {
|
||||
const dream = loadSkill("memory-dream", config);
|
||||
if (!dream) return "";
|
||||
return dream.prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective categories — user overrides merged with defaults.
|
||||
*/
|
||||
export function resolveCategories(
|
||||
config: SkillsConfig = {},
|
||||
): Record<string, CategoryConfig> {
|
||||
return { ...DEFAULT_CATEGORIES, ...(config.categories || {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve credential patterns — user overrides merged with defaults.
|
||||
*/
|
||||
export function resolveCredentialPatterns(config: SkillsConfig = {}): string[] {
|
||||
return config.triage?.credentialPatterns ?? DEFAULT_CREDENTIAL_PATTERNS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if skills mode is active (triage enabled).
|
||||
*/
|
||||
export function isSkillsMode(config: SkillsConfig | undefined): boolean {
|
||||
if (!config) return false;
|
||||
return config.triage?.enabled !== false; // enabled by default when skills config exists
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
---
|
||||
name: memory-dream
|
||||
description: >
|
||||
Memory consolidation protocol. Reviews all stored memories, merges duplicates,
|
||||
removes noise and credentials, rewrites unclear entries, and enforces TTL expiration.
|
||||
Use when the user asks to clean up, consolidate, or review their memories.
|
||||
Also triggers automatically after sufficient activity (configurable).
|
||||
user-invocable: true
|
||||
metadata:
|
||||
{"openclaw": {"injected": true, "emoji": "💤", "requires": {"env": ["MEM0_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"], "bins": []}}}
|
||||
---
|
||||
|
||||
# Memory Consolidation
|
||||
|
||||
You are performing a memory consolidation pass. Your goal is to review all stored memories for this user and improve their overall quality. Think of this as compressing raw observations into clean, durable knowledge.
|
||||
|
||||
## Available Tools
|
||||
|
||||
### memory_search
|
||||
Semantic search across stored memories.
|
||||
- `query` (required): search query
|
||||
- `limit`: max results
|
||||
- `userId`, `agentId`: scope overrides
|
||||
- `scope`: `"all"` (default), `"session"`, or `"long-term"`
|
||||
- `categories`: filter by category array
|
||||
|
||||
### memory_add
|
||||
Store new facts in long-term memory.
|
||||
- `facts` (required): array of facts — ALL must share the same category
|
||||
- `category`: `"identity"`, `"preference"`, `"decision"`, `"rule"`, `"project"`, `"configuration"`, `"technical"`, `"relationship"`
|
||||
- `importance`: 0.0–1.0
|
||||
|
||||
### memory_get
|
||||
Retrieve a single memory by ID.
|
||||
- `memoryId` (required): the memory ID
|
||||
|
||||
### memory_list
|
||||
List all stored memories for a user or agent.
|
||||
- `userId`, `agentId`: scope overrides
|
||||
- `scope`: `"all"` (default), `"session"`, or `"long-term"`
|
||||
|
||||
### memory_update
|
||||
Update an existing memory's text in place. Atomic and preserves edit history.
|
||||
- `memoryId` (required): the memory ID to update
|
||||
- `text` (required): the new text (replaces old)
|
||||
|
||||
### memory_delete
|
||||
Delete memories by ID, query, or bulk.
|
||||
- `memoryId`: specific memory ID to delete
|
||||
- `all`: delete ALL memories (requires `confirm: true`)
|
||||
- `userId`, `agentId`: scope overrides
|
||||
|
||||
### memory_event_list
|
||||
List recent background processing events (platform mode only).
|
||||
|
||||
### memory_event_status
|
||||
Get status of a specific background event.
|
||||
- `event_id` (required): the event ID to check
|
||||
|
||||
Follow these four phases in order. Do not skip phases.
|
||||
|
||||
## Phase 1: Orient
|
||||
|
||||
Survey the current memory landscape before making any changes.
|
||||
|
||||
1. Call `memory_list` to load all stored memories.
|
||||
2. Count memories by category. Note the total.
|
||||
3. Identify the oldest and newest memories by their timestamps.
|
||||
4. Note any obvious problems visible in the list: duplicates, very short entries, entries without temporal anchors.
|
||||
|
||||
Do not modify anything in this phase. The goal is to understand what you are working with.
|
||||
|
||||
## Phase 2: Gather Targets
|
||||
|
||||
Identify which memories need action. Use the tools to investigate.
|
||||
|
||||
**Search for recent additions:**
|
||||
Call `memory_search` with a `created_at` filter to find memories added since the last consolidation. These are the most likely to need merging or cleanup.
|
||||
|
||||
**Classify each target into one of these actions:**
|
||||
- DELETE: contains credentials, expired by TTL, pure noise, raw tool output, standalone timestamps
|
||||
- MERGE: two or more memories express the same fact in different words, or a series tracks incremental changes to the same entity
|
||||
- REWRITE: vague, missing temporal anchor, uses first person instead of third, wrong category, overly verbose
|
||||
|
||||
## Phase 3: Consolidate
|
||||
|
||||
Execute the actions identified in Phase 2. Work in this priority order:
|
||||
|
||||
### 3a. Delete dangerous and expired entries
|
||||
|
||||
Delete immediately using `memory_delete`:
|
||||
- Credentials, API keys, tokens, passwords, secrets (matching known credential prefixes and auth patterns injected by the plugin at runtime)
|
||||
- Pure timestamps with no context
|
||||
- Raw tool output stored as memory
|
||||
- Heartbeat or cron execution records
|
||||
- Generic acknowledgments stored as memory ("ok", "got it")
|
||||
- Operational memories older than 7 days
|
||||
- Project memories older than 90 days
|
||||
|
||||
### 3b. Merge duplicates
|
||||
|
||||
When two or more memories express the same fact:
|
||||
1. Pick the most complete version as the base
|
||||
2. Call `memory_update` on the best version to incorporate missing details from the others
|
||||
3. Call `memory_delete` on the redundant entries
|
||||
|
||||
`memory_update` is preferred over forget-then-store because it is atomic and preserves edit history.
|
||||
|
||||
When merging, follow these rules:
|
||||
- Keep the user's original words for opinions and preferences
|
||||
- Preserve temporal anchors from both versions
|
||||
- Do not exceed 50 words in the merged result
|
||||
- The merged memory must be self-contained (understandable without the deleted ones)
|
||||
|
||||
### 3c. Rewrite unclear entries
|
||||
|
||||
When a memory needs improvement but is not a duplicate:
|
||||
1. Call `memory_update` with the improved text
|
||||
|
||||
Rewrite when:
|
||||
- Memory uses first person ("I prefer") instead of third ("User prefers")
|
||||
- Memory lacks a temporal anchor for time-sensitive information
|
||||
- Memory is vague ("likes python") and can be made specific ("User prefers Python for backend development")
|
||||
- Memory has the wrong category assignment
|
||||
- Memory is over 50 words and can be compressed without losing information
|
||||
|
||||
## Phase 4: Report
|
||||
|
||||
After completing all operations, summarize what you did:
|
||||
|
||||
```
|
||||
Consolidation complete.
|
||||
- Reviewed: [total count]
|
||||
- Deleted (credentials/secrets): [count]
|
||||
- Deleted (expired/stale): [count]
|
||||
- Merged: [count] groups into [count] memories
|
||||
- Rewritten: [count]
|
||||
- Final count: [total remaining]
|
||||
- Issues found: [any notable problems or observations]
|
||||
```
|
||||
|
||||
## Quality Targets
|
||||
|
||||
After consolidation, the memory store should have:
|
||||
- Zero memories containing credentials or secrets
|
||||
- Zero duplicate memories (same fact in different words)
|
||||
- All project and operational memories have temporal anchors ("As of YYYY-MM-DD")
|
||||
- All memories use third person voice
|
||||
- All memories are correctly categorized
|
||||
- Each memory is 15-50 words, self-contained, and atomic (one fact per memory)
|
||||
@@ -0,0 +1,389 @@
|
||||
---
|
||||
name: memory-triage
|
||||
description: >
|
||||
Persistent long-term memory protocol powered by mem0.
|
||||
Evaluate conversations for durable facts worth storing via memory_add.
|
||||
Handles identity, preferences, decisions, configurations, rules,
|
||||
projects, and relationships. Loaded by the openclaw-mem0 plugin when skills mode is active.
|
||||
user-invocable: false
|
||||
metadata:
|
||||
{"openclaw": {"always": false, "injected": true, "emoji": "🧠", "requires": {"env": ["MEM0_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"], "bins": []}}}
|
||||
---
|
||||
|
||||
# Memory Protocol
|
||||
|
||||
You have persistent long-term memory powered by mem0. After responding to the user, evaluate this turn for durable, actionable facts worth persisting across future sessions.
|
||||
|
||||
Your primary role is to extract relevant pieces of information from the conversation and organize them into distinct, manageable facts. This allows for easy retrieval and personalization in future interactions.
|
||||
|
||||
**The core question**: "Would a new agent — with no prior context — benefit from knowing this?" If no → do nothing. Most turns produce zero memory operations. That is correct and expected.
|
||||
|
||||
## Available Tools
|
||||
|
||||
### memory_search
|
||||
Semantic search across stored memories.
|
||||
- `query` (required): search query
|
||||
- `limit`: max results (default: configured topK)
|
||||
- `userId`, `agentId`: scope overrides
|
||||
- `scope`: `"all"` (default), `"session"`, or `"long-term"`
|
||||
- `categories`: filter by category array
|
||||
- `filters`: advanced filter object
|
||||
|
||||
### memory_add
|
||||
Store new facts in long-term memory.
|
||||
- `facts` (required): array of facts to store — ALL must share the same category
|
||||
- `text`: alternative single-fact string
|
||||
- `category`: `"identity"`, `"preference"`, `"decision"`, `"rule"`, `"project"`, `"configuration"`, `"technical"`, `"relationship"`
|
||||
- `importance`: 0.0–1.0 (omit for category default)
|
||||
- `userId`, `agentId`: scope overrides
|
||||
- `metadata`: additional key-value metadata
|
||||
- `longTerm`: true (default) for persistent, false for session-scoped
|
||||
|
||||
### memory_get
|
||||
Retrieve a single memory by ID.
|
||||
- `memoryId` (required): the memory ID
|
||||
|
||||
### memory_list
|
||||
List all stored memories for a user or agent.
|
||||
- `userId`, `agentId`: scope overrides
|
||||
- `scope`: `"all"` (default), `"session"`, or `"long-term"`
|
||||
|
||||
### memory_update
|
||||
Update an existing memory's text in place. Atomic and preserves edit history.
|
||||
- `memoryId` (required): the memory ID to update
|
||||
- `text` (required): the new text (replaces old)
|
||||
|
||||
### memory_delete
|
||||
Delete memories by ID, query, or bulk.
|
||||
- `memoryId`: specific memory ID to delete
|
||||
- `query`: search query to find and delete matching memories
|
||||
- `all`: delete ALL memories (requires `confirm: true`)
|
||||
- `confirm`: safety gate for bulk operations
|
||||
- `userId`, `agentId`: scope overrides
|
||||
|
||||
### memory_event_list
|
||||
List recent background processing events (platform mode only).
|
||||
|
||||
### memory_event_status
|
||||
Get status of a specific background event.
|
||||
- `event_id` (required): the event ID to check
|
||||
|
||||
## Decision Gate
|
||||
|
||||
Every candidate fact must pass ALL four gates:
|
||||
|
||||
**Gate 1 — FUTURE UTILITY**: Would this matter to a new agent days or weeks from now?
|
||||
- Pass: identity, configurations, standing rules, preferences with rationale, decisions, project milestones, relationships, important personal details
|
||||
- Fail: tool outputs, status checks, one-time commands, transient state, small talk, generic responses → SKIP
|
||||
|
||||
**Gate 2 — NOVELTY**: Check your recalled memories below — is this already known?
|
||||
- Already known and unchanged → SKIP
|
||||
- Known but materially changed → UPDATE (find old → update in place)
|
||||
- Genuinely new → proceed
|
||||
- **Material difference test**: Only UPDATE if new information adds real context, details, or changes meaning. Cosmetic differences (synonyms, rephrasing, punctuation) are NOT updates. "Loves daily walks" vs "enjoys daily walks" = no material change = SKIP.
|
||||
|
||||
**Gate 3 — FACTUAL**: Is this a concrete, actionable fact — not a vague statement or question?
|
||||
- Pass: specific names, configs, choices with rationale, deadlines, system states, plans, preferences
|
||||
- Fail: vague impressions, questions, small talk, acknowledgments, generic assistant responses ("Sure, I can help") → SKIP
|
||||
|
||||
**Gate 4 — SAFE**: Does this contain ANY credential, secret, or token?
|
||||
- Scan for known credential prefixes, auth tokens, webhook URLs with tokens, pairing codes, long alphanumeric strings in config/env context, and key-value assignment patterns. The plugin injects the full pattern list at runtime.
|
||||
- ANY match → NEVER STORE the value. Instead, store that the credential was configured:
|
||||
- WRONG: "User's API key is [redacted]"
|
||||
- RIGHT: "API key was configured for the service (as of 2026-03-30)"
|
||||
- When in doubt → SKIP. No exceptions.
|
||||
|
||||
All four gates must pass. If any fails → do nothing.
|
||||
|
||||
## What to Extract (Priority Order)
|
||||
|
||||
### 1. Configuration & System State (importance: 0.95 | permanent)
|
||||
Tools/services configured, installed, or removed (with versions/dates). Model assignments for agents. Cron schedules, automation pipelines, deployment configs. Architecture decisions. Specific identifiers: file paths, sheet IDs, channel IDs, machine specs.
|
||||
```
|
||||
"User's Tailscale machine 'mac' (IP 100.71.135.41) is configured under beau@rizedigital.io (as of 2026-02-20)"
|
||||
"User's executive orchestrator agent Quin runs on Claude Opus, heartbeat every 10 min"
|
||||
```
|
||||
|
||||
### 2. Standing Rules & Policies (importance: 0.90 | permanent)
|
||||
Explicit user directives about behavior. Workflow policies. Security constraints, permission boundaries. Always capture the reason.
|
||||
```
|
||||
"User rule: never create accounts without explicit user consent. Reason: security policy"
|
||||
"User rule: each agent must review model selection before completing a task"
|
||||
```
|
||||
|
||||
### 3. Identity & Demographics (importance: 0.95 | permanent)
|
||||
Name, location, timezone, language preferences. Occupation, employer, job role, industry. Keep related facts together in a single memory.
|
||||
```
|
||||
"User is Chris, senior platform engineer at Mem0, based in EST timezone"
|
||||
```
|
||||
|
||||
### 4. Preferences & Opinions (importance: 0.85 | permanent)
|
||||
Communication style, tool preferences, technology opinions. Always capture the WHY when stated. Preserve the user's exact words for feelings and opinions.
|
||||
```
|
||||
"User prefers Cursor over VS Code for AI-assisted coding because of inline completions"
|
||||
"User prefers terse responses with no trailing summaries"
|
||||
```
|
||||
|
||||
### 5. Goals, Projects & Milestones (importance: 0.75 | expires: 90 days)
|
||||
Active projects with name, description, current status. Completed milestones with dates. Deadlines, roadmaps, progress.
|
||||
```
|
||||
"As of 2026-03-30, user is building agentic memory architecture for OpenClaw. Status: active development, team demo planned early April"
|
||||
"ElevenLabs voice integration fully configured as of 2026-02-20"
|
||||
```
|
||||
|
||||
### 6. Technical Context (importance: 0.80 | permanent)
|
||||
Tech stack, development environment, agent ecosystem structure (names, roles, relationships). Skill levels.
|
||||
```
|
||||
"User's stack: Python/Django backend, Next.js 15 frontend, PostgreSQL with pgvector, deployed on EKS"
|
||||
```
|
||||
|
||||
### 7. Relationships & People (importance: 0.75 | permanent)
|
||||
Names and roles of people mentioned. Team structure, key contacts.
|
||||
```
|
||||
"Deshraj owns the frontend, Taranjeet owns the backend platform at Mem0"
|
||||
```
|
||||
|
||||
### 8. Decisions & Lessons (importance: 0.80 | permanent)
|
||||
Important decisions made with reasoning. Lessons learned. Strategies that worked or failed.
|
||||
```
|
||||
"As of 2026-03-30, user decided to use infer=false for all skill-based memory storage — agent extracts, mem0 stores directly without re-extraction"
|
||||
```
|
||||
|
||||
## CRITICAL: Memory Completeness and Self-Containment
|
||||
|
||||
Each memory you store must be a **self-contained, independently understandable fact**. This is the single most important quality rule.
|
||||
|
||||
### Entity-Based Grouping
|
||||
|
||||
**ALWAYS group all information about the same entity, concept, event, or subject into a SINGLE unified memory.** If multiple pieces of information refer to the same entity (e.g., a conference, a project, a person, a system), they MUST be combined into one comprehensive memory.
|
||||
|
||||
**DO NOT split requirements, specifications, or details about the same entity across multiple memory_add calls.** Even if information is phrased differently ("Budget for X", "X requires Y", "X needs Z"), if they all refer to the same entity, combine ALL into ONE call.
|
||||
|
||||
**WRONG** — fragmented into separate facts:
|
||||
```
|
||||
memory_add(facts: ["Conference requires at least 4 breakout rooms", "Conference requires vegan options", "Conference requires parking"], category: "project")
|
||||
```
|
||||
|
||||
**CORRECT** — grouped into one self-contained fact:
|
||||
```
|
||||
memory_add(facts: ["Conference requires at least 4 breakout rooms for 30-40 people each, robust vegan and vegetarian options with allergen-free alternatives, parking for at least 100 vehicles, venue within walking distance of transit"], category: "project")
|
||||
```
|
||||
|
||||
**WRONG** — same entity split into separate facts:
|
||||
```
|
||||
memory_add(facts: ["Budget is $150-175 per person for TechForward event", "TechForward event requires strong WiFi", "TechForward event requires hybrid capabilities"], category: "project")
|
||||
```
|
||||
|
||||
**CORRECT** — combined into one fact about TechForward:
|
||||
```
|
||||
memory_add(facts: ["TechForward event has a budget of $150-175 per person per day including venue rental, standard AV setup, and catering. Requires strong WiFi and hybrid event capabilities for remote attendees."], category: "project")
|
||||
```
|
||||
|
||||
**Only create separate memories when information refers to genuinely different entities, concepts, or unrelated topics** (e.g., "TechForward event" vs "Marketing campaign" are separate).
|
||||
|
||||
### No Pronouns — Use Specific Names
|
||||
|
||||
DO NOT create memories that rely on pronouns (they, them, he, she, it). Always use specific names and entities.
|
||||
|
||||
- **WRONG**: "They work at Google" and "They live in San Francisco"
|
||||
- **CORRECT**: "John works at Google and lives in San Francisco"
|
||||
|
||||
### No Inference
|
||||
|
||||
Do not infer unstated attributes (gender, age, ethnicity, beliefs) from names or context.
|
||||
- **WRONG**: "Kiran's sister visited him last week"
|
||||
- **CORRECT**: "Kiran's sister visited last week"
|
||||
|
||||
### No Assistant Attribution
|
||||
|
||||
Do not store characterizations from assistant messages (e.g., "user seems excited") unless the user explicitly confirmed them.
|
||||
|
||||
## How to Store
|
||||
|
||||
Use `memory_add` with the `facts` array. All facts in one call MUST share the same category because category determines retention policy (TTL, immutability).
|
||||
|
||||
```
|
||||
memory_add(
|
||||
facts: ["fact one in third person", "fact two in third person"],
|
||||
category: "identity"
|
||||
)
|
||||
```
|
||||
|
||||
If a turn produces facts in different categories, make one call per category:
|
||||
|
||||
```
|
||||
memory_add(facts: ["User is Alex, senior engineer at Stripe, PST timezone"], category: "identity")
|
||||
memory_add(facts: ["As of 2026-04-01, user decided to migrate from Postgres to CockroachDB"], category: "decision")
|
||||
```
|
||||
|
||||
Categories: `identity`, `configuration`, `rule`, `preference`, `decision`, `technical`, `relationship`, `project`
|
||||
|
||||
### Storage Principles
|
||||
|
||||
**15-50 WORDS per fact**: Each fact should be 1-2 sentences. If combining would exceed this, consolidate into key facts rather than creating a paragraph. Distill rather than append.
|
||||
|
||||
**OUTCOMES OVER INTENT**: Extract what WAS DONE, not what was requested.
|
||||
- GOOD: "Call scripts sheet (ID: 146Qbb...) was updated with truth-based templates"
|
||||
- BAD: "User wants to update call scripts"
|
||||
|
||||
**TEMPORAL ANCHORING**: Time-sensitive facts MUST include "As of YYYY-MM-DD, ..."
|
||||
- If no date available, note "date unknown" rather than omitting.
|
||||
- Extract dates from conversation context or the current date.
|
||||
|
||||
**PRESERVE USER'S WORDS**: When the user expresses feelings, opinions, or preferences, keep their exact phrasing.
|
||||
- GOOD: "User says daily walks with Poppy are the best part of their day"
|
||||
- BAD: "User finds emotional significance in walking their dog"
|
||||
|
||||
**THIRD PERSON**: "User prefers..." not "I prefer..."
|
||||
|
||||
**NO PRONOUNS**: Use specific names and entities. Not "they" or "it."
|
||||
|
||||
**PRESERVE LANGUAGE**: If the user speaks Spanish, store in Spanish. Do not translate.
|
||||
|
||||
**BATCH BY CATEGORY**: Group all same-category facts into one call. Different categories require separate calls. Most turns need zero or one call.
|
||||
|
||||
### Updating Existing Memories
|
||||
|
||||
When a recalled memory needs updating (fact changed, status changed, new detail added):
|
||||
1. `memory_search` to find the existing memory
|
||||
2. `memory_update` on the memory's ID with the corrected/expanded text
|
||||
|
||||
`memory_update` is preferred over delete+add because it is **atomic and preserves edit history**.
|
||||
|
||||
**Choose the MORE COMPLETE version.** When both old and new have unique context, COMBINE them into a unified memory using the user's stated words.
|
||||
|
||||
**Material difference test**: Only update if the new version adds real information.
|
||||
- "User likes Python" → "User prefers Python for backend services because of async support" = material update (added rationale, specificity)
|
||||
- "User likes Python" → "User enjoys Python" = NOT material = SKIP
|
||||
- When both have unique context, combine: Old "Trip to Paris in September with Jack" + New "User can't wait to visit Eiffel Tower" → "Trip to Paris in September 2025 with friend Jack, user says they can't wait to visit the Eiffel Tower and try authentic French pastries"
|
||||
|
||||
**Consolidation**: When a rich new fact encompasses multiple existing memories, `memory_update` the best one to the comprehensive version and `memory_delete` the rest.
|
||||
- Old: "User has a dog" + "Dog's name is Poppy" + "User walks dog daily"
|
||||
- New: "User has a dog named Poppy and says taking him for walks is the best part of their day"
|
||||
- Action: `memory_update` the best version with consolidated text, `memory_delete` the redundant ones
|
||||
|
||||
**Temporary vs permanent changes**: A temporary constraint (e.g., injury pausing a hobby) does NOT contradict the underlying preference. Store the constraint as a new memory; don't delete the preference.
|
||||
- Old: "User enjoys hiking on weekends"
|
||||
- New: "User has temporarily paused hiking due to knee injury"
|
||||
- Action: store the new constraint, leave old preference untouched
|
||||
|
||||
## What NEVER to Store
|
||||
|
||||
- **Credentials and secrets** — even embedded in config blocks, setup logs, or tool output. Includes any known credential prefixes, auth tokens, bearer tokens, webhook URLs with tokens, pairing codes, and long alphanumeric strings in config/env contexts. Record that the credential was configured, never the value itself.
|
||||
- **Raw tool output** — bash results, file contents, API responses, logs, diffs, test output. Extract only the durable OUTCOME or ROOT CAUSE.
|
||||
- **One-time commands** — "stop the script", "continue where you left off", "run this"
|
||||
- **Acknowledgments and emotional reactions** — "ok", "sure", "sounds good", "sir", "got it", "thanks", "you're right"
|
||||
- **Transient UI/navigation states** — "user is in admin panel", "relay is attached"
|
||||
- **Ephemeral process status** — "download at 50%", "daemon not running", "still syncing"
|
||||
- **Cron heartbeat outputs** — NO_REPLY, HEARTBEAT_OK, compaction directives
|
||||
- **Timestamps as standalone facts** — "Current time is 3:25 PM" is NEVER worth storing. But DO use timestamps to anchor other facts.
|
||||
- **System routing metadata** — message IDs, sender IDs, channel routing info
|
||||
- **Generic small talk** — no informational content
|
||||
- **Raw code snippets** — capture the intent/decision, not the code itself
|
||||
- **Information the user explicitly asks not to remember**
|
||||
- **Facts already in recalled memories that haven't materially changed**
|
||||
- **Generic assistant responses** — "Sure, I can help", "How can I assist you?"
|
||||
|
||||
## Worked Examples
|
||||
|
||||
### Example 1: Configuration extraction (entity-grouped)
|
||||
```
|
||||
User: "I set up the research agent on Claude Sonnet with a 30-min cron. It checks HackerNews and sends summaries to #research-feed in Slack."
|
||||
Agent: [responds helpfully]
|
||||
→ memory_add(facts: ["User's research agent runs on Claude Sonnet, cron every 30 minutes, monitors HackerNews and posts summaries to Slack #research-feed"], category: "configuration")
|
||||
```
|
||||
|
||||
### Example 2: NOOP — tool output
|
||||
```
|
||||
User: "Run the healthcheck on all services"
|
||||
Agent: [executes healthcheck, returns results]
|
||||
→ No memory operations. Tool output fails Gate 1.
|
||||
```
|
||||
|
||||
### Example 3: NOOP — already recalled, no material change
|
||||
```
|
||||
Recalled: ["User is Chris, senior platform engineer at Mem0"]
|
||||
User: "Hey Chris here again"
|
||||
→ No memory operations. Already known, no material change.
|
||||
```
|
||||
|
||||
### Example 4: Rule with rationale (preserving user's words)
|
||||
```
|
||||
User: "Never use Docker for local dev, it ate 40GB of disk last time and my Mac mini only has 256GB"
|
||||
→ memory_add(facts: ["User rule: avoid Docker for local dev. Reason: ate 40GB of disk on 256GB Mac mini"], category: "rule")
|
||||
```
|
||||
|
||||
### Example 5: UPDATE — combining contexts from both versions
|
||||
```
|
||||
Recalled: ["As of 2026-03-15, user is planning trip to Paris in September with friend Jack"]
|
||||
User: "Can't wait for the Paris trip, definitely want to hit the Eiffel Tower and try authentic French pastries"
|
||||
→ memory_search("Paris trip planning")
|
||||
→ memory_update(memoryId: "mem-id-of-old", text: "As of 2026-03-30, user is planning trip to Paris in September 2025 with friend Jack, says they can't wait to visit the Eiffel Tower and try authentic French pastries")
|
||||
```
|
||||
|
||||
### Example 6: Outcome over intent
|
||||
```
|
||||
User: "Update the call scripts sheet with the new truth-based templates"
|
||||
Agent: [updates the sheet successfully]
|
||||
→ memory_add(facts: ["Call scripts sheet (ID: 146Qbb...) was updated with truth-based templates (as of 2026-03-30)"], category: "configuration")
|
||||
```
|
||||
|
||||
### Example 7: Credential — store the fact, not the value
|
||||
```
|
||||
User: "Use this API key for the new service: [credential value]"
|
||||
Agent: [configures the service]
|
||||
→ memory_add(facts: ["API key was configured for the new service (as of 2026-03-30)"], category: "configuration")
|
||||
```
|
||||
|
||||
### Example 8: NOOP — cosmetic difference, not material
|
||||
```
|
||||
Recalled: ["User has a dog named Poppy and enjoys their daily walks together"]
|
||||
User: "Yeah me and Poppy love our daily walks"
|
||||
→ No memory operations. Semantically equivalent. No new context.
|
||||
```
|
||||
|
||||
### Example 9: Entity grouping — single call, not fragmented
|
||||
```
|
||||
User: "The budget for the offsite is $200 per head. We need a venue with WiFi, parking for 50 cars, and a projector."
|
||||
→ memory_add(facts: ["Team offsite budget is $200 per person. Venue requirements: WiFi, parking for 50 vehicles, and projector setup."], category: "project")
|
||||
All details about the same entity (offsite) go in one fact, one call.
|
||||
```
|
||||
|
||||
### Example 10: Temporary constraint — don't delete the preference
|
||||
```
|
||||
Recalled: ["User enjoys hiking on weekends and finds it therapeutic"]
|
||||
User: "I hurt my knee last week, can't hike for a while"
|
||||
→ memory_add(facts: ["As of 2026-03-30, user has temporarily paused hiking due to knee injury"], category: "project")
|
||||
DO NOT delete the hiking preference. It is temporarily paused, not contradicted.
|
||||
```
|
||||
|
||||
### Example 11: Mixed categories in one turn — separate calls
|
||||
```
|
||||
User: "I'm Sarah, I work at Cloudflare. I just decided to switch our monitoring from Datadog to Grafana because of cost."
|
||||
→ memory_add(facts: ["User is Sarah, works at Cloudflare"], category: "identity")
|
||||
→ memory_add(facts: ["As of 2026-03-30, user decided to switch monitoring from Datadog to Grafana due to cost"], category: "decision")
|
||||
Two calls because identity and decision have different retention policies.
|
||||
```
|
||||
|
||||
### Example 12: NOOP — generic greeting
|
||||
```
|
||||
User: "Hi"
|
||||
Agent: "Hello! How can I help?"
|
||||
→ No memory operations. No extractable facts.
|
||||
```
|
||||
|
||||
### Example 11: Consolidation — rich memory absorbs atomic ones
|
||||
```
|
||||
Recalled: ["User has a dog", "Dog's name is Poppy", "User walks dog daily"]
|
||||
User: "Poppy learned fetch! Our walks are even better now, honestly it's the best part of my day"
|
||||
→ memory_search("dog Poppy walks") → find all three old memory IDs
|
||||
→ memory_update(memoryId: "id-1", text: "User has a dog named Poppy and says taking him for walks is the best part of their day. Poppy recently learned fetch, making walks more enjoyable.")
|
||||
→ memory_delete(memoryId: "id-2"), memory_delete(memoryId: "id-3")
|
||||
```
|
||||
|
||||
### Example 12: NOOP — generic greeting, nothing to store
|
||||
```
|
||||
User: "Hi"
|
||||
Agent: "Hello! How can I help?"
|
||||
→ No memory operations. No extractable facts.
|
||||
```
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: companion
|
||||
description: Domain overlay for personal AI companion / conversational buddy use cases
|
||||
applies_to: memory-triage
|
||||
---
|
||||
|
||||
## Companion-Specific Extraction Rules
|
||||
|
||||
In addition to the base triage protocol, apply these rules for personal AI companion interactions:
|
||||
|
||||
### Additionally Extract
|
||||
|
||||
- **Personal preferences**: Likes, dislikes, specific preferences in food, products, activities, entertainment — always with the WHY when stated
|
||||
- "User loves Italian food, especially homemade pasta because it reminds them of childhood visits to their grandmother"
|
||||
- "User prefers hiking over gym workouts because they find nature therapeutic"
|
||||
|
||||
- **Important personal details**: Names of family, friends, pets. Relationships and their significance. Important dates (birthdays, anniversaries)
|
||||
- "User has a dog named Poppy and says taking him for walks is the best part of their day"
|
||||
- "User's sister Maya lives in Portland and they talk every Sunday"
|
||||
|
||||
- **Plans and intentions**: Upcoming events, trips, goals the user has shared — with dates when available
|
||||
- "As of 2026-03-30, user is planning a trip to Paris in September with friend Jack, excited about visiting the Eiffel Tower"
|
||||
- "User wants to learn piano by end of 2026, looking into online courses"
|
||||
|
||||
- **Activity and service preferences**: Dining, travel, hobbies, routines
|
||||
- "User plays cricket with childhood friends every Sunday morning at the local park, a tradition maintained for over 5 years"
|
||||
- "User prefers window seats on flights and always books aisle for trains"
|
||||
|
||||
- **Health and wellness**: Dietary restrictions, fitness routines, wellness habits (only what user voluntarily shares)
|
||||
- "User is vegetarian and allergic to nuts"
|
||||
- "User switched from coffee to green tea because their doctor recommended it"
|
||||
|
||||
- **Emotional context and life events**: Major life transitions, milestones, things the user cares deeply about — preserve the user's own words for feelings
|
||||
- "User says their new apartment is the first place that truly feels like home"
|
||||
- "User recently got promoted to team lead and says they're nervous but excited"
|
||||
|
||||
- **Routines and patterns**: Daily habits, work patterns, schedules
|
||||
- "User does yoga every morning at 6 AM before work"
|
||||
- "User has a Friday night tradition of ordering pizza and watching movies"
|
||||
|
||||
### Additionally Skip
|
||||
|
||||
- Momentary emotional reactions without lasting significance ("ugh, traffic was bad today")
|
||||
- Weather small talk unless it relates to a plan or preference
|
||||
- Generic social pleasantries ("how are you", "good morning")
|
||||
- Play-by-play of daily activities with no lasting value ("I ate lunch, then went back to work")
|
||||
- Conversation about the AI itself (compliments, complaints about responses) unless it reveals a user preference
|
||||
|
||||
### Companion-Specific Guidelines
|
||||
|
||||
- **Preserve warmth**: When storing preferences and feelings, keep the user's language and tone. "User says Poppy is the best part of their day" is better than "User owns a dog."
|
||||
- **Relationships matter**: People the user mentions are important. Always store name + role + context. "User's friend Jake from college" is better than "User mentioned Jake."
|
||||
- **Evolving preferences**: If a user's taste changes, UPDATE the memory rather than contradicting. "User switched from coffee to green tea" preserves the journey.
|
||||
- **Temporary vs permanent**: Injuries, short-term moods, or temporary states should be ADD'd as separate memories, not used to DELETE existing preferences.
|
||||
@@ -0,0 +1,223 @@
|
||||
---
|
||||
name: memory-recall
|
||||
description: Protocol for searching and using recalled memories. Defines query rewriting for retrieval.
|
||||
applies_to: memory-triage
|
||||
---
|
||||
|
||||
# Recalled Memories
|
||||
|
||||
Below your instructions you will find a `<recalled-memories>` section containing stored facts about this user. These memories persist across sessions and channels.
|
||||
|
||||
## Acting on Recalled Memories
|
||||
|
||||
Personalize naturally. If you know the user's name, use it. If you know their preferences, respect them. Do not announce that you are using memory. Never say "I remember that you..." or "According to my memory..." Act on the information without drawing attention to the mechanism.
|
||||
|
||||
Identity memories are ground truth. Trust name, role, timezone, system configurations unless the user explicitly corrects them.
|
||||
|
||||
Rules are mandatory. If a recalled memory says "User rule: never do X", follow it. Rules override your defaults.
|
||||
|
||||
Check timestamps. Project and operational memories have temporal anchors ("As of ..."). If a memory looks outdated, verify before relying on it.
|
||||
|
||||
## Before Recommending from Memory
|
||||
|
||||
A memory is a claim about what was true when it was written. It may no longer be true. Before recommending based on a memory:
|
||||
|
||||
- If the memory names a tool, service, or configuration: confirm it is still in use.
|
||||
- If the memory names a preference: it may have evolved. Use it as a default, not an absolute.
|
||||
- If the user is about to act on your recommendation, verify the memory first.
|
||||
|
||||
"The memory says X" is not the same as "X is true now."
|
||||
|
||||
## When to Search for More Context
|
||||
|
||||
Use `memory_search` when:
|
||||
|
||||
- The user references something not covered by your recalled memories
|
||||
- The conversation topic shifts to a new domain
|
||||
- The user asks "do you remember" or "what was" or references a past conversation
|
||||
- You need to find an existing memory before updating it
|
||||
|
||||
Do NOT search when:
|
||||
- Recalled memories already cover the topic
|
||||
- The turn has no memory-relevant content
|
||||
- A search query would be too generic to return useful results
|
||||
|
||||
## Constructing Search Queries
|
||||
|
||||
This section defines exactly how to write a memory_search query. Follow this process for every call. Do not skip steps. Do not pass the user's raw message.
|
||||
|
||||
### Why Rewriting Matters
|
||||
|
||||
The search engine matches your query against stored memories using vector similarity and keyword overlap. Stored memories are factual third-person statements like "User is a data scientist based in Berlin" or "User decided to adopt weekly sprint reviews because biweekly was too slow." The user's conversational message contains noise words ("can you", "I was wondering", "help me") that dilute the signal and match nothing useful in the memory store.
|
||||
|
||||
### The Process
|
||||
|
||||
For every memory_search call, follow these four steps:
|
||||
|
||||
**Step 1. Name your target.**
|
||||
Before writing the query, identify what category of stored memory you expect to find. This prevents aimless retrieval.
|
||||
|
||||
**Step 2. Extract signal words.**
|
||||
Pull out every proper noun, technical term, domain concept, and specific detail from the user's message. Drop conversational framing, questions, pronouns, and filler.
|
||||
|
||||
**Step 3. Bridge to storage language.**
|
||||
Think about how the memory was written when it was stored. Memories are third-person factual statements. They contain words like "User", "configured", "decided", "prefers", "rule", "team", "project", "based in", "works at". Add the relevant category term if it helps: "identity", "decision", "rule", "preference", "configuration", "relationship".
|
||||
|
||||
**Step 4. Compose a keyword query.**
|
||||
Join the terms from steps 2 and 3 into a string of 3 to 6 keywords. No question marks. No pronouns. No sentence structure. The query should read like index terms, not natural language.
|
||||
|
||||
### Worked Examples
|
||||
|
||||
Each example shows the full reasoning chain. The examples deliberately span different domains to prevent anchoring on any single use case.
|
||||
|
||||
**Example 1: Looking for a person**
|
||||
```
|
||||
User: "Who was that nutritionist my wife recommended?"
|
||||
Step 1: Target = a relationship or reference memory about a nutritionist
|
||||
Step 2: Signal = nutritionist, wife, recommended
|
||||
Step 3: Bridge = stored memory likely contains the name, "nutritionist", "wife recommended", "relationship"
|
||||
Step 4: memory_search("nutritionist wife recommended relationship")
|
||||
```
|
||||
|
||||
**Example 2: Looking for a preference**
|
||||
```
|
||||
User: "How do I like my reports formatted again?"
|
||||
Step 1: Target = a preference about report formatting
|
||||
Step 2: Signal = reports, formatted
|
||||
Step 3: Bridge = stored memory likely says "User prefers", "reports", "format", a specific style
|
||||
Step 4: memory_search("report format preference style")
|
||||
```
|
||||
|
||||
**Example 3: Looking for a technical decision**
|
||||
```
|
||||
User: "Remind me why we picked that message queue"
|
||||
Step 1: Target = a decision memory about message queue technology
|
||||
Step 2: Signal = message queue, picked, why
|
||||
Step 3: Bridge = stored memory likely says "decided", "chose", the queue name, "because", a rationale
|
||||
Step 4: memory_search("message queue decision chose rationale")
|
||||
```
|
||||
|
||||
**Example 4: Looking for identity info**
|
||||
```
|
||||
User: "What timezone am I in?"
|
||||
Step 1: Target = identity memory with timezone
|
||||
Step 2: Signal = timezone
|
||||
Step 3: Bridge = stored memory likely says "User is based in", a city, a timezone abbreviation
|
||||
Step 4: memory_search("user timezone location based")
|
||||
```
|
||||
|
||||
**Example 5: Looking for a rule**
|
||||
```
|
||||
User: "Is there anything I told you to always do before deploying?"
|
||||
Step 1: Target = a rule memory about deployment
|
||||
Step 2: Signal = deploy, always do, before
|
||||
Step 3: Bridge = stored memory likely says "User rule:", "always", "before deploying", a specific action
|
||||
Step 4: memory_search("rule deploy always before")
|
||||
```
|
||||
|
||||
**Example 6: Looking for a project status**
|
||||
```
|
||||
User: "Where are we with the onboarding redesign?"
|
||||
Step 1: Target = a project memory about onboarding
|
||||
Step 2: Signal = onboarding, redesign
|
||||
Step 3: Bridge = stored memory likely says "As of", "onboarding", "redesign", "status", a milestone
|
||||
Step 4: memory_search("onboarding redesign project status")
|
||||
```
|
||||
|
||||
**Example 7: Looking for a life event**
|
||||
```
|
||||
User: "When's my sister's birthday?"
|
||||
Step 1: Target = a relationship or life event memory about the user's sister
|
||||
Step 2: Signal = sister, birthday
|
||||
Step 3: Bridge = stored memory likely contains "sister", a name, "birthday", a date
|
||||
Step 4: memory_search("sister birthday date relationship")
|
||||
```
|
||||
|
||||
### Failure Patterns
|
||||
|
||||
These query patterns produce poor results. Recognize and avoid them.
|
||||
|
||||
| Pattern | Why it fails | Fix |
|
||||
|---|---|---|
|
||||
| Raw user message as query | Noise words ("can you", "help me") dilute signal | Extract entities and concepts only |
|
||||
| Question words in query | "what", "how", "when", "who" are not in stored memories | Drop all question framing |
|
||||
| Pronouns in query | "we", "our", "my", "I" do not appear in third-person memories | Use "user" or the entity name |
|
||||
| Single keyword | Too narrow, misses related context | Use 3 to 6 terms |
|
||||
| More than 8 keywords | Too broad, ranks everything equally | Trim to strongest 4-5 terms |
|
||||
| Vague category words only | "user information stuff" matches everything | Include at least one specific entity or concept |
|
||||
| Repeating the same search | If a search returned nothing, a rephrased version of the same query will likely also return nothing | Try a different angle or accept the memory does not exist |
|
||||
|
||||
## Constructing Filters
|
||||
|
||||
The `filters` parameter narrows search results by time, category, or metadata. Use it alongside your rewritten query. The query handles semantic relevance. Filters handle structural constraints.
|
||||
|
||||
### When to Add Filters
|
||||
|
||||
Add filters when the user's intent implies a structural constraint beyond semantic similarity:
|
||||
|
||||
- Time references ("last week", "recently", "in January", "yesterday"): add `created_at` filter with gte/lte dates
|
||||
- Category requests ("my preferences", "any rules", "what decisions"): add `categories` filter
|
||||
- Recency bias ("latest", "most recent", "current"): add `created_at` with recent date
|
||||
- No time or category signal in the user's message: do not add filters. Let the query handle it alone.
|
||||
|
||||
### Filter Syntax
|
||||
|
||||
Operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `contains`, `icontains`
|
||||
Logical: `AND`, `OR`, `NOT` (wrap conditions in arrays)
|
||||
Date format: YYYY-MM-DD
|
||||
|
||||
### Worked Examples with Filters
|
||||
|
||||
```
|
||||
User: "What did we decide last week about the migration?"
|
||||
Query: "decision migration chose rationale"
|
||||
Filter: created_at >= 7 days ago
|
||||
Call: memory_search("decision migration chose rationale", filters: {"created_at": {"gte": "2026-03-25"}})
|
||||
```
|
||||
|
||||
```
|
||||
User: "What are all my standing rules?"
|
||||
Query: "user rule always never"
|
||||
Filter: category = rule
|
||||
Call: memory_search("user rule always never", categories: ["rule"])
|
||||
```
|
||||
|
||||
```
|
||||
User: "Show me recent project updates"
|
||||
Query: "project status milestone update"
|
||||
Filter: category + time
|
||||
Call: memory_search("project status milestone", categories: ["project"], filters: {"created_at": {"gte": "2026-03-01"}})
|
||||
```
|
||||
|
||||
```
|
||||
User: "What preferences have I shared?"
|
||||
Query: "user prefers preference"
|
||||
Filter: category = preference
|
||||
Call: memory_search("user prefers preference", categories: ["preference"])
|
||||
```
|
||||
|
||||
```
|
||||
User: "What do you know about me?"
|
||||
Query: "user identity name role location timezone"
|
||||
Filter: category = identity
|
||||
Call: memory_search("user identity name role location", categories: ["identity"])
|
||||
```
|
||||
|
||||
```
|
||||
User: "Anything from our conversation yesterday?"
|
||||
Query: "user context discussed"
|
||||
Filter: date range = yesterday
|
||||
Call: memory_search("user context discussed", filters: {"created_at": {"gte": "2026-03-31", "lte": "2026-04-01"}})
|
||||
```
|
||||
|
||||
### When NOT to Add Filters
|
||||
|
||||
- The user's message has no time signal and no category signal. Just use the rewritten query.
|
||||
- You are unsure of the exact date. Do not guess dates. Omit the filter and let vector search handle it.
|
||||
- The query is already narrow enough. Adding filters to a very specific query risks filtering out the answer.
|
||||
|
||||
## When NOT to Search
|
||||
|
||||
- Recalled memories already cover the topic. Do not re-search for what is in front of you.
|
||||
- The turn has no memory-relevant content. Most turns do not need a search.
|
||||
- The query would be too generic to return useful results.
|
||||
@@ -0,0 +1,789 @@
|
||||
/**
|
||||
* Tests for SQLite resilience fixes:
|
||||
* 1. disableHistory config passthrough
|
||||
* 2. initPromise poisoning fix (retry after failure)
|
||||
* 3. Graceful SQLite fallback in OSSProvider
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mem0ConfigSchema, createProvider } from "./index.ts";
|
||||
|
||||
/** Stub vector-store classes required by OSSProvider._init's patching loop. */
|
||||
function vectorStubs() {
|
||||
return {
|
||||
PGVector: class { initialize() { return Promise.resolve(); } },
|
||||
RedisDB: class { initialize() { return Promise.resolve(); } },
|
||||
Qdrant: class { initialize() { return Promise.resolve(); } },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Config: disableHistory passthrough
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("mem0ConfigSchema — disableHistory", () => {
|
||||
const baseConfig = {
|
||||
mode: "open-source",
|
||||
oss: {
|
||||
embedder: { provider: "openai", config: { apiKey: "sk-test" } },
|
||||
},
|
||||
};
|
||||
|
||||
it("preserves oss.disableHistory: true through config parsing", () => {
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
...baseConfig,
|
||||
oss: { ...baseConfig.oss, disableHistory: true },
|
||||
});
|
||||
expect(cfg.oss?.disableHistory).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves oss.disableHistory: false through config parsing", () => {
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
...baseConfig,
|
||||
oss: { ...baseConfig.oss, disableHistory: false },
|
||||
});
|
||||
expect(cfg.oss?.disableHistory).toBe(false);
|
||||
});
|
||||
|
||||
it("omits disableHistory when not provided", () => {
|
||||
const cfg = mem0ConfigSchema.parse(baseConfig);
|
||||
expect(cfg.oss?.disableHistory).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not reject unknown keys inside oss object", () => {
|
||||
// oss sub-object is passed through resolveEnvVarsDeep, not key-checked
|
||||
expect(() =>
|
||||
mem0ConfigSchema.parse({
|
||||
...baseConfig,
|
||||
oss: { ...baseConfig.oss, disableHistory: true },
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. OSSProvider: disableHistory flows to Memory constructor
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("OSSProvider — disableHistory passthrough to Memory", () => {
|
||||
let capturedConfig: Record<string, unknown> | undefined;
|
||||
let memoryCallCount: number;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedConfig = undefined;
|
||||
memoryCallCount = 0;
|
||||
vi.resetModules();
|
||||
|
||||
vi.doMock("mem0ai/oss", () => ({
|
||||
Memory: class MockMemory {
|
||||
constructor(config: Record<string, unknown>) {
|
||||
memoryCallCount++;
|
||||
capturedConfig = { ...config };
|
||||
}
|
||||
async add() {
|
||||
return { results: [] };
|
||||
}
|
||||
async search() {
|
||||
return { results: [] };
|
||||
}
|
||||
async get() {
|
||||
return {};
|
||||
}
|
||||
async getAll() {
|
||||
return [];
|
||||
}
|
||||
async delete() {}
|
||||
},
|
||||
...vectorStubs(),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("passes disableHistory: true to Memory when configured", async () => {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: { disableHistory: true },
|
||||
});
|
||||
const api = { resolvePath: (p: string) => p } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
// Trigger lazy init by calling search
|
||||
try {
|
||||
await provider.search("test", { user_id: "u1" });
|
||||
} catch {
|
||||
/* provider may fail on mock, that's ok */
|
||||
}
|
||||
|
||||
expect(capturedConfig).toBeDefined();
|
||||
expect(capturedConfig!.disableHistory).toBe(true);
|
||||
});
|
||||
|
||||
it("does not set disableHistory when not configured and sqlite works", async () => {
|
||||
// Mock better-sqlite3 so the proactive probe succeeds
|
||||
vi.doMock("better-sqlite3", () => {
|
||||
return { default: class { close() {} } };
|
||||
});
|
||||
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: {},
|
||||
});
|
||||
const api = { resolvePath: (p: string) => p } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
try {
|
||||
await provider.search("test", { user_id: "u1" });
|
||||
} catch {}
|
||||
|
||||
expect(capturedConfig).toBeDefined();
|
||||
expect(capturedConfig!.disableHistory).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. OSSProvider: initPromise is cleared on failure (allows retry)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("OSSProvider — initPromise retry after failure", () => {
|
||||
let callCount: number;
|
||||
|
||||
beforeEach(() => {
|
||||
callCount = 0;
|
||||
vi.resetModules();
|
||||
|
||||
vi.doMock("mem0ai/oss", () => ({
|
||||
Memory: class MockMemory {
|
||||
constructor() {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
throw new Error("SQLITE_CANTOPEN: simulated binding failure");
|
||||
}
|
||||
// Second+ call succeeds
|
||||
}
|
||||
async search() {
|
||||
return { results: [] };
|
||||
}
|
||||
async get() {
|
||||
return {};
|
||||
}
|
||||
async getAll() {
|
||||
return [];
|
||||
}
|
||||
async add() {
|
||||
return { results: [] };
|
||||
}
|
||||
async delete() {}
|
||||
},
|
||||
...vectorStubs(),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("retries initialization after a transient failure", async () => {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: { disableHistory: true },
|
||||
});
|
||||
const api = { resolvePath: (p: string) => p } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
// First call: _init throws, but initPromise is cleared so retry is possible
|
||||
await expect(provider.search("test", { user_id: "u1" })).rejects.toThrow(
|
||||
"SQLITE_CANTOPEN",
|
||||
);
|
||||
|
||||
// Second call: should retry _init (not return cached rejection)
|
||||
// callCount === 1 threw, so callCount === 2 should succeed
|
||||
const results = await provider.search("test", { user_id: "u1" });
|
||||
expect(results).toBeDefined();
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. OSSProvider: graceful fallback disables history on init failure
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("OSSProvider — graceful SQLite fallback", () => {
|
||||
let capturedConfigs: Record<string, unknown>[];
|
||||
/** When set, the mock Memory constructor always throws with this message. */
|
||||
let forceConstructorError: string | null;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedConfigs = [];
|
||||
forceConstructorError = null;
|
||||
vi.resetModules();
|
||||
|
||||
vi.doMock("mem0ai/oss", () => ({
|
||||
Memory: class MockMemory {
|
||||
constructor(config: Record<string, unknown>) {
|
||||
capturedConfigs.push({ ...config });
|
||||
if (forceConstructorError) {
|
||||
throw new Error(forceConstructorError);
|
||||
}
|
||||
if (!config.disableHistory) {
|
||||
throw new Error("Could not locate the bindings file");
|
||||
}
|
||||
// Succeeds when disableHistory is true
|
||||
}
|
||||
async search() {
|
||||
return { results: [] };
|
||||
}
|
||||
async get() {
|
||||
return {};
|
||||
}
|
||||
async getAll() {
|
||||
return [];
|
||||
}
|
||||
async add() {
|
||||
return { results: [] };
|
||||
}
|
||||
async delete() {}
|
||||
},
|
||||
...vectorStubs(),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("retries with disableHistory: true when initial construction fails", async () => {
|
||||
// Mock better-sqlite3 so the proactive probe succeeds — tests the
|
||||
// catch-retry fallback path for other constructor errors.
|
||||
vi.doMock("better-sqlite3", () => {
|
||||
return { default: class { close() {} } };
|
||||
});
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: {},
|
||||
});
|
||||
const api = { resolvePath: (p: string) => p } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
// Should succeed — first attempt fails, fallback with disableHistory succeeds
|
||||
const results = await provider.search("test", { user_id: "u1" });
|
||||
expect(results).toBeDefined();
|
||||
|
||||
// Memory constructor was called twice
|
||||
expect(capturedConfigs).toHaveLength(2);
|
||||
expect(capturedConfigs[0].disableHistory).toBeFalsy();
|
||||
expect(capturedConfigs[1].disableHistory).toBe(true);
|
||||
|
||||
// Warning was logged
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[mem0] Memory initialization failed"),
|
||||
expect.stringContaining("bindings file"),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("proactively disables history when better-sqlite3 binary is broken", async () => {
|
||||
// Do NOT mock better-sqlite3 — let probe detect the real version mismatch
|
||||
// (or force it to fail if native binary happens to work on this Node).
|
||||
vi.doMock("better-sqlite3", () => {
|
||||
return { default: class { constructor() { throw new Error("NODE_MODULE_VERSION mismatch"); } } };
|
||||
});
|
||||
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: {},
|
||||
});
|
||||
const api = { resolvePath: (p: string) => p } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
const results = await provider.search("test", { user_id: "u1" });
|
||||
expect(results).toBeDefined();
|
||||
|
||||
// Only ONE constructor call — probe detected broken sqlite, skipped retry
|
||||
expect(capturedConfigs).toHaveLength(1);
|
||||
expect(capturedConfigs[0].disableHistory).toBe(true);
|
||||
});
|
||||
|
||||
it("does not retry when disableHistory is already true", async () => {
|
||||
// Force the constructor to always throw, regardless of disableHistory
|
||||
forceConstructorError = "vector store connection refused";
|
||||
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: { disableHistory: true },
|
||||
});
|
||||
const api = { resolvePath: (p: string) => p } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
// Should throw — no fallback possible when disableHistory was already set
|
||||
await expect(provider.search("test", { user_id: "u1" })).rejects.toThrow(
|
||||
"vector store connection refused",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. PlatformProvider — initPromise retry after failure
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("PlatformProvider — initPromise retry after failure", () => {
|
||||
let callCount: number;
|
||||
|
||||
beforeEach(() => {
|
||||
callCount = 0;
|
||||
vi.resetModules();
|
||||
|
||||
vi.doMock("mem0ai", () => ({
|
||||
default: class MockMemoryClient {
|
||||
constructor() {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
throw new Error("Network timeout");
|
||||
}
|
||||
}
|
||||
async search() {
|
||||
return [];
|
||||
}
|
||||
async get() {
|
||||
return {};
|
||||
}
|
||||
async getAll() {
|
||||
return [];
|
||||
}
|
||||
async add() {
|
||||
return { results: [] };
|
||||
}
|
||||
async delete() {}
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("retries initialization after a transient failure", async () => {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "platform",
|
||||
apiKey: "test-api-key",
|
||||
});
|
||||
const api = { resolvePath: (p: string) => p } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
// First call fails
|
||||
await expect(provider.search("test", { user_id: "u1" })).rejects.toThrow(
|
||||
"Network timeout",
|
||||
);
|
||||
|
||||
// Second call should retry (not return cached rejection)
|
||||
const results = await provider.search("test", { user_id: "u1" });
|
||||
expect(results).toBeDefined();
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. OSSProvider: _buildConfig covers all branches
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("OSSProvider — _buildConfig branch coverage", () => {
|
||||
let capturedConfig: Record<string, unknown> | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedConfig = undefined;
|
||||
vi.resetModules();
|
||||
|
||||
vi.doMock("mem0ai/oss", () => ({
|
||||
Memory: class MockMemory {
|
||||
constructor(config: Record<string, unknown>) {
|
||||
capturedConfig = { ...config };
|
||||
}
|
||||
async search() { return { results: [] }; }
|
||||
async get() { return {}; }
|
||||
async getAll() { return []; }
|
||||
async add() { return { results: [] }; }
|
||||
async delete() {}
|
||||
},
|
||||
...vectorStubs(),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("builds config with custom embedder, llm, vectorStore, and historyDbPath", async () => {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: {
|
||||
embedder: { provider: "openai", config: { apiKey: "sk-e", model: "text-embedding-3-small" } },
|
||||
llm: { provider: "openai", config: { apiKey: "sk-l", model: "gpt-4" } },
|
||||
vectorStore: { provider: "qdrant", config: { host: "localhost", port: 6333 } },
|
||||
historyDbPath: "/tmp/history.db",
|
||||
disableHistory: true,
|
||||
},
|
||||
});
|
||||
const api = { resolvePath: (p: string) => `/resolved${p}` } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
await provider.search("test", { user_id: "u1" });
|
||||
|
||||
expect(capturedConfig).toBeDefined();
|
||||
expect(capturedConfig!.embedder).toEqual({
|
||||
provider: "openai",
|
||||
config: { model: "text-embedding-3-small", apiKey: "sk-e" },
|
||||
});
|
||||
expect(capturedConfig!.llm).toEqual({
|
||||
provider: "openai",
|
||||
config: expect.objectContaining({ model: "gpt-4", apiKey: "sk-l" }),
|
||||
});
|
||||
expect(capturedConfig!.vectorStore).toEqual({ provider: "qdrant", config: { host: "localhost", port: 6333 } });
|
||||
expect(capturedConfig!.historyDbPath).toBe("/tmp/history.db");
|
||||
expect(capturedConfig!.disableHistory).toBe(true);
|
||||
});
|
||||
|
||||
it("strips empty-string values from embedder and llm config", async () => {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: {
|
||||
embedder: { provider: "openai", config: { apiKey: "", model: "custom-model" } },
|
||||
llm: { provider: "openai", config: { apiKey: "", model: "" } },
|
||||
disableHistory: true,
|
||||
},
|
||||
});
|
||||
const api = { resolvePath: (p: string) => p } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
await provider.search("test", { user_id: "u1" });
|
||||
|
||||
expect(capturedConfig).toBeDefined();
|
||||
// Empty apiKey should be stripped, leaving only the non-empty model
|
||||
const embedderCfg = (capturedConfig!.embedder as any).config;
|
||||
expect(embedderCfg.apiKey).toBeUndefined();
|
||||
expect(embedderCfg.model).toBe("custom-model");
|
||||
// Both empty keys in llm should be stripped, defaults applied
|
||||
const llmCfg = (capturedConfig!.llm as any).config;
|
||||
expect(llmCfg.apiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to default provider when embedder/llm provider is empty", async () => {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: {
|
||||
embedder: { provider: "", config: { apiKey: "sk-e" } },
|
||||
llm: { provider: "", config: { apiKey: "sk-l" } },
|
||||
disableHistory: true,
|
||||
},
|
||||
});
|
||||
const api = { resolvePath: (p: string) => p } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
await provider.search("test", { user_id: "u1" });
|
||||
|
||||
expect(capturedConfig).toBeDefined();
|
||||
// Empty provider should fall back to "openai" default
|
||||
expect((capturedConfig!.embedder as any).provider).toBe("openai");
|
||||
expect((capturedConfig!.llm as any).provider).toBe("openai");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. OSSProvider: vector store dimension patching
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("OSSProvider — vector store dimension patching", () => {
|
||||
let capturedModule: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
|
||||
vi.doMock("mem0ai/oss", () => {
|
||||
const mod = {
|
||||
Memory: class MockMemory {
|
||||
constructor() {}
|
||||
async search() { return { results: [] }; }
|
||||
async get() { return {}; }
|
||||
async getAll() { return []; }
|
||||
async add() { return { results: [] }; }
|
||||
async delete() {}
|
||||
},
|
||||
PGVector: class {
|
||||
config: any;
|
||||
dimension: any;
|
||||
_initializePromise: any;
|
||||
initialize() { return Promise.resolve("pg-initialized"); }
|
||||
},
|
||||
RedisDB: class {
|
||||
config: any;
|
||||
_initializePromise: any;
|
||||
initialize() { return Promise.resolve("redis-initialized"); }
|
||||
},
|
||||
Qdrant: class {
|
||||
config: any;
|
||||
dimension: any;
|
||||
_initializePromise: any;
|
||||
initialize() { return Promise.resolve("qdrant-initialized"); }
|
||||
},
|
||||
};
|
||||
capturedModule = mod;
|
||||
return mod;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function triggerInit() {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: { disableHistory: true },
|
||||
});
|
||||
const provider = createProvider(cfg, { resolvePath: (p: string) => p } as any);
|
||||
await provider.search("test", { user_id: "u1" });
|
||||
}
|
||||
|
||||
it("copies config.dimension to embeddingModelDims and this.dimension", async () => {
|
||||
await triggerInit();
|
||||
|
||||
const pg = new capturedModule.PGVector();
|
||||
pg.config = { dimension: 1536 };
|
||||
await pg.initialize();
|
||||
|
||||
expect(pg.config.embeddingModelDims).toBe(1536);
|
||||
expect(pg.dimension).toBe(1536);
|
||||
});
|
||||
|
||||
it("returns resolved promise when no dimensions are known", async () => {
|
||||
await triggerInit();
|
||||
|
||||
const pg = new capturedModule.PGVector();
|
||||
pg.config = {};
|
||||
const result = await pg.initialize();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("runs original initialize only once via cached promise", async () => {
|
||||
await triggerInit();
|
||||
|
||||
const q = new capturedModule.Qdrant();
|
||||
q.config = { dimension: 768 };
|
||||
|
||||
const first = await q.initialize();
|
||||
const second = await q.initialize();
|
||||
expect(first).toBe("qdrant-initialized");
|
||||
expect(second).toBe("qdrant-initialized");
|
||||
expect(q._initializePromise).toBeDefined();
|
||||
});
|
||||
|
||||
it("skips missing vector store classes without crashing", async () => {
|
||||
// Override with a mock that omits PGVector entirely
|
||||
vi.resetModules();
|
||||
vi.doMock("mem0ai/oss", () => ({
|
||||
Memory: class {
|
||||
constructor() {}
|
||||
async search() { return { results: [] }; }
|
||||
async get() { return {}; }
|
||||
async getAll() { return []; }
|
||||
async add() { return { results: [] }; }
|
||||
async delete() {}
|
||||
},
|
||||
PGVector: undefined, // explicitly absent — tests the !VectorCls guard
|
||||
RedisDB: class { initialize() { return Promise.resolve(); } },
|
||||
Qdrant: class { initialize() { return Promise.resolve(); } },
|
||||
}));
|
||||
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: { disableHistory: true },
|
||||
});
|
||||
const provider = createProvider(cfg, { resolvePath: (p: string) => p } as any);
|
||||
|
||||
// Should not throw even though PGVector is missing
|
||||
const results = await provider.search("test", { user_id: "u1" });
|
||||
expect(results).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. OSSProvider: history() error handler
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("OSSProvider — history error handling", () => {
|
||||
/** When set, the mock history() throws this value instead of an Error. */
|
||||
let historyThrowValue: unknown;
|
||||
|
||||
beforeEach(() => {
|
||||
historyThrowValue = new Error("history not available");
|
||||
vi.resetModules();
|
||||
|
||||
vi.doMock("mem0ai/oss", () => ({
|
||||
Memory: class MockMemory {
|
||||
constructor() {}
|
||||
async search() { return { results: [] }; }
|
||||
async get() { return {}; }
|
||||
async getAll() { return []; }
|
||||
async add() { return { results: [] }; }
|
||||
async delete() {}
|
||||
async history() { throw historyThrowValue; }
|
||||
},
|
||||
...vectorStubs(),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns empty array and warns when history() throws an Error", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: { disableHistory: true },
|
||||
});
|
||||
const api = { resolvePath: (p: string) => p } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
await provider.search("test", { user_id: "u1" });
|
||||
|
||||
const result = await provider.history("mem-123");
|
||||
expect(result).toEqual([]);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
"[mem0] OSS history() failed:",
|
||||
"history not available",
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("handles non-Error thrown values in history()", async () => {
|
||||
historyThrowValue = "raw string error";
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: { disableHistory: true },
|
||||
});
|
||||
const api = { resolvePath: (p: string) => p } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
await provider.search("test", { user_id: "u1" });
|
||||
|
||||
const result = await provider.history("mem-456");
|
||||
expect(result).toEqual([]);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
"[mem0] OSS history() failed:",
|
||||
"raw string error",
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. OSSProvider: customInstructions passthrough (v3.0.0: renamed from customPrompt)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("OSSProvider — customInstructions passthrough", () => {
|
||||
let capturedConfig: Record<string, unknown> | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedConfig = undefined;
|
||||
vi.resetModules();
|
||||
|
||||
vi.doMock("mem0ai/oss", () => ({
|
||||
Memory: class MockMemory {
|
||||
constructor(config: Record<string, unknown>) {
|
||||
capturedConfig = { ...config };
|
||||
}
|
||||
async search() { return { results: [] }; }
|
||||
async get() { return {}; }
|
||||
async getAll() { return []; }
|
||||
async add() { return { results: [] }; }
|
||||
async delete() {}
|
||||
},
|
||||
...vectorStubs(),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// v3.0.0: customPrompt renamed to customInstructions
|
||||
it("passes customInstructions to Memory config when provided", async () => {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: { disableHistory: true },
|
||||
customInstructions: "Extract only user preferences.",
|
||||
});
|
||||
const api = { resolvePath: (p: string) => p } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
await provider.search("test", { user_id: "u1" });
|
||||
|
||||
expect(capturedConfig).toBeDefined();
|
||||
expect(capturedConfig!.customInstructions).toBe("Extract only user preferences.");
|
||||
});
|
||||
|
||||
it("preserves absolute Unix historyDbPath without resolvePath mangling", async () => {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: {
|
||||
historyDbPath: "/home/user/.myapp/history.db",
|
||||
disableHistory: true,
|
||||
},
|
||||
});
|
||||
const api = { resolvePath: (p: string) => `/stateDir/${p}` } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
await provider.search("test", { user_id: "u1" });
|
||||
|
||||
expect(capturedConfig).toBeDefined();
|
||||
expect(capturedConfig!.historyDbPath).toBe("/home/user/.myapp/history.db");
|
||||
});
|
||||
|
||||
it("preserves absolute Windows historyDbPath without resolvePath mangling", async () => {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: {
|
||||
historyDbPath: "C:\\Users\\me\\history.db",
|
||||
disableHistory: true,
|
||||
},
|
||||
});
|
||||
const api = { resolvePath: (p: string) => `/stateDir/${p}` } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
await provider.search("test", { user_id: "u1" });
|
||||
|
||||
expect(capturedConfig).toBeDefined();
|
||||
expect(capturedConfig!.historyDbPath).toBe("C:\\Users\\me\\history.db");
|
||||
});
|
||||
|
||||
it("still resolves relative historyDbPath via resolvePath", async () => {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: {
|
||||
historyDbPath: "data/history.db",
|
||||
disableHistory: true,
|
||||
},
|
||||
});
|
||||
const api = { resolvePath: (p: string) => `/resolved/${p}` } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
await provider.search("test", { user_id: "u1" });
|
||||
|
||||
expect(capturedConfig).toBeDefined();
|
||||
expect(capturedConfig!.historyDbPath).toBe("/resolved/data/history.db");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Plugin telemetry — anonymous usage tracking via PostHog.
|
||||
*
|
||||
* Sends fire-and-forget events to PostHog using native fetch().
|
||||
* Events are batched and flushed every 5 seconds or when the queue
|
||||
* reaches 10 events, whichever comes first.
|
||||
*
|
||||
* Disable with: MEM0_TELEMETRY=false
|
||||
*/
|
||||
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { readPluginAuth, writePluginAuth, getBaseUrl, clearAnonymousTelemetryId } from "./cli/config-file.ts";
|
||||
|
||||
declare const __OPENCLAW_PLUGIN_VERSION__: string;
|
||||
export const PLUGIN_VERSION: string = __OPENCLAW_PLUGIN_VERSION__;
|
||||
|
||||
const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX";
|
||||
const POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/";
|
||||
|
||||
const FLUSH_INTERVAL_MS = 5_000;
|
||||
const FLUSH_THRESHOLD = 10;
|
||||
|
||||
let eventQueue: Record<string, unknown>[] = [];
|
||||
let flushTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
let _cachedAnonymousId: string | undefined;
|
||||
let _aliasCheckDone = false;
|
||||
|
||||
/**
|
||||
* Return a persistent per-machine anonymous ID, generating one if needed.
|
||||
*
|
||||
* Stored in ~/.openclaw/openclaw.json under the plugin's `anonymousTelemetryId`
|
||||
* field so repeat sessions on the same machine share one PostHog identity
|
||||
* instead of collapsing into a single shared fallback string. The result is
|
||||
* cached in module memory after the first read so we don't re-touch disk on
|
||||
* every queued event.
|
||||
*/
|
||||
function getOrCreateAnonymousId(): string {
|
||||
if (_cachedAnonymousId) return _cachedAnonymousId;
|
||||
try {
|
||||
const auth = readPluginAuth();
|
||||
if (auth.anonymousTelemetryId) {
|
||||
_cachedAnonymousId = auth.anonymousTelemetryId;
|
||||
return _cachedAnonymousId;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const newId = `openclaw-anon-${randomUUID().replace(/-/g, "")}`;
|
||||
try {
|
||||
writePluginAuth({ anonymousTelemetryId: newId });
|
||||
} catch {
|
||||
/* ignore — return generated id anyway */
|
||||
}
|
||||
_cachedAnonymousId = newId;
|
||||
return newId;
|
||||
}
|
||||
|
||||
/**
|
||||
* If we just resolved to a real identity but a stored anonymous id exists,
|
||||
* build a one-shot PostHog $identify event so the pre-signup history gets
|
||||
* stitched onto the authenticated profile. Returns null when no aliasing is
|
||||
* needed (already done, or no anon id on disk, or still anonymous).
|
||||
*
|
||||
* Caller is responsible for pushing the returned event onto eventQueue ahead
|
||||
* of the regular event.
|
||||
*/
|
||||
function maybeBuildIdentifyEvent(
|
||||
distinctId: string,
|
||||
): Record<string, unknown> | null {
|
||||
if (_aliasCheckDone) return null;
|
||||
if (!distinctId || distinctId.startsWith("openclaw-anon-")) return null;
|
||||
try {
|
||||
const auth = readPluginAuth();
|
||||
const storedAnon = auth.anonymousTelemetryId;
|
||||
if (!storedAnon) {
|
||||
_aliasCheckDone = true;
|
||||
return null;
|
||||
}
|
||||
const identifyEvent = {
|
||||
event: "$identify",
|
||||
distinct_id: distinctId,
|
||||
properties: {
|
||||
$anon_distinct_id: storedAnon,
|
||||
$lib: "posthog-node",
|
||||
},
|
||||
};
|
||||
// Clear the anonymous ID from config after aliasing (don't write empty string)
|
||||
try {
|
||||
clearAnonymousTelemetryId();
|
||||
} catch {
|
||||
/* ignore — alias may double-fire next session, harmless */
|
||||
}
|
||||
_aliasCheckDone = true;
|
||||
_cachedAnonymousId = undefined;
|
||||
return identifyEvent;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let _emailResolutionAttempted = false;
|
||||
|
||||
/**
|
||||
* If we have an apiKey but no cached userEmail, do a one-shot /v1/ping/
|
||||
* call to resolve the email and cache it. This runs async as a side-effect;
|
||||
* the current event ships with md5(apiKey) but subsequent events (including
|
||||
* those flushed by the beforeExit handler in the same process) will use
|
||||
* the resolved email.
|
||||
*/
|
||||
function maybeResolveEmail(apiKey: string): void {
|
||||
if (_emailResolutionAttempted) return;
|
||||
_emailResolutionAttempted = true;
|
||||
|
||||
const baseUrl = getBaseUrl().replace(/\/+$/, "");
|
||||
fetch(`${baseUrl}/v1/ping/`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Token ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data: any) => {
|
||||
const email = data?.user_email;
|
||||
if (email) {
|
||||
try {
|
||||
writePluginAuth({ userEmail: email });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const oldId = createHash("sha256").update(apiKey).digest("hex");
|
||||
const newId = createHash("sha256").update(email).digest("hex");
|
||||
for (const ev of eventQueue) {
|
||||
if (ev.distinct_id === oldId) {
|
||||
ev.distinct_id = newId;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* silently swallow — md5(apiKey) is used as fallback */
|
||||
});
|
||||
}
|
||||
|
||||
let _telemetryEnabled: boolean | undefined;
|
||||
function isTelemetryEnabled(): boolean {
|
||||
if (_telemetryEnabled !== undefined) return _telemetryEnabled;
|
||||
try {
|
||||
const val = (globalThis as any).__mem0_telemetry_override;
|
||||
if (val !== undefined) {
|
||||
const s = String(val).toLowerCase();
|
||||
_telemetryEnabled = s !== "false" && s !== "0" && s !== "no";
|
||||
} else {
|
||||
_telemetryEnabled = true;
|
||||
}
|
||||
} catch {
|
||||
_telemetryEnabled = true;
|
||||
}
|
||||
return _telemetryEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a stable anonymous identifier for the current user.
|
||||
*
|
||||
* Priority: cached userEmail (from /v1/ping/) > MD5(apiKey) >
|
||||
* persistent per-machine anonymous ID.
|
||||
*/
|
||||
function getDistinctId(apiKey?: string): string {
|
||||
try {
|
||||
const auth = readPluginAuth();
|
||||
if (auth.userEmail) {
|
||||
return createHash("sha256").update(auth.userEmail).digest("hex");
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (apiKey) {
|
||||
return createHash("sha256").update(apiKey).digest("hex");
|
||||
}
|
||||
return getOrCreateAnonymousId();
|
||||
}
|
||||
|
||||
function ensureFlushTimer(): void {
|
||||
if (flushTimer) return;
|
||||
flushTimer = setInterval(flushEvents, FLUSH_INTERVAL_MS);
|
||||
if (typeof flushTimer === "object" && "unref" in flushTimer) {
|
||||
flushTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
let _exitHandlerInstalled = false;
|
||||
|
||||
/**
|
||||
* Install a one-time `beforeExit` handler that drains queued events on
|
||||
* process exit. Without this, short-lived CLI invocations (e.g. one
|
||||
* `openclaw mem0 status` call) exit before the unref'd flushTimer fires
|
||||
* and before FLUSH_THRESHOLD is hit, dropping every queued event silently.
|
||||
*
|
||||
* Returning a Promise from a `beforeExit` handler keeps the event loop
|
||||
* alive until that Promise resolves, so the awaited fetch actually has
|
||||
* time to land at PostHog.
|
||||
*/
|
||||
function ensureExitHandler(): void {
|
||||
if (_exitHandlerInstalled) return;
|
||||
_exitHandlerInstalled = true;
|
||||
process.on("beforeExit", async () => {
|
||||
if (eventQueue.length === 0) return;
|
||||
const batch = eventQueue;
|
||||
eventQueue = [];
|
||||
const body = JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
|
||||
try {
|
||||
await fetch(POSTHOG_HOST, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": String(Buffer.byteLength(body)),
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
});
|
||||
} catch {
|
||||
/* silently swallow */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function flushEvents(): void {
|
||||
if (eventQueue.length === 0) return;
|
||||
const batch = eventQueue;
|
||||
eventQueue = [];
|
||||
|
||||
const body = JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
|
||||
fetch(POSTHOG_HOST, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": String(Buffer.byteLength(body)),
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
}).catch(() => {
|
||||
/* silently swallow */
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a PostHog event (non-blocking, never throws).
|
||||
*/
|
||||
export function captureEvent(
|
||||
eventName: string,
|
||||
properties: Record<string, unknown> = {},
|
||||
ctx?: { apiKey?: string; mode?: string; skillsActive?: boolean },
|
||||
): void {
|
||||
if (!isTelemetryEnabled()) return;
|
||||
|
||||
try {
|
||||
const distinctId = getDistinctId(ctx?.apiKey);
|
||||
|
||||
let hasEmail = false;
|
||||
try { hasEmail = !!readPluginAuth().userEmail; } catch { /* ignore */ }
|
||||
if (ctx?.apiKey && !hasEmail && !distinctId.startsWith("openclaw-anon-")) {
|
||||
maybeResolveEmail(ctx.apiKey);
|
||||
}
|
||||
|
||||
// First authenticated event after a previous anonymous session: queue a
|
||||
// $identify ahead of the regular event so PostHog merges the anonymous
|
||||
// history onto the authenticated profile in the same batch flush.
|
||||
const identifyEvent = maybeBuildIdentifyEvent(distinctId);
|
||||
if (identifyEvent) {
|
||||
eventQueue.push(identifyEvent);
|
||||
}
|
||||
|
||||
eventQueue.push({
|
||||
event: eventName,
|
||||
distinct_id: distinctId,
|
||||
properties: {
|
||||
source: "OPENCLAW",
|
||||
language: "node",
|
||||
plugin_version: PLUGIN_VERSION,
|
||||
node_version: process.version,
|
||||
os: process.platform,
|
||||
mode: ctx?.mode,
|
||||
skills_active: ctx?.skillsActive,
|
||||
$process_person_profile: false,
|
||||
$lib: "posthog-node",
|
||||
...properties,
|
||||
},
|
||||
});
|
||||
|
||||
ensureFlushTimer();
|
||||
ensureExitHandler();
|
||||
|
||||
if (eventQueue.length >= FLUSH_THRESHOLD) {
|
||||
flushEvents();
|
||||
}
|
||||
} catch {
|
||||
/* silently swallow */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Test shim for openclaw/plugin-sdk/plugin-entry.
|
||||
* At runtime this is resolved from the OpenClaw gateway.
|
||||
*/
|
||||
export function definePluginEntry<T>(entry: T): T {
|
||||
return entry;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Test shim for openclaw/plugin-sdk.
|
||||
* At runtime this is resolved from the OpenClaw gateway.
|
||||
*/
|
||||
export interface OpenClawPluginApi {
|
||||
pluginConfig: Record<string, unknown>;
|
||||
logger: {
|
||||
info(msg: string): void;
|
||||
warn(msg: string): void;
|
||||
error(msg: string): void;
|
||||
debug(msg: string): void;
|
||||
};
|
||||
resolvePath(p: string): string;
|
||||
registerTool(definition: Record<string, unknown>, metadata?: Record<string, unknown>): void;
|
||||
on(event: string, handler: (event: any, ctx: any) => any): void;
|
||||
registerCli(handler: (context: { program: any }) => void, options?: Record<string, unknown>): void;
|
||||
registerService(service: { id: string; start: (...args: any[]) => void; stop: () => void }): void;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Tests for the Backend interface module: error classes and type-level
|
||||
* interface verification.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import {
|
||||
AuthError,
|
||||
NotFoundError,
|
||||
APIError,
|
||||
type Backend,
|
||||
type AddOptions,
|
||||
type SearchOptions,
|
||||
type ListOptions,
|
||||
type DeleteOptions,
|
||||
type EntityIds,
|
||||
} from "../backend/base.ts";
|
||||
import { PlatformBackend } from "../backend/platform.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AuthError
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("AuthError", () => {
|
||||
it("uses the default message when none is provided", () => {
|
||||
const err = new AuthError();
|
||||
expect(err.message).toBe(
|
||||
"Authentication failed. Your API key may be invalid or expired.",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a custom message", () => {
|
||||
const err = new AuthError("Token revoked");
|
||||
expect(err.message).toBe("Token revoked");
|
||||
});
|
||||
|
||||
it("has name 'AuthError'", () => {
|
||||
const err = new AuthError();
|
||||
expect(err.name).toBe("AuthError");
|
||||
});
|
||||
|
||||
it("is an instance of Error", () => {
|
||||
const err = new AuthError();
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NotFoundError
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("NotFoundError", () => {
|
||||
it("includes the path in the message", () => {
|
||||
const err = new NotFoundError("/v1/memories/abc-123");
|
||||
expect(err.message).toBe("Resource not found: /v1/memories/abc-123");
|
||||
});
|
||||
|
||||
it("has name 'NotFoundError'", () => {
|
||||
const err = new NotFoundError("/any");
|
||||
expect(err.name).toBe("NotFoundError");
|
||||
});
|
||||
|
||||
it("is an instance of Error", () => {
|
||||
const err = new NotFoundError("/any");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// APIError
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("APIError", () => {
|
||||
it("includes both path and detail in the message", () => {
|
||||
const err = new APIError("/v1/memories", "Invalid JSON body");
|
||||
expect(err.message).toBe("Bad request to /v1/memories: Invalid JSON body");
|
||||
});
|
||||
|
||||
it("has name 'APIError'", () => {
|
||||
const err = new APIError("/x", "y");
|
||||
expect(err.name).toBe("APIError");
|
||||
});
|
||||
|
||||
it("is an instance of Error", () => {
|
||||
const err = new APIError("/x", "y");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend interface — compile-time verification
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Backend interface (type-level)", () => {
|
||||
it("can be referenced as a type", () => {
|
||||
// This test verifies that the Backend type and option interfaces
|
||||
// import correctly and are usable at the type level.
|
||||
const _backendRef: Backend | undefined = undefined;
|
||||
const _addOpts: AddOptions = {};
|
||||
const _searchOpts: SearchOptions = {};
|
||||
const _listOpts: ListOptions = {};
|
||||
const _deleteOpts: DeleteOptions = {};
|
||||
const _entityIds: EntityIds = {};
|
||||
|
||||
// If this file compiles and this test runs, the interface is valid.
|
||||
expect(_backendRef).toBeUndefined();
|
||||
expect(_addOpts).toBeDefined();
|
||||
expect(_searchOpts).toBeDefined();
|
||||
expect(_listOpts).toBeDefined();
|
||||
expect(_deleteOpts).toBeDefined();
|
||||
expect(_entityIds).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PlatformBackend
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("PlatformBackend", () => {
|
||||
const BASE_URL = "https://api.mem0.ai";
|
||||
const API_KEY = "test-api-key-123";
|
||||
|
||||
function createBackend(): PlatformBackend {
|
||||
return new PlatformBackend({ apiKey: API_KEY, baseUrl: BASE_URL });
|
||||
}
|
||||
|
||||
function mockFetchResponse(
|
||||
status: number,
|
||||
body: unknown,
|
||||
statusText = "OK",
|
||||
): typeof fetch {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText,
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// -- Constructor ---------------------------------------------------------
|
||||
it("creates an instance with apiKey and baseUrl", () => {
|
||||
const backend = createBackend();
|
||||
expect(backend).toBeInstanceOf(PlatformBackend);
|
||||
});
|
||||
|
||||
it("strips trailing slashes from baseUrl", () => {
|
||||
const backend = new PlatformBackend({
|
||||
apiKey: API_KEY,
|
||||
baseUrl: "https://api.mem0.ai///",
|
||||
});
|
||||
// We can verify by calling status and checking the base_url in the response
|
||||
const mock = mockFetchResponse(200, { status: "ok" });
|
||||
vi.stubGlobal("fetch", mock);
|
||||
return backend.status().then((result) => {
|
||||
expect(result.base_url).toBe("https://api.mem0.ai");
|
||||
});
|
||||
});
|
||||
|
||||
// -- add() ---------------------------------------------------------------
|
||||
it("add() sends POST to /v1/memories/ with correct body structure", async () => {
|
||||
const mock = mockFetchResponse(200, { id: "mem-1", memory: "test" });
|
||||
vi.stubGlobal("fetch", mock);
|
||||
|
||||
const backend = createBackend();
|
||||
const result = await backend.add("Remember this", undefined, {
|
||||
userId: "user-1",
|
||||
});
|
||||
|
||||
expect(mock).toHaveBeenCalledOnce();
|
||||
const [url, opts] = (mock as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe("https://api.mem0.ai/v1/memories/");
|
||||
expect(opts.method).toBe("POST");
|
||||
expect(opts.headers).toMatchObject({
|
||||
Authorization: `Token ${API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
const body = JSON.parse(opts.body);
|
||||
expect(body.messages).toEqual([{ role: "user", content: "Remember this" }]);
|
||||
expect(body.user_id).toBe("user-1");
|
||||
expect(result).toEqual({ id: "mem-1", memory: "test" });
|
||||
});
|
||||
|
||||
it("add() passes messages directly when provided", async () => {
|
||||
const mock = mockFetchResponse(200, { id: "mem-2" });
|
||||
vi.stubGlobal("fetch", mock);
|
||||
|
||||
const messages = [
|
||||
{ role: "user", content: "Hi" },
|
||||
{ role: "assistant", content: "Hello!" },
|
||||
];
|
||||
const backend = createBackend();
|
||||
await backend.add(undefined, messages);
|
||||
|
||||
const body = JSON.parse(
|
||||
(mock as ReturnType<typeof vi.fn>).mock.calls[0][1].body,
|
||||
);
|
||||
expect(body.messages).toEqual(messages);
|
||||
});
|
||||
|
||||
// -- search() ------------------------------------------------------------
|
||||
it("search() sends POST to /v2/memories/search/", async () => {
|
||||
const mock = mockFetchResponse(200, [
|
||||
{ id: "mem-1", score: 0.95, memory: "test" },
|
||||
]);
|
||||
vi.stubGlobal("fetch", mock);
|
||||
|
||||
const backend = createBackend();
|
||||
const results = await backend.search("find this", { userId: "u1" });
|
||||
|
||||
expect(mock).toHaveBeenCalledOnce();
|
||||
const [url, opts] = (mock as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe("https://api.mem0.ai/v2/memories/search/");
|
||||
expect(opts.method).toBe("POST");
|
||||
|
||||
const body = JSON.parse(opts.body);
|
||||
expect(body.query).toBe("find this");
|
||||
expect(body.top_k).toBe(10);
|
||||
expect(body.threshold).toBe(0.3);
|
||||
expect(body.filters).toEqual({ user_id: "u1" });
|
||||
expect(results).toEqual([{ id: "mem-1", score: 0.95, memory: "test" }]);
|
||||
});
|
||||
|
||||
it("search() unwraps results from object envelope", async () => {
|
||||
const mock = mockFetchResponse(200, {
|
||||
results: [{ id: "mem-1" }],
|
||||
});
|
||||
vi.stubGlobal("fetch", mock);
|
||||
|
||||
const backend = createBackend();
|
||||
const results = await backend.search("query");
|
||||
expect(results).toEqual([{ id: "mem-1" }]);
|
||||
});
|
||||
|
||||
// -- get() ---------------------------------------------------------------
|
||||
it("get() sends GET to /v1/memories/{id}/", async () => {
|
||||
const mock = mockFetchResponse(200, { id: "mem-abc", memory: "test" });
|
||||
vi.stubGlobal("fetch", mock);
|
||||
|
||||
const backend = createBackend();
|
||||
const result = await backend.get("mem-abc");
|
||||
|
||||
expect(mock).toHaveBeenCalledOnce();
|
||||
const [url, opts] = (mock as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe("https://api.mem0.ai/v1/memories/mem-abc/");
|
||||
expect(opts.method).toBe("GET");
|
||||
expect(result).toEqual({ id: "mem-abc", memory: "test" });
|
||||
});
|
||||
|
||||
// -- delete() with memoryId ----------------------------------------------
|
||||
it("delete() with memoryId sends DELETE to /v1/memories/{id}/", async () => {
|
||||
const mock = mockFetchResponse(200, { deleted: true });
|
||||
vi.stubGlobal("fetch", mock);
|
||||
|
||||
const backend = createBackend();
|
||||
const result = await backend.delete("mem-del-1");
|
||||
|
||||
expect(mock).toHaveBeenCalledOnce();
|
||||
const [url, opts] = (mock as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe("https://api.mem0.ai/v1/memories/mem-del-1/");
|
||||
expect(opts.method).toBe("DELETE");
|
||||
expect(result).toEqual({ deleted: true });
|
||||
});
|
||||
|
||||
// -- delete() with all=true ----------------------------------------------
|
||||
it("delete() with all=true sends DELETE to /v1/memories/ with scope params", async () => {
|
||||
const mock = mockFetchResponse(200, { deleted: 5 });
|
||||
vi.stubGlobal("fetch", mock);
|
||||
|
||||
const backend = createBackend();
|
||||
const result = await backend.delete(undefined, {
|
||||
all: true,
|
||||
userId: "user-1",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
|
||||
expect(mock).toHaveBeenCalledOnce();
|
||||
const [url, opts] = (mock as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe(
|
||||
"https://api.mem0.ai/v1/memories/?user_id=user-1&agent_id=agent-1",
|
||||
);
|
||||
expect(opts.method).toBe("DELETE");
|
||||
expect(result).toEqual({ deleted: 5 });
|
||||
});
|
||||
|
||||
// -- status() ------------------------------------------------------------
|
||||
it("status() returns connected:true on successful ping", async () => {
|
||||
const mock = mockFetchResponse(200, { status: "ok" });
|
||||
vi.stubGlobal("fetch", mock);
|
||||
|
||||
const backend = createBackend();
|
||||
const result = await backend.status();
|
||||
|
||||
expect(result.connected).toBe(true);
|
||||
expect(result.backend).toBe("platform");
|
||||
expect(result.base_url).toBe(BASE_URL);
|
||||
});
|
||||
|
||||
it("status() returns connected:false on failure", async () => {
|
||||
const mock = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("Network error")) as unknown as typeof fetch;
|
||||
vi.stubGlobal("fetch", mock);
|
||||
|
||||
const backend = createBackend();
|
||||
const result = await backend.status();
|
||||
|
||||
expect(result.connected).toBe(false);
|
||||
expect(result.backend).toBe("platform");
|
||||
expect(result.error).toBe("Network error");
|
||||
});
|
||||
|
||||
// -- Error handling ------------------------------------------------------
|
||||
it("throws AuthError on 401", async () => {
|
||||
const mock = mockFetchResponse(401, {}, "Unauthorized");
|
||||
vi.stubGlobal("fetch", mock);
|
||||
|
||||
const backend = createBackend();
|
||||
await expect(backend.get("mem-1")).rejects.toThrow(AuthError);
|
||||
});
|
||||
|
||||
it("throws NotFoundError on 404", async () => {
|
||||
const mock = mockFetchResponse(404, {}, "Not Found");
|
||||
vi.stubGlobal("fetch", mock);
|
||||
|
||||
const backend = createBackend();
|
||||
await expect(backend.get("mem-nonexistent")).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it("throws APIError on 400", async () => {
|
||||
const mock = mockFetchResponse(
|
||||
400,
|
||||
{ detail: "Invalid request body" },
|
||||
"Bad Request",
|
||||
);
|
||||
vi.stubGlobal("fetch", mock);
|
||||
|
||||
const backend = createBackend();
|
||||
await expect(backend.add("bad data")).rejects.toThrow(APIError);
|
||||
});
|
||||
|
||||
it("throws generic Error on other non-ok status", async () => {
|
||||
const mock = mockFetchResponse(
|
||||
500,
|
||||
{ detail: "Internal server error" },
|
||||
"Internal Server Error",
|
||||
);
|
||||
vi.stubGlobal("fetch", mock);
|
||||
|
||||
const backend = createBackend();
|
||||
await expect(backend.get("mem-1")).rejects.toThrow("HTTP 500");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Tests for cli/config-file.ts — file-based config helpers.
|
||||
*
|
||||
* All filesystem operations are mocked via fs-safe.ts so tests never
|
||||
* touch the real disk.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../fs-safe.ts", () => ({
|
||||
readText: vi.fn(),
|
||||
exists: vi.fn(),
|
||||
writeText: vi.fn(),
|
||||
mkdirp: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
|
||||
import { readText, exists, writeText, mkdirp } from "../fs-safe.ts";
|
||||
import {
|
||||
readPluginAuth,
|
||||
writePluginAuth,
|
||||
getBaseUrl,
|
||||
DEFAULT_BASE_URL,
|
||||
} from "../cli/config-file.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockExists = exists as ReturnType<typeof vi.fn>;
|
||||
const mockReadText = readText as ReturnType<typeof vi.fn>;
|
||||
const mockWriteText = writeText as ReturnType<typeof vi.fn>;
|
||||
const mockMkdirp = mkdirp as ReturnType<typeof vi.fn>;
|
||||
|
||||
function setConfigFile(obj: Record<string, unknown>): void {
|
||||
mockExists.mockReturnValue(true);
|
||||
mockReadText.mockReturnValue(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
function setNoFile(): void {
|
||||
mockExists.mockReturnValue(false);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// readPluginAuth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("readPluginAuth", () => {
|
||||
it("returns empty object when config file does not exist", () => {
|
||||
setNoFile();
|
||||
expect(readPluginAuth()).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object when config has no plugins section", () => {
|
||||
setConfigFile({ someOtherKey: true });
|
||||
expect(readPluginAuth()).toEqual({});
|
||||
});
|
||||
|
||||
it("reads all fields correctly from nested config", () => {
|
||||
setConfigFile({
|
||||
plugins: {
|
||||
entries: {
|
||||
"openclaw-mem0": {
|
||||
enabled: true,
|
||||
config: {
|
||||
apiKey: "sk-test-123",
|
||||
baseUrl: "https://custom.api.com",
|
||||
userId: "user-1",
|
||||
mode: "platform",
|
||||
autoRecall: true,
|
||||
autoCapture: false,
|
||||
topK: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const auth = readPluginAuth();
|
||||
expect(auth).toEqual({
|
||||
apiKey: "sk-test-123",
|
||||
baseUrl: "https://custom.api.com",
|
||||
userId: "user-1",
|
||||
mode: "platform",
|
||||
autoRecall: true,
|
||||
autoCapture: false,
|
||||
topK: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles snake_case aliases (api_key, base_url, user_id)", () => {
|
||||
setConfigFile({
|
||||
plugins: {
|
||||
entries: {
|
||||
"openclaw-mem0": {
|
||||
enabled: true,
|
||||
config: {
|
||||
api_key: "sk-snake",
|
||||
base_url: "https://snake.api.com",
|
||||
user_id: "user-snake",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const auth = readPluginAuth();
|
||||
expect(auth.apiKey).toBe("sk-snake");
|
||||
expect(auth.baseUrl).toBe("https://snake.api.com");
|
||||
expect(auth.userId).toBe("user-snake");
|
||||
});
|
||||
|
||||
it("throws error when JSON is invalid (prevents config destruction)", () => {
|
||||
mockExists.mockReturnValue(true);
|
||||
mockReadText.mockReturnValue("not valid json {{{");
|
||||
expect(() => readPluginAuth()).toThrow(/Failed to parse[\s\S]*Fix the JSON syntax error/);
|
||||
});
|
||||
|
||||
it("returns empty object when config file is empty", () => {
|
||||
mockExists.mockReturnValue(true);
|
||||
mockReadText.mockReturnValue(" ");
|
||||
expect(readPluginAuth()).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// writePluginAuth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("writePluginAuth", () => {
|
||||
it("creates nested structure from scratch when no config exists", () => {
|
||||
setNoFile();
|
||||
// exists returns false for both the file (readFullConfig) and the dir (writeFullConfig)
|
||||
mockExists.mockReturnValue(false);
|
||||
|
||||
writePluginAuth({ apiKey: "sk-new", userId: "u1" });
|
||||
|
||||
expect(mockMkdirp).toHaveBeenCalled();
|
||||
expect(mockWriteText).toHaveBeenCalledOnce();
|
||||
|
||||
const written = JSON.parse(mockWriteText.mock.calls[0][1]);
|
||||
expect(written.plugins.entries["openclaw-mem0"].enabled).toBe(true);
|
||||
expect(written.plugins.entries["openclaw-mem0"].config.apiKey).toBe(
|
||||
"sk-new",
|
||||
);
|
||||
expect(written.plugins.entries["openclaw-mem0"].config.userId).toBe("u1");
|
||||
});
|
||||
|
||||
it("merges into existing config preserving other data", () => {
|
||||
setConfigFile({
|
||||
otherSetting: "keep-me",
|
||||
plugins: {
|
||||
entries: {
|
||||
"openclaw-mem0": {
|
||||
enabled: true,
|
||||
config: {
|
||||
apiKey: "sk-old",
|
||||
mode: "platform",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
writePluginAuth({ baseUrl: "https://new.api.com" });
|
||||
|
||||
const written = JSON.parse(mockWriteText.mock.calls[0][1]);
|
||||
// Existing fields preserved
|
||||
expect(written.otherSetting).toBe("keep-me");
|
||||
expect(written.plugins.entries["openclaw-mem0"].config.apiKey).toBe(
|
||||
"sk-old",
|
||||
);
|
||||
expect(written.plugins.entries["openclaw-mem0"].config.mode).toBe(
|
||||
"platform",
|
||||
);
|
||||
// New field added
|
||||
expect(written.plugins.entries["openclaw-mem0"].config.baseUrl).toBe(
|
||||
"https://new.api.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("creates directory if missing", () => {
|
||||
// File doesn't exist (readFullConfig returns {}), dir doesn't exist
|
||||
mockExists.mockReturnValue(false);
|
||||
|
||||
writePluginAuth({ apiKey: "sk-test" });
|
||||
|
||||
expect(mockMkdirp).toHaveBeenCalledWith(
|
||||
expect.stringContaining(".openclaw"),
|
||||
0o700,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips undefined values", () => {
|
||||
setNoFile();
|
||||
mockExists.mockReturnValue(false);
|
||||
|
||||
writePluginAuth({
|
||||
apiKey: "sk-set",
|
||||
baseUrl: undefined,
|
||||
userId: undefined,
|
||||
});
|
||||
|
||||
const written = JSON.parse(mockWriteText.mock.calls[0][1]);
|
||||
const cfg = written.plugins.entries["openclaw-mem0"].config;
|
||||
expect(cfg.apiKey).toBe("sk-set");
|
||||
expect(cfg).not.toHaveProperty("baseUrl");
|
||||
expect(cfg).not.toHaveProperty("userId");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getBaseUrl
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getBaseUrl", () => {
|
||||
it("returns configured URL when baseUrl is set", () => {
|
||||
setConfigFile({
|
||||
plugins: {
|
||||
entries: {
|
||||
"openclaw-mem0": {
|
||||
enabled: true,
|
||||
config: { baseUrl: "https://custom.example.com" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getBaseUrl()).toBe("https://custom.example.com");
|
||||
});
|
||||
|
||||
it("returns default URL when baseUrl is not configured", () => {
|
||||
setNoFile();
|
||||
expect(getBaseUrl()).toBe(DEFAULT_BASE_URL);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,484 @@
|
||||
/**
|
||||
* Tests for config.ts — mem0ConfigSchema.parse() and exported constants.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
mem0ConfigSchema,
|
||||
DEFAULT_CUSTOM_INSTRUCTIONS,
|
||||
DEFAULT_CUSTOM_CATEGORIES,
|
||||
} from "../config.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported constants
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("DEFAULT_CUSTOM_INSTRUCTIONS", () => {
|
||||
it("is a non-empty string", () => {
|
||||
expect(typeof DEFAULT_CUSTOM_INSTRUCTIONS).toBe("string");
|
||||
expect(DEFAULT_CUSTOM_INSTRUCTIONS.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_CUSTOM_CATEGORIES", () => {
|
||||
it("is a non-empty object with string values", () => {
|
||||
expect(typeof DEFAULT_CUSTOM_CATEGORIES).toBe("object");
|
||||
const keys = Object.keys(DEFAULT_CUSTOM_CATEGORIES);
|
||||
expect(keys.length).toBeGreaterThan(0);
|
||||
for (const key of keys) {
|
||||
expect(typeof DEFAULT_CUSTOM_CATEGORIES[key]).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mem0ConfigSchema.parse() — defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("mem0ConfigSchema.parse() — defaults", () => {
|
||||
it("mode defaults to 'platform' when omitted", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
|
||||
expect(cfg.mode).toBe("platform");
|
||||
});
|
||||
|
||||
it("userId falls back to a non-empty string when not provided", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
|
||||
expect(typeof cfg.userId).toBe("string");
|
||||
expect(cfg.userId.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("autoCapture defaults to true", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
|
||||
expect(cfg.autoCapture).toBe(true);
|
||||
});
|
||||
|
||||
it("autoRecall defaults to true", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
|
||||
expect(cfg.autoRecall).toBe(true);
|
||||
});
|
||||
|
||||
it("topK defaults to 5", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
|
||||
expect(cfg.topK).toBe(5);
|
||||
});
|
||||
|
||||
it("searchThreshold defaults to 0.1", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
|
||||
expect(cfg.searchThreshold).toBe(0.1);
|
||||
});
|
||||
|
||||
it("customInstructions defaults to DEFAULT_CUSTOM_INSTRUCTIONS", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
|
||||
expect(cfg.customInstructions).toBe(DEFAULT_CUSTOM_INSTRUCTIONS);
|
||||
});
|
||||
|
||||
it("customCategories defaults to DEFAULT_CUSTOM_CATEGORIES", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
|
||||
expect(cfg.customCategories).toBe(DEFAULT_CUSTOM_CATEGORIES);
|
||||
});
|
||||
|
||||
// v3.0.0: customPrompt removed, use customInstructions instead
|
||||
it("customPrompt input falls back to customInstructions", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key", customPrompt: "My prompt" });
|
||||
expect(cfg.customInstructions).toBe("My prompt");
|
||||
});
|
||||
|
||||
it("oss defaults to undefined", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
|
||||
expect(cfg.oss).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skills defaults to undefined", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
|
||||
expect(cfg.skills).toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows anonymousTelemetryId", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key", anonymousTelemetryId: "123" });
|
||||
expect(cfg.anonymousTelemetryId).toBe("123");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mem0ConfigSchema.parse() — mode parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("mem0ConfigSchema.parse() — mode parsing", () => {
|
||||
it('"oss" is not a valid mode and defaults to "platform"', () => {
|
||||
const cfg = mem0ConfigSchema.parse({ mode: "oss", apiKey: "k" });
|
||||
expect(cfg.mode).toBe("platform");
|
||||
});
|
||||
|
||||
it('"open-source" stays as "open-source"', () => {
|
||||
const cfg = mem0ConfigSchema.parse({ mode: "open-source" });
|
||||
expect(cfg.mode).toBe("open-source");
|
||||
});
|
||||
|
||||
it("any other string defaults to 'platform'", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ mode: "something-else", apiKey: "k" });
|
||||
expect(cfg.mode).toBe("platform");
|
||||
});
|
||||
|
||||
it("undefined mode defaults to 'platform'", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "k" });
|
||||
expect(cfg.mode).toBe("platform");
|
||||
});
|
||||
|
||||
it("numeric mode defaults to 'platform'", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ mode: 42, apiKey: "k" });
|
||||
expect(cfg.mode).toBe("platform");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mem0ConfigSchema.parse() — userId precedence
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("mem0ConfigSchema.parse() — userId", () => {
|
||||
it("userId from config takes precedence over os.userInfo() fallback", () => {
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
apiKey: "test-key",
|
||||
userId: "custom-user",
|
||||
});
|
||||
expect(cfg.userId).toBe("custom-user");
|
||||
});
|
||||
|
||||
it("empty string userId falls back to os.userInfo()", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key", userId: "" });
|
||||
// Empty string is falsy, so the fallback should kick in
|
||||
expect(typeof cfg.userId).toBe("string");
|
||||
expect(cfg.userId.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("non-string userId falls back to os.userInfo()", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key", userId: 123 });
|
||||
expect(typeof cfg.userId).toBe("string");
|
||||
expect(cfg.userId.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mem0ConfigSchema.parse() — needsSetup
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("mem0ConfigSchema.parse() — needsSetup", () => {
|
||||
// Note: needsSetup = (mode === "platform" && !resolvedApiKey).
|
||||
// resolvedApiKey can come from the config OR from ~/.mem0/config.json fallback.
|
||||
// When no apiKey is provided and no config file exists, needsSetup is true.
|
||||
// When ~/.mem0/config.json has a key, the fallback populates resolvedApiKey.
|
||||
|
||||
it("needsSetup is consistent: false when apiKey resolves, true otherwise (no apiKey in config)", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ mode: "platform" });
|
||||
// needsSetup should be true only if NO apiKey was resolved (including from ~/.mem0/config.json)
|
||||
if (cfg.apiKey) {
|
||||
expect(cfg.needsSetup).toBe(false);
|
||||
} else {
|
||||
expect(cfg.needsSetup).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("needsSetup is consistent with empty config", () => {
|
||||
const cfg = mem0ConfigSchema.parse({});
|
||||
if (cfg.apiKey) {
|
||||
expect(cfg.needsSetup).toBe(false);
|
||||
} else {
|
||||
expect(cfg.needsSetup).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("is false when apiKey is explicitly provided", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "my-api-key" });
|
||||
expect(cfg.needsSetup).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when mode is 'open-source' via explicit string (no apiKey needed)", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ mode: "open-source" });
|
||||
expect(cfg.needsSetup).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when mode is 'open-source' (no apiKey needed)", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ mode: "open-source" });
|
||||
expect(cfg.needsSetup).toBe(false);
|
||||
});
|
||||
|
||||
it("needsSetup is always false when apiKey is a valid string", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key-123" });
|
||||
expect(cfg.apiKey).toBe("test-key-123");
|
||||
expect(cfg.needsSetup).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mem0ConfigSchema.parse() — error cases
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("mem0ConfigSchema.parse() — error cases", () => {
|
||||
it("throws on unknown keys", () => {
|
||||
expect(() =>
|
||||
mem0ConfigSchema.parse({ apiKey: "k", unknownKey: "value" }),
|
||||
).toThrow(/unknown keys.*unknownKey/);
|
||||
});
|
||||
|
||||
it("throws when multiple unknown keys are present", () => {
|
||||
expect(() =>
|
||||
mem0ConfigSchema.parse({ apiKey: "k", foo: 1, bar: 2 }),
|
||||
).toThrow(/unknown keys/);
|
||||
});
|
||||
|
||||
it("throws on null input", () => {
|
||||
expect(() => mem0ConfigSchema.parse(null)).toThrow(
|
||||
"openclaw-mem0 config required",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on undefined input", () => {
|
||||
expect(() => mem0ConfigSchema.parse(undefined)).toThrow(
|
||||
"openclaw-mem0 config required",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on string input", () => {
|
||||
expect(() => mem0ConfigSchema.parse("not an object")).toThrow(
|
||||
"openclaw-mem0 config required",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on number input", () => {
|
||||
expect(() => mem0ConfigSchema.parse(42)).toThrow(
|
||||
"openclaw-mem0 config required",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on array input", () => {
|
||||
expect(() => mem0ConfigSchema.parse([1, 2, 3])).toThrow(
|
||||
"openclaw-mem0 config required",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on boolean input", () => {
|
||||
expect(() => mem0ConfigSchema.parse(true)).toThrow(
|
||||
"openclaw-mem0 config required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mem0ConfigSchema.parse() — explicit overrides
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("mem0ConfigSchema.parse() — explicit overrides", () => {
|
||||
it("autoCapture can be set to false", () => {
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
apiKey: "k",
|
||||
autoCapture: false,
|
||||
});
|
||||
expect(cfg.autoCapture).toBe(false);
|
||||
});
|
||||
|
||||
it("autoRecall can be set to false", () => {
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
apiKey: "k",
|
||||
autoRecall: false,
|
||||
});
|
||||
expect(cfg.autoRecall).toBe(false);
|
||||
});
|
||||
|
||||
it("custom topK is used when provided", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "k", topK: 20 });
|
||||
expect(cfg.topK).toBe(20);
|
||||
});
|
||||
|
||||
it("custom searchThreshold is used when provided", () => {
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
apiKey: "k",
|
||||
searchThreshold: 0.8,
|
||||
});
|
||||
expect(cfg.searchThreshold).toBe(0.8);
|
||||
});
|
||||
|
||||
it("custom customInstructions override defaults", () => {
|
||||
const custom = "My custom instructions";
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
apiKey: "k",
|
||||
customInstructions: custom,
|
||||
});
|
||||
expect(cfg.customInstructions).toBe(custom);
|
||||
});
|
||||
|
||||
// v3.0.0: customPrompt renamed to customInstructions (backwards compat: customPrompt maps to customInstructions)
|
||||
it("customPrompt input maps to customInstructions output", () => {
|
||||
const custom = "My custom prompt";
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
apiKey: "k",
|
||||
customPrompt: custom,
|
||||
});
|
||||
expect(cfg.customInstructions).toBe(custom);
|
||||
});
|
||||
|
||||
it("custom customCategories override defaults", () => {
|
||||
const cats = { myCategory: "description" };
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
apiKey: "k",
|
||||
customCategories: cats,
|
||||
});
|
||||
expect(cfg.customCategories).toEqual(cats);
|
||||
});
|
||||
|
||||
it("baseUrl is passed through when provided", () => {
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
apiKey: "k",
|
||||
baseUrl: "https://custom.api.com",
|
||||
});
|
||||
expect(cfg.baseUrl).toBe("https://custom.api.com");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mem0ConfigSchema.parse() — oss config
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("mem0ConfigSchema.parse() — oss config", () => {
|
||||
it("parses oss object when provided", () => {
|
||||
const ossConfig = {
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: { provider: "qdrant", config: { host: "localhost" } },
|
||||
llm: { provider: "openai", config: { model: "gpt-4" } },
|
||||
historyDbPath: "/tmp/history.db",
|
||||
disableHistory: false,
|
||||
};
|
||||
const cfg = mem0ConfigSchema.parse({ mode: "open-source", oss: ossConfig });
|
||||
expect(cfg.mode).toBe("open-source");
|
||||
expect(cfg.oss).toEqual(ossConfig);
|
||||
});
|
||||
|
||||
it("ignores oss when it is not a plain object", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ mode: "open-source", oss: "not-an-object" });
|
||||
expect(cfg.oss).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores oss when it is an array", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ mode: "open-source", oss: [1, 2, 3] });
|
||||
expect(cfg.oss).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores oss when it is null", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ mode: "open-source", oss: null });
|
||||
expect(cfg.oss).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mem0ConfigSchema.parse() — skills config
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("mem0ConfigSchema.parse() — skills config", () => {
|
||||
it("parses skills object when provided", () => {
|
||||
const skillsConfig = {
|
||||
triage: {
|
||||
enabled: true,
|
||||
importanceThreshold: 3,
|
||||
credentialPatterns: ["sk-", "ghp_"],
|
||||
},
|
||||
recall: {
|
||||
enabled: true,
|
||||
strategy: "smart" as const,
|
||||
tokenBudget: 2000,
|
||||
maxMemories: 10,
|
||||
},
|
||||
dream: {
|
||||
enabled: true,
|
||||
auto: true,
|
||||
minHours: 12,
|
||||
minSessions: 3,
|
||||
minMemories: 15,
|
||||
},
|
||||
domain: "engineering",
|
||||
customRules: {
|
||||
include: ["tool configs"],
|
||||
exclude: ["passwords"],
|
||||
},
|
||||
categories: {
|
||||
identity: {
|
||||
importance: 5,
|
||||
ttl: null,
|
||||
immutable: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
apiKey: "k",
|
||||
skills: skillsConfig,
|
||||
});
|
||||
expect(cfg.skills).toEqual(skillsConfig);
|
||||
});
|
||||
|
||||
it("skills is undefined when not provided", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "k" });
|
||||
expect(cfg.skills).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skills is undefined when set to a non-object value", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "k", skills: "invalid" });
|
||||
expect(cfg.skills).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skills is undefined when set to an array", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "k", skills: [1, 2] });
|
||||
expect(cfg.skills).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skills is undefined when set to null", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "k", skills: null });
|
||||
expect(cfg.skills).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mem0ConfigSchema.parse() — customCategories edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("mem0ConfigSchema.parse() — customCategories edge cases", () => {
|
||||
it("non-object customCategories falls back to defaults", () => {
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
apiKey: "k",
|
||||
customCategories: "not-an-object",
|
||||
});
|
||||
expect(cfg.customCategories).toBe(DEFAULT_CUSTOM_CATEGORIES);
|
||||
});
|
||||
|
||||
it("array customCategories falls back to defaults", () => {
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
apiKey: "k",
|
||||
customCategories: ["a", "b"],
|
||||
});
|
||||
expect(cfg.customCategories).toBe(DEFAULT_CUSTOM_CATEGORIES);
|
||||
});
|
||||
|
||||
it("null customCategories falls back to defaults", () => {
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
apiKey: "k",
|
||||
customCategories: null,
|
||||
});
|
||||
expect(cfg.customCategories).toBe(DEFAULT_CUSTOM_CATEGORIES);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mem0ConfigSchema.parse() — non-string apiKey
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("mem0ConfigSchema.parse() — apiKey edge cases", () => {
|
||||
// Note: When a non-string apiKey is provided, the parser treats it as
|
||||
// undefined. However, readMem0ConfigFile() may still provide a fallback
|
||||
// apiKey from ~/.mem0/config.json if one exists on the system.
|
||||
|
||||
it("non-string apiKey is not used directly from config", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: 12345 });
|
||||
// The numeric value is not used directly — apiKey comes from fallback or is undefined
|
||||
// Either way, the non-string value is never the resolved apiKey
|
||||
expect(cfg.apiKey).not.toBe(12345);
|
||||
});
|
||||
|
||||
it("boolean apiKey is not used directly from config", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: true });
|
||||
expect(cfg.apiKey).not.toBe(true);
|
||||
});
|
||||
|
||||
it("string apiKey takes precedence over any fallback", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "explicit-key" });
|
||||
expect(cfg.apiKey).toBe("explicit-key");
|
||||
expect(cfg.needsSetup).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* Tests for dream-gate.ts — activity tracking, gate logic, and lock mechanism
|
||||
* for automatic memory consolidation.
|
||||
*
|
||||
* All filesystem operations are mocked via fs-safe.ts.
|
||||
* Time-dependent tests use vi.useFakeTimers().
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("../fs-safe.ts", () => ({
|
||||
readText: vi.fn(),
|
||||
writeText: vi.fn(),
|
||||
mkdirp: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
|
||||
import { readText, writeText, mkdirp, unlink } from "../fs-safe.ts";
|
||||
import {
|
||||
incrementSessionCount,
|
||||
checkCheapGates,
|
||||
checkMemoryGate,
|
||||
acquireDreamLock,
|
||||
releaseDreamLock,
|
||||
recordDreamCompletion,
|
||||
getDreamState,
|
||||
} from "../dream-gate.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockReadText = readText as ReturnType<typeof vi.fn>;
|
||||
const mockWriteText = writeText as ReturnType<typeof vi.fn>;
|
||||
const mockMkdirp = mkdirp as ReturnType<typeof vi.fn>;
|
||||
const mockUnlink = unlink as ReturnType<typeof vi.fn>;
|
||||
|
||||
const STATE_DIR = "/tmp/test-state";
|
||||
|
||||
interface DreamState {
|
||||
lastConsolidatedAt: number;
|
||||
sessionsSince: number;
|
||||
lastSessionId: string | null;
|
||||
}
|
||||
|
||||
function setDreamState(state: DreamState): void {
|
||||
mockReadText.mockImplementation((filePath: string) => {
|
||||
if (filePath.endsWith("dream-state.json")) {
|
||||
return JSON.stringify(state);
|
||||
}
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
}
|
||||
|
||||
function setNoState(): void {
|
||||
mockReadText.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
}
|
||||
|
||||
function getWrittenState(): DreamState {
|
||||
const call = mockWriteText.mock.calls.find((c: unknown[]) =>
|
||||
(c[0] as string).endsWith("dream-state.json"),
|
||||
);
|
||||
if (!call) throw new Error("No state file written");
|
||||
return JSON.parse(call[1] as string);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockMkdirp.mockReturnValue(undefined);
|
||||
mockUnlink.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// incrementSessionCount
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("incrementSessionCount", () => {
|
||||
it("increments counter for a new session", () => {
|
||||
setDreamState({
|
||||
lastConsolidatedAt: 0,
|
||||
sessionsSince: 3,
|
||||
lastSessionId: "session-old",
|
||||
});
|
||||
|
||||
incrementSessionCount(STATE_DIR, "session-new");
|
||||
|
||||
const written = getWrittenState();
|
||||
expect(written.sessionsSince).toBe(4);
|
||||
expect(written.lastSessionId).toBe("session-new");
|
||||
});
|
||||
|
||||
it("deduplicates same session (no increment)", () => {
|
||||
setDreamState({
|
||||
lastConsolidatedAt: 0,
|
||||
sessionsSince: 3,
|
||||
lastSessionId: "session-same",
|
||||
});
|
||||
|
||||
incrementSessionCount(STATE_DIR, "session-same");
|
||||
|
||||
// writeText should NOT have been called for the state file
|
||||
const stateWrites = mockWriteText.mock.calls.filter((c: unknown[]) =>
|
||||
(c[0] as string).endsWith("dream-state.json"),
|
||||
);
|
||||
expect(stateWrites).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// checkCheapGates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("checkCheapGates", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("fails time gate when consolidation was too recent", () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
// Last consolidated 1 hour ago, but minHours is 24
|
||||
setDreamState({
|
||||
lastConsolidatedAt: now - 1 * 3_600_000,
|
||||
sessionsSince: 100,
|
||||
lastSessionId: null,
|
||||
});
|
||||
|
||||
const result = checkCheapGates(STATE_DIR, { minHours: 24, minSessions: 5 });
|
||||
expect(result.proceed).toBe(false);
|
||||
expect(result.reason).toContain("time");
|
||||
});
|
||||
|
||||
it("fails session gate when too few sessions", () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
// Last consolidated 48 hours ago (passes time gate), but only 2 sessions
|
||||
setDreamState({
|
||||
lastConsolidatedAt: now - 48 * 3_600_000,
|
||||
sessionsSince: 2,
|
||||
lastSessionId: null,
|
||||
});
|
||||
|
||||
const result = checkCheapGates(STATE_DIR, {
|
||||
minHours: 24,
|
||||
minSessions: 5,
|
||||
});
|
||||
expect(result.proceed).toBe(false);
|
||||
expect(result.reason).toContain("sessions");
|
||||
});
|
||||
|
||||
it("passes both gates when conditions are met", () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
// 48 hours ago, 10 sessions — both gates pass
|
||||
setDreamState({
|
||||
lastConsolidatedAt: now - 48 * 3_600_000,
|
||||
sessionsSince: 10,
|
||||
lastSessionId: null,
|
||||
});
|
||||
|
||||
const result = checkCheapGates(STATE_DIR, {
|
||||
minHours: 24,
|
||||
minSessions: 5,
|
||||
});
|
||||
expect(result.proceed).toBe(true);
|
||||
expect(result.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses defaults when config is empty", () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
// Never consolidated (0), 100 sessions — should pass with defaults (24h, 5 sessions)
|
||||
setDreamState({
|
||||
lastConsolidatedAt: 0,
|
||||
sessionsSince: 100,
|
||||
lastSessionId: null,
|
||||
});
|
||||
|
||||
const result = checkCheapGates(STATE_DIR, {});
|
||||
expect(result.proceed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// checkMemoryGate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("checkMemoryGate", () => {
|
||||
it("fails when too few memories", () => {
|
||||
const result = checkMemoryGate(5, { minMemories: 20 });
|
||||
expect(result.pass).toBe(false);
|
||||
expect(result.reason).toContain("memories");
|
||||
expect(result.reason).toContain("5");
|
||||
});
|
||||
|
||||
it("passes when enough memories", () => {
|
||||
const result = checkMemoryGate(25, { minMemories: 20 });
|
||||
expect(result.pass).toBe(true);
|
||||
expect(result.reason).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// acquireDreamLock
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("acquireDreamLock", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("succeeds when no lock exists", () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
// readText throws for lock file (not found), writeText succeeds for wx create
|
||||
mockReadText.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
mockWriteText.mockReturnValue(undefined);
|
||||
|
||||
const result = acquireDreamLock(STATE_DIR);
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify it wrote a lock file with wx flag
|
||||
const lockWrite = mockWriteText.mock.calls.find((c: unknown[]) =>
|
||||
(c[0] as string).endsWith("dream.lock"),
|
||||
);
|
||||
expect(lockWrite).toBeDefined();
|
||||
const lockData = JSON.parse(lockWrite![1] as string);
|
||||
expect(lockData.pid).toBe(process.pid);
|
||||
expect(lockData.startedAt).toBe(now);
|
||||
expect(lockWrite![2]).toEqual({ flag: "wx" });
|
||||
});
|
||||
|
||||
it("fails when lock exists and is fresh", () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
// Lock was created 10 minutes ago — still fresh (< 1 hour)
|
||||
mockReadText.mockImplementation((filePath: string) => {
|
||||
if (filePath.endsWith("dream.lock")) {
|
||||
return JSON.stringify({
|
||||
pid: 12345,
|
||||
startedAt: now - 10 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
|
||||
const result = acquireDreamLock(STATE_DIR);
|
||||
expect(result).toBe(false);
|
||||
|
||||
// Should NOT have written a new lock
|
||||
const lockWrites = mockWriteText.mock.calls.filter((c: unknown[]) =>
|
||||
(c[0] as string).endsWith("dream.lock"),
|
||||
);
|
||||
expect(lockWrites).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("succeeds when lock is stale (>1hr old)", () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
// Lock was created 2 hours ago — stale
|
||||
mockReadText.mockImplementation((filePath: string) => {
|
||||
if (filePath.endsWith("dream.lock")) {
|
||||
return JSON.stringify({
|
||||
pid: 99999,
|
||||
startedAt: now - 2 * 60 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
mockWriteText.mockReturnValue(undefined);
|
||||
|
||||
const result = acquireDreamLock(STATE_DIR);
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Should have unlinked the stale lock
|
||||
expect(mockUnlink).toHaveBeenCalled();
|
||||
|
||||
// Should have written a new lock
|
||||
const lockWrite = mockWriteText.mock.calls.find((c: unknown[]) =>
|
||||
(c[0] as string).endsWith("dream.lock"),
|
||||
);
|
||||
expect(lockWrite).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// releaseDreamLock
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("releaseDreamLock", () => {
|
||||
it("removes lock file", () => {
|
||||
releaseDreamLock(STATE_DIR);
|
||||
expect(mockUnlink).toHaveBeenCalledWith(
|
||||
expect.stringContaining("dream.lock"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// recordDreamCompletion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("recordDreamCompletion", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("resets session counter and records timestamp", () => {
|
||||
const now = 1700000000000;
|
||||
vi.setSystemTime(now);
|
||||
|
||||
setDreamState({
|
||||
lastConsolidatedAt: 0,
|
||||
sessionsSince: 15,
|
||||
lastSessionId: "session-xyz",
|
||||
});
|
||||
|
||||
recordDreamCompletion(STATE_DIR);
|
||||
|
||||
const written = getWrittenState();
|
||||
expect(written.lastConsolidatedAt).toBe(now);
|
||||
expect(written.sessionsSince).toBe(0);
|
||||
expect(written.lastSessionId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getDreamState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getDreamState", () => {
|
||||
it("returns default state when no file exists", () => {
|
||||
setNoState();
|
||||
|
||||
const state = getDreamState(STATE_DIR);
|
||||
expect(state).toEqual({
|
||||
lastConsolidatedAt: 0,
|
||||
sessionsSince: 0,
|
||||
lastSessionId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns persisted state when file exists", () => {
|
||||
const persisted = {
|
||||
lastConsolidatedAt: 1700000000000,
|
||||
sessionsSince: 7,
|
||||
lastSessionId: "session-abc",
|
||||
};
|
||||
setDreamState(persisted);
|
||||
|
||||
const state = getDreamState(STATE_DIR);
|
||||
expect(state).toEqual(persisted);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isNoiseMessage,
|
||||
isGenericAssistantMessage,
|
||||
isSessionSpecificContent,
|
||||
stripNoiseFromContent,
|
||||
filterMessagesForExtraction,
|
||||
} from "../filtering.ts";
|
||||
|
||||
describe("isNoiseMessage", () => {
|
||||
it("returns true for heartbeat messages", () => {
|
||||
expect(isNoiseMessage("HEARTBEAT_OK")).toBe(true);
|
||||
expect(isNoiseMessage("NO_REPLY")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for single-word acknowledgments", () => {
|
||||
expect(isNoiseMessage("ok")).toBe(true);
|
||||
expect(isNoiseMessage("done")).toBe(true);
|
||||
expect(isNoiseMessage("thanks")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for substantive content", () => {
|
||||
expect(isNoiseMessage("My name is John and I live in Tokyo")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGenericAssistantMessage", () => {
|
||||
it("returns true for generic acknowledgments", () => {
|
||||
expect(isGenericAssistantMessage("I see you've shared this. How can I help?")).toBe(true);
|
||||
expect(isGenericAssistantMessage("Got it! How can I assist you?")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for substantive responses", () => {
|
||||
expect(isGenericAssistantMessage("Based on the code, the bug is in line 42 where the null check is missing.")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSessionSpecificContent", () => {
|
||||
it("returns true for tool availability discussions", () => {
|
||||
expect(isSessionSpecificContent(
|
||||
"I do not currently see Mem0 write/update/delete tools exposed in this session."
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for plugin capability statements", () => {
|
||||
expect(isSessionSpecificContent(
|
||||
"As of 2026-04-18, the openclaw-mem0 plugin does not expose a memory wiki capability."
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for session-specific tool lists", () => {
|
||||
expect(isSessionSpecificContent(
|
||||
"The tools I have access to in this session are memory_search and memory_get."
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for user preferences", () => {
|
||||
expect(isSessionSpecificContent(
|
||||
"My name is Kartik and I like to watch Pokemon."
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for technical facts that span sessions", () => {
|
||||
expect(isSessionSpecificContent(
|
||||
"The project uses TypeScript 5.0 and Node.js 20."
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterMessagesForExtraction", () => {
|
||||
it("filters out session-specific tool discussions", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "What tools do you have access to?" },
|
||||
{ role: "assistant", content: "I have memory_search and memory_get tools. The other tools are not exposed in this session." },
|
||||
{ role: "user", content: "My name is Kartik" },
|
||||
{ role: "assistant", content: "Got it, I'll remember that!" },
|
||||
];
|
||||
|
||||
const filtered = filterMessagesForExtraction(messages);
|
||||
|
||||
// Should keep the name statement, filter the tool discussion
|
||||
expect(filtered.some(m => m.content.includes("Kartik"))).toBe(true);
|
||||
expect(filtered.some(m => m.content.includes("not exposed in this session"))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps valuable user facts", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "I prefer dark mode and use VS Code for development." },
|
||||
{ role: "assistant", content: "Noted! Dark mode in VS Code is great for reducing eye strain." },
|
||||
];
|
||||
|
||||
const filtered = filterMessagesForExtraction(messages);
|
||||
expect(filtered.length).toBe(2);
|
||||
expect(filtered[0].content).toContain("dark mode");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { bootstrapTelemetryFlag } from "../fs-safe.ts";
|
||||
|
||||
describe("bootstrapTelemetryFlag", () => {
|
||||
beforeEach(() => {
|
||||
delete (globalThis as any).__mem0_telemetry_override;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as any).__mem0_telemetry_override;
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("sets globalThis override when MEM0_TELEMETRY is set", () => {
|
||||
vi.stubEnv("MEM0_TELEMETRY", "false");
|
||||
bootstrapTelemetryFlag();
|
||||
expect((globalThis as any).__mem0_telemetry_override).toBe("false");
|
||||
});
|
||||
|
||||
it("does not set globalThis override when MEM0_TELEMETRY is unset", () => {
|
||||
vi.stubEnv("MEM0_TELEMETRY", undefined as unknown as string);
|
||||
bootstrapTelemetryFlag();
|
||||
expect((globalThis as any).__mem0_telemetry_override).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes through truthy values", () => {
|
||||
vi.stubEnv("MEM0_TELEMETRY", "true");
|
||||
bootstrapTelemetryFlag();
|
||||
expect((globalThis as any).__mem0_telemetry_override).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { jsonOut, jsonErr, redactSecrets } from "../cli/json-helpers.ts";
|
||||
|
||||
describe("jsonOut", () => {
|
||||
let writeSpy: ReturnType<typeof vi.spyOn>;
|
||||
beforeEach(() => { writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); });
|
||||
afterEach(() => { writeSpy.mockRestore(); });
|
||||
|
||||
it("returns false and prints nothing when json is falsy", () => {
|
||||
expect(jsonOut({}, { ok: true })).toBe(false);
|
||||
expect(writeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns true and prints JSON to stdout when json is true", () => {
|
||||
expect(jsonOut({ json: true }, { ok: true, count: 3 })).toBe(true);
|
||||
expect(writeSpy).toHaveBeenCalledOnce();
|
||||
const parsed = JSON.parse(writeSpy.mock.calls[0][0] as string);
|
||||
expect(parsed).toEqual({ ok: true, count: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("jsonErr", () => {
|
||||
let writeSpy: ReturnType<typeof vi.spyOn>;
|
||||
beforeEach(() => { writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); });
|
||||
afterEach(() => { writeSpy.mockRestore(); });
|
||||
|
||||
it("returns false when json is falsy", () => {
|
||||
expect(jsonErr({}, "bad")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true and prints error JSON to stdout", () => {
|
||||
expect(jsonErr({ json: true }, "Something broke")).toBe(true);
|
||||
const parsed = JSON.parse(writeSpy.mock.calls[0][0] as string);
|
||||
expect(parsed).toEqual({ ok: false, error: "Something broke" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactSecrets", () => {
|
||||
it("redacts string values for known secret keys", () => {
|
||||
const input = { apiKey: "m0-abcdefghijklmnop", name: "test" };
|
||||
const result = redactSecrets(input, new Set(["apiKey"]));
|
||||
expect(result.apiKey).toBe("m0-a...mnop");
|
||||
expect(result.name).toBe("test");
|
||||
});
|
||||
|
||||
it("handles short keys", () => {
|
||||
const result = redactSecrets({ apiKey: "ab" }, new Set(["apiKey"]));
|
||||
expect(result.apiKey).toBe("ab***");
|
||||
});
|
||||
|
||||
it("skips non-string values", () => {
|
||||
const result = redactSecrets({ count: 5 }, new Set(["count"]));
|
||||
expect(result.count).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
LLM_PROVIDERS,
|
||||
EMBEDDER_PROVIDERS,
|
||||
VECTOR_PROVIDERS,
|
||||
KNOWN_EMBEDDER_DIMS,
|
||||
buildOssLlmConfig,
|
||||
buildOssEmbedderConfig,
|
||||
buildOssVectorConfig,
|
||||
collectionNameForDims,
|
||||
validateOssFlags,
|
||||
checkQdrantConnectivity,
|
||||
checkOllamaConnectivity,
|
||||
checkPgConnectivity,
|
||||
} from "../cli/oss-wizard.ts";
|
||||
|
||||
describe("LLM_PROVIDERS", () => {
|
||||
it("has 3 providers", () => {
|
||||
expect(LLM_PROVIDERS).toHaveLength(3);
|
||||
expect(LLM_PROVIDERS.map((p) => p.id)).toEqual(["openai", "ollama", "anthropic"]);
|
||||
});
|
||||
|
||||
it("openai requires API key", () => {
|
||||
const openai = LLM_PROVIDERS.find((p) => p.id === "openai")!;
|
||||
expect(openai.needsApiKey).toBe(true);
|
||||
expect(openai.defaultModel).toBe("gpt-5-mini");
|
||||
});
|
||||
|
||||
it("ollama needs no API key but needs URL", () => {
|
||||
const ollama = LLM_PROVIDERS.find((p) => p.id === "ollama")!;
|
||||
expect(ollama.needsApiKey).toBe(false);
|
||||
expect(ollama.needsUrl).toBe(true);
|
||||
expect(ollama.defaultUrl).toBe("http://localhost:11434");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EMBEDDER_PROVIDERS", () => {
|
||||
it("has 2 providers", () => {
|
||||
expect(EMBEDDER_PROVIDERS).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("KNOWN_EMBEDDER_DIMS", () => {
|
||||
it("maps default models to dims", () => {
|
||||
expect(KNOWN_EMBEDDER_DIMS["text-embedding-3-small"]).toBe(1536);
|
||||
expect(KNOWN_EMBEDDER_DIMS["nomic-embed-text"]).toBe(768);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildOssLlmConfig", () => {
|
||||
it("builds openai config with API key", () => {
|
||||
const result = buildOssLlmConfig("openai", { apiKey: "sk-test" });
|
||||
expect(result).toEqual({
|
||||
provider: "openai",
|
||||
config: { model: "gpt-5-mini", apiKey: "sk-test" },
|
||||
});
|
||||
});
|
||||
|
||||
it("builds ollama config with custom URL and model", () => {
|
||||
const result = buildOssLlmConfig("ollama", { url: "http://myhost:11434", model: "mistral" });
|
||||
expect(result).toEqual({
|
||||
provider: "ollama",
|
||||
config: { model: "mistral", url: "http://myhost:11434" },
|
||||
});
|
||||
});
|
||||
|
||||
it("builds ollama config with default URL", () => {
|
||||
const result = buildOssLlmConfig("ollama", {});
|
||||
expect(result.config.url).toBe("http://localhost:11434");
|
||||
});
|
||||
|
||||
it("ignores url for non-ollama providers", () => {
|
||||
const result = buildOssLlmConfig("anthropic", { apiKey: "sk-ant", url: "http://ignored" });
|
||||
expect(result.config).not.toHaveProperty("url");
|
||||
expect(result.config).toHaveProperty("apiKey", "sk-ant");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildOssEmbedderConfig", () => {
|
||||
it("builds openai embedder", () => {
|
||||
const result = buildOssEmbedderConfig("openai", { apiKey: "sk-test" });
|
||||
expect(result.config.model).toBe("text-embedding-3-small");
|
||||
expect(result.dims).toBe(1536);
|
||||
});
|
||||
|
||||
it("builds ollama embedder with url field", () => {
|
||||
const result = buildOssEmbedderConfig("ollama", { url: "http://myhost:11434" });
|
||||
expect(result.config.url).toBe("http://myhost:11434");
|
||||
expect(result.config).not.toHaveProperty("ollama_base_url");
|
||||
expect(result.config.model).toBe("nomic-embed-text");
|
||||
expect(result.dims).toBe(768);
|
||||
});
|
||||
|
||||
it("falls back to provider default dims for custom model", () => {
|
||||
const result = buildOssEmbedderConfig("ollama", { model: "custom-embed" });
|
||||
expect(result.dims).toBe(768);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VECTOR_PROVIDERS", () => {
|
||||
it("has 2 providers", () => {
|
||||
expect(VECTOR_PROVIDERS).toHaveLength(2);
|
||||
expect(VECTOR_PROVIDERS.map((p) => p.id)).toEqual(["qdrant", "pgvector"]);
|
||||
});
|
||||
|
||||
it("qdrant requires server connection", () => {
|
||||
const qdrant = VECTOR_PROVIDERS.find((p) => p.id === "qdrant")!;
|
||||
expect(qdrant.needsConnection).toBe(true);
|
||||
expect(qdrant.defaultUrl).toBe("http://localhost:6333");
|
||||
expect(qdrant.setupHint).toContain("docker");
|
||||
});
|
||||
|
||||
it("pgvector requires connection and has setup hint", () => {
|
||||
const pg = VECTOR_PROVIDERS.find((p) => p.id === "pgvector")!;
|
||||
expect(pg.needsConnection).toBe(true);
|
||||
expect(pg.defaultPort).toBe(5432);
|
||||
expect(pg.setupHint).toContain("pgvector");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildOssVectorConfig", () => {
|
||||
it("builds qdrant with default url and dims", () => {
|
||||
const result = buildOssVectorConfig("qdrant", { dims: 1536 });
|
||||
expect(result.config.url).toBe("http://localhost:6333");
|
||||
expect(result.config.onDisk).toBe(true);
|
||||
expect(result.config.dimension).toBe(1536);
|
||||
});
|
||||
|
||||
it("builds qdrant with custom url", () => {
|
||||
const result = buildOssVectorConfig("qdrant", { url: "http://qdrant.local:6333", dims: 768 });
|
||||
expect(result.config.url).toBe("http://qdrant.local:6333");
|
||||
expect(result.config.onDisk).toBe(true);
|
||||
expect(result.config.dimension).toBe(768);
|
||||
});
|
||||
|
||||
it("builds qdrant with api key for cloud", () => {
|
||||
const result = buildOssVectorConfig("qdrant", { url: "https://cloud.qdrant.io", apiKey: "qd-key", dims: 1536 });
|
||||
expect(result.config.apiKey).toBe("qd-key");
|
||||
expect(result.config.url).toBe("https://cloud.qdrant.io");
|
||||
});
|
||||
|
||||
it("builds pgvector with connection details", () => {
|
||||
const result = buildOssVectorConfig("pgvector", {
|
||||
host: "db.local", port: "5432", user: "me", password: "pw", dbname: "mydb", dims: 512,
|
||||
});
|
||||
expect(result.config.host).toBe("db.local");
|
||||
expect(result.config.dimension).toBe(512);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectionNameForDims", () => {
|
||||
it("generates dimension-based collection name", () => {
|
||||
expect(collectionNameForDims(1536)).toBe("mem0_1536d");
|
||||
expect(collectionNameForDims(768)).toBe("mem0_768d");
|
||||
expect(collectionNameForDims(384)).toBe("mem0_384d");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildOssVectorConfig dimension safety", () => {
|
||||
it("sets both dimension and embeddingModelDims when dims provided", () => {
|
||||
const result = buildOssVectorConfig("qdrant", { dims: 768 });
|
||||
expect(result.config.dimension).toBe(768);
|
||||
expect(result.config.embeddingModelDims).toBe(768);
|
||||
});
|
||||
|
||||
it("sets collectionName based on dims", () => {
|
||||
const result = buildOssVectorConfig("qdrant", { dims: 768 });
|
||||
expect(result.config.collectionName).toBe("mem0_768d");
|
||||
});
|
||||
|
||||
it("uses different collection names for different dims", () => {
|
||||
const r1 = buildOssVectorConfig("qdrant", { dims: 1536 });
|
||||
const r2 = buildOssVectorConfig("qdrant", { dims: 768 });
|
||||
expect(r1.config.collectionName).not.toBe(r2.config.collectionName);
|
||||
});
|
||||
|
||||
it("omits dimension fields when dims not provided", () => {
|
||||
const result = buildOssVectorConfig("qdrant", {});
|
||||
expect(result.config.dimension).toBeUndefined();
|
||||
expect(result.config.embeddingModelDims).toBeUndefined();
|
||||
expect(result.config.collectionName).toBeUndefined();
|
||||
});
|
||||
|
||||
it("works with pgvector too", () => {
|
||||
const result = buildOssVectorConfig("pgvector", {
|
||||
host: "localhost", port: "5432", user: "me", password: "pw", dbname: "test", dims: 768,
|
||||
});
|
||||
expect(result.config.dimension).toBe(768);
|
||||
expect(result.config.embeddingModelDims).toBe(768);
|
||||
expect(result.config.collectionName).toBe("mem0_768d");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkQdrantConnectivity", () => {
|
||||
it("returns error for unreachable host", async () => {
|
||||
const result = await checkQdrantConnectivity("http://localhost:19999");
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("Cannot reach Qdrant");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkOllamaConnectivity", () => {
|
||||
it("returns error for unreachable host", async () => {
|
||||
const result = await checkOllamaConnectivity("http://localhost:19998");
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("Cannot reach Ollama");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkPgConnectivity", () => {
|
||||
it("returns error for unreachable host", async () => {
|
||||
const result = await checkPgConnectivity("localhost", 19997);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("PostgreSQL not reachable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateOssFlags", () => {
|
||||
it("returns error when openai LLM has no key", () => {
|
||||
const result = validateOssFlags({ ossLlm: "openai" });
|
||||
expect(result.error).toContain("--oss-llm-key");
|
||||
});
|
||||
|
||||
it("passes for ollama with no key", () => {
|
||||
const result = validateOssFlags({ ossLlm: "ollama", ossEmbedder: "ollama", ossVector: "qdrant" });
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns error for unknown provider", () => {
|
||||
const result = validateOssFlags({ ossLlm: "bogus" });
|
||||
expect(result.error).toContain("Unknown LLM provider");
|
||||
});
|
||||
|
||||
it("returns error when pgvector missing user", () => {
|
||||
const result = validateOssFlags({
|
||||
ossLlm: "ollama", ossEmbedder: "ollama", ossVector: "pgvector",
|
||||
});
|
||||
expect(result.error).toContain("--oss-vector-user");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Tests for providers.ts — providerToBackend adapter layer.
|
||||
*
|
||||
* Verifies that the Backend wrapper correctly delegates to the
|
||||
* underlying Mem0Provider methods with proper argument mapping.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { providerToBackend } from "../providers.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock provider factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMockProvider() {
|
||||
return {
|
||||
search: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: "m1", memory: "found", score: 0.9 }]),
|
||||
add: vi.fn().mockResolvedValue({
|
||||
results: [{ id: "m1", event: "ADD", memory: "stored" }],
|
||||
}),
|
||||
get: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ id: "m1", memory: "test", created_at: "2026-01-01" }),
|
||||
getAll: vi.fn().mockResolvedValue([{ id: "m1", memory: "listed" }]),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAll: vi.fn().mockResolvedValue(undefined),
|
||||
history: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_USER = "test-user";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("providerToBackend — search", () => {
|
||||
// v3.0.0: keyword_search, reranking removed from SDK
|
||||
it("delegates to provider.search with correct options", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
const results = await backend.search("hello world", {
|
||||
topK: 10,
|
||||
threshold: 0.5,
|
||||
filters: { category: "preference" },
|
||||
});
|
||||
|
||||
expect(provider.search).toHaveBeenCalledWith("hello world", {
|
||||
user_id: DEFAULT_USER,
|
||||
top_k: 10,
|
||||
threshold: 0.5,
|
||||
filters: { category: "preference" },
|
||||
source: "OPENCLAW",
|
||||
});
|
||||
expect(results).toHaveLength(1);
|
||||
expect((results[0] as any).id).toBe("m1");
|
||||
});
|
||||
|
||||
it("uses default userId when opts.userId is not provided", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
await backend.search("query");
|
||||
|
||||
// v3.0.0: keyword_search, reranking removed
|
||||
expect(provider.search).toHaveBeenCalledWith("query", {
|
||||
user_id: DEFAULT_USER,
|
||||
top_k: undefined,
|
||||
threshold: undefined,
|
||||
filters: undefined,
|
||||
source: "OPENCLAW",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// add
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("providerToBackend — add", () => {
|
||||
it("delegates to provider.add with content as message", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
const result = await backend.add("Remember this fact");
|
||||
|
||||
expect(provider.add).toHaveBeenCalledWith(
|
||||
[{ role: "user", content: "Remember this fact" }],
|
||||
expect.objectContaining({ user_id: DEFAULT_USER }),
|
||||
);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it("passes messages array when provided", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
const messages = [
|
||||
{ role: "user", content: "Hi" },
|
||||
{ role: "assistant", content: "Hello" },
|
||||
];
|
||||
await backend.add(undefined, messages);
|
||||
|
||||
expect(provider.add).toHaveBeenCalledWith(
|
||||
messages,
|
||||
expect.objectContaining({ user_id: DEFAULT_USER }),
|
||||
);
|
||||
});
|
||||
|
||||
// v3.0.0: immutable, expiration_date removed from SDK
|
||||
it("forwards optional add options", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
await backend.add("fact", undefined, {
|
||||
runId: "run-1",
|
||||
metadata: { source: "test" },
|
||||
infer: false,
|
||||
});
|
||||
|
||||
expect(provider.add).toHaveBeenCalledWith(
|
||||
[{ role: "user", content: "fact" }],
|
||||
expect.objectContaining({
|
||||
user_id: DEFAULT_USER,
|
||||
run_id: "run-1",
|
||||
metadata: { source: "test" },
|
||||
infer: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("providerToBackend — get", () => {
|
||||
it("delegates to provider.get", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
const result = await backend.get("mem-123");
|
||||
|
||||
expect(provider.get).toHaveBeenCalledWith("mem-123");
|
||||
expect((result as any).id).toBe("m1");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listMemories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("providerToBackend — listMemories", () => {
|
||||
it("delegates to provider.getAll", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
const results = await backend.listMemories({ pageSize: 50 });
|
||||
|
||||
expect(provider.getAll).toHaveBeenCalledWith({
|
||||
user_id: DEFAULT_USER,
|
||||
page_size: 50,
|
||||
source: "OPENCLAW",
|
||||
});
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses default userId when opts.userId is not provided", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
await backend.listMemories();
|
||||
|
||||
expect(provider.getAll).toHaveBeenCalledWith({
|
||||
user_id: DEFAULT_USER,
|
||||
page_size: undefined,
|
||||
source: "OPENCLAW",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("providerToBackend — update", () => {
|
||||
it("calls provider.update with content", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
const result = await backend.update("mem-123", "updated text");
|
||||
|
||||
expect(provider.update).toHaveBeenCalledWith("mem-123", "updated text");
|
||||
expect((result as any).id).toBe("mem-123");
|
||||
expect((result as any).updated).toBe(true);
|
||||
});
|
||||
|
||||
it("warns and skips when only metadata is provided (no content)", async () => {
|
||||
const provider = createMockProvider();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
const result = await backend.update("mem-123", undefined, {
|
||||
tag: "important",
|
||||
});
|
||||
|
||||
expect(provider.update).not.toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("metadata updates are not supported"),
|
||||
);
|
||||
expect((result as any).id).toBe("mem-123");
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("providerToBackend — delete", () => {
|
||||
it("calls provider.delete with memoryId", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
const result = await backend.delete("mem-456");
|
||||
|
||||
expect(provider.delete).toHaveBeenCalledWith("mem-456");
|
||||
expect((result as any).deleted).toBe("mem-456");
|
||||
});
|
||||
|
||||
it("calls provider.deleteAll when opts.all is true", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
const result = await backend.delete(undefined, {
|
||||
all: true,
|
||||
userId: "custom-user",
|
||||
});
|
||||
|
||||
expect(provider.deleteAll).toHaveBeenCalledWith("custom-user");
|
||||
expect((result as any).deleted).toBe("all");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deleteEntities (platform-only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("providerToBackend — deleteEntities", () => {
|
||||
it("throws platform-only error", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
await expect(
|
||||
backend.deleteEntities({ userId: DEFAULT_USER }),
|
||||
).rejects.toThrow("platform mode");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("providerToBackend — status", () => {
|
||||
it("returns connected: true", async () => {
|
||||
const provider = createMockProvider();
|
||||
const backend = providerToBackend(provider as any, DEFAULT_USER);
|
||||
|
||||
const result = await backend.status();
|
||||
|
||||
expect((result as any).connected).toBe(true);
|
||||
expect((result as any).backend).toBe("oss");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock config-file before importing telemetry
|
||||
vi.mock("../cli/config-file.ts", () => ({
|
||||
readPluginAuth: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
import { captureEvent } from "../telemetry.ts";
|
||||
import { readPluginAuth } from "../cli/config-file.ts";
|
||||
|
||||
describe("telemetry", () => {
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset telemetry enabled state
|
||||
(globalThis as any).__mem0_telemetry_override = undefined;
|
||||
fetchSpy = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete (globalThis as any).__mem0_telemetry_override;
|
||||
});
|
||||
|
||||
it("captureEvent does not throw", () => {
|
||||
expect(() => captureEvent("test_event")).not.toThrow();
|
||||
});
|
||||
|
||||
it("captureEvent accepts properties and context", () => {
|
||||
expect(() =>
|
||||
captureEvent("test_event", { key: "val" }, { apiKey: "m0-key", mode: "platform" }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("captureEvent is silent when telemetry disabled", () => {
|
||||
(globalThis as any).__mem0_telemetry_override = "false";
|
||||
// Force re-evaluation by resetting cached value
|
||||
// Since _telemetryEnabled is module-level, we test indirectly
|
||||
expect(() => captureEvent("test_event")).not.toThrow();
|
||||
});
|
||||
|
||||
it("uses userEmail as distinct ID when available", () => {
|
||||
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValueOnce({
|
||||
userEmail: "test@example.com",
|
||||
});
|
||||
expect(() => captureEvent("test_event")).not.toThrow();
|
||||
});
|
||||
|
||||
it("falls back to a generated anonymous id when no apiKey", () => {
|
||||
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValueOnce({});
|
||||
expect(() => captureEvent("test_event", {}, {})).not.toThrow();
|
||||
});
|
||||
|
||||
it("handles readPluginAuth errors gracefully", () => {
|
||||
(readPluginAuth as ReturnType<typeof vi.fn>).mockImplementationOnce(() => {
|
||||
throw new Error("config read failed");
|
||||
});
|
||||
expect(() => captureEvent("test_event")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,889 @@
|
||||
/**
|
||||
* Tests for the tool factory functions in tools/.
|
||||
*
|
||||
* Verifies each factory returns the expected shape (name, label,
|
||||
* description, parameters, execute) and that execute() delegates
|
||||
* to the correct provider/backend methods.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import type { ToolDeps } from "../tools/index.ts";
|
||||
import { registerAllTools } from "../tools/index.ts";
|
||||
import { createMemorySearchTool } from "../tools/memory-search.ts";
|
||||
import { createMemoryAddTool } from "../tools/memory-add.ts";
|
||||
import { createMemoryGetTool } from "../tools/memory-get.ts";
|
||||
import { createMemoryDeleteTool } from "../tools/memory-delete.ts";
|
||||
import { createMemoryListTool } from "../tools/memory-list.ts";
|
||||
import { createMemoryUpdateTool } from "../tools/memory-update.ts";
|
||||
import { createMemoryEventListTool } from "../tools/memory-event-list.ts";
|
||||
import { createMemoryEventStatusTool } from "../tools/memory-event-status.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMockToolDeps(overrides = {}): ToolDeps {
|
||||
return {
|
||||
api: {
|
||||
registerTool: vi.fn(),
|
||||
logger: { info: vi.fn(), warn: vi.fn() },
|
||||
} as any,
|
||||
cfg: {
|
||||
mode: "platform",
|
||||
userId: "testuser",
|
||||
topK: 5,
|
||||
autoCapture: true,
|
||||
autoRecall: true,
|
||||
searchThreshold: 0.1,
|
||||
customInstructions: "test",
|
||||
customCategories: {},
|
||||
} as any,
|
||||
provider: {
|
||||
search: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: "m1", memory: "test memory", score: 0.9 }]),
|
||||
add: vi.fn().mockResolvedValue({
|
||||
results: [{ event: "ADD", memory: "stored" }],
|
||||
}),
|
||||
getAll: vi.fn().mockResolvedValue([{ id: "m1", memory: "test memory" }]),
|
||||
update: vi.fn().mockResolvedValue({ memory: "updated" }),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAll: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn().mockResolvedValue({
|
||||
id: "test-id",
|
||||
memory: "test memory",
|
||||
created_at: "2026-01-01",
|
||||
updated_at: "2026-01-02",
|
||||
}),
|
||||
history: vi.fn().mockResolvedValue([]),
|
||||
getHistory: vi.fn().mockResolvedValue([]),
|
||||
} as any,
|
||||
resolveUserId: vi.fn().mockReturnValue("testuser"),
|
||||
effectiveUserId: vi.fn().mockReturnValue("testuser"),
|
||||
agentUserId: vi.fn().mockReturnValue("testuser:agent:test"),
|
||||
getCurrentSessionId: vi.fn().mockReturnValue(undefined),
|
||||
skillsActive: false,
|
||||
captureToolEvent: vi.fn(),
|
||||
buildAddOptions: vi
|
||||
.fn()
|
||||
.mockReturnValue({ user_id: "testuser", source: "OPENCLAW" }),
|
||||
buildSearchOptions: vi
|
||||
.fn()
|
||||
.mockReturnValue({ user_id: "testuser", top_k: 5, source: "OPENCLAW" }),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// registerAllTools
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("registerAllTools", () => {
|
||||
it("calls api.registerTool exactly 8 times", () => {
|
||||
const ctx = createMockToolDeps();
|
||||
registerAllTools(ctx);
|
||||
expect(ctx.api.registerTool).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
|
||||
it("registers tools with the correct names", () => {
|
||||
const ctx = createMockToolDeps();
|
||||
registerAllTools(ctx);
|
||||
|
||||
// Tools are registered as required (single argument — no metadata object).
|
||||
// The name comes from the tool definition itself (call[0]).
|
||||
const names = (
|
||||
ctx.api.registerTool as ReturnType<typeof vi.fn>
|
||||
).mock.calls.map((call: unknown[]) => (call[0] as { name: string }).name);
|
||||
|
||||
expect(names).toEqual([
|
||||
"memory_search",
|
||||
"memory_add",
|
||||
"memory_get",
|
||||
"memory_list",
|
||||
"memory_update",
|
||||
"memory_delete",
|
||||
"memory_event_list",
|
||||
"memory_event_status",
|
||||
]);
|
||||
});
|
||||
|
||||
it("registers tools with optional: false metadata", () => {
|
||||
const ctx = createMockToolDeps();
|
||||
registerAllTools(ctx);
|
||||
|
||||
const calls = (ctx.api.registerTool as ReturnType<typeof vi.fn>).mock.calls;
|
||||
for (const call of calls) {
|
||||
expect(call).toHaveLength(2);
|
||||
expect(call[1]).toEqual({ optional: false });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool factory shape checks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("tool factory shape", () => {
|
||||
const factories = [
|
||||
{ fn: createMemorySearchTool, expectedName: "memory_search" },
|
||||
{ fn: createMemoryAddTool, expectedName: "memory_add" },
|
||||
{ fn: createMemoryGetTool, expectedName: "memory_get" },
|
||||
{ fn: createMemoryDeleteTool, expectedName: "memory_delete" },
|
||||
{ fn: createMemoryListTool, expectedName: "memory_list" },
|
||||
{ fn: createMemoryEventListTool, expectedName: "memory_event_list" },
|
||||
{ fn: createMemoryEventStatusTool, expectedName: "memory_event_status" },
|
||||
];
|
||||
|
||||
for (const { fn, expectedName } of factories) {
|
||||
describe(expectedName, () => {
|
||||
it("returns an object with name, label, description, parameters, and execute", () => {
|
||||
const ctx = createMockToolDeps();
|
||||
const tool = fn(ctx);
|
||||
|
||||
expect(tool.name).toBe(expectedName);
|
||||
expect(typeof tool.label).toBe("string");
|
||||
expect(tool.label.length).toBeGreaterThan(0);
|
||||
expect(typeof tool.description).toBe("string");
|
||||
expect(tool.description.length).toBeGreaterThan(0);
|
||||
expect(tool.parameters).toBeDefined();
|
||||
expect(typeof tool.execute).toBe("function");
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// memory_search execute
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("memory_search execute", () => {
|
||||
it("returns formatted results when provider returns matches", async () => {
|
||||
const ctx = createMockToolDeps();
|
||||
const tool = createMemorySearchTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-1", {
|
||||
query: "user preferences",
|
||||
});
|
||||
|
||||
expect(ctx.provider!.search).toHaveBeenCalled();
|
||||
expect(result.content[0].text).toContain("Found 1 memories");
|
||||
expect(result.content[0].text).toContain("test memory");
|
||||
expect(result.content[0].text).toContain("90%");
|
||||
expect(result.details.count).toBe(1);
|
||||
expect(result.details.memories).toHaveLength(1);
|
||||
expect(result.details.memories[0].id).toBe("m1");
|
||||
});
|
||||
|
||||
it("returns 'no relevant memories' when provider returns empty", async () => {
|
||||
const ctx = createMockToolDeps({
|
||||
provider: {
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
add: vi.fn(),
|
||||
getAll: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
get: vi.fn(),
|
||||
history: vi.fn(),
|
||||
},
|
||||
});
|
||||
const tool = createMemorySearchTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-2", { query: "nothing" });
|
||||
|
||||
expect(result.content[0].text).toBe("No relevant memories found.");
|
||||
expect(result.details.count).toBe(0);
|
||||
});
|
||||
|
||||
it("handles errors gracefully", async () => {
|
||||
const ctx = createMockToolDeps({
|
||||
provider: {
|
||||
search: vi.fn().mockRejectedValue(new Error("network failure")),
|
||||
add: vi.fn(),
|
||||
getAll: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
get: vi.fn(),
|
||||
history: vi.fn(),
|
||||
},
|
||||
});
|
||||
const tool = createMemorySearchTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-3", { query: "test" });
|
||||
|
||||
expect(result.content[0].text).toContain("Memory search failed");
|
||||
expect(result.content[0].text).toContain("network failure");
|
||||
expect(result.details.error).toContain("network failure");
|
||||
});
|
||||
|
||||
it("calls resolveUserId with provided agentId and userId", async () => {
|
||||
const ctx = createMockToolDeps();
|
||||
const tool = createMemorySearchTool(ctx);
|
||||
|
||||
await tool.execute("call-4", {
|
||||
query: "test",
|
||||
agentId: "researcher",
|
||||
userId: "alice",
|
||||
});
|
||||
|
||||
expect(ctx.resolveUserId).toHaveBeenCalledWith({
|
||||
agentId: "researcher",
|
||||
userId: "alice",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes limit to buildSearchOptions", async () => {
|
||||
const ctx = createMockToolDeps();
|
||||
const tool = createMemorySearchTool(ctx);
|
||||
|
||||
await tool.execute("call-5", { query: "test", limit: 10 });
|
||||
|
||||
expect(ctx.buildSearchOptions).toHaveBeenCalledWith("testuser", 10);
|
||||
});
|
||||
|
||||
it("searches only session scope when scope='session' and session exists", async () => {
|
||||
const searchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: "s1", memory: "session mem", score: 0.8 }]);
|
||||
const ctx = createMockToolDeps({
|
||||
getCurrentSessionId: vi.fn().mockReturnValue("session-abc"),
|
||||
provider: {
|
||||
search: searchMock,
|
||||
add: vi.fn(),
|
||||
getAll: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
get: vi.fn(),
|
||||
history: vi.fn(),
|
||||
},
|
||||
});
|
||||
const tool = createMemorySearchTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-6", {
|
||||
query: "test",
|
||||
scope: "session",
|
||||
});
|
||||
|
||||
// Should call buildSearchOptions with session ID as 4th arg (sessionKey)
|
||||
expect(ctx.buildSearchOptions).toHaveBeenCalledWith(
|
||||
"testuser",
|
||||
undefined,
|
||||
undefined,
|
||||
"session-abc",
|
||||
);
|
||||
expect(result.details.count).toBe(1);
|
||||
});
|
||||
|
||||
it("deduplicates results in 'all' scope", async () => {
|
||||
const searchMock = vi
|
||||
.fn()
|
||||
// First call: long-term
|
||||
.mockResolvedValueOnce([
|
||||
{ id: "m1", memory: "shared memory", score: 0.95 },
|
||||
])
|
||||
// Second call: session
|
||||
.mockResolvedValueOnce([
|
||||
{ id: "m1", memory: "shared memory", score: 0.85 },
|
||||
{ id: "m2", memory: "session only", score: 0.7 },
|
||||
]);
|
||||
|
||||
const ctx = createMockToolDeps({
|
||||
getCurrentSessionId: vi.fn().mockReturnValue("session-xyz"),
|
||||
provider: {
|
||||
search: searchMock,
|
||||
add: vi.fn(),
|
||||
getAll: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
get: vi.fn(),
|
||||
history: vi.fn(),
|
||||
},
|
||||
});
|
||||
const tool = createMemorySearchTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-7", {
|
||||
query: "test",
|
||||
scope: "all",
|
||||
});
|
||||
|
||||
// m1 appears only once (from long-term), m2 is session-only
|
||||
expect(result.details.count).toBe(2);
|
||||
const ids = result.details.memories.map((m: any) => m.id);
|
||||
expect(ids).toEqual(["m1", "m2"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// memory_add execute
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("memory_add execute", () => {
|
||||
it("calls provider.add with the text and returns stored result", async () => {
|
||||
const ctx = createMockToolDeps();
|
||||
const tool = createMemoryAddTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-1", {
|
||||
text: "User prefers dark mode",
|
||||
});
|
||||
|
||||
expect(ctx.provider!.add).toHaveBeenCalled();
|
||||
const addCall = (ctx.provider!.add as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0];
|
||||
expect(addCall[0]).toEqual([
|
||||
{ role: "user", content: "User prefers dark mode" },
|
||||
]);
|
||||
expect(result.content[0].text).toContain("Stored");
|
||||
expect(result.details.action).toBe("stored");
|
||||
});
|
||||
|
||||
it("returns error when no text or facts are provided", async () => {
|
||||
const ctx = createMockToolDeps();
|
||||
const tool = createMemoryAddTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-2", {});
|
||||
|
||||
expect(result.content[0].text).toContain("No facts provided");
|
||||
expect(result.details.error).toBe("missing_facts");
|
||||
});
|
||||
|
||||
it("supports facts array", async () => {
|
||||
const ctx = createMockToolDeps();
|
||||
const tool = createMemoryAddTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-3", {
|
||||
facts: ["fact one", "fact two"],
|
||||
});
|
||||
|
||||
expect(ctx.provider!.add).toHaveBeenCalled();
|
||||
const addCall = (ctx.provider!.add as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0];
|
||||
expect(addCall[0]).toEqual([
|
||||
{ role: "user", content: "fact one\nfact two" },
|
||||
]);
|
||||
expect(result.details.action).toBe("stored");
|
||||
});
|
||||
|
||||
it("handles errors gracefully", async () => {
|
||||
const ctx = createMockToolDeps({
|
||||
provider: {
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
add: vi.fn().mockRejectedValue(new Error("API error")),
|
||||
getAll: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
get: vi.fn(),
|
||||
history: vi.fn(),
|
||||
},
|
||||
});
|
||||
const tool = createMemoryAddTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-4", { text: "test" });
|
||||
|
||||
expect(result.content[0].text).toContain("Memory add failed");
|
||||
expect(result.details.error).toContain("API error");
|
||||
});
|
||||
|
||||
it("uses skills mode with infer=false when skillsActive is true", async () => {
|
||||
const addMock = vi.fn().mockResolvedValue({
|
||||
results: [{ event: "ADD", memory: "stored in skills mode" }],
|
||||
});
|
||||
const ctx = createMockToolDeps({
|
||||
skillsActive: true,
|
||||
provider: {
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
add: addMock,
|
||||
getAll: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
get: vi.fn(),
|
||||
history: vi.fn(),
|
||||
},
|
||||
});
|
||||
const tool = createMemoryAddTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-5", {
|
||||
text: "skills fact",
|
||||
category: "preference",
|
||||
});
|
||||
|
||||
expect(addMock).toHaveBeenCalledOnce();
|
||||
const addOpts = addMock.mock.calls[0][1];
|
||||
expect(addOpts.infer).toBe(false);
|
||||
expect(result.details.mode).toBe("skills");
|
||||
expect(result.details.category).toBe("preference");
|
||||
});
|
||||
|
||||
it("blocks subagent sessions from storing", async () => {
|
||||
const ctx = createMockToolDeps({
|
||||
getCurrentSessionId: vi
|
||||
.fn()
|
||||
.mockReturnValue("agent:main:subagent:uuid-123"),
|
||||
});
|
||||
const tool = createMemoryAddTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-6", { text: "subagent fact" });
|
||||
|
||||
expect(ctx.provider!.add).not.toHaveBeenCalled();
|
||||
expect(result.details.error).toBe("subagent_blocked");
|
||||
});
|
||||
|
||||
it("performs dedup search before adding in legacy mode", async () => {
|
||||
const searchMock = vi.fn().mockResolvedValue([]);
|
||||
const addMock = vi.fn().mockResolvedValue({
|
||||
results: [{ event: "ADD", memory: "stored" }],
|
||||
});
|
||||
const ctx = createMockToolDeps({
|
||||
skillsActive: false,
|
||||
provider: {
|
||||
search: searchMock,
|
||||
add: addMock,
|
||||
getAll: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
get: vi.fn(),
|
||||
history: vi.fn(),
|
||||
},
|
||||
});
|
||||
const tool = createMemoryAddTool(ctx);
|
||||
|
||||
await tool.execute("call-7", { text: "new fact" });
|
||||
|
||||
// Mem0 backend handles dedup internally — no separate search call
|
||||
expect(searchMock).not.toHaveBeenCalled();
|
||||
expect(addMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// memory_get execute
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("memory_get execute", () => {
|
||||
it("calls provider.get with the memoryId and returns formatted result", async () => {
|
||||
const ctx = createMockToolDeps();
|
||||
const tool = createMemoryGetTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-1", { memoryId: "test-id" });
|
||||
|
||||
expect(ctx.provider!.get).toHaveBeenCalledWith("test-id");
|
||||
expect(result.content[0].text).toContain("Memory test-id");
|
||||
expect(result.content[0].text).toContain("test memory");
|
||||
expect(result.content[0].text).toContain("Created:");
|
||||
expect(result.details.memory).toBeDefined();
|
||||
expect(result.details.memory.id).toBe("test-id");
|
||||
});
|
||||
|
||||
it("handles errors gracefully", async () => {
|
||||
const ctx = createMockToolDeps({
|
||||
provider: {
|
||||
search: vi.fn(),
|
||||
add: vi.fn(),
|
||||
getAll: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
get: vi.fn().mockRejectedValue(new Error("not found")),
|
||||
history: vi.fn(),
|
||||
},
|
||||
});
|
||||
const tool = createMemoryGetTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-2", { memoryId: "bad-id" });
|
||||
|
||||
expect(result.content[0].text).toContain("Memory get failed");
|
||||
expect(result.details.error).toContain("not found");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// memory_delete execute
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("memory_delete execute", () => {
|
||||
it("deletes by memoryId via provider.delete", async () => {
|
||||
const ctx = createMockToolDeps();
|
||||
const tool = createMemoryDeleteTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-1", { memoryId: "mem-abc" });
|
||||
|
||||
expect(ctx.provider!.delete).toHaveBeenCalledWith("mem-abc");
|
||||
expect(result.content[0].text).toBe("Memory mem-abc deleted.");
|
||||
expect(result.details.action).toBe("deleted");
|
||||
expect(result.details.id).toBe("mem-abc");
|
||||
});
|
||||
|
||||
it("searches and auto-deletes single high-confidence match by query", async () => {
|
||||
const searchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: "m1", memory: "match", score: 0.95 }]);
|
||||
const deleteMock = vi.fn().mockResolvedValue(undefined);
|
||||
const ctx = createMockToolDeps({
|
||||
provider: {
|
||||
search: searchMock,
|
||||
add: vi.fn(),
|
||||
getAll: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: deleteMock,
|
||||
get: vi.fn(),
|
||||
history: vi.fn(),
|
||||
},
|
||||
});
|
||||
const tool = createMemoryDeleteTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-2", {
|
||||
query: "find and delete",
|
||||
});
|
||||
|
||||
expect(searchMock).toHaveBeenCalled();
|
||||
expect(deleteMock).toHaveBeenCalledWith("m1");
|
||||
expect(result.content[0].text).toContain("Deleted:");
|
||||
expect(result.details.action).toBe("deleted");
|
||||
});
|
||||
|
||||
it("returns candidates when query matches multiple ambiguous results", async () => {
|
||||
const searchMock = vi.fn().mockResolvedValue([
|
||||
{ id: "m1", memory: "candidate one", score: 0.7 },
|
||||
{ id: "m2", memory: "candidate two", score: 0.6 },
|
||||
]);
|
||||
const deleteMock = vi.fn();
|
||||
const ctx = createMockToolDeps({
|
||||
provider: {
|
||||
search: searchMock,
|
||||
add: vi.fn(),
|
||||
getAll: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: deleteMock,
|
||||
get: vi.fn(),
|
||||
history: vi.fn(),
|
||||
},
|
||||
});
|
||||
const tool = createMemoryDeleteTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-3", {
|
||||
query: "ambiguous",
|
||||
});
|
||||
|
||||
// Should NOT have called delete
|
||||
expect(deleteMock).not.toHaveBeenCalled();
|
||||
expect(result.content[0].text).toContain("Found 2 candidates");
|
||||
expect(result.details.action).toBe("candidates");
|
||||
expect(result.details.candidates).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns no matching memories when query yields empty results", async () => {
|
||||
const ctx = createMockToolDeps({
|
||||
provider: {
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
add: vi.fn(),
|
||||
getAll: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
get: vi.fn(),
|
||||
history: vi.fn(),
|
||||
},
|
||||
});
|
||||
const tool = createMemoryDeleteTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-4", { query: "nothing" });
|
||||
|
||||
expect(result.content[0].text).toBe("No matching memories found.");
|
||||
expect(result.details.found).toBe(0);
|
||||
});
|
||||
|
||||
it("requires confirm:true for bulk delete (all)", async () => {
|
||||
const ctx = createMockToolDeps();
|
||||
const tool = createMemoryDeleteTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-5", { all: true });
|
||||
|
||||
expect(result.content[0].text).toContain("confirm: true");
|
||||
expect(result.details.error).toBe("confirmation_required");
|
||||
});
|
||||
|
||||
it("performs bulk delete when all:true and confirm:true", async () => {
|
||||
const deleteAllMock = vi.fn().mockResolvedValue(undefined);
|
||||
const ctx = createMockToolDeps({
|
||||
provider: {
|
||||
search: vi.fn(),
|
||||
add: vi.fn(),
|
||||
getAll: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
deleteAll: deleteAllMock,
|
||||
get: vi.fn(),
|
||||
history: vi.fn(),
|
||||
},
|
||||
});
|
||||
const tool = createMemoryDeleteTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-6", {
|
||||
all: true,
|
||||
confirm: true,
|
||||
});
|
||||
|
||||
expect(deleteAllMock).toHaveBeenCalledWith("testuser");
|
||||
expect(result.content[0].text).toContain("All memories deleted");
|
||||
expect(result.details.action).toBe("deleted_all");
|
||||
});
|
||||
|
||||
it("returns error when no mode param is specified", async () => {
|
||||
const ctx = createMockToolDeps();
|
||||
const tool = createMemoryDeleteTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-9", {});
|
||||
|
||||
expect(result.content[0].text).toContain(
|
||||
"Provide memoryId, query, or all:true",
|
||||
);
|
||||
expect(result.details.error).toBe("missing_param");
|
||||
});
|
||||
|
||||
it("blocks subagent sessions from deleting", async () => {
|
||||
const ctx = createMockToolDeps({
|
||||
getCurrentSessionId: vi
|
||||
.fn()
|
||||
.mockReturnValue("agent:main:subagent:uuid-456"),
|
||||
});
|
||||
const tool = createMemoryDeleteTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-10", { memoryId: "m1" });
|
||||
|
||||
expect(ctx.provider!.delete).not.toHaveBeenCalled();
|
||||
expect(result.details.error).toBe("subagent_blocked");
|
||||
});
|
||||
|
||||
it("handles errors gracefully", async () => {
|
||||
const ctx = createMockToolDeps({
|
||||
provider: {
|
||||
search: vi.fn(),
|
||||
add: vi.fn(),
|
||||
getAll: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn().mockRejectedValue(new Error("delete failed")),
|
||||
get: vi.fn(),
|
||||
history: vi.fn(),
|
||||
},
|
||||
});
|
||||
const tool = createMemoryDeleteTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-11", { memoryId: "m1" });
|
||||
|
||||
expect(result.content[0].text).toContain("Memory delete failed");
|
||||
expect(result.details.error).toContain("delete failed");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// memory_list execute
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("memory_list execute", () => {
|
||||
it("calls provider.getAll and returns formatted list", async () => {
|
||||
const ctx = createMockToolDeps();
|
||||
const tool = createMemoryListTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-1", {});
|
||||
|
||||
expect(ctx.provider!.getAll).toHaveBeenCalled();
|
||||
expect(result.content[0].text).toContain("1 memories");
|
||||
expect(result.content[0].text).toContain("test memory");
|
||||
expect(result.details.count).toBe(1);
|
||||
expect(result.details.memories).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns 'no memories stored' when provider returns empty", async () => {
|
||||
const ctx = createMockToolDeps({
|
||||
provider: {
|
||||
search: vi.fn(),
|
||||
add: vi.fn(),
|
||||
getAll: vi.fn().mockResolvedValue([]),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
get: vi.fn(),
|
||||
history: vi.fn(),
|
||||
},
|
||||
});
|
||||
const tool = createMemoryListTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-2", {});
|
||||
|
||||
expect(result.content[0].text).toBe("No memories stored yet.");
|
||||
expect(result.details.count).toBe(0);
|
||||
});
|
||||
|
||||
it("handles errors gracefully", async () => {
|
||||
const ctx = createMockToolDeps({
|
||||
provider: {
|
||||
search: vi.fn(),
|
||||
add: vi.fn(),
|
||||
getAll: vi.fn().mockRejectedValue(new Error("list failed")),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
get: vi.fn(),
|
||||
history: vi.fn(),
|
||||
},
|
||||
});
|
||||
const tool = createMemoryListTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-3", {});
|
||||
|
||||
expect(result.content[0].text).toContain("Memory list failed");
|
||||
expect(result.details.error).toContain("list failed");
|
||||
});
|
||||
|
||||
it("resolves userId from agentId", async () => {
|
||||
const ctx = createMockToolDeps();
|
||||
const tool = createMemoryListTool(ctx);
|
||||
|
||||
await tool.execute("call-4", { agentId: "researcher" });
|
||||
|
||||
expect(ctx.resolveUserId).toHaveBeenCalledWith({
|
||||
agentId: "researcher",
|
||||
userId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("deduplicates results in 'all' scope", async () => {
|
||||
const getAllMock = vi
|
||||
.fn()
|
||||
// First call: long-term
|
||||
.mockResolvedValueOnce([{ id: "m1", memory: "shared" }])
|
||||
// Second call: session
|
||||
.mockResolvedValueOnce([
|
||||
{ id: "m1", memory: "shared" },
|
||||
{ id: "m2", memory: "session only" },
|
||||
]);
|
||||
|
||||
const ctx = createMockToolDeps({
|
||||
getCurrentSessionId: vi.fn().mockReturnValue("session-123"),
|
||||
provider: {
|
||||
search: vi.fn(),
|
||||
add: vi.fn(),
|
||||
getAll: getAllMock,
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
get: vi.fn(),
|
||||
history: vi.fn(),
|
||||
},
|
||||
});
|
||||
const tool = createMemoryListTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-5", { scope: "all" });
|
||||
|
||||
expect(result.details.count).toBe(2);
|
||||
const ids = result.details.memories.map((m: any) => m.id);
|
||||
expect(ids).toEqual(["m1", "m2"]);
|
||||
});
|
||||
|
||||
it("only fetches session memories when scope='session'", async () => {
|
||||
const getAllMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: "s1", memory: "session mem" }]);
|
||||
const ctx = createMockToolDeps({
|
||||
getCurrentSessionId: vi.fn().mockReturnValue("sess-abc"),
|
||||
provider: {
|
||||
search: vi.fn(),
|
||||
add: vi.fn(),
|
||||
getAll: getAllMock,
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
get: vi.fn(),
|
||||
history: vi.fn(),
|
||||
},
|
||||
});
|
||||
const tool = createMemoryListTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-6", { scope: "session" });
|
||||
|
||||
// Should call getAll once with run_id
|
||||
expect(getAllMock).toHaveBeenCalledOnce();
|
||||
const opts = getAllMock.mock.calls[0][0];
|
||||
expect(opts.run_id).toBe("sess-abc");
|
||||
expect(result.details.count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// memory_update execute
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("memory_update execute", () => {
|
||||
it("calls provider.update and returns success", async () => {
|
||||
const ctx = createMockToolDeps();
|
||||
const tool = createMemoryUpdateTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-1", {
|
||||
memoryId: "mem-123",
|
||||
text: "Updated preference",
|
||||
});
|
||||
|
||||
expect(ctx.provider!.update).toHaveBeenCalledWith(
|
||||
"mem-123",
|
||||
"Updated preference",
|
||||
);
|
||||
expect(result.content[0].text).toContain("Updated memory mem-123");
|
||||
expect(result.content[0].text).toContain("Updated preference");
|
||||
expect(result.details.action).toBe("updated");
|
||||
expect(result.details.id).toBe("mem-123");
|
||||
});
|
||||
|
||||
it("truncates long text in response", async () => {
|
||||
const ctx = createMockToolDeps();
|
||||
const tool = createMemoryUpdateTool(ctx);
|
||||
|
||||
const longText = "A".repeat(120);
|
||||
const result = await tool.execute("call-2", {
|
||||
memoryId: "mem-456",
|
||||
text: longText,
|
||||
});
|
||||
|
||||
expect(ctx.provider!.update).toHaveBeenCalledWith("mem-456", longText);
|
||||
// The response text should contain the first 80 chars followed by "..."
|
||||
expect(result.content[0].text).toContain("A".repeat(80) + "...");
|
||||
expect(result.content[0].text).not.toContain("A".repeat(81));
|
||||
expect(result.details.action).toBe("updated");
|
||||
});
|
||||
|
||||
it("blocks subagent sessions", async () => {
|
||||
const ctx = createMockToolDeps({
|
||||
getCurrentSessionId: vi
|
||||
.fn()
|
||||
.mockReturnValue("agent:main:subagent:uuid-789"),
|
||||
});
|
||||
const tool = createMemoryUpdateTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-3", {
|
||||
memoryId: "mem-123",
|
||||
text: "should not update",
|
||||
});
|
||||
|
||||
expect(ctx.provider!.update).not.toHaveBeenCalled();
|
||||
expect(result.content[0].text).toContain(
|
||||
"not available in subagent sessions",
|
||||
);
|
||||
expect(result.details.error).toBe("subagent_blocked");
|
||||
});
|
||||
|
||||
it("handles errors gracefully", async () => {
|
||||
const ctx = createMockToolDeps({
|
||||
provider: {
|
||||
search: vi.fn(),
|
||||
add: vi.fn(),
|
||||
getAll: vi.fn(),
|
||||
update: vi.fn().mockRejectedValue(new Error("update conflict")),
|
||||
delete: vi.fn(),
|
||||
get: vi.fn(),
|
||||
history: vi.fn(),
|
||||
},
|
||||
});
|
||||
const tool = createMemoryUpdateTool(ctx);
|
||||
|
||||
const result = await tool.execute("call-4", {
|
||||
memoryId: "mem-123",
|
||||
text: "new text",
|
||||
});
|
||||
|
||||
expect(result.content[0].text).toContain("Memory update failed");
|
||||
expect(result.content[0].text).toContain("update conflict");
|
||||
expect(result.details.error).toContain("update conflict");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import type { Mem0Config, Mem0Provider, AddOptions, SearchOptions } from "../types.ts";
|
||||
import type { Backend } from "../backend/base.ts";
|
||||
|
||||
import { createMemorySearchTool } from "./memory-search.ts";
|
||||
import { createMemoryAddTool } from "./memory-add.ts";
|
||||
import { createMemoryGetTool } from "./memory-get.ts";
|
||||
import { createMemoryListTool } from "./memory-list.ts";
|
||||
import { createMemoryUpdateTool } from "./memory-update.ts";
|
||||
import { createMemoryDeleteTool } from "./memory-delete.ts";
|
||||
import { createMemoryEventListTool } from "./memory-event-list.ts";
|
||||
import { createMemoryEventStatusTool } from "./memory-event-status.ts";
|
||||
|
||||
export interface ToolDeps {
|
||||
api: OpenClawPluginApi;
|
||||
provider: Mem0Provider;
|
||||
cfg: Mem0Config;
|
||||
backend?: Backend;
|
||||
resolveUserId: (opts: { agentId?: string; userId?: string }) => string;
|
||||
effectiveUserId: (sessionKey?: string) => string;
|
||||
agentUserId: (id: string) => string;
|
||||
buildAddOptions: (userIdOverride?: string, runId?: string, sessionKey?: string) => AddOptions;
|
||||
buildSearchOptions: (userIdOverride?: string, limit?: number, runId?: string, sessionKey?: string) => SearchOptions;
|
||||
getCurrentSessionId: () => string | undefined;
|
||||
skillsActive: boolean;
|
||||
captureToolEvent: (toolName: string, properties: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function registerAllTools(deps: ToolDeps): void {
|
||||
const { api } = deps;
|
||||
const nonOptional = { optional: false };
|
||||
|
||||
api.registerTool(createMemorySearchTool(deps), nonOptional);
|
||||
api.registerTool(createMemoryAddTool(deps), nonOptional);
|
||||
api.registerTool(createMemoryGetTool(deps), nonOptional);
|
||||
api.registerTool(createMemoryListTool(deps), nonOptional);
|
||||
api.registerTool(createMemoryUpdateTool(deps), nonOptional);
|
||||
api.registerTool(createMemoryDeleteTool(deps), nonOptional);
|
||||
api.registerTool(createMemoryEventListTool(deps), nonOptional);
|
||||
api.registerTool(createMemoryEventStatusTool(deps), nonOptional);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { AddOptions } from "../types.ts";
|
||||
import { isSubagentSession } from "../isolation.ts";
|
||||
import { isNoiseMessage, stripNoiseFromContent } from "../filtering.ts";
|
||||
// v3.0.0: resolveCategories/ttlToExpirationDate removed - expiration_date/immutable no longer supported
|
||||
import type { ToolDeps } from "./index.ts";
|
||||
|
||||
export function createMemoryAddTool(deps: ToolDeps) {
|
||||
const { api, provider, resolveUserId, getCurrentSessionId, buildAddOptions, buildSearchOptions, skillsActive } = deps;
|
||||
|
||||
return {
|
||||
name: "memory_add",
|
||||
label: "Memory Add",
|
||||
description: "Save important information in long-term memory via Mem0. Use for preferences, facts, decisions, and anything worth remembering.",
|
||||
parameters: Type.Object({
|
||||
text: Type.Optional(Type.String({ description: "Single fact to remember" })),
|
||||
facts: Type.Optional(Type.Array(Type.String(), { description: "Array of facts to store. ALL must share the same category." })),
|
||||
category: Type.Optional(Type.String({ description: 'Category: "identity", "preference", "decision", "rule", "project", "configuration", "technical", "relationship"' })),
|
||||
importance: Type.Optional(Type.Number({ description: "Importance (0.0-1.0), omit for category default" })),
|
||||
userId: Type.Optional(Type.String({ description: "User ID to scope this memory" })),
|
||||
agentId: Type.Optional(Type.String({ description: "Agent ID namespace" })),
|
||||
metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Additional metadata" })),
|
||||
longTerm: Type.Optional(Type.Boolean({ description: "Long-term (default: true). Set false for session-scoped." })),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
||||
const p = params as {
|
||||
text?: string; facts?: string[]; category?: string; importance?: number;
|
||||
userId?: string; agentId?: string; metadata?: Record<string, unknown>; longTerm?: boolean;
|
||||
};
|
||||
|
||||
const rawFacts: string[] = p.facts?.length ? p.facts : (p.text ? [p.text] : []);
|
||||
if (rawFacts.length === 0) {
|
||||
return { content: [{ type: "text", text: "No facts provided. Pass 'text' or 'facts' array." }], details: { error: "missing_facts" } };
|
||||
}
|
||||
|
||||
// Filter out noise and clean the facts before storing
|
||||
const allFacts = rawFacts
|
||||
.map((f) => stripNoiseFromContent(f))
|
||||
.filter((f) => f.length > 0 && !isNoiseMessage(f));
|
||||
|
||||
if (allFacts.length === 0) {
|
||||
return { content: [{ type: "text", text: "All provided facts were filtered as noise. Nothing stored." }], details: { error: "all_noise" } };
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
const currentSessionId = getCurrentSessionId();
|
||||
|
||||
if (isSubagentSession(currentSessionId)) {
|
||||
return { content: [{ type: "text", text: "Memory storage is not available in subagent sessions." }], details: { error: "subagent_blocked" } };
|
||||
}
|
||||
|
||||
const uid = resolveUserId({ agentId: p.agentId, userId: p.userId });
|
||||
const runId = !(p.longTerm ?? true) && currentSessionId ? currentSessionId : undefined;
|
||||
|
||||
if (skillsActive) {
|
||||
const rawMetadata = p.metadata;
|
||||
const category = p.category ?? rawMetadata?.category as string | undefined;
|
||||
const importance = p.importance ?? rawMetadata?.importance as number | undefined;
|
||||
const parsedMetadata: Record<string, unknown> = {
|
||||
...(rawMetadata ?? {}),
|
||||
...(category && { category }),
|
||||
...(importance !== undefined && { importance }),
|
||||
};
|
||||
|
||||
const addOpts: AddOptions = {
|
||||
user_id: uid, source: "OPENCLAW", infer: false,
|
||||
deduced_memories: allFacts, metadata: parsedMetadata ?? {},
|
||||
};
|
||||
if (runId) addOpts.run_id = runId;
|
||||
|
||||
const result = await provider.add([{ role: "user", content: allFacts.join("\n") }], addOpts);
|
||||
const count = result.results?.length ?? 0;
|
||||
api.logger.info(`openclaw-mem0: stored ${count} memor${count === 1 ? "y" : "ies"} (infer=false, category=${category ?? "none"})`);
|
||||
|
||||
deps.captureToolEvent("memory_add", { success: true, latency_ms: Date.now() - start, fact_count: allFacts.length, mode: "skills" });
|
||||
return {
|
||||
content: [{ type: "text", text: `Stored ${allFacts.length} fact(s) [${category ?? "uncategorized"}]: ${allFacts.map(f => `"${f.slice(0, 60)}${f.length > 60 ? "..." : ""}"`).join(", ")}` }],
|
||||
details: { action: "stored", mode: "skills", category, factCount: allFacts.length, results: result.results },
|
||||
};
|
||||
}
|
||||
|
||||
const combinedText = allFacts.join("\n");
|
||||
|
||||
const result = await provider.add([{ role: "user", content: combinedText }], buildAddOptions(uid, runId, currentSessionId));
|
||||
const added = result.results?.filter((r) => r.event === "ADD") ?? [];
|
||||
const updated = result.results?.filter((r) => r.event === "UPDATE") ?? [];
|
||||
const summary = [];
|
||||
if (added.length > 0) summary.push(`${added.length} added`);
|
||||
if (updated.length > 0) summary.push(`${updated.length} updated`);
|
||||
if (summary.length === 0) summary.push("No new memories extracted");
|
||||
|
||||
deps.captureToolEvent("memory_add", { success: true, latency_ms: Date.now() - start, fact_count: allFacts.length });
|
||||
return {
|
||||
content: [{ type: "text", text: `Stored: ${summary.join(", ")}. ${result.results?.map((r) => `[${r.event}] ${r.memory}`).join("; ") ?? ""}` }],
|
||||
details: { action: "stored", results: result.results },
|
||||
};
|
||||
} catch (err) {
|
||||
deps.captureToolEvent("memory_add", { success: false, latency_ms: Date.now() - start, error: String(err) });
|
||||
return { content: [{ type: "text", text: `Memory add failed: ${String(err)}` }], details: { error: String(err) } };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { isSubagentSession } from "../isolation.ts";
|
||||
import type { ToolDeps } from "./index.ts";
|
||||
|
||||
export function createMemoryDeleteTool(deps: ToolDeps) {
|
||||
const { api, provider, resolveUserId, getCurrentSessionId, buildSearchOptions } = deps;
|
||||
|
||||
return {
|
||||
name: "memory_delete",
|
||||
label: "Memory Delete",
|
||||
description: "Delete memories. Provide memoryId, query to search-and-delete, or all:true for bulk deletion (requires confirm:true).",
|
||||
parameters: Type.Object({
|
||||
memoryId: Type.Optional(Type.String({ description: "Specific memory ID to delete" })),
|
||||
query: Type.Optional(Type.String({ description: "Search query to find and delete" })),
|
||||
agentId: Type.Optional(Type.String({ description: "Agent ID to scope deletion" })),
|
||||
all: Type.Optional(Type.Boolean({ description: "Delete ALL memories. Requires confirm: true." })),
|
||||
confirm: Type.Optional(Type.Boolean({ description: "Safety gate for bulk operations" })),
|
||||
userId: Type.Optional(Type.String({ description: "User ID scope" })),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
||||
const { memoryId, query, agentId, all, confirm, userId } = params as {
|
||||
memoryId?: string; query?: string; agentId?: string;
|
||||
all?: boolean; confirm?: boolean; userId?: string;
|
||||
};
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
if (isSubagentSession(getCurrentSessionId())) {
|
||||
return { content: [{ type: "text", text: "Memory deletion is not available in subagent sessions." }], details: { error: "subagent_blocked" } };
|
||||
}
|
||||
|
||||
if (memoryId) {
|
||||
await provider.delete(memoryId);
|
||||
deps.captureToolEvent("memory_delete", { success: true, latency_ms: Date.now() - start, delete_mode: "single" });
|
||||
return { content: [{ type: "text", text: `Memory ${memoryId} deleted.` }], details: { action: "deleted", id: memoryId } };
|
||||
}
|
||||
|
||||
if (query) {
|
||||
const uid = resolveUserId({ agentId, userId });
|
||||
const results = await provider.search(query, buildSearchOptions(uid, 5));
|
||||
if (!results || results.length === 0) {
|
||||
return { content: [{ type: "text", text: "No matching memories found." }], details: { found: 0 } };
|
||||
}
|
||||
if (results.length === 1 || (results[0].score ?? 0) > 0.9) {
|
||||
await provider.delete(results[0].id);
|
||||
return { content: [{ type: "text", text: `Deleted: "${results[0].memory}"` }], details: { action: "deleted", id: results[0].id } };
|
||||
}
|
||||
const list = results.map((r) =>
|
||||
`- [${r.id}] ${r.memory.slice(0, 80)}${r.memory.length > 80 ? "..." : ""} (${((r.score ?? 0) * 100).toFixed(0)}%)`
|
||||
).join("\n");
|
||||
return {
|
||||
content: [{ type: "text", text: `Found ${results.length} candidates. Specify memoryId:\n${list}` }],
|
||||
details: { action: "candidates", candidates: results.map((r) => ({ id: r.id, memory: r.memory, score: r.score })) },
|
||||
};
|
||||
}
|
||||
|
||||
if (all) {
|
||||
if (!confirm) {
|
||||
return { content: [{ type: "text", text: "Bulk deletion requires confirm: true." }], details: { error: "confirmation_required" } };
|
||||
}
|
||||
const uid = resolveUserId({ agentId, userId });
|
||||
await provider.deleteAll(uid);
|
||||
deps.captureToolEvent("memory_delete", { success: true, latency_ms: Date.now() - start, delete_mode: "all" });
|
||||
api.logger.info(`openclaw-mem0: deleted all memories for user ${uid}`);
|
||||
return { content: [{ type: "text", text: `All memories deleted for user "${uid}".` }], details: { action: "deleted_all", user_id: uid } };
|
||||
}
|
||||
|
||||
return { content: [{ type: "text", text: "Provide memoryId, query, or all:true." }], details: { error: "missing_param" } };
|
||||
} catch (err) {
|
||||
deps.captureToolEvent("memory_delete", { success: false, latency_ms: Date.now() - start, error: String(err) });
|
||||
return { content: [{ type: "text", text: `Memory delete failed: ${String(err)}` }], details: { error: String(err) } };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { ToolDeps } from "./index.ts";
|
||||
|
||||
export function createMemoryEventListTool(deps: ToolDeps) {
|
||||
return {
|
||||
name: "memory_event_list",
|
||||
label: "Memory Event List",
|
||||
description:
|
||||
"List recent background processing events from the Mem0 Platform. Use to check whether memory operations (add, update, delete) were processed successfully.",
|
||||
parameters: Type.Object({}),
|
||||
|
||||
async execute(_toolCallId: string, _params: Record<string, unknown>) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
if (!deps.backend) {
|
||||
deps.captureToolEvent("memory_event_list", { success: false, latency_ms: 0, error: "not_platform" });
|
||||
return {
|
||||
content: [{ type: "text", text: "Event tracking is only available in platform mode." }],
|
||||
details: { error: "not_platform" },
|
||||
};
|
||||
}
|
||||
|
||||
const results = await deps.backend.listEvents();
|
||||
if (!results.length) {
|
||||
deps.captureToolEvent("memory_event_list", { success: true, latency_ms: Date.now() - start, count: 0 });
|
||||
return {
|
||||
content: [{ type: "text", text: "No events found." }],
|
||||
details: { count: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const rows = results.map((ev) => {
|
||||
const evId = String(ev.id ?? "");
|
||||
const evType = String(ev.event_type ?? "—");
|
||||
const status = String(ev.status ?? "—");
|
||||
const latency =
|
||||
typeof ev.latency === "number" ? `${Math.round(ev.latency as number)}ms` : "—";
|
||||
const created = String(ev.created_at ?? "—").slice(0, 19).replace("T", " ");
|
||||
return { id: evId, type: evType, status, latency, created };
|
||||
});
|
||||
|
||||
const text = rows
|
||||
.map((r) => `- ${r.id} | ${r.type} | ${r.status} | ${r.latency} | ${r.created}`)
|
||||
.join("\n");
|
||||
|
||||
deps.captureToolEvent("memory_event_list", { success: true, latency_ms: Date.now() - start, count: results.length });
|
||||
return {
|
||||
content: [{ type: "text", text: `${results.length} event(s):\n${text}` }],
|
||||
details: { count: results.length, events: rows },
|
||||
};
|
||||
} catch (err) {
|
||||
deps.captureToolEvent("memory_event_list", { success: false, latency_ms: Date.now() - start, error: String(err) });
|
||||
return {
|
||||
content: [{ type: "text", text: `Failed to list events: ${String(err)}` }],
|
||||
details: { error: String(err) },
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { ToolDeps } from "./index.ts";
|
||||
|
||||
export function createMemoryEventStatusTool(deps: ToolDeps) {
|
||||
return {
|
||||
name: "memory_event_status",
|
||||
label: "Memory Event Status",
|
||||
description:
|
||||
"Get detailed status of a specific background processing event. Use to verify whether a memory add/update/delete was processed, view latency, and inspect results.",
|
||||
parameters: Type.Object({
|
||||
event_id: Type.String({ description: "The event ID to check" }),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
||||
const { event_id: eventId } = params as { event_id: string };
|
||||
const start = Date.now();
|
||||
try {
|
||||
if (!deps.backend) {
|
||||
deps.captureToolEvent("memory_event_status", { success: false, latency_ms: 0, error: "not_platform" });
|
||||
return {
|
||||
content: [{ type: "text", text: "Event tracking is only available in platform mode." }],
|
||||
details: { error: "not_platform" },
|
||||
};
|
||||
}
|
||||
|
||||
const ev = await deps.backend.getEvent(eventId);
|
||||
|
||||
const status = String(ev.status ?? "—");
|
||||
const evType = String(ev.event_type ?? "—");
|
||||
const latency =
|
||||
typeof ev.latency === "number" ? `${Math.round(ev.latency as number)}ms` : "—";
|
||||
const created = String(ev.created_at ?? "—").slice(0, 19).replace("T", " ");
|
||||
const updated = String(ev.updated_at ?? "—").slice(0, 19).replace("T", " ");
|
||||
|
||||
let text = `Event: ${eventId}\nType: ${evType}\nStatus: ${status}\nLatency: ${latency}\nCreated: ${created}\nUpdated: ${updated}`;
|
||||
|
||||
const results = ev.results as Record<string, unknown>[] | undefined;
|
||||
if (results && Array.isArray(results) && results.length) {
|
||||
const resultLines = results.map((r) => {
|
||||
const memId = String(r.id ?? "").slice(0, 8);
|
||||
const data = r.data as Record<string, unknown> | undefined;
|
||||
const memory = data?.memory ?? "";
|
||||
const evName = String(r.event ?? "");
|
||||
return `- [${evName}] ${memory} (${memId})`;
|
||||
});
|
||||
text += `\n\nResults (${results.length}):\n${resultLines.join("\n")}`;
|
||||
}
|
||||
|
||||
deps.captureToolEvent("memory_event_status", { success: true, latency_ms: Date.now() - start });
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
details: { event: ev },
|
||||
};
|
||||
} catch (err) {
|
||||
deps.captureToolEvent("memory_event_status", { success: false, latency_ms: Date.now() - start, error: String(err) });
|
||||
return {
|
||||
content: [{ type: "text", text: `Failed to get event: ${String(err)}` }],
|
||||
details: { error: String(err) },
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { ToolDeps } from "./index.ts";
|
||||
|
||||
export function createMemoryGetTool(deps: ToolDeps) {
|
||||
const { provider } = deps;
|
||||
|
||||
return {
|
||||
name: "memory_get",
|
||||
label: "Memory Get",
|
||||
description: "Retrieve a specific memory by its ID from Mem0.",
|
||||
parameters: Type.Object({
|
||||
memoryId: Type.String({ description: "The memory ID to retrieve" }),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
||||
const { memoryId } = params as { memoryId: string };
|
||||
const start = Date.now();
|
||||
try {
|
||||
const memory = await provider.get(memoryId);
|
||||
deps.captureToolEvent("memory_get", { success: true, latency_ms: Date.now() - start });
|
||||
return {
|
||||
content: [{ type: "text", text: `Memory ${memory.id}:\n${memory.memory}\n\nCreated: ${memory.created_at ?? "unknown"}\nUpdated: ${memory.updated_at ?? "unknown"}` }],
|
||||
details: { memory },
|
||||
};
|
||||
} catch (err) {
|
||||
deps.captureToolEvent("memory_get", { success: false, latency_ms: Date.now() - start, error: String(err) });
|
||||
return { content: [{ type: "text", text: `Memory get failed: ${String(err)}` }], details: { error: String(err) } };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { MemoryItem } from "../types.ts";
|
||||
import type { ToolDeps } from "./index.ts";
|
||||
|
||||
export function createMemoryListTool(deps: ToolDeps) {
|
||||
const { provider, resolveUserId, getCurrentSessionId } = deps;
|
||||
|
||||
return {
|
||||
name: "memory_list",
|
||||
label: "Memory List",
|
||||
description: "List all stored memories for a user or agent.",
|
||||
parameters: Type.Object({
|
||||
userId: Type.Optional(Type.String({ description: "User ID (default: configured)" })),
|
||||
agentId: Type.Optional(Type.String({ description: "Agent ID namespace" })),
|
||||
scope: Type.Optional(
|
||||
Type.Union([Type.Literal("session"), Type.Literal("long-term"), Type.Literal("all")], {
|
||||
description: 'Scope: "all" (default), "session", or "long-term"',
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
||||
const { userId, agentId, scope = "all" } = params as {
|
||||
userId?: string; agentId?: string; scope?: "session" | "long-term" | "all";
|
||||
};
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
let memories: MemoryItem[] = [];
|
||||
const uid = resolveUserId({ agentId, userId });
|
||||
const currentSessionId = getCurrentSessionId();
|
||||
|
||||
if (scope === "session") {
|
||||
if (currentSessionId) memories = await provider.getAll({ user_id: uid, run_id: currentSessionId, source: "OPENCLAW" });
|
||||
} else if (scope === "long-term") {
|
||||
memories = await provider.getAll({ user_id: uid, source: "OPENCLAW" });
|
||||
} else {
|
||||
const longTerm = await provider.getAll({ user_id: uid, source: "OPENCLAW" });
|
||||
let session: MemoryItem[] = [];
|
||||
if (currentSessionId) session = await provider.getAll({ user_id: uid, run_id: currentSessionId, source: "OPENCLAW" });
|
||||
const seen = new Set(longTerm.map((r) => r.id));
|
||||
memories = [...longTerm, ...session.filter((r) => !seen.has(r.id))];
|
||||
}
|
||||
|
||||
deps.captureToolEvent("memory_list", { success: true, latency_ms: Date.now() - start, result_count: memories.length });
|
||||
|
||||
if (!memories || memories.length === 0) {
|
||||
return { content: [{ type: "text", text: "No memories stored yet." }], details: { count: 0 } };
|
||||
}
|
||||
|
||||
const text = memories.map((r, i) => `${i + 1}. ${r.memory} (id: ${r.id})`).join("\n");
|
||||
return {
|
||||
content: [{ type: "text", text: `${memories.length} memories:\n\n${text}` }],
|
||||
details: {
|
||||
count: memories.length,
|
||||
memories: memories.map((r) => ({ id: r.id, memory: r.memory, categories: r.categories, created_at: r.created_at })),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
deps.captureToolEvent("memory_list", { success: false, latency_ms: Date.now() - start, error: String(err) });
|
||||
return { content: [{ type: "text", text: `Memory list failed: ${String(err)}` }], details: { error: String(err) } };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { MemoryItem, SearchOptions } from "../types.ts";
|
||||
import type { ToolDeps } from "./index.ts";
|
||||
|
||||
export function createMemorySearchTool(deps: ToolDeps) {
|
||||
const { cfg, provider, resolveUserId, buildSearchOptions, getCurrentSessionId } = deps;
|
||||
|
||||
return {
|
||||
name: "memory_search",
|
||||
label: "Memory Search",
|
||||
description: "Search through long-term memories stored in Mem0.",
|
||||
parameters: Type.Object({
|
||||
query: Type.String({ description: "Search query" }),
|
||||
limit: Type.Optional(Type.Number({ description: `Max results (default: ${cfg.topK})` })),
|
||||
userId: Type.Optional(Type.String({ description: "User ID to scope search" })),
|
||||
agentId: Type.Optional(Type.String({ description: "Agent ID to search a specific agent's memories" })),
|
||||
scope: Type.Optional(
|
||||
Type.Union([Type.Literal("session"), Type.Literal("long-term"), Type.Literal("all")], {
|
||||
description: 'Scope: "all" (default), "session", or "long-term"',
|
||||
}),
|
||||
),
|
||||
categories: Type.Optional(Type.Array(Type.String(), { description: "Filter by category" })),
|
||||
filters: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Advanced filters" })),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
||||
const {
|
||||
query, limit, userId, agentId, scope = "all",
|
||||
categories: filterCategories, filters: agentFilters,
|
||||
} = params as {
|
||||
query: string; limit?: number; userId?: string; agentId?: string;
|
||||
scope?: "session" | "long-term" | "all"; categories?: string[];
|
||||
filters?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
let results: MemoryItem[] = [];
|
||||
const uid = resolveUserId({ agentId, userId });
|
||||
const currentSessionId = getCurrentSessionId();
|
||||
|
||||
const applyFilters = (opts: SearchOptions): SearchOptions => {
|
||||
if (filterCategories?.length) opts.categories = filterCategories;
|
||||
if (agentFilters) opts.filters = agentFilters;
|
||||
return opts;
|
||||
};
|
||||
|
||||
if (scope === "session") {
|
||||
if (currentSessionId) {
|
||||
results = await provider.search(query, applyFilters(buildSearchOptions(uid, limit, undefined, currentSessionId)));
|
||||
}
|
||||
} else if (scope === "long-term") {
|
||||
results = await provider.search(query, applyFilters(buildSearchOptions(uid, limit)));
|
||||
} else {
|
||||
const longTerm = await provider.search(query, applyFilters(buildSearchOptions(uid, limit)));
|
||||
let session: MemoryItem[] = [];
|
||||
if (currentSessionId) {
|
||||
session = await provider.search(query, applyFilters(buildSearchOptions(uid, limit, undefined, currentSessionId)));
|
||||
}
|
||||
const seen = new Set(longTerm.map((r) => r.id));
|
||||
results = [...longTerm, ...session.filter((r) => !seen.has(r.id))];
|
||||
}
|
||||
|
||||
deps.captureToolEvent("memory_search", { success: true, latency_ms: Date.now() - start, result_count: results.length });
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
return { content: [{ type: "text", text: "No relevant memories found." }], details: { count: 0 } };
|
||||
}
|
||||
|
||||
const text = results.map((r, i) =>
|
||||
`${i + 1}. ${r.memory} (score: ${((r.score ?? 0) * 100).toFixed(0)}%, id: ${r.id})`
|
||||
).join("\n");
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Found ${results.length} memories:\n\n${text}` }],
|
||||
details: {
|
||||
count: results.length,
|
||||
memories: results.map((r) => ({ id: r.id, memory: r.memory, score: r.score, categories: r.categories, created_at: r.created_at })),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
deps.captureToolEvent("memory_search", { success: false, latency_ms: Date.now() - start, error: String(err) });
|
||||
return { content: [{ type: "text", text: `Memory search failed: ${String(err)}` }], details: { error: String(err) } };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { isSubagentSession } from "../isolation.ts";
|
||||
import type { ToolDeps } from "./index.ts";
|
||||
|
||||
export function createMemoryUpdateTool(deps: ToolDeps) {
|
||||
const { api, provider, getCurrentSessionId } = deps;
|
||||
|
||||
return {
|
||||
name: "memory_update",
|
||||
label: "Memory Update",
|
||||
description: "Update an existing memory's text in place. Atomic and preserves history.",
|
||||
parameters: Type.Object({
|
||||
memoryId: Type.String({ description: "The memory ID to update" }),
|
||||
text: Type.String({ description: "The new text (replaces old)" }),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
||||
const { memoryId, text } = params as { memoryId: string; text: string };
|
||||
const start = Date.now();
|
||||
try {
|
||||
if (isSubagentSession(getCurrentSessionId())) {
|
||||
return { content: [{ type: "text", text: "Memory update is not available in subagent sessions." }], details: { error: "subagent_blocked" } };
|
||||
}
|
||||
await provider.update(memoryId, text);
|
||||
deps.captureToolEvent("memory_update", { success: true, latency_ms: Date.now() - start });
|
||||
return {
|
||||
content: [{ type: "text", text: `Updated memory ${memoryId}: "${text.slice(0, 80)}${text.length > 80 ? "..." : ""}"` }],
|
||||
details: { action: "updated", id: memoryId },
|
||||
};
|
||||
} catch (err) {
|
||||
deps.captureToolEvent("memory_update", { success: false, latency_ms: Date.now() - start, error: String(err) });
|
||||
return { content: [{ type: "text", text: `Memory update failed: ${String(err)}` }], details: { error: String(err) } };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"strict": false,
|
||||
"noImplicitAny": false,
|
||||
"types": ["node"],
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["index.ts", "types.ts", "providers.ts", "config.ts", "filtering.ts", "isolation.ts", "openclaw-plugin-sdk.d.ts", "backend/**/*.ts", "tools/**/*.ts", "cli/**/*.ts", "skill-loader.ts", "recall.ts", "dream-gate.ts", "telemetry.ts", "fs-safe.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "tsup";
|
||||
import pkg from "./package.json";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["index.ts", "fs-safe.ts"],
|
||||
format: ["esm"],
|
||||
splitting: true,
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
external: [/^node:/, /^openclaw\//, "fs", "os", "path", "url", "readline", "module",
|
||||
"mem0ai", /^mem0ai\//, "better-sqlite3", "@sinclair/typebox"],
|
||||
define: {
|
||||
__OPENCLAW_PLUGIN_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Shared type definitions for the OpenClaw Mem0 plugin.
|
||||
*/
|
||||
|
||||
export type Mem0Mode = "platform" | "open-source";
|
||||
|
||||
export type Mem0Config = {
|
||||
mode: Mem0Mode;
|
||||
// Platform-specific
|
||||
apiKey?: string;
|
||||
anonymousTelemetryId?: string;
|
||||
baseUrl?: string;
|
||||
customInstructions: string;
|
||||
customCategories: Record<string, string>;
|
||||
// OSS-specific (customPrompt renamed to customInstructions in v3.0.0)
|
||||
oss?: {
|
||||
embedder?: { provider: string; config: Record<string, unknown> };
|
||||
vectorStore?: { provider: string; config: Record<string, unknown> };
|
||||
llm?: { provider: string; config: Record<string, unknown> };
|
||||
historyDbPath?: string;
|
||||
disableHistory?: boolean;
|
||||
};
|
||||
// Shared
|
||||
userId: string;
|
||||
autoCapture: boolean;
|
||||
autoRecall: boolean;
|
||||
searchThreshold: number;
|
||||
topK: number;
|
||||
// Setup state
|
||||
needsSetup?: boolean;
|
||||
// Agentic harness skills
|
||||
skills?: SkillsConfig;
|
||||
};
|
||||
|
||||
export interface AddOptions {
|
||||
user_id: string;
|
||||
run_id?: string;
|
||||
custom_instructions?: string;
|
||||
custom_categories?: Record<string, string>;
|
||||
source?: string;
|
||||
// Agentic harness additions
|
||||
infer?: boolean;
|
||||
deduced_memories?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
user_id: string;
|
||||
run_id?: string;
|
||||
top_k?: number;
|
||||
threshold?: number;
|
||||
categories?: string[];
|
||||
filters?: Record<string, unknown>;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Skills Configuration Types
|
||||
// ============================================================================
|
||||
|
||||
export interface CategoryConfig {
|
||||
importance: number;
|
||||
ttl: string | null; // e.g. "7d", "90d", null = permanent
|
||||
immutable?: boolean;
|
||||
}
|
||||
|
||||
export interface SkillsConfig {
|
||||
triage?: {
|
||||
enabled?: boolean;
|
||||
importanceThreshold?: number;
|
||||
credentialPatterns?: string[];
|
||||
};
|
||||
recall?: {
|
||||
/** Master switch. false = no auto-recall regardless of strategy. */
|
||||
enabled?: boolean;
|
||||
/** Controls auto-recall behavior. Only consulted when enabled !== false.
|
||||
* "smart" (default): long-term search only, 1 search/turn.
|
||||
* "manual": zero plugin searches, agent controls all search.
|
||||
* "always": long-term + session search, 2 searches/turn. */
|
||||
strategy?: "always" | "smart" | "manual";
|
||||
tokenBudget?: number;
|
||||
maxMemories?: number;
|
||||
rerank?: boolean;
|
||||
keywordSearch?: boolean;
|
||||
filterMemories?: boolean;
|
||||
threshold?: number;
|
||||
identityAlwaysInclude?: boolean;
|
||||
categoryOrder?: string[];
|
||||
};
|
||||
dream?: {
|
||||
enabled?: boolean;
|
||||
/** Enable automatic triggering based on activity gates. Default: true when dream enabled. */
|
||||
auto?: boolean;
|
||||
/** Minimum hours between consolidations. Default: 24. */
|
||||
minHours?: number;
|
||||
/** Minimum interactive sessions before triggering. Default: 5. */
|
||||
minSessions?: number;
|
||||
/** Minimum total memories to justify consolidation. Default: 20. */
|
||||
minMemories?: number;
|
||||
};
|
||||
domain?: string;
|
||||
customRules?: {
|
||||
include?: string[];
|
||||
exclude?: string[];
|
||||
};
|
||||
categories?: Record<string, CategoryConfig>;
|
||||
}
|
||||
|
||||
export interface ListOptions {
|
||||
user_id: string;
|
||||
run_id?: string;
|
||||
page_size?: number;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface MemoryItem {
|
||||
id: string;
|
||||
memory: string;
|
||||
user_id?: string;
|
||||
score?: number;
|
||||
categories?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface AddResultItem {
|
||||
id: string;
|
||||
memory: string;
|
||||
event: "ADD" | "UPDATE" | "DELETE" | "NOOP";
|
||||
}
|
||||
|
||||
export interface AddResult {
|
||||
results: AddResultItem[];
|
||||
}
|
||||
|
||||
export interface Mem0Provider {
|
||||
add(
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
options: AddOptions,
|
||||
): Promise<AddResult>;
|
||||
search(query: string, options: SearchOptions): Promise<MemoryItem[]>;
|
||||
get(memoryId: string): Promise<MemoryItem>;
|
||||
getAll(options: ListOptions): Promise<MemoryItem[]>;
|
||||
update(memoryId: string, text: string): Promise<void>;
|
||||
delete(memoryId: string): Promise<void>;
|
||||
deleteAll(userId: string): Promise<void>;
|
||||
history(
|
||||
memoryId: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
old_memory: string;
|
||||
new_memory: string;
|
||||
event: string;
|
||||
created_at: string;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import pkg from "./package.json";
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
__OPENCLAW_PLUGIN_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
test: {
|
||||
alias: {
|
||||
// OpenClaw SDK modules are resolved from the gateway at runtime.
|
||||
// During unit tests we provide lightweight stubs.
|
||||
"openclaw/plugin-sdk/plugin-entry": new URL(
|
||||
"./test-shims/plugin-entry.ts",
|
||||
import.meta.url,
|
||||
).pathname,
|
||||
"openclaw/plugin-sdk": new URL(
|
||||
"./test-shims/plugin-sdk.ts",
|
||||
import.meta.url,
|
||||
).pathname,
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user