diff --git a/docs/public/docs.json b/docs/public/docs.json index 28a68e84..d9503182 100644 --- a/docs/public/docs.json +++ b/docs/public/docs.json @@ -73,7 +73,8 @@ "modes", "development", "troubleshooting", - "platform-integration" + "platform-integration", + "openclaw-integration" ] }, { diff --git a/docs/public/openclaw-integration.mdx b/docs/public/openclaw-integration.mdx new file mode 100644 index 00000000..d5847f04 --- /dev/null +++ b/docs/public/openclaw-integration.mdx @@ -0,0 +1,362 @@ +--- +title: OpenClaw Integration +description: Persistent memory for OpenClaw agents — observation recording, MEMORY.md live sync, and real-time observation feeds +icon: dragon +--- + +## Overview + +The OpenClaw plugin gives claude-mem persistent memory to agents running on the [OpenClaw](https://openclaw.ai) gateway. It handles three things: + +1. **Observation recording** — Captures tool usage from OpenClaw's embedded runner and sends it to the claude-mem worker for AI processing +2. **MEMORY.md live sync** — Writes a continuously-updated timeline to each agent's workspace so agents always have context from previous sessions +3. **Observation feed** — Streams new observations to messaging channels (Telegram, Discord, Slack, etc.) in real-time via SSE + + +OpenClaw's embedded runner (`pi-embedded`) calls the Anthropic API directly without spawning a `claude` process, so claude-mem's standard hooks never fire. This plugin bridges that gap by using OpenClaw's event system to capture the same data. + + +## How It Works + +```plaintext +OpenClaw Gateway + │ + ├── before_agent_start ──→ Sync MEMORY.md + Init session + ├── tool_result_persist ──→ Record observation + Re-sync MEMORY.md + ├── agent_end ────────────→ Summarize + Complete session + └── gateway_start ────────→ Reset session tracking + │ + ▼ + 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 ──→ MEMORY.md content + └── GET /stream ─────────────→ SSE → Messaging channels +``` + +### Event Lifecycle + + + + When an OpenClaw agent starts, the plugin does two things: + + 1. **Syncs MEMORY.md** — Fetches the latest timeline from the worker's `/api/context/inject` endpoint and writes it to `MEMORY.md` in the agent's workspace directory. This gives the agent context from all previous sessions before it starts working. + + 2. **Initializes a session** — Sends the user prompt to `POST /api/sessions/init` so the worker can create a new session and start processing. + + Short prompts (under 10 characters) skip session init but still sync MEMORY.md. + + + Every time the agent uses a tool (Read, Write, Bash, etc.), the plugin: + + 1. **Sends the observation** to `POST /api/sessions/observations` with the tool name, input, and truncated response (max 1000 chars) + 2. **Re-syncs MEMORY.md** with the latest timeline from the worker + + Both operations are fire-and-forget — they don't block the agent from continuing work. The MEMORY.md file gets progressively richer as the session continues. + + Tools prefixed with `memory_` are skipped to avoid recursive recording. + + + When the agent completes, the plugin extracts the last assistant message and sends it to `POST /api/sessions/summarize`, then calls `POST /api/sessions/complete` to close the session. Both are fire-and-forget. + + + Clears all session tracking (session IDs, workspace directory mappings) so agents get fresh state after a gateway restart. + + + +### MEMORY.md Live Sync + +The plugin writes a `MEMORY.md` file to each agent's workspace directory containing the full timeline of observations and summaries from previous sessions. This file is updated: + +- On every `before_agent_start` event (agent gets fresh context before starting) +- On every `tool_result_persist` event (context stays current during the session) + +The content comes from the worker's `GET /api/context/inject?projects=` endpoint, which generates a formatted markdown timeline from the SQLite database. + + +MEMORY.md updates are fire-and-forget. They run in the background without blocking the agent. The file reflects whatever the worker has processed so far — it doesn't wait for the current observation to be fully processed before writing. + + +### Observation Feed (SSE → Messaging) + +The plugin runs a background service that connects to the worker's SSE stream (`GET /stream`) and forwards `new_observation` events to a configured messaging channel. This lets you monitor what your agents are learning in real-time from Telegram, Discord, Slack, or any supported OpenClaw channel. + +The SSE connection uses exponential backoff (1s → 30s) for automatic reconnection. + +## Setting Up the Observation Feed + +The observation feed sends a formatted message to your OpenClaw channel every time claude-mem creates a new observation. Each message includes the observation title and subtitle so you can follow along as your agents work. + +Messages look like this in your channel: + +``` +🧠 Claude-Mem Observation +**Implemented retry logic for API client** +Added exponential backoff with configurable max retries to handle transient failures +``` + +### Step 1: Choose your channel + +The observation feed works with any channel that your OpenClaw gateway has configured. You need two pieces of information: + +- **Channel type** — The name of the channel plugin registered with OpenClaw (e.g., `telegram`, `discord`, `slack`, `signal`, `whatsapp`, `line`) +- **Target ID** — The chat ID, channel ID, or user ID where messages should be sent + + + + **Channel type:** `telegram` + + **Target ID:** Your Telegram chat ID (numeric). To find it: + 1. Message [@userinfobot](https://t.me/userinfobot) on Telegram + 2. It will reply with your chat ID (e.g., `123456789`) + 3. For group chats, the ID is negative (e.g., `-1001234567890`) + + ```json + "observationFeed": { + "enabled": true, + "channel": "telegram", + "to": "123456789" + } + ``` + + + + **Channel type:** `discord` + + **Target ID:** The Discord channel ID. To find it: + 1. Enable Developer Mode in Discord (Settings → Advanced → Developer Mode) + 2. Right-click the channel → Copy Channel ID + + ```json + "observationFeed": { + "enabled": true, + "channel": "discord", + "to": "1234567890123456789" + } + ``` + + + + **Channel type:** `slack` + + **Target ID:** The Slack channel ID (not the channel name). To find it: + 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" + } + ``` + + + + **Channel type:** `signal` + + **Target ID:** The Signal phone number or group ID configured in your OpenClaw gateway. + + ```json + "observationFeed": { + "enabled": true, + "channel": "signal", + "to": "+1234567890" + } + ``` + + + + **Channel type:** `whatsapp` + + **Target ID:** The WhatsApp phone number or group JID configured in your OpenClaw gateway. + + ```json + "observationFeed": { + "enabled": true, + "channel": "whatsapp", + "to": "+1234567890" + } + ``` + + + + **Channel type:** `line` + + **Target ID:** The LINE user ID or group ID from the LINE Developer Console. + + ```json + "observationFeed": { + "enabled": true, + "channel": "line", + "to": "U1234567890abcdef" + } + ``` + + + +### Step 2: Add the config to your gateway + +Add the `observationFeed` block to your claude-mem plugin config in your OpenClaw gateway configuration: + +```json +{ + "plugins": { + "claude-mem": { + "enabled": true, + "config": { + "project": "my-project", + "observationFeed": { + "enabled": true, + "channel": "telegram", + "to": "123456789" + } + } + } + } +} +``` + + +The `channel` value must match a channel plugin that is already configured and running on your OpenClaw gateway. If the channel isn't registered, you'll see `Unknown channel type: ` in the logs. + + +### Step 3: Verify the connection + +After starting the gateway, check that the feed is connected: + +1. **Check the logs** — You should see: + ``` + [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 + ``` + +2. **Use the status command** — Run `/claude-mem-feed` in any OpenClaw chat to see: + ``` + Claude-Mem Observation Feed + Enabled: yes + Channel: telegram + Target: 123456789 + Connection: connected + ``` + +3. **Trigger a test** — Have an agent do some work. When the worker processes the tool usage into an observation, you'll receive a message in your configured channel. + + +The feed only sends `new_observation` events — not raw tool usage. Observations are generated asynchronously by the worker's AI agent, so there's a 1-2 second delay between tool use and the observation message appearing in your channel. + + +### Troubleshooting the Feed + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `Connection: disconnected` | Worker not running or wrong port | Check `workerPort` config, run `npm run worker:status` | +| `Connection: reconnecting` | Worker was running but connection dropped | The plugin auto-reconnects with backoff — wait up to 30s | +| `Unknown channel type` in logs | Channel plugin not loaded on gateway | Verify your OpenClaw gateway has the channel plugin configured | +| No messages appearing | Feed connected but no observations being created | Check that agents are running and the worker is processing observations | +| `Observation feed disabled` in logs | `enabled` is `false` or missing | Set `observationFeed.enabled` to `true` | +| `Observation feed misconfigured` in logs | Missing `channel` or `to` | Both `channel` and `to` are required | + +## Installation + +Add `claude-mem` to your OpenClaw gateway's plugin configuration: + +```json +{ + "plugins": { + "claude-mem": { + "enabled": true, + "config": { + "project": "my-project", + "syncMemoryFile": true, + "workerPort": 37777, + "observationFeed": { + "enabled": true, + "channel": "telegram", + "to": "your-chat-id" + } + } + } + } +} +``` + + +The claude-mem worker service must be running on the same machine as the OpenClaw gateway. The plugin communicates with it via HTTP on `localhost:37777`. + + +## Configuration + + + Project name for scoping observations in the memory database. All observations from this gateway will be stored under this project name. + + + + Enable automatic MEMORY.md sync to agent workspaces. Set to `false` if you don't want the plugin writing files to workspace directories. + + + + Port for the claude-mem worker service. Override if your worker runs on a non-default port. + + + + Enable live observation streaming to messaging channels. + + + + Channel type: `telegram`, `discord`, `signal`, `slack`, `whatsapp`, `line` + + + + Target chat/user/channel ID to send observations to. + + +## Commands + +### /claude-mem-feed + +Show or toggle the observation feed status. + +``` +/claude-mem-feed # Show current status +/claude-mem-feed on # Request enable +/claude-mem-feed off # Request disable +``` + +### /claude-mem-status + +Check worker health and session status. + +``` +/claude-mem-status +``` + +Returns worker status, port, active session count, and observation feed connection state. + +## Architecture + +The plugin uses HTTP calls to the already-running claude-mem worker service rather than spawning subprocesses. This means: + +- No `bun` dependency required on the gateway +- No process spawn overhead per event +- Uses the same worker API that Claude Code hooks use +- All operations are non-blocking (fire-and-forget where possible) + +### Session Tracking + +Each OpenClaw agent session gets a unique `contentSessionId` (format: `openclaw--`) that maps to a claude-mem session in the worker. The plugin tracks: + +- `sessionIds` — Maps OpenClaw session keys to content session IDs +- `workspaceDirsBySessionKey` — Maps session keys to workspace directories so `tool_result_persist` events can sync MEMORY.md even when the event context doesn't include `workspaceDir` + +Both maps are cleared on `gateway_start`. + +## Requirements + +- Claude-mem worker service running on `localhost:37777` (or configured port) +- OpenClaw gateway with plugin support +- Network access between gateway and worker (localhost only) diff --git a/openclaw/.gitignore b/openclaw/.gitignore new file mode 100644 index 00000000..b9470778 --- /dev/null +++ b/openclaw/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/openclaw/Dockerfile.e2e b/openclaw/Dockerfile.e2e new file mode 100644 index 00000000..fcc0bcbd --- /dev/null +++ b/openclaw/Dockerfile.e2e @@ -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"] diff --git a/openclaw/SKILL.md b/openclaw/SKILL.md new file mode 100644 index 00000000..b4ca107c --- /dev/null +++ b/openclaw/SKILL.md @@ -0,0 +1,418 @@ +# Claude-Mem OpenClaw Plugin — Setup Guide + +This guide walks through setting up the claude-mem plugin on an OpenClaw gateway from scratch. Follow every step in order. By the end, your agents will have persistent memory across sessions, a live-updating MEMORY.md in their workspace, and optionally a real-time observation feed streaming to a messaging channel. + +## 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 writes a `MEMORY.md` file to each agent's workspace directory. This file contains the full timeline of observations and summaries from previous sessions, and it updates on every tool use so agents always have fresh context. Set to `false` only if you don't want the plugin writing files to agent workspaces. + +- **`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, syncs MEMORY.md to the workspace +2. **`tool_result_persist`** — Records each tool use (Read, Write, Bash, etc.) as an observation, re-syncs MEMORY.md +3. **`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 agent's workspace directory for a `MEMORY.md` file after the agent runs. It should contain a formatted timeline of observations. + +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 ──→ Sync MEMORY.md + Init session + ├── tool_result_persist ──→ Record observation + Re-sync MEMORY.md + ├── agent_end ────────────→ Summarize + Complete session + └── gateway_start ────────→ Reset session tracking + │ + ▼ + 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 ──→ MEMORY.md content + └── GET /stream ─────────────→ SSE → Messaging channels +``` + +### MEMORY.md live sync + +The plugin writes `MEMORY.md` to each agent's workspace with the full observation timeline. It updates: +- On every `before_agent_start` — agent gets fresh context before starting +- On every `tool_result_persist` — context stays current as the agent works + +Updates are fire-and-forget (non-blocking). The agent is never held up waiting for MEMORY.md to write. + +### 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, syncs MEMORY.md. Short prompts (under 10 chars) skip session init but still sync. +- **`tool_result_persist`** — Records observation (fire-and-forget), re-syncs MEMORY.md (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, workspace mappings) 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 MEMORY.md appearing | Check that `syncMemoryFile` is not set to `false`. Verify the agent's event context includes `workspaceDir`. | +| 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` | Write MEMORY.md to agent workspaces | +| `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 | diff --git a/openclaw/TESTING.md b/openclaw/TESTING.md new file mode 100644 index 00000000..a3d4123e --- /dev/null +++ b/openclaw/TESTING.md @@ -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 ` 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 diff --git a/openclaw/e2e-verify.sh b/openclaw/e2e-verify.sh new file mode 100755 index 00000000..464e08e6 --- /dev/null +++ b/openclaw/e2e-verify.sh @@ -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 diff --git a/openclaw/openclaw.plugin.json b/openclaw/openclaw.plugin.json new file mode 100644 index 00000000..6ba0b5a5 --- /dev/null +++ b/openclaw/openclaw.plugin.json @@ -0,0 +1,49 @@ +{ + "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": "1.0.0", + "author": "thedotmack", + "homepage": "https://claude-mem.com", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "syncMemoryFile": { + "type": "boolean", + "default": true, + "description": "Automatically sync MEMORY.md on session start" + }, + "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" + } + } + } + } + } +} diff --git a/openclaw/package.json b/openclaw/package.json new file mode 100644 index 00000000..ad95ca25 --- /dev/null +++ b/openclaw/package.json @@ -0,0 +1,15 @@ +{ + "name": "@claude-mem/openclaw-plugin", + "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" + } +} diff --git a/openclaw/src/index.test.ts b/openclaw/src/index.test.ts new file mode 100644 index 00000000..6b3a08d5 --- /dev/null +++ b/openclaw/src/index.test.ts @@ -0,0 +1,962 @@ +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 = {}) { + const logs: string[] = []; + const sentMessages: Array<{ to: string; text: string; channel: string; opts?: any }> = []; + + let registeredService: any = null; + const registeredCommands: Map = new Map(); + const eventHandlers: Map = 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) || []; + for (const handler of handlers) { + await handler(data, ctx); + } + }, + }; +} + +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("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.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.includes("Enabled: yes")); + assert.ok(result.includes("Channel: telegram")); + assert.ok(result.includes("Target: 123")); + assert.ok(result.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.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.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.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 { + 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 does not call init", 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, 0, "before_agent_start should not init"); + }); + + 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.includes("Status: ok")); + assert.ok(result.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.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("MEMORY.md context sync", () => { + let workerServer: Server; + let workerPort: number; + let receivedRequests: Array<{ method: string; url: string; body: any }> = []; + let tmpDir: string; + let contextResponse = "# Claude-Mem Context\n\n## Timeline\n- Session 1: Did some work"; + + function startWorkerMock(): Promise { + 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(); + tmpDir = await mkdtemp(join(tmpdir(), "claude-mem-test-")); + }); + + afterEach(async () => { + workerServer?.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("writes MEMORY.md to workspace on before_agent_start", async () => { + const { api, logs, 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)); + + 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")); + + const memoryContent = await readFile(join(tmpDir, "MEMORY.md"), "utf-8"); + assert.ok(memoryContent.includes("Claude-Mem Context"), "MEMORY.md should contain context"); + assert.ok(memoryContent.includes("Session 1"), "MEMORY.md should contain timeline"); + assert.ok(logs.some((l) => l.includes("MEMORY.md synced"))); + }); + + it("syncs MEMORY.md on every before_agent_start call", async () => { + const { api, fireEvent } = createMockApi({ workerPort }); + claudeMemPlugin(api); + + await fireEvent("before_agent_start", { + prompt: "First prompt for this agent", + }, { sessionKey: "agent-a", workspaceDir: tmpDir }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + const firstContextRequests = receivedRequests.filter((r) => r.url?.startsWith("/api/context/inject")); + assert.equal(firstContextRequests.length, 1, "first call should fetch context"); + + await fireEvent("before_agent_start", { + prompt: "Second prompt for same agent", + }, { sessionKey: "agent-a", workspaceDir: tmpDir }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + const allContextRequests = receivedRequests.filter((r) => r.url?.startsWith("/api/context/inject")); + assert.equal(allContextRequests.length, 2, "should re-fetch context on every call"); + }); + + it("syncs MEMORY.md on tool_result_persist via fire-and-forget", async () => { + const { api, fireEvent } = createMockApi({ workerPort }); + claudeMemPlugin(api); + + // Init session to register workspace dir + await fireEvent("before_agent_start", { + prompt: "Help me write a function", + }, { sessionKey: "tool-sync", workspaceDir: tmpDir }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + const preToolContextRequests = receivedRequests.filter((r) => r.url?.startsWith("/api/context/inject")); + assert.equal(preToolContextRequests.length, 1, "before_agent_start should sync once"); + + // Fire tool result — should trigger another MEMORY.md sync + 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 postToolContextRequests = receivedRequests.filter((r) => r.url?.startsWith("/api/context/inject")); + assert.equal(postToolContextRequests.length, 2, "tool_result_persist should trigger another sync"); + + const memoryContent = await readFile(join(tmpDir, "MEMORY.md"), "utf-8"); + assert.ok(memoryContent.includes("Claude-Mem Context"), "MEMORY.md should be updated"); + }); + + it("skips MEMORY.md sync when syncMemoryFile is false", async () => { + const { api, fireEvent } = createMockApi({ workerPort, syncMemoryFile: false }); + claudeMemPlugin(api); + + await fireEvent("before_agent_start", { + prompt: "Help me write a function", + }, { sessionKey: "no-sync", workspaceDir: tmpDir }); + + 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 sync disabled"); + }); + + it("skips MEMORY.md sync when no workspaceDir in context", async () => { + const { api, fireEvent } = createMockApi({ workerPort }); + claudeMemPlugin(api); + + await fireEvent("before_agent_start", { + prompt: "Help me write a function", + }, { sessionKey: "no-workspace" }); + + 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 without workspaceDir"); + }); + + it("skips writing MEMORY.md when context is empty", async () => { + contextResponse = " "; + const { api, logs, fireEvent } = createMockApi({ workerPort }); + claudeMemPlugin(api); + + await fireEvent("before_agent_start", { + prompt: "Help me write a function", + }, { sessionKey: "empty-ctx", workspaceDir: tmpDir }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + assert.ok(!logs.some((l) => l.includes("MEMORY.md synced")), "should not log sync for empty context"); + }); + + it("gateway_start resets sync tracking so next agent re-syncs", async () => { + const { api, fireEvent } = createMockApi({ workerPort }); + claudeMemPlugin(api); + + // First sync + await fireEvent("before_agent_start", { + prompt: "Help me write a function", + }, { sessionKey: "agent-1", workspaceDir: tmpDir }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + const firstContextRequests = receivedRequests.filter((r) => r.url?.startsWith("/api/context/inject")); + assert.equal(firstContextRequests.length, 1); + + // Gateway restart + await fireEvent("gateway_start", {}, {}); + + // Second sync after gateway restart — same workspace should re-sync + await fireEvent("before_agent_start", { + prompt: "Help me after gateway restart", + }, { sessionKey: "agent-1", workspaceDir: tmpDir }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + const allContextRequests = receivedRequests.filter((r) => r.url?.startsWith("/api/context/inject")); + assert.equal(allContextRequests.length, 2, "should re-fetch context after gateway restart"); + }); + + it("uses custom project name in context inject URL", async () => { + const { api, fireEvent } = createMockApi({ workerPort, project: "my-bot" }); + claudeMemPlugin(api); + + await fireEvent("before_agent_start", { + prompt: "Help me write a function", + }, { sessionKey: "proj-test", workspaceDir: tmpDir }); + + 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"); + }); +}); + +describe("SSE stream integration", () => { + let server: Server; + let serverPort: number; + let serverResponses: ServerResponse[] = []; + + function startSSEServer(): Promise { + 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({}); + }); +}); diff --git a/openclaw/src/index.ts b/openclaw/src/index.ts new file mode 100644 index 00000000..f750023d --- /dev/null +++ b/openclaw/src/index.ts @@ -0,0 +1,678 @@ +import { writeFile } from "fs/promises"; +import { join } from "path"; + +// Minimal type declarations for the OpenClaw Plugin SDK. +// These match the real OpenClawPluginApi provided by the gateway at runtime. +// See: https://docs.openclaw.ai/plugin + +interface PluginLogger { + debug?: (message: string) => void; + info: (message: string) => void; + warn: (message: string) => void; + error: (message: string) => void; +} + +interface PluginServiceContext { + config: Record; + workspaceDir?: string; + stateDir: string; + logger: PluginLogger; +} + +interface PluginCommandContext { + senderId?: string; + channel: string; + isAuthorizedSender: boolean; + args?: string; + commandBody: string; + config: Record; +} + +type PluginCommandResult = string | { text: string } | { text: string; format?: string }; + +// OpenClaw event types for agent lifecycle +interface BeforeAgentStartEvent { + prompt?: string; +} + +interface ToolResultPersistEvent { + toolName?: string; + params?: Record; + message?: { + content?: Array<{ type: string; text?: string }>; + }; +} + +interface AgentEndEvent { + messages?: Array<{ + role: string; + content: string | Array<{ type: string; text?: string }>; + }>; +} + +interface SessionStartEvent { + sessionId: string; + resumedFrom?: string; +} + +interface AfterCompactionEvent { + messageCount: number; + tokenCount?: number; + compactedCount: number; +} + +interface SessionEndEvent { + sessionId: string; + messageCount: number; + durationMs?: number; +} + +interface EventContext { + sessionKey?: string; + workspaceDir?: string; + agentId?: string; +} + +type EventCallback = (event: T, ctx: EventContext) => void | Promise; + +interface OpenClawPluginApi { + id: string; + name: string; + version?: string; + source: string; + config: Record; + pluginConfig?: Record; + logger: PluginLogger; + registerService: (service: { + id: string; + start: (ctx: PluginServiceContext) => void | Promise; + stop?: (ctx: PluginServiceContext) => void | Promise; + }) => void; + registerCommand: (command: { + name: string; + description: string; + acceptsArgs?: boolean; + requireAuth?: boolean; + handler: (ctx: PluginCommandContext) => PluginCommandResult | Promise; + }) => void; + on: ((event: "before_agent_start", callback: EventCallback) => void) & + ((event: "tool_result_persist", callback: EventCallback) => void) & + ((event: "agent_end", callback: EventCallback) => void) & + ((event: "session_start", callback: EventCallback) => void) & + ((event: "session_end", callback: EventCallback) => void) & + ((event: "after_compaction", callback: EventCallback) => void) & + ((event: "gateway_start", callback: EventCallback>) => void); + runtime: { + channel: Record Promise>>; + }; +} + +// ============================================================================ +// SSE Observation Feed Types +// ============================================================================ + +interface ObservationSSEPayload { + id: number; + memory_session_id: string; + session_id: string; + type: string; + title: string | null; + subtitle: string | null; + text: string | null; + narrative: string | null; + facts: string | null; + concepts: string | null; + files_read: string | null; + files_modified: string | null; + project: string; + prompt_number: number; + created_at_epoch: number; +} + +interface SSENewObservationEvent { + type: "new_observation"; + observation: ObservationSSEPayload; + timestamp: number; +} + +type ConnectionState = "disconnected" | "connected" | "reconnecting"; + +// ============================================================================ +// Plugin Configuration +// ============================================================================ + +interface ClaudeMemPluginConfig { + syncMemoryFile?: boolean; + project?: string; + workerPort?: number; + observationFeed?: { + enabled?: boolean; + channel?: string; + to?: string; + }; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const MAX_SSE_BUFFER_SIZE = 1024 * 1024; // 1MB +const DEFAULT_WORKER_PORT = 37777; +const TOOL_RESULT_MAX_LENGTH = 1000; + +// ============================================================================ +// Worker HTTP Client +// ============================================================================ + +function workerBaseUrl(port: number): string { + return `http://127.0.0.1:${port}`; +} + +async function workerPost( + port: number, + path: string, + body: Record, + logger: PluginLogger +): Promise | null> { + try { + const response = await fetch(`${workerBaseUrl(port)}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) { + logger.warn(`[claude-mem] Worker POST ${path} returned ${response.status}`); + return null; + } + return (await response.json()) as Record; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`[claude-mem] Worker POST ${path} failed: ${message}`); + return null; + } +} + +function workerPostFireAndForget( + port: number, + path: string, + body: Record, + logger: PluginLogger +): void { + fetch(`${workerBaseUrl(port)}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`[claude-mem] Worker POST ${path} failed: ${message}`); + }); +} + +async function workerGetText( + port: number, + path: string, + logger: PluginLogger +): Promise { + try { + const response = await fetch(`${workerBaseUrl(port)}${path}`); + if (!response.ok) { + logger.warn(`[claude-mem] Worker GET ${path} returned ${response.status}`); + return null; + } + return await response.text(); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`[claude-mem] Worker GET ${path} failed: ${message}`); + return null; + } +} + +// ============================================================================ +// SSE Observation Feed +// ============================================================================ + +function formatObservationMessage(observation: ObservationSSEPayload): string { + const title = observation.title || "Untitled"; + let message = `🧠 Claude-Mem Observation\n**${title}**`; + if (observation.subtitle) { + message += `\n${observation.subtitle}`; + } + return message; +} + +// Explicit mapping from channel name to [runtime namespace key, send function name]. +// These match the PluginRuntime.channel structure in the OpenClaw SDK. +const CHANNEL_SEND_MAP: Record = { + telegram: { namespace: "telegram", functionName: "sendMessageTelegram" }, + whatsapp: { namespace: "whatsapp", functionName: "sendMessageWhatsApp" }, + discord: { namespace: "discord", functionName: "sendMessageDiscord" }, + slack: { namespace: "slack", functionName: "sendMessageSlack" }, + signal: { namespace: "signal", functionName: "sendMessageSignal" }, + imessage: { namespace: "imessage", functionName: "sendMessageIMessage" }, + line: { namespace: "line", functionName: "sendMessageLine" }, +}; + +function sendToChannel( + api: OpenClawPluginApi, + channel: string, + to: string, + text: string +): Promise { + const mapping = CHANNEL_SEND_MAP[channel]; + if (!mapping) { + api.logger.warn(`[claude-mem] Unsupported channel type: ${channel}`); + return Promise.resolve(); + } + + const channelApi = api.runtime.channel[mapping.namespace]; + if (!channelApi) { + api.logger.warn(`[claude-mem] Channel "${channel}" not available in runtime`); + return Promise.resolve(); + } + + const senderFunction = channelApi[mapping.functionName]; + if (!senderFunction) { + api.logger.warn(`[claude-mem] Channel "${channel}" has no ${mapping.functionName} function`); + return Promise.resolve(); + } + + // WhatsApp requires a third options argument with { verbose: boolean } + const args: unknown[] = channel === "whatsapp" + ? [to, text, { verbose: false }] + : [to, text]; + + return senderFunction(...args).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + api.logger.error(`[claude-mem] Failed to send to ${channel}: ${message}`); + }); +} + +async function connectToSSEStream( + api: OpenClawPluginApi, + port: number, + channel: string, + to: string, + abortController: AbortController, + setConnectionState: (state: ConnectionState) => void +): Promise { + let backoffMs = 1000; + const maxBackoffMs = 30000; + + while (!abortController.signal.aborted) { + try { + setConnectionState("reconnecting"); + api.logger.info(`[claude-mem] Connecting to SSE stream at ${workerBaseUrl(port)}/stream`); + + const response = await fetch(`${workerBaseUrl(port)}/stream`, { + signal: abortController.signal, + headers: { Accept: "text/event-stream" }, + }); + + if (!response.ok) { + throw new Error(`SSE stream returned HTTP ${response.status}`); + } + + if (!response.body) { + throw new Error("SSE stream response has no body"); + } + + setConnectionState("connected"); + backoffMs = 1000; + api.logger.info("[claude-mem] Connected to SSE stream"); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + if (buffer.length > MAX_SSE_BUFFER_SIZE) { + api.logger.warn("[claude-mem] SSE buffer overflow, clearing buffer"); + buffer = ""; + } + + const frames = buffer.split("\n\n"); + buffer = frames.pop() || ""; + + for (const frame of frames) { + // SSE spec: concatenate all data: lines with \n + const dataLines = frame + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trim()); + if (dataLines.length === 0) continue; + + const jsonStr = dataLines.join("\n"); + if (!jsonStr) continue; + + try { + const parsed = JSON.parse(jsonStr); + if (parsed.type === "new_observation" && parsed.observation) { + const event = parsed as SSENewObservationEvent; + const message = formatObservationMessage(event.observation); + await sendToChannel(api, channel, to, message); + } + } catch (parseError: unknown) { + const errorMessage = parseError instanceof Error ? parseError.message : String(parseError); + api.logger.warn(`[claude-mem] Failed to parse SSE frame: ${errorMessage}`); + } + } + } + } catch (error: unknown) { + if (abortController.signal.aborted) { + break; + } + setConnectionState("reconnecting"); + const errorMessage = error instanceof Error ? error.message : String(error); + api.logger.warn(`[claude-mem] SSE stream error: ${errorMessage}. Reconnecting in ${backoffMs / 1000}s`); + } + + if (abortController.signal.aborted) break; + + await new Promise((resolve) => setTimeout(resolve, backoffMs)); + backoffMs = Math.min(backoffMs * 2, maxBackoffMs); + } + + setConnectionState("disconnected"); +} + +// ============================================================================ +// Plugin Entry Point +// ============================================================================ + +export default function claudeMemPlugin(api: OpenClawPluginApi): void { + const userConfig = (api.pluginConfig || {}) as ClaudeMemPluginConfig; + const workerPort = userConfig.workerPort || DEFAULT_WORKER_PORT; + const projectName = userConfig.project || "openclaw"; + + // ------------------------------------------------------------------ + // Session tracking for observation I/O + // ------------------------------------------------------------------ + const sessionIds = new Map(); + const workspaceDirsBySessionKey = new Map(); + const syncMemoryFile = userConfig.syncMemoryFile !== false; // default true + + function getContentSessionId(sessionKey?: string): string { + const key = sessionKey || "default"; + if (!sessionIds.has(key)) { + sessionIds.set(key, `openclaw-${key}-${Date.now()}`); + } + return sessionIds.get(key)!; + } + + async function syncMemoryToWorkspace(workspaceDir: string): Promise { + const contextText = await workerGetText( + workerPort, + `/api/context/inject?projects=${encodeURIComponent(projectName)}`, + api.logger + ); + if (contextText && contextText.trim().length > 0) { + try { + await writeFile(join(workspaceDir, "MEMORY.md"), contextText, "utf-8"); + api.logger.info(`[claude-mem] MEMORY.md synced to ${workspaceDir}`); + } catch (writeError: unknown) { + const msg = writeError instanceof Error ? writeError.message : String(writeError); + api.logger.warn(`[claude-mem] Failed to write MEMORY.md: ${msg}`); + } + } + } + + // ------------------------------------------------------------------ + // Event: session_start — init claude-mem session (fires on /new, /reset) + // ------------------------------------------------------------------ + api.on("session_start", async (_event, ctx) => { + const contentSessionId = getContentSessionId(ctx.sessionKey); + + await workerPost(workerPort, "/api/sessions/init", { + contentSessionId, + project: projectName, + prompt: "", + }, api.logger); + + api.logger.info(`[claude-mem] Session initialized: ${contentSessionId}`); + }); + + // ------------------------------------------------------------------ + // Event: after_compaction — re-init session after context compaction + // ------------------------------------------------------------------ + api.on("after_compaction", async (_event, ctx) => { + const contentSessionId = getContentSessionId(ctx.sessionKey); + + await workerPost(workerPort, "/api/sessions/init", { + contentSessionId, + project: projectName, + prompt: "", + }, api.logger); + + api.logger.info(`[claude-mem] Session re-initialized after compaction: ${contentSessionId}`); + }); + + // ------------------------------------------------------------------ + // Event: before_agent_start — sync MEMORY.md + track workspace + // ------------------------------------------------------------------ + api.on("before_agent_start", async (_event, ctx) => { + // Track workspace dir so tool_result_persist can sync MEMORY.md later + if (ctx.workspaceDir) { + workspaceDirsBySessionKey.set(ctx.sessionKey || "default", ctx.workspaceDir); + } + + // Sync MEMORY.md before agent runs (provides context to agent) + if (syncMemoryFile && ctx.workspaceDir) { + await syncMemoryToWorkspace(ctx.workspaceDir); + } + }); + + // ------------------------------------------------------------------ + // Event: tool_result_persist — record tool observations + sync MEMORY.md + // ------------------------------------------------------------------ + api.on("tool_result_persist", (event, ctx) => { + const toolName = event.toolName; + if (!toolName || toolName.startsWith("memory_")) return; + + const contentSessionId = getContentSessionId(ctx.sessionKey); + + // Extract result text from message content + let toolResponseText = ""; + const content = event.message?.content; + if (Array.isArray(content)) { + const textBlock = content.find( + (block) => block.type === "tool_result" || block.type === "text" + ); + if (textBlock && "text" in textBlock) { + toolResponseText = String(textBlock.text).slice(0, TOOL_RESULT_MAX_LENGTH); + } + } + + // Fire-and-forget: send observation + sync MEMORY.md in parallel + workerPostFireAndForget(workerPort, "/api/sessions/observations", { + contentSessionId, + tool_name: toolName, + tool_input: event.params || {}, + tool_response: toolResponseText, + cwd: "", + }, api.logger); + + const workspaceDir = ctx.workspaceDir || workspaceDirsBySessionKey.get(ctx.sessionKey || "default"); + if (syncMemoryFile && workspaceDir) { + syncMemoryToWorkspace(workspaceDir); + } + }); + + // ------------------------------------------------------------------ + // Event: agent_end — summarize and complete session + // ------------------------------------------------------------------ + api.on("agent_end", async (event, ctx) => { + const contentSessionId = getContentSessionId(ctx.sessionKey); + + // Extract last assistant message for summarization + let lastAssistantMessage = ""; + if (Array.isArray(event.messages)) { + for (let i = event.messages.length - 1; i >= 0; i--) { + const message = event.messages[i]; + if (message?.role === "assistant") { + if (typeof message.content === "string") { + lastAssistantMessage = message.content; + } else if (Array.isArray(message.content)) { + lastAssistantMessage = message.content + .filter((block) => block.type === "text") + .map((block) => block.text || "") + .join("\n"); + } + break; + } + } + } + + workerPostFireAndForget(workerPort, "/api/sessions/summarize", { + contentSessionId, + last_assistant_message: lastAssistantMessage, + }, api.logger); + + workerPostFireAndForget(workerPort, "/api/sessions/complete", { + contentSessionId, + }, api.logger); + }); + + // ------------------------------------------------------------------ + // Event: session_end — clean up session tracking to prevent unbounded growth + // ------------------------------------------------------------------ + api.on("session_end", async (_event, ctx) => { + const key = ctx.sessionKey || "default"; + sessionIds.delete(key); + workspaceDirsBySessionKey.delete(key); + }); + + // ------------------------------------------------------------------ + // Event: gateway_start — clear session tracking for fresh start + // ------------------------------------------------------------------ + api.on("gateway_start", async () => { + workspaceDirsBySessionKey.clear(); + sessionIds.clear(); + api.logger.info("[claude-mem] Gateway started — session tracking reset"); + }); + + // ------------------------------------------------------------------ + // Service: SSE observation feed → messaging channels + // ------------------------------------------------------------------ + let sseAbortController: AbortController | null = null; + let connectionState: ConnectionState = "disconnected"; + let connectionPromise: Promise | null = null; + + api.registerService({ + id: "claude-mem-observation-feed", + start: async (_ctx) => { + if (sseAbortController) { + sseAbortController.abort(); + if (connectionPromise) { + await connectionPromise; + connectionPromise = null; + } + } + + const feedConfig = userConfig.observationFeed; + + if (!feedConfig?.enabled) { + api.logger.info("[claude-mem] Observation feed disabled"); + return; + } + + if (!feedConfig.channel || !feedConfig.to) { + api.logger.warn("[claude-mem] Observation feed misconfigured — channel or target missing"); + return; + } + + api.logger.info(`[claude-mem] Observation feed starting — channel: ${feedConfig.channel}, target: ${feedConfig.to}`); + + sseAbortController = new AbortController(); + connectionPromise = connectToSSEStream( + api, + workerPort, + feedConfig.channel, + feedConfig.to, + sseAbortController, + (state) => { connectionState = state; } + ); + }, + stop: async (_ctx) => { + if (sseAbortController) { + sseAbortController.abort(); + sseAbortController = null; + } + if (connectionPromise) { + await connectionPromise; + connectionPromise = null; + } + connectionState = "disconnected"; + api.logger.info("[claude-mem] Observation feed stopped — SSE connection closed"); + }, + }); + + // ------------------------------------------------------------------ + // Command: /claude-mem-feed — status & toggle + // ------------------------------------------------------------------ + api.registerCommand({ + name: "claude-mem-feed", + description: "Show or toggle Claude-Mem observation feed status", + acceptsArgs: true, + handler: async (ctx) => { + const feedConfig = userConfig.observationFeed; + + if (!feedConfig) { + return "Observation feed not configured. Add observationFeed to your plugin config."; + } + + const arg = ctx.args?.trim(); + + if (arg === "on") { + api.logger.info("[claude-mem] Feed enable requested via command"); + return "Feed enable requested. Update observationFeed.enabled in your plugin config to persist."; + } + + if (arg === "off") { + api.logger.info("[claude-mem] Feed disable requested via command"); + return "Feed disable requested. Update observationFeed.enabled in your plugin config to persist."; + } + + return [ + "Claude-Mem Observation Feed", + `Enabled: ${feedConfig.enabled ? "yes" : "no"}`, + `Channel: ${feedConfig.channel || "not set"}`, + `Target: ${feedConfig.to || "not set"}`, + `Connection: ${connectionState}`, + ].join("\n"); + }, + }); + + // ------------------------------------------------------------------ + // Command: /claude-mem-status — worker health check + // ------------------------------------------------------------------ + api.registerCommand({ + name: "claude-mem-status", + description: "Check Claude-Mem worker health and session status", + handler: async () => { + const healthText = await workerGetText(workerPort, "/api/health", api.logger); + if (!healthText) { + return `Claude-Mem worker unreachable at port ${workerPort}`; + } + + try { + const health = JSON.parse(healthText); + return [ + "Claude-Mem Worker Status", + `Status: ${health.status || "unknown"}`, + `Port: ${workerPort}`, + `Active sessions: ${sessionIds.size}`, + `Observation feed: ${connectionState}`, + ].join("\n"); + } catch { + return `Claude-Mem worker responded but returned unexpected data`; + } + }, + }); + + api.logger.info(`[claude-mem] OpenClaw plugin loaded — v1.0.0 (worker: 127.0.0.1:${workerPort})`); +} diff --git a/openclaw/test-e2e.sh b/openclaw/test-e2e.sh new file mode 100755 index 00000000..8af7291d --- /dev/null +++ b/openclaw/test-e2e.sh @@ -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 diff --git a/openclaw/test-sse-consumer.js b/openclaw/test-sse-consumer.js new file mode 100644 index 00000000..eb1e88ec --- /dev/null +++ b/openclaw/test-sse-consumer.js @@ -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"); +} diff --git a/openclaw/tsconfig.json b/openclaw/tsconfig.json new file mode 100644 index 00000000..ac91678f --- /dev/null +++ b/openclaw/tsconfig.json @@ -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" + ] +}