wip: [01-stabilize] paused at task 1/1 - OCR Hallucination Immune logic via Semantic delta window and fret-isolation

This commit is contained in:
2026-03-29 22:08:40 +09:00
parent aca7bf592a
commit 2507de45d3
4289 changed files with 732689 additions and 28672 deletions

View File

@@ -0,0 +1,2 @@
node_modules/
dist/

View File

@@ -0,0 +1,69 @@
# Dockerfile.e2e — End-to-end test: install claude-mem plugin on a real OpenClaw instance
# Simulates the complete plugin installation flow a user would follow.
#
# Usage:
# docker build -f Dockerfile.e2e -t openclaw-e2e-test . && docker run --rm openclaw-e2e-test
#
# Interactive (for human testing):
# docker run --rm -it openclaw-e2e-test /bin/bash
FROM ghcr.io/openclaw/openclaw:main
USER root
# Install curl for health checks in e2e-verify.sh, and TypeScript for building
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
RUN npm install -g typescript@5
# Create staging directory for the plugin source
WORKDIR /tmp/claude-mem-plugin
# Copy plugin source files
COPY package.json tsconfig.json openclaw.plugin.json ./
COPY src/ ./src/
# Build the plugin (TypeScript → JavaScript)
# NODE_ENV=production is set in the base image; override to install devDependencies
RUN NODE_ENV=development npm install && npx tsc
# Create the installable plugin package:
# OpenClaw `plugins install` expects package.json with openclaw.extensions field.
# The package name must match the plugin ID in openclaw.plugin.json (claude-mem).
# Only include the main plugin entry point, not test/mock files.
RUN mkdir -p /tmp/claude-mem-installable/dist && \
cp dist/index.js /tmp/claude-mem-installable/dist/ && \
cp dist/index.d.ts /tmp/claude-mem-installable/dist/ 2>/dev/null || true && \
cp openclaw.plugin.json /tmp/claude-mem-installable/ && \
node -e " \
const pkg = { \
name: 'claude-mem', \
version: '1.0.0', \
type: 'module', \
main: 'dist/index.js', \
openclaw: { extensions: ['./dist/index.js'] } \
}; \
require('fs').writeFileSync('/tmp/claude-mem-installable/package.json', JSON.stringify(pkg, null, 2)); \
"
# Switch back to app directory and node user for installation
WORKDIR /app
USER node
# Create the OpenClaw config directory
RUN mkdir -p /home/node/.openclaw
# Install the plugin using OpenClaw's official CLI
RUN node openclaw.mjs plugins install /tmp/claude-mem-installable
# Enable the plugin
RUN node openclaw.mjs plugins enable claude-mem
# Copy the e2e verification script and mock worker
COPY --chown=node:node e2e-verify.sh /app/e2e-verify.sh
USER root
RUN chmod +x /app/e2e-verify.sh && \
cp /tmp/claude-mem-plugin/dist/mock-worker.js /app/mock-worker.js
USER node
# Default: run the automated verification
CMD ["/bin/bash", "/app/e2e-verify.sh"]

View File

