#!/usr/bin/env bash set -euo pipefail # claude-mem OpenClaw Plugin Installer # Installs the claude-mem persistent memory plugin for OpenClaw gateways. # # Usage: # curl -fsSL https://install.cmem.ai/openclaw.sh | bash # # Or with options: # curl -fsSL https://install.cmem.ai/openclaw.sh | bash -s -- --provider=gemini --api-key=YOUR_KEY # # Direct execution: # bash install.sh [--non-interactive] [--upgrade] [--provider=claude|gemini|openrouter] [--api-key=KEY] ############################################################################### # Constants ############################################################################### readonly MIN_BUN_VERSION="1.1.14" readonly INSTALLER_VERSION="1.0.0" ############################################################################### # Argument parsing ############################################################################### NON_INTERACTIVE="" CLI_PROVIDER="" CLI_API_KEY="" UPGRADE_MODE="" CLI_BRANCH="" while [[ $# -gt 0 ]]; do case "$1" in --non-interactive) NON_INTERACTIVE="true" shift ;; --upgrade) UPGRADE_MODE="true" shift ;; --branch=*) CLI_BRANCH="${1#--branch=}" shift ;; --branch) CLI_BRANCH="${2:-}" shift 2 ;; --provider=*) CLI_PROVIDER="${1#--provider=}" shift ;; --provider) CLI_PROVIDER="${2:-}" shift 2 ;; --api-key=*) CLI_API_KEY="${1#--api-key=}" shift ;; --api-key) CLI_API_KEY="${2:-}" shift 2 ;; *) shift ;; esac done ############################################################################### # TTY detection — ensure interactive prompts work under curl | bash # When piped, stdin reads from curl's output, not the terminal. # We open /dev/tty on fd 3 and read interactive input from there. ############################################################################### TTY_FD=0 setup_tty() { if [[ -t 0 ]]; then # stdin IS a terminal — use it directly TTY_FD=0 elif [[ "$NON_INTERACTIVE" == "true" ]]; then # In non-interactive mode, do not require /dev/tty TTY_FD=0 elif [[ -r /dev/tty ]]; then # stdin is piped (curl | bash) but /dev/tty is available and readable exec 3&2 echo "Use --non-interactive or run directly: bash install.sh" >&2 exit 1 fi } ############################################################################### # Color utilities — auto-detect terminal color support ############################################################################### if [[ -t 1 ]] && [[ "${TERM:-}" != "dumb" ]]; then readonly COLOR_RED='\033[0;31m' readonly COLOR_GREEN='\033[0;32m' readonly COLOR_YELLOW='\033[0;33m' readonly COLOR_BLUE='\033[0;34m' readonly COLOR_MAGENTA='\033[0;35m' readonly COLOR_CYAN='\033[0;36m' readonly COLOR_BOLD='\033[1m' readonly COLOR_RESET='\033[0m' else readonly COLOR_RED='' readonly COLOR_GREEN='' readonly COLOR_YELLOW='' readonly COLOR_BLUE='' readonly COLOR_MAGENTA='' readonly COLOR_CYAN='' readonly COLOR_BOLD='' readonly COLOR_RESET='' fi info() { echo -e "${COLOR_BLUE}ℹ${COLOR_RESET} $*"; } success() { echo -e "${COLOR_GREEN}✓${COLOR_RESET} $*"; } warn() { echo -e "${COLOR_YELLOW}⚠${COLOR_RESET} $*"; } error() { echo -e "${COLOR_RED}✗${COLOR_RESET} $*" >&2; } prompt_user() { if [[ "$NON_INTERACTIVE" == "true" ]]; then error "Cannot prompt in non-interactive mode: $*" return 1 fi echo -en "${COLOR_CYAN}?${COLOR_RESET} $* " } # Read a line from the terminal (works even when stdin is piped from curl) # Callers always pass -r via $@; shellcheck can't see through the delegation read_tty() { # shellcheck disable=SC2162 read "$@" <&"$TTY_FD" } ############################################################################### # Global cleanup trap — removes temp directories on unexpected exit ############################################################################### CLEANUP_DIRS=() register_cleanup_dir() { CLEANUP_DIRS+=("$1") } cleanup_on_exit() { local exit_code=$? for dir in "${CLEANUP_DIRS[@]+"${CLEANUP_DIRS[@]}"}"; do if [[ -d "$dir" ]]; then rm -rf "$dir" fi done if [[ $exit_code -ne 0 ]]; then echo "" >&2 error "Installation failed (exit code: ${exit_code})" error "Any temporary files have been cleaned up." error "Fix the issue above and re-run the installer." fi } trap cleanup_on_exit EXIT ############################################################################### # Prerequisite checks ############################################################################### check_git() { if command -v git &>/dev/null; then return 0 fi error "git is not installed" echo "" >&2 case "${PLATFORM:-}" in macos) error "Install git on macOS with:" error " xcode-select --install" error " # or: brew install git" ;; linux) error "Install git on Linux with:" error " sudo apt install git # Debian/Ubuntu" error " sudo dnf install git # Fedora/RHEL" error " sudo pacman -S git # Arch" ;; *) error "Please install git and re-run this installer." ;; esac exit 1 } ############################################################################### # Port conflict detection — check if port 37777 is already in use ############################################################################### check_port_37777() { local port_in_use="" # Try lsof first (macOS/Linux) if command -v lsof &>/dev/null; then if lsof -i :37777 -sTCP:LISTEN &>/dev/null; then port_in_use="true" fi # Fallback to ss (Linux) elif command -v ss &>/dev/null; then if ss -tlnp 2>/dev/null | grep -q ':37777 '; then port_in_use="true" fi # Fallback to curl probe elif command -v curl &>/dev/null; then local response response="$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:37777/api/health" 2>/dev/null)" || true if [[ "$response" == "200" ]]; then port_in_use="true" fi fi if [[ "$port_in_use" == "true" ]]; then return 0 # port IS in use fi return 1 # port is free } ############################################################################### # Upgrade detection — check if claude-mem is already installed ############################################################################### is_claude_mem_installed() { # Check if the plugin directory exists with the worker script if find_claude_mem_install_dir 2>/dev/null; then return 0 fi return 1 } ############################################################################### # JSON manipulation helper — jq with python3/node fallback # Usage: ensure_jq_or_fallback [jq_args...] # For simple read operations, returns the result on stdout. # For write operations, updates the file in-place. ############################################################################### ensure_jq_or_fallback() { local json_file="$1" shift local jq_filter="$1" shift # remaining args are passed as jq --arg pairs if command -v jq &>/dev/null; then local tmp_file tmp_file="$(mktemp)" jq "$@" "$jq_filter" "$json_file" > "$tmp_file" && mv "$tmp_file" "$json_file" return $? fi if command -v python3 &>/dev/null; then # For complex jq filters, fall back to node instead # Python is used only for simple operations : fi # Fallback to node (always available — it's a dependency) # This is a passthrough; callers that need node-specific logic # should use node -e directly. This function is for jq compatibility. warn "jq not found — using node for JSON manipulation" return 1 } ############################################################################### # Parse /api/health JSON response — extract worker metadata into globals # Uses jq → python3 → node fallback chain (matching installer conventions) # Sets: WORKER_VERSION, WORKER_AI_PROVIDER, WORKER_AI_AUTH_METHOD, # WORKER_INITIALIZED, WORKER_REPORTED_PID, WORKER_UPTIME ############################################################################### parse_health_json() { local raw_json="$1" # Reset all health globals before parsing WORKER_VERSION="" WORKER_AI_PROVIDER="" WORKER_AI_AUTH_METHOD="" WORKER_INITIALIZED="" WORKER_REPORTED_PID="" WORKER_UPTIME="" if [[ -z "$raw_json" ]]; then return 0 fi # Try jq first (fastest, most reliable) if command -v jq &>/dev/null; then WORKER_VERSION="$(echo "$raw_json" | jq -r '.version // empty' 2>/dev/null)" || true WORKER_AI_PROVIDER="$(echo "$raw_json" | jq -r '.ai.provider // empty' 2>/dev/null)" || true WORKER_AI_AUTH_METHOD="$(echo "$raw_json" | jq -r '.ai.authMethod // empty' 2>/dev/null)" || true WORKER_INITIALIZED="$(echo "$raw_json" | jq -r '.initialized // empty' 2>/dev/null)" || true WORKER_REPORTED_PID="$(echo "$raw_json" | jq -r '.pid // empty' 2>/dev/null)" || true WORKER_UPTIME="$(echo "$raw_json" | jq -r '.uptime // empty' 2>/dev/null)" || true return 0 fi # Try python3 fallback if command -v python3 &>/dev/null; then local parsed parsed="$(INSTALLER_HEALTH_JSON="$raw_json" python3 -c " import json, os, sys try: data = json.loads(os.environ['INSTALLER_HEALTH_JSON']) ai = data.get('ai') or {} fields = [ str(data.get('version', '')), str(ai.get('provider', '')), str(ai.get('authMethod', '')), str(data.get('initialized', '')), str(data.get('pid', '')), str(data.get('uptime', '')), ] sys.stdout.write('\n'.join(fields)) except Exception: sys.stdout.write('\n\n\n\n\n') " 2>/dev/null)" || true if [[ -n "$parsed" ]]; then local -a health_fields IFS=$'\n' read -r -d '' -a health_fields <<< "$parsed" || true WORKER_VERSION="${health_fields[0]:-}" WORKER_AI_PROVIDER="${health_fields[1]:-}" WORKER_AI_AUTH_METHOD="${health_fields[2]:-}" WORKER_INITIALIZED="${health_fields[3]:-}" WORKER_REPORTED_PID="${health_fields[4]:-}" WORKER_UPTIME="${health_fields[5]:-}" # Normalize python's None/empty representations [[ "$WORKER_VERSION" == "None" ]] && WORKER_VERSION="" [[ "$WORKER_AI_PROVIDER" == "None" ]] && WORKER_AI_PROVIDER="" [[ "$WORKER_AI_AUTH_METHOD" == "None" ]] && WORKER_AI_AUTH_METHOD="" [[ "$WORKER_INITIALIZED" == "None" ]] && WORKER_INITIALIZED="" [[ "$WORKER_REPORTED_PID" == "None" ]] && WORKER_REPORTED_PID="" [[ "$WORKER_UPTIME" == "None" ]] && WORKER_UPTIME="" fi return 0 fi # Fallback to node (always available — it's a dependency) local parsed parsed="$(INSTALLER_HEALTH_JSON="$raw_json" node -e " try { const data = JSON.parse(process.env.INSTALLER_HEALTH_JSON); const ai = data.ai || {}; const fields = [ data.version ?? '', ai.provider ?? '', ai.authMethod ?? '', data.initialized != null ? String(data.initialized) : '', data.pid != null ? String(data.pid) : '', data.uptime != null ? String(data.uptime) : '', ]; process.stdout.write(fields.join('\n')); } catch (e) { process.stdout.write('\n\n\n\n\n'); } " 2>/dev/null)" || true if [[ -n "$parsed" ]]; then local -a health_fields IFS=$'\n' read -r -d '' -a health_fields <<< "$parsed" || true WORKER_VERSION="${health_fields[0]:-}" WORKER_AI_PROVIDER="${health_fields[1]:-}" WORKER_AI_AUTH_METHOD="${health_fields[2]:-}" WORKER_INITIALIZED="${health_fields[3]:-}" WORKER_REPORTED_PID="${health_fields[4]:-}" WORKER_UPTIME="${health_fields[5]:-}" fi } ############################################################################### # Format uptime from milliseconds to human-readable (e.g., "2m 15s", "1h 23m") ############################################################################### format_uptime_ms() { local ms="$1" local secs=$((ms / 1000)) if (( secs >= 3600 )); then echo "$((secs / 3600))h $((secs % 3600 / 60))m" elif (( secs >= 60 )); then echo "$((secs / 60))m $((secs % 60))s" else echo "${secs}s" fi } ############################################################################### # Banner ############################################################################### print_banner() { echo -e "${COLOR_MAGENTA}${COLOR_BOLD}" cat << 'BANNER' ┌─────────────────────────────────────────┐ │ claude-mem × OpenClaw │ │ Persistent Memory Plugin Installer │ └─────────────────────────────────────────┘ BANNER echo -e "${COLOR_RESET}" info "Installer v${INSTALLER_VERSION}" echo "" } ############################################################################### # Platform detection ############################################################################### PLATFORM="" IS_WSL="" detect_platform() { local uname_out uname_out="$(uname -s)" case "${uname_out}" in Darwin*) PLATFORM="macos" ;; Linux*) if grep -qi microsoft /proc/version 2>/dev/null; then PLATFORM="linux" IS_WSL="true" else PLATFORM="linux" fi ;; MINGW*|MSYS*|CYGWIN*) PLATFORM="windows" ;; *) error "Unsupported platform: ${uname_out}" exit 1 ;; esac info "Detected platform: ${PLATFORM}${IS_WSL:+ (WSL)}" } ############################################################################### # Version comparison — returns 0 if $1 >= $2 ############################################################################### version_gte() { local v1="$1" v2="$2" local -a parts1 parts2 IFS='.' read -ra parts1 <<< "$v1" IFS='.' read -ra parts2 <<< "$v2" for i in 0 1 2; do local p1="${parts1[$i]:-0}" local p2="${parts2[$i]:-0}" if (( p1 > p2 )); then return 0; fi if (( p1 < p2 )); then return 1; fi done return 0 } ############################################################################### # Bun detection and installation # Translated from plugin/scripts/smart-install.js patterns ############################################################################### BUN_PATH="" find_bun_path() { # Try PATH first if command -v bun &>/dev/null; then BUN_PATH="$(command -v bun)" return 0 fi # Check common installation paths (handles fresh installs before PATH reload) local -a bun_paths=( "${HOME}/.bun/bin/bun" "/usr/local/bin/bun" "/opt/homebrew/bin/bun" ) for candidate in "${bun_paths[@]}"; do if [[ -x "$candidate" ]]; then BUN_PATH="$candidate" return 0 fi done BUN_PATH="" return 1 } check_bun() { if ! find_bun_path; then return 1 fi # Verify minimum version local bun_version bun_version="$("$BUN_PATH" --version 2>/dev/null)" || return 1 if version_gte "$bun_version" "$MIN_BUN_VERSION"; then success "Bun ${bun_version} found at ${BUN_PATH}" return 0 else warn "Bun ${bun_version} is below minimum required version ${MIN_BUN_VERSION}" return 1 fi } install_bun() { info "Installing Bun runtime..." if ! curl -fsSL https://bun.sh/install | bash; then error "Failed to install Bun automatically" error "Please install manually:" error " curl -fsSL https://bun.sh/install | bash" error " Or: brew install oven-sh/bun/bun (macOS)" error "Then restart your terminal and re-run this installer." exit 1 fi # Re-detect after install (installer may have placed it in ~/.bun/bin) if ! find_bun_path; then error "Bun installation completed but binary not found in expected locations" error "Please restart your terminal and re-run this installer." exit 1 fi local bun_version bun_version="$("$BUN_PATH" --version 2>/dev/null)" || true success "Bun ${bun_version} installed at ${BUN_PATH}" } ############################################################################### # uv detection and installation # Translated from plugin/scripts/smart-install.js patterns ############################################################################### UV_PATH="" find_uv_path() { # Try PATH first if command -v uv &>/dev/null; then UV_PATH="$(command -v uv)" return 0 fi # Check common installation paths (handles fresh installs before PATH reload) local -a uv_paths=( "${HOME}/.local/bin/uv" "${HOME}/.cargo/bin/uv" "/usr/local/bin/uv" "/opt/homebrew/bin/uv" ) for candidate in "${uv_paths[@]}"; do if [[ -x "$candidate" ]]; then UV_PATH="$candidate" return 0 fi done UV_PATH="" return 1 } check_uv() { if ! find_uv_path; then return 1 fi local uv_version uv_version="$("$UV_PATH" --version 2>/dev/null)" || return 1 success "uv ${uv_version} found at ${UV_PATH}" return 0 } install_uv() { info "Installing uv (Python package manager for Chroma support)..." if ! curl -LsSf https://astral.sh/uv/install.sh | sh; then error "Failed to install uv automatically" error "Please install manually:" error " curl -LsSf https://astral.sh/uv/install.sh | sh" error " Or: brew install uv (macOS)" error "Then restart your terminal and re-run this installer." exit 1 fi # Re-detect after install if ! find_uv_path; then error "uv installation completed but binary not found in expected locations" error "Please restart your terminal and re-run this installer." exit 1 fi local uv_version uv_version="$("$UV_PATH" --version 2>/dev/null)" || true success "uv ${uv_version} installed at ${UV_PATH}" } ############################################################################### # OpenClaw gateway detection ############################################################################### OPENCLAW_PATH="" find_openclaw() { # Try PATH first — check both "openclaw" and "openclaw.mjs" binary names for bin_name in openclaw openclaw.mjs; do if command -v "$bin_name" &>/dev/null; then OPENCLAW_PATH="$(command -v "$bin_name")" return 0 fi done # Check common installation paths local -a openclaw_paths=( "${HOME}/.openclaw/openclaw.mjs" "/usr/local/bin/openclaw.mjs" "/usr/local/bin/openclaw" "/usr/local/lib/node_modules/openclaw/openclaw.mjs" "${HOME}/.npm-global/lib/node_modules/openclaw/openclaw.mjs" "${HOME}/.npm-global/bin/openclaw" ) # Also check for node_modules in common project locations if [[ -n "${NODE_PATH:-}" ]]; then openclaw_paths+=("${NODE_PATH}/openclaw/openclaw.mjs") fi for candidate in "${openclaw_paths[@]}"; do if [[ -f "$candidate" ]]; then OPENCLAW_PATH="$candidate" return 0 fi done OPENCLAW_PATH="" return 1 } check_openclaw() { if ! find_openclaw; then error "OpenClaw gateway not found" error "" error "The claude-mem plugin requires an OpenClaw gateway to be installed." error "Please install OpenClaw first:" error "" error " npm install -g openclaw" error " # or visit: https://openclaw.dev/docs/installation" error "" error "Then re-run this installer." exit 1 fi success "OpenClaw gateway found at ${OPENCLAW_PATH}" } # Run openclaw command — uses node for .mjs files, direct execution otherwise run_openclaw() { if [[ "$OPENCLAW_PATH" == *.mjs ]]; then node "$OPENCLAW_PATH" "$@" else "$OPENCLAW_PATH" "$@" fi } ############################################################################### # Plugin installation — clone, build, install, enable # Flow based on openclaw/Dockerfile.e2e ############################################################################### CLAUDE_MEM_REPO="https://github.com/thedotmack/claude-mem.git" CLAUDE_MEM_BRANCH="${CLI_BRANCH:-main}" PLUGIN_FRESHLY_INSTALLED="" # Resolve the target extension directory. # Priority: existing installPath from config > plugins.load.paths > default. resolve_extension_dir() { local oc_config="${HOME}/.openclaw/openclaw.json" if [[ -f "$oc_config" ]] && command -v node &>/dev/null; then local existing_path existing_path="$(node -e " try { const c = require('$oc_config'); const p = c?.plugins?.installs?.['claude-mem']?.installPath; if (p) console.log(p); } catch {} " 2>/dev/null)" || true if [[ -n "$existing_path" ]]; then echo "$existing_path" return fi local load_path load_path="$(node -e " try { const c = require('$oc_config'); const paths = c?.plugins?.load?.paths || []; const p = paths.find(p => p.endsWith('/claude-mem')); if (p) console.log(p); } catch {} " 2>/dev/null)" || true if [[ -n "$load_path" ]]; then echo "$load_path" return fi fi echo "${HOME}/.openclaw/extensions/claude-mem" } CLAUDE_MEM_EXTENSION_DIR="" install_plugin() { # Check for git before attempting clone check_git CLAUDE_MEM_EXTENSION_DIR="$(resolve_extension_dir)" # Remove existing plugin installation to allow clean re-install local existing_plugin_dir="$CLAUDE_MEM_EXTENSION_DIR" if [[ -d "$existing_plugin_dir" ]]; then info "Removing existing claude-mem plugin at ${existing_plugin_dir}..." rm -rf "$existing_plugin_dir" fi local build_dir build_dir="$(mktemp -d)" register_cleanup_dir "$build_dir" info "Cloning claude-mem repository (branch: ${CLAUDE_MEM_BRANCH})..." if ! git clone --depth 1 --branch "$CLAUDE_MEM_BRANCH" "$CLAUDE_MEM_REPO" "$build_dir/claude-mem" 2>&1; then error "Failed to clone claude-mem repository" error "Check your internet connection and try again." exit 1 fi local plugin_src="${build_dir}/claude-mem/openclaw" # Build the TypeScript plugin info "Building TypeScript plugin..." if ! (cd "$plugin_src" && NODE_ENV=development npm install --ignore-scripts 2>&1 && npx tsc 2>&1); then error "Failed to build the claude-mem OpenClaw plugin" error "Make sure Node.js and npm are installed." exit 1 fi # Create minimal installable package (matches Dockerfile.e2e pattern) local installable_dir="${build_dir}/claude-mem-installable" mkdir -p "${installable_dir}/dist" cp "${plugin_src}/dist/index.js" "${installable_dir}/dist/" cp "${plugin_src}/dist/index.d.ts" "${installable_dir}/dist/" 2>/dev/null || true cp "${plugin_src}/openclaw.plugin.json" "${installable_dir}/" # Generate the installable package.json with openclaw.extensions field INSTALLER_PACKAGE_DIR="$installable_dir" 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(process.env.INSTALLER_PACKAGE_DIR + '/package.json', JSON.stringify(pkg, null, 2)); " # Clean up stale claude-mem plugin entry before installing. # If the config references claude-mem but the plugin isn't installed, # OpenClaw's config validator blocks ALL CLI commands (including plugins install). # We temporarily remove the entry and save the config so `plugins install` can run, # then `plugins install` + `plugins enable` will re-create it properly. local oc_config="${HOME}/.openclaw/openclaw.json" local saved_plugin_config="" if [[ -f "$oc_config" ]]; then saved_plugin_config=$(INSTALLER_CONFIG_FILE="$oc_config" node -e " const fs = require('fs'); const configPath = process.env.INSTALLER_CONFIG_FILE; const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); const entry = config?.plugins?.entries?.['claude-mem']; const allowHasClaudeMem = Array.isArray(config?.plugins?.allow) && config.plugins.allow.includes('claude-mem'); if (entry || config?.plugins?.slots?.memory === 'claude-mem' || allowHasClaudeMem) { // Save the config block so we can restore it after install process.stdout.write(JSON.stringify(entry?.config || {})); // Remove the stale entry so OpenClaw CLI can run if (entry) delete config.plugins.entries['claude-mem']; // Also remove stale allowlist reference — this alone can block ALL CLI commands if (Array.isArray(config?.plugins?.allow)) { config.plugins.allow = config.plugins.allow.filter((x) => x !== 'claude-mem'); } // Also remove the slot reference — if the slot points to a plugin // that isn't in entries, OpenClaw's config validator rejects ALL commands if (config?.plugins?.slots?.memory === 'claude-mem') { delete config.plugins.slots.memory; } fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); } " 2>/dev/null) || true fi # Install the plugin using OpenClaw's CLI info "Installing claude-mem plugin into OpenClaw..." if ! run_openclaw plugins install "$installable_dir" 2>&1; then error "Failed to install claude-mem plugin" error "Try manually: ${OPENCLAW_PATH} plugins install " exit 1 fi # Enable the plugin info "Enabling claude-mem plugin..." if ! run_openclaw plugins enable claude-mem 2>&1; then error "Failed to enable claude-mem plugin" error "Try manually: ${OPENCLAW_PATH} plugins enable claude-mem" exit 1 fi # Ensure claude-mem is present in plugins.allow after successful install+enable. # Some OpenClaw environments require explicit allowlisting for local plugins. # This write is guaranteed: if config doesn't exist, configure_memory_slot() will create it. if [[ -f "$oc_config" ]]; then if ! INSTALLER_CONFIG_FILE="$oc_config" node -e " const fs = require('fs'); const configPath = process.env.INSTALLER_CONFIG_FILE; const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); if (!config.plugins) config.plugins = {}; if (!Array.isArray(config.plugins.allow)) config.plugins.allow = []; if (!config.plugins.allow.includes('claude-mem')) { config.plugins.allow.push('claude-mem'); fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); console.log('Added claude-mem to plugins.allow'); } else { console.log('claude-mem already in plugins.allow'); } " 2>&1; then warn "Failed to write plugins.allow — claude-mem may need manual allowlisting" fi else # Config doesn't exist yet; configure_memory_slot() will create it with plugins.allow # We'll add claude-mem to the allowlist in a follow-up step after config is materialized info "OpenClaw config not yet materialized; will ensure allowlist in post-install" # Force config materialization by running a harmless OpenClaw command if run_openclaw status --json >/dev/null 2>&1 && [[ -f "$oc_config" ]]; then if ! INSTALLER_CONFIG_FILE="$oc_config" node -e " const fs = require('fs'); const configPath = process.env.INSTALLER_CONFIG_FILE; const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); if (!config.plugins) config.plugins = {}; if (!Array.isArray(config.plugins.allow)) config.plugins.allow = []; if (!config.plugins.allow.includes('claude-mem')) { config.plugins.allow.push('claude-mem'); fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); console.log('Added claude-mem to plugins.allow (post-materialization)'); } " 2>&1; then warn "Failed to write plugins.allow after materialization — configure manually" fi fi fi # Restore saved plugin config (workerPort, syncMemoryFile, observationFeed, etc.) # from any pre-existing installation that was temporarily removed above. if [[ -n "$saved_plugin_config" && "$saved_plugin_config" != "{}" ]]; then info "Restoring previous plugin configuration..." INSTALLER_CONFIG_FILE="$oc_config" INSTALLER_SAVED_CONFIG="$saved_plugin_config" node -e " const fs = require('fs'); const configPath = process.env.INSTALLER_CONFIG_FILE; const savedConfig = JSON.parse(process.env.INSTALLER_SAVED_CONFIG); const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); if (config?.plugins?.entries?.['claude-mem']) { config.plugins.entries['claude-mem'].config = savedConfig; fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); } " 2>/dev/null || warn "Could not restore previous plugin config — configure manually" fi success "claude-mem plugin installed and enabled" # ── Copy core plugin files (worker, hooks, scripts) to extension directory ── # The OpenClaw extension only contains the gateway hook (dist/index.js). # The actual worker service and Claude Code hooks live in the plugin/ directory # of the main repo. We copy them so find_claude_mem_install_dir() can locate # the worker-service.cjs and the worker runs the updated version. local extension_dir="$CLAUDE_MEM_EXTENSION_DIR" local repo_root="${build_dir}/claude-mem" if [[ -d "$extension_dir" && -d "${repo_root}/plugin" ]]; then info "Copying core plugin files to ${extension_dir}..." # Copy plugin/ directory (worker service, hooks, scripts, skills, UI) cp -R "${repo_root}/plugin" "${extension_dir}/" # Merge the canonical version from root package.json into the existing # extension package.json, preserving the openclaw.extensions field that # plugin discovery requires. local root_version root_version="$(node -e "console.log(require('${repo_root}/package.json').version)")" node -e " const fs = require('fs'); const pkgPath = '${extension_dir}/package.json'; const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); pkg.version = '${root_version}'; fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); " success "Core plugin files updated at ${extension_dir}" else warn "Could not copy core plugin files — worker may need manual update" fi PLUGIN_FRESHLY_INSTALLED="true" } ############################################################################### # Memory slot configuration # Sets plugins.slots.memory = "claude-mem" in ~/.openclaw/openclaw.json ############################################################################### configure_memory_slot() { local config_dir="${HOME}/.openclaw" local config_file="${config_dir}/openclaw.json" mkdir -p "$config_dir" if [[ ! -f "$config_file" ]]; then # No config file exists — create one with the memory slot info "Creating OpenClaw configuration with claude-mem memory slot..." INSTALLER_CONFIG_FILE="$config_file" node -e " const config = { plugins: { slots: { memory: 'claude-mem' }, entries: { 'claude-mem': { enabled: true, config: { workerPort: 37777, syncMemoryFile: true } } } } }; require('fs').writeFileSync(process.env.INSTALLER_CONFIG_FILE, JSON.stringify(config, null, 2)); " success "Created ${config_file} with memory slot set to claude-mem" return 0 fi # Config file exists — update it to set the memory slot info "Updating OpenClaw configuration to use claude-mem memory slot..." # Use node for reliable JSON manipulation INSTALLER_CONFIG_FILE="$config_file" node -e " const fs = require('fs'); const configPath = process.env.INSTALLER_CONFIG_FILE; const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); // Ensure plugins structure exists if (!config.plugins) config.plugins = {}; if (!config.plugins.slots) config.plugins.slots = {}; if (!config.plugins.entries) config.plugins.entries = {}; // Set memory slot to claude-mem config.plugins.slots.memory = 'claude-mem'; // Ensure claude-mem entry exists and is enabled if (!config.plugins.entries['claude-mem']) { config.plugins.entries['claude-mem'] = { enabled: true, config: { workerPort: 37777, syncMemoryFile: true } }; } else { config.plugins.entries['claude-mem'].enabled = true; // Remove unrecognized keys that cause OpenClaw config validation errors const allowedKeys = new Set(['enabled', 'config']); for (const key of Object.keys(config.plugins.entries['claude-mem'])) { if (!allowedKeys.has(key)) { delete config.plugins.entries['claude-mem'][key]; } } } fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); " success "Memory slot set to claude-mem in ${config_file}" } ############################################################################### # AI Provider setup — interactive provider selection # Reads defaults from SettingsDefaultsManager.ts (single source of truth) ############################################################################### AI_PROVIDER="" AI_PROVIDER_API_KEY="" mask_api_key() { local key="$1" local len=${#key} if (( len <= 4 )); then echo "****" else local masked_len=$((len - 4)) local mask="" for (( i=0; i/dev/null | head -n 1)" || true if [[ -n "$found" ]]; then # Strip /plugin/scripts/worker-service.cjs to get the install dir CLAUDE_MEM_INSTALL_DIR="${found%/plugin/scripts/worker-service.cjs}" return 0 fi fi done CLAUDE_MEM_INSTALL_DIR="" return 1 } ############################################################################### # Worker service startup # Starts the claude-mem worker using bun in the background ############################################################################### WORKER_PID="" WORKER_VERSION="" WORKER_AI_PROVIDER="" WORKER_AI_AUTH_METHOD="" WORKER_INITIALIZED="" WORKER_REPORTED_PID="" WORKER_UPTIME="" start_worker() { info "Starting claude-mem worker service..." if ! find_claude_mem_install_dir; then error "Cannot find claude-mem plugin installation directory" error "Expected worker-service.cjs in one of:" error " ~/.openclaw/extensions/claude-mem/plugin/scripts/" error " ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/" error "" error "Try reinstalling the plugin and re-running this installer." return 1 fi local worker_script="${CLAUDE_MEM_INSTALL_DIR}/plugin/scripts/worker-service.cjs" local log_dir="${HOME}/.claude-mem/logs" local log_date log_date="$(date +%Y-%m-%d)" local log_file="${log_dir}/worker-${log_date}.log" mkdir -p "$log_dir" # Ensure bun path is available if [[ -z "$BUN_PATH" ]]; then if ! find_bun_path; then error "Bun not found — cannot start worker service" return 1 fi fi # Start worker in background with nohup CLAUDE_MEM_WORKER_PORT=37777 nohup "$BUN_PATH" "$worker_script" \ >> "$log_file" 2>&1 & WORKER_PID=$! # Write PID file for future management local pid_file="${HOME}/.claude-mem/worker.pid" mkdir -p "${HOME}/.claude-mem" INSTALLER_PID_FILE="$pid_file" INSTALLER_WORKER_PID="$WORKER_PID" node -e " const info = { pid: parseInt(process.env.INSTALLER_WORKER_PID, 10), port: 37777, startedAt: new Date().toISOString(), version: 'installer' }; require('fs').writeFileSync(process.env.INSTALLER_PID_FILE, JSON.stringify(info, null, 2)); " success "Worker process started (PID: ${WORKER_PID})" info "Logs: ${log_file}" } ############################################################################### # Health verification — two-stage: health (alive) then readiness (initialized) # Stage 1: Poll /api/health for HTTP 200 (worker process is running) # Stage 2: Poll /api/readiness for HTTP 200 (worker is fully initialized) # Total budget: 30 attempts (30 seconds) shared across both stages ############################################################################### verify_health() { local max_attempts=30 local attempt=1 local health_url="http://127.0.0.1:37777/api/health" local readiness_url="http://127.0.0.1:37777/api/readiness" local health_alive=false info "Verifying worker health..." # ── Stage 1: Wait for /api/health to return HTTP 200 (worker is alive) ── while (( attempt <= max_attempts )); do local http_status http_status="$(curl -s -o /dev/null -w "%{http_code}" "$health_url" 2>/dev/null)" || true if [[ "$http_status" == "200" ]]; then health_alive=true # Fetch the full health response body and parse metadata local body body="$(curl -s "$health_url" 2>/dev/null)" || true parse_health_json "$body" success "Worker is alive, waiting for initialization..." break fi info "Waiting for worker to start... (attempt ${attempt}/${max_attempts})" sleep 1 attempt=$((attempt + 1)) done # If health never responded, the worker is not running at all if [[ "$health_alive" != "true" ]]; then warn "Worker health check timed out after ${max_attempts} attempts" warn "The worker may still be starting up. Check status with:" warn " curl http://127.0.0.1:37777/api/health" warn " Or check logs: ~/.claude-mem/logs/" return 1 fi # ── Stage 2: Wait for /api/readiness to return HTTP 200 (fully initialized) ── attempt=$((attempt + 1)) while (( attempt <= max_attempts )); do local readiness_status readiness_status="$(curl -s -o /dev/null -w "%{http_code}" "$readiness_url" 2>/dev/null)" || true if [[ "$readiness_status" == "200" ]]; then success "Worker is ready!" return 0 fi info "Waiting for worker to initialize... (attempt ${attempt}/${max_attempts})" sleep 1 attempt=$((attempt + 1)) done # Readiness timed out but health is OK — worker is running, just not fully initialized yet warn "Worker is running but initialization is still in progress" warn "This is normal on first run — the worker will finish initializing in the background." warn "Check readiness with: curl http://127.0.0.1:37777/api/readiness" return 0 } ############################################################################### # Observation feed setup — optional interactive channel configuration ############################################################################### FEED_CHANNEL="" FEED_TARGET_ID="" FEED_CONFIGURED=false setup_observation_feed() { echo "" echo -e " ${COLOR_BOLD}Real-Time Observation Feed${COLOR_RESET}" echo "" echo " claude-mem can stream AI-compressed observations to a messaging" echo " channel in real time. Every time an agent learns something," echo " you'll see it in your chat." echo "" if [[ "$NON_INTERACTIVE" == "true" ]]; then info "Non-interactive mode: skipping observation feed setup" info "Configure later in ~/.openclaw/openclaw.json under" info " plugins.entries.claude-mem.config.observationFeed" return 0 fi prompt_user "Would you like to set up real-time observation streaming to a messaging channel? (y/n)" local answer read_tty -r answer answer="${answer:-n}" if [[ "$answer" != [yY] && "$answer" != [yY][eE][sS] ]]; then echo "" info "Skipped observation feed setup." info "You can configure it later by re-running this installer or" info "editing ~/.openclaw/openclaw.json under" info " plugins.entries.claude-mem.config.observationFeed" return 0 fi echo "" echo -e " ${COLOR_BOLD}Select your messaging channel:${COLOR_RESET}" echo "" echo -e " ${COLOR_BOLD}1)${COLOR_RESET} Telegram" echo -e " ${COLOR_BOLD}2)${COLOR_RESET} Discord" echo -e " ${COLOR_BOLD}3)${COLOR_RESET} Slack" echo -e " ${COLOR_BOLD}4)${COLOR_RESET} Signal" echo -e " ${COLOR_BOLD}5)${COLOR_RESET} WhatsApp" echo -e " ${COLOR_BOLD}6)${COLOR_RESET} LINE" echo "" local channel_choice while true; do prompt_user "Enter choice [1-6]:" read_tty -r channel_choice case "$channel_choice" in 1) FEED_CHANNEL="telegram" echo "" echo -e " ${COLOR_CYAN}How to find your Telegram chat ID:${COLOR_RESET}" echo " Message @userinfobot on Telegram (https://t.me/userinfobot)" echo " — it replies with your numeric chat ID." echo " For groups, the ID is negative (e.g., -1001234567890)." break ;; 2) FEED_CHANNEL="discord" echo "" echo -e " ${COLOR_CYAN}How to find your Discord channel ID:${COLOR_RESET}" echo " Enable Developer Mode (Settings → Advanced → Developer Mode)," echo " right-click the target channel → Copy Channel ID" break ;; 3) FEED_CHANNEL="slack" echo "" echo -e " ${COLOR_CYAN}How to find your Slack channel ID:${COLOR_RESET}" echo " Open the channel, click the channel name at top," echo " scroll to bottom — ID looks like C01ABC2DEFG" break ;; 4) FEED_CHANNEL="signal" echo "" echo -e " ${COLOR_CYAN}How to find your Signal target ID:${COLOR_RESET}" echo " Use the phone number or group ID from your" echo " OpenClaw Signal plugin config" break ;; 5) FEED_CHANNEL="whatsapp" echo "" echo -e " ${COLOR_CYAN}How to find your WhatsApp target ID:${COLOR_RESET}" echo " Use the phone number or group JID from your" echo " OpenClaw WhatsApp plugin config" break ;; 6) FEED_CHANNEL="line" echo "" echo -e " ${COLOR_CYAN}How to find your LINE target ID:${COLOR_RESET}" echo " Use the user ID or group ID from the" echo " LINE Developer Console" break ;; *) warn "Invalid choice. Please enter a number between 1 and 6." ;; esac done echo "" prompt_user "Enter your ${FEED_CHANNEL} target ID:" read_tty -r FEED_TARGET_ID if [[ -z "$FEED_TARGET_ID" ]]; then warn "No target ID provided — skipping observation feed setup." warn "You can configure it later in ~/.openclaw/openclaw.json" FEED_CHANNEL="" return 0 fi success "Observation feed: ${FEED_CHANNEL} → ${FEED_TARGET_ID}" FEED_CONFIGURED=true } ############################################################################### # Write observation feed config into ~/.openclaw/openclaw.json ############################################################################### write_observation_feed_config() { if [[ "$FEED_CONFIGURED" != "true" ]]; then return 0 fi local config_file="${HOME}/.openclaw/openclaw.json" if [[ ! -f "$config_file" ]]; then warn "OpenClaw config file not found at ${config_file}" warn "Cannot write observation feed config." return 1 fi info "Writing observation feed configuration..." # Use jq if available, fall back to python3, then node for JSON manipulation if command -v jq &>/dev/null; then local tmp_file tmp_file="$(mktemp)" jq --arg channel "$FEED_CHANNEL" --arg target "$FEED_TARGET_ID" ' .plugins //= {} | .plugins.entries //= {} | .plugins.entries["claude-mem"] //= {"enabled": true, "config": {}} | .plugins.entries["claude-mem"].config //= {} | .plugins.entries["claude-mem"].config.observationFeed = { "enabled": true, "channel": $channel, "to": $target } ' "$config_file" > "$tmp_file" && mv "$tmp_file" "$config_file" elif command -v python3 &>/dev/null; then INSTALLER_FEED_CHANNEL="$FEED_CHANNEL" \ INSTALLER_FEED_TARGET_ID="$FEED_TARGET_ID" \ INSTALLER_CONFIG_FILE="$config_file" \ python3 -c " import json, os config_path = os.environ['INSTALLER_CONFIG_FILE'] channel = os.environ['INSTALLER_FEED_CHANNEL'] target_id = os.environ['INSTALLER_FEED_TARGET_ID'] with open(config_path) as f: config = json.load(f) config.setdefault('plugins', {}) config['plugins'].setdefault('entries', {}) config['plugins']['entries'].setdefault('claude-mem', {'enabled': True, 'config': {}}) config['plugins']['entries']['claude-mem'].setdefault('config', {}) config['plugins']['entries']['claude-mem']['config']['observationFeed'] = { 'enabled': True, 'channel': channel, 'to': target_id } with open(config_path, 'w') as f: json.dump(config, f, indent=2) " else # Fallback to node (always available since it's a dependency) INSTALLER_FEED_CHANNEL="$FEED_CHANNEL" \ INSTALLER_FEED_TARGET_ID="$FEED_TARGET_ID" \ INSTALLER_CONFIG_FILE="$config_file" \ node -e " const fs = require('fs'); const configPath = process.env.INSTALLER_CONFIG_FILE; const channel = process.env.INSTALLER_FEED_CHANNEL; const targetId = process.env.INSTALLER_FEED_TARGET_ID; const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); if (!config.plugins) config.plugins = {}; if (!config.plugins.entries) config.plugins.entries = {}; if (!config.plugins.entries['claude-mem']) { config.plugins.entries['claude-mem'] = { enabled: true, config: {} }; } if (!config.plugins.entries['claude-mem'].config) { config.plugins.entries['claude-mem'].config = {}; } config.plugins.entries['claude-mem'].config.observationFeed = { enabled: true, channel: channel, to: targetId }; fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); " fi success "Observation feed config written to ${config_file}" echo "" echo -e " ${COLOR_BOLD}Observation feed summary:${COLOR_RESET}" echo -e " Channel: ${COLOR_CYAN}${FEED_CHANNEL}${COLOR_RESET}" echo -e " Target: ${COLOR_CYAN}${FEED_TARGET_ID}${COLOR_RESET}" echo -e " Enabled: ${COLOR_GREEN}yes${COLOR_RESET}" echo "" info "Restart your OpenClaw gateway to activate the observation feed." info "You should see these log lines:" echo " [claude-mem] Observation feed starting — channel: ${FEED_CHANNEL}, target: ${FEED_TARGET_ID}" echo "" info "After restarting, run /claude-mem-feed in any OpenClaw chat to verify" info "the feed is connected." } ############################################################################### # Completion summary ############################################################################### print_completion_summary() { local provider_display="" case "$AI_PROVIDER" in claude) provider_display="Claude Max Plan (CLI authentication)" ;; gemini) provider_display="Gemini (gemini-2.5-flash-lite)" ;; openrouter) provider_display="OpenRouter (xiaomi/mimo-v2-flash:free)" ;; *) provider_display="$AI_PROVIDER" ;; esac echo "" echo -e "${COLOR_MAGENTA}${COLOR_BOLD}" echo " ┌──────────────────────────────────────────┐" echo " │ Installation Complete! │" echo " └──────────────────────────────────────────┘" echo -e "${COLOR_RESET}" echo -e " ${COLOR_GREEN}✓${COLOR_RESET} Dependencies installed (Bun, uv)" echo -e " ${COLOR_GREEN}✓${COLOR_RESET} OpenClaw gateway detected" # Show installed version from health data if available if [[ -n "$WORKER_VERSION" ]]; then echo -e " ${COLOR_GREEN}✓${COLOR_RESET} claude-mem v${COLOR_BOLD}${WORKER_VERSION}${COLOR_RESET} installed and running" else echo -e " ${COLOR_GREEN}✓${COLOR_RESET} claude-mem plugin installed and enabled" fi echo -e " ${COLOR_GREEN}✓${COLOR_RESET} Memory slot configured" # Show AI provider with auth method from health data if available if [[ -n "$WORKER_AI_AUTH_METHOD" ]]; then echo -e " ${COLOR_GREEN}✓${COLOR_RESET} AI provider: ${COLOR_BOLD}${WORKER_AI_PROVIDER} (${WORKER_AI_AUTH_METHOD})${COLOR_RESET}" else echo -e " ${COLOR_GREEN}✓${COLOR_RESET} AI provider: ${COLOR_BOLD}${provider_display}${COLOR_RESET}" fi echo -e " ${COLOR_GREEN}✓${COLOR_RESET} Settings written to ~/.claude-mem/settings.json" if [[ -n "$WORKER_PID" ]] && kill -0 "$WORKER_PID" 2>/dev/null; then echo -e " ${COLOR_GREEN}✓${COLOR_RESET} Worker running on port ${COLOR_BOLD}37777${COLOR_RESET} (PID: ${WORKER_PID})" elif [[ -n "$WORKER_UPTIME" && "$WORKER_UPTIME" =~ ^[0-9]+$ ]] && (( WORKER_UPTIME > 0 )); then local uptime_formatted uptime_formatted="$(format_uptime_ms "$WORKER_UPTIME")" echo -e " ${COLOR_GREEN}✓${COLOR_RESET} Worker running on port ${COLOR_BOLD}37777${COLOR_RESET} (PID: ${WORKER_REPORTED_PID}, uptime: ${uptime_formatted})" else echo -e " ${COLOR_YELLOW}⚠${COLOR_RESET} Worker may not be running — check logs at ~/.claude-mem/logs/" fi # Show initialization warning if worker is alive but not yet initialized if [[ "$WORKER_INITIALIZED" != "true" ]] && { [[ -n "$WORKER_REPORTED_PID" ]] || { [[ -n "$WORKER_PID" ]] && kill -0 "$WORKER_PID" 2>/dev/null; }; }; then echo -e " ${COLOR_YELLOW}⚠${COLOR_RESET} Worker is starting but still initializing (this is normal on first run)" fi if [[ "$FEED_CONFIGURED" == "true" ]]; then echo -e " ${COLOR_GREEN}✓${COLOR_RESET} Observation feed: ${COLOR_BOLD}${FEED_CHANNEL}${COLOR_RESET} → ${FEED_TARGET_ID}" else echo -e " ${COLOR_YELLOW}─${COLOR_RESET} Observation feed: not configured (optional)" echo -e " Configure later in ~/.openclaw/openclaw.json under" echo -e " plugins.entries.claude-mem.config.observationFeed" fi echo "" echo -e " ${COLOR_BOLD}What's next?${COLOR_RESET}" echo "" echo -e " ${COLOR_CYAN}1.${COLOR_RESET} Restart your OpenClaw gateway to load the plugin" echo -e " ${COLOR_CYAN}2.${COLOR_RESET} Verify with ${COLOR_BOLD}/claude-mem-status${COLOR_RESET} in any OpenClaw chat" echo -e " ${COLOR_CYAN}3.${COLOR_RESET} Check the viewer UI at ${COLOR_BOLD}http://localhost:37777${COLOR_RESET}" if [[ "$FEED_CONFIGURED" == "true" ]]; then echo -e " ${COLOR_CYAN}4.${COLOR_RESET} Run ${COLOR_BOLD}/claude-mem-feed${COLOR_RESET} to check feed status" fi echo "" echo -e " ${COLOR_BOLD}To re-run this installer:${COLOR_RESET}" echo " bash <(curl -fsSL https://install.cmem.ai/openclaw.sh)" echo "" } ############################################################################### # Main ############################################################################### main() { setup_tty print_banner detect_platform # --- Step 1: Dependencies --- echo "" info "${COLOR_BOLD}[1/8]${COLOR_RESET} Checking dependencies..." echo "" if ! check_bun; then install_bun fi if ! check_uv; then install_uv fi echo "" success "All dependencies satisfied" # --- Step 2: OpenClaw gateway --- echo "" info "${COLOR_BOLD}[2/8]${COLOR_RESET} Locating OpenClaw gateway..." check_openclaw # --- Step 3: Plugin installation (skip if upgrading and already installed) --- echo "" info "${COLOR_BOLD}[3/8]${COLOR_RESET} Installing claude-mem plugin..." if [[ "$UPGRADE_MODE" == "true" ]] && is_claude_mem_installed; then success "claude-mem already installed at ${CLAUDE_MEM_INSTALL_DIR}" info "Upgrade mode: skipping clone/build/register, updating settings only" else install_plugin fi # --- Step 4: Memory slot configuration --- echo "" info "${COLOR_BOLD}[4/8]${COLOR_RESET} Configuring memory slot..." configure_memory_slot # --- Step 5: AI provider setup --- echo "" info "${COLOR_BOLD}[5/8]${COLOR_RESET} AI provider setup..." setup_ai_provider # --- Step 6: Write settings --- echo "" info "${COLOR_BOLD}[6/8]${COLOR_RESET} Writing settings..." write_settings # --- Step 7: Start worker and verify --- echo "" info "${COLOR_BOLD}[7/8]${COLOR_RESET} Starting worker service..." if check_port_37777; then warn "Port 37777 is already in use (worker may already be running)" info "Checking if the existing service is healthy..." if verify_health; then # verify_health already called parse_health_json — WORKER_* globals are set. # Determine the expected version from the installed plugin's package.json. local expected_version="" if [[ -n "$CLAUDE_MEM_INSTALL_DIR" ]] || find_claude_mem_install_dir; then expected_version="$(INSTALLER_PKG="${CLAUDE_MEM_INSTALL_DIR}/package.json" node -e " try { process.stdout.write(JSON.parse(require('fs').readFileSync(process.env.INSTALLER_PKG, 'utf8')).version || ''); } catch(e) {} " 2>/dev/null)" || true fi local needs_restart="" # If we just installed fresh plugin files, always restart the worker # to pick up the new version — even if the old worker was healthy. if [[ "$PLUGIN_FRESHLY_INSTALLED" == "true" ]]; then if [[ -n "$WORKER_VERSION" && -n "$expected_version" && "$WORKER_VERSION" != "$expected_version" ]]; then info "Upgrading worker from v${WORKER_VERSION} to v${expected_version}..." else info "Plugin files updated — restarting worker to load new code..." fi needs_restart="true" fi # Check if worker version is outdated compared to installed version if [[ "$needs_restart" != "true" && -n "$WORKER_VERSION" && -n "$expected_version" && "$WORKER_VERSION" != "$expected_version" ]]; then info "Upgrading worker from v${WORKER_VERSION} to v${expected_version}..." needs_restart="true" fi # Check if AI provider doesn't match current configuration if [[ "$needs_restart" != "true" && -n "$WORKER_AI_PROVIDER" && -n "$AI_PROVIDER" && "$WORKER_AI_PROVIDER" != "$AI_PROVIDER" ]]; then warn "Worker is using ${WORKER_AI_PROVIDER} but you configured ${AI_PROVIDER} — restarting to apply" needs_restart="true" fi # Restart worker if needed: kill old process, start fresh if [[ "$needs_restart" == "true" ]]; then info "Stopping existing worker..." # Try graceful shutdown via API first, fall back to SIGTERM curl -s -X POST "http://127.0.0.1:37777/api/admin/shutdown" >/dev/null 2>&1 || true sleep 2 # If still running, send SIGTERM to known PID if check_port_37777; then if [[ -n "$WORKER_REPORTED_PID" ]]; then kill "$WORKER_REPORTED_PID" 2>/dev/null || true sleep 1 fi # Check PID file as fallback local pid_file="${HOME}/.claude-mem/worker.pid" if [[ -f "$pid_file" ]]; then local file_pid file_pid="$(INSTALLER_PID_FILE="$pid_file" node -e " try { process.stdout.write(String(JSON.parse(require('fs').readFileSync(process.env.INSTALLER_PID_FILE, 'utf8')).pid || '')); } catch(e) {} " 2>/dev/null)" || true if [[ -n "$file_pid" ]]; then kill "$file_pid" 2>/dev/null || true sleep 1 fi fi fi # Start fresh worker if start_worker; then verify_health || true else warn "Worker restart failed — you can start it manually later" fi else # No restart needed — show healthy status local uptime_display="" if [[ -n "$WORKER_UPTIME" && "$WORKER_UPTIME" =~ ^[0-9]+$ && "$WORKER_UPTIME" != "0" ]]; then uptime_display="$(format_uptime_ms "$WORKER_UPTIME")" fi local status_parts="" if [[ -n "$WORKER_VERSION" ]]; then status_parts="v${WORKER_VERSION}" fi if [[ -n "$WORKER_AI_PROVIDER" ]]; then status_parts="${status_parts:+${status_parts}, }${WORKER_AI_PROVIDER}" fi if [[ -n "$uptime_display" ]]; then status_parts="${status_parts:+${status_parts}, }uptime: ${uptime_display}" fi if [[ -n "$status_parts" ]]; then success "Existing worker is healthy (${status_parts}) — skipping startup" else success "Existing worker is healthy — skipping startup" fi fi else warn "Port 37777 is occupied but not responding to health checks" warn "Another process may be using this port. Stop it and re-run the installer," warn "or change CLAUDE_MEM_WORKER_PORT in ~/.claude-mem/settings.json" fi else if start_worker; then verify_health || true else warn "Worker startup failed — you can start it manually later" warn " cd ~/.openclaw/extensions/claude-mem && bun plugin/scripts/worker-service.cjs" fi fi # --- Step 8: Observation feed setup (optional) --- echo "" info "${COLOR_BOLD}[8/8]${COLOR_RESET} Observation feed setup..." setup_observation_feed write_observation_feed_config # --- Completion --- print_completion_summary } main "$@"