Merge pull request #1076 from thedotmack/openclaw-installer

Add OpenClaw one-liner installer script with comprehensive test suite
This commit is contained in:
Alex Newman
2026-02-12 22:22:25 -05:00
committed by GitHub
8 changed files with 4501 additions and 301 deletions
+48 -8
View File
@@ -1,8 +1,46 @@
# 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.
This guide walks through setting up the claude-mem plugin on an OpenClaw gateway. By the end, your agents will have persistent memory across sessions, 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
## Quick Install (Recommended)
Run this one-liner to install everything automatically:
```bash
curl -fsSL https://raw.githubusercontent.com/thedotmack/claude-mem/main/openclaw/install.sh | bash
```
The installer handles dependency checks (Bun, uv), plugin installation, memory slot configuration, AI provider setup, worker startup, and optional observation feed configuration — all interactively.
### Install with options
Pre-select your AI provider and API key to skip interactive prompts:
```bash
curl -fsSL https://raw.githubusercontent.com/thedotmack/claude-mem/main/openclaw/install.sh | bash -s -- --provider=gemini --api-key=YOUR_KEY
```
For fully unattended installation (defaults to Claude Max Plan, skips observation feed):
```bash
curl -fsSL https://raw.githubusercontent.com/thedotmack/claude-mem/main/openclaw/install.sh | bash -s -- --non-interactive
```
To upgrade an existing installation (preserves settings, updates plugin):
```bash
curl -fsSL https://raw.githubusercontent.com/thedotmack/claude-mem/main/openclaw/install.sh | bash -s -- --upgrade
```
After installation, skip to [Step 4: Restart the Gateway and Verify](#step-4-restart-the-gateway-and-verify) to confirm everything is working.
---
## Manual Setup
The steps below are for manual installation if you prefer not to use the automated installer, or need to troubleshoot individual steps.
### Step 1: Clone the Claude-Mem Repo
First, clone the claude-mem repository to a location accessible by your OpenClaw gateway. This gives you the worker service source and the plugin code.
@@ -20,11 +58,11 @@ You'll need **bun** installed for the worker service. If you don't have it:
curl -fsSL https://bun.sh/install | bash
```
## Step 2: Get the Worker Running
### 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
#### Check if it's already running
If this machine also runs Claude Code with claude-mem installed, the worker may already be running:
@@ -36,7 +74,7 @@ curl http://localhost:37777/api/health
**Got connection refused or no response?** The worker isn't running. Continue below.
### If Claude Code has claude-mem installed
#### 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:
@@ -54,7 +92,7 @@ curl http://localhost:37777/api/health
**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
#### If there's no Claude Code installation
Run the worker from the cloned repo:
@@ -77,7 +115,7 @@ curl http://localhost:37777/api/health
- 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
### Step 3: Add the Plugin to Your Gateway
Add the `claude-mem` plugin to your OpenClaw gateway configuration:
@@ -96,7 +134,7 @@ Add the `claude-mem` plugin to your OpenClaw gateway configuration:
}
```
### Config fields explained
#### 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"`.
@@ -104,6 +142,8 @@ Add the `claude-mem` plugin to your OpenClaw gateway configuration:
- **`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:
+1732
View File
File diff suppressed because it is too large Load Diff
+41 -13
View File
@@ -1,5 +1,5 @@
import { writeFile } from "fs/promises";
import { join } from "path";
import { basename, join } from "path";
// Minimal type declarations for the OpenClaw Plugin SDK.
// These match the real OpenClawPluginApi provided by the gateway at runtime.
@@ -67,13 +67,27 @@ interface SessionEndEvent {
durationMs?: number;
}
interface MessageReceivedEvent {
from: string;
content: string;
timestamp?: number;
metadata?: Record<string, unknown>;
}
interface EventContext {
sessionKey?: string;
workspaceDir?: string;
agentId?: string;
}
interface MessageContext {
channelId: string;
accountId?: string;
conversationId?: string;
}
type EventCallback<T> = (event: T, ctx: EventContext) => void | Promise<void>;
type MessageEventCallback<T> = (event: T, ctx: MessageContext) => void | Promise<void>;
interface OpenClawPluginApi {
id: string;
@@ -100,6 +114,7 @@ interface OpenClawPluginApi {
((event: "agent_end", callback: EventCallback<AgentEndEvent>) => void) &
((event: "session_start", callback: EventCallback<SessionStartEvent>) => void) &
((event: "session_end", callback: EventCallback<SessionEndEvent>) => void) &
((event: "message_received", callback: MessageEventCallback<MessageReceivedEvent>) => void) &
((event: "after_compaction", callback: EventCallback<AfterCompactionEvent>) => void) &
((event: "gateway_start", callback: EventCallback<Record<string, never>>) => void);
runtime: {
@@ -158,7 +173,6 @@ interface ClaudeMemPluginConfig {
const MAX_SSE_BUFFER_SIZE = 1024 * 1024; // 1MB
const DEFAULT_WORKER_PORT = 37777;
const TOOL_RESULT_MAX_LENGTH = 1000;
// Agent emoji map for observation feed messages.
// When creating a new OpenClaw agent, add its agentId and emoji here.
@@ -451,9 +465,11 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
}
async function syncMemoryToWorkspace(workspaceDir: string): Promise<void> {
// Derive project name from workspace directory (matches Claude Code's getProjectName logic)
const workspaceProject = basename(workspaceDir) || baseProjectName;
const contextText = await workerGetText(
workerPort,
`/api/context/inject?projects=${encodeURIComponent(baseProjectName)}`,
`/api/context/inject?projects=${encodeURIComponent(workspaceProject)}`,
api.logger
);
if (contextText && contextText.trim().length > 0) {
@@ -482,6 +498,20 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
api.logger.info(`[claude-mem] Session initialized: ${contentSessionId}`);
});
// ------------------------------------------------------------------
// Event: message_received — capture inbound user prompts from channels
// ------------------------------------------------------------------
api.on("message_received", async (event, ctx) => {
const sessionKey = ctx.conversationId || ctx.channelId || "default";
const contentSessionId = getContentSessionId(sessionKey);
await workerPost(workerPort, "/api/sessions/init", {
contentSessionId,
project: baseProjectName,
prompt: event.content || "[media prompt]",
}, api.logger);
});
// ------------------------------------------------------------------
// Event: after_compaction — re-init session after context compaction
// ------------------------------------------------------------------
@@ -500,7 +530,7 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
// ------------------------------------------------------------------
// Event: before_agent_start — init session + sync MEMORY.md + track workspace
// ------------------------------------------------------------------
api.on("before_agent_start", async (_event, ctx) => {
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);
@@ -512,7 +542,7 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
await workerPost(workerPort, "/api/sessions/init", {
contentSessionId,
project: getProjectName(ctx),
prompt: ctx.sessionKey || "agent run",
prompt: event.prompt || "agent run",
}, api.logger);
// Sync MEMORY.md before agent runs (provides context to agent)
@@ -527,20 +557,18 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
api.on("tool_result_persist", (event, ctx) => {
api.logger.info(`[claude-mem] tool_result_persist fired: tool=${event.toolName ?? "unknown"} agent=${ctx.agentId ?? "none"} session=${ctx.sessionKey ?? "none"}`);
const toolName = event.toolName;
if (!toolName || toolName.startsWith("memory_")) return;
if (!toolName) return;
const contentSessionId = getContentSessionId(ctx.sessionKey);
// Extract result text from message content
// Extract result text from all content blocks
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);
}
toolResponseText = content
.filter((block) => (block.type === "tool_result" || block.type === "text") && "text" in block)
.map((block) => String(block.text))
.join("\n");
}
// Fire-and-forget: send observation + sync MEMORY.md in parallel
+2339
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+21 -4
View File
@@ -30,6 +30,19 @@ export interface RouteHandler {
setupRoutes(app: Application): void;
}
/**
* AI provider status for health endpoint
*/
export interface AiStatus {
provider: string;
authMethod: string;
lastInteraction: {
timestamp: number;
success: boolean;
error?: string;
} | null;
}
/**
* Options for initializing the server
*/
@@ -42,6 +55,10 @@ export interface ServerOptions {
onShutdown: () => Promise<void>;
/** Restart function for admin endpoints */
onRestart: () => Promise<void>;
/** Filesystem path to the worker entry point */
workerPath: string;
/** Callback to get current AI provider status */
getAiStatus: () => AiStatus;
}
/**
@@ -140,20 +157,20 @@ export class Server {
* Setup core system routes (health, readiness, version, admin)
*/
private setupCoreRoutes(): void {
// Test build ID for debugging which build is running
const TEST_BUILD_ID = 'TEST-008-wrapper-ipc';
// Health check endpoint - always responds, even during initialization
this.app.get('/api/health', (_req: Request, res: Response) => {
res.status(200).json({
status: 'ok',
build: TEST_BUILD_ID,
version: BUILT_IN_VERSION,
workerPath: this.options.workerPath,
uptime: Date.now() - this.startTime,
managed: process.env.CLAUDE_MEM_MANAGED === 'true',
hasIpc: typeof process.send === 'function',
platform: process.platform,
pid: process.pid,
initialized: this.options.getInitializationComplete(),
mcpReady: this.options.getMcpReady(),
ai: this.options.getAiStatus(),
});
});
+50 -1
View File
@@ -16,6 +16,7 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { getWorkerPort, getWorkerHost } from '../shared/worker-utils.js';
import { HOOK_TIMEOUTS } from '../shared/hook-constants.js';
import { SettingsDefaultsManager } from '../shared/SettingsDefaultsManager.js';
import { getAuthMethodDescription } from '../shared/EnvManager.js';
import { logger } from '../utils/logger.js';
// Windows: avoid repeated spawn popups when startup fails (issue #921)
@@ -170,6 +171,14 @@ export class WorkerService {
// Orphan reaper cleanup function (Issue #737)
private stopOrphanReaper: (() => void) | null = null;
// AI interaction tracking for health endpoint
private lastAiInteraction: {
timestamp: number;
success: boolean;
provider: string;
error?: string;
} | null = null;
constructor() {
// Initialize the promise that will resolve when background initialization completes
this.initializationComplete = new Promise((resolve) => {
@@ -206,7 +215,24 @@ export class WorkerService {
getInitializationComplete: () => this.initializationCompleteFlag,
getMcpReady: () => this.mcpReady,
onShutdown: () => this.shutdown(),
onRestart: () => this.shutdown()
onRestart: () => this.shutdown(),
workerPath: __filename,
getAiStatus: () => {
let provider = 'claude';
if (isOpenRouterSelected() && isOpenRouterAvailable()) provider = 'openrouter';
else if (isGeminiSelected() && isGeminiAvailable()) provider = 'gemini';
return {
provider,
authMethod: getAuthMethodDescription(),
lastInteraction: this.lastAiInteraction
? {
timestamp: this.lastAiInteraction.timestamp,
success: this.lastAiInteraction.success,
...(this.lastAiInteraction.error && { error: this.lastAiInteraction.error }),
}
: null,
};
},
});
// Register route handlers
@@ -459,6 +485,7 @@ export class WorkerService {
// Track whether generator failed with an unrecoverable error to prevent infinite restart loops
let hadUnrecoverableError = false;
let sessionFailed = false;
logger.info('SYSTEM', `Starting generator (${source}) using ${providerName}`, { sessionId: sid });
@@ -476,6 +503,12 @@ export class WorkerService {
];
if (unrecoverablePatterns.some(pattern => errorMessage.includes(pattern))) {
hadUnrecoverableError = true;
this.lastAiInteraction = {
timestamp: Date.now(),
success: false,
provider: providerName,
error: errorMessage,
};
logger.error('SDK', 'Unrecoverable generator error - will NOT restart', {
sessionId: session.sessionDbId,
project: session.project,
@@ -512,11 +545,27 @@ export class WorkerService {
project: session.project,
provider: providerName
}, error as Error);
sessionFailed = true;
this.lastAiInteraction = {
timestamp: Date.now(),
success: false,
provider: providerName,
error: errorMessage,
};
throw error;
})
.finally(() => {
session.generatorPromise = null;
// Record successful AI interaction if no error occurred
if (!sessionFailed && !hadUnrecoverableError) {
this.lastAiInteraction = {
timestamp: Date.now(),
success: true,
provider: providerName,
};
}
// Do NOT restart after unrecoverable errors - prevents infinite loops
if (hadUnrecoverableError) {
logger.warn('SYSTEM', 'Skipping restart due to unrecoverable error', {