@@ -0,0 +1,462 @@
# Claude-Mem OpenClaw Plugin — Setup Guide
This guide walks through setting up the claude-mem plugin on an OpenClaw gateway. By the end, your agents will have persistent memory across sessions via system prompt context injection, and optionally a real-time observation feed streaming to a messaging channel.
## Quick Install (Recommended)
Run this one-liner to install everything automatically:
```bash
curl -fsSL https://install.cmem.ai/openclaw.sh | bash
```
The installer handles dependency checks (Bun, uv), plugin installation, memory slot configuration, AI provider setup, worker startup, and optional observation feed configuration — all interactively.
### Install with options
Pre-select your AI provider and API key to skip interactive prompts:
```bash
curl -fsSL https://install.cmem.ai/openclaw.sh | bash -s -- --provider=gemini --api-key=YOUR_KEY
```
For fully unattended installation (defaults to Claude Max Plan, skips observation feed):
```bash
curl -fsSL https://install.cmem.ai/openclaw.sh | bash -s -- --non-interactive
```
To upgrade an existing installation (preserves settings, updates plugin):
```bash
curl -fsSL https://install.cmem.ai/openclaw.sh | bash -s -- --upgrade
```
After installation, skip to [Step 4: Restart the Gateway and Verify](#step-4-restart-the-gateway-and-verify) to confirm everything is working.
---
## Manual Setup
The steps below are for manual installation if you prefer not to use the automated installer, or need to troubleshoot individual steps.
### Step 1: Clone the Claude-Mem Repo
First, clone the claude-mem repository to a location accessible by your OpenClaw gateway. This gives you the worker service source and the plugin code.
```bash
cd /opt # or wherever you want to keep it
git clone https://github.com/thedotmack/claude-mem.git
cd claude-mem
npm install
npm run build
```
You'll need **bun** installed for the worker service. If you don't have it:
```bash
curl -fsSL https://bun.sh/install | bash
```
### Step 2: Get the Worker Running
The claude-mem worker is an HTTP service on port 37777. It stores observations, generates summaries, and serves the context timeline. The plugin talks to it over HTTP — it doesn't matter where the worker is running, just that it's reachable on localhost:37777.
#### Check if it's already running
If this machine also runs Claude Code with claude-mem installed, the worker may already be running:
```bash
curl http://localhost:37777/api/health
```
**Got `{"status":"ok"}`?** The worker is already running. Skip to Step 3.
**Got connection refused or no response?** The worker isn't running. Continue below.
#### If Claude Code has claude-mem installed
If claude-mem is installed as a Claude Code plugin (at `~/.claude/plugins/marketplaces/thedotmack/`), start the worker from that installation:
```bash
cd ~/.claude/plugins/marketplaces/thedotmack
npm run worker:restart
```
Verify:
```bash
curl http://localhost:37777/api/health
```
**Got `{"status":"ok"}`?** You're set. Skip to Step 3.
**Still not working?** Check `npm run worker:status` for error details, or check that bun is installed and on your PATH.
#### If there's no Claude Code installation
Run the worker from the cloned repo:
```bash
cd /opt/claude-mem # wherever you cloned it
npm run worker:start
```
Verify:
```bash
curl http://localhost:37777/api/health
```
**Got `{"status":"ok"}`?** You're set. Move to Step 3.
**Still not working?** Debug steps:
- Check that bun is installed: `bun --version`
- Check the worker status: `npm run worker:status`
- Check if something else is using port 37777: `lsof -i :37777`
- Check logs: `npm run worker:logs` (if available)
- Try running it directly to see errors: `bun plugin/scripts/worker-service.cjs start`
### Step 3: Add the Plugin to Your Gateway
Add the `claude-mem` plugin to your OpenClaw gateway configuration:
```json
{
"plugins": {
"claude-mem": {
"enabled": true,
"config": {
"project": "my-project",
"syncMemoryFile": true,
"workerPort": 37777
}
}
}
}
```
#### Config fields explained
- **`project`** (string, default: `"openclaw"`) — The project name that scopes all observations in the memory database. Use a unique name per gateway/use-case so observations don't mix. For example, if this gateway runs a coding bot, use `"coding-bot"`.
- **`syncMemoryFile`** (boolean, default: `true`) — When enabled, the plugin injects the observation timeline into each agent's system prompt via the `before_prompt_build` hook. This gives agents cross-session context without writing to MEMORY.md. Set to `false` to disable context injection entirely (observations are still recorded).
- **`syncMemoryFileExclude`** (string[], default: `[]`) — Agent IDs excluded from automatic context injection. Useful for agents that curate their own memory. Observations are still recorded for excluded agents.
- **`workerPort`** (number, default: `37777`) — The port where the claude-mem worker service is listening. Only change this if you configured the worker to use a different port.
---
## Step 4: Restart the Gateway and Verify
Restart your OpenClaw gateway so it picks up the new plugin configuration. After restart, check the gateway logs for:
```
[claude-mem] OpenClaw plugin loaded — v1.0.0 (worker: 127.0.0.1:37777)
```
If you see this, the plugin is loaded. You can also verify by running `/claude_mem_status` in any OpenClaw chat:
```
Claude-Mem Worker Status
Status: ok
Port: 37777
Active sessions: 0
Observation feed: disconnected
```
The observation feed shows `disconnected` because we haven't configured it yet. That's next.
## Step 5: Verify Observations Are Being Recorded
Have an agent do some work. The plugin automatically records observations through these OpenClaw events:
1. **`before_agent_start`** — Initializes a claude-mem session when the agent starts
2. **`before_prompt_build`** — Injects the observation timeline into the agent's system prompt (cached for 60s)
3. **`tool_result_persist`** — Records each tool use (Read, Write, Bash, etc.) as an observation
4. **`agent_end`** — Summarizes the session and marks it complete
All of this happens automatically. No additional configuration needed.
To verify it's working, check the worker's viewer UI at http://localhost:37777 to see observations appearing after the agent runs.
You can also check the worker's viewer UI at http://localhost:37777 to see observations appearing in real time.
## Step 6: Set Up the Observation Feed (Streaming to a Channel)
The observation feed connects to the claude-mem worker's SSE (Server-Sent Events) stream and forwards every new observation to a messaging channel in real time. Your agents learn things, and you see them learning in your Telegram/Discord/Slack/etc.
### What you'll see
Every time claude-mem creates a new observation from your agent's tool usage, a message like this appears in your channel:
```
🧠 Claude-Mem Observation
**Implemented retry logic for API client**
Added exponential backoff with configurable max retries to handle transient failures
```
### Pick your channel
You need two things:
- **Channel type** — Must match a channel plugin already running on your OpenClaw gateway
- **Target ID** — The chat/channel/user ID where messages go
#### Telegram
Channel type: `telegram`
To find your chat ID:
1. Message @userinfobot on Telegram — https://t.me/userinfobot
2. It replies with your numeric chat ID (e.g., `123456789`)
3. For group chats, the ID is negative (e.g., `-1001234567890`)
```json
"observationFeed": {
"enabled": true,
"channel": "telegram",
"to": "123456789"
}
```
#### Discord
Channel type: `discord`
To find your channel ID:
1. Enable Developer Mode in Discord: Settings → Advanced → Developer Mode
2. Right-click the target channel → Copy Channel ID
```json
"observationFeed": {
"enabled": true,
"channel": "discord",
"to": "1234567890123456789"
}
```
#### Slack
Channel type: `slack`
To find your channel ID (not the channel name):
1. Open the channel in Slack
2. Click the channel name at the top
3. Scroll to the bottom of the channel details — the ID looks like `C01ABC2DEFG`
```json
"observationFeed": {
"enabled": true,
"channel": "slack",
"to": "C01ABC2DEFG"
}
```
#### Signal
Channel type: `signal`
Use the phone number or group ID configured in your OpenClaw gateway's Signal plugin.
```json
"observationFeed": {
"enabled": true,
"channel": "signal",
"to": "+1234567890"
}
```
#### WhatsApp
Channel type: `whatsapp`
Use the phone number or group JID configured in your OpenClaw gateway's WhatsApp plugin.
```json
"observationFeed": {
"enabled": true,
"channel": "whatsapp",
"to": "+1234567890"
}
```
#### LINE
Channel type: `line`
Use the user ID or group ID from the LINE Developer Console.
```json
"observationFeed": {
"enabled": true,
"channel": "line",
"to": "U1234567890abcdef"
}
```
### Add it to your config
Your complete plugin config should now look like this (using Telegram as an example):
```json
{
"plugins": {
"claude-mem": {
"enabled": true,
"config": {
"project": "my-project",
"syncMemoryFile": true,
"workerPort": 37777,
"observationFeed": {
"enabled": true,
"channel": "telegram",
"to": "123456789"
}
}
}
}
}
```
### Restart and verify
Restart the gateway. Check the logs for these three lines in order:
```
[claude-mem] Observation feed starting — channel: telegram, target: 123456789
[claude-mem] Connecting to SSE stream at http://localhost:37777/stream
[claude-mem] Connected to SSE stream
```
Then run `/claude_mem_feed` in any OpenClaw chat:
```
Claude-Mem Observation Feed
Enabled: yes
Channel: telegram
Target: 123456789
Connection: connected
```
If `Connection` shows `connected`, you're done. Have an agent do some work and watch observations stream to your channel.
## Commands Reference
The plugin registers two commands:
### /claude_mem_status
Reports worker health and current session state.
```
/claude_mem_status
```
Output:
```
Claude-Mem Worker Status
Status: ok
Port: 37777
Active sessions: 2
Observation feed: connected
```
### /claude_mem_feed
Shows observation feed status. Accepts optional `on`/`off` argument.
```
/claude_mem_feed — show status
/claude_mem_feed on — request enable (update config to persist)
/claude_mem_feed off — request disable (update config to persist)
```
## How It All Works
```
OpenClaw Gateway
├── before_agent_start ───→ Init session
├── before_prompt_build ──→ Inject context into system prompt
├── tool_result_persist ──→ Record observation
├── agent_end ────────────→ Summarize + Complete session
└── gateway_start ────────→ Reset session tracking + context cache
Claude-Mem Worker (localhost:37777)
├── POST /api/sessions/init
├── POST /api/sessions/observations
├── POST /api/sessions/summarize
├── POST /api/sessions/complete
├── GET /api/context/inject ──→ System prompt context
└── GET /stream ─────────────→ SSE → Messaging channels
```
### System prompt context injection
The plugin injects the observation timeline into each agent's system prompt via the `before_prompt_build` hook. The content comes from the worker's `GET /api/context/inject` endpoint. Context is cached for 60 seconds per project to avoid re-fetching on every LLM turn. The cache is cleared on gateway restart.
This keeps MEMORY.md under the agent's control for curated long-term memory, while the observation timeline is delivered through the system prompt.
### Observation recording
Every tool use (Read, Write, Bash, etc.) is sent to the claude-mem worker as an observation. The worker's AI agent processes it into a structured observation with title, subtitle, facts, concepts, and narrative. Tools prefixed with `memory_` are skipped to avoid recursive recording.
### Session lifecycle
- **`before_agent_start`** — Creates a session in the worker.
- **`before_prompt_build`** — Fetches the observation timeline and returns it as `appendSystemContext`. Cached for 60s.
- **`tool_result_persist`** — Records observation (fire-and-forget). Tool responses are truncated to 1000 characters.
- **`agent_end`** — Sends the last assistant message for summarization, then completes the session. Both fire-and-forget.
- **`gateway_start`** — Clears all session tracking (session IDs, context cache) so agents start fresh.
### Observation feed
A background service connects to the worker's SSE stream and forwards `new_observation` events to a configured messaging channel. The connection auto-reconnects with exponential backoff (1s → 30s max).
## Troubleshooting
| Problem | What to check |
|---------|---------------|
| Worker health check fails | Is bun installed? (`bun --version`). Is something else on port 37777? (`lsof -i :37777`). Try running directly: `bun plugin/scripts/worker-service.cjs start` |
| Worker started from Claude Code install but not responding | Check `cd ~/.claude/plugins/marketplaces/thedotmack && npm run worker:status`. May need `npm run worker:restart`. |
| Worker started from cloned repo but not responding | Check `cd /path/to/claude-mem && npm run worker:status`. Make sure you ran `npm install && npm run build` first. |
| No context in agent system prompt | Check that `syncMemoryFile` is not set to `false`. Check that the agent's ID is not in `syncMemoryFileExclude`. Verify the worker is running and has observations. |
| Observations not being recorded | Check gateway logs for `[claude-mem]` messages. The worker must be running and reachable on localhost:37777. |
| Feed shows `disconnected` | Worker's `/stream` endpoint not reachable. Check `workerPort` matches the actual worker port. |
| Feed shows `reconnecting` | Connection dropped. The plugin auto-reconnects — wait up to 30 seconds. |
| `Unknown channel type` in logs | The channel plugin (e.g., telegram) isn't loaded on your gateway. Make sure the channel is configured and running. |
| `Observation feed disabled` in logs | Set `observationFeed.enabled` to `true` in your config. |
| `Observation feed misconfigured` in logs | Both `observationFeed.channel` and `observationFeed.to` are required. |
| No messages in channel despite `connected` | The feed only sends processed observations, not raw tool usage. There's a 1-2 second delay. Make sure the worker is actually processing observations (check http://localhost:37777). |
## Full Config Reference
```json
{
"plugins": {
"claude-mem": {
"enabled": true,
"config": {
"project": "openclaw",
"syncMemoryFile": true,
"workerPort": 37777,
"observationFeed": {
"enabled": false,
"channel": "telegram",
"to": "123456789"
}
}
}
}
}
```
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `project` | string | `"openclaw"` | Project name scoping observations in the database |
| `syncMemoryFile` | boolean | `true` | Inject observation context into agent system prompt |
| `syncMemoryFileExclude` | string[] | `[]` | Agent IDs excluded from context injection |
| `workerPort` | number | `37777` | Claude-mem worker service port |
| `observationFeed.enabled` | boolean | `false` | Stream observations to a messaging channel |
| `observationFeed.channel` | string | — | Channel type: `telegram`, `discord`, `slack`, `signal`, `whatsapp`, `line` |
| `observationFeed.to` | string | — | Target chat/channel/user ID |

View File

@@ -0,0 +1,279 @@
# OpenClaw Claude-Mem Plugin — Testing Guide
## Quick Start (Docker)
The fastest way to test the plugin is using the pre-built Docker E2E environment:
```bash
cd openclaw
# Automated test (builds, installs plugin on real OpenClaw, verifies everything)
./test-e2e.sh
# Interactive shell (for manual exploration)
./test-e2e.sh --interactive
# Just build the image
./test-e2e.sh --build-only
```
---
## Test Layers
### 1. Unit Tests (fastest)
```bash
cd openclaw
npm test # compiles TypeScript, runs 17 tests
```
Tests plugin registration, service lifecycle, command handling, SSE integration, and all 6 channel types.
### 2. Smoke Test
```bash
node test-sse-consumer.js
```
Quick check that the plugin loads and registers its service + command correctly.
### 3. Container Unit Tests (fresh install)
```bash
./test-container.sh # Unit tests in clean Docker
./test-container.sh --full # Integration tests with mock worker
```
### 4. E2E on Real OpenClaw (Docker)
```bash
./test-e2e.sh
```
This is the most comprehensive test. It:
1. Uses the official `ghcr.io/openclaw/openclaw:main` Docker image
2. Installs the plugin via `openclaw plugins install` (same as a real user)
3. Enables the plugin via `openclaw plugins enable`
4. Starts a mock claude-mem worker on port 37777
5. Starts the OpenClaw gateway with plugin config
6. Verifies the plugin loads, connects to SSE, and processes events
**All 16 checks must pass.**
---
## Human E2E Testing (Interactive Docker)
For manual walkthrough testing, use the interactive Docker mode:
```bash
./test-e2e.sh --interactive
```
This drops you into a fully-configured OpenClaw container with the plugin pre-installed.
### Step-by-step inside the container
#### 1. Verify plugin is installed
```bash
node openclaw.mjs plugins list
node openclaw.mjs plugins info claude-mem
node openclaw.mjs plugins doctor
```
**Expected:**
- `claude-mem` appears in the plugins list as "enabled" or "loaded"
- Info shows version 1.0.0, source at `/home/node/.openclaw/extensions/claude-mem/`
- Doctor reports no issues
#### 2. Inspect plugin files
```bash
ls -la /home/node/.openclaw/extensions/claude-mem/
cat /home/node/.openclaw/extensions/claude-mem/openclaw.plugin.json
cat /home/node/.openclaw/extensions/claude-mem/package.json
```
**Expected:**
- `dist/index.js` exists (compiled plugin)
- `openclaw.plugin.json` has `"id": "claude-mem"` and `"kind": "memory"`
- `package.json` has `openclaw.extensions` field pointing to `./dist/index.js`
#### 3. Start mock worker
```bash
node /app/mock-worker.js &
```
Verify it's running:
```bash
curl -s http://localhost:37777/health
# → {"status":"ok"}
curl -s --max-time 3 http://localhost:37777/stream
# → data: {"type":"connected","message":"Mock worker SSE stream"}
# → data: {"type":"new_observation","observation":{...}}
```
#### 4. Configure and start gateway
```bash
cat > /home/node/.openclaw/openclaw.json << 'EOF'
{
"gateway": {
"mode": "local",
"auth": {
"mode": "token",
"token": "e2e-test-token"
}
},
"plugins": {
"slots": {
"memory": "claude-mem"
},
"entries": {
"claude-mem": {
"enabled": true,
"config": {
"workerPort": 37777,
"observationFeed": {
"enabled": true,
"channel": "telegram",
"to": "test-chat-id-12345"
}
}
}
}
}
}
EOF
node openclaw.mjs gateway --allow-unconfigured --verbose --token e2e-test-token
```
**Expected in gateway logs:**
- `[claude-mem] OpenClaw plugin loaded — v1.0.0`
- `[claude-mem] Observation feed starting — channel: telegram, target: test-chat-id-12345`
- `[claude-mem] Connecting to SSE stream at http://localhost:37777/stream`
- `[claude-mem] Connected to SSE stream`
#### 5. Run automated verification (optional)
From a second shell in the container (or after stopping the gateway):
```bash
/bin/bash /app/e2e-verify.sh
```
---
## Manual E2E (Real OpenClaw + Real Worker)
For testing with a real claude-mem worker and real messaging channel:
### Prerequisites
- OpenClaw gateway installed and configured
- Claude-Mem worker running on port 37777
- Plugin built: `cd openclaw && npm run build`
### 1. Install the plugin
```bash
# Build the plugin
cd openclaw && npm run build
# Install on OpenClaw (from the openclaw/ directory)
openclaw plugins install .
# Enable it
openclaw plugins enable claude-mem
```
### 2. Configure
Edit `~/.openclaw/openclaw.json` to add plugin config:
```json
{
"plugins": {
"entries": {
"claude-mem": {
"enabled": true,
"config": {
"workerPort": 37777,
"observationFeed": {
"enabled": true,
"channel": "telegram",
"to": "YOUR_CHAT_ID"
}
}
}
}
}
}
```
**Supported channels:** `telegram`, `discord`, `signal`, `slack`, `whatsapp`, `line`
### 3. Restart gateway
```bash
openclaw restart
```
**Look for in logs:**
- `[claude-mem] OpenClaw plugin loaded — v1.0.0`
- `[claude-mem] Connected to SSE stream`
### 4. Trigger an observation
Start a Claude Code session with claude-mem enabled and perform any action. The worker will emit a `new_observation` SSE event.
### 5. Verify delivery
Check the target messaging channel for:
```
🧠 Claude-Mem Observation
**Observation Title**
Optional subtitle
```
---
## Troubleshooting
### `api.log is not a function`
The plugin was built against the wrong API. Ensure `src/index.ts` uses `api.logger.info()` not `api.log()`. Rebuild with `npm run build`.
### Worker not running
- **Symptom:** `SSE stream error: fetch failed. Reconnecting in 1s`
- **Fix:** Start the worker: `cd /path/to/claude-mem && npm run build-and-sync`
### Port mismatch
- **Fix:** Ensure `workerPort` in config matches the worker's actual port (default: 37777)
### Channel not configured
- **Symptom:** `Observation feed misconfigured — channel or target missing`
- **Fix:** Add both `channel` and `to` to `observationFeed` in config
### Unknown channel type
- **Fix:** Use: `telegram`, `discord`, `signal`, `slack`, `whatsapp`, or `line`
### Feed disabled
- **Symptom:** `Observation feed disabled`
- **Fix:** Set `observationFeed.enabled: true`
### Messages not arriving
1. Verify the bot/integration is configured in the target channel
2. Check the target ID (`to`) is correct
3. Look for `Failed to send to <channel>` in logs
4. Test the channel via OpenClaw's built-in tools
### Memory slot conflict
- **Symptom:** `plugin disabled (memory slot set to "memory-core")`
- **Fix:** Add `"slots": { "memory": "claude-mem" }` to plugins config

View File

@@ -0,0 +1,265 @@
#!/usr/bin/env bash
# e2e-verify.sh — Automated E2E verification for claude-mem plugin on OpenClaw
#
# This script verifies the complete plugin installation and operation flow:
# 1. Plugin is installed and visible in OpenClaw
# 2. Plugin loads correctly when gateway starts
# 3. Mock worker SSE stream is consumed by the plugin
# 4. Observations are received and formatted
#
# Exit 0 = all checks passed, Exit 1 = failure
set -euo pipefail
PASS=0
FAIL=0
TOTAL=0
pass() {
PASS=$((PASS + 1))
TOTAL=$((TOTAL + 1))
echo " PASS: $1"
}
fail() {
FAIL=$((FAIL + 1))
TOTAL=$((TOTAL + 1))
echo " FAIL: $1"
}
section() {
echo ""
echo "=== $1 ==="
}
# ─── Phase 1: Plugin Discovery ───
section "Phase 1: Plugin Discovery"
# Check plugin is listed
PLUGIN_LIST=$(node /app/openclaw.mjs plugins list 2>&1)
if echo "$PLUGIN_LIST" | grep -q "claude-mem"; then
pass "Plugin appears in 'plugins list'"
else
fail "Plugin NOT found in 'plugins list'"
echo "$PLUGIN_LIST"
fi
# Check plugin info
PLUGIN_INFO=$(node /app/openclaw.mjs plugins info claude-mem 2>&1 || true)
if echo "$PLUGIN_INFO" | grep -qi "claude-mem"; then
pass "Plugin info shows claude-mem details"
else
fail "Plugin info failed"
echo "$PLUGIN_INFO"
fi
# Check plugin is enabled
if echo "$PLUGIN_LIST" | grep -A1 "claude-mem" | grep -qi "enabled\|loaded"; then
pass "Plugin is enabled"
else
# Try to check via info
if echo "$PLUGIN_INFO" | grep -qi "enabled\|loaded"; then
pass "Plugin is enabled (via info)"
else
fail "Plugin does not appear enabled"
echo "$PLUGIN_INFO"
fi
fi
# Check plugin doctor reports no issues
DOCTOR_OUT=$(node /app/openclaw.mjs plugins doctor 2>&1 || true)
if echo "$DOCTOR_OUT" | grep -qi "no.*issue\|0 issue"; then
pass "Plugin doctor reports no issues"
else
fail "Plugin doctor reports issues"
echo "$DOCTOR_OUT"
fi
# ─── Phase 2: Plugin Files ───
section "Phase 2: Plugin Files"
# Check extension directory exists
EXTENSIONS_DIR="/home/node/.openclaw/extensions/openclaw-plugin"
if [ ! -d "$EXTENSIONS_DIR" ]; then
# Try alternative naming
EXTENSIONS_DIR="/home/node/.openclaw/extensions/claude-mem"
if [ ! -d "$EXTENSIONS_DIR" ]; then
# Search for it
FOUND_DIR=$(find /home/node/.openclaw/extensions/ -name "openclaw.plugin.json" -exec dirname {} \; 2>/dev/null | head -1 || true)
if [ -n "$FOUND_DIR" ]; then
EXTENSIONS_DIR="$FOUND_DIR"
fi
fi
fi
if [ -d "$EXTENSIONS_DIR" ]; then
pass "Plugin directory exists: $EXTENSIONS_DIR"
else
fail "Plugin directory not found under /home/node/.openclaw/extensions/"
ls -la /home/node/.openclaw/extensions/ 2>/dev/null || echo " (extensions dir not found)"
fi
# Check key files exist
for FILE in "openclaw.plugin.json" "dist/index.js" "package.json"; do
if [ -f "$EXTENSIONS_DIR/$FILE" ]; then
pass "File exists: $FILE"
else
fail "File missing: $FILE"
fi
done
# ─── Phase 3: Mock Worker + Plugin Integration ───
section "Phase 3: Mock Worker + Plugin Integration"
# Start mock worker in background
echo " Starting mock claude-mem worker..."
node /app/mock-worker.js &
MOCK_PID=$!
# Wait for mock worker to be ready
for i in $(seq 1 10); do
if curl -sf http://localhost:37777/health > /dev/null 2>&1; then
break
fi
sleep 0.5
done
if curl -sf http://localhost:37777/health > /dev/null 2>&1; then
pass "Mock worker health check passed"
else
fail "Mock worker health check failed"
kill $MOCK_PID 2>/dev/null || true
fi
# Test SSE stream connectivity (curl with max-time to capture initial SSE frame)
SSE_TEST=$(curl -s --max-time 2 http://localhost:37777/stream 2>/dev/null || true)
if echo "$SSE_TEST" | grep -q "connected"; then
pass "SSE stream returns connected event"
else
fail "SSE stream did not return connected event"
echo " Got: $(echo "$SSE_TEST" | head -5)"
fi
# ─── Phase 4: Gateway + Plugin Load ───
section "Phase 4: Gateway Startup with Plugin"
# Create a minimal config that enables the plugin with the mock worker.
# The memory slot must be set to "claude-mem" to match what `plugins install` configured.
# Gateway auth is disabled via token for headless testing.
mkdir -p /home/node/.openclaw
cat > /home/node/.openclaw/openclaw.json << 'EOFCONFIG'
{
"gateway": {
"mode": "local",
"auth": {
"mode": "token",
"token": "e2e-test-token"
}
},
"plugins": {
"slots": {
"memory": "claude-mem"
},
"entries": {
"claude-mem": {
"enabled": true,
"config": {
"workerPort": 37777,
"observationFeed": {
"enabled": true,
"channel": "telegram",
"to": "test-chat-id-12345"
}
}
}
}
}
}
EOFCONFIG
pass "OpenClaw config written with plugin enabled"
# Start gateway in background and capture output
GATEWAY_LOG="/tmp/gateway.log"
echo " Starting OpenClaw gateway (timeout 15s)..."
OPENCLAW_GATEWAY_TOKEN=e2e-test-token timeout 15 node /app/openclaw.mjs gateway --allow-unconfigured --verbose --token e2e-test-token > "$GATEWAY_LOG" 2>&1 &
GATEWAY_PID=$!
# Give the gateway time to start and load plugins
sleep 5
# Check if gateway started
if kill -0 $GATEWAY_PID 2>/dev/null; then
pass "Gateway process is running"
else
fail "Gateway process exited early"
echo " Gateway log:"
cat "$GATEWAY_LOG" 2>/dev/null | tail -30
fi
# Check gateway log for plugin load messages
if grep -qi "claude-mem" "$GATEWAY_LOG" 2>/dev/null; then
pass "Gateway log mentions claude-mem plugin"
else
fail "Gateway log does not mention claude-mem"
echo " Gateway log (last 20 lines):"
tail -20 "$GATEWAY_LOG" 2>/dev/null
fi
# Check for plugin loaded message
if grep -q "plugin loaded" "$GATEWAY_LOG" 2>/dev/null || grep -q "v1.0.0" "$GATEWAY_LOG" 2>/dev/null; then
pass "Plugin load message found in gateway log"
else
fail "Plugin load message not found"
fi
# Check for observation feed messages
if grep -qi "observation feed" "$GATEWAY_LOG" 2>/dev/null; then
pass "Observation feed activity in gateway log"
else
fail "No observation feed activity detected"
fi
# Check for SSE connection to mock worker
if grep -qi "connected.*SSE\|SSE.*stream\|connecting.*SSE" "$GATEWAY_LOG" 2>/dev/null; then
pass "SSE connection activity detected"
else
fail "No SSE connection activity in log"
fi
# ─── Cleanup ───
section "Cleanup"
kill $GATEWAY_PID 2>/dev/null || true
kill $MOCK_PID 2>/dev/null || true
wait $GATEWAY_PID 2>/dev/null || true
wait $MOCK_PID 2>/dev/null || true
echo " Processes stopped."
# ─── Summary ───
echo ""
echo "==============================="
echo " E2E Test Results"
echo "==============================="
echo " Total: $TOTAL"
echo " Passed: $PASS"
echo " Failed: $FAIL"
echo "==============================="
if [ "$FAIL" -gt 0 ]; then
echo ""
echo " SOME TESTS FAILED"
echo ""
echo " Full gateway log:"
cat "$GATEWAY_LOG" 2>/dev/null
exit 1
fi
echo ""
echo " ALL TESTS PASSED"
exit 0

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,92 @@
{
"id": "claude-mem",
"name": "Claude-Mem (Persistent Memory)",
"description": "Official OpenClaw plugin for Claude-Mem. Records observations from embedded runner sessions and streams them to messaging channels.",
"kind": "memory",
"version": "10.4.1",
"author": "thedotmack",
"homepage": "https://claude-mem.com",
"skills": ["skills/make-plan", "skills/do"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"syncMemoryFile": {
"type": "boolean",
"default": true,
"description": "Inject observation context into the agent system prompt via before_prompt_build hook. When true, agents receive cross-session context without MEMORY.md being overwritten."
},
"syncMemoryFileExclude": {
"type": "array",
"items": { "type": "string" },
"default": [],
"description": "Agent IDs excluded from automatic context injection (observations are still recorded, only prompt injection is skipped)"
},
"workerPort": {
"type": "number",
"default": 37777,
"description": "Port for Claude-Mem worker service"
},
"project": {
"type": "string",
"default": "openclaw",
"description": "Project name for scoping observations in the memory database"
},
"observationFeed": {
"type": "object",
"description": "Live observation feed — streams observations to any OpenClaw channel in real-time",
"properties": {
"enabled": {
"type": "boolean",
"default": false,
"description": "Enable live observation feed to messaging channels"
},
"channel": {
"type": "string",
"description": "Channel type: telegram, discord, signal, slack, whatsapp, line"
},
"to": {
"type": "string",
"description": "Target chat/user ID to send observations to"
},
"botToken": {
"type": "string",
"description": "Optional dedicated Telegram bot token for the feed (bypasses gateway channel)"
},
"emojis": {
"type": "object",
"description": "Emoji personalization for the observation feed. Each agent gets a unique emoji automatically — customize here to override.",
"properties": {
"primary": {
"type": "string",
"default": "🦞",
"description": "Emoji for the main OpenClaw gateway (project='openclaw')"
},
"claudeCode": {
"type": "string",
"default": "⌨️",
"description": "Emoji for Claude Code sessions (non-OpenClaw)"
},
"claudeCodeLabel": {
"type": "string",
"default": "Claude Code Session",
"description": "Display label prefix for Claude Code sessions in the feed (project identifier is appended automatically)"
},
"default": {
"type": "string",
"default": "🦀",
"description": "Fallback emoji when no match is found"
},
"agents": {
"type": "object",
"default": {},
"description": "Pin specific emojis to agent IDs (e.g. {\"devops\": \"🔧\"}). Agents not listed here get auto-assigned emojis.",
"additionalProperties": { "type": "string" }
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,20 @@
{
"name": "@openclaw/claude-mem",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"test": "tsc && node --test dist/index.test.js"
},
"devDependencies": {
"@types/node": "^25.2.1",
"typescript": "^5.3.0"
},
"openclaw": {
"extensions": [
"./dist/index.js"
]
}
}

View File

@@ -0,0 +1 @@
../../../plugin/skills/do/SKILL.md

View File

@@ -0,0 +1 @@
../../../plugin/skills/make-plan/SKILL.md

View File

@@ -0,0 +1,981 @@
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
import { mkdtemp, readFile, rm } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
import claudeMemPlugin from "./index.js";
function createMockApi(pluginConfigOverride: Record<string, any> = {}) {
const logs: string[] = [];
const sentMessages: Array<{ to: string; text: string; channel: string; opts?: any }> = [];
let registeredService: any = null;
const registeredCommands: Map<string, any> = new Map();
const eventHandlers: Map<string, Function[]> = new Map();
const api = {
id: "claude-mem",
name: "Claude-Mem (Persistent Memory)",
version: "1.0.0",
source: "/test/extensions/claude-mem/dist/index.js",
config: {},
pluginConfig: pluginConfigOverride,
logger: {
info: (message: string) => { logs.push(message); },
warn: (message: string) => { logs.push(message); },
error: (message: string) => { logs.push(message); },
debug: (message: string) => { logs.push(message); },
},
registerService: (service: any) => {
registeredService = service;
},
registerCommand: (command: any) => {
registeredCommands.set(command.name, command);
},
on: (event: string, callback: Function) => {
if (!eventHandlers.has(event)) {
eventHandlers.set(event, []);
}
eventHandlers.get(event)!.push(callback);
},
runtime: {
channel: {
telegram: {
sendMessageTelegram: async (to: string, text: string) => {
sentMessages.push({ to, text, channel: "telegram" });
},
},
discord: {
sendMessageDiscord: async (to: string, text: string) => {
sentMessages.push({ to, text, channel: "discord" });
},
},
signal: {
sendMessageSignal: async (to: string, text: string) => {
sentMessages.push({ to, text, channel: "signal" });
},
},
slack: {
sendMessageSlack: async (to: string, text: string) => {
sentMessages.push({ to, text, channel: "slack" });
},
},
whatsapp: {
sendMessageWhatsApp: async (to: string, text: string, opts?: { verbose: boolean }) => {
sentMessages.push({ to, text, channel: "whatsapp", opts });
},
},
line: {
sendMessageLine: async (to: string, text: string) => {
sentMessages.push({ to, text, channel: "line" });
},
},
},
},
};
return {
api: api as any,
logs,
sentMessages,
getService: () => registeredService,
getCommand: (name?: string) => {
if (name) return registeredCommands.get(name);
return registeredCommands.get("claude_mem_feed");
},
getEventHandlers: (event: string) => eventHandlers.get(event) || [],
fireEvent: async (event: string, data: any, ctx: any = {}) => {
const handlers = eventHandlers.get(event) || [];
let lastResult: any;
for (const handler of handlers) {
lastResult = await handler(data, ctx);
}
return lastResult;
},
};
}
describe("claudeMemPlugin", () => {
it("registers service, commands, and event handlers on load", () => {
const { api, logs, getService, getCommand, getEventHandlers } = createMockApi();
claudeMemPlugin(api);
assert.ok(getService(), "service should be registered");
assert.equal(getService().id, "claude-mem-observation-feed");
assert.ok(getCommand("claude_mem_feed"), "feed command should be registered");
assert.ok(getCommand("claude_mem_status"), "status command should be registered");
assert.ok(getEventHandlers("session_start").length > 0, "session_start handler registered");
assert.ok(getEventHandlers("after_compaction").length > 0, "after_compaction handler registered");
assert.ok(getEventHandlers("before_agent_start").length > 0, "before_agent_start handler registered");
assert.ok(getEventHandlers("before_prompt_build").length > 0, "before_prompt_build handler registered");
assert.ok(getEventHandlers("tool_result_persist").length > 0, "tool_result_persist handler registered");
assert.ok(getEventHandlers("agent_end").length > 0, "agent_end handler registered");
assert.ok(getEventHandlers("gateway_start").length > 0, "gateway_start handler registered");
assert.ok(logs.some((l) => l.includes("plugin loaded")));
});
describe("service start", () => {
it("logs disabled when feed not enabled", async () => {
const { api, logs, getService } = createMockApi({});
claudeMemPlugin(api);
await getService().start({});
assert.ok(logs.some((l) => l.includes("feed disabled")));
});
it("logs disabled when enabled is false", async () => {
const { api, logs, getService } = createMockApi({
observationFeed: { enabled: false },
});
claudeMemPlugin(api);
await getService().start({});
assert.ok(logs.some((l) => l.includes("feed disabled")));
});
it("logs misconfigured when channel is missing", async () => {
const { api, logs, getService } = createMockApi({
observationFeed: { enabled: true, to: "123" },
});
claudeMemPlugin(api);
await getService().start({});
assert.ok(logs.some((l) => l.includes("misconfigured")));
});
it("logs misconfigured when to is missing", async () => {
const { api, logs, getService } = createMockApi({
observationFeed: { enabled: true, channel: "telegram" },
});
claudeMemPlugin(api);
await getService().start({});
assert.ok(logs.some((l) => l.includes("misconfigured")));
});
});
describe("service stop", () => {
it("logs disconnection on stop", async () => {
const { api, logs, getService } = createMockApi({});
claudeMemPlugin(api);
await getService().stop({});
assert.ok(logs.some((l) => l.includes("feed stopped")));
});
});
describe("command handler", () => {
it("returns not configured when no feedConfig", async () => {
const { api, getCommand } = createMockApi({});
claudeMemPlugin(api);
const result = await getCommand().handler({ args: "", channel: "telegram", isAuthorizedSender: true, commandBody: "/claude_mem_feed", config: {} });
assert.ok(result.text.includes("not configured"));
});
it("returns status when no args", async () => {
const { api, getCommand } = createMockApi({
observationFeed: { enabled: true, channel: "telegram", to: "123" },
});
claudeMemPlugin(api);
const result = await getCommand().handler({ args: "", channel: "telegram", isAuthorizedSender: true, commandBody: "/claude_mem_feed", config: {} });
assert.ok(result.text.includes("Enabled: yes"));
assert.ok(result.text.includes("Channel: telegram"));
assert.ok(result.text.includes("Target: 123"));
assert.ok(result.text.includes("Connection:"));
});
it("handles 'on' argument", async () => {
const { api, logs, getCommand } = createMockApi({
observationFeed: { enabled: false },
});
claudeMemPlugin(api);
const result = await getCommand().handler({ args: "on", channel: "telegram", isAuthorizedSender: true, commandBody: "/claude_mem_feed on", config: {} });
assert.ok(result.text.includes("enable requested"));
assert.ok(logs.some((l) => l.includes("enable requested")));
});
it("handles 'off' argument", async () => {
const { api, logs, getCommand } = createMockApi({
observationFeed: { enabled: true },
});
claudeMemPlugin(api);
const result = await getCommand().handler({ args: "off", channel: "telegram", isAuthorizedSender: true, commandBody: "/claude_mem_feed off", config: {} });
assert.ok(result.text.includes("disable requested"));
assert.ok(logs.some((l) => l.includes("disable requested")));
});
it("shows connection state in status output", async () => {
const { api, getCommand } = createMockApi({
observationFeed: { enabled: false, channel: "slack", to: "#general" },
});
claudeMemPlugin(api);
const result = await getCommand().handler({ args: "", channel: "slack", isAuthorizedSender: true, commandBody: "/claude_mem_feed", config: {} });
assert.ok(result.text.includes("Connection: disconnected"));
});
});
});
describe("Observation I/O event handlers", () => {
let workerServer: Server;
let workerPort: number;
let receivedRequests: Array<{ method: string; url: string; body: any }> = [];
function startWorkerMock(): Promise<number> {
return new Promise((resolve) => {
workerServer = createServer((req: IncomingMessage, res: ServerResponse) => {
let body = "";
req.on("data", (chunk) => { body += chunk.toString(); });
req.on("end", () => {
let parsedBody: any = null;
try { parsedBody = JSON.parse(body); } catch {}
receivedRequests.push({
method: req.method || "GET",
url: req.url || "/",
body: parsedBody,
});
// Handle different endpoints
if (req.url === "/api/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return;
}
if (req.url === "/api/sessions/init") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ sessionDbId: 1, promptNumber: 1, skipped: false }));
return;
}
if (req.url === "/api/sessions/observations") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "queued" }));
return;
}
if (req.url === "/api/sessions/summarize") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "queued" }));
return;
}
if (req.url === "/api/sessions/complete") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed" }));
return;
}
if (req.url?.startsWith("/api/context/inject")) {
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
res.end("# Claude-Mem Context\n\n## Timeline\n- Session 1: Did some work");
return;
}
if (req.url === "/stream") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
return;
}
res.writeHead(404);
res.end();
});
});
workerServer.listen(0, () => {
const address = workerServer.address();
if (address && typeof address === "object") {
resolve(address.port);
}
});
});
}
beforeEach(async () => {
receivedRequests = [];
workerPort = await startWorkerMock();
});
afterEach(() => {
workerServer?.close();
});
it("session_start sends session init to worker", async () => {
const { api, logs, fireEvent } = createMockApi({ workerPort });
claudeMemPlugin(api);
await fireEvent("session_start", {
sessionId: "test-session-1",
}, { sessionKey: "agent-1" });
// Wait for HTTP request
await new Promise((resolve) => setTimeout(resolve, 100));
const initRequest = receivedRequests.find((r) => r.url === "/api/sessions/init");
assert.ok(initRequest, "should send init request to worker");
assert.equal(initRequest!.body.project, "openclaw");
assert.ok(initRequest!.body.contentSessionId.startsWith("openclaw-agent-1-"));
assert.ok(logs.some((l) => l.includes("Session initialized")));
});
it("session_start calls init on worker", async () => {
const { api, fireEvent } = createMockApi({ workerPort });
claudeMemPlugin(api);
await fireEvent("session_start", { sessionId: "test-session-1" }, {});
await new Promise((resolve) => setTimeout(resolve, 100));
const initRequests = receivedRequests.filter((r) => r.url === "/api/sessions/init");
assert.equal(initRequests.length, 1, "should init on session_start");
});
it("after_compaction re-inits session on worker", async () => {
const { api, fireEvent } = createMockApi({ workerPort });
claudeMemPlugin(api);
await fireEvent("after_compaction", { messageCount: 5, compactedCount: 3 }, {});
await new Promise((resolve) => setTimeout(resolve, 100));
const initRequests = receivedRequests.filter((r) => r.url === "/api/sessions/init");
assert.equal(initRequests.length, 1, "should re-init after compaction");
});
it("before_agent_start calls init for session privacy check", async () => {
const { api, fireEvent } = createMockApi({ workerPort });
claudeMemPlugin(api);
await fireEvent("before_agent_start", { prompt: "hello" }, {});
await new Promise((resolve) => setTimeout(resolve, 100));
const initRequests = receivedRequests.filter((r) => r.url === "/api/sessions/init");
assert.equal(initRequests.length, 1, "before_agent_start should init session");
});
it("tool_result_persist sends observation to worker", async () => {
const { api, fireEvent } = createMockApi({ workerPort });
claudeMemPlugin(api);
// Establish contentSessionId via session_start
await fireEvent("session_start", { sessionId: "s1" }, { sessionKey: "test-agent" });
await new Promise((resolve) => setTimeout(resolve, 100));
// Fire tool result event
await fireEvent("tool_result_persist", {
toolName: "Read",
params: { file_path: "/src/index.ts" },
message: {
content: [{ type: "text", text: "file contents here..." }],
},
}, { sessionKey: "test-agent" });
await new Promise((resolve) => setTimeout(resolve, 100));
const obsRequest = receivedRequests.find((r) => r.url === "/api/sessions/observations");
assert.ok(obsRequest, "should send observation to worker");
assert.equal(obsRequest!.body.tool_name, "Read");
assert.deepEqual(obsRequest!.body.tool_input, { file_path: "/src/index.ts" });
assert.equal(obsRequest!.body.tool_response, "file contents here...");
assert.ok(obsRequest!.body.contentSessionId.startsWith("openclaw-test-agent-"));
});
it("tool_result_persist skips memory_ tools", async () => {
const { api, fireEvent } = createMockApi({ workerPort });
claudeMemPlugin(api);
await fireEvent("tool_result_persist", {
toolName: "memory_search",
params: {},
}, {});
await new Promise((resolve) => setTimeout(resolve, 100));
const obsRequest = receivedRequests.find((r) => r.url === "/api/sessions/observations");
assert.ok(!obsRequest, "should skip memory_ tools");
});
it("tool_result_persist truncates long responses", async () => {
const { api, fireEvent } = createMockApi({ workerPort });
claudeMemPlugin(api);
const longText = "x".repeat(2000);
await fireEvent("tool_result_persist", {
toolName: "Bash",
params: { command: "ls" },
message: {
content: [{ type: "text", text: longText }],
},
}, {});
await new Promise((resolve) => setTimeout(resolve, 100));
const obsRequest = receivedRequests.find((r) => r.url === "/api/sessions/observations");
assert.ok(obsRequest, "should send observation");
assert.equal(obsRequest!.body.tool_response.length, 1000, "should truncate to 1000 chars");
});
it("agent_end sends summarize and complete to worker", async () => {
const { api, fireEvent } = createMockApi({ workerPort });
claudeMemPlugin(api);
// Establish session
await fireEvent("session_start", { sessionId: "s1" }, { sessionKey: "summarize-test" });
await new Promise((resolve) => setTimeout(resolve, 100));
// Fire agent end
await fireEvent("agent_end", {
messages: [
{ role: "user", content: "help me" },
{ role: "assistant", content: "Here is the solution..." },
],
}, { sessionKey: "summarize-test" });
await new Promise((resolve) => setTimeout(resolve, 100));
const summarizeRequest = receivedRequests.find((r) => r.url === "/api/sessions/summarize");
assert.ok(summarizeRequest, "should send summarize to worker");
assert.equal(summarizeRequest!.body.last_assistant_message, "Here is the solution...");
assert.ok(summarizeRequest!.body.contentSessionId.startsWith("openclaw-summarize-test-"));
const completeRequest = receivedRequests.find((r) => r.url === "/api/sessions/complete");
assert.ok(completeRequest, "should send complete to worker");
assert.ok(completeRequest!.body.contentSessionId.startsWith("openclaw-summarize-test-"));
});
it("agent_end extracts text from array content", async () => {
const { api, fireEvent } = createMockApi({ workerPort });
claudeMemPlugin(api);
await fireEvent("session_start", { sessionId: "s1" }, { sessionKey: "array-content" });
await new Promise((resolve) => setTimeout(resolve, 100));
await fireEvent("agent_end", {
messages: [
{
role: "assistant",
content: [
{ type: "text", text: "First part" },
{ type: "text", text: "Second part" },
],
},
],
}, { sessionKey: "array-content" });
await new Promise((resolve) => setTimeout(resolve, 100));
const summarizeRequest = receivedRequests.find((r) => r.url === "/api/sessions/summarize");
assert.ok(summarizeRequest, "should send summarize");
assert.equal(summarizeRequest!.body.last_assistant_message, "First part\nSecond part");
});
it("uses custom project name from config", async () => {
const { api, fireEvent } = createMockApi({ workerPort, project: "my-project" });
claudeMemPlugin(api);
await fireEvent("session_start", { sessionId: "s1" }, {});
await new Promise((resolve) => setTimeout(resolve, 100));
const initRequest = receivedRequests.find((r) => r.url === "/api/sessions/init");
assert.ok(initRequest, "should send init");
assert.equal(initRequest!.body.project, "my-project");
});
it("claude_mem_status command reports worker health", async () => {
const { api, getCommand } = createMockApi({ workerPort });
claudeMemPlugin(api);
const statusCmd = getCommand("claude_mem_status");
assert.ok(statusCmd, "status command should exist");
const result = await statusCmd.handler({ args: "", channel: "telegram", isAuthorizedSender: true, commandBody: "/claude_mem_status", config: {} });
assert.ok(result.text.includes("Status: ok"));
assert.ok(result.text.includes(`Port: ${workerPort}`));
});
it("claude_mem_status reports unreachable when worker is down", async () => {
workerServer.close();
await new Promise((resolve) => setTimeout(resolve, 100));
const { api, getCommand } = createMockApi({ workerPort: 59999 });
claudeMemPlugin(api);
const statusCmd = getCommand("claude_mem_status");
const result = await statusCmd.handler({ args: "", channel: "telegram", isAuthorizedSender: true, commandBody: "/claude_mem_status", config: {} });
assert.ok(result.text.includes("unreachable"));
});
it("reuses same contentSessionId for same sessionKey", async () => {
const { api, fireEvent } = createMockApi({ workerPort });
claudeMemPlugin(api);
await fireEvent("session_start", { sessionId: "s1" }, { sessionKey: "reuse-test" });
await new Promise((resolve) => setTimeout(resolve, 100));
await fireEvent("tool_result_persist", {
toolName: "Read",
params: { file_path: "/src/index.ts" },
message: { content: [{ type: "text", text: "contents" }] },
}, { sessionKey: "reuse-test" });
await new Promise((resolve) => setTimeout(resolve, 100));
const initRequest = receivedRequests.find((r) => r.url === "/api/sessions/init");
const obsRequest = receivedRequests.find((r) => r.url === "/api/sessions/observations");
assert.ok(initRequest && obsRequest, "both requests should exist");
assert.equal(
initRequest!.body.contentSessionId,
obsRequest!.body.contentSessionId,
"should reuse contentSessionId for same sessionKey"
);
});
});
describe("before_prompt_build context injection", () => {
let workerServer: Server;
let workerPort: number;
let receivedRequests: Array<{ method: string; url: string; body: any }> = [];
let contextResponse = "# Claude-Mem Context\n\n## Timeline\n- Session 1: Did some work";
function startWorkerMock(): Promise<number> {
return new Promise((resolve) => {
workerServer = createServer((req: IncomingMessage, res: ServerResponse) => {
let body = "";
req.on("data", (chunk) => { body += chunk.toString(); });
req.on("end", () => {
let parsedBody: any = null;
try { parsedBody = JSON.parse(body); } catch {}
receivedRequests.push({
method: req.method || "GET",
url: req.url || "/",
body: parsedBody,
});
if (req.url?.startsWith("/api/context/inject")) {
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
res.end(contextResponse);
return;
}
if (req.url === "/api/sessions/init") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ sessionDbId: 1, promptNumber: 1, skipped: false }));
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
});
});
workerServer.listen(0, () => {
const address = workerServer.address();
if (address && typeof address === "object") {
resolve(address.port);
}
});
});
}
beforeEach(async () => {
receivedRequests = [];
contextResponse = "# Claude-Mem Context\n\n## Timeline\n- Session 1: Did some work";
workerPort = await startWorkerMock();
});
afterEach(async () => {
workerServer?.close();
});
it("returns appendSystemContext from before_prompt_build", async () => {
const { api, logs, fireEvent } = createMockApi({ workerPort });
claudeMemPlugin(api);
const result = await fireEvent("before_prompt_build", {
prompt: "Help me write a function",
messages: [],
}, { agentId: "main" });
await new Promise((resolve) => setTimeout(resolve, 200));
const contextRequest = receivedRequests.find((r) => r.url?.startsWith("/api/context/inject"));
assert.ok(contextRequest, "should request context from worker");
assert.ok(contextRequest!.url!.includes("projects=openclaw"));
assert.ok(result, "should return a result");
assert.ok(result.appendSystemContext, "should return appendSystemContext");
assert.ok(result.appendSystemContext.includes("Claude-Mem Context"), "should contain context");
assert.ok(result.appendSystemContext.includes("Session 1"), "should contain timeline");
assert.ok(logs.some((l) => l.includes("Context injected via system prompt")));
});
it("does not write MEMORY.md on before_agent_start", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "claude-mem-test-"));
try {
const { api, fireEvent } = createMockApi({ workerPort });
claudeMemPlugin(api);
await fireEvent("before_agent_start", {
prompt: "Help me write a function",
}, { sessionKey: "sync-test", workspaceDir: tmpDir });
await new Promise((resolve) => setTimeout(resolve, 200));
let memoryExists = true;
try {
await readFile(join(tmpDir, "MEMORY.md"), "utf-8");
} catch {
memoryExists = false;
}
assert.ok(!memoryExists, "MEMORY.md should not be created by before_agent_start");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
it("does not sync MEMORY.md on tool_result_persist", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "claude-mem-test-"));
try {
const { api, fireEvent } = createMockApi({ workerPort });
claudeMemPlugin(api);
await fireEvent("before_agent_start", {
prompt: "Help me write a function",
}, { sessionKey: "tool-sync", workspaceDir: tmpDir });
await new Promise((resolve) => setTimeout(resolve, 200));
await fireEvent("tool_result_persist", {
toolName: "Read",
params: { file_path: "/src/app.ts" },
message: { content: [{ type: "text", text: "file contents" }] },
}, { sessionKey: "tool-sync" });
await new Promise((resolve) => setTimeout(resolve, 200));
const contextRequests = receivedRequests.filter((r) => r.url?.startsWith("/api/context/inject"));
assert.equal(contextRequests.length, 0, "tool_result_persist should not fetch context");
let memoryExists = true;
try {
await readFile(join(tmpDir, "MEMORY.md"), "utf-8");
} catch {
memoryExists = false;
}
assert.ok(!memoryExists, "MEMORY.md should not be written by tool_result_persist");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
it("skips context injection when syncMemoryFile is false", async () => {
const { api, fireEvent } = createMockApi({ workerPort, syncMemoryFile: false });
claudeMemPlugin(api);
const result = await fireEvent("before_prompt_build", {
prompt: "Help me write a function",
messages: [],
}, { agentId: "main" });
await new Promise((resolve) => setTimeout(resolve, 200));
const contextRequest = receivedRequests.find((r) => r.url?.startsWith("/api/context/inject"));
assert.ok(!contextRequest, "should not fetch context when injection disabled");
assert.equal(result, undefined, "should return undefined when injection disabled");
});
it("skips context injection for excluded agents", async () => {
const { api, fireEvent } = createMockApi({ workerPort, syncMemoryFileExclude: ["snarf"] });
claudeMemPlugin(api);
const result = await fireEvent("before_prompt_build", {
prompt: "Help me",
messages: [],
}, { agentId: "snarf" });
await new Promise((resolve) => setTimeout(resolve, 200));
const contextRequest = receivedRequests.find((r) => r.url?.startsWith("/api/context/inject"));
assert.ok(!contextRequest, "should not fetch context for excluded agent");
assert.equal(result, undefined, "should return undefined for excluded agent");
});
it("injects context for non-excluded agents", async () => {
const { api, fireEvent } = createMockApi({ workerPort, syncMemoryFileExclude: ["snarf"] });
claudeMemPlugin(api);
const result = await fireEvent("before_prompt_build", {
prompt: "Help me",
messages: [],
}, { agentId: "main" });
await new Promise((resolve) => setTimeout(resolve, 200));
assert.ok(result, "should return a result for non-excluded agent");
assert.ok(result.appendSystemContext, "should inject context for non-excluded agent");
});
it("returns undefined when context is empty", async () => {
contextResponse = " ";
const { api, logs, fireEvent } = createMockApi({ workerPort });
claudeMemPlugin(api);
const result = await fireEvent("before_prompt_build", {
prompt: "Help me write a function",
messages: [],
}, { agentId: "main" });
await new Promise((resolve) => setTimeout(resolve, 200));
assert.equal(result, undefined, "should return undefined for empty context");
assert.ok(!logs.some((l) => l.includes("Context injected")), "should not log injection for empty context");
});
it("uses custom project name in context inject URL", async () => {
const { api, fireEvent } = createMockApi({ workerPort, project: "my-bot" });
claudeMemPlugin(api);
await fireEvent("before_prompt_build", {
prompt: "Help me write a function",
messages: [],
}, { agentId: "main" });
await new Promise((resolve) => setTimeout(resolve, 200));
const contextRequest = receivedRequests.find((r) => r.url?.startsWith("/api/context/inject"));
assert.ok(contextRequest, "should request context");
assert.ok(contextRequest!.url!.includes("projects=my-bot"), "should use custom project name");
});
it("includes agent-scoped project in context request", async () => {
const { api, fireEvent } = createMockApi({ workerPort });
claudeMemPlugin(api);
await fireEvent("before_prompt_build", {
prompt: "Help me",
messages: [],
}, { agentId: "debugger" });
await new Promise((resolve) => setTimeout(resolve, 200));
const contextRequest = receivedRequests.find((r) => r.url?.startsWith("/api/context/inject"));
assert.ok(contextRequest, "should request context");
const url = decodeURIComponent(contextRequest!.url!);
assert.ok(url.includes("openclaw,openclaw-debugger"), "should include both base and agent-scoped projects");
});
});
describe("SSE stream integration", () => {
let server: Server;
let serverPort: number;
let serverResponses: ServerResponse[] = [];
function startSSEServer(): Promise<number> {
return new Promise((resolve) => {
server = createServer((req: IncomingMessage, res: ServerResponse) => {
if (req.url !== "/stream") {
res.writeHead(404);
res.end();
return;
}
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
serverResponses.push(res);
});
server.listen(0, () => {
const address = server.address();
if (address && typeof address === "object") {
resolve(address.port);
}
});
});
}
beforeEach(async () => {
serverResponses = [];
serverPort = await startSSEServer();
});
afterEach(() => {
for (const res of serverResponses) {
try {
res.end();
} catch {}
}
server?.close();
});
it("connects to SSE stream and receives new_observation events", async () => {
const { api, logs, sentMessages, getService } = createMockApi({
workerPort: serverPort,
observationFeed: { enabled: true, channel: "telegram", to: "12345" },
});
claudeMemPlugin(api);
await getService().start({});
// Wait for connection
await new Promise((resolve) => setTimeout(resolve, 200));
assert.ok(logs.some((l) => l.includes("Connecting to SSE stream")));
// Send an SSE event
const observation = {
type: "new_observation",
observation: {
id: 1,
title: "Test Observation",
subtitle: "Found something interesting",
type: "discovery",
project: "test",
prompt_number: 1,
created_at_epoch: Date.now(),
},
timestamp: Date.now(),
};
for (const res of serverResponses) {
res.write(`data: ${JSON.stringify(observation)}\n\n`);
}
// Wait for processing
await new Promise((resolve) => setTimeout(resolve, 200));
assert.equal(sentMessages.length, 1);
assert.equal(sentMessages[0].channel, "telegram");
assert.equal(sentMessages[0].to, "12345");
assert.ok(sentMessages[0].text.includes("Test Observation"));
assert.ok(sentMessages[0].text.includes("Found something interesting"));
await getService().stop({});
});
it("filters out non-observation events", async () => {
const { api, sentMessages, getService } = createMockApi({
workerPort: serverPort,
observationFeed: { enabled: true, channel: "discord", to: "channel-id" },
});
claudeMemPlugin(api);
await getService().start({});
await new Promise((resolve) => setTimeout(resolve, 200));
// Send non-observation events
for (const res of serverResponses) {
res.write(`data: ${JSON.stringify({ type: "processing_status", isProcessing: true })}\n\n`);
res.write(`data: ${JSON.stringify({ type: "session_started", sessionId: "abc" })}\n\n`);
}
await new Promise((resolve) => setTimeout(resolve, 200));
assert.equal(sentMessages.length, 0, "non-observation events should be filtered");
await getService().stop({});
});
it("handles observation with null subtitle", async () => {
const { api, sentMessages, getService } = createMockApi({
workerPort: serverPort,
observationFeed: { enabled: true, channel: "telegram", to: "999" },
});
claudeMemPlugin(api);
await getService().start({});
await new Promise((resolve) => setTimeout(resolve, 200));
for (const res of serverResponses) {
res.write(
`data: ${JSON.stringify({
type: "new_observation",
observation: { id: 2, title: "No Subtitle", subtitle: null },
timestamp: Date.now(),
})}\n\n`
);
}
await new Promise((resolve) => setTimeout(resolve, 200));
assert.equal(sentMessages.length, 1);
assert.ok(sentMessages[0].text.includes("No Subtitle"));
assert.ok(!sentMessages[0].text.includes("null"));
await getService().stop({});
});
it("handles observation with null title", async () => {
const { api, sentMessages, getService } = createMockApi({
workerPort: serverPort,
observationFeed: { enabled: true, channel: "telegram", to: "999" },
});
claudeMemPlugin(api);
await getService().start({});
await new Promise((resolve) => setTimeout(resolve, 200));
for (const res of serverResponses) {
res.write(
`data: ${JSON.stringify({
type: "new_observation",
observation: { id: 3, title: null, subtitle: "Has subtitle" },
timestamp: Date.now(),
})}\n\n`
);
}
await new Promise((resolve) => setTimeout(resolve, 200));
assert.equal(sentMessages.length, 1);
assert.ok(sentMessages[0].text.includes("Untitled"));
await getService().stop({});
});
it("uses custom workerPort from config", async () => {
const { api, logs, getService } = createMockApi({
workerPort: serverPort,
observationFeed: { enabled: true, channel: "telegram", to: "12345" },
});
claudeMemPlugin(api);
await getService().start({});
await new Promise((resolve) => setTimeout(resolve, 200));
assert.ok(logs.some((l) => l.includes(`127.0.0.1:${serverPort}`)));
await getService().stop({});
});
it("logs unknown channel type", async () => {
const { api, logs, sentMessages, getService } = createMockApi({
workerPort: serverPort,
observationFeed: { enabled: true, channel: "matrix", to: "room-id" },
});
claudeMemPlugin(api);
await getService().start({});
await new Promise((resolve) => setTimeout(resolve, 200));
for (const res of serverResponses) {
res.write(
`data: ${JSON.stringify({
type: "new_observation",
observation: { id: 4, title: "Test", subtitle: null },
timestamp: Date.now(),
})}\n\n`
);
}
await new Promise((resolve) => setTimeout(resolve, 200));
assert.equal(sentMessages.length, 0);
assert.ok(logs.some((l) => l.includes("Unsupported channel type: matrix")));
await getService().stop({});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# test-e2e.sh — Run E2E test of claude-mem plugin on real OpenClaw
#
# Usage:
# ./test-e2e.sh # Automated E2E test (build + run + verify)
# ./test-e2e.sh --interactive # Drop into shell for manual testing
# ./test-e2e.sh --build-only # Just build the image, don't run
set -euo pipefail
cd "$(dirname "$0")"
IMAGE_NAME="openclaw-claude-mem-e2e"
echo "=== Building E2E test image ==="
echo " Base: ghcr.io/openclaw/openclaw:main"
echo " Plugin: @claude-mem/openclaw-plugin (PR #1012)"
echo ""
docker build -f Dockerfile.e2e -t "$IMAGE_NAME" .
if [ "${1:-}" = "--build-only" ]; then
echo ""
echo "Image built: $IMAGE_NAME"
echo "Run manually with: docker run --rm $IMAGE_NAME"
exit 0
fi
echo ""
echo "=== Running E2E verification ==="
echo ""
if [ "${1:-}" = "--interactive" ]; then
echo "Dropping into interactive shell."
echo ""
echo "Useful commands inside the container:"
echo " node openclaw.mjs plugins list # Verify plugin is installed"
echo " node openclaw.mjs plugins info claude-mem # Plugin details"
echo " node openclaw.mjs plugins doctor # Check for issues"
echo " node /app/mock-worker.js & # Start mock worker"
echo " node openclaw.mjs gateway --allow-unconfigured --verbose # Start gateway"
echo " /bin/bash /app/e2e-verify.sh # Run automated verification"
echo ""
docker run --rm -it "$IMAGE_NAME" /bin/bash
else
docker run --rm "$IMAGE_NAME"
fi

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,106 @@
/**
* Smoke test for OpenClaw claude-mem plugin registration.
* Validates the plugin structure works independently of the full OpenClaw runtime.
*
* Run: node test-sse-consumer.js
*/
import claudeMemPlugin from "./dist/index.js";
let registeredService = null;
const registeredCommands = new Map();
const eventHandlers = new Map();
const logs = [];
const mockApi = {
id: "claude-mem",
name: "Claude-Mem (Persistent Memory)",
version: "1.0.0",
source: "/test/extensions/claude-mem/dist/index.js",
config: {},
pluginConfig: {},
logger: {
info: (message) => { logs.push(message); },
warn: (message) => { logs.push(message); },
error: (message) => { logs.push(message); },
debug: (message) => { logs.push(message); },
},
registerService: (service) => {
registeredService = service;
},
registerCommand: (command) => {
registeredCommands.set(command.name, command);
},
on: (event, callback) => {
if (!eventHandlers.has(event)) {
eventHandlers.set(event, []);
}
eventHandlers.get(event).push(callback);
},
runtime: {
channel: {
telegram: { sendMessageTelegram: async () => {} },
discord: { sendMessageDiscord: async () => {} },
signal: { sendMessageSignal: async () => {} },
slack: { sendMessageSlack: async () => {} },
whatsapp: { sendMessageWhatsApp: async () => {} },
line: { sendMessageLine: async () => {} },
},
},
};
// Call the default export with mock API
claudeMemPlugin(mockApi);
// Verify registration
let failures = 0;
if (!registeredService) {
console.error("FAIL: No service was registered");
failures++;
} else if (registeredService.id !== "claude-mem-observation-feed") {
console.error(
`FAIL: Service ID is "${registeredService.id}", expected "claude-mem-observation-feed"`
);
failures++;
} else {
console.log("OK: Service registered with id 'claude-mem-observation-feed'");
}
if (!registeredCommands.has("claude-mem-feed")) {
console.error("FAIL: No 'claude-mem-feed' command registered");
failures++;
} else {
console.log("OK: Command registered with name 'claude-mem-feed'");
}
if (!registeredCommands.has("claude-mem-status")) {
console.error("FAIL: No 'claude-mem-status' command registered");
failures++;
} else {
console.log("OK: Command registered with name 'claude-mem-status'");
}
const expectedEvents = ["before_agent_start", "tool_result_persist", "agent_end", "gateway_start"];
for (const event of expectedEvents) {
if (!eventHandlers.has(event) || eventHandlers.get(event).length === 0) {
console.error(`FAIL: No handler registered for '${event}'`);
failures++;
} else {
console.log(`OK: Event handler registered for '${event}'`);
}
}
if (!logs.some((l) => l.includes("plugin loaded"))) {
console.error("FAIL: Plugin did not log a load message");
failures++;
} else {
console.log("OK: Plugin logged load message");
}
if (failures > 0) {
console.error(`\nFAIL: ${failures} check(s) failed`);
process.exit(1);
} else {
console.log("\nPASS: Plugin registers service, commands, and event handlers correctly");
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"lib": ["ES2022", "DOM"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"allowSyntheticDefaultImports": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
]
}