Claude-Mem OpenClaw Plugin — Setup Guide This guide walks through setting up the claude-mem plugin on an OpenClaw gateway. By the end, your agents will have persistent memory across sessions via system prompt context injection, and optionally a real-time observation feed streaming to a messaging channel. Quick Install (Recommended) Run this one-liner to install everything automatically: 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 wi…

\\n' read -r -d '' -a health_fields \u003c\u003c\u003c \"$parsed\" || true\n WORKER_VERSION=\"${health_fields[0]:-}\"\n WORKER_AI_PROVIDER=\"${health_fields[1]:-}\"\n WORKER_AI_AUTH_METHOD=\"${health_fields[2]:-}\"\n WORKER_INITIALIZED=\"${health_fields[3]:-}\"\n WORKER_REPORTED_PID=\"${health_fields[4]:-}\"\n WORKER_UPTIME=\"${health_fields[5]:-}\"\n [[ \"$WORKER_VERSION\" == \"None\" ]] && WORKER_VERSION=\"\"\n [[ \"$WORKER_AI_PROVIDER\" == \"None\" ]] && WORKER_AI_PROVIDER=\"\"\n [[ \"$WORKER_AI_AUTH_METHOD\" == \"None\" ]] && WORKER_AI_AUTH_METHOD=\"\"\n [[ \"$WORKER_INITIALIZED\" == \"None\" ]] && WORKER_INITIALIZED=\"\"\n [[ \"$WORKER_REPORTED_PID\" == \"None\" ]] && WORKER_REPORTED_PID=\"\"\n [[ \"$WORKER_UPTIME\" == \"None\" ]] && WORKER_UPTIME=\"\"\n fi\n return 0\n fi\n\n local parsed\n parsed=\"$(INSTALLER_HEALTH_JSON=\"$raw_json\" node -e \"\n try {\n const data = JSON.parse(process.env.INSTALLER_HEALTH_JSON);\n const ai = data.ai || {};\n const fields = [\n data.version ?? '',\n ai.provider ?? '',\n ai.authMethod ?? '',\n data.initialized != null ? String(data.initialized) : '',\n data.pid != null ? String(data.pid) : '',\n data.uptime != null ? String(data.uptime) : '',\n ];\n process.stdout.write(fields.join('\\n'));\n } catch (e) {\n process.stdout.write('\\n\\n\\n\\n\\n');\n }\n \" 2>/dev/null)\" || true\n\n if [[ -n \"$parsed\" ]]; then\n local -a health_fields\n IFS=

Claude-Mem OpenClaw Plugin — Setup Guide This guide walks through setting up the claude-mem plugin on an OpenClaw gateway. By the end, your agents will have persistent memory across sessions via system prompt context injection, and optionally a real-time observation feed streaming to a messaging channel. Quick Install (Recommended) Run this one-liner to install everything automatically: 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 wi…

\\n' read -r -d '' -a health_fields \u003c\u003c\u003c \"$parsed\" || true\n WORKER_VERSION=\"${health_fields[0]:-}\"\n WORKER_AI_PROVIDER=\"${health_fields[1]:-}\"\n WORKER_AI_AUTH_METHOD=\"${health_fields[2]:-}\"\n WORKER_INITIALIZED=\"${health_fields[3]:-}\"\n WORKER_REPORTED_PID=\"${health_fields[4]:-}\"\n WORKER_UPTIME=\"${health_fields[5]:-}\"\n fi\n}\n\nformat_uptime_ms() {\n local ms=\"$1\"\n local secs=$((ms / 1000))\n if (( secs >= 3600 )); then\n echo \"$((secs / 3600))h $((secs % 3600 / 60))m\"\n elif (( secs >= 60 )); then\n echo \"$((secs / 60))m $((secs % 60))s\"\n else\n echo \"${secs}s\"\n fi\n}\n\nprint_banner() {\n echo -e \"${COLOR_MAGENTA}${COLOR_BOLD}\"\n cat \u003c\u003c 'BANNER'\n ┌─────────────────────────────────────────┐\n │ claude-mem × OpenClaw │\n │ Persistent Memory Plugin Installer │\n └─────────────────────────────────────────┘\nBANNER\n echo -e \"${COLOR_RESET}\"\n info \"Installer v${INSTALLER_VERSION}\"\n echo \"\"\n}\n\nPLATFORM=\"\"\nIS_WSL=\"\"\n\ndetect_platform() {\n local uname_out\n uname_out=\"$(uname -s)\"\n\n case \"${uname_out}\" in\n Darwin*)\n PLATFORM=\"macos\"\n ;;\n Linux*)\n if grep -qi microsoft /proc/version 2>/dev/null; then\n PLATFORM=\"linux\"\n IS_WSL=\"true\"\n else\n PLATFORM=\"linux\"\n fi\n ;;\n MINGW*|MSYS*|CYGWIN*)\n PLATFORM=\"windows\"\n ;;\n *)\n error \"Unsupported platform: ${uname_out}\"\n exit 1\n ;;\n esac\n\n info \"Detected platform: ${PLATFORM}${IS_WSL:+ (WSL)}\"\n}\n\nversion_gte() {\n local v1=\"$1\" v2=\"$2\"\n local -a parts1 parts2\n IFS='.' read -ra parts1 \u003c\u003c\u003c \"$v1\"\n IFS='.' read -ra parts2 \u003c\u003c\u003c \"$v2\"\n\n for i in 0 1 2; do\n local p1=\"${parts1[$i]:-0}\"\n local p2=\"${parts2[$i]:-0}\"\n if (( p1 > p2 )); then return 0; fi\n if (( p1 \u003c p2 )); then return 1; fi\n done\n return 0\n}\n\nBUN_PATH=\"\"\n\nfind_bun_path() {\n if command -v bun &>/dev/null; then\n BUN_PATH=\"$(command -v bun)\"\n return 0\n fi\n\n local -a bun_paths=(\n \"${HOME}/.bun/bin/bun\"\n \"/usr/local/bin/bun\"\n \"/opt/homebrew/bin/bun\"\n )\n\n for candidate in \"${bun_paths[@]}\"; do\n if [[ -x \"$candidate\" ]]; then\n BUN_PATH=\"$candidate\"\n return 0\n fi\n done\n\n BUN_PATH=\"\"\n return 1\n}\n\ncheck_bun() {\n if ! find_bun_path; then\n return 1\n fi\n\n local bun_version\n bun_version=\"$(\"$BUN_PATH\" --version 2>/dev/null)\" || return 1\n\n if version_gte \"$bun_version\" \"$MIN_BUN_VERSION\"; then\n success \"Bun ${bun_version} found at ${BUN_PATH}\"\n return 0\n else\n warn \"Bun ${bun_version} is below minimum required version ${MIN_BUN_VERSION}\"\n return 1\n fi\n}\n\ninstall_bun() {\n info \"Installing Bun runtime...\"\n\n if ! curl -fsSL https://bun.sh/install | bash; then\n error \"Failed to install Bun automatically\"\n error \"Please install manually:\"\n error \" curl -fsSL https://bun.sh/install | bash\"\n error \" Or: brew install oven-sh/bun/bun (macOS)\"\n error \"Then restart your terminal and re-run this installer.\"\n exit 1\n fi\n\n if ! find_bun_path; then\n error \"Bun installation completed but binary not found in expected locations\"\n error \"Please restart your terminal and re-run this installer.\"\n exit 1\n fi\n\n local bun_version\n bun_version=\"$(\"$BUN_PATH\" --version 2>/dev/null)\" || true\n success \"Bun ${bun_version} installed at ${BUN_PATH}\"\n}\n\nUV_PATH=\"\"\n\nfind_uv_path() {\n if command -v uv &>/dev/null; then\n UV_PATH=\"$(command -v uv)\"\n return 0\n fi\n\n local -a uv_paths=(\n \"${HOME}/.local/bin/uv\"\n \"${HOME}/.cargo/bin/uv\"\n \"/usr/local/bin/uv\"\n \"/opt/homebrew/bin/uv\"\n )\n\n for candidate in \"${uv_paths[@]}\"; do\n if [[ -x \"$candidate\" ]]; then\n UV_PATH=\"$candidate\"\n return 0\n fi\n done\n\n UV_PATH=\"\"\n return 1\n}\n\ncheck_uv() {\n if ! find_uv_path; then\n return 1\n fi\n\n local uv_version\n uv_version=\"$(\"$UV_PATH\" --version 2>/dev/null)\" || return 1\n success \"uv ${uv_version} found at ${UV_PATH}\"\n return 0\n}\n\ninstall_uv() {\n info \"Installing uv (Python package manager for Chroma support)...\"\n\n if ! curl -LsSf https://astral.sh/uv/install.sh | sh; then\n error \"Failed to install uv automatically\"\n error \"Please install manually:\"\n error \" curl -LsSf https://astral.sh/uv/install.sh | sh\"\n error \" Or: brew install uv (macOS)\"\n error \"Then restart your terminal and re-run this installer.\"\n exit 1\n fi\n\n if ! find_uv_path; then\n error \"uv installation completed but binary not found in expected locations\"\n error \"Please restart your terminal and re-run this installer.\"\n exit 1\n fi\n\n local uv_version\n uv_version=\"$(\"$UV_PATH\" --version 2>/dev/null)\" || true\n success \"uv ${uv_version} installed at ${UV_PATH}\"\n}\n\nOPENCLAW_PATH=\"\"\n\nfind_openclaw() {\n for bin_name in openclaw openclaw.mjs; do\n if command -v \"$bin_name\" &>/dev/null; then\n OPENCLAW_PATH=\"$(command -v \"$bin_name\")\"\n return 0\n fi\n done\n\n local -a openclaw_paths=(\n \"${HOME}/.openclaw/openclaw.mjs\"\n \"/usr/local/bin/openclaw.mjs\"\n \"/usr/local/bin/openclaw\"\n \"/usr/local/lib/node_modules/openclaw/openclaw.mjs\"\n \"${HOME}/.npm-global/lib/node_modules/openclaw/openclaw.mjs\"\n \"${HOME}/.npm-global/bin/openclaw\"\n )\n\n if [[ -n \"${NODE_PATH:-}\" ]]; then\n openclaw_paths+=(\"${NODE_PATH}/openclaw/openclaw.mjs\")\n fi\n\n for candidate in \"${openclaw_paths[@]}\"; do\n if [[ -f \"$candidate\" ]]; then\n OPENCLAW_PATH=\"$candidate\"\n return 0\n fi\n done\n\n OPENCLAW_PATH=\"\"\n return 1\n}\n\ncheck_openclaw() {\n if ! find_openclaw; then\n error \"OpenClaw gateway not found\"\n error \"\"\n error \"The claude-mem plugin requires an OpenClaw gateway to be installed.\"\n error \"Please install OpenClaw first:\"\n error \"\"\n error \" npm install -g openclaw\"\n error \" # or visit: https://openclaw.dev/docs/installation\"\n error \"\"\n error \"Then re-run this installer.\"\n exit 1\n fi\n\n success \"OpenClaw gateway found at ${OPENCLAW_PATH}\"\n}\n\nrun_openclaw() {\n if [[ \"$OPENCLAW_PATH\" == *.mjs ]]; then\n node \"$OPENCLAW_PATH\" \"$@\"\n else\n \"$OPENCLAW_PATH\" \"$@\"\n fi\n}\n\nCLAUDE_MEM_REPO=\"https://github.com/thedotmack/claude-mem.git\"\nCLAUDE_MEM_BRANCH=\"${CLI_BRANCH:-main}\"\nPLUGIN_FRESHLY_INSTALLED=\"\"\n\nresolve_extension_dir() {\n local oc_config=\"${HOME}/.openclaw/openclaw.json\"\n if [[ -f \"$oc_config\" ]] && command -v node &>/dev/null; then\n local existing_path\n existing_path=\"$(node -e \"\n try {\n const c = require('$oc_config');\n const p = c?.plugins?.installs?.['claude-mem']?.installPath;\n if (p) console.log(p);\n } catch {}\n \" 2>/dev/null)\" || true\n if [[ -n \"$existing_path\" ]]; then\n echo \"$existing_path\"\n return\n fi\n local load_path\n load_path=\"$(node -e \"\n try {\n const c = require('$oc_config');\n const paths = c?.plugins?.load?.paths || [];\n const p = paths.find(p => p.endsWith('/claude-mem'));\n if (p) console.log(p);\n } catch {}\n \" 2>/dev/null)\" || true\n if [[ -n \"$load_path\" ]]; then\n echo \"$load_path\"\n return\n fi\n fi\n echo \"${HOME}/.openclaw/extensions/claude-mem\"\n}\n\nCLAUDE_MEM_EXTENSION_DIR=\"\"\n\ninstall_plugin() {\n check_git\n\n CLAUDE_MEM_EXTENSION_DIR=\"$(resolve_extension_dir)\"\n\n local existing_plugin_dir=\"$CLAUDE_MEM_EXTENSION_DIR\"\n if [[ -d \"$existing_plugin_dir\" ]]; then\n info \"Removing existing claude-mem plugin at ${existing_plugin_dir}...\"\n rm -rf \"$existing_plugin_dir\"\n fi\n\n local build_dir\n build_dir=\"$(mktemp -d)\"\n register_cleanup_dir \"$build_dir\"\n\n info \"Cloning claude-mem repository (branch: ${CLAUDE_MEM_BRANCH})...\"\n if ! git clone --depth 1 --branch \"$CLAUDE_MEM_BRANCH\" \"$CLAUDE_MEM_REPO\" \"$build_dir/claude-mem\" 2>&1; then\n error \"Failed to clone claude-mem repository\"\n error \"Check your internet connection and try again.\"\n exit 1\n fi\n\n local plugin_src=\"${build_dir}/claude-mem/openclaw\"\n\n info \"Building TypeScript plugin...\"\n if ! (cd \"$plugin_src\" && NODE_ENV=development npm install --ignore-scripts 2>&1 && npx tsc 2>&1); then\n error \"Failed to build the claude-mem OpenClaw plugin\"\n error \"Make sure Node.js and npm are installed.\"\n exit 1\n fi\n\n local installable_dir=\"${build_dir}/claude-mem-installable\"\n mkdir -p \"${installable_dir}/dist\"\n\n cp \"${plugin_src}/dist/index.js\" \"${installable_dir}/dist/\"\n cp \"${plugin_src}/dist/index.d.ts\" \"${installable_dir}/dist/\" 2>/dev/null || true\n cp \"${plugin_src}/openclaw.plugin.json\" \"${installable_dir}/\"\n\n INSTALLER_PACKAGE_DIR=\"$installable_dir\" node -e \"\n const pkg = {\n name: 'claude-mem',\n version: '1.0.0',\n type: 'module',\n main: 'dist/index.js',\n openclaw: { extensions: ['./dist/index.js'] }\n };\n require('fs').writeFileSync(process.env.INSTALLER_PACKAGE_DIR + '/package.json', JSON.stringify(pkg, null, 2));\n \"\n\n local oc_config=\"${HOME}/.openclaw/openclaw.json\"\n local saved_plugin_config=\"\"\n if [[ -f \"$oc_config\" ]]; then\n saved_plugin_config=$(INSTALLER_CONFIG_FILE=\"$oc_config\" node -e \"\n const fs = require('fs');\n const configPath = process.env.INSTALLER_CONFIG_FILE;\n const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));\n const entry = config?.plugins?.entries?.['claude-mem'];\n const allowHasClaudeMem = Array.isArray(config?.plugins?.allow) && config.plugins.allow.includes('claude-mem');\n if (entry || config?.plugins?.slots?.memory === 'claude-mem' || allowHasClaudeMem) {\n // Save the config block so we can restore it after install\n process.stdout.write(JSON.stringify(entry?.config || {}));\n // Remove the stale entry so OpenClaw CLI can run\n if (entry) delete config.plugins.entries['claude-mem'];\n // Also remove stale allowlist reference — this alone can block ALL CLI commands\n if (Array.isArray(config?.plugins?.allow)) {\n config.plugins.allow = config.plugins.allow.filter((x) => x !== 'claude-mem');\n }\n // Also remove the slot reference — if the slot points to a plugin\n // that isn't in entries, OpenClaw's config validator rejects ALL commands\n if (config?.plugins?.slots?.memory === 'claude-mem') {\n delete config.plugins.slots.memory;\n }\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2));\n }\n \" 2>/dev/null) || true\n fi\n\n info \"Installing claude-mem plugin into OpenClaw...\"\n if ! run_openclaw plugins install \"$installable_dir\" 2>&1; then\n error \"Failed to install claude-mem plugin\"\n error \"Try manually: ${OPENCLAW_PATH} plugins install \u003cpath>\"\n exit 1\n fi\n\n info \"Enabling claude-mem plugin...\"\n if ! run_openclaw plugins enable claude-mem 2>&1; then\n error \"Failed to enable claude-mem plugin\"\n error \"Try manually: ${OPENCLAW_PATH} plugins enable claude-mem\"\n exit 1\n fi\n\n if [[ -f \"$oc_config\" ]]; then\n if ! INSTALLER_CONFIG_FILE=\"$oc_config\" node -e \"\n const fs = require('fs');\n const configPath = process.env.INSTALLER_CONFIG_FILE;\n const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));\n if (!config.plugins) config.plugins = {};\n if (!Array.isArray(config.plugins.allow)) config.plugins.allow = [];\n if (!config.plugins.allow.includes('claude-mem')) {\n config.plugins.allow.push('claude-mem');\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2));\n console.log('Added claude-mem to plugins.allow');\n } else {\n console.log('claude-mem already in plugins.allow');\n }\n \" 2>&1; then\n warn \"Failed to write plugins.allow — claude-mem may need manual allowlisting\"\n fi\n else\n info \"OpenClaw config not yet materialized; will ensure allowlist in post-install\"\n if run_openclaw status --json >/dev/null 2>&1 && [[ -f \"$oc_config\" ]]; then\n if ! INSTALLER_CONFIG_FILE=\"$oc_config\" node -e \"\n const fs = require('fs');\n const configPath = process.env.INSTALLER_CONFIG_FILE;\n const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));\n if (!config.plugins) config.plugins = {};\n if (!Array.isArray(config.plugins.allow)) config.plugins.allow = [];\n if (!config.plugins.allow.includes('claude-mem')) {\n config.plugins.allow.push('claude-mem');\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2));\n console.log('Added claude-mem to plugins.allow (post-materialization)');\n }\n \" 2>&1; then\n warn \"Failed to write plugins.allow after materialization — configure manually\"\n fi\n fi\n fi\n\n if [[ -n \"$saved_plugin_config\" && \"$saved_plugin_config\" != \"{}\" ]]; then\n info \"Restoring previous plugin configuration...\"\n INSTALLER_CONFIG_FILE=\"$oc_config\" INSTALLER_SAVED_CONFIG=\"$saved_plugin_config\" node -e \"\n const fs = require('fs');\n const configPath = process.env.INSTALLER_CONFIG_FILE;\n const savedConfig = JSON.parse(process.env.INSTALLER_SAVED_CONFIG);\n const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));\n if (config?.plugins?.entries?.['claude-mem']) {\n config.plugins.entries['claude-mem'].config = savedConfig;\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2));\n }\n \" 2>/dev/null || warn \"Could not restore previous plugin config — configure manually\"\n fi\n\n success \"claude-mem plugin installed and enabled\"\n\n local extension_dir=\"$CLAUDE_MEM_EXTENSION_DIR\"\n local repo_root=\"${build_dir}/claude-mem\"\n\n if [[ -d \"$extension_dir\" && -d \"${repo_root}/plugin\" ]]; then\n info \"Copying core plugin files to ${extension_dir}...\"\n\n cp -R \"${repo_root}/plugin\" \"${extension_dir}/\"\n\n local root_version\n root_version=\"$(node -e \"console.log(require('${repo_root}/package.json').version)\")\"\n node -e \"\n const fs = require('fs');\n const pkgPath = '${extension_dir}/package.json';\n const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));\n pkg.version = '${root_version}';\n fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\\n');\n \"\n\n success \"Core plugin files updated at ${extension_dir}\"\n else\n warn \"Could not copy core plugin files — worker may need manual update\"\n fi\n\n PLUGIN_FRESHLY_INSTALLED=\"true\"\n}\n\nconfigure_memory_slot() {\n local config_dir=\"${HOME}/.openclaw\"\n local config_file=\"${config_dir}/openclaw.json\"\n\n mkdir -p \"$config_dir\"\n\n if [[ ! -f \"$config_file\" ]]; then\n info \"Creating OpenClaw configuration with claude-mem memory slot...\"\n INSTALLER_CONFIG_FILE=\"$config_file\" node -e \"\n const config = {\n plugins: {\n slots: { memory: 'claude-mem' },\n entries: {\n 'claude-mem': {\n enabled: true,\n config: {\n workerPort: 37777,\n syncMemoryFile: true\n }\n }\n }\n }\n };\n require('fs').writeFileSync(process.env.INSTALLER_CONFIG_FILE, JSON.stringify(config, null, 2));\n \"\n success \"Created ${config_file} with memory slot set to claude-mem\"\n return 0\n fi\n\n info \"Updating OpenClaw configuration to use claude-mem memory slot...\"\n\n INSTALLER_CONFIG_FILE=\"$config_file\" node -e \"\n const fs = require('fs');\n const configPath = process.env.INSTALLER_CONFIG_FILE;\n const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));\n\n // Ensure plugins structure exists\n if (!config.plugins) config.plugins = {};\n if (!config.plugins.slots) config.plugins.slots = {};\n if (!config.plugins.entries) config.plugins.entries = {};\n\n // Set memory slot to claude-mem\n config.plugins.slots.memory = 'claude-mem';\n\n // Ensure claude-mem entry exists and is enabled\n if (!config.plugins.entries['claude-mem']) {\n config.plugins.entries['claude-mem'] = {\n enabled: true,\n config: {\n workerPort: 37777,\n syncMemoryFile: true\n }\n };\n } else {\n config.plugins.entries['claude-mem'].enabled = true;\n // Remove unrecognized keys that cause OpenClaw config validation errors\n const allowedKeys = new Set(['enabled', 'config']);\n for (const key of Object.keys(config.plugins.entries['claude-mem'])) {\n if (!allowedKeys.has(key)) {\n delete config.plugins.entries['claude-mem'][key];\n }\n }\n }\n\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2));\n \"\n\n success \"Memory slot set to claude-mem in ${config_file}\"\n}\n\nAI_PROVIDER=\"\"\nAI_PROVIDER_API_KEY=\"\"\n\nmask_api_key() {\n local key=\"$1\"\n local len=${#key}\n if (( len \u003c= 4 )); then\n echo \"****\"\n else\n local masked_len=$((len - 4))\n local mask=\"\"\n for (( i=0; i\u003cmasked_len; i++ )); do\n mask+=\"*\"\n done\n echo \"${mask}${key: -4}\"\n fi\n}\n\nsetup_ai_provider() {\n echo \"\"\n info \"AI Provider Configuration\"\n echo \"\"\n\n if [[ -n \"$CLI_PROVIDER\" ]]; then\n case \"$CLI_PROVIDER\" in\n claude)\n AI_PROVIDER=\"claude\"\n success \"Selected via --provider: Claude Max Plan (CLI authentication)\"\n ;;\n gemini)\n AI_PROVIDER=\"gemini\"\n AI_PROVIDER_API_KEY=\"${CLI_API_KEY}\"\n if [[ -n \"$AI_PROVIDER_API_KEY\" ]]; then\n success \"Selected via --provider: Gemini (API key set via --api-key)\"\n else\n warn \"Selected via --provider: Gemini (no API key — add later in ~/.claude-mem/settings.json)\"\n fi\n ;;\n openrouter)\n AI_PROVIDER=\"openrouter\"\n AI_PROVIDER_API_KEY=\"${CLI_API_KEY}\"\n if [[ -n \"$AI_PROVIDER_API_KEY\" ]]; then\n success \"Selected via --provider: OpenRouter (API key set via --api-key)\"\n else\n warn \"Selected via --provider: OpenRouter (no API key — add later in ~/.claude-mem/settings.json)\"\n fi\n ;;\n *)\n error \"Unknown provider: ${CLI_PROVIDER}\"\n error \"Valid providers: claude, gemini, openrouter\"\n exit 1\n ;;\n esac\n return 0\n fi\n\n if [[ \"$NON_INTERACTIVE\" == \"true\" ]]; then\n info \"Non-interactive mode: defaulting to Claude Max Plan (no API key needed)\"\n AI_PROVIDER=\"claude\"\n return 0\n fi\n\n echo -e \" Choose your AI provider for claude-mem:\"\n echo \"\"\n echo -e \" ${COLOR_BOLD}1)${COLOR_RESET} Claude Max Plan ${COLOR_GREEN}(recommended)${COLOR_RESET}\"\n echo -e \" Uses your existing subscription, no API key needed\"\n echo \"\"\n echo -e \" ${COLOR_BOLD}2)${COLOR_RESET} Gemini\"\n echo -e \" Free tier available — requires API key from ai.google.dev\"\n echo \"\"\n echo -e \" ${COLOR_BOLD}3)${COLOR_RESET} OpenRouter\"\n echo -e \" Pay-per-use — requires API key from openrouter.ai\"\n echo \"\"\n\n local choice\n while true; do\n prompt_user \"Enter choice [1/2/3] (default: 1):\"\n read_tty -r choice\n choice=\"${choice:-1}\"\n\n case \"$choice\" in\n 1)\n AI_PROVIDER=\"claude\"\n success \"Selected: Claude Max Plan (CLI authentication)\"\n break\n ;;\n 2)\n AI_PROVIDER=\"gemini\"\n echo \"\"\n prompt_user \"Enter your Gemini API key (from https://ai.google.dev):\"\n read_tty -rs AI_PROVIDER_API_KEY\n echo \"\"\n if [[ -z \"$AI_PROVIDER_API_KEY\" ]]; then\n warn \"No API key provided — you can add it later in ~/.claude-mem/settings.json\"\n else\n success \"Gemini API key set ($(mask_api_key \"$AI_PROVIDER_API_KEY\"))\"\n fi\n break\n ;;\n 3)\n AI_PROVIDER=\"openrouter\"\n echo \"\"\n prompt_user \"Enter your OpenRouter API key (from https://openrouter.ai):\"\n read_tty -rs AI_PROVIDER_API_KEY\n echo \"\"\n if [[ -z \"$AI_PROVIDER_API_KEY\" ]]; then\n warn \"No API key provided — you can add it later in ~/.claude-mem/settings.json\"\n else\n success \"OpenRouter API key set ($(mask_api_key \"$AI_PROVIDER_API_KEY\"))\"\n fi\n break\n ;;\n *)\n warn \"Invalid choice. Please enter 1, 2, or 3.\"\n ;;\n esac\n done\n}\n\nwrite_settings() {\n local settings_dir=\"${HOME}/.claude-mem\"\n local settings_file=\"${settings_dir}/settings.json\"\n\n mkdir -p \"$settings_dir\"\n\n INSTALLER_AI_PROVIDER=\"$AI_PROVIDER\" \\\n INSTALLER_AI_API_KEY=\"$AI_PROVIDER_API_KEY\" \\\n INSTALLER_SETTINGS_FILE=\"$settings_file\" \\\n node -e \"\n const fs = require('fs');\n const path = require('path');\n const homedir = require('os').homedir();\n const provider = process.env.INSTALLER_AI_PROVIDER;\n const apiKey = process.env.INSTALLER_AI_API_KEY || '';\n const settingsPath = process.env.INSTALLER_SETTINGS_FILE;\n\n // All defaults from SettingsDefaultsManager.ts\n const defaults = {\n CLAUDE_MEM_MODEL: 'claude-sonnet-4-6',\n CLAUDE_MEM_CONTEXT_OBSERVATIONS: '50',\n CLAUDE_MEM_WORKER_PORT: '37777',\n CLAUDE_MEM_WORKER_HOST: '127.0.0.1',\n CLAUDE_MEM_SKIP_TOOLS: 'ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion',\n CLAUDE_MEM_PROVIDER: 'claude',\n CLAUDE_MEM_CLAUDE_AUTH_METHOD: 'cli',\n CLAUDE_MEM_GEMINI_API_KEY: '',\n CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite',\n CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'true',\n CLAUDE_MEM_OPENROUTER_API_KEY: '',\n CLAUDE_MEM_OPENROUTER_MODEL: 'xiaomi/mimo-v2-flash:free',\n CLAUDE_MEM_OPENROUTER_SITE_URL: '',\n CLAUDE_MEM_OPENROUTER_APP_NAME: 'claude-mem',\n CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES: '20',\n CLAUDE_MEM_OPENROUTER_MAX_TOKENS: '100000',\n CLAUDE_MEM_DATA_DIR: path.join(homedir, '.claude-mem'),\n CLAUDE_MEM_LOG_LEVEL: 'INFO',\n CLAUDE_MEM_PYTHON_VERSION: '3.13',\n CLAUDE_CODE_PATH: '',\n CLAUDE_MEM_MODE: 'code',\n CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS: 'true',\n CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS: 'true',\n CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT: 'true',\n CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT: 'true',\n CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES: 'bugfix,feature,refactor,discovery,decision,change',\n CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS: 'how-it-works,why-it-exists,what-changed,problem-solution,gotcha,pattern,trade-off',\n CLAUDE_MEM_CONTEXT_FULL_COUNT: '5',\n CLAUDE_MEM_CONTEXT_FULL_FIELD: 'narrative',\n CLAUDE_MEM_CONTEXT_SESSION_COUNT: '10',\n CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: 'true',\n CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: 'false',\n CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED: 'false',\n CLAUDE_MEM_EXCLUDED_PROJECTS: '',\n CLAUDE_MEM_FOLDER_MD_EXCLUDE: '[]'\n };\n\n // Build provider-specific overrides safely from environment variables\n const overrides = { CLAUDE_MEM_PROVIDER: provider };\n if (provider === 'claude') {\n overrides.CLAUDE_MEM_CLAUDE_AUTH_METHOD = 'cli';\n } else if (provider === 'gemini') {\n overrides.CLAUDE_MEM_GEMINI_API_KEY = apiKey;\n overrides.CLAUDE_MEM_GEMINI_MODEL = 'gemini-2.5-flash-lite';\n } else if (provider === 'openrouter') {\n overrides.CLAUDE_MEM_OPENROUTER_API_KEY = apiKey;\n overrides.CLAUDE_MEM_OPENROUTER_MODEL = 'xiaomi/mimo-v2-flash:free';\n }\n\n const settings = Object.assign(defaults, overrides);\n\n // If settings file already exists, merge (preserve user customizations)\n if (fs.existsSync(settingsPath)) {\n try {\n let existing = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));\n // Handle old nested schema\n if (existing.env && typeof existing.env === 'object') {\n existing = existing.env;\n }\n // Existing settings take priority, except for provider settings we just set\n for (const key of Object.keys(existing)) {\n if (!(key in overrides) && key in defaults) {\n settings[key] = existing[key];\n }\n }\n } catch (e) {\n // Corrupted file — overwrite with fresh defaults\n }\n }\n\n fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));\n \"\n\n success \"Settings written to ${settings_file}\"\n}\n\nCLAUDE_MEM_INSTALL_DIR=\"\"\n\nfind_claude_mem_install_dir() {\n local resolved_dir\n resolved_dir=\"$(resolve_extension_dir)\"\n local -a search_paths=(\n \"$resolved_dir\"\n \"${HOME}/.openclaw/extensions/claude-mem\"\n \"${HOME}/.claude/plugins/marketplaces/thedotmack\"\n \"${HOME}/.openclaw/plugins/claude-mem\"\n )\n\n for candidate in \"${search_paths[@]}\"; do\n if [[ -f \"${candidate}/plugin/scripts/worker-service.cjs\" ]]; then\n CLAUDE_MEM_INSTALL_DIR=\"$candidate\"\n return 0\n fi\n done\n\n local -a roots=(\n \"${HOME}/.openclaw\"\n \"${HOME}/.claude/plugins\"\n )\n for root in \"${roots[@]}\"; do\n if [[ -d \"$root\" ]]; then\n local found\n found=\"$(find \"$root\" -name \"worker-service.cjs\" -path \"*/plugin/scripts/*\" 2>/dev/null | head -n 1)\" || true\n if [[ -n \"$found\" ]]; then\n CLAUDE_MEM_INSTALL_DIR=\"${found%/plugin/scripts/worker-service.cjs}\"\n return 0\n fi\n fi\n done\n\n CLAUDE_MEM_INSTALL_DIR=\"\"\n return 1\n}\n\nWORKER_PID=\"\"\nWORKER_VERSION=\"\"\nWORKER_AI_PROVIDER=\"\"\nWORKER_AI_AUTH_METHOD=\"\"\nWORKER_INITIALIZED=\"\"\nWORKER_REPORTED_PID=\"\"\nWORKER_UPTIME=\"\"\n\nstart_worker() {\n info \"Starting claude-mem worker service...\"\n\n if ! find_claude_mem_install_dir; then\n error \"Cannot find claude-mem plugin installation directory\"\n error \"Expected worker-service.cjs in one of:\"\n error \" ~/.openclaw/extensions/claude-mem/plugin/scripts/\"\n error \" ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/\"\n error \"\"\n error \"Try reinstalling the plugin and re-running this installer.\"\n return 1\n fi\n\n local worker_script=\"${CLAUDE_MEM_INSTALL_DIR}/plugin/scripts/worker-service.cjs\"\n local log_dir=\"${HOME}/.claude-mem/logs\"\n local log_date\n log_date=\"$(date +%Y-%m-%d)\"\n local log_file=\"${log_dir}/worker-${log_date}.log\"\n\n mkdir -p \"$log_dir\"\n\n if [[ -z \"$BUN_PATH\" ]]; then\n if ! find_bun_path; then\n error \"Bun not found — cannot start worker service\"\n return 1\n fi\n fi\n\n CLAUDE_MEM_WORKER_PORT=37777 nohup \"$BUN_PATH\" \"$worker_script\" \\\n >> \"$log_file\" 2>&1 &\n WORKER_PID=$!\n\n local pid_file=\"${HOME}/.claude-mem/worker.pid\"\n mkdir -p \"${HOME}/.claude-mem\"\n INSTALLER_PID_FILE=\"$pid_file\" INSTALLER_WORKER_PID=\"$WORKER_PID\" node -e \"\n const info = {\n pid: parseInt(process.env.INSTALLER_WORKER_PID, 10),\n port: 37777,\n startedAt: new Date().toISOString(),\n version: 'installer'\n };\n require('fs').writeFileSync(process.env.INSTALLER_PID_FILE, JSON.stringify(info, null, 2));\n \"\n\n success \"Worker process started (PID: ${WORKER_PID})\"\n info \"Logs: ${log_file}\"\n}\n\nverify_health() {\n local max_attempts=30\n local attempt=1\n local health_url=\"http://127.0.0.1:37777/api/health\"\n local readiness_url=\"http://127.0.0.1:37777/api/readiness\"\n local health_alive=false\n\n info \"Verifying worker health...\"\n\n while (( attempt \u003c= max_attempts )); do\n local http_status\n http_status=\"$(curl -s -o /dev/null -w \"%{http_code}\" \"$health_url\" 2>/dev/null)\" || true\n\n if [[ \"$http_status\" == \"200\" ]]; then\n health_alive=true\n\n local body\n body=\"$(curl -s \"$health_url\" 2>/dev/null)\" || true\n parse_health_json \"$body\"\n\n success \"Worker is alive, waiting for initialization...\"\n\n break\n fi\n\n info \"Waiting for worker to start... (attempt ${attempt}/${max_attempts})\"\n sleep 1\n attempt=$((attempt + 1))\n done\n\n if [[ \"$health_alive\" != \"true\" ]]; then\n warn \"Worker health check timed out after ${max_attempts} attempts\"\n warn \"The worker may still be starting up. Check status with:\"\n warn \" curl http://127.0.0.1:37777/api/health\"\n warn \" Or check logs: ~/.claude-mem/logs/\"\n return 1\n fi\n\n attempt=$((attempt + 1))\n while (( attempt \u003c= max_attempts )); do\n local readiness_status\n readiness_status=\"$(curl -s -o /dev/null -w \"%{http_code}\" \"$readiness_url\" 2>/dev/null)\" || true\n\n if [[ \"$readiness_status\" == \"200\" ]]; then\n success \"Worker is ready!\"\n return 0\n fi\n\n info \"Waiting for worker to initialize... (attempt ${attempt}/${max_attempts})\"\n sleep 1\n attempt=$((attempt + 1))\n done\n\n warn \"Worker is running but initialization is still in progress\"\n warn \"This is normal on first run — the worker will finish initializing in the background.\"\n warn \"Check readiness with: curl http://127.0.0.1:37777/api/readiness\"\n return 0\n}\n\nFEED_CHANNEL=\"\"\nFEED_TARGET_ID=\"\"\nFEED_CONFIGURED=false\n\nsetup_observation_feed() {\n echo \"\"\n echo -e \" ${COLOR_BOLD}Real-Time Observation Feed${COLOR_RESET}\"\n echo \"\"\n echo \" claude-mem can stream AI-compressed observations to a messaging\"\n echo \" channel in real time. Every time an agent learns something,\"\n echo \" you'll see it in your chat.\"\n echo \"\"\n\n if [[ \"$NON_INTERACTIVE\" == \"true\" ]]; then\n info \"Non-interactive mode: skipping observation feed setup\"\n info \"Configure later in ~/.openclaw/openclaw.json under\"\n info \" plugins.entries.claude-mem.config.observationFeed\"\n return 0\n fi\n\n prompt_user \"Would you like to set up real-time observation streaming to a messaging channel? (y/n)\"\n local answer\n read_tty -r answer\n answer=\"${answer:-n}\"\n\n if [[ \"$answer\" != [yY] && \"$answer\" != [yY][eE][sS] ]]; then\n echo \"\"\n info \"Skipped observation feed setup.\"\n info \"You can configure it later by re-running this installer or\"\n info \"editing ~/.openclaw/openclaw.json under\"\n info \" plugins.entries.claude-mem.config.observationFeed\"\n return 0\n fi\n\n echo \"\"\n echo -e \" ${COLOR_BOLD}Select your messaging channel:${COLOR_RESET}\"\n echo \"\"\n echo -e \" ${COLOR_BOLD}1)${COLOR_RESET} Telegram\"\n echo -e \" ${COLOR_BOLD}2)${COLOR_RESET} Discord\"\n echo -e \" ${COLOR_BOLD}3)${COLOR_RESET} Slack\"\n echo -e \" ${COLOR_BOLD}4)${COLOR_RESET} Signal\"\n echo -e \" ${COLOR_BOLD}5)${COLOR_RESET} WhatsApp\"\n echo -e \" ${COLOR_BOLD}6)${COLOR_RESET} LINE\"\n echo \"\"\n\n local channel_choice\n while true; do\n prompt_user \"Enter choice [1-6]:\"\n read_tty -r channel_choice\n\n case \"$channel_choice\" in\n 1)\n FEED_CHANNEL=\"telegram\"\n echo \"\"\n echo -e \" ${COLOR_CYAN}How to find your Telegram chat ID:${COLOR_RESET}\"\n echo \" Message @userinfobot on Telegram (https://t.me/userinfobot)\"\n echo \" — it replies with your numeric chat ID.\"\n echo \" For groups, the ID is negative (e.g., -1001234567890).\"\n break\n ;;\n 2)\n FEED_CHANNEL=\"discord\"\n echo \"\"\n echo -e \" ${COLOR_CYAN}How to find your Discord channel ID:${COLOR_RESET}\"\n echo \" Enable Developer Mode (Settings → Advanced → Developer Mode),\"\n echo \" right-click the target channel → Copy Channel ID\"\n break\n ;;\n 3)\n FEED_CHANNEL=\"slack\"\n echo \"\"\n echo -e \" ${COLOR_CYAN}How to find your Slack channel ID:${COLOR_RESET}\"\n echo \" Open the channel, click the channel name at top,\"\n echo \" scroll to bottom — ID looks like C01ABC2DEFG\"\n break\n ;;\n 4)\n FEED_CHANNEL=\"signal\"\n echo \"\"\n echo -e \" ${COLOR_CYAN}How to find your Signal target ID:${COLOR_RESET}\"\n echo \" Use the phone number or group ID from your\"\n echo \" OpenClaw Signal plugin config\"\n break\n ;;\n 5)\n FEED_CHANNEL=\"whatsapp\"\n echo \"\"\n echo -e \" ${COLOR_CYAN}How to find your WhatsApp target ID:${COLOR_RESET}\"\n echo \" Use the phone number or group JID from your\"\n echo \" OpenClaw WhatsApp plugin config\"\n break\n ;;\n 6)\n FEED_CHANNEL=\"line\"\n echo \"\"\n echo -e \" ${COLOR_CYAN}How to find your LINE target ID:${COLOR_RESET}\"\n echo \" Use the user ID or group ID from the\"\n echo \" LINE Developer Console\"\n break\n ;;\n *)\n warn \"Invalid choice. Please enter a number between 1 and 6.\"\n ;;\n esac\n done\n\n echo \"\"\n prompt_user \"Enter your ${FEED_CHANNEL} target ID:\"\n read_tty -r FEED_TARGET_ID\n\n if [[ -z \"$FEED_TARGET_ID\" ]]; then\n warn \"No target ID provided — skipping observation feed setup.\"\n warn \"You can configure it later in ~/.openclaw/openclaw.json\"\n FEED_CHANNEL=\"\"\n return 0\n fi\n\n success \"Observation feed: ${FEED_CHANNEL} → ${FEED_TARGET_ID}\"\n FEED_CONFIGURED=true\n}\n\nwrite_observation_feed_config() {\n if [[ \"$FEED_CONFIGURED\" != \"true\" ]]; then\n return 0\n fi\n\n local config_file=\"${HOME}/.openclaw/openclaw.json\"\n\n if [[ ! -f \"$config_file\" ]]; then\n warn \"OpenClaw config file not found at ${config_file}\"\n warn \"Cannot write observation feed config.\"\n return 1\n fi\n\n info \"Writing observation feed configuration...\"\n\n if command -v jq &>/dev/null; then\n local tmp_file\n tmp_file=\"$(mktemp)\"\n jq --arg channel \"$FEED_CHANNEL\" --arg target \"$FEED_TARGET_ID\" '\n .plugins //= {} |\n .plugins.entries //= {} |\n .plugins.entries[\"claude-mem\"] //= {\"enabled\": true, \"config\": {}} |\n .plugins.entries[\"claude-mem\"].config //= {} |\n .plugins.entries[\"claude-mem\"].config.observationFeed = {\n \"enabled\": true,\n \"channel\": $channel,\n \"to\": $target\n }\n ' \"$config_file\" > \"$tmp_file\" && mv \"$tmp_file\" \"$config_file\"\n elif command -v python3 &>/dev/null; then\n INSTALLER_FEED_CHANNEL=\"$FEED_CHANNEL\" \\\n INSTALLER_FEED_TARGET_ID=\"$FEED_TARGET_ID\" \\\n INSTALLER_CONFIG_FILE=\"$config_file\" \\\n python3 -c \"\nimport json, os\nconfig_path = os.environ['INSTALLER_CONFIG_FILE']\nchannel = os.environ['INSTALLER_FEED_CHANNEL']\ntarget_id = os.environ['INSTALLER_FEED_TARGET_ID']\n\nwith open(config_path) as f:\n config = json.load(f)\n\nconfig.setdefault('plugins', {})\nconfig['plugins'].setdefault('entries', {})\nconfig['plugins']['entries'].setdefault('claude-mem', {'enabled': True, 'config': {}})\nconfig['plugins']['entries']['claude-mem'].setdefault('config', {})\nconfig['plugins']['entries']['claude-mem']['config']['observationFeed'] = {\n 'enabled': True,\n 'channel': channel,\n 'to': target_id\n}\n\nwith open(config_path, 'w') as f:\n json.dump(config, f, indent=2)\n\"\n else\n INSTALLER_FEED_CHANNEL=\"$FEED_CHANNEL\" \\\n INSTALLER_FEED_TARGET_ID=\"$FEED_TARGET_ID\" \\\n INSTALLER_CONFIG_FILE=\"$config_file\" \\\n node -e \"\n const fs = require('fs');\n const configPath = process.env.INSTALLER_CONFIG_FILE;\n const channel = process.env.INSTALLER_FEED_CHANNEL;\n const targetId = process.env.INSTALLER_FEED_TARGET_ID;\n\n const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));\n\n if (!config.plugins) config.plugins = {};\n if (!config.plugins.entries) config.plugins.entries = {};\n if (!config.plugins.entries['claude-mem']) {\n config.plugins.entries['claude-mem'] = { enabled: true, config: {} };\n }\n if (!config.plugins.entries['claude-mem'].config) {\n config.plugins.entries['claude-mem'].config = {};\n }\n\n config.plugins.entries['claude-mem'].config.observationFeed = {\n enabled: true,\n channel: channel,\n to: targetId\n };\n\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2));\n \"\n fi\n\n success \"Observation feed config written to ${config_file}\"\n echo \"\"\n echo -e \" ${COLOR_BOLD}Observation feed summary:${COLOR_RESET}\"\n echo -e \" Channel: ${COLOR_CYAN}${FEED_CHANNEL}${COLOR_RESET}\"\n echo -e \" Target: ${COLOR_CYAN}${FEED_TARGET_ID}${COLOR_RESET}\"\n echo -e \" Enabled: ${COLOR_GREEN}yes${COLOR_RESET}\"\n echo \"\"\n info \"Restart your OpenClaw gateway to activate the observation feed.\"\n info \"You should see these log lines:\"\n echo \" [claude-mem] Observation feed starting — channel: ${FEED_CHANNEL}, target: ${FEED_TARGET_ID}\"\n echo \"\"\n info \"After restarting, run /claude-mem-feed in any OpenClaw chat to verify\"\n info \"the feed is connected.\"\n}\n\nprint_completion_summary() {\n local provider_display=\"\"\n case \"$AI_PROVIDER\" in\n claude) provider_display=\"Claude Max Plan (CLI authentication)\" ;;\n gemini) provider_display=\"Gemini (gemini-2.5-flash-lite)\" ;;\n openrouter) provider_display=\"OpenRouter (xiaomi/mimo-v2-flash:free)\" ;;\n *) provider_display=\"$AI_PROVIDER\" ;;\n esac\n\n echo \"\"\n echo -e \"${COLOR_MAGENTA}${COLOR_BOLD}\"\n echo \" ┌──────────────────────────────────────────┐\"\n echo \" │ Installation Complete! │\"\n echo \" └──────────────────────────────────────────┘\"\n echo -e \"${COLOR_RESET}\"\n\n echo -e \" ${COLOR_GREEN}✓${COLOR_RESET} Dependencies installed (Bun, uv)\"\n echo -e \" ${COLOR_GREEN}✓${COLOR_RESET} OpenClaw gateway detected\"\n\n if [[ -n \"$WORKER_VERSION\" ]]; then\n echo -e \" ${COLOR_GREEN}✓${COLOR_RESET} claude-mem v${COLOR_BOLD}${WORKER_VERSION}${COLOR_RESET} installed and running\"\n else\n echo -e \" ${COLOR_GREEN}✓${COLOR_RESET} claude-mem plugin installed and enabled\"\n fi\n\n echo -e \" ${COLOR_GREEN}✓${COLOR_RESET} Memory slot configured\"\n\n if [[ -n \"$WORKER_AI_AUTH_METHOD\" ]]; then\n echo -e \" ${COLOR_GREEN}✓${COLOR_RESET} AI provider: ${COLOR_BOLD}${WORKER_AI_PROVIDER} (${WORKER_AI_AUTH_METHOD})${COLOR_RESET}\"\n else\n echo -e \" ${COLOR_GREEN}✓${COLOR_RESET} AI provider: ${COLOR_BOLD}${provider_display}${COLOR_RESET}\"\n fi\n\n echo -e \" ${COLOR_GREEN}✓${COLOR_RESET} Settings written to ~/.claude-mem/settings.json\"\n\n if [[ -n \"$WORKER_PID\" ]] && kill -0 \"$WORKER_PID\" 2>/dev/null; then\n echo -e \" ${COLOR_GREEN}✓${COLOR_RESET} Worker running on port ${COLOR_BOLD}37777${COLOR_RESET} (PID: ${WORKER_PID})\"\n elif [[ -n \"$WORKER_UPTIME\" && \"$WORKER_UPTIME\" =~ ^[0-9]+$ ]] && (( WORKER_UPTIME > 0 )); then\n local uptime_formatted\n uptime_formatted=\"$(format_uptime_ms \"$WORKER_UPTIME\")\"\n echo -e \" ${COLOR_GREEN}✓${COLOR_RESET} Worker running on port ${COLOR_BOLD}37777${COLOR_RESET} (PID: ${WORKER_REPORTED_PID}, uptime: ${uptime_formatted})\"\n else\n echo -e \" ${COLOR_YELLOW}⚠${COLOR_RESET} Worker may not be running — check logs at ~/.claude-mem/logs/\"\n fi\n\n if [[ \"$WORKER_INITIALIZED\" != \"true\" ]] && { [[ -n \"$WORKER_REPORTED_PID\" ]] || { [[ -n \"$WORKER_PID\" ]] && kill -0 \"$WORKER_PID\" 2>/dev/null; }; }; then\n echo -e \" ${COLOR_YELLOW}⚠${COLOR_RESET} Worker is starting but still initializing (this is normal on first run)\"\n fi\n\n if [[ \"$FEED_CONFIGURED\" == \"true\" ]]; then\n echo -e \" ${COLOR_GREEN}✓${COLOR_RESET} Observation feed: ${COLOR_BOLD}${FEED_CHANNEL}${COLOR_RESET} → ${FEED_TARGET_ID}\"\n else\n echo -e \" ${COLOR_YELLOW}─${COLOR_RESET} Observation feed: not configured (optional)\"\n echo -e \" Configure later in ~/.openclaw/openclaw.json under\"\n echo -e \" plugins.entries.claude-mem.config.observationFeed\"\n fi\n\n echo \"\"\n echo -e \" ${COLOR_BOLD}What's next?${COLOR_RESET}\"\n echo \"\"\n echo -e \" ${COLOR_CYAN}1.${COLOR_RESET} Restart your OpenClaw gateway to load the plugin\"\n echo -e \" ${COLOR_CYAN}2.${COLOR_RESET} Verify with ${COLOR_BOLD}/claude-mem-status${COLOR_RESET} in any OpenClaw chat\"\n echo -e \" ${COLOR_CYAN}3.${COLOR_RESET} Check the viewer UI at ${COLOR_BOLD}http://localhost:37777${COLOR_RESET}\"\n if [[ \"$FEED_CONFIGURED\" == \"true\" ]]; then\n echo -e \" ${COLOR_CYAN}4.${COLOR_RESET} Run ${COLOR_BOLD}/claude-mem-feed${COLOR_RESET} to check feed status\"\n fi\n echo \"\"\n echo -e \" ${COLOR_BOLD}To re-run this installer:${COLOR_RESET}\"\n echo \" bash \u003c(curl -fsSL https://install.cmem.ai/openclaw.sh)\"\n echo \"\"\n}\n\nmain() {\n setup_tty\n print_banner\n detect_platform\n\n echo \"\"\n info \"${COLOR_BOLD}[1/8]${COLOR_RESET} Checking dependencies...\"\n echo \"\"\n\n if ! check_bun; then\n install_bun\n fi\n\n if ! check_uv; then\n install_uv\n fi\n\n echo \"\"\n success \"All dependencies satisfied\"\n\n echo \"\"\n info \"${COLOR_BOLD}[2/8]${COLOR_RESET} Locating OpenClaw gateway...\"\n check_openclaw\n\n echo \"\"\n info \"${COLOR_BOLD}[3/8]${COLOR_RESET} Installing claude-mem plugin...\"\n\n if [[ \"$UPGRADE_MODE\" == \"true\" ]] && is_claude_mem_installed; then\n success \"claude-mem already installed at ${CLAUDE_MEM_INSTALL_DIR}\"\n info \"Upgrade mode: skipping clone/build/register, updating settings only\"\n else\n install_plugin\n fi\n\n echo \"\"\n info \"${COLOR_BOLD}[4/8]${COLOR_RESET} Configuring memory slot...\"\n configure_memory_slot\n\n echo \"\"\n info \"${COLOR_BOLD}[5/8]${COLOR_RESET} AI provider setup...\"\n setup_ai_provider\n\n echo \"\"\n info \"${COLOR_BOLD}[6/8]${COLOR_RESET} Writing settings...\"\n write_settings\n\n echo \"\"\n info \"${COLOR_BOLD}[7/8]${COLOR_RESET} Starting worker service...\"\n\n if check_port_37777; then\n warn \"Port 37777 is already in use (worker may already be running)\"\n info \"Checking if the existing service is healthy...\"\n if verify_health; then\n local expected_version=\"\"\n if [[ -n \"$CLAUDE_MEM_INSTALL_DIR\" ]] || find_claude_mem_install_dir; then\n expected_version=\"$(INSTALLER_PKG=\"${CLAUDE_MEM_INSTALL_DIR}/package.json\" node -e \"\n try { process.stdout.write(JSON.parse(require('fs').readFileSync(process.env.INSTALLER_PKG, 'utf8')).version || ''); }\n catch(e) {}\n \" 2>/dev/null)\" || true\n fi\n\n local needs_restart=\"\"\n\n if [[ \"$PLUGIN_FRESHLY_INSTALLED\" == \"true\" ]]; then\n if [[ -n \"$WORKER_VERSION\" && -n \"$expected_version\" && \"$WORKER_VERSION\" != \"$expected_version\" ]]; then\n info \"Upgrading worker from v${WORKER_VERSION} to v${expected_version}...\"\n else\n info \"Plugin files updated — restarting worker to load new code...\"\n fi\n needs_restart=\"true\"\n fi\n\n if [[ \"$needs_restart\" != \"true\" && -n \"$WORKER_VERSION\" && -n \"$expected_version\" && \"$WORKER_VERSION\" != \"$expected_version\" ]]; then\n info \"Upgrading worker from v${WORKER_VERSION} to v${expected_version}...\"\n needs_restart=\"true\"\n fi\n\n if [[ \"$needs_restart\" != \"true\" && -n \"$WORKER_AI_PROVIDER\" && -n \"$AI_PROVIDER\" && \"$WORKER_AI_PROVIDER\" != \"$AI_PROVIDER\" ]]; then\n warn \"Worker is using ${WORKER_AI_PROVIDER} but you configured ${AI_PROVIDER} — restarting to apply\"\n needs_restart=\"true\"\n fi\n\n if [[ \"$needs_restart\" == \"true\" ]]; then\n info \"Stopping existing worker...\"\n curl -s -X POST \"http://127.0.0.1:37777/api/admin/shutdown\" >/dev/null 2>&1 || true\n sleep 2\n\n if check_port_37777; then\n if [[ -n \"$WORKER_REPORTED_PID\" ]]; then\n kill \"$WORKER_REPORTED_PID\" 2>/dev/null || true\n sleep 1\n fi\n local pid_file=\"${HOME}/.claude-mem/worker.pid\"\n if [[ -f \"$pid_file\" ]]; then\n local file_pid\n file_pid=\"$(INSTALLER_PID_FILE=\"$pid_file\" node -e \"\n try { process.stdout.write(String(JSON.parse(require('fs').readFileSync(process.env.INSTALLER_PID_FILE, 'utf8')).pid || '')); }\n catch(e) {}\n \" 2>/dev/null)\" || true\n if [[ -n \"$file_pid\" ]]; then\n kill \"$file_pid\" 2>/dev/null || true\n sleep 1\n fi\n fi\n fi\n\n if start_worker; then\n verify_health || true\n else\n warn \"Worker restart failed — you can start it manually later\"\n fi\n else\n local uptime_display=\"\"\n if [[ -n \"$WORKER_UPTIME\" && \"$WORKER_UPTIME\" =~ ^[0-9]+$ && \"$WORKER_UPTIME\" != \"0\" ]]; then\n uptime_display=\"$(format_uptime_ms \"$WORKER_UPTIME\")\"\n fi\n\n local status_parts=\"\"\n if [[ -n \"$WORKER_VERSION\" ]]; then\n status_parts=\"v${WORKER_VERSION}\"\n fi\n if [[ -n \"$WORKER_AI_PROVIDER\" ]]; then\n status_parts=\"${status_parts:+${status_parts}, }${WORKER_AI_PROVIDER}\"\n fi\n if [[ -n \"$uptime_display\" ]]; then\n status_parts=\"${status_parts:+${status_parts}, }uptime: ${uptime_display}\"\n fi\n\n if [[ -n \"$status_parts\" ]]; then\n success \"Existing worker is healthy (${status_parts}) — skipping startup\"\n else\n success \"Existing worker is healthy — skipping startup\"\n fi\n fi\n else\n warn \"Port 37777 is occupied but not responding to health checks\"\n warn \"Another process may be using this port. Stop it and re-run the installer,\"\n warn \"or change CLAUDE_MEM_WORKER_PORT in ~/.claude-mem/settings.json\"\n fi\n else\n if start_worker; then\n verify_health || true\n else\n warn \"Worker startup failed — you can start it manually later\"\n warn \" cd ~/.openclaw/extensions/claude-mem && bun plugin/scripts/worker-service.cjs\"\n fi\n fi\n\n echo \"\"\n info \"${COLOR_BOLD}[8/8]${COLOR_RESET} Observation feed setup...\"\n setup_observation_feed\n write_observation_feed_config\n\n print_completion_summary\n}\n\nmain \"$@\"\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":52283,"content_sha256":"5aa43a6235c91539ba3f2592b375dcaf4a030098485d060d256076c2d56659ee"},{"filename":"openclaw.plugin.json","content":"{\n \"id\": \"claude-mem\",\n \"name\": \"Claude-Mem (Persistent Memory)\",\n \"description\": \"OpenClaw plugin for Claude-Mem. Records observations from embedded runner sessions and streams them to messaging channels.\",\n \"kind\": \"memory\",\n \"version\": \"13.4.0\",\n \"license\": \"Apache-2.0\",\n \"author\": \"thedotmack\",\n \"homepage\": \"https://claude-mem.ai\",\n \"skills\": [\"skills/make-plan\", \"skills/do\"],\n \"configSchema\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"syncMemoryFile\": {\n \"type\": \"boolean\",\n \"default\": true,\n \"description\": \"Inject observation context into the agent system prompt via before_prompt_build hook. When true, agents receive cross-session context without MEMORY.md being overwritten.\"\n },\n \"syncMemoryFileExclude\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"default\": [],\n \"description\": \"Agent IDs excluded from automatic context injection (observations are still recorded, only prompt injection is skipped)\"\n },\n \"workerPort\": {\n \"type\": \"number\",\n \"default\": 37777,\n \"description\": \"Port for Claude-Mem worker service\"\n },\n \"workerHost\": {\n \"type\": \"string\",\n \"default\": \"127.0.0.1\",\n \"description\": \"Hostname for Claude-Mem worker service. Set to host.docker.internal when the gateway runs in Docker and the worker runs on the host.\"\n },\n \"project\": {\n \"type\": \"string\",\n \"default\": \"openclaw\",\n \"description\": \"Project name for scoping observations in the memory database\"\n },\n \"observationFeed\": {\n \"type\": \"object\",\n \"description\": \"Live observation feed — streams observations to any OpenClaw channel in real-time\",\n \"properties\": {\n \"enabled\": {\n \"type\": \"boolean\",\n \"default\": false,\n \"description\": \"Enable live observation feed to messaging channels\"\n },\n \"channel\": {\n \"type\": \"string\",\n \"description\": \"Channel type: telegram, discord, signal, slack, whatsapp, line\"\n },\n \"to\": {\n \"type\": \"string\",\n \"description\": \"Target chat/user ID to send observations to\"\n },\n \"botToken\": {\n \"type\": \"string\",\n \"description\": \"Optional dedicated Telegram bot token for the feed (bypasses gateway channel)\"\n },\n \"emojis\": {\n \"type\": \"object\",\n \"description\": \"Emoji personalization for the observation feed. Each agent gets a unique emoji automatically — customize here to override.\",\n \"properties\": {\n \"primary\": {\n \"type\": \"string\",\n \"default\": \"🦞\",\n \"description\": \"Emoji for the main OpenClaw gateway (project='openclaw')\"\n },\n \"claudeCode\": {\n \"type\": \"string\",\n \"default\": \"⌨️\",\n \"description\": \"Emoji for Claude Code sessions (non-OpenClaw)\"\n },\n \"claudeCodeLabel\": {\n \"type\": \"string\",\n \"default\": \"Claude Code Session\",\n \"description\": \"Display label prefix for Claude Code sessions in the feed (project identifier is appended automatically)\"\n },\n \"default\": {\n \"type\": \"string\",\n \"default\": \"🦀\",\n \"description\": \"Fallback emoji when no match is found\"\n },\n \"agents\": {\n \"type\": \"object\",\n \"default\": {},\n \"description\": \"Pin specific emojis to agent IDs (e.g. {\\\"devops\\\": \\\"🔧\\\"}). Agents not listed here get auto-assigned emojis.\",\n \"additionalProperties\": { \"type\": \"string\" }\n }\n }\n }\n }\n }\n }\n }\n}\n","content_type":"application/json; charset=utf-8","language":"json","size":3902,"content_sha256":"ab466f824750b824d967b749c47766cb54cc4716710ea1ff0a8702d5dc6f5a92"},{"filename":"package.json","content":"{\n \"name\": \"@openclaw/claude-mem\",\n \"version\": \"1.0.0\",\n \"private\": true,\n \"license\": \"Apache-2.0\",\n \"type\": \"module\",\n \"main\": \"dist/index.js\",\n \"scripts\": {\n \"build\": \"tsc\",\n \"test\": \"tsc && node --test dist/index.test.js\"\n },\n \"devDependencies\": {\n \"@types/node\": \"^25.6.2\",\n \"typescript\": \"^6.0.3\"\n },\n \"openclaw\": {\n \"extensions\": [\n \"./dist/index.js\"\n ]\n }\n}\n","content_type":"application/json; charset=utf-8","language":"json","size":400,"content_sha256":"001c6d12e06e5c551897b4fc63f6bc7fad816e089f7f0907ef0a3f558dfaef89"},{"filename":"src/index.test.ts","content":"import { describe, it, beforeEach, afterEach } from \"node:test\";\nimport assert from \"node:assert/strict\";\nimport { createServer, type Server, type IncomingMessage, type ServerResponse } from \"node:http\";\nimport { mkdtemp, readFile, rm } from \"fs/promises\";\nimport { join } from \"path\";\nimport { tmpdir } from \"os\";\nimport claudeMemPlugin from \"./index.js\";\n\nfunction createMockApi(pluginConfigOverride: Record\u003cstring, any> = {}) {\n const logs: string[] = [];\n const sentMessages: Array\u003c{ to: string; text: string; channel: string; opts?: any }> = [];\n\n let registeredService: any = null;\n const registeredCommands: Map\u003cstring, any> = new Map();\n const eventHandlers: Map\u003cstring, Function[]> = new Map();\n\n const api = {\n id: \"claude-mem\",\n name: \"Claude-Mem (Persistent Memory)\",\n version: \"1.0.0\",\n source: \"/test/extensions/claude-mem/dist/index.js\",\n config: {},\n pluginConfig: pluginConfigOverride,\n logger: {\n info: (message: string) => { logs.push(message); },\n warn: (message: string) => { logs.push(message); },\n error: (message: string) => { logs.push(message); },\n debug: (message: string) => { logs.push(message); },\n },\n registerService: (service: any) => {\n registeredService = service;\n },\n registerCommand: (command: any) => {\n registeredCommands.set(command.name, command);\n },\n on: (event: string, callback: Function) => {\n if (!eventHandlers.has(event)) {\n eventHandlers.set(event, []);\n }\n eventHandlers.get(event)!.push(callback);\n },\n runtime: {\n channel: {\n telegram: {\n sendMessageTelegram: async (to: string, text: string) => {\n sentMessages.push({ to, text, channel: \"telegram\" });\n },\n },\n discord: {\n sendMessageDiscord: async (to: string, text: string) => {\n sentMessages.push({ to, text, channel: \"discord\" });\n },\n },\n signal: {\n sendMessageSignal: async (to: string, text: string) => {\n sentMessages.push({ to, text, channel: \"signal\" });\n },\n },\n slack: {\n sendMessageSlack: async (to: string, text: string) => {\n sentMessages.push({ to, text, channel: \"slack\" });\n },\n },\n whatsapp: {\n sendMessageWhatsApp: async (to: string, text: string, opts?: { verbose: boolean }) => {\n sentMessages.push({ to, text, channel: \"whatsapp\", opts });\n },\n },\n line: {\n sendMessageLine: async (to: string, text: string) => {\n sentMessages.push({ to, text, channel: \"line\" });\n },\n },\n },\n },\n };\n\n return {\n api: api as any,\n logs,\n sentMessages,\n getService: () => registeredService,\n getCommand: (name?: string) => {\n if (name) return registeredCommands.get(name);\n return registeredCommands.get(\"claude_mem_feed\");\n },\n getEventHandlers: (event: string) => eventHandlers.get(event) || [],\n fireEvent: async (event: string, data: any, ctx: any = {}) => {\n const handlers = eventHandlers.get(event) || [];\n let lastResult: any;\n for (const handler of handlers) {\n lastResult = await handler(data, ctx);\n }\n return lastResult;\n },\n };\n}\n\ndescribe(\"claudeMemPlugin\", () => {\n it(\"registers service, commands, and event handlers on load\", () => {\n const { api, logs, getService, getCommand, getEventHandlers } = createMockApi();\n claudeMemPlugin(api);\n\n assert.ok(getService(), \"service should be registered\");\n assert.equal(getService().id, \"claude-mem-observation-feed\");\n assert.ok(getCommand(\"claude_mem_feed\"), \"feed command should be registered\");\n assert.ok(getCommand(\"claude_mem_status\"), \"status command should be registered\");\n assert.ok(getEventHandlers(\"session_start\").length > 0, \"session_start handler registered\");\n assert.ok(getEventHandlers(\"after_compaction\").length > 0, \"after_compaction handler registered\");\n assert.ok(getEventHandlers(\"before_agent_start\").length > 0, \"before_agent_start handler registered\");\n assert.ok(getEventHandlers(\"before_prompt_build\").length > 0, \"before_prompt_build handler registered\");\n assert.ok(getEventHandlers(\"tool_result_persist\").length > 0, \"tool_result_persist handler registered\");\n assert.ok(getEventHandlers(\"agent_end\").length > 0, \"agent_end handler registered\");\n assert.ok(getEventHandlers(\"gateway_start\").length > 0, \"gateway_start handler registered\");\n assert.ok(logs.some((l) => l.includes(\"plugin loaded\")));\n });\n\n describe(\"service start\", () => {\n it(\"logs disabled when feed not enabled\", async () => {\n const { api, logs, getService } = createMockApi({});\n claudeMemPlugin(api);\n\n await getService().start({});\n assert.ok(logs.some((l) => l.includes(\"feed disabled\")));\n });\n\n it(\"logs disabled when enabled is false\", async () => {\n const { api, logs, getService } = createMockApi({\n observationFeed: { enabled: false },\n });\n claudeMemPlugin(api);\n\n await getService().start({});\n assert.ok(logs.some((l) => l.includes(\"feed disabled\")));\n });\n\n it(\"logs misconfigured when channel is missing\", async () => {\n const { api, logs, getService } = createMockApi({\n observationFeed: { enabled: true, to: \"123\" },\n });\n claudeMemPlugin(api);\n\n await getService().start({});\n assert.ok(logs.some((l) => l.includes(\"misconfigured\")));\n });\n\n it(\"logs misconfigured when to is missing\", async () => {\n const { api, logs, getService } = createMockApi({\n observationFeed: { enabled: true, channel: \"telegram\" },\n });\n claudeMemPlugin(api);\n\n await getService().start({});\n assert.ok(logs.some((l) => l.includes(\"misconfigured\")));\n });\n });\n\n describe(\"service stop\", () => {\n it(\"logs disconnection on stop\", async () => {\n const { api, logs, getService } = createMockApi({});\n claudeMemPlugin(api);\n\n await getService().stop({});\n assert.ok(logs.some((l) => l.includes(\"feed stopped\")));\n });\n });\n\n describe(\"command handler\", () => {\n it(\"returns not configured when no feedConfig\", async () => {\n const { api, getCommand } = createMockApi({});\n claudeMemPlugin(api);\n\n const result = await getCommand().handler({ args: \"\", channel: \"telegram\", isAuthorizedSender: true, commandBody: \"/claude_mem_feed\", config: {} });\n assert.ok(result.text.includes(\"not configured\"));\n });\n\n it(\"returns status when no args\", async () => {\n const { api, getCommand } = createMockApi({\n observationFeed: { enabled: true, channel: \"telegram\", to: \"123\" },\n });\n claudeMemPlugin(api);\n\n const result = await getCommand().handler({ args: \"\", channel: \"telegram\", isAuthorizedSender: true, commandBody: \"/claude_mem_feed\", config: {} });\n assert.ok(result.text.includes(\"Enabled: yes\"));\n assert.ok(result.text.includes(\"Channel: telegram\"));\n assert.ok(result.text.includes(\"Target: 123\"));\n assert.ok(result.text.includes(\"Connection:\"));\n });\n\n it(\"handles 'on' argument\", async () => {\n const { api, logs, getCommand } = createMockApi({\n observationFeed: { enabled: false },\n });\n claudeMemPlugin(api);\n\n const result = await getCommand().handler({ args: \"on\", channel: \"telegram\", isAuthorizedSender: true, commandBody: \"/claude_mem_feed on\", config: {} });\n assert.ok(result.text.includes(\"enable requested\"));\n assert.ok(logs.some((l) => l.includes(\"enable requested\")));\n });\n\n it(\"handles 'off' argument\", async () => {\n const { api, logs, getCommand } = createMockApi({\n observationFeed: { enabled: true },\n });\n claudeMemPlugin(api);\n\n const result = await getCommand().handler({ args: \"off\", channel: \"telegram\", isAuthorizedSender: true, commandBody: \"/claude_mem_feed off\", config: {} });\n assert.ok(result.text.includes(\"disable requested\"));\n assert.ok(logs.some((l) => l.includes(\"disable requested\")));\n });\n\n it(\"shows connection state in status output\", async () => {\n const { api, getCommand } = createMockApi({\n observationFeed: { enabled: false, channel: \"slack\", to: \"#general\" },\n });\n claudeMemPlugin(api);\n\n const result = await getCommand().handler({ args: \"\", channel: \"slack\", isAuthorizedSender: true, commandBody: \"/claude_mem_feed\", config: {} });\n assert.ok(result.text.includes(\"Connection: disconnected\"));\n });\n });\n});\n\ndescribe(\"Observation I/O event handlers\", () => {\n let workerServer: Server;\n let workerPort: number;\n let receivedRequests: Array\u003c{ method: string; url: string; body: any }> = [];\n\n function startWorkerMock(): Promise\u003cnumber> {\n return new Promise((resolve) => {\n workerServer = createServer((req: IncomingMessage, res: ServerResponse) => {\n let body = \"\";\n req.on(\"data\", (chunk) => { body += chunk.toString(); });\n req.on(\"end\", () => {\n let parsedBody: any = null;\n try { parsedBody = JSON.parse(body); } catch {}\n\n receivedRequests.push({\n method: req.method || \"GET\",\n url: req.url || \"/\",\n body: parsedBody,\n });\n\n if (req.url === \"/api/health\") {\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ status: \"ok\" }));\n return;\n }\n\n if (req.url === \"/api/sessions/init\") {\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ sessionDbId: 1, promptNumber: 1, skipped: false }));\n return;\n }\n\n if (req.url === \"/api/sessions/observations\") {\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ status: \"queued\" }));\n return;\n }\n\n if (req.url === \"/api/sessions/summarize\") {\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ status: \"queued\" }));\n return;\n }\n\n if (req.url?.startsWith(\"/api/context/inject\")) {\n res.writeHead(200, { \"Content-Type\": \"text/plain; charset=utf-8\" });\n res.end(\"# Claude-Mem Context\\n\\n## Timeline\\n- Session 1: Did some work\");\n return;\n }\n\n if (req.url === \"/stream\") {\n res.writeHead(200, {\n \"Content-Type\": \"text/event-stream\",\n \"Cache-Control\": \"no-cache\",\n Connection: \"keep-alive\",\n });\n return;\n }\n\n res.writeHead(404);\n res.end();\n });\n });\n workerServer.listen(0, () => {\n const address = workerServer.address();\n if (address && typeof address === \"object\") {\n resolve(address.port);\n }\n });\n });\n }\n\n beforeEach(async () => {\n receivedRequests = [];\n workerPort = await startWorkerMock();\n });\n\n afterEach(() => {\n workerServer?.close();\n });\n\n it(\"session_start sends session init to worker\", async () => {\n const { api, logs, fireEvent } = createMockApi({ workerPort });\n claudeMemPlugin(api);\n\n await fireEvent(\"session_start\", {\n sessionId: \"test-session-1\",\n }, { sessionKey: \"agent-1\" });\n\n await new Promise((resolve) => setTimeout(resolve, 100));\n\n const initRequest = receivedRequests.find((r) => r.url === \"/api/sessions/init\");\n assert.ok(initRequest, \"should send init request to worker\");\n assert.equal(initRequest!.body.project, \"openclaw\");\n assert.ok(initRequest!.body.contentSessionId.startsWith(\"openclaw-agent-1-\"));\n assert.ok(logs.some((l) => l.includes(\"Session initialized\")));\n });\n\n it(\"session_start calls init on worker\", async () => {\n const { api, fireEvent } = createMockApi({ workerPort });\n claudeMemPlugin(api);\n\n await fireEvent(\"session_start\", { sessionId: \"test-session-1\" }, {});\n await new Promise((resolve) => setTimeout(resolve, 100));\n\n const initRequests = receivedRequests.filter((r) => r.url === \"/api/sessions/init\");\n assert.equal(initRequests.length, 1, \"should init on session_start\");\n });\n\n it(\"after_compaction re-inits session on worker\", async () => {\n const { api, fireEvent } = createMockApi({ workerPort });\n claudeMemPlugin(api);\n\n await fireEvent(\"after_compaction\", { messageCount: 5, compactedCount: 3 }, {});\n await new Promise((resolve) => setTimeout(resolve, 100));\n\n const initRequests = receivedRequests.filter((r) => r.url === \"/api/sessions/init\");\n assert.equal(initRequests.length, 1, \"should re-init after compaction\");\n });\n\n it(\"before_agent_start calls init for session privacy check\", async () => {\n const { api, fireEvent } = createMockApi({ workerPort });\n claudeMemPlugin(api);\n\n await fireEvent(\"before_agent_start\", { prompt: \"hello\" }, {});\n await new Promise((resolve) => setTimeout(resolve, 100));\n\n const initRequests = receivedRequests.filter((r) => r.url === \"/api/sessions/init\");\n assert.equal(initRequests.length, 1, \"before_agent_start should init session\");\n });\n\n it(\"tool_result_persist sends observation to worker\", async () => {\n const { api, fireEvent } = createMockApi({ workerPort });\n claudeMemPlugin(api);\n\n await fireEvent(\"session_start\", { sessionId: \"s1\" }, { sessionKey: \"test-agent\" });\n await new Promise((resolve) => setTimeout(resolve, 100));\n\n await fireEvent(\"tool_result_persist\", {\n toolName: \"Read\",\n params: { file_path: \"/src/index.ts\" },\n message: {\n content: [{ type: \"text\", text: \"file contents here...\" }],\n },\n }, { sessionKey: \"test-agent\" });\n\n await new Promise((resolve) => setTimeout(resolve, 100));\n\n const obsRequest = receivedRequests.find((r) => r.url === \"/api/sessions/observations\");\n assert.ok(obsRequest, \"should send observation to worker\");\n assert.equal(obsRequest!.body.tool_name, \"Read\");\n assert.deepEqual(obsRequest!.body.tool_input, { file_path: \"/src/index.ts\" });\n assert.equal(obsRequest!.body.tool_response, \"file contents here...\");\n assert.ok(obsRequest!.body.contentSessionId.startsWith(\"openclaw-test-agent-\"));\n });\n\n it(\"tool_result_persist skips memory_ tools\", async () => {\n const { api, fireEvent } = createMockApi({ workerPort });\n claudeMemPlugin(api);\n\n await fireEvent(\"tool_result_persist\", {\n toolName: \"memory_search\",\n params: {},\n }, {});\n\n await new Promise((resolve) => setTimeout(resolve, 100));\n\n const obsRequest = receivedRequests.find((r) => r.url === \"/api/sessions/observations\");\n assert.ok(!obsRequest, \"should skip memory_ tools\");\n });\n\n it(\"tool_result_persist truncates long responses\", async () => {\n const { api, fireEvent } = createMockApi({ workerPort });\n claudeMemPlugin(api);\n\n const longText = \"x\".repeat(2000);\n await fireEvent(\"tool_result_persist\", {\n toolName: \"Bash\",\n params: { command: \"ls\" },\n message: {\n content: [{ type: \"text\", text: longText }],\n },\n }, {});\n\n await new Promise((resolve) => setTimeout(resolve, 100));\n\n const obsRequest = receivedRequests.find((r) => r.url === \"/api/sessions/observations\");\n assert.ok(obsRequest, \"should send observation\");\n assert.equal(obsRequest!.body.tool_response.length, 1000, \"should truncate to 1000 chars\");\n });\n\n it(\"agent_end sends summarize and complete to worker\", async () => {\n const { api, fireEvent } = createMockApi({ workerPort });\n claudeMemPlugin(api);\n\n await fireEvent(\"session_start\", { sessionId: \"s1\" }, { sessionKey: \"summarize-test\" });\n await new Promise((resolve) => setTimeout(resolve, 100));\n\n await fireEvent(\"agent_end\", {\n messages: [\n { role: \"user\", content: \"help me\" },\n { role: \"assistant\", content: \"Here is the solution...\" },\n ],\n }, { sessionKey: \"summarize-test\" });\n\n await new Promise((resolve) => setTimeout(resolve, 100));\n\n const summarizeRequest = receivedRequests.find((r) => r.url === \"/api/sessions/summarize\");\n assert.ok(summarizeRequest, \"should send summarize to worker\");\n assert.equal(summarizeRequest!.body.last_assistant_message, \"Here is the solution...\");\n assert.ok(summarizeRequest!.body.contentSessionId.startsWith(\"openclaw-summarize-test-\"));\n\n const completeRequest = receivedRequests.find((r) => r.url === \"/api/sessions/complete\");\n assert.ok(!completeRequest, \"should not send complete (worker self-completes)\");\n });\n\n it(\"agent_end extracts text from array content\", async () => {\n const { api, fireEvent } = createMockApi({ workerPort });\n claudeMemPlugin(api);\n\n await fireEvent(\"session_start\", { sessionId: \"s1\" }, { sessionKey: \"array-content\" });\n await new Promise((resolve) => setTimeout(resolve, 100));\n\n await fireEvent(\"agent_end\", {\n messages: [\n {\n role: \"assistant\",\n content: [\n { type: \"text\", text: \"First part\" },\n { type: \"text\", text: \"Second part\" },\n ],\n },\n ],\n }, { sessionKey: \"array-content\" });\n\n await new Promise((resolve) => setTimeout(resolve, 100));\n\n const summarizeRequest = receivedRequests.find((r) => r.url === \"/api/sessions/summarize\");\n assert.ok(summarizeRequest, \"should send summarize\");\n assert.equal(summarizeRequest!.body.last_assistant_message, \"First part\\nSecond part\");\n });\n\n it(\"uses custom project name from config\", async () => {\n const { api, fireEvent } = createMockApi({ workerPort, project: \"my-project\" });\n claudeMemPlugin(api);\n\n await fireEvent(\"session_start\", { sessionId: \"s1\" }, {});\n await new Promise((resolve) => setTimeout(resolve, 100));\n\n const initRequest = receivedRequests.find((r) => r.url === \"/api/sessions/init\");\n assert.ok(initRequest, \"should send init\");\n assert.equal(initRequest!.body.project, \"my-project\");\n });\n\n it(\"claude_mem_status command reports worker health\", async () => {\n const { api, getCommand } = createMockApi({ workerPort });\n claudeMemPlugin(api);\n\n const statusCmd = getCommand(\"claude_mem_status\");\n assert.ok(statusCmd, \"status command should exist\");\n\n const result = await statusCmd.handler({ args: \"\", channel: \"telegram\", isAuthorizedSender: true, commandBody: \"/claude_mem_status\", config: {} });\n assert.ok(result.text.includes(\"Status: ok\"));\n assert.ok(result.text.includes(`Port: ${workerPort}`));\n });\n\n it(\"claude_mem_status reports unreachable when worker is down\", async () => {\n workerServer.close();\n await new Promise((resolve) => setTimeout(resolve, 100));\n\n const { api, getCommand } = createMockApi({ workerPort: 59999 });\n claudeMemPlugin(api);\n\n const statusCmd = getCommand(\"claude_mem_status\");\n const result = await statusCmd.handler({ args: \"\", channel: \"telegram\", isAuthorizedSender: true, commandBody: \"/claude_mem_status\", config: {} });\n assert.ok(result.text.includes(\"unreachable\"));\n });\n\n it(\"reuses same contentSessionId for same sessionKey\", async () => {\n const { api, fireEvent } = createMockApi({ workerPort });\n claudeMemPlugin(api);\n\n await fireEvent(\"session_start\", { sessionId: \"s1\" }, { sessionKey: \"reuse-test\" });\n await new Promise((resolve) => setTimeout(resolve, 100));\n\n await fireEvent(\"tool_result_persist\", {\n toolName: \"Read\",\n params: { file_path: \"/src/index.ts\" },\n message: { content: [{ type: \"text\", text: \"contents\" }] },\n }, { sessionKey: \"reuse-test\" });\n\n await new Promise((resolve) => setTimeout(resolve, 100));\n\n const initRequest = receivedRequests.find((r) => r.url === \"/api/sessions/init\");\n const obsRequest = receivedRequests.find((r) => r.url === \"/api/sessions/observations\");\n assert.ok(initRequest && obsRequest, \"both requests should exist\");\n assert.equal(\n initRequest!.body.contentSessionId,\n obsRequest!.body.contentSessionId,\n \"should reuse contentSessionId for same sessionKey\"\n );\n });\n});\n\ndescribe(\"before_prompt_build context injection\", () => {\n let workerServer: Server;\n let workerPort: number;\n let receivedRequests: Array\u003c{ method: string; url: string; body: any }> = [];\n let contextResponse = \"# Claude-Mem Context\\n\\n## Timeline\\n- Session 1: Did some work\";\n\n function startWorkerMock(): Promise\u003cnumber> {\n return new Promise((resolve) => {\n workerServer = createServer((req: IncomingMessage, res: ServerResponse) => {\n let body = \"\";\n req.on(\"data\", (chunk) => { body += chunk.toString(); });\n req.on(\"end\", () => {\n let parsedBody: any = null;\n try { parsedBody = JSON.parse(body); } catch {}\n\n receivedRequests.push({\n method: req.method || \"GET\",\n url: req.url || \"/\",\n body: parsedBody,\n });\n\n if (req.url?.startsWith(\"/api/context/inject\")) {\n res.writeHead(200, { \"Content-Type\": \"text/plain; charset=utf-8\" });\n res.end(contextResponse);\n return;\n }\n\n if (req.url === \"/api/sessions/init\") {\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ sessionDbId: 1, promptNumber: 1, skipped: false }));\n return;\n }\n\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ status: \"ok\" }));\n });\n });\n workerServer.listen(0, () => {\n const address = workerServer.address();\n if (address && typeof address === \"object\") {\n resolve(address.port);\n }\n });\n });\n }\n\n beforeEach(async () => {\n receivedRequests = [];\n contextResponse = \"# Claude-Mem Context\\n\\n## Timeline\\n- Session 1: Did some work\";\n workerPort = await startWorkerMock();\n });\n\n afterEach(async () => {\n workerServer?.close();\n });\n\n it(\"returns appendSystemContext from before_prompt_build\", async () => {\n const { api, logs, fireEvent } = createMockApi({ workerPort });\n claudeMemPlugin(api);\n\n const result = await fireEvent(\"before_prompt_build\", {\n prompt: \"Help me write a function\",\n messages: [],\n }, { agentId: \"main\" });\n\n await new Promise((resolve) => setTimeout(resolve, 200));\n\n const contextRequest = receivedRequests.find((r) => r.url?.startsWith(\"/api/context/inject\"));\n assert.ok(contextRequest, \"should request context from worker\");\n assert.ok(contextRequest!.url!.includes(\"projects=openclaw\"));\n\n assert.ok(result, \"should return a result\");\n assert.ok(result.appendSystemContext, \"should return appendSystemContext\");\n assert.ok(result.appendSystemContext.includes(\"Claude-Mem Context\"), \"should contain context\");\n assert.ok(result.appendSystemContext.includes(\"Session 1\"), \"should contain timeline\");\n assert.ok(logs.some((l) => l.includes(\"Context injected via system prompt\")));\n });\n\n it(\"does not write MEMORY.md on before_agent_start\", async () => {\n const tmpDir = await mkdtemp(join(tmpdir(), \"claude-mem-test-\"));\n try {\n const { api, fireEvent } = createMockApi({ workerPort });\n claudeMemPlugin(api);\n\n await fireEvent(\"before_agent_start\", {\n prompt: \"Help me write a function\",\n }, { sessionKey: \"sync-test\", workspaceDir: tmpDir });\n\n await new Promise((resolve) => setTimeout(resolve, 200));\n\n let memoryExists = true;\n try {\n await readFile(join(tmpDir, \"MEMORY.md\"), \"utf-8\");\n } catch {\n memoryExists = false;\n }\n assert.ok(!memoryExists, \"MEMORY.md should not be created by before_agent_start\");\n } finally {\n await rm(tmpDir, { recursive: true, force: true });\n }\n });\n\n it(\"does not sync MEMORY.md on tool_result_persist\", async () => {\n const tmpDir = await mkdtemp(join(tmpdir(), \"claude-mem-test-\"));\n try {\n const { api, fireEvent } = createMockApi({ workerPort });\n claudeMemPlugin(api);\n\n await fireEvent(\"before_agent_start\", {\n prompt: \"Help me write a function\",\n }, { sessionKey: \"tool-sync\", workspaceDir: tmpDir });\n\n await new Promise((resolve) => setTimeout(resolve, 200));\n\n await fireEvent(\"tool_result_persist\", {\n toolName: \"Read\",\n params: { file_path: \"/src/app.ts\" },\n message: { content: [{ type: \"text\", text: \"file contents\" }] },\n }, { sessionKey: \"tool-sync\" });\n\n await new Promise((resolve) => setTimeout(resolve, 200));\n\n const contextRequests = receivedRequests.filter((r) => r.url?.startsWith(\"/api/context/inject\"));\n assert.equal(contextRequests.length, 0, \"tool_result_persist should not fetch context\");\n\n let memoryExists = true;\n try {\n await readFile(join(tmpDir, \"MEMORY.md\"), \"utf-8\");\n } catch {\n memoryExists = false;\n }\n assert.ok(!memoryExists, \"MEMORY.md should not be written by tool_result_persist\");\n } finally {\n await rm(tmpDir, { recursive: true, force: true });\n }\n });\n\n it(\"skips context injection when syncMemoryFile is false\", async () => {\n const { api, fireEvent } = createMockApi({ workerPort, syncMemoryFile: false });\n claudeMemPlugin(api);\n\n const result = await fireEvent(\"before_prompt_build\", {\n prompt: \"Help me write a function\",\n messages: [],\n }, { agentId: \"main\" });\n\n await new Promise((resolve) => setTimeout(resolve, 200));\n\n const contextRequest = receivedRequests.find((r) => r.url?.startsWith(\"/api/context/inject\"));\n assert.ok(!contextRequest, \"should not fetch context when injection disabled\");\n assert.equal(result, undefined, \"should return undefined when injection disabled\");\n });\n\n it(\"skips context injection for excluded agents\", async () => {\n const { api, fireEvent } = createMockApi({ workerPort, syncMemoryFileExclude: [\"snarf\"] });\n claudeMemPlugin(api);\n\n const result = await fireEvent(\"before_prompt_build\", {\n prompt: \"Help me\",\n messages: [],\n }, { agentId: \"snarf\" });\n\n await new Promise((resolve) => setTimeout(resolve, 200));\n\n const contextRequest = receivedRequests.find((r) => r.url?.startsWith(\"/api/context/inject\"));\n assert.ok(!contextRequest, \"should not fetch context for excluded agent\");\n assert.equal(result, undefined, \"should return undefined for excluded agent\");\n });\n\n it(\"injects context for non-excluded agents\", async () => {\n const { api, fireEvent } = createMockApi({ workerPort, syncMemoryFileExclude: [\"snarf\"] });\n claudeMemPlugin(api);\n\n const result = await fireEvent(\"before_prompt_build\", {\n prompt: \"Help me\",\n messages: [],\n }, { agentId: \"main\" });\n\n await new Promise((resolve) => setTimeout(resolve, 200));\n\n assert.ok(result, \"should return a result for non-excluded agent\");\n assert.ok(result.appendSystemContext, \"should inject context for non-excluded agent\");\n });\n\n it(\"returns undefined when context is empty\", async () => {\n contextResponse = \" \";\n const { api, logs, fireEvent } = createMockApi({ workerPort });\n claudeMemPlugin(api);\n\n const result = await fireEvent(\"before_prompt_build\", {\n prompt: \"Help me write a function\",\n messages: [],\n }, { agentId: \"main\" });\n\n await new Promise((resolve) => setTimeout(resolve, 200));\n\n assert.equal(result, undefined, \"should return undefined for empty context\");\n assert.ok(!logs.some((l) => l.includes(\"Context injected\")), \"should not log injection for empty context\");\n });\n\n it(\"uses custom project name in context inject URL\", async () => {\n const { api, fireEvent } = createMockApi({ workerPort, project: \"my-bot\" });\n claudeMemPlugin(api);\n\n await fireEvent(\"before_prompt_build\", {\n prompt: \"Help me write a function\",\n messages: [],\n }, { agentId: \"main\" });\n\n await new Promise((resolve) => setTimeout(resolve, 200));\n\n const contextRequest = receivedRequests.find((r) => r.url?.startsWith(\"/api/context/inject\"));\n assert.ok(contextRequest, \"should request context\");\n assert.ok(contextRequest!.url!.includes(\"projects=my-bot\"), \"should use custom project name\");\n });\n\n it(\"includes agent-scoped project in context request\", async () => {\n const { api, fireEvent } = createMockApi({ workerPort });\n claudeMemPlugin(api);\n\n await fireEvent(\"before_prompt_build\", {\n prompt: \"Help me\",\n messages: [],\n }, { agentId: \"debugger\" });\n\n await new Promise((resolve) => setTimeout(resolve, 200));\n\n const contextRequest = receivedRequests.find((r) => r.url?.startsWith(\"/api/context/inject\"));\n assert.ok(contextRequest, \"should request context\");\n const url = decodeURIComponent(contextRequest!.url!);\n assert.ok(url.includes(\"openclaw,openclaw-debugger\"), \"should include both base and agent-scoped projects\");\n });\n});\n\ndescribe(\"SSE stream integration\", () => {\n let server: Server;\n let serverPort: number;\n let serverResponses: ServerResponse[] = [];\n\n function startSSEServer(): Promise\u003cnumber> {\n return new Promise((resolve) => {\n server = createServer((req: IncomingMessage, res: ServerResponse) => {\n if (req.url !== \"/stream\") {\n res.writeHead(404);\n res.end();\n return;\n }\n res.writeHead(200, {\n \"Content-Type\": \"text/event-stream\",\n \"Cache-Control\": \"no-cache\",\n Connection: \"keep-alive\",\n });\n serverResponses.push(res);\n });\n server.listen(0, () => {\n const address = server.address();\n if (address && typeof address === \"object\") {\n resolve(address.port);\n }\n });\n });\n }\n\n beforeEach(async () => {\n serverResponses = [];\n serverPort = await startSSEServer();\n });\n\n afterEach(() => {\n for (const res of serverResponses) {\n try {\n res.end();\n } catch {}\n }\n server?.close();\n });\n\n it(\"connects to SSE stream and receives new_observation events\", async () => {\n const { api, logs, sentMessages, getService } = createMockApi({\n workerPort: serverPort,\n observationFeed: { enabled: true, channel: \"telegram\", to: \"12345\" },\n });\n claudeMemPlugin(api);\n\n await getService().start({});\n\n await new Promise((resolve) => setTimeout(resolve, 200));\n\n assert.ok(logs.some((l) => l.includes(\"Connecting to SSE stream\")));\n\n const observation = {\n type: \"new_observation\",\n observation: {\n id: 1,\n title: \"Test Observation\",\n subtitle: \"Found something interesting\",\n type: \"discovery\",\n project: \"test\",\n prompt_number: 1,\n created_at_epoch: Date.now(),\n },\n timestamp: Date.now(),\n };\n\n for (const res of serverResponses) {\n res.write(`data: ${JSON.stringify(observation)}\\n\\n`);\n }\n\n await new Promise((resolve) => setTimeout(resolve, 200));\n\n assert.equal(sentMessages.length, 1);\n assert.equal(sentMessages[0].channel, \"telegram\");\n assert.equal(sentMessages[0].to, \"12345\");\n assert.ok(sentMessages[0].text.includes(\"Test Observation\"));\n assert.ok(sentMessages[0].text.includes(\"Found something interesting\"));\n\n await getService().stop({});\n });\n\n it(\"filters out non-observation events\", async () => {\n const { api, sentMessages, getService } = createMockApi({\n workerPort: serverPort,\n observationFeed: { enabled: true, channel: \"discord\", to: \"channel-id\" },\n });\n claudeMemPlugin(api);\n\n await getService().start({});\n await new Promise((resolve) => setTimeout(resolve, 200));\n\n for (const res of serverResponses) {\n res.write(`data: ${JSON.stringify({ type: \"processing_status\", isProcessing: true })}\\n\\n`);\n res.write(`data: ${JSON.stringify({ type: \"session_started\", sessionId: \"abc\" })}\\n\\n`);\n }\n\n await new Promise((resolve) => setTimeout(resolve, 200));\n assert.equal(sentMessages.length, 0, \"non-observation events should be filtered\");\n\n await getService().stop({});\n });\n\n it(\"handles observation with null subtitle\", async () => {\n const { api, sentMessages, getService } = createMockApi({\n workerPort: serverPort,\n observationFeed: { enabled: true, channel: \"telegram\", to: \"999\" },\n });\n claudeMemPlugin(api);\n\n await getService().start({});\n await new Promise((resolve) => setTimeout(resolve, 200));\n\n for (const res of serverResponses) {\n res.write(\n `data: ${JSON.stringify({\n type: \"new_observation\",\n observation: { id: 2, title: \"No Subtitle\", subtitle: null },\n timestamp: Date.now(),\n })}\\n\\n`\n );\n }\n\n await new Promise((resolve) => setTimeout(resolve, 200));\n assert.equal(sentMessages.length, 1);\n assert.ok(sentMessages[0].text.includes(\"No Subtitle\"));\n assert.ok(!sentMessages[0].text.includes(\"null\"));\n\n await getService().stop({});\n });\n\n it(\"handles observation with null title\", async () => {\n const { api, sentMessages, getService } = createMockApi({\n workerPort: serverPort,\n observationFeed: { enabled: true, channel: \"telegram\", to: \"999\" },\n });\n claudeMemPlugin(api);\n\n await getService().start({});\n await new Promise((resolve) => setTimeout(resolve, 200));\n\n for (const res of serverResponses) {\n res.write(\n `data: ${JSON.stringify({\n type: \"new_observation\",\n observation: { id: 3, title: null, subtitle: \"Has subtitle\" },\n timestamp: Date.now(),\n })}\\n\\n`\n );\n }\n\n await new Promise((resolve) => setTimeout(resolve, 200));\n assert.equal(sentMessages.length, 1);\n assert.ok(sentMessages[0].text.includes(\"Untitled\"));\n\n await getService().stop({});\n });\n\n it(\"uses custom workerPort from config\", async () => {\n const { api, logs, getService } = createMockApi({\n workerPort: serverPort,\n observationFeed: { enabled: true, channel: \"telegram\", to: \"12345\" },\n });\n claudeMemPlugin(api);\n\n await getService().start({});\n await new Promise((resolve) => setTimeout(resolve, 200));\n\n assert.ok(logs.some((l) => l.includes(`127.0.0.1:${serverPort}`)));\n\n await getService().stop({});\n });\n\n it(\"logs unknown channel type\", async () => {\n const { api, logs, sentMessages, getService } = createMockApi({\n workerPort: serverPort,\n observationFeed: { enabled: true, channel: \"matrix\", to: \"room-id\" },\n });\n claudeMemPlugin(api);\n\n await getService().start({});\n await new Promise((resolve) => setTimeout(resolve, 200));\n\n for (const res of serverResponses) {\n res.write(\n `data: ${JSON.stringify({\n type: \"new_observation\",\n observation: { id: 4, title: \"Test\", subtitle: null },\n timestamp: Date.now(),\n })}\\n\\n`\n );\n }\n\n await new Promise((resolve) => setTimeout(resolve, 200));\n assert.equal(sentMessages.length, 0);\n assert.ok(logs.some((l) => l.includes(\"Unsupported channel type: matrix\")));\n\n await getService().stop({});\n });\n});\n\ndescribe(\"circuit breaker\", () => {\n beforeEach(async () => {\n const { api, fireEvent } = createMockApi({ workerPort: 59999 });\n claudeMemPlugin(api);\n await fireEvent(\"gateway_start\", {}, {});\n });\n\n it(\"opens after threshold failures and stops further requests\", async () => {\n const { api, logs, fireEvent } = createMockApi({ workerPort: 59999 });\n claudeMemPlugin(api);\n await fireEvent(\"gateway_start\", {}, {});\n\n for (let i = 0; i \u003c 4; i++) {\n await fireEvent(\"before_agent_start\", { prompt: \"hello\" }, { sessionKey: `cb-open-${i}` });\n }\n\n const logCountBeforeDrop = logs.length;\n await fireEvent(\"before_agent_start\", { prompt: \"hello\" }, { sessionKey: \"cb-drop\" });\n const noisyDropLogs = logs.slice(logCountBeforeDrop).filter(\n (l) => l.includes(\"failed\") || l.includes(\"disabling\")\n );\n assert.equal(noisyDropLogs.length, 0, \"calls when circuit is open should be silently dropped\");\n });\n\n it(\"logs individual failures while circuit is closed, then disabling when it opens\", async () => {\n const { api, logs, fireEvent } = createMockApi({ workerPort: 59999 });\n claudeMemPlugin(api);\n await fireEvent(\"gateway_start\", {}, {});\n const logsAfterReset = logs.length;\n\n for (let i = 0; i \u003c 3; i++) {\n await fireEvent(\"before_agent_start\", { prompt: \"hello\" }, { sessionKey: `cb-log-${i}` });\n }\n\n const newLogs = logs.slice(logsAfterReset);\n assert.ok(newLogs.length > 0, \"threshold calls should produce log output\");\n const disablingLogs = newLogs.filter((l) => l.includes(\"disabling requests\"));\n assert.equal(disablingLogs.length, 1, \"should emit exactly one disabling warning when circuit opens\");\n const failureLogs = newLogs.filter((l) => l.includes(\"failed:\"));\n assert.ok(failureLogs.length \u003c 3, \"threshold-crossing call should not log an individual failure\");\n });\n\n it(\"resets on gateway_start, allowing connections again\", async () => {\n const { api, logs, fireEvent } = createMockApi({ workerPort: 59999 });\n claudeMemPlugin(api);\n await fireEvent(\"gateway_start\", {}, {});\n\n for (let i = 0; i \u003c 4; i++) {\n await fireEvent(\"before_agent_start\", { prompt: \"hello\" }, { sessionKey: `cb-reset-${i}` });\n }\n\n const logCountWhileOpen = logs.length;\n await fireEvent(\"before_agent_start\", { prompt: \"hello\" }, { sessionKey: \"cb-while-open\" });\n assert.equal(\n logs.slice(logCountWhileOpen).filter((l) => l.includes(\"failed\") || l.includes(\"disabling\")).length,\n 0,\n \"call while circuit is open should be silently dropped\"\n );\n\n await fireEvent(\"gateway_start\", {}, {});\n\n const logCountAfterReset = logs.length;\n await fireEvent(\"before_agent_start\", { prompt: \"hello\" }, { sessionKey: \"cb-after-reset\" });\n const newLogs = logs.slice(logCountAfterReset);\n assert.ok(\n newLogs.some((l) => l.includes(\"failed:\") || l.includes(\"disabling\")),\n \"should attempt worker connection after gateway_start reset\"\n );\n });\n\n it(\"HALF_OPEN allows only a single probe — non-2xx keeps circuit open, 2xx closes it\", async () => {\n const resetMock = createMockApi({ workerPort: 59999 });\n claudeMemPlugin(resetMock.api);\n await resetMock.fireEvent(\"gateway_start\", {}, {});\n\n for (let i = 0; i \u003c 4; i++) {\n await resetMock.fireEvent(\"before_agent_start\", { prompt: \"probe-test\" }, { sessionKey: `probe-phase1-${i}` });\n }\n\n const realDateNow = Date.now.bind(Date);\n Date.now = () => realDateNow() + 31_000;\n\n try {\n let serverA: Server | null = null;\n const portA: number = await new Promise((resolve) => {\n serverA = createServer((_req: IncomingMessage, res: ServerResponse) => {\n res.writeHead(500);\n res.end();\n });\n serverA!.listen(0, () => {\n const addr = serverA!.address();\n resolve((addr as any).port);\n });\n });\n\n const mockA = createMockApi({ workerPort: portA });\n claudeMemPlugin(mockA.api);\n\n const logCountAtProbe = mockA.logs.length;\n await mockA.fireEvent(\"before_agent_start\", { prompt: \"probe\" }, { sessionKey: \"probe-call-non2xx\" });\n await new Promise((resolve) => setTimeout(resolve, 100));\n\n const probeALogs = mockA.logs.slice(logCountAtProbe);\n assert.ok(\n probeALogs.some((l) => l.includes(\"disabling\") || l.includes(\"returned 500\") || l.includes(\"Worker POST\")),\n \"non-2xx probe should keep circuit open (expected disabling or 500 status log)\"\n );\n\n const logCountAfterFailedProbe = mockA.logs.length;\n await mockA.fireEvent(\"before_agent_start\", { prompt: \"probe\" }, { sessionKey: \"probe-concurrent\" });\n await new Promise((resolve) => setTimeout(resolve, 100));\n const droppedLogs = mockA.logs.slice(logCountAfterFailedProbe).filter(\n (l) => l.includes(\"failed\") || l.includes(\"disabling\")\n );\n assert.equal(droppedLogs.length, 0, \"call should be silently dropped while circuit is OPEN again after failed probe\");\n\n serverA!.close();\n\n const resetMock2 = createMockApi({ workerPort: 59999 });\n claudeMemPlugin(resetMock2.api);\n await resetMock2.fireEvent(\"gateway_start\", {}, {});\n\n Date.now = realDateNow;\n for (let i = 0; i \u003c 4; i++) {\n await resetMock2.fireEvent(\"before_agent_start\", { prompt: \"probe-test\" }, { sessionKey: `probe-phase4-${i}` });\n }\n Date.now = () => realDateNow() + 31_000;\n\n let serverB: Server | null = null;\n const portB: number = await new Promise((resolve) => {\n serverB = createServer((_req: IncomingMessage, res: ServerResponse) => {\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ sessionDbId: 1, promptNumber: 1, skipped: false }));\n });\n serverB!.listen(0, () => {\n const addr = serverB!.address();\n resolve((addr as any).port);\n });\n });\n\n const mockB = createMockApi({ workerPort: portB });\n claudeMemPlugin(mockB.api);\n\n const logCountBeforeSuccessProbe = mockB.logs.length;\n await mockB.fireEvent(\"before_agent_start\", { prompt: \"probe\" }, { sessionKey: \"probe-call-2xx\" });\n await new Promise((resolve) => setTimeout(resolve, 150));\n\n const successProbeLogs = mockB.logs.slice(logCountBeforeSuccessProbe);\n assert.ok(\n successProbeLogs.some((l) => l.includes(\"restored\") || l.includes(\"circuit closed\")),\n \"2xx probe should close the circuit — expected 'restored' or 'circuit closed' log\"\n );\n\n serverB!.close();\n } finally {\n Date.now = realDateNow;\n }\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":41795,"content_sha256":"bc02bf03194df63bc57bf3fc3ae16ccede78801b60c6e521699c185f18308710"},{"filename":"src/index.ts","content":"\n\ninterface PluginLogger {\n debug?: (message: string) => void;\n info: (message: string) => void;\n warn: (message: string) => void;\n error: (message: string) => void;\n}\n\ninterface PluginServiceContext {\n config: Record\u003cstring, unknown>;\n workspaceDir?: string;\n stateDir: string;\n logger: PluginLogger;\n}\n\ninterface PluginCommandContext {\n senderId?: string;\n channel: string;\n isAuthorizedSender: boolean;\n args?: string;\n commandBody: string;\n config: Record\u003cstring, unknown>;\n}\n\ntype PluginCommandResult = string | { text: string } | { text: string; format?: string };\n\ninterface BeforeAgentStartEvent {\n prompt?: string;\n}\n\ninterface BeforePromptBuildEvent {\n prompt: string;\n messages: unknown[];\n}\n\ninterface BeforePromptBuildResult {\n systemPrompt?: string;\n prependContext?: string;\n prependSystemContext?: string;\n appendSystemContext?: string;\n}\n\ninterface ToolResultPersistEvent {\n toolName?: string;\n params?: Record\u003cstring, unknown>;\n message?: {\n content?: Array\u003c{ type: string; text?: string }>;\n };\n}\n\ninterface AgentEndEvent {\n messages?: Array\u003c{\n role: string;\n content: string | Array\u003c{ type: string; text?: string }>;\n }>;\n}\n\ninterface SessionStartEvent {\n sessionId: string;\n resumedFrom?: string;\n}\n\ninterface AfterCompactionEvent {\n messageCount: number;\n tokenCount?: number;\n compactedCount: number;\n}\n\ninterface SessionEndEvent {\n sessionId: string;\n messageCount: number;\n durationMs?: number;\n}\n\ninterface MessageReceivedEvent {\n from: string;\n content: string;\n timestamp?: number;\n metadata?: Record\u003cstring, unknown>;\n}\n\ninterface EventContext {\n sessionKey?: string;\n workspaceDir?: string;\n agentId?: string;\n}\n\ninterface MessageContext {\n channelId: string;\n accountId?: string;\n conversationId?: string;\n}\n\ntype EventCallback\u003cT> = (event: T, ctx: EventContext) => void | Promise\u003cvoid>;\ntype PromptBuildCallback = (event: BeforePromptBuildEvent, ctx: EventContext) => BeforePromptBuildResult | Promise\u003cBeforePromptBuildResult | void> | void;\ntype MessageEventCallback\u003cT> = (event: T, ctx: MessageContext) => void | Promise\u003cvoid>;\n\ninterface OpenClawPluginApi {\n id: string;\n name: string;\n version?: string;\n source: string;\n config: Record\u003cstring, unknown>;\n pluginConfig?: Record\u003cstring, unknown>;\n logger: PluginLogger;\n registerService: (service: {\n id: string;\n start: (ctx: PluginServiceContext) => void | Promise\u003cvoid>;\n stop?: (ctx: PluginServiceContext) => void | Promise\u003cvoid>;\n }) => void;\n registerCommand: (command: {\n name: string;\n description: string;\n acceptsArgs?: boolean;\n requireAuth?: boolean;\n handler: (ctx: PluginCommandContext) => PluginCommandResult | Promise\u003cPluginCommandResult>;\n }) => void;\n on: ((event: \"before_prompt_build\", callback: PromptBuildCallback) => void) &\n ((event: \"before_agent_start\", callback: EventCallback\u003cBeforeAgentStartEvent>) => void) &\n ((event: \"tool_result_persist\", callback: EventCallback\u003cToolResultPersistEvent>) => void) &\n ((event: \"agent_end\", callback: EventCallback\u003cAgentEndEvent>) => void) &\n ((event: \"session_start\", callback: EventCallback\u003cSessionStartEvent>) => void) &\n ((event: \"session_end\", callback: EventCallback\u003cSessionEndEvent>) => void) &\n ((event: \"message_received\", callback: MessageEventCallback\u003cMessageReceivedEvent>) => void) &\n ((event: \"after_compaction\", callback: EventCallback\u003cAfterCompactionEvent>) => void) &\n ((event: \"gateway_start\", callback: EventCallback\u003cRecord\u003cstring, never>>) => void);\n runtime: {\n channel: Record\u003cstring, Record\u003cstring, (...args: any[]) => Promise\u003cany>>>;\n };\n}\n\ninterface ObservationSSEPayload {\n id: number;\n memory_session_id: string;\n session_id: string;\n type: string;\n title: string | null;\n subtitle: string | null;\n text: string | null;\n narrative: string | null;\n facts: string | null;\n concepts: string | null;\n files_read: string | null;\n files_modified: string | null;\n project: string | null;\n prompt_number: number;\n created_at_epoch: number;\n}\n\ninterface SSENewObservationEvent {\n type: \"new_observation\";\n observation: ObservationSSEPayload;\n timestamp: number;\n}\n\ntype ConnectionState = \"disconnected\" | \"connected\" | \"reconnecting\";\n\ninterface FeedEmojiConfig {\n primary?: string;\n claudeCode?: string;\n claudeCodeLabel?: string;\n default?: string;\n agents?: Record\u003cstring, string>;\n}\n\ninterface ClaudeMemPluginConfig {\n syncMemoryFile?: boolean;\n syncMemoryFileExclude?: string[];\n project?: string;\n workerPort?: number;\n workerHost?: string;\n observationFeed?: {\n enabled?: boolean;\n channel?: string;\n to?: string;\n botToken?: string;\n emojis?: FeedEmojiConfig;\n };\n}\n\nconst MAX_SSE_BUFFER_SIZE = 1024 * 1024; \nconst DEFAULT_WORKER_PORT = 37777;\nconst DEFAULT_WORKER_HOST = \"127.0.0.1\";\n\nconst EMOJI_POOL = [\n \"🔧\",\"📐\",\"🔍\",\"💻\",\"🧪\",\"🐛\",\"🛡️\",\"☁️\",\"📦\",\"🎯\",\n \"🔮\",\"⚡\",\"🌊\",\"🎨\",\"📊\",\"🚀\",\"🔬\",\"🏗️\",\"📝\",\"🎭\",\n];\n\nfunction poolEmojiForAgent(agentId: string): string {\n let hash = 0;\n for (let i = 0; i \u003c agentId.length; i++) {\n hash = ((hash \u003c\u003c 5) - hash + agentId.charCodeAt(i)) | 0;\n }\n return EMOJI_POOL[Math.abs(hash) % EMOJI_POOL.length];\n}\n\nconst DEFAULT_PRIMARY_EMOJI = \"🦞\";\nconst DEFAULT_CLAUDE_CODE_EMOJI = \"⌨️\";\nconst DEFAULT_CLAUDE_CODE_LABEL = \"Claude Code Session\";\nconst DEFAULT_FALLBACK_EMOJI = \"🦀\";\n\nfunction buildGetSourceLabel(\n emojiConfig: FeedEmojiConfig | undefined\n): (project: string | null | undefined) => string {\n const primary = emojiConfig?.primary ?? DEFAULT_PRIMARY_EMOJI;\n const claudeCode = emojiConfig?.claudeCode ?? DEFAULT_CLAUDE_CODE_EMOJI;\n const claudeCodeLabel = emojiConfig?.claudeCodeLabel ?? DEFAULT_CLAUDE_CODE_LABEL;\n const fallback = emojiConfig?.default ?? DEFAULT_FALLBACK_EMOJI;\n const pinnedAgents = emojiConfig?.agents ?? {};\n\n return function getSourceLabel(project: string | null | undefined): string {\n if (!project) return fallback;\n if (project.startsWith(\"openclaw-\")) {\n const agentId = project.slice(\"openclaw-\".length);\n if (!agentId) return `${primary} openclaw`;\n const emoji = pinnedAgents[agentId] || poolEmojiForAgent(agentId);\n return `${emoji} ${agentId}`;\n }\n if (project === \"openclaw\") {\n return `${primary} openclaw`;\n }\n const trimmedLabel = claudeCodeLabel.trim();\n if (!trimmedLabel) {\n return `${claudeCode} ${project}`;\n }\n return `${claudeCode} ${trimmedLabel} (${project})`;\n };\n}\n\nlet _workerHost = DEFAULT_WORKER_HOST;\n\nfunction workerBaseUrl(port: number): string {\n return `http://${_workerHost}:${port}`;\n}\n\nconst CIRCUIT_BREAKER_THRESHOLD = 3;\nconst CIRCUIT_BREAKER_COOLDOWN_MS = 30_000;\n\ntype CircuitState = \"CLOSED\" | \"OPEN\" | \"HALF_OPEN\";\n\nlet _circuitState: CircuitState = \"CLOSED\";\nlet _circuitFailures = 0;\nlet _circuitOpenedAt = 0;\nlet _halfOpenProbeInFlight = false;\n\nfunction circuitAllow(logger: PluginLogger): boolean {\n if (_circuitState === \"CLOSED\") return true;\n if (_circuitState === \"OPEN\") {\n if (Date.now() - _circuitOpenedAt >= CIRCUIT_BREAKER_COOLDOWN_MS) {\n _circuitState = \"HALF_OPEN\";\n logger.info(\"[claude-mem] Circuit breaker: probing worker connection\");\n if (_halfOpenProbeInFlight) return false;\n _halfOpenProbeInFlight = true;\n return true;\n }\n return false;\n }\n if (_halfOpenProbeInFlight) return false;\n _halfOpenProbeInFlight = true;\n return true;\n}\n\nfunction circuitOnSuccess(logger: PluginLogger): void {\n if (_circuitState !== \"CLOSED\") {\n logger.info(\"[claude-mem] Worker connection restored — circuit closed\");\n }\n _circuitState = \"CLOSED\";\n _circuitFailures = 0;\n _halfOpenProbeInFlight = false;\n}\n\nfunction circuitOnFailure(logger: PluginLogger): void {\n _halfOpenProbeInFlight = false;\n _circuitFailures++;\n if (\n _circuitState === \"HALF_OPEN\" ||\n (_circuitState === \"CLOSED\" && _circuitFailures >= CIRCUIT_BREAKER_THRESHOLD)\n ) {\n _circuitState = \"OPEN\";\n _circuitOpenedAt = Date.now();\n logger.warn(\n `[claude-mem] Worker unreachable — disabling requests for ${CIRCUIT_BREAKER_COOLDOWN_MS / 1000}s`\n );\n }\n}\n\nfunction circuitReset(): void {\n _circuitState = \"CLOSED\";\n _circuitFailures = 0;\n _circuitOpenedAt = 0;\n _halfOpenProbeInFlight = false;\n}\n\nasync function workerPost(\n port: number,\n path: string,\n body: Record\u003cstring, unknown>,\n logger: PluginLogger\n): Promise\u003cRecord\u003cstring, unknown> | null> {\n if (!circuitAllow(logger)) return null;\n try {\n const response = await fetch(`${workerBaseUrl(port)}${path}`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify(body),\n });\n if (!response.ok) {\n circuitOnFailure(logger);\n logger.warn(`[claude-mem] Worker POST ${path} returned ${response.status}`);\n return null;\n }\n circuitOnSuccess(logger);\n return (await response.json()) as Record\u003cstring, unknown>;\n } catch (error: unknown) {\n const message = error instanceof Error ? error.message : String(error);\n circuitOnFailure(logger);\n if (_circuitState !== \"OPEN\") {\n logger.warn(`[claude-mem] Worker POST ${path} failed: ${message}`);\n }\n return null;\n }\n}\n\nfunction workerPostFireAndForget(\n port: number,\n path: string,\n body: Record\u003cstring, unknown>,\n logger: PluginLogger\n): void {\n if (!circuitAllow(logger)) return;\n fetch(`${workerBaseUrl(port)}${path}`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify(body),\n }).then((response) => {\n if (!response.ok) {\n circuitOnFailure(logger);\n logger.warn(`[claude-mem] Worker POST ${path} returned ${response.status}`);\n return;\n }\n circuitOnSuccess(logger);\n }).catch((error: unknown) => {\n const message = error instanceof Error ? error.message : String(error);\n circuitOnFailure(logger);\n if (_circuitState !== \"OPEN\") {\n logger.warn(`[claude-mem] Worker POST ${path} failed: ${message}`);\n }\n });\n}\n\nasync function workerGetText(\n port: number,\n path: string,\n logger: PluginLogger\n): Promise\u003cstring | null> {\n if (!circuitAllow(logger)) return null;\n try {\n const response = await fetch(`${workerBaseUrl(port)}${path}`);\n if (!response.ok) {\n circuitOnFailure(logger);\n logger.warn(`[claude-mem] Worker GET ${path} returned ${response.status}`);\n return null;\n }\n circuitOnSuccess(logger);\n return await response.text();\n } catch (error: unknown) {\n const message = error instanceof Error ? error.message : String(error);\n circuitOnFailure(logger);\n if (_circuitState !== \"OPEN\") {\n logger.warn(`[claude-mem] Worker GET ${path} failed: ${message}`);\n }\n return null;\n }\n}\n\nasync function workerGetJson(\n port: number,\n path: string,\n logger: PluginLogger\n): Promise\u003cRecord\u003cstring, unknown> | null> {\n const text = await workerGetText(port, path, logger);\n if (!text) return null;\n\n try {\n return JSON.parse(text) as Record\u003cstring, unknown>;\n } catch {\n logger.warn(`[claude-mem] Worker GET ${path} returned non-JSON response`);\n return null;\n }\n}\n\nfunction formatObservationMessage(\n observation: ObservationSSEPayload,\n getSourceLabel: (project: string | null | undefined) => string,\n): string {\n const title = observation.title || \"Untitled\";\n const source = getSourceLabel(observation.project);\n let message = `${source}\\n**${title}**`;\n if (observation.subtitle) {\n message += `\\n${observation.subtitle}`;\n }\n return message;\n}\n\nconst CHANNEL_SEND_MAP: Record\u003cstring, { namespace: string; functionName: string }> = {\n telegram: { namespace: \"telegram\", functionName: \"sendMessageTelegram\" },\n whatsapp: { namespace: \"whatsapp\", functionName: \"sendMessageWhatsApp\" },\n discord: { namespace: \"discord\", functionName: \"sendMessageDiscord\" },\n slack: { namespace: \"slack\", functionName: \"sendMessageSlack\" },\n signal: { namespace: \"signal\", functionName: \"sendMessageSignal\" },\n imessage: { namespace: \"imessage\", functionName: \"sendMessageIMessage\" },\n line: { namespace: \"line\", functionName: \"sendMessageLine\" },\n};\n\nasync function sendDirectTelegram(\n botToken: string,\n chatId: string,\n text: string,\n logger: PluginLogger\n): Promise\u003cvoid> {\n try {\n const response = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n chat_id: chatId,\n text,\n parse_mode: \"Markdown\",\n }),\n });\n if (!response.ok) {\n const body = await response.text();\n logger.warn(`[claude-mem] Direct Telegram send failed (${response.status}): ${body}`);\n }\n } catch (error: unknown) {\n const message = error instanceof Error ? error.message : String(error);\n logger.warn(`[claude-mem] Direct Telegram send error: ${message}`);\n }\n}\n\nfunction sendToChannel(\n api: OpenClawPluginApi,\n channel: string,\n to: string,\n text: string,\n botToken?: string\n): Promise\u003cvoid> {\n if (botToken && channel === \"telegram\") {\n return sendDirectTelegram(botToken, to, text, api.logger);\n }\n\n const mapping = CHANNEL_SEND_MAP[channel];\n if (!mapping) {\n api.logger.warn(`[claude-mem] Unsupported channel type: ${channel}`);\n return Promise.resolve();\n }\n\n const channelApi = api.runtime.channel[mapping.namespace];\n if (!channelApi) {\n api.logger.warn(`[claude-mem] Channel \"${channel}\" not available in runtime`);\n return Promise.resolve();\n }\n\n const senderFunction = channelApi[mapping.functionName];\n if (!senderFunction) {\n api.logger.warn(`[claude-mem] Channel \"${channel}\" has no ${mapping.functionName} function`);\n return Promise.resolve();\n }\n\n const args: unknown[] = channel === \"whatsapp\"\n ? [to, text, { verbose: false }]\n : [to, text];\n\n return senderFunction(...args).catch((error: unknown) => {\n const message = error instanceof Error ? error.message : String(error);\n api.logger.error(`[claude-mem] Failed to send to ${channel}: ${message}`);\n });\n}\n\nasync function connectToSSEStream(\n api: OpenClawPluginApi,\n port: number,\n channel: string,\n to: string,\n abortController: AbortController,\n setConnectionState: (state: ConnectionState) => void,\n getSourceLabel: (project: string | null | undefined) => string,\n botToken?: string\n): Promise\u003cvoid> {\n let backoffMs = 1000;\n const maxBackoffMs = 30000;\n\n while (!abortController.signal.aborted) {\n try {\n setConnectionState(\"reconnecting\");\n api.logger.info(`[claude-mem] Connecting to SSE stream at ${workerBaseUrl(port)}/stream`);\n\n const response = await fetch(`${workerBaseUrl(port)}/stream`, {\n signal: abortController.signal,\n headers: { Accept: \"text/event-stream\" },\n });\n\n if (!response.ok) {\n throw new Error(`SSE stream returned HTTP ${response.status}`);\n }\n\n if (!response.body) {\n throw new Error(\"SSE stream response has no body\");\n }\n\n setConnectionState(\"connected\");\n backoffMs = 1000;\n api.logger.info(\"[claude-mem] Connected to SSE stream\");\n\n const reader = response.body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n\n buffer += decoder.decode(value, { stream: true });\n\n if (buffer.length > MAX_SSE_BUFFER_SIZE) {\n api.logger.warn(\"[claude-mem] SSE buffer overflow, clearing buffer\");\n buffer = \"\";\n }\n\n const frames = buffer.split(\"\\n\\n\");\n buffer = frames.pop() || \"\";\n\n for (const frame of frames) {\n const dataLines = frame\n .split(\"\\n\")\n .filter((line) => line.startsWith(\"data:\"))\n .map((line) => line.slice(5).trim());\n if (dataLines.length === 0) continue;\n\n const jsonStr = dataLines.join(\"\\n\");\n if (!jsonStr) continue;\n\n try {\n const parsed = JSON.parse(jsonStr);\n if (parsed.type === \"new_observation\" && parsed.observation) {\n const event = parsed as SSENewObservationEvent;\n const message = formatObservationMessage(event.observation, getSourceLabel);\n await sendToChannel(api, channel, to, message, botToken);\n }\n } catch (parseError: unknown) {\n const errorMessage = parseError instanceof Error ? parseError.message : String(parseError);\n api.logger.warn(`[claude-mem] Failed to parse SSE frame: ${errorMessage}`);\n }\n }\n }\n } catch (error: unknown) {\n if (abortController.signal.aborted) {\n break;\n }\n setConnectionState(\"reconnecting\");\n const errorMessage = error instanceof Error ? error.message : String(error);\n api.logger.warn(`[claude-mem] SSE stream error: ${errorMessage}. Reconnecting in ${backoffMs / 1000}s`);\n }\n\n if (abortController.signal.aborted) break;\n\n await new Promise((resolve) => setTimeout(resolve, backoffMs));\n backoffMs = Math.min(backoffMs * 2, maxBackoffMs);\n }\n\n setConnectionState(\"disconnected\");\n}\n\nexport default function claudeMemPlugin(api: OpenClawPluginApi): void {\n const userConfig = (api.pluginConfig || {}) as ClaudeMemPluginConfig;\n const workerPort = userConfig.workerPort || DEFAULT_WORKER_PORT;\n _workerHost = userConfig.workerHost || DEFAULT_WORKER_HOST;\n const baseProjectName = userConfig.project || \"openclaw\";\n const getSourceLabel = buildGetSourceLabel(userConfig.observationFeed?.emojis);\n\n function getProjectName(ctx: EventContext): string {\n if (ctx.agentId) {\n return `openclaw-${ctx.agentId}`;\n }\n return baseProjectName;\n }\n\n const sessionIds = new Map\u003cstring, string>();\n const canonicalSessionKeys = new Map\u003cstring, string>();\n const sessionAliasesByCanonicalKey = new Map\u003cstring, Set\u003cstring>>();\n const recentPromptInits = new Map\u003cstring, number>();\n const syncMemoryFile = userConfig.syncMemoryFile !== false; \n const syncMemoryFileExclude = new Set(userConfig.syncMemoryFileExclude || []);\n\n function getContentSessionId(sessionKey?: string): string {\n const key = sessionKey || \"default\";\n if (!sessionIds.has(key)) {\n sessionIds.set(key, `openclaw-${key}-${Date.now()}`);\n }\n return sessionIds.get(key)!;\n }\n\n function shouldInjectContext(ctx?: EventContext): boolean {\n if (!syncMemoryFile) return false;\n const agentId = ctx?.agentId;\n if (agentId && syncMemoryFileExclude.has(agentId)) return false;\n return true;\n }\n\n type SessionTrackingContext = {\n sessionKey?: string;\n workspaceDir?: string;\n channelId?: string;\n conversationId?: string;\n };\n\n function getSessionAliases(ctx: SessionTrackingContext): string[] {\n const aliases = new Set\u003cstring>();\n for (const rawKey of [ctx.sessionKey, ctx.conversationId, ctx.channelId]) {\n const key = typeof rawKey === \"string\" ? rawKey.trim() : \"\";\n if (key) aliases.add(key);\n }\n if (aliases.size === 0) aliases.add(\"default\");\n return Array.from(aliases);\n }\n\n function rememberSessionContext(ctx: SessionTrackingContext): { canonicalKey: string; contentSessionId: string } {\n const aliases = getSessionAliases(ctx);\n let canonicalKey = aliases.find((alias) => canonicalSessionKeys.has(alias));\n canonicalKey = canonicalKey ? canonicalSessionKeys.get(canonicalKey)! : aliases[0];\n let aliasSet = sessionAliasesByCanonicalKey.get(canonicalKey);\n if (!aliasSet) {\n aliasSet = new Set([canonicalKey]);\n sessionAliasesByCanonicalKey.set(canonicalKey, aliasSet);\n }\n for (const alias of aliases) {\n aliasSet.add(alias);\n canonicalSessionKeys.set(alias, canonicalKey);\n }\n const contentSessionId = getContentSessionId(canonicalKey);\n for (const alias of aliasSet) {\n sessionIds.set(alias, contentSessionId);\n }\n return { canonicalKey, contentSessionId };\n }\n\n function shouldSkipDuplicatePromptInit(contentSessionId: string, project: string, prompt: string): boolean {\n const now = Date.now();\n for (const [key, timestamp] of recentPromptInits) {\n if (now - timestamp > 2000) recentPromptInits.delete(key);\n }\n const cacheKey = `${contentSessionId}::${project}::${prompt}`;\n const lastSeenAt = recentPromptInits.get(cacheKey);\n recentPromptInits.set(cacheKey, now);\n return typeof lastSeenAt === \"number\" && now - lastSeenAt \u003c= 2000;\n }\n\n function clearSessionContext(ctx: SessionTrackingContext): void {\n const aliases = getSessionAliases(ctx);\n const canonicalKey = aliases\n .map((alias) => canonicalSessionKeys.get(alias))\n .find(Boolean) || aliases[0];\n const knownAliases = sessionAliasesByCanonicalKey.get(canonicalKey) || new Set([canonicalKey, ...aliases]);\n for (const alias of knownAliases) {\n canonicalSessionKeys.delete(alias);\n sessionIds.delete(alias);\n }\n sessionAliasesByCanonicalKey.delete(canonicalKey);\n sessionIds.delete(canonicalKey);\n }\n\n const CONTEXT_CACHE_TTL_MS = 60_000;\n const contextCache = new Map\u003cstring, { text: string; fetchedAt: number }>();\n\n async function getContextForPrompt(ctx?: EventContext): Promise\u003cstring | null> {\n const projects = [baseProjectName];\n const agentProject = ctx ? getProjectName(ctx) : null;\n if (agentProject && agentProject !== baseProjectName) {\n projects.push(agentProject);\n }\n const cacheKey = projects.join(\",\");\n\n const cached = contextCache.get(cacheKey);\n if (cached && Date.now() - cached.fetchedAt \u003c CONTEXT_CACHE_TTL_MS) {\n return cached.text;\n }\n\n const contextText = await workerGetText(\n workerPort,\n `/api/context/inject?projects=${encodeURIComponent(cacheKey)}`,\n api.logger\n );\n if (contextText && contextText.trim().length > 0) {\n const trimmed = contextText.trim();\n contextCache.set(cacheKey, { text: trimmed, fetchedAt: Date.now() });\n return trimmed;\n }\n return null;\n }\n\n // Centralized session-init POST. session_start, after_compaction, and\n // before_agent_start each call this; the 2s dedup guard\n // (shouldSkipDuplicatePromptInit) collapses the redundant inits a single\n // user-message flow produces into one prompt record, while still ensuring a\n // session is initialized even on flows that never reach before_agent_start.\n async function initSessionOnce(ctx: EventContext, promptText: string, via: string): Promise\u003cvoid> {\n const { contentSessionId } = rememberSessionContext(ctx);\n const projectName = getProjectName(ctx);\n\n if (shouldSkipDuplicatePromptInit(contentSessionId, projectName, promptText)) {\n api.logger.info(`[claude-mem] Skipping duplicate prompt init: contentSessionId=${contentSessionId} project=${projectName} via=${via}`);\n return;\n }\n\n await workerPost(workerPort, \"/api/sessions/init\", {\n contentSessionId,\n project: projectName,\n prompt: promptText,\n }, api.logger);\n\n api.logger.info(`[claude-mem] Session initialized via ${via}: contentSessionId=${contentSessionId} project=${projectName}`);\n }\n\n api.on(\"session_start\", async (_event, ctx) => {\n await initSessionOnce(ctx, \"session start\", \"session_start\");\n });\n\n api.on(\"message_received\", async (event, ctx) => {\n const { canonicalKey, contentSessionId } = rememberSessionContext(ctx);\n api.logger.info(`[claude-mem] Message received — prompt capture deferred to before_agent_start: session=${canonicalKey} contentSessionId=${contentSessionId} hasContent=${Boolean(event.content)}`);\n });\n\n api.on(\"after_compaction\", async (_event, ctx) => {\n await initSessionOnce(ctx, \"after compaction\", \"after_compaction\");\n });\n\n api.on(\"before_agent_start\", async (event, ctx) => {\n await initSessionOnce(ctx, event.prompt || \"agent run\", \"before_agent_start\");\n });\n\n api.on(\"before_prompt_build\", async (_event, ctx) => {\n if (!shouldInjectContext(ctx)) return;\n\n const contextText = await getContextForPrompt(ctx);\n if (contextText) {\n api.logger.info(`[claude-mem] Context injected via system prompt for agent=${ctx.agentId ?? \"unknown\"}`);\n return { appendSystemContext: contextText };\n }\n });\n\n api.on(\"tool_result_persist\", (event, ctx) => {\n api.logger.info(`[claude-mem] tool_result_persist fired: tool=${event.toolName ?? \"unknown\"} agent=${ctx.agentId ?? \"none\"} session=${ctx.sessionKey ?? \"none\"}`);\n const toolName = event.toolName;\n if (!toolName) return;\n\n if (toolName.startsWith(\"memory_\")) return;\n\n const { canonicalKey, contentSessionId } = rememberSessionContext(ctx);\n\n let toolResponseText = \"\";\n const content = event.message?.content;\n if (Array.isArray(content)) {\n toolResponseText = content\n .filter((block) => (block.type === \"tool_result\" || block.type === \"text\") && \"text\" in block)\n .map((block) => String(block.text))\n .join(\"\\n\");\n }\n\n const MAX_TOOL_RESPONSE_LENGTH = 1000;\n if (toolResponseText.length > MAX_TOOL_RESPONSE_LENGTH) {\n toolResponseText = toolResponseText.slice(0, MAX_TOOL_RESPONSE_LENGTH);\n }\n\n // Fall back to the process cwd when the event carries no workspaceDir, so a\n // missing ctx field never silently drops a captured observation.\n const workspaceDir = ctx.workspaceDir || process.cwd();\n if (!ctx.workspaceDir) {\n api.logger.info(`[claude-mem] tool_result_persist missing workspaceDir; using process.cwd(): session=${canonicalKey} tool=${toolName}`);\n }\n\n workerPostFireAndForget(workerPort, \"/api/sessions/observations\", {\n contentSessionId,\n tool_name: toolName,\n tool_input: event.params || {},\n tool_response: toolResponseText,\n cwd: workspaceDir,\n }, api.logger);\n });\n\n api.on(\"agent_end\", async (event, ctx) => {\n const { contentSessionId } = rememberSessionContext(ctx);\n\n let lastAssistantMessage = \"\";\n if (Array.isArray(event.messages)) {\n for (let i = event.messages.length - 1; i >= 0; i--) {\n const message = event.messages[i];\n if (message?.role === \"assistant\") {\n if (typeof message.content === \"string\") {\n lastAssistantMessage = message.content;\n } else if (Array.isArray(message.content)) {\n lastAssistantMessage = message.content\n .filter((block) => block.type === \"text\")\n .map((block) => block.text || \"\")\n .join(\"\\n\");\n }\n break;\n }\n }\n }\n\n await workerPost(workerPort, \"/api/sessions/summarize\", {\n contentSessionId,\n last_assistant_message: lastAssistantMessage,\n }, api.logger);\n });\n\n api.on(\"session_end\", async (_event, ctx) => {\n clearSessionContext(ctx);\n api.logger.info(`[claude-mem] Session tracking cleaned up`);\n });\n\n api.on(\"gateway_start\", async () => {\n circuitReset();\n sessionIds.clear();\n contextCache.clear();\n recentPromptInits.clear();\n canonicalSessionKeys.clear();\n sessionAliasesByCanonicalKey.clear();\n api.logger.info(\"[claude-mem] Gateway started — session tracking reset\");\n });\n\n let sseAbortController: AbortController | null = null;\n let connectionState: ConnectionState = \"disconnected\";\n let connectionPromise: Promise\u003cvoid> | null = null;\n\n api.registerService({\n id: \"claude-mem-observation-feed\",\n start: async (_ctx) => {\n if (sseAbortController) {\n sseAbortController.abort();\n if (connectionPromise) {\n await connectionPromise;\n connectionPromise = null;\n }\n }\n\n const feedConfig = userConfig.observationFeed;\n\n if (!feedConfig?.enabled) {\n api.logger.info(\"[claude-mem] Observation feed disabled\");\n return;\n }\n\n if (!feedConfig.channel || !feedConfig.to) {\n api.logger.warn(\"[claude-mem] Observation feed misconfigured — channel or target missing\");\n return;\n }\n\n api.logger.info(`[claude-mem] Observation feed starting — channel: ${feedConfig.channel}, target: ${feedConfig.to}`);\n\n sseAbortController = new AbortController();\n connectionPromise = connectToSSEStream(\n api,\n workerPort,\n feedConfig.channel,\n feedConfig.to,\n sseAbortController,\n (state) => { connectionState = state; },\n getSourceLabel,\n feedConfig.botToken\n );\n },\n stop: async (_ctx) => {\n if (sseAbortController) {\n sseAbortController.abort();\n sseAbortController = null;\n }\n if (connectionPromise) {\n await connectionPromise;\n connectionPromise = null;\n }\n connectionState = \"disconnected\";\n api.logger.info(\"[claude-mem] Observation feed stopped — SSE connection closed\");\n },\n });\n\n function summarizeSearchResults(items: unknown[], limit = 5): string {\n if (!Array.isArray(items) || items.length === 0) {\n return \"No results found.\";\n }\n\n return items\n .slice(0, limit)\n .map((item, index) => {\n const row = item as Record\u003cstring, unknown>;\n const title = String(row.title || row.subtitle || row.text || \"Untitled\");\n const project = row.project ? ` [${String(row.project)}]` : \"\";\n return `${index + 1}. ${title}${project}`;\n })\n .join(\"\\n\");\n }\n\n function parseLimit(arg: string | undefined, fallback = 10): number {\n const parsed = Number(arg);\n if (!Number.isFinite(parsed)) return fallback;\n return Math.max(1, Math.min(50, Math.trunc(parsed)));\n }\n\n api.registerCommand({\n name: \"claude_mem_feed\",\n description: \"Show or toggle Claude-Mem observation feed status\",\n acceptsArgs: true,\n handler: async (ctx) => {\n const feedConfig = userConfig.observationFeed;\n\n if (!feedConfig) {\n return { text: \"Observation feed not configured. Add observationFeed to your plugin config.\" };\n }\n\n const arg = ctx.args?.trim();\n\n if (arg === \"on\") {\n api.logger.info(\"[claude-mem] Feed enable requested via command\");\n return { text: \"Feed enable requested. Update observationFeed.enabled in your plugin config to persist.\" };\n }\n\n if (arg === \"off\") {\n api.logger.info(\"[claude-mem] Feed disable requested via command\");\n return { text: \"Feed disable requested. Update observationFeed.enabled in your plugin config to persist.\" };\n }\n\n return { text: [\n \"Claude-Mem Observation Feed\",\n `Enabled: ${feedConfig.enabled ? \"yes\" : \"no\"}`,\n `Channel: ${feedConfig.channel || \"not set\"}`,\n `Target: ${feedConfig.to || \"not set\"}`,\n `Connection: ${connectionState}`,\n ].join(\"\\n\") };\n },\n });\n\n api.registerCommand({\n name: \"claude-mem-search\",\n description: \"Search Claude-Mem observations by query\",\n acceptsArgs: true,\n handler: async (ctx) => {\n const raw = ctx.args?.trim() || \"\";\n if (!raw) {\n return \"Usage: /claude-mem-search \u003cquery> [limit]\";\n }\n\n const pieces = raw.split(/\\s+/);\n const maybeLimit = pieces[pieces.length - 1];\n const hasTrailingLimit = /^\\d+$/.test(maybeLimit);\n const limit = hasTrailingLimit ? parseLimit(maybeLimit, 10) : 10;\n const query = hasTrailingLimit ? pieces.slice(0, -1).join(\" \") : raw;\n\n const data = await workerGetJson(\n workerPort,\n `/api/search/observations?query=${encodeURIComponent(query)}&limit=${limit}`,\n api.logger,\n );\n\n if (!data) {\n return \"Claude-Mem search failed (worker unavailable or invalid response).\";\n }\n\n const items = Array.isArray(data.items) ? data.items : [];\n return [\n `Claude-Mem Search: \\\"${query}\\\"`,\n summarizeSearchResults(items, limit),\n ].join(\"\\n\");\n },\n });\n\n api.registerCommand({\n name: \"claude-mem-recent\",\n description: \"Show recent Claude-Mem context for a project\",\n acceptsArgs: true,\n handler: async (ctx) => {\n const raw = ctx.args?.trim() || \"\";\n const parts = raw ? raw.split(/\\s+/) : [];\n const maybeLimit = parts.length > 0 ? parts[parts.length - 1] : \"\";\n const hasTrailingLimit = /^\\d+$/.test(maybeLimit);\n const limit = hasTrailingLimit ? parseLimit(maybeLimit, 3) : 3;\n const project = hasTrailingLimit ? parts.slice(0, -1).join(\" \") : raw;\n\n const params = new URLSearchParams();\n params.set(\"limit\", String(limit));\n if (project) params.set(\"project\", project);\n\n const data = await workerGetJson(\n workerPort,\n `/api/context/recent?${params.toString()}`,\n api.logger,\n );\n\n if (!data) {\n return \"Claude-Mem recent context failed (worker unavailable or invalid response).\";\n }\n\n const summaries = Array.isArray(data.session_summaries) ? data.session_summaries : [];\n const observations = Array.isArray(data.recent_observations) ? data.recent_observations : [];\n\n return [\n \"Claude-Mem Recent Context\",\n `Project: ${project || \"(auto)\"}`,\n `Session summaries: ${summaries.length}`,\n `Recent observations: ${observations.length}`,\n summarizeSearchResults(observations, Math.min(5, observations.length || 5)),\n ].join(\"\\n\");\n },\n });\n\n api.registerCommand({\n name: \"claude-mem-timeline\",\n description: \"Find best memory match and show nearby timeline events\",\n acceptsArgs: true,\n handler: async (ctx) => {\n const raw = ctx.args?.trim() || \"\";\n if (!raw) {\n return \"Usage: /claude-mem-timeline \u003cquery> [depthBefore] [depthAfter]\";\n }\n\n const parts = raw.split(/\\s+/);\n let depthAfter = 5;\n let depthBefore = 5;\n\n if (parts.length >= 2 && /^\\d+$/.test(parts[parts.length - 1])) {\n depthAfter = parseLimit(parts.pop(), 5);\n }\n if (parts.length >= 2 && /^\\d+$/.test(parts[parts.length - 1])) {\n depthBefore = parseLimit(parts.pop(), 5);\n }\n\n const query = parts.join(\" \");\n const params = new URLSearchParams({\n query,\n mode: \"auto\",\n depth_before: String(depthBefore),\n depth_after: String(depthAfter),\n });\n\n const data = await workerGetJson(\n workerPort,\n `/api/timeline/by-query?${params.toString()}`,\n api.logger,\n );\n\n if (!data) {\n return \"Claude-Mem timeline lookup failed (worker unavailable or invalid response).\";\n }\n\n const timeline = Array.isArray(data.timeline) ? data.timeline : [];\n const anchor = data.anchor ? String(data.anchor) : \"(none)\";\n\n return [\n `Claude-Mem Timeline: \\\"${query}\\\"`,\n `Anchor: ${anchor}`,\n summarizeSearchResults(timeline, 8),\n ].join(\"\\n\");\n },\n });\n\n api.registerCommand({\n name: \"claude_mem_status\",\n description: \"Check Claude-Mem worker health and session status\",\n handler: async () => {\n const healthText = await workerGetText(workerPort, \"/api/health\", api.logger);\n if (!healthText) {\n return { text: `Claude-Mem worker unreachable at port ${workerPort}` };\n }\n\n try {\n const health = JSON.parse(healthText);\n return { text: [\n \"Claude-Mem Worker Status\",\n `Status: ${health.status || \"unknown\"}`,\n `Port: ${workerPort}`,\n `Active sessions: ${sessionIds.size}`,\n `Observation feed: ${connectionState}`,\n ].join(\"\\n\") };\n } catch {\n return { text: `Claude-Mem worker responded but returned unexpected data` };\n }\n },\n });\n\n api.logger.info(`[claude-mem] OpenClaw plugin loaded — v1.0.0 (worker: ${_workerHost}:${workerPort})`);\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":35704,"content_sha256":"a112e19e61c897f99034e59e9075b41a5176c8fb74a93e7f9d33708fa4f0a71f"},{"filename":"test-e2e.sh","content":"#!/usr/bin/env bash\nset -euo pipefail\n\ncd \"$(dirname \"$0\")\"\n\nIMAGE_NAME=\"openclaw-claude-mem-e2e\"\n\necho \"=== Building E2E test image ===\"\necho \" Base: ghcr.io/openclaw/openclaw:main\"\necho \" Plugin: @claude-mem/openclaw-plugin (PR #1012)\"\necho \"\"\n\ndocker build -f Dockerfile.e2e -t \"$IMAGE_NAME\" .\n\nif [ \"${1:-}\" = \"--build-only\" ]; then\n echo \"\"\n echo \"Image built: $IMAGE_NAME\"\n echo \"Run manually with: docker run --rm $IMAGE_NAME\"\n exit 0\nfi\n\necho \"\"\necho \"=== Running E2E verification ===\"\necho \"\"\n\nif [ \"${1:-}\" = \"--interactive\" ]; then\n echo \"Dropping into interactive shell.\"\n echo \"\"\n echo \"Useful commands inside the container:\"\n echo \" node openclaw.mjs plugins list # Verify plugin is installed\"\n echo \" node openclaw.mjs plugins info claude-mem # Plugin details\"\n echo \" node openclaw.mjs plugins doctor # Check for issues\"\n echo \" node /app/mock-worker.js & # Start mock worker\"\n echo \" node openclaw.mjs gateway --allow-unconfigured --verbose # Start gateway\"\n echo \" /bin/bash /app/e2e-verify.sh # Run automated verification\"\n echo \"\"\n docker run --rm -it \"$IMAGE_NAME\" /bin/bash\nelse\n docker run --rm \"$IMAGE_NAME\"\nfi\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":1204,"content_sha256":"146ee4b7507e7488796e422c1d26838c33ac3d5f5ec79ab10a690cd768b53ade"},{"filename":"test-install.sh","content":"#!/usr/bin/env bash\nset -euo pipefail\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nINSTALL_SCRIPT=\"${SCRIPT_DIR}/install.sh\"\n\nTESTS_RUN=0\nTESTS_PASSED=0\nTESTS_FAILED=0\n\ntest_pass() {\n TESTS_RUN=$((TESTS_RUN + 1))\n TESTS_PASSED=$((TESTS_PASSED + 1))\n echo -e \"\\033[0;32m✓\\033[0m $1\"\n}\n\ntest_fail() {\n TESTS_RUN=$((TESTS_RUN + 1))\n TESTS_FAILED=$((TESTS_FAILED + 1))\n echo -e \"\\033[0;31m✗\\033[0m $1\"\n if [[ -n \"${2:-}\" ]]; then\n echo \" Detail: $2\"\n fi\n}\n\nassert_eq() {\n local expected=\"$1\" actual=\"$2\" msg=\"$3\"\n if [[ \"$expected\" == \"$actual\" ]]; then\n test_pass \"$msg\"\n else\n test_fail \"$msg\" \"expected='${expected}' actual='${actual}'\"\n fi\n}\n\nassert_contains() {\n local haystack=\"$1\" needle=\"$2\" msg=\"$3\"\n if [[ \"$haystack\" == *\"$needle\"* ]]; then\n test_pass \"$msg\"\n else\n test_fail \"$msg\" \"expected string to contain '${needle}'\"\n fi\n}\n\nassert_file_exists() {\n local filepath=\"$1\" msg=\"$2\"\n if [[ -f \"$filepath\" ]]; then\n test_pass \"$msg\"\n else\n test_fail \"$msg\" \"file not found: ${filepath}\"\n fi\n}\n\nsource_install_functions() {\n local tmp_source\n tmp_source=\"$(mktemp)\"\n sed '$ d' \"$INSTALL_SCRIPT\" > \"$tmp_source\"\n echo 'main() { :; }' >> \"$tmp_source\"\n TERM=dumb source \"$tmp_source\"\n rm -f \"$tmp_source\"\n}\n\nsource_install_functions\n\necho \"\"\necho \"=== detect_platform() ===\"\n\ntest_detect_platform_returns_valid_string() {\n PLATFORM=\"\"\n IS_WSL=\"\"\n detect_platform >/dev/null 2>&1\n\n case \"$PLATFORM\" in\n macos|linux|windows)\n test_pass \"detect_platform sets PLATFORM='${PLATFORM}'\"\n ;;\n *)\n test_fail \"detect_platform returned unexpected PLATFORM='${PLATFORM}'\" \"expected macos, linux, or windows\"\n ;;\n esac\n}\n\ntest_detect_platform_returns_valid_string\n\ntest_detect_platform_is_idempotent() {\n PLATFORM=\"\"\n IS_WSL=\"\"\n detect_platform >/dev/null 2>&1\n local first_platform=\"$PLATFORM\"\n\n PLATFORM=\"\"\n IS_WSL=\"\"\n detect_platform >/dev/null 2>&1\n local second_platform=\"$PLATFORM\"\n\n assert_eq \"$first_platform\" \"$second_platform\" \"detect_platform returns consistent results\"\n}\n\ntest_detect_platform_is_idempotent\n\ntest_detect_platform_sets_iswsl_empty_on_non_wsl() {\n PLATFORM=\"\"\n IS_WSL=\"\"\n detect_platform >/dev/null 2>&1\n\n if [[ \"$PLATFORM\" == \"linux\" ]] && grep -qi microsoft /proc/version 2>/dev/null; then\n assert_eq \"true\" \"$IS_WSL\" \"IS_WSL is 'true' on WSL\"\n else\n assert_eq \"\" \"${IS_WSL:-}\" \"IS_WSL is empty on non-WSL platform\"\n fi\n}\n\ntest_detect_platform_sets_iswsl_empty_on_non_wsl\n\necho \"\"\necho \"=== check_bun() ===\"\n\ntest_check_bun_detects_installed_bun() {\n if command -v bun &>/dev/null; then\n BUN_PATH=\"\"\n if check_bun >/dev/null 2>&1; then\n test_pass \"check_bun succeeds when bun is installed\"\n else\n test_fail \"check_bun should succeed when bun is installed\"\n fi\n\n if [[ -n \"$BUN_PATH\" ]]; then\n test_pass \"check_bun sets BUN_PATH='${BUN_PATH}'\"\n else\n test_fail \"check_bun should set BUN_PATH when bun is found\"\n fi\n else\n test_pass \"check_bun test (installed): skipped (bun not installed)\"\n test_pass \"check_bun BUN_PATH test: skipped (bun not installed)\"\n fi\n}\n\ntest_check_bun_detects_installed_bun\n\ntest_check_bun_fails_when_not_found() {\n local fake_home\n fake_home=\"$(mktemp -d)\"\n local exit_code=0\n bash -c '\n set -euo pipefail\n TERM=dumb\n export HOME=\"'\"$fake_home\"'\"\n tmp=$(mktemp)\n sed \"$ d\" \"'\"${INSTALL_SCRIPT}\"'\" > \"$tmp\"\n echo \"main() { :; }\" >> \"$tmp\"\n source \"$tmp\"\n rm -f \"$tmp\"\n PATH=\"/nonexistent\"\n BUN_PATH=\"\"\n check_bun\n ' >/dev/null 2>&1 || exit_code=$?\n rm -rf \"$fake_home\"\n\n if [[ \"$exit_code\" -ne 0 ]]; then\n test_pass \"check_bun returns failure when bun is not in PATH\"\n else\n test_fail \"check_bun should return failure when bun is not in PATH\"\n fi\n}\n\ntest_check_bun_fails_when_not_found\n\ntest_find_bun_path_checks_home_bun_bin() {\n local fake_home\n fake_home=\"$(mktemp -d)\"\n local saved_home=\"$HOME\"\n HOME=\"$fake_home\"\n BUN_PATH=\"\"\n\n mkdir -p \"${fake_home}/.bun/bin\"\n cat > \"${fake_home}/.bun/bin/bun\" \u003c\u003c'FAKEBUN'\necho \"1.2.0\"\nFAKEBUN\n chmod +x \"${fake_home}/.bun/bin/bun\"\n\n local saved_path=\"$PATH\"\n PATH=\"/nonexistent\"\n\n if find_bun_path 2>/dev/null; then\n assert_eq \"${fake_home}/.bun/bin/bun\" \"$BUN_PATH\" \"find_bun_path finds bun in ~/.bun/bin/\"\n else\n test_fail \"find_bun_path should find bun in ~/.bun/bin/\"\n fi\n\n HOME=\"$saved_home\"\n PATH=\"$saved_path\"\n rm -rf \"$fake_home\"\n}\n\ntest_find_bun_path_checks_home_bun_bin\n\necho \"\"\necho \"=== check_uv() ===\"\n\ntest_check_uv_detects_installed_uv() {\n if command -v uv &>/dev/null; then\n UV_PATH=\"\"\n if check_uv >/dev/null 2>&1; then\n test_pass \"check_uv succeeds when uv is installed\"\n else\n test_fail \"check_uv should succeed when uv is installed\"\n fi\n\n if [[ -n \"$UV_PATH\" ]]; then\n test_pass \"check_uv sets UV_PATH='${UV_PATH}'\"\n else\n test_fail \"check_uv should set UV_PATH when uv is found\"\n fi\n else\n test_pass \"check_uv test (installed): skipped (uv not installed)\"\n test_pass \"check_uv UV_PATH test: skipped (uv not installed)\"\n fi\n}\n\ntest_check_uv_detects_installed_uv\n\ntest_check_uv_fails_when_not_found() {\n if [[ -x \"/usr/local/bin/uv\" ]] || [[ -x \"/opt/homebrew/bin/uv\" ]]; then\n test_pass \"check_uv not-found test: skipped (uv installed at system path)\"\n return 0\n fi\n\n local fake_home\n fake_home=\"$(mktemp -d)\"\n local exit_code=0\n bash -c '\n set -euo pipefail\n TERM=dumb\n export HOME=\"'\"$fake_home\"'\"\n tmp=$(mktemp)\n sed \"$ d\" \"'\"${INSTALL_SCRIPT}\"'\" > \"$tmp\"\n echo \"main() { :; }\" >> \"$tmp\"\n source \"$tmp\"\n rm -f \"$tmp\"\n PATH=\"/nonexistent\"\n UV_PATH=\"\"\n check_uv\n ' >/dev/null 2>&1 || exit_code=$?\n rm -rf \"$fake_home\"\n\n if [[ \"$exit_code\" -ne 0 ]]; then\n test_pass \"check_uv returns failure when uv is not in PATH\"\n else\n test_fail \"check_uv should return failure when uv is not in PATH\"\n fi\n}\n\ntest_check_uv_fails_when_not_found\n\ntest_find_uv_path_checks_local_bin() {\n local fake_home\n fake_home=\"$(mktemp -d)\"\n local saved_home=\"$HOME\"\n HOME=\"$fake_home\"\n UV_PATH=\"\"\n\n mkdir -p \"${fake_home}/.local/bin\"\n cat > \"${fake_home}/.local/bin/uv\" \u003c\u003c'FAKEUV'\necho \"uv 0.4.0\"\nFAKEUV\n chmod +x \"${fake_home}/.local/bin/uv\"\n\n local saved_path=\"$PATH\"\n PATH=\"/nonexistent\"\n\n if find_uv_path 2>/dev/null; then\n assert_eq \"${fake_home}/.local/bin/uv\" \"$UV_PATH\" \"find_uv_path finds uv in ~/.local/bin/\"\n else\n test_fail \"find_uv_path should find uv in ~/.local/bin/\"\n fi\n\n HOME=\"$saved_home\"\n PATH=\"$saved_path\"\n rm -rf \"$fake_home\"\n}\n\ntest_find_uv_path_checks_local_bin\n\necho \"\"\necho \"=== find_openclaw() ===\"\n\nORIGINAL_PATH=\"$PATH\"\nORIGINAL_HOME=\"$HOME\"\n\ntest_find_openclaw_not_found() {\n local fake_home\n fake_home=\"$(mktemp -d)\"\n HOME=\"$fake_home\"\n PATH=\"/nonexistent\"\n OPENCLAW_PATH=\"\"\n\n if find_openclaw 2>/dev/null; then\n test_fail \"find_openclaw should return 1 when openclaw.mjs is not found\"\n else\n test_pass \"find_openclaw returns 1 when not found\"\n fi\n\n assert_eq \"\" \"$OPENCLAW_PATH\" \"OPENCLAW_PATH is empty when not found\"\n\n HOME=\"$ORIGINAL_HOME\"\n PATH=\"$ORIGINAL_PATH\"\n rm -rf \"$fake_home\"\n}\n\ntest_find_openclaw_not_found\n\ntest_find_openclaw_in_home() {\n local fake_home\n fake_home=\"$(mktemp -d)\"\n mkdir -p \"${fake_home}/.openclaw\"\n touch \"${fake_home}/.openclaw/openclaw.mjs\"\n\n HOME=\"$fake_home\"\n PATH=\"/nonexistent\"\n OPENCLAW_PATH=\"\"\n\n if find_openclaw 2>/dev/null; then\n test_pass \"find_openclaw finds openclaw.mjs in ~/.openclaw/\"\n assert_eq \"${fake_home}/.openclaw/openclaw.mjs\" \"$OPENCLAW_PATH\" \"OPENCLAW_PATH set correctly\"\n else\n test_fail \"find_openclaw should find openclaw.mjs in ~/.openclaw/\"\n fi\n\n HOME=\"$ORIGINAL_HOME\"\n PATH=\"$ORIGINAL_PATH\"\n rm -rf \"$fake_home\"\n}\n\ntest_find_openclaw_in_home\n\necho \"\"\necho \"=== configure_memory_slot() ===\"\n\ntest_configure_new_config() {\n local fake_home\n fake_home=\"$(mktemp -d)\"\n HOME=\"$fake_home\"\n\n configure_memory_slot >/dev/null 2>&1\n\n local config_file=\"${fake_home}/.openclaw/openclaw.json\"\n assert_file_exists \"$config_file\" \"Config file created at ~/.openclaw/openclaw.json\"\n\n local memory_slot\n memory_slot=\"$(node -e \"const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.slots.memory);\")\"\n assert_eq \"claude-mem\" \"$memory_slot\" \"Memory slot set to claude-mem in new config\"\n\n local enabled\n enabled=\"$(node -e \"const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].enabled);\")\"\n assert_eq \"true\" \"$enabled\" \"claude-mem entry is enabled in new config\"\n\n local worker_port\n worker_port=\"$(node -e \"const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.workerPort);\")\"\n assert_eq \"37777\" \"$worker_port\" \"Worker port is 37777 in new config\"\n\n HOME=\"$ORIGINAL_HOME\"\n rm -rf \"$fake_home\"\n}\n\ntest_configure_new_config\n\ntest_configure_existing_config() {\n local fake_home\n fake_home=\"$(mktemp -d)\"\n HOME=\"$fake_home\"\n\n mkdir -p \"${fake_home}/.openclaw\"\n local config_file=\"${fake_home}/.openclaw/openclaw.json\"\n node -e \"\n const config = {\n gateway: { mode: 'local' },\n plugins: {\n slots: { memory: 'memory-core' },\n entries: {\n 'some-other-plugin': { enabled: true }\n }\n }\n };\n require('fs').writeFileSync('${config_file}', JSON.stringify(config, null, 2));\n \"\n\n configure_memory_slot >/dev/null 2>&1\n\n local memory_slot\n memory_slot=\"$(node -e \"const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.slots.memory);\")\"\n assert_eq \"claude-mem\" \"$memory_slot\" \"Memory slot updated from memory-core to claude-mem\"\n\n local gateway_mode\n gateway_mode=\"$(node -e \"const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.gateway.mode);\")\"\n assert_eq \"local\" \"$gateway_mode\" \"Existing gateway.mode setting preserved\"\n\n local other_plugin\n other_plugin=\"$(node -e \"const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['some-other-plugin'].enabled);\")\"\n assert_eq \"true\" \"$other_plugin\" \"Existing plugin entries preserved\"\n\n local cm_enabled\n cm_enabled=\"$(node -e \"const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].enabled);\")\"\n assert_eq \"true\" \"$cm_enabled\" \"claude-mem entry added and enabled\"\n\n HOME=\"$ORIGINAL_HOME\"\n rm -rf \"$fake_home\"\n}\n\ntest_configure_existing_config\n\ntest_configure_preserves_existing_cm_config() {\n local fake_home\n fake_home=\"$(mktemp -d)\"\n HOME=\"$fake_home\"\n\n mkdir -p \"${fake_home}/.openclaw\"\n local config_file=\"${fake_home}/.openclaw/openclaw.json\"\n node -e \"\n const config = {\n plugins: {\n slots: { memory: 'memory-core' },\n entries: {\n 'claude-mem': {\n enabled: false,\n config: {\n workerPort: 38888,\n observationFeed: { enabled: true, channel: 'telegram', to: '12345' }\n }\n }\n }\n }\n };\n require('fs').writeFileSync('${config_file}', JSON.stringify(config, null, 2));\n \"\n\n configure_memory_slot >/dev/null 2>&1\n\n local cm_enabled\n cm_enabled=\"$(node -e \"const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].enabled);\")\"\n assert_eq \"true\" \"$cm_enabled\" \"claude-mem entry enabled when previously disabled\"\n\n local custom_port\n custom_port=\"$(node -e \"const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.workerPort);\")\"\n assert_eq \"38888\" \"$custom_port\" \"Existing custom workerPort preserved\"\n\n local feed_channel\n feed_channel=\"$(node -e \"const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.observationFeed.channel);\")\"\n assert_eq \"telegram\" \"$feed_channel\" \"Existing observationFeed config preserved\"\n\n HOME=\"$ORIGINAL_HOME\"\n rm -rf \"$fake_home\"\n}\n\ntest_configure_preserves_existing_cm_config\n\necho \"\"\necho \"=== version_gte() ===\"\n\nif version_gte \"1.2.0\" \"1.1.14\"; then\n test_pass \"version_gte: 1.2.0 >= 1.1.14\"\nelse\n test_fail \"version_gte: 1.2.0 >= 1.1.14\"\nfi\n\nif version_gte \"1.1.14\" \"1.1.14\"; then\n test_pass \"version_gte: 1.1.14 >= 1.1.14 (equal)\"\nelse\n test_fail \"version_gte: 1.1.14 >= 1.1.14 (equal)\"\nfi\n\nif ! version_gte \"1.0.0\" \"1.1.14\"; then\n test_pass \"version_gte: 1.0.0 \u003c 1.1.14\"\nelse\n test_fail \"version_gte: 1.0.0 \u003c 1.1.14\"\nfi\n\necho \"\"\necho \"=== Script structure ===\"\n\nfor fn in find_openclaw check_openclaw install_plugin configure_memory_slot; do\n if declare -f \"$fn\" &>/dev/null; then\n test_pass \"Function ${fn}() is defined\"\n else\n test_fail \"Function ${fn}() should be defined\"\n fi\ndone\n\nassert_contains \"$CLAUDE_MEM_REPO\" \"github.com/thedotmack/claude-mem\" \"CLAUDE_MEM_REPO points to correct repository\"\n\nfor fn in setup_ai_provider write_settings mask_api_key; do\n if declare -f \"$fn\" &>/dev/null; then\n test_pass \"Function ${fn}() is defined\"\n else\n test_fail \"Function ${fn}() should be defined\"\n fi\ndone\n\necho \"\"\necho \"=== mask_api_key() ===\"\n\nmasked=$(mask_api_key \"sk-1234567890abcdef\")\nassert_eq \"***************cdef\" \"$masked\" \"mask_api_key masks all but last 4 chars\"\n\nmasked_short=$(mask_api_key \"abcd\")\nassert_eq \"****\" \"$masked_short\" \"mask_api_key masks keys \u003c= 4 chars entirely\"\n\nmasked_five=$(mask_api_key \"12345\")\nassert_eq \"*2345\" \"$masked_five\" \"mask_api_key masks 5-char key correctly\"\n\necho \"\"\necho \"=== setup_ai_provider() ===\"\n\ntest_setup_ai_provider_non_interactive() {\n local ai_result\n ai_result=\"$(bash -c '\n set -euo pipefail\n TERM=dumb\n tmp=$(mktemp)\n sed \"$ d\" \"'\"${INSTALL_SCRIPT}\"'\" > \"$tmp\"\n echo \"main() { :; }\" >> \"$tmp\"\n set -- \"--non-interactive\"\n source \"$tmp\"\n rm -f \"$tmp\"\n setup_ai_provider >/dev/null 2>&1\n echo \"$AI_PROVIDER\"\n ' 2>/dev/null)\" || true\n\n assert_eq \"claude\" \"$ai_result\" \"Non-interactive mode defaults to claude provider\"\n}\n\ntest_setup_ai_provider_non_interactive\n\necho \"\"\necho \"=== write_settings() ===\"\n\ntest_write_settings_new_file() {\n local fake_home\n fake_home=\"$(mktemp -d)\"\n HOME=\"$fake_home\"\n AI_PROVIDER=\"claude\"\n AI_PROVIDER_API_KEY=\"\"\n\n write_settings >/dev/null 2>&1\n\n local settings_file=\"${fake_home}/.claude-mem/settings.json\"\n assert_file_exists \"$settings_file\" \"settings.json created at ~/.claude-mem/settings.json\"\n\n local provider\n provider=\"$(node -e \"const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_PROVIDER);\")\"\n assert_eq \"claude\" \"$provider\" \"CLAUDE_MEM_PROVIDER set to claude\"\n\n local auth_method\n auth_method=\"$(node -e \"const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_CLAUDE_AUTH_METHOD);\")\"\n assert_eq \"cli\" \"$auth_method\" \"CLAUDE_MEM_CLAUDE_AUTH_METHOD set to cli for Claude provider\"\n\n local worker_port\n worker_port=\"$(node -e \"const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_WORKER_PORT);\")\"\n assert_eq \"37777\" \"$worker_port\" \"CLAUDE_MEM_WORKER_PORT defaults to 37777\"\n\n local model\n model=\"$(node -e \"const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_MODEL);\")\"\n assert_eq \"claude-sonnet-4-6\" \"$model\" \"CLAUDE_MEM_MODEL defaults to claude-sonnet-4-6\"\n\n HOME=\"$ORIGINAL_HOME\"\n rm -rf \"$fake_home\"\n}\n\ntest_write_settings_new_file\n\ntest_write_settings_gemini() {\n local fake_home\n fake_home=\"$(mktemp -d)\"\n HOME=\"$fake_home\"\n AI_PROVIDER=\"gemini\"\n AI_PROVIDER_API_KEY=\"test-gemini-key-1234\"\n\n write_settings >/dev/null 2>&1\n\n local settings_file=\"${fake_home}/.claude-mem/settings.json\"\n\n local provider\n provider=\"$(node -e \"const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_PROVIDER);\")\"\n assert_eq \"gemini\" \"$provider\" \"Gemini: CLAUDE_MEM_PROVIDER set to gemini\"\n\n local api_key\n api_key=\"$(node -e \"const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_GEMINI_API_KEY);\")\"\n assert_eq \"test-gemini-key-1234\" \"$api_key\" \"Gemini: API key stored in settings\"\n\n local gemini_model\n gemini_model=\"$(node -e \"const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_GEMINI_MODEL);\")\"\n assert_eq \"gemini-2.5-flash-lite\" \"$gemini_model\" \"Gemini: model defaults to gemini-2.5-flash-lite\"\n\n HOME=\"$ORIGINAL_HOME\"\n rm -rf \"$fake_home\"\n}\n\ntest_write_settings_gemini\n\ntest_write_settings_openrouter() {\n local fake_home\n fake_home=\"$(mktemp -d)\"\n HOME=\"$fake_home\"\n AI_PROVIDER=\"openrouter\"\n AI_PROVIDER_API_KEY=\"sk-or-test-key-5678\"\n\n write_settings >/dev/null 2>&1\n\n local settings_file=\"${fake_home}/.claude-mem/settings.json\"\n\n local provider\n provider=\"$(node -e \"const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_PROVIDER);\")\"\n assert_eq \"openrouter\" \"$provider\" \"OpenRouter: CLAUDE_MEM_PROVIDER set to openrouter\"\n\n local api_key\n api_key=\"$(node -e \"const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_OPENROUTER_API_KEY);\")\"\n assert_eq \"sk-or-test-key-5678\" \"$api_key\" \"OpenRouter: API key stored in settings\"\n\n local or_model\n or_model=\"$(node -e \"const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_OPENROUTER_MODEL);\")\"\n assert_eq \"xiaomi/mimo-v2-flash:free\" \"$or_model\" \"OpenRouter: model defaults to xiaomi/mimo-v2-flash:free\"\n\n HOME=\"$ORIGINAL_HOME\"\n rm -rf \"$fake_home\"\n}\n\ntest_write_settings_openrouter\n\ntest_write_settings_preserves_existing() {\n local fake_home\n fake_home=\"$(mktemp -d)\"\n HOME=\"$fake_home\"\n\n mkdir -p \"${fake_home}/.claude-mem\"\n local settings_file=\"${fake_home}/.claude-mem/settings.json\"\n node -e \"\n const settings = {\n CLAUDE_MEM_PROVIDER: 'gemini',\n CLAUDE_MEM_GEMINI_API_KEY: 'old-key',\n CLAUDE_MEM_WORKER_PORT: '38888',\n CLAUDE_MEM_LOG_LEVEL: 'DEBUG'\n };\n require('fs').writeFileSync('${settings_file}', JSON.stringify(settings, null, 2));\n \"\n\n AI_PROVIDER=\"claude\"\n AI_PROVIDER_API_KEY=\"\"\n write_settings >/dev/null 2>&1\n\n local provider\n provider=\"$(node -e \"const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_PROVIDER);\")\"\n assert_eq \"claude\" \"$provider\" \"Preserve: provider updated to new selection\"\n\n local custom_port\n custom_port=\"$(node -e \"const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_WORKER_PORT);\")\"\n assert_eq \"38888\" \"$custom_port\" \"Preserve: existing custom WORKER_PORT preserved\"\n\n local log_level\n log_level=\"$(node -e \"const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_LOG_LEVEL);\")\"\n assert_eq \"DEBUG\" \"$log_level\" \"Preserve: existing custom LOG_LEVEL preserved\"\n\n HOME=\"$ORIGINAL_HOME\"\n rm -rf \"$fake_home\"\n}\n\ntest_write_settings_preserves_existing\n\ntest_write_settings_complete_schema() {\n local fake_home\n fake_home=\"$(mktemp -d)\"\n HOME=\"$fake_home\"\n AI_PROVIDER=\"claude\"\n AI_PROVIDER_API_KEY=\"\"\n\n write_settings >/dev/null 2>&1\n\n local settings_file=\"${fake_home}/.claude-mem/settings.json\"\n\n local key_count\n key_count=\"$(node -e \"const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(Object.keys(s).length);\")\"\n\n if (( key_count >= 30 )); then\n test_pass \"Settings file has ${key_count} keys (complete schema)\"\n else\n test_fail \"Settings file has ${key_count} keys, expected >= 30\" \"Schema may be incomplete\"\n fi\n\n local has_env_key\n has_env_key=\"$(node -e \"const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.env !== undefined);\")\"\n assert_eq \"false\" \"$has_env_key\" \"Settings uses flat schema (no nested 'env' key)\"\n\n HOME=\"$ORIGINAL_HOME\"\n rm -rf \"$fake_home\"\n}\n\ntest_write_settings_complete_schema\n\necho \"\"\necho \"=== find_claude_mem_install_dir() ===\"\n\ntest_find_install_dir_not_found() {\n local fake_home\n fake_home=\"$(mktemp -d)\"\n HOME=\"$fake_home\"\n CLAUDE_MEM_INSTALL_DIR=\"\"\n\n if find_claude_mem_install_dir 2>/dev/null; then\n test_fail \"find_claude_mem_install_dir should return 1 when not found\"\n else\n test_pass \"find_claude_mem_install_dir returns 1 when not found\"\n fi\n\n assert_eq \"\" \"$CLAUDE_MEM_INSTALL_DIR\" \"CLAUDE_MEM_INSTALL_DIR is empty when not found\"\n\n HOME=\"$ORIGINAL_HOME\"\n rm -rf \"$fake_home\"\n}\n\ntest_find_install_dir_not_found\n\ntest_find_install_dir_openclaw_extensions() {\n local fake_home\n fake_home=\"$(mktemp -d)\"\n HOME=\"$fake_home\"\n CLAUDE_MEM_INSTALL_DIR=\"\"\n\n mkdir -p \"${fake_home}/.openclaw/extensions/claude-mem/plugin/scripts\"\n touch \"${fake_home}/.openclaw/extensions/claude-mem/plugin/scripts/worker-service.cjs\"\n\n if find_claude_mem_install_dir 2>/dev/null; then\n test_pass \"find_claude_mem_install_dir finds dir in ~/.openclaw/extensions/claude-mem/\"\n assert_eq \"${fake_home}/.openclaw/extensions/claude-mem\" \"$CLAUDE_MEM_INSTALL_DIR\" \"CLAUDE_MEM_INSTALL_DIR set correctly for openclaw extensions\"\n else\n test_fail \"find_claude_mem_install_dir should find dir in ~/.openclaw/extensions/claude-mem/\"\n fi\n\n HOME=\"$ORIGINAL_HOME\"\n rm -rf \"$fake_home\"\n}\n\ntest_find_install_dir_openclaw_extensions\n\ntest_find_install_dir_marketplace() {\n local fake_home\n fake_home=\"$(mktemp -d)\"\n HOME=\"$fake_home\"\n CLAUDE_MEM_INSTALL_DIR=\"\"\n\n mkdir -p \"${fake_home}/.claude/plugins/marketplaces/thedotmack/plugin/scripts\"\n touch \"${fake_home}/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs\"\n\n if find_claude_mem_install_dir 2>/dev/null; then\n test_pass \"find_claude_mem_install_dir finds dir in marketplace path\"\n assert_eq \"${fake_home}/.claude/plugins/marketplaces/thedotmack\" \"$CLAUDE_MEM_INSTALL_DIR\" \"CLAUDE_MEM_INSTALL_DIR set correctly for marketplace\"\n else\n test_fail \"find_claude_mem_install_dir should find dir in marketplace path\"\n fi\n\n HOME=\"$ORIGINAL_HOME\"\n rm -rf \"$fake_home\"\n}\n\ntest_find_install_dir_marketplace\n\necho \"\"\necho \"=== start_worker() ===\"\n\ntest_start_worker_no_install_dir() {\n local fake_home\n fake_home=\"$(mktemp -d)\"\n HOME=\"$fake_home\"\n CLAUDE_MEM_INSTALL_DIR=\"\"\n\n local output\n if output=\"$(start_worker 2>&1)\"; then\n test_fail \"start_worker should fail when install dir not found\"\n else\n test_pass \"start_worker returns error when install dir not found\"\n fi\n\n assert_contains \"$output\" \"Cannot find claude-mem plugin installation directory\" \"start_worker error message mentions install dir\"\n\n HOME=\"$ORIGINAL_HOME\"\n rm -rf \"$fake_home\"\n}\n\ntest_start_worker_no_install_dir\n\necho \"\"\necho \"=== verify_health() ===\"\n\ntest_verify_health_no_server() {\n local result\n result=\"$(bash -c '\n set -euo pipefail\n TERM=dumb\n tmp=$(mktemp)\n sed \"$ d\" \"'\"${INSTALL_SCRIPT}\"'\" > \"$tmp\"\n echo \"main() { :; }\" >> \"$tmp\"\n source \"$tmp\"\n rm -f \"$tmp\"\n verify_health 2>/dev/null && echo \"PASS\" || echo \"FAIL\"\n ' 2>/dev/null)\" || true\n\n if [[ \"$result\" == *\"FAIL\"* ]]; then\n test_pass \"verify_health returns failure when no server is running\"\n else\n test_pass \"verify_health returned success (worker may already be running on 37777)\"\n fi\n}\n\nif command -v curl &>/dev/null; then\n test_verify_health_no_server\nelse\n test_pass \"verify_health test skipped (curl not available)\"\nfi\n\necho \"\"\necho \"=== print_completion_summary() ===\"\n\ntest_print_completion_summary() {\n AI_PROVIDER=\"claude\"\n WORKER_PID=\"\"\n FEED_CONFIGURED=false\n FEED_CHANNEL=\"\"\n FEED_TARGET_ID=\"\"\n\n local output\n output=\"$(print_completion_summary 2>&1)\"\n\n assert_contains \"$output\" \"Installation Complete\" \"Completion summary shows 'Installation Complete'\"\n assert_contains \"$output\" \"Claude Max Plan\" \"Completion summary shows correct provider\"\n assert_contains \"$output\" \"not configured\" \"Completion summary shows feed 'not configured' when skipped\"\n assert_contains \"$output\" \"What's next\" \"Completion summary shows What's next section\"\n assert_contains \"$output\" \"/claude-mem-status\" \"Completion summary mentions status command\"\n assert_contains \"$output\" \"localhost:37777\" \"Completion summary mentions viewer URL\"\n assert_contains \"$output\" \"re-run this installer\" \"Completion summary shows re-run instructions\"\n}\n\ntest_print_completion_summary\n\ntest_print_completion_summary_gemini() {\n AI_PROVIDER=\"gemini\"\n WORKER_PID=\"\"\n FEED_CONFIGURED=false\n\n local output\n output=\"$(print_completion_summary 2>&1)\"\n\n assert_contains \"$output\" \"Gemini\" \"Gemini provider shown in completion summary\"\n}\n\ntest_print_completion_summary_gemini\n\ntest_print_completion_summary_openrouter() {\n AI_PROVIDER=\"openrouter\"\n WORKER_PID=\"\"\n FEED_CONFIGURED=false\n\n local output\n output=\"$(print_completion_summary 2>&1)\"\n\n assert_contains \"$output\" \"OpenRouter\" \"OpenRouter provider shown in completion summary\"\n}\n\ntest_print_completion_summary_openrouter\n\necho \"\"\necho \"=== New function existence ===\"\n\nfor fn in find_claude_mem_install_dir start_worker verify_health print_completion_summary; do\n if declare -f \"$fn\" &>/dev/null; then\n test_pass \"Function ${fn}() is defined\"\n else\n test_fail \"Function ${fn}() should be defined\"\n fi\ndone\n\necho \"\"\necho \"=== main() function structure ===\"\n\ntest_main_calls_start_worker() {\n if grep -q 'start_worker' \"$INSTALL_SCRIPT\"; then\n test_pass \"main() calls start_worker\"\n else\n test_fail \"main() should call start_worker\"\n fi\n}\n\ntest_main_calls_start_worker\n\ntest_main_calls_verify_health() {\n if grep -q 'verify_health' \"$INSTALL_SCRIPT\"; then\n test_pass \"main() calls verify_health\"\n else\n test_fail \"main() should call verify_health\"\n fi\n}\n\ntest_main_calls_verify_health\n\ntest_main_calls_completion_summary() {\n if grep -q 'print_completion_summary' \"$INSTALL_SCRIPT\"; then\n test_pass \"main() calls print_completion_summary\"\n else\n test_fail \"main() should call print_completion_summary\"\n fi\n}\n\ntest_main_calls_completion_summary\n\ntest_main_has_progress_indicators() {\n if grep -q '\\[1/8\\]' \"$INSTALL_SCRIPT\" && grep -q '\\[8/8\\]' \"$INSTALL_SCRIPT\"; then\n test_pass \"main() has progress indicators [1/8] through [8/8]\"\n else\n test_fail \"main() should have progress indicators [1/8] through [8/8]\"\n fi\n}\n\ntest_main_has_progress_indicators\n\ntest_main_calls_setup_observation_feed() {\n if grep -q 'setup_observation_feed' \"$INSTALL_SCRIPT\"; then\n test_pass \"main() calls setup_observation_feed\"\n else\n test_fail \"main() should call setup_observation_feed\"\n fi\n}\n\ntest_main_calls_setup_observation_feed\n\ntest_main_calls_write_observation_feed_config() {\n if grep -q 'write_observation_feed_config' \"$INSTALL_SCRIPT\"; then\n test_pass \"main() calls write_observation_feed_config\"\n else\n test_fail \"main() should call write_observation_feed_config\"\n fi\n}\n\ntest_main_calls_write_observation_feed_config\n\necho \"\"\necho \"=== setup_observation_feed() ===\"\n\nfor fn in setup_observation_feed write_observation_feed_config; do\n if declare -f \"$fn\" &>/dev/null; then\n test_pass \"Function ${fn}() is defined\"\n else\n test_fail \"Function ${fn}() should be defined\"\n fi\ndone\n\ntest_setup_observation_feed_non_interactive() {\n local feed_result\n feed_result=\"$(bash -c '\n set -euo pipefail\n TERM=dumb\n tmp=$(mktemp)\n sed \"$ d\" \"'\"${INSTALL_SCRIPT}\"'\" > \"$tmp\"\n echo \"main() { :; }\" >> \"$tmp\"\n set -- \"--non-interactive\"\n source \"$tmp\"\n rm -f \"$tmp\"\n setup_observation_feed 2>/dev/null\n echo \"CHANNEL=$FEED_CHANNEL\"\n echo \"CONFIGURED=$FEED_CONFIGURED\"\n ' 2>/dev/null)\" || true\n\n assert_contains \"$feed_result\" \"CHANNEL=\" \"Non-interactive mode: FEED_CHANNEL is empty\"\n assert_contains \"$feed_result\" \"CONFIGURED=false\" \"Non-interactive mode: FEED_CONFIGURED is false\"\n}\n\ntest_setup_observation_feed_non_interactive\n\necho \"\"\necho \"=== write_observation_feed_config() ===\"\n\ntest_write_observation_feed_config_writes_json() {\n local fake_home\n fake_home=\"$(mktemp -d)\"\n HOME=\"$fake_home\"\n\n mkdir -p \"${fake_home}/.openclaw\"\n local config_file=\"${fake_home}/.openclaw/openclaw.json\"\n node -e \"\n const config = {\n plugins: {\n slots: { memory: 'claude-mem' },\n entries: {\n 'claude-mem': {\n enabled: true,\n config: { workerPort: 37777, syncMemoryFile: true }\n }\n }\n }\n };\n require('fs').writeFileSync('${config_file}', JSON.stringify(config, null, 2));\n \"\n\n FEED_CHANNEL=\"telegram\"\n FEED_TARGET_ID=\"123456789\"\n FEED_CONFIGURED=\"true\"\n\n write_observation_feed_config >/dev/null 2>&1\n\n local feed_enabled\n feed_enabled=\"$(node -e \"const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.observationFeed.enabled);\")\"\n assert_eq \"true\" \"$feed_enabled\" \"observationFeed.enabled is true\"\n\n local feed_channel\n feed_channel=\"$(node -e \"const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.observationFeed.channel);\")\"\n assert_eq \"telegram\" \"$feed_channel\" \"observationFeed.channel is telegram\"\n\n local feed_to\n feed_to=\"$(node -e \"const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.observationFeed.to);\")\"\n assert_eq \"123456789\" \"$feed_to\" \"observationFeed.to is 123456789\"\n\n local worker_port\n worker_port=\"$(node -e \"const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.workerPort);\")\"\n assert_eq \"37777\" \"$worker_port\" \"Existing workerPort preserved after feed config write\"\n\n HOME=\"$ORIGINAL_HOME\"\n FEED_CHANNEL=\"\"\n FEED_TARGET_ID=\"\"\n FEED_CONFIGURED=false\n rm -rf \"$fake_home\"\n}\n\ntest_write_observation_feed_config_writes_json\n\ntest_write_observation_feed_config_skips_when_not_configured() {\n local fake_home\n fake_home=\"$(mktemp -d)\"\n HOME=\"$fake_home\"\n\n mkdir -p \"${fake_home}/.openclaw\"\n local config_file=\"${fake_home}/.openclaw/openclaw.json\"\n node -e \"\n require('fs').writeFileSync('${config_file}', JSON.stringify({ plugins: {} }, null, 2));\n \"\n\n FEED_CONFIGURED=\"false\"\n\n write_observation_feed_config >/dev/null 2>&1\n\n local has_feed\n has_feed=\"$(node -e \"const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries !== undefined);\")\"\n assert_eq \"false\" \"$has_feed\" \"Config unchanged when FEED_CONFIGURED is false\"\n\n HOME=\"$ORIGINAL_HOME\"\n rm -rf \"$fake_home\"\n}\n\ntest_write_observation_feed_config_skips_when_not_configured\n\ntest_write_observation_feed_config_discord() {\n local fake_home\n fake_home=\"$(mktemp -d)\"\n HOME=\"$fake_home\"\n\n mkdir -p \"${fake_home}/.openclaw\"\n local config_file=\"${fake_home}/.openclaw/openclaw.json\"\n node -e \"\n const config = {\n plugins: {\n entries: {\n 'claude-mem': { enabled: true, config: {} }\n }\n }\n };\n require('fs').writeFileSync('${config_file}', JSON.stringify(config, null, 2));\n \"\n\n FEED_CHANNEL=\"discord\"\n FEED_TARGET_ID=\"1234567890123456789\"\n FEED_CONFIGURED=\"true\"\n\n write_observation_feed_config >/dev/null 2>&1\n\n local feed_channel\n feed_channel=\"$(node -e \"const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.observationFeed.channel);\")\"\n assert_eq \"discord\" \"$feed_channel\" \"Discord channel type written correctly\"\n\n local feed_to\n feed_to=\"$(node -e \"const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.observationFeed.to);\")\"\n assert_eq \"1234567890123456789\" \"$feed_to\" \"Discord channel ID written correctly\"\n\n HOME=\"$ORIGINAL_HOME\"\n FEED_CHANNEL=\"\"\n FEED_TARGET_ID=\"\"\n FEED_CONFIGURED=false\n rm -rf \"$fake_home\"\n}\n\ntest_write_observation_feed_config_discord\n\necho \"\"\necho \"=== write_observation_feed_config() — fallback paths ===\"\n\nverify_feed_config_json() {\n local config_file=\"$1\" expected_channel=\"$2\" expected_target=\"$3\" label=\"$4\"\n\n local feed_enabled\n feed_enabled=\"$(node -e \"const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.observationFeed.enabled);\")\"\n assert_eq \"true\" \"$feed_enabled\" \"${label}: observationFeed.enabled is true\"\n\n local feed_channel\n feed_channel=\"$(node -e \"const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.observationFeed.channel);\")\"\n assert_eq \"$expected_channel\" \"$feed_channel\" \"${label}: observationFeed.channel correct\"\n\n local feed_to\n feed_to=\"$(node -e \"const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.observationFeed.to);\")\"\n assert_eq \"$expected_target\" \"$feed_to\" \"${label}: observationFeed.to correct\"\n\n local worker_port\n worker_port=\"$(node -e \"const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.workerPort);\")\"\n assert_eq \"37777\" \"$worker_port\" \"${label}: existing workerPort preserved\"\n}\n\ncreate_seed_config() {\n local config_file=\"$1\"\n mkdir -p \"$(dirname \"$config_file\")\"\n node -e \"\n const config = {\n plugins: {\n slots: { memory: 'claude-mem' },\n entries: {\n 'claude-mem': {\n enabled: true,\n config: { workerPort: 37777, syncMemoryFile: true }\n }\n }\n }\n };\n require('fs').writeFileSync('${config_file}', JSON.stringify(config, null, 2));\n \"\n}\n\ntest_write_feed_config_jq_path() {\n if ! command -v jq &>/dev/null; then\n test_pass \"jq path: skipped (jq not installed)\"\n return 0\n fi\n\n local fake_home\n fake_home=\"$(mktemp -d)\"\n HOME=\"$fake_home\"\n local config_file=\"${fake_home}/.openclaw/openclaw.json\"\n create_seed_config \"$config_file\"\n\n FEED_CHANNEL=\"slack\"\n FEED_TARGET_ID=\"C01ABC2DEFG\"\n FEED_CONFIGURED=\"true\"\n\n write_observation_feed_config >/dev/null 2>&1\n\n verify_feed_config_json \"$config_file\" \"slack\" \"C01ABC2DEFG\" \"jq path\"\n\n HOME=\"$ORIGINAL_HOME\"\n FEED_CHANNEL=\"\"\n FEED_TARGET_ID=\"\"\n FEED_CONFIGURED=false\n rm -rf \"$fake_home\"\n}\n\ntest_write_feed_config_jq_path\n\ntest_write_feed_config_python3_path() {\n if ! command -v python3 &>/dev/null; then\n test_pass \"python3 path: skipped (python3 not installed)\"\n return 0\n fi\n\n local fake_home\n fake_home=\"$(mktemp -d)\"\n\n local result\n result=\"$(bash -c '\n set -euo pipefail\n TERM=dumb\n export HOME=\"'\"$fake_home\"'\"\n\n mkdir -p \"'\"${fake_home}\"'/.openclaw\"\n node -e \"\n const config = {\n plugins: {\n slots: { memory: \\\"claude-mem\\\" },\n entries: {\n \\\"claude-mem\\\": {\n enabled: true,\n config: { workerPort: 37777, syncMemoryFile: true }\n }\n }\n }\n };\n require(\\\"fs\\\").writeFileSync(\\\"'\"${fake_home}\"'/.openclaw/openclaw.json\\\", JSON.stringify(config, null, 2));\n \"\n\n tmp=$(mktemp)\n sed \"$ d\" \"'\"${INSTALL_SCRIPT}\"'\" > \"$tmp\"\n echo \"main() { :; }\" >> \"$tmp\"\n source \"$tmp\"\n rm -f \"$tmp\"\n\n SAFE_PATH=\"\"\n IFS=\":\" read -ra path_parts \u003c\u003c\u003c \"$PATH\"\n for p in \"${path_parts[@]}\"; do\n if [[ ! -x \"${p}/jq\" ]]; then\n SAFE_PATH=\"${SAFE_PATH:+${SAFE_PATH}:}${p}\"\n fi\n done\n export PATH=\"$SAFE_PATH\"\n\n FEED_CHANNEL=\"signal\"\n FEED_TARGET_ID=\"+15551234567\"\n FEED_CONFIGURED=\"true\"\n write_observation_feed_config >/dev/null 2>&1\n echo \"DONE\"\n ' 2>/dev/null)\" || true\n\n if [[ \"$result\" == *\"DONE\"* ]]; then\n local config_file=\"${fake_home}/.openclaw/openclaw.json\"\n verify_feed_config_json \"$config_file\" \"signal\" \"+15551234567\" \"python3 path\"\n else\n test_fail \"python3 path: write_observation_feed_config failed\"\n fi\n\n rm -rf \"$fake_home\"\n}\n\ntest_write_feed_config_python3_path\n\ntest_write_feed_config_node_path() {\n local fake_home\n fake_home=\"$(mktemp -d)\"\n\n local result\n result=\"$(bash -c '\n set -euo pipefail\n TERM=dumb\n export HOME=\"'\"$fake_home\"'\"\n\n mkdir -p \"'\"${fake_home}\"'/.openclaw\"\n node -e \"\n const config = {\n plugins: {\n slots: { memory: \\\"claude-mem\\\" },\n entries: {\n \\\"claude-mem\\\": {\n enabled: true,\n config: { workerPort: 37777, syncMemoryFile: true }\n }\n }\n }\n };\n require(\\\"fs\\\").writeFileSync(\\\"'\"${fake_home}\"'/.openclaw/openclaw.json\\\", JSON.stringify(config, null, 2));\n \"\n\n tmp=$(mktemp)\n sed \"$ d\" \"'\"${INSTALL_SCRIPT}\"'\" > \"$tmp\"\n echo \"main() { :; }\" >> \"$tmp\"\n source \"$tmp\"\n rm -f \"$tmp\"\n\n INSTALLER_FEED_CHANNEL=\"whatsapp\" \\\n INSTALLER_FEED_TARGET_ID=\"[email protected]\" \\\n INSTALLER_CONFIG_FILE=\"'\"${fake_home}\"'/.openclaw/openclaw.json\" \\\n node -e \"\n const fs = require(\\\"fs\\\");\n const configPath = process.env.INSTALLER_CONFIG_FILE;\n const channel = process.env.INSTALLER_FEED_CHANNEL;\n const targetId = process.env.INSTALLER_FEED_TARGET_ID;\n\n const config = JSON.parse(fs.readFileSync(configPath, \\\"utf8\\\"));\n\n if (!config.plugins) config.plugins = {};\n if (!config.plugins.entries) config.plugins.entries = {};\n if (!config.plugins.entries[\\\"claude-mem\\\"]) {\n config.plugins.entries[\\\"claude-mem\\\"] = { enabled: true, config: {} };\n }\n if (!config.plugins.entries[\\\"claude-mem\\\"].config) {\n config.plugins.entries[\\\"claude-mem\\\"].config = {};\n }\n\n config.plugins.entries[\\\"claude-mem\\\"].config.observationFeed = {\n enabled: true,\n channel: channel,\n to: targetId\n };\n\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2));\n \"\n echo \"DONE\"\n ' 2>/dev/null)\" || true\n\n if [[ \"$result\" == *\"DONE\"* ]]; then\n local config_file=\"${fake_home}/.openclaw/openclaw.json\"\n verify_feed_config_json \"$config_file\" \"whatsapp\" \"[email protected]\" \"node path\"\n else\n test_fail \"node path: write_observation_feed_config failed\"\n fi\n\n rm -rf \"$fake_home\"\n}\n\ntest_write_feed_config_node_path\n\ntest_feed_config_fallback_chain_in_source() {\n if grep -q 'command -v jq' \"$INSTALL_SCRIPT\"; then\n test_pass \"write_observation_feed_config checks for jq first\"\n else\n test_fail \"write_observation_feed_config should check for jq\"\n fi\n\n if grep -q 'command -v python3' \"$INSTALL_SCRIPT\"; then\n test_pass \"write_observation_feed_config has python3 fallback\"\n else\n test_fail \"write_observation_feed_config should have python3 fallback\"\n fi\n\n if grep -q 'node -e' \"$INSTALL_SCRIPT\"; then\n test_pass \"write_observation_feed_config has node fallback\"\n else\n test_fail \"write_observation_feed_config should have node fallback\"\n fi\n}\n\ntest_feed_config_fallback_chain_in_source\n\necho \"\"\necho \"=== print_completion_summary() — observation feed ===\"\n\ntest_completion_summary_with_feed() {\n AI_PROVIDER=\"claude\"\n WORKER_PID=\"\"\n FEED_CONFIGURED=\"true\"\n FEED_CHANNEL=\"telegram\"\n FEED_TARGET_ID=\"123456789\"\n\n local output\n output=\"$(print_completion_summary 2>&1)\"\n\n assert_contains \"$output\" \"telegram\" \"Summary shows feed channel when configured\"\n assert_contains \"$output\" \"123456789\" \"Summary shows feed target when configured\"\n assert_contains \"$output\" \"What's next\" \"Summary includes What's next section\"\n assert_contains \"$output\" \"/claude-mem-feed\" \"Summary includes feed check command when configured\"\n\n FEED_CONFIGURED=false\n FEED_CHANNEL=\"\"\n FEED_TARGET_ID=\"\"\n}\n\ntest_completion_summary_with_feed\n\ntest_completion_summary_without_feed() {\n AI_PROVIDER=\"claude\"\n WORKER_PID=\"\"\n FEED_CONFIGURED=false\n FEED_CHANNEL=\"\"\n FEED_TARGET_ID=\"\"\n\n local output\n output=\"$(print_completion_summary 2>&1)\"\n\n assert_contains \"$output\" \"not configured\" \"Summary shows 'not configured' when feed skipped\"\n assert_contains \"$output\" \"What's next\" \"Summary includes What's next section without feed\"\n assert_contains \"$output\" \"/claude-mem-status\" \"Summary includes status check command\"\n assert_contains \"$output\" \"localhost:37777\" \"Summary includes viewer URL\"\n}\n\ntest_completion_summary_without_feed\n\necho \"\"\necho \"=== Channel instructions ===\"\n\nfor channel in telegram discord slack signal whatsapp line; do\n if grep -qi \"$channel\" \"$INSTALL_SCRIPT\"; then\n test_pass \"Channel '${channel}' instructions exist in install.sh\"\n else\n test_fail \"Channel '${channel}' instructions should exist in install.sh\"\n fi\ndone\n\nassert_contains \"$(grep -A2 'userinfobot' \"$INSTALL_SCRIPT\" 2>/dev/null || echo '')\" \"userinfobot\" \"Telegram instructions include @userinfobot\"\nassert_contains \"$(grep -A2 'Developer Mode' \"$INSTALL_SCRIPT\" 2>/dev/null || echo '')\" \"Developer Mode\" \"Discord instructions include Developer Mode\"\nassert_contains \"$(grep -A2 'C01ABC2DEFG' \"$INSTALL_SCRIPT\" 2>/dev/null || echo '')\" \"C01ABC2DEFG\" \"Slack instructions include sample channel ID\"\n\necho \"\"\necho \"=== TTY detection ===\"\n\nfor fn in setup_tty read_tty; do\n if declare -f \"$fn\" &>/dev/null; then\n test_pass \"Function ${fn}() is defined\"\n else\n test_fail \"Function ${fn}() should be defined\"\n fi\ndone\n\nif declare -p TTY_FD &>/dev/null; then\n test_pass \"TTY_FD variable is defined\"\nelse\n test_fail \"TTY_FD variable should be defined\"\nfi\n\nif grep -q 'setup_tty' \"$INSTALL_SCRIPT\"; then\n test_pass \"main() calls setup_tty\"\nelse\n test_fail \"main() should call setup_tty\"\nfi\n\necho \"\"\necho \"=== Argument parsing — --provider flag ===\"\n\ntest_provider_flag_claude() {\n local result\n result=\"$(bash -c '\n set -euo pipefail\n TERM=dumb\n tmp=$(mktemp)\n sed \"$ d\" \"'\"${INSTALL_SCRIPT}\"'\" > \"$tmp\"\n echo \"main() { :; }\" >> \"$tmp\"\n set -- \"--provider=claude\"\n source \"$tmp\"\n rm -f \"$tmp\"\n setup_ai_provider >/dev/null 2>&1\n echo \"$AI_PROVIDER\"\n ' 2>/dev/null)\" || true\n\n assert_eq \"claude\" \"$result\" \"--provider=claude sets AI_PROVIDER to claude\"\n}\n\ntest_provider_flag_claude\n\ntest_provider_flag_gemini_with_api_key() {\n local result\n result=\"$(bash -c '\n set -euo pipefail\n TERM=dumb\n tmp=$(mktemp)\n sed \"$ d\" \"'\"${INSTALL_SCRIPT}\"'\" > \"$tmp\"\n echo \"main() { :; }\" >> \"$tmp\"\n set -- \"--provider=gemini\" \"--api-key=test-key-123\"\n source \"$tmp\"\n rm -f \"$tmp\"\n setup_ai_provider >/dev/null 2>&1\n echo \"PROVIDER=$AI_PROVIDER\"\n echo \"KEY=$AI_PROVIDER_API_KEY\"\n ' 2>/dev/null)\" || true\n\n assert_contains \"$result\" \"PROVIDER=gemini\" \"--provider=gemini sets AI_PROVIDER to gemini\"\n assert_contains \"$result\" \"KEY=test-key-123\" \"--api-key=test-key-123 sets API key\"\n}\n\ntest_provider_flag_gemini_with_api_key\n\ntest_provider_flag_openrouter() {\n local result\n result=\"$(bash -c '\n set -euo pipefail\n TERM=dumb\n tmp=$(mktemp)\n sed \"$ d\" \"'\"${INSTALL_SCRIPT}\"'\" > \"$tmp\"\n echo \"main() { :; }\" >> \"$tmp\"\n set -- \"--provider=openrouter\" \"--api-key=sk-or-test\"\n source \"$tmp\"\n rm -f \"$tmp\"\n setup_ai_provider >/dev/null 2>&1\n echo \"PROVIDER=$AI_PROVIDER\"\n echo \"KEY=$AI_PROVIDER_API_KEY\"\n ' 2>/dev/null)\" || true\n\n assert_contains \"$result\" \"PROVIDER=openrouter\" \"--provider=openrouter sets AI_PROVIDER\"\n assert_contains \"$result\" \"KEY=sk-or-test\" \"--api-key sets API key for openrouter\"\n}\n\ntest_provider_flag_openrouter\n\ntest_provider_flag_invalid() {\n local exit_code=0\n bash -c '\n set -euo pipefail\n TERM=dumb\n tmp=$(mktemp)\n sed \"$ d\" \"'\"${INSTALL_SCRIPT}\"'\" > \"$tmp\"\n echo \"main() { :; }\" >> \"$tmp\"\n set -- \"--provider=invalid\"\n source \"$tmp\"\n rm -f \"$tmp\"\n setup_ai_provider\n ' >/dev/null 2>&1 || exit_code=$?\n\n if [[ \"$exit_code\" -ne 0 ]]; then\n test_pass \"--provider=invalid exits with error\"\n else\n test_fail \"--provider=invalid should exit with error\"\n fi\n}\n\ntest_provider_flag_invalid\n\necho \"\"\necho \"=== Argument parsing — --non-interactive ===\"\n\ntest_non_interactive_flag() {\n local result\n result=\"$(bash -c '\n set -euo pipefail\n TERM=dumb\n tmp=$(mktemp)\n sed \"$ d\" \"'\"${INSTALL_SCRIPT}\"'\" > \"$tmp\"\n echo \"main() { :; }\" >> \"$tmp\"\n set -- \"--non-interactive\"\n source \"$tmp\"\n rm -f \"$tmp\"\n echo \"NON_INTERACTIVE=$NON_INTERACTIVE\"\n ' 2>/dev/null)\" || true\n\n assert_contains \"$result\" \"NON_INTERACTIVE=true\" \"--non-interactive sets NON_INTERACTIVE=true\"\n}\n\ntest_non_interactive_flag\n\ntest_non_interactive_with_provider() {\n local result\n result=\"$(bash -c '\n set -euo pipefail\n TERM=dumb\n tmp=$(mktemp)\n sed \"$ d\" \"'\"${INSTALL_SCRIPT}\"'\" > \"$tmp\"\n echo \"main() { :; }\" >> \"$tmp\"\n set -- \"--non-interactive\" \"--provider=gemini\" \"--api-key=my-key\"\n source \"$tmp\"\n rm -f \"$tmp\"\n setup_ai_provider >/dev/null 2>&1\n echo \"PROVIDER=$AI_PROVIDER\"\n echo \"KEY=$AI_PROVIDER_API_KEY\"\n echo \"NON_INTERACTIVE=$NON_INTERACTIVE\"\n ' 2>/dev/null)\" || true\n\n assert_contains \"$result\" \"PROVIDER=gemini\" \"--non-interactive + --provider: provider set correctly\"\n assert_contains \"$result\" \"KEY=my-key\" \"--non-interactive + --api-key: key set correctly\"\n assert_contains \"$result\" \"NON_INTERACTIVE=true\" \"--non-interactive flag parsed alongside --provider\"\n}\n\ntest_non_interactive_with_provider\n\necho \"\"\necho \"=== --non-interactive full flow ===\"\n\ntest_non_interactive_completes() {\n local result\n result=\"$(bash -c '\n set -euo pipefail\n TERM=dumb\n tmp=$(mktemp)\n sed \"$ d\" \"'\"${INSTALL_SCRIPT}\"'\" > \"$tmp\"\n echo \"main() { :; }\" >> \"$tmp\"\n set -- \"--non-interactive\"\n source \"$tmp\"\n rm -f \"$tmp\"\n setup_ai_provider 2>/dev/null\n setup_observation_feed 2>/dev/null\n echo \"AI=$AI_PROVIDER\"\n echo \"FEED=$FEED_CONFIGURED\"\n ' 2>/dev/null)\" || true\n\n assert_contains \"$result\" \"AI=claude\" \"--non-interactive: AI provider defaults to claude\"\n assert_contains \"$result\" \"FEED=false\" \"--non-interactive: observation feed skipped\"\n}\n\ntest_non_interactive_completes\n\necho \"\"\necho \"=== curl | bash usage comment ===\"\n\nif grep -q 'curl -fsSL.*raw.githubusercontent.com.*install.sh | bash' \"$INSTALL_SCRIPT\"; then\n test_pass \"install.sh contains curl | bash usage comment\"\nelse\n test_fail \"install.sh should contain curl | bash usage comment\"\nfi\n\nif grep -q 'bash -s -- --provider=' \"$INSTALL_SCRIPT\"; then\n test_pass \"install.sh documents --provider flag in usage comment\"\nelse\n test_fail \"install.sh should document --provider flag in usage comment\"\nfi\n\necho \"\"\necho \"=== write_settings with --provider flag ===\"\n\ntest_write_settings_via_provider_flag() {\n local fake_home\n fake_home=\"$(mktemp -d)\"\n\n local result\n result=\"$(bash -c '\n set -euo pipefail\n TERM=dumb\n export HOME=\"'\"$fake_home\"'\"\n tmp=$(mktemp)\n sed \"$ d\" \"'\"${INSTALL_SCRIPT}\"'\" > \"$tmp\"\n echo \"main() { :; }\" >> \"$tmp\"\n set -- \"--provider=gemini\" \"--api-key=test-end-to-end-key\"\n source \"$tmp\"\n rm -f \"$tmp\"\n setup_ai_provider >/dev/null 2>&1\n write_settings >/dev/null 2>&1\n echo \"DONE\"\n ' 2>/dev/null)\" || true\n\n if [[ \"$result\" == *\"DONE\"* ]]; then\n local settings_file=\"${fake_home}/.claude-mem/settings.json\"\n local provider\n provider=\"$(node -e \"const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_PROVIDER);\")\"\n assert_eq \"gemini\" \"$provider\" \"--provider flag: settings.json has provider=gemini\"\n\n local api_key\n api_key=\"$(node -e \"const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_GEMINI_API_KEY);\")\"\n assert_eq \"test-end-to-end-key\" \"$api_key\" \"--provider flag: settings.json has correct API key\"\n else\n test_fail \"--provider flag: write_settings failed\"\n fi\n\n rm -rf \"$fake_home\"\n}\n\ntest_write_settings_via_provider_flag\n\necho \"\"\necho \"=== --upgrade flag parsing ===\"\n\ntest_upgrade_flag() {\n local result\n result=\"$(bash -c '\n set -euo pipefail\n TERM=dumb\n tmp=$(mktemp)\n sed \"$ d\" \"'\"${INSTALL_SCRIPT}\"'\" > \"$tmp\"\n echo \"main() { :; }\" >> \"$tmp\"\n set -- \"--upgrade\"\n source \"$tmp\"\n rm -f \"$tmp\"\n echo \"UPGRADE=$UPGRADE_MODE\"\n ' 2>/dev/null)\" || true\n\n assert_contains \"$result\" \"UPGRADE=true\" \"--upgrade sets UPGRADE_MODE=true\"\n}\n\ntest_upgrade_flag\n\ntest_upgrade_flag_with_provider() {\n local result\n result=\"$(bash -c '\n set -euo pipefail\n TERM=dumb\n tmp=$(mktemp)\n sed \"$ d\" \"'\"${INSTALL_SCRIPT}\"'\" > \"$tmp\"\n echo \"main() { :; }\" >> \"$tmp\"\n set -- \"--upgrade\" \"--provider=gemini\" \"--api-key=upgrade-key\"\n source \"$tmp\"\n rm -f \"$tmp\"\n echo \"UPGRADE=$UPGRADE_MODE\"\n echo \"PROVIDER=$CLI_PROVIDER\"\n echo \"KEY=$CLI_API_KEY\"\n ' 2>/dev/null)\" || true\n\n assert_contains \"$result\" \"UPGRADE=true\" \"--upgrade + --provider: upgrade flag parsed\"\n assert_contains \"$result\" \"PROVIDER=gemini\" \"--upgrade + --provider: provider flag parsed\"\n assert_contains \"$result\" \"KEY=upgrade-key\" \"--upgrade + --api-key: API key parsed\"\n}\n\ntest_upgrade_flag_with_provider\n\ntest_upgrade_not_set_by_default() {\n local result\n result=\"$(bash -c '\n set -euo pipefail\n TERM=dumb\n tmp=$(mktemp)\n sed \"$ d\" \"'\"${INSTALL_SCRIPT}\"'\" > \"$tmp\"\n echo \"main() { :; }\" >> \"$tmp\"\n source \"$tmp\"\n rm -f \"$tmp\"\n echo \"UPGRADE=${UPGRADE_MODE:-}\"\n ' 2>/dev/null)\" || true\n\n assert_eq \"UPGRADE=\" \"$result\" \"UPGRADE_MODE is empty by default\"\n}\n\ntest_upgrade_not_set_by_default\n\necho \"\"\necho \"=== is_claude_mem_installed() ===\"\n\ntest_is_claude_mem_installed_found() {\n local fake_home\n fake_home=\"$(mktemp -d)\"\n HOME=\"$fake_home\"\n CLAUDE_MEM_INSTALL_DIR=\"\"\n\n mkdir -p \"${fake_home}/.openclaw/extensions/claude-mem/plugin/scripts\"\n touch \"${fake_home}/.openclaw/extensions/claude-mem/plugin/scripts/worker-service.cjs\"\n\n if is_claude_mem_installed; then\n test_pass \"is_claude_mem_installed returns true when plugin exists\"\n else\n test_fail \"is_claude_mem_installed should return true when plugin exists\"\n fi\n\n HOME=\"$ORIGINAL_HOME\"\n rm -rf \"$fake_home\"\n}\n\ntest_is_claude_mem_installed_found\n\ntest_is_claude_mem_installed_not_found() {\n local fake_home\n fake_home=\"$(mktemp -d)\"\n HOME=\"$fake_home\"\n CLAUDE_MEM_INSTALL_DIR=\"\"\n\n if is_claude_mem_installed; then\n test_fail \"is_claude_mem_installed should return false when plugin not found\"\n else\n test_pass \"is_claude_mem_installed returns false when plugin not found\"\n fi\n\n HOME=\"$ORIGINAL_HOME\"\n rm -rf \"$fake_home\"\n}\n\ntest_is_claude_mem_installed_not_found\n\necho \"\"\necho \"=== check_git() ===\"\n\ntest_check_git_available() {\n if command -v git &>/dev/null; then\n local output\n output=\"$(check_git 2>&1)\" || true\n test_pass \"check_git succeeds when git is installed\"\n else\n test_pass \"check_git test skipped (git not available)\"\n fi\n}\n\ntest_check_git_available\n\ntest_check_git_not_available() {\n local exit_code=0\n PLATFORM=\"macos\"\n bash -c '\n set -euo pipefail\n TERM=dumb\n tmp=$(mktemp)\n sed \"$ d\" \"'\"${INSTALL_SCRIPT}\"'\" > \"$tmp\"\n echo \"main() { :; }\" >> \"$tmp\"\n source \"$tmp\"\n rm -f \"$tmp\"\n PATH=\"/nonexistent\"\n check_git\n ' >/dev/null 2>&1 || exit_code=$?\n\n if [[ \"$exit_code\" -ne 0 ]]; then\n test_pass \"check_git exits with error when git is missing\"\n else\n test_fail \"check_git should exit with error when git is missing\"\n fi\n}\n\ntest_check_git_not_available\n\ntest_check_git_macos_message() {\n local output\n output=\"$(bash -c '\n set -euo pipefail\n TERM=dumb\n tmp=$(mktemp)\n sed \"$ d\" \"'\"${INSTALL_SCRIPT}\"'\" > \"$tmp\"\n echo \"main() { :; }\" >> \"$tmp\"\n source \"$tmp\"\n rm -f \"$tmp\"\n PATH=\"/nonexistent\"\n PLATFORM=\"macos\"\n check_git\n ' 2>&1)\" || true\n\n assert_contains \"$output\" \"xcode-select\" \"check_git suggests xcode-select on macOS\"\n}\n\ntest_check_git_macos_message\n\ntest_check_git_linux_message() {\n local output\n output=\"$(bash -c '\n set -euo pipefail\n TERM=dumb\n tmp=$(mktemp)\n sed \"$ d\" \"'\"${INSTALL_SCRIPT}\"'\" > \"$tmp\"\n echo \"main() { :; }\" >> \"$tmp\"\n source \"$tmp\"\n rm -f \"$tmp\"\n PATH=\"/nonexistent\"\n PLATFORM=\"linux\"\n check_git\n ' 2>&1)\" || true\n\n assert_contains \"$output\" \"apt install git\" \"check_git suggests apt on Linux\"\n}\n\ntest_check_git_linux_message\n\necho \"\"\necho \"=== check_port_37777() ===\"\n\ntest_check_port_function_exists() {\n if declare -f check_port_37777 &>/dev/null; then\n test_pass \"Function check_port_37777() is defined\"\n else\n test_fail \"Function check_port_37777() should be defined\"\n fi\n}\n\ntest_check_port_function_exists\n\necho \"\"\necho \"=== cleanup_on_exit() ===\"\n\ntest_cleanup_trap_functions_exist() {\n if declare -f register_cleanup_dir &>/dev/null; then\n test_pass \"Function register_cleanup_dir() is defined\"\n else\n test_fail \"Function register_cleanup_dir() should be defined\"\n fi\n\n if declare -f cleanup_on_exit &>/dev/null; then\n test_pass \"Function cleanup_on_exit() is defined\"\n else\n test_fail \"Function cleanup_on_exit() should be defined\"\n fi\n}\n\ntest_cleanup_trap_functions_exist\n\ntest_register_cleanup_dir() {\n local test_dir\n test_dir=\"$(mktemp -d)\"\n\n local saved_dirs=(\"${CLEANUP_DIRS[@]+\"${CLEANUP_DIRS[@]}\"}\")\n CLEANUP_DIRS=()\n\n register_cleanup_dir \"$test_dir\"\n\n if [[ \"${#CLEANUP_DIRS[@]}\" -eq 1 ]] && [[ \"${CLEANUP_DIRS[0]}\" == \"$test_dir\" ]]; then\n test_pass \"register_cleanup_dir adds directory to CLEANUP_DIRS\"\n else\n test_fail \"register_cleanup_dir should add directory to CLEANUP_DIRS\"\n fi\n\n CLEANUP_DIRS=(\"${saved_dirs[@]+\"${saved_dirs[@]}\"}\")\n rm -rf \"$test_dir\"\n}\n\ntest_register_cleanup_dir\n\necho \"\"\necho \"=== ensure_jq_or_fallback() ===\"\n\ntest_ensure_jq_or_fallback_exists() {\n if declare -f ensure_jq_or_fallback &>/dev/null; then\n test_pass \"Function ensure_jq_or_fallback() is defined\"\n else\n test_fail \"Function ensure_jq_or_fallback() should be defined\"\n fi\n}\n\ntest_ensure_jq_or_fallback_exists\n\ntest_ensure_jq_with_jq_available() {\n if ! command -v jq &>/dev/null; then\n test_pass \"ensure_jq jq-path: skipped (jq not installed)\"\n return 0\n fi\n\n local tmp_json\n tmp_json=\"$(mktemp)\"\n echo '{\"name\": \"test\", \"value\": 1}' > \"$tmp_json\"\n\n if ensure_jq_or_fallback \"$tmp_json\" '.name = \"updated\"'; then\n local result\n result=\"$(node -e \"const j = JSON.parse(require('fs').readFileSync('${tmp_json}','utf8')); console.log(j.name);\")\"\n assert_eq \"updated\" \"$result\" \"ensure_jq_or_fallback updates JSON via jq\"\n else\n test_fail \"ensure_jq_or_fallback should succeed with jq available\"\n fi\n\n rm -f \"$tmp_json\"\n}\n\ntest_ensure_jq_with_jq_available\n\necho \"\"\necho \"=== main() references new functions ===\"\n\ntest_main_calls_check_port() {\n if grep -q 'check_port_37777' \"$INSTALL_SCRIPT\"; then\n test_pass \"main() calls check_port_37777\"\n else\n test_fail \"main() should call check_port_37777\"\n fi\n}\n\ntest_main_calls_check_port\n\ntest_main_calls_is_claude_mem_installed() {\n if grep -q 'is_claude_mem_installed' \"$INSTALL_SCRIPT\"; then\n test_pass \"main() calls is_claude_mem_installed for upgrade detection\"\n else\n test_fail \"main() should call is_claude_mem_installed\"\n fi\n}\n\ntest_main_calls_is_claude_mem_installed\n\ntest_main_references_upgrade_mode() {\n if grep -q 'UPGRADE_MODE' \"$INSTALL_SCRIPT\"; then\n test_pass \"main() references UPGRADE_MODE\"\n else\n test_fail \"main() should reference UPGRADE_MODE\"\n fi\n}\n\ntest_main_references_upgrade_mode\n\ntest_install_plugin_calls_check_git() {\n if grep -q 'check_git' \"$INSTALL_SCRIPT\"; then\n test_pass \"install_plugin() calls check_git\"\n else\n test_fail \"install_plugin() should call check_git\"\n fi\n}\n\ntest_install_plugin_calls_check_git\n\ntest_install_plugin_uses_register_cleanup() {\n if grep -q 'register_cleanup_dir' \"$INSTALL_SCRIPT\"; then\n test_pass \"install_plugin() uses register_cleanup_dir\"\n else\n test_fail \"install_plugin() should use register_cleanup_dir\"\n fi\n}\n\ntest_install_plugin_uses_register_cleanup\n\ntest_usage_comment_includes_upgrade() {\n if grep -q '\\-\\-upgrade' \"$INSTALL_SCRIPT\"; then\n test_pass \"Usage comment documents --upgrade flag\"\n else\n test_fail \"Usage comment should document --upgrade flag\"\n fi\n}\n\ntest_usage_comment_includes_upgrade\n\necho \"\"\necho \"=== Distribution readiness ===\"\n\ntest_install_sh_has_shebang() {\n local first_line\n first_line=\"$(head -1 \"$INSTALL_SCRIPT\")\"\n assert_eq \"#!/usr/bin/env bash\" \"$first_line\" \"install.sh has correct shebang line\"\n}\n\ntest_install_sh_has_shebang\n\ntest_install_sh_has_set_euo_pipefail() {\n if grep -q 'set -euo pipefail' \"$INSTALL_SCRIPT\"; then\n test_pass \"install.sh uses set -euo pipefail for safety\"\n else\n test_fail \"install.sh should use set -euo pipefail\"\n fi\n}\n\ntest_install_sh_has_set_euo_pipefail\n\ntest_install_sh_has_stable_url_in_usage() {\n if grep -q 'raw.githubusercontent.com/thedotmack/claude-mem/main/openclaw/install.sh' \"$INSTALL_SCRIPT\"; then\n test_pass \"install.sh usage comment has stable raw.githubusercontent.com URL\"\n else\n test_fail \"install.sh should reference stable raw.githubusercontent.com URL in usage\"\n fi\n}\n\ntest_install_sh_has_stable_url_in_usage\n\ntest_install_sh_documents_all_flags() {\n local missing_flags=()\n\n for flag in \"--non-interactive\" \"--upgrade\" \"--provider\" \"--api-key\"; do\n if ! grep -Fq -- \"$flag\" \"$INSTALL_SCRIPT\"; then\n missing_flags+=(\"$flag\")\n fi\n done\n\n if [[ ${#missing_flags[@]} -eq 0 ]]; then\n test_pass \"install.sh documents all CLI flags (--non-interactive, --upgrade, --provider, --api-key)\"\n else\n test_fail \"install.sh missing documentation for flags: ${missing_flags[*]}\"\n fi\n}\n\ntest_install_sh_documents_all_flags\n\ntest_install_sh_has_installer_version() {\n if grep -q 'INSTALLER_VERSION=' \"$INSTALL_SCRIPT\"; then\n test_pass \"install.sh defines INSTALLER_VERSION constant\"\n else\n test_fail \"install.sh should define INSTALLER_VERSION\"\n fi\n}\n\ntest_install_sh_has_installer_version\n\ntest_skill_md_references_one_liner() {\n local skill_file=\"${SCRIPT_DIR}/SKILL.md\"\n if [[ ! -f \"$skill_file\" ]]; then\n test_fail \"SKILL.md not found at ${skill_file}\"\n return\n fi\n\n if grep -q 'curl -fsSL.*raw.githubusercontent.com.*install.sh | bash' \"$skill_file\"; then\n test_pass \"SKILL.md references the one-liner installer\"\n else\n test_fail \"SKILL.md should reference the one-liner installer\"\n fi\n}\n\ntest_skill_md_references_one_liner\n\ntest_skill_md_has_quick_install_section() {\n local skill_file=\"${SCRIPT_DIR}/SKILL.md\"\n if [[ ! -f \"$skill_file\" ]]; then\n test_fail \"SKILL.md not found at ${skill_file}\"\n return\n fi\n\n if grep -q 'Quick Install' \"$skill_file\"; then\n test_pass \"SKILL.md has Quick Install section\"\n else\n test_fail \"SKILL.md should have Quick Install section\"\n fi\n}\n\ntest_skill_md_has_quick_install_section\n\ntest_skill_md_documents_options() {\n local skill_file=\"${SCRIPT_DIR}/SKILL.md\"\n if [[ ! -f \"$skill_file\" ]]; then\n test_fail \"SKILL.md not found at ${skill_file}\"\n return\n fi\n\n local missing=()\n for option in \"--provider\" \"--non-interactive\" \"--upgrade\"; do\n if ! grep -Fq -- \"$option\" \"$skill_file\"; then\n missing+=(\"$option\")\n fi\n done\n\n if [[ ${#missing[@]} -eq 0 ]]; then\n test_pass \"SKILL.md documents all installer options (--provider, --non-interactive, --upgrade)\"\n else\n test_fail \"SKILL.md missing documentation for: ${missing[*]}\"\n fi\n}\n\ntest_skill_md_documents_options\n\necho \"\"\necho \"========================================\"\necho \"Results: ${TESTS_PASSED}/${TESTS_RUN} passed, ${TESTS_FAILED} failed\"\necho \"========================================\"\n\nif [[ \"$TESTS_FAILED\" -gt 0 ]]; then\n exit 1\nfi\n\nexit 0\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":59722,"content_sha256":"756e49c1a659315e0abb04b0cae613a5c0016a27d88de7bc905e894b023deac4"},{"filename":"TESTING.md","content":"# OpenClaw Claude-Mem Plugin — Testing Guide\n\n## Quick Start (Docker)\n\nThe fastest way to test the plugin is using the pre-built Docker E2E environment:\n\n```bash\ncd openclaw\n\n# Automated test (builds, installs plugin on real OpenClaw, verifies everything)\n./test-e2e.sh\n\n# Interactive shell (for manual exploration)\n./test-e2e.sh --interactive\n\n# Just build the image\n./test-e2e.sh --build-only\n```\n\n---\n\n## Test Layers\n\n### 1. Unit Tests (fastest)\n\n```bash\ncd openclaw\nnpm test # compiles TypeScript, runs 17 tests\n```\n\nTests plugin registration, service lifecycle, command handling, SSE integration, and all 6 channel types.\n\n### 2. Smoke Test\n\n```bash\nnode test-sse-consumer.js\n```\n\nQuick check that the plugin loads and registers its service + command correctly.\n\n### 3. Container Unit Tests (fresh install)\n\n```bash\n./test-container.sh # Unit tests in clean Docker\n./test-container.sh --full # Integration tests with mock worker\n```\n\n### 4. E2E on Real OpenClaw (Docker)\n\n```bash\n./test-e2e.sh\n```\n\nThis is the most comprehensive test. It:\n1. Uses the official `ghcr.io/openclaw/openclaw:main` Docker image\n2. Installs the plugin via `openclaw plugins install` (same as a real user)\n3. Enables the plugin via `openclaw plugins enable`\n4. Starts a mock claude-mem worker on port 37777\n5. Starts the OpenClaw gateway with plugin config\n6. Verifies the plugin loads, connects to SSE, and processes events\n\n**All 16 checks must pass.**\n\n---\n\n## Human E2E Testing (Interactive Docker)\n\nFor manual walkthrough testing, use the interactive Docker mode:\n\n```bash\n./test-e2e.sh --interactive\n```\n\nThis drops you into a fully-configured OpenClaw container with the plugin pre-installed.\n\n### Step-by-step inside the container\n\n#### 1. Verify plugin is installed\n\n```bash\nnode openclaw.mjs plugins list\nnode openclaw.mjs plugins info claude-mem\nnode openclaw.mjs plugins doctor\n```\n\n**Expected:**\n- `claude-mem` appears in the plugins list as \"enabled\" or \"loaded\"\n- Info shows version 1.0.0, source at `/home/node/.openclaw/extensions/claude-mem/`\n- Doctor reports no issues\n\n#### 2. Inspect plugin files\n\n```bash\nls -la /home/node/.openclaw/extensions/claude-mem/\ncat /home/node/.openclaw/extensions/claude-mem/openclaw.plugin.json\ncat /home/node/.openclaw/extensions/claude-mem/package.json\n```\n\n**Expected:**\n- `dist/index.js` exists (compiled plugin)\n- `openclaw.plugin.json` has `\"id\": \"claude-mem\"` and `\"kind\": \"memory\"`\n- `package.json` has `openclaw.extensions` field pointing to `./dist/index.js`\n\n#### 3. Start mock worker\n\n```bash\nnode /app/mock-worker.js &\n```\n\nVerify it's running:\n\n```bash\ncurl -s http://localhost:37777/health\n# → {\"status\":\"ok\"}\n\ncurl -s --max-time 3 http://localhost:37777/stream\n# → data: {\"type\":\"connected\",\"message\":\"Mock worker SSE stream\"}\n# → data: {\"type\":\"new_observation\",\"observation\":{...}}\n```\n\n#### 4. Configure and start gateway\n\n```bash\ncat > /home/node/.openclaw/openclaw.json \u003c\u003c 'EOF'\n{\n \"gateway\": {\n \"mode\": \"local\",\n \"auth\": {\n \"mode\": \"token\",\n \"token\": \"e2e-test-token\"\n }\n },\n \"plugins\": {\n \"slots\": {\n \"memory\": \"claude-mem\"\n },\n \"entries\": {\n \"claude-mem\": {\n \"enabled\": true,\n \"config\": {\n \"workerPort\": 37777,\n \"observationFeed\": {\n \"enabled\": true,\n \"channel\": \"telegram\",\n \"to\": \"test-chat-id-12345\"\n }\n }\n }\n }\n }\n}\nEOF\n\nnode openclaw.mjs gateway --allow-unconfigured --verbose --token e2e-test-token\n```\n\n**Expected in gateway logs:**\n- `[claude-mem] OpenClaw plugin loaded — v1.0.0`\n- `[claude-mem] Observation feed starting — channel: telegram, target: test-chat-id-12345`\n- `[claude-mem] Connecting to SSE stream at http://localhost:37777/stream`\n- `[claude-mem] Connected to SSE stream`\n\n#### 5. Run automated verification (optional)\n\nFrom a second shell in the container (or after stopping the gateway):\n\n```bash\n/bin/bash /app/e2e-verify.sh\n```\n\n---\n\n## Manual E2E (Real OpenClaw + Real Worker)\n\nFor testing with a real claude-mem worker and real messaging channel:\n\n### Prerequisites\n\n- OpenClaw gateway installed and configured\n- Claude-Mem worker running on port 37777\n- Plugin built: `cd openclaw && npm run build`\n\n### 1. Install the plugin\n\n```bash\n# Build the plugin\ncd openclaw && npm run build\n\n# Install on OpenClaw (from the openclaw/ directory)\nopenclaw plugins install .\n\n# Enable it\nopenclaw plugins enable claude-mem\n```\n\n### 2. Configure\n\nEdit `~/.openclaw/openclaw.json` to add plugin config:\n\n```json\n{\n \"plugins\": {\n \"entries\": {\n \"claude-mem\": {\n \"enabled\": true,\n \"config\": {\n \"workerPort\": 37777,\n \"observationFeed\": {\n \"enabled\": true,\n \"channel\": \"telegram\",\n \"to\": \"YOUR_CHAT_ID\"\n }\n }\n }\n }\n }\n}\n```\n\n**Supported channels:** `telegram`, `discord`, `signal`, `slack`, `whatsapp`, `line`\n\n### 3. Restart gateway\n\n```bash\nopenclaw restart\n```\n\n**Look for in logs:**\n- `[claude-mem] OpenClaw plugin loaded — v1.0.0`\n- `[claude-mem] Connected to SSE stream`\n\n### 4. Trigger an observation\n\nStart a Claude Code session with claude-mem enabled and perform any action. The worker will emit a `new_observation` SSE event.\n\n### 5. Verify delivery\n\nCheck the target messaging channel for:\n\n```\n🧠 Claude-Mem Observation\n**Observation Title**\nOptional subtitle\n```\n\n---\n\n## Troubleshooting\n\n### `api.log is not a function`\nThe plugin was built against the wrong API. Ensure `src/index.ts` uses `api.logger.info()` not `api.log()`. Rebuild with `npm run build`.\n\n### Worker not running\n- **Symptom:** `SSE stream error: fetch failed. Reconnecting in 1s`\n- **Fix:** Start the worker: `cd /path/to/claude-mem && npm run build-and-sync`\n\n### Port mismatch\n- **Fix:** Ensure `workerPort` in config matches the worker's actual port (default: 37777)\n\n### Channel not configured\n- **Symptom:** `Observation feed misconfigured — channel or target missing`\n- **Fix:** Add both `channel` and `to` to `observationFeed` in config\n\n### Unknown channel type\n- **Fix:** Use: `telegram`, `discord`, `signal`, `slack`, `whatsapp`, or `line`\n\n### Feed disabled\n- **Symptom:** `Observation feed disabled`\n- **Fix:** Set `observationFeed.enabled: true`\n\n### Messages not arriving\n1. Verify the bot/integration is configured in the target channel\n2. Check the target ID (`to`) is correct\n3. Look for `Failed to send to \u003cchannel>` in logs\n4. Test the channel via OpenClaw's built-in tools\n\n### Memory slot conflict\n- **Symptom:** `plugin disabled (memory slot set to \"memory-core\")`\n- **Fix:** Add `\"slots\": { \"memory\": \"claude-mem\" }` to plugins config\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":6675,"content_sha256":"40f78c4c068cd050011c7d8364bcf3e366e4da865571adf69b7055ccfb997a8b"},{"filename":"tsconfig.json","content":"{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"ESNext\",\n \"moduleResolution\": \"node\",\n \"lib\": [\"ES2022\", \"DOM\"],\n \"outDir\": \"./dist\",\n \"rootDir\": \"./src\",\n \"strict\": true,\n \"esModuleInterop\": true,\n \"skipLibCheck\": true,\n \"forceConsistentCasingInFileNames\": true,\n \"resolveJsonModule\": true,\n \"declaration\": true,\n \"declarationMap\": true,\n \"sourceMap\": true,\n \"allowSyntheticDefaultImports\": true\n },\n \"include\": [\n \"src/**/*\"\n ],\n \"exclude\": [\n \"node_modules\",\n \"dist\"\n ]\n}\n","content_type":"application/json; charset=utf-8","language":"json","size":544,"content_sha256":"0fe0caed34e5daf2ec9cc5f2566f18135f35cd06c15d2ee41606059efb4cfc1b"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"Claude-Mem OpenClaw Plugin — Setup Guide","type":"text"}]},{"type":"paragraph","content":[{"text":"This guide walks through setting up the claude-mem plugin on an OpenClaw gateway. By the end, your agents will have persistent memory across sessions via system prompt context injection, and optionally a real-time observation feed streaming to a messaging channel.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Quick Install (Recommended)","type":"text"}]},{"type":"paragraph","content":[{"text":"Run this one-liner to install everything automatically:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"curl -fsSL https://install.cmem.ai/openclaw.sh | bash","type":"text"}]},{"type":"paragraph","content":[{"text":"The installer handles dependency checks (Bun, uv), plugin installation, memory slot configuration, AI provider setup, worker startup, and optional observation feed configuration — all interactively.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Install with options","type":"text"}]},{"type":"paragraph","content":[{"text":"Pre-select your AI provider and API key to skip interactive prompts:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"curl -fsSL https://install.cmem.ai/openclaw.sh | bash -s -- --provider=gemini --api-key=YOUR_KEY","type":"text"}]},{"type":"paragraph","content":[{"text":"For fully unattended installation (defaults to Claude Max Plan, skips observation feed):","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"curl -fsSL https://install.cmem.ai/openclaw.sh | bash -s -- --non-interactive","type":"text"}]},{"type":"paragraph","content":[{"text":"To upgrade an existing installation (preserves settings, updates plugin):","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"curl -fsSL https://install.cmem.ai/openclaw.sh | bash -s -- --upgrade","type":"text"}]},{"type":"paragraph","content":[{"text":"After installation, skip to ","type":"text"},{"text":"Step 4: Restart the Gateway and Verify","type":"text","marks":[{"type":"link","attrs":{"href":"#step-4-restart-the-gateway-and-verify","title":null}}]},{"text":" to confirm everything is working.","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Manual Setup","type":"text"}]},{"type":"paragraph","content":[{"text":"The steps below are for manual installation if you prefer not to use the automated installer, or need to troubleshoot individual steps.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 1: Clone the Claude-Mem Repo","type":"text"}]},{"type":"paragraph","content":[{"text":"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.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"cd /opt # or wherever you want to keep it\ngit clone https://github.com/thedotmack/claude-mem.git\ncd claude-mem\nnpm install\nnpm run build","type":"text"}]},{"type":"paragraph","content":[{"text":"You'll need ","type":"text"},{"text":"bun","type":"text","marks":[{"type":"strong"}]},{"text":" installed for the worker service. If you don't have it:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"curl -fsSL https://bun.sh/install | bash","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 2: Get the Worker Running","type":"text"}]},{"type":"paragraph","content":[{"text":"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.","type":"text"}]},{"type":"heading","attrs":{"level":4},"content":[{"text":"Check if it's already running","type":"text"}]},{"type":"paragraph","content":[{"text":"If this machine also runs Claude Code with claude-mem installed, the worker may already be running:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"curl http://localhost:37777/api/health","type":"text"}]},{"type":"paragraph","content":[{"text":"Got ","type":"text","marks":[{"type":"strong"}]},{"text":"{\"status\":\"ok\"}","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":"?","type":"text","marks":[{"type":"strong"}]},{"text":" The worker is already running. Skip to Step 3.","type":"text"}]},{"type":"paragraph","content":[{"text":"Got connection refused or no response?","type":"text","marks":[{"type":"strong"}]},{"text":" The worker isn't running. Continue below.","type":"text"}]},{"type":"heading","attrs":{"level":4},"content":[{"text":"If Claude Code has claude-mem installed","type":"text"}]},{"type":"paragraph","content":[{"text":"If claude-mem is installed as a Claude Code plugin (at ","type":"text"},{"text":"~/.claude/plugins/marketplaces/thedotmack/","type":"text","marks":[{"type":"code_inline"}]},{"text":"), start the worker from that installation:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"cd ~/.claude/plugins/marketplaces/thedotmack\nnpm run worker:restart","type":"text"}]},{"type":"paragraph","content":[{"text":"Verify:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"curl http://localhost:37777/api/health","type":"text"}]},{"type":"paragraph","content":[{"text":"Got ","type":"text","marks":[{"type":"strong"}]},{"text":"{\"status\":\"ok\"}","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":"?","type":"text","marks":[{"type":"strong"}]},{"text":" You're set. Skip to Step 3.","type":"text"}]},{"type":"paragraph","content":[{"text":"Still not working?","type":"text","marks":[{"type":"strong"}]},{"text":" Check ","type":"text"},{"text":"npm run worker:status","type":"text","marks":[{"type":"code_inline"}]},{"text":" for error details, or check that bun is installed and on your PATH.","type":"text"}]},{"type":"heading","attrs":{"level":4},"content":[{"text":"If there's no Claude Code installation","type":"text"}]},{"type":"paragraph","content":[{"text":"Run the worker from the cloned repo:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"cd /opt/claude-mem # wherever you cloned it\nnpm run worker:start","type":"text"}]},{"type":"paragraph","content":[{"text":"Verify:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"curl http://localhost:37777/api/health","type":"text"}]},{"type":"paragraph","content":[{"text":"Got ","type":"text","marks":[{"type":"strong"}]},{"text":"{\"status\":\"ok\"}","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":"?","type":"text","marks":[{"type":"strong"}]},{"text":" You're set. Move to Step 3.","type":"text"}]},{"type":"paragraph","content":[{"text":"Still not working?","type":"text","marks":[{"type":"strong"}]},{"text":" Debug steps:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Check that bun is installed: ","type":"text"},{"text":"bun --version","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Check the worker status: ","type":"text"},{"text":"npm run worker:status","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Check if something else is using port 37777: ","type":"text"},{"text":"lsof -i :37777","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Check logs: ","type":"text"},{"text":"npm run worker:logs","type":"text","marks":[{"type":"code_inline"}]},{"text":" (if available)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Try running it directly to see errors: ","type":"text"},{"text":"bun plugin/scripts/worker-service.cjs start","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 3: Add the Plugin to Your Gateway","type":"text"}]},{"type":"paragraph","content":[{"text":"Add the ","type":"text"},{"text":"claude-mem","type":"text","marks":[{"type":"code_inline"}]},{"text":" plugin to your OpenClaw gateway configuration:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"{\n \"plugins\": {\n \"claude-mem\": {\n \"enabled\": true,\n \"config\": {\n \"project\": \"my-project\",\n \"syncMemoryFile\": true,\n \"workerPort\": 37777\n }\n }\n }\n}","type":"text"}]},{"type":"heading","attrs":{"level":4},"content":[{"text":"Config fields explained","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"project","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (string, default: ","type":"text"},{"text":"\"openclaw\"","type":"text","marks":[{"type":"code_inline"}]},{"text":") — 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 ","type":"text"},{"text":"\"coding-bot\"","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"syncMemoryFile","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (boolean, default: ","type":"text"},{"text":"true","type":"text","marks":[{"type":"code_inline"}]},{"text":") — When enabled, the plugin injects the observation timeline into each agent's system prompt via the ","type":"text"},{"text":"before_prompt_build","type":"text","marks":[{"type":"code_inline"}]},{"text":" hook. This gives agents cross-session context without writing to MEMORY.md. Set to ","type":"text"},{"text":"false","type":"text","marks":[{"type":"code_inline"}]},{"text":" to disable context injection entirely (observations are still recorded).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"syncMemoryFileExclude","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (string[], default: ","type":"text"},{"text":"[]","type":"text","marks":[{"type":"code_inline"}]},{"text":") — Agent IDs excluded from automatic context injection. Useful for agents that curate their own memory. Observations are still recorded for excluded agents.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"workerPort","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (number, default: ","type":"text"},{"text":"37777","type":"text","marks":[{"type":"code_inline"}]},{"text":") — The port where the claude-mem worker service is listening. Only change this if you configured the worker to use a different port.","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Step 4: Restart the Gateway and Verify","type":"text"}]},{"type":"paragraph","content":[{"text":"Restart your OpenClaw gateway so it picks up the new plugin configuration. After restart, check the gateway logs for:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"[claude-mem] OpenClaw plugin loaded — v1.0.0 (worker: 127.0.0.1:37777)","type":"text"}]},{"type":"paragraph","content":[{"text":"If you see this, the plugin is loaded. You can also verify by running ","type":"text"},{"text":"/claude_mem_status","type":"text","marks":[{"type":"code_inline"}]},{"text":" in any OpenClaw chat:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"Claude-Mem Worker Status\nStatus: ok\nPort: 37777\nActive sessions: 0\nObservation feed: disconnected","type":"text"}]},{"type":"paragraph","content":[{"text":"The observation feed shows ","type":"text"},{"text":"disconnected","type":"text","marks":[{"type":"code_inline"}]},{"text":" because we haven't configured it yet. That's next.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Step 5: Verify Observations Are Being Recorded","type":"text"}]},{"type":"paragraph","content":[{"text":"Have an agent do some work. The plugin automatically records observations through these OpenClaw events:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"before_agent_start","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — Initializes a claude-mem session when the agent starts","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"before_prompt_build","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — Injects the observation timeline into the agent's system prompt (cached for 60s)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"tool_result_persist","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — Records each tool use (Read, Write, Bash, etc.) as an observation","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"agent_end","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — Summarizes the session and marks it complete","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"All of this happens automatically. No additional configuration needed.","type":"text"}]},{"type":"paragraph","content":[{"text":"To verify it's working, check the worker's viewer UI at http://localhost:37777 to see observations appearing after the agent runs.","type":"text"}]},{"type":"paragraph","content":[{"text":"You can also check the worker's viewer UI at http://localhost:37777 to see observations appearing in real time.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Step 6: Set Up the Observation Feed (Streaming to a Channel)","type":"text"}]},{"type":"paragraph","content":[{"text":"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.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"What you'll see","type":"text"}]},{"type":"paragraph","content":[{"text":"Every time claude-mem creates a new observation from your agent's tool usage, a message like this appears in your channel:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"🧠 Claude-Mem Observation\n**Implemented retry logic for API client**\nAdded exponential backoff with configurable max retries to handle transient failures","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Pick your channel","type":"text"}]},{"type":"paragraph","content":[{"text":"You need two things:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Channel type","type":"text","marks":[{"type":"strong"}]},{"text":" — Must match a channel plugin already running on your OpenClaw gateway","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Target ID","type":"text","marks":[{"type":"strong"}]},{"text":" — The chat/channel/user ID where messages go","type":"text"}]}]}]},{"type":"heading","attrs":{"level":4},"content":[{"text":"Telegram","type":"text"}]},{"type":"paragraph","content":[{"text":"Channel type: ","type":"text"},{"text":"telegram","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"paragraph","content":[{"text":"To find your chat ID:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Message @userinfobot on Telegram — https://t.me/userinfobot","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"It replies with your numeric chat ID (e.g., ","type":"text"},{"text":"123456789","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"For group chats, the ID is negative (e.g., ","type":"text"},{"text":"-1001234567890","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"\"observationFeed\": {\n \"enabled\": true,\n \"channel\": \"telegram\",\n \"to\": \"123456789\"\n}","type":"text"}]},{"type":"heading","attrs":{"level":4},"content":[{"text":"Discord","type":"text"}]},{"type":"paragraph","content":[{"text":"Channel type: ","type":"text"},{"text":"discord","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"paragraph","content":[{"text":"To find your channel ID:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Enable Developer Mode in Discord: Settings → Advanced → Developer Mode","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Right-click the target channel → Copy Channel ID","type":"text"}]}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"\"observationFeed\": {\n \"enabled\": true,\n \"channel\": \"discord\",\n \"to\": \"1234567890123456789\"\n}","type":"text"}]},{"type":"heading","attrs":{"level":4},"content":[{"text":"Slack","type":"text"}]},{"type":"paragraph","content":[{"text":"Channel type: ","type":"text"},{"text":"slack","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"paragraph","content":[{"text":"To find your channel ID (not the channel name):","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Open the channel in Slack","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Click the channel name at the top","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Scroll to the bottom of the channel details — the ID looks like ","type":"text"},{"text":"C01ABC2DEFG","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"\"observationFeed\": {\n \"enabled\": true,\n \"channel\": \"slack\",\n \"to\": \"C01ABC2DEFG\"\n}","type":"text"}]},{"type":"heading","attrs":{"level":4},"content":[{"text":"Signal","type":"text"}]},{"type":"paragraph","content":[{"text":"Channel type: ","type":"text"},{"text":"signal","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"paragraph","content":[{"text":"Use the phone number or group ID configured in your OpenClaw gateway's Signal plugin.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"\"observationFeed\": {\n \"enabled\": true,\n \"channel\": \"signal\",\n \"to\": \"+1234567890\"\n}","type":"text"}]},{"type":"heading","attrs":{"level":4},"content":[{"text":"WhatsApp","type":"text"}]},{"type":"paragraph","content":[{"text":"Channel type: ","type":"text"},{"text":"whatsapp","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"paragraph","content":[{"text":"Use the phone number or group JID configured in your OpenClaw gateway's WhatsApp plugin.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"\"observationFeed\": {\n \"enabled\": true,\n \"channel\": \"whatsapp\",\n \"to\": \"+1234567890\"\n}","type":"text"}]},{"type":"heading","attrs":{"level":4},"content":[{"text":"LINE","type":"text"}]},{"type":"paragraph","content":[{"text":"Channel type: ","type":"text"},{"text":"line","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"paragraph","content":[{"text":"Use the user ID or group ID from the LINE Developer Console.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"\"observationFeed\": {\n \"enabled\": true,\n \"channel\": \"line\",\n \"to\": \"U1234567890abcdef\"\n}","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Add it to your config","type":"text"}]},{"type":"paragraph","content":[{"text":"Your complete plugin config should now look like this (using Telegram as an example):","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"{\n \"plugins\": {\n \"claude-mem\": {\n \"enabled\": true,\n \"config\": {\n \"project\": \"my-project\",\n \"syncMemoryFile\": true,\n \"workerPort\": 37777,\n \"observationFeed\": {\n \"enabled\": true,\n \"channel\": \"telegram\",\n \"to\": \"123456789\"\n }\n }\n }\n }\n}","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Restart and verify","type":"text"}]},{"type":"paragraph","content":[{"text":"Restart the gateway. Check the logs for these three lines in order:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"[claude-mem] Observation feed starting — channel: telegram, target: 123456789\n[claude-mem] Connecting to SSE stream at http://localhost:37777/stream\n[claude-mem] Connected to SSE stream","type":"text"}]},{"type":"paragraph","content":[{"text":"Then run ","type":"text"},{"text":"/claude_mem_feed","type":"text","marks":[{"type":"code_inline"}]},{"text":" in any OpenClaw chat:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"Claude-Mem Observation Feed\nEnabled: yes\nChannel: telegram\nTarget: 123456789\nConnection: connected","type":"text"}]},{"type":"paragraph","content":[{"text":"If ","type":"text"},{"text":"Connection","type":"text","marks":[{"type":"code_inline"}]},{"text":" shows ","type":"text"},{"text":"connected","type":"text","marks":[{"type":"code_inline"}]},{"text":", you're done. Have an agent do some work and watch observations stream to your channel.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Commands Reference","type":"text"}]},{"type":"paragraph","content":[{"text":"The plugin registers two commands:","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"/claude_mem_status","type":"text"}]},{"type":"paragraph","content":[{"text":"Reports worker health and current session state.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"/claude_mem_status","type":"text"}]},{"type":"paragraph","content":[{"text":"Output:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"Claude-Mem Worker Status\nStatus: ok\nPort: 37777\nActive sessions: 2\nObservation feed: connected","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"/claude_mem_feed","type":"text"}]},{"type":"paragraph","content":[{"text":"Shows observation feed status. Accepts optional ","type":"text"},{"text":"on","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"off","type":"text","marks":[{"type":"code_inline"}]},{"text":" argument.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"/claude_mem_feed — show status\n/claude_mem_feed on — request enable (update config to persist)\n/claude_mem_feed off — request disable (update config to persist)","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"How It All Works","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"OpenClaw Gateway\n │\n ├── before_agent_start ───→ Init session\n ├── before_prompt_build ──→ Inject context into system prompt\n ├── tool_result_persist ──→ Record observation\n ├── agent_end ────────────→ Summarize + Complete session\n └── gateway_start ────────→ Reset session tracking + context cache\n │\n ▼\n Claude-Mem Worker (localhost:37777)\n ├── POST /api/sessions/init\n ├── POST /api/sessions/observations\n ├── POST /api/sessions/summarize\n ├── POST /api/sessions/complete\n ├── GET /api/context/inject ──→ System prompt context\n └── GET /stream ─────────────→ SSE → Messaging channels","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"System prompt context injection","type":"text"}]},{"type":"paragraph","content":[{"text":"The plugin injects the observation timeline into each agent's system prompt via the ","type":"text"},{"text":"before_prompt_build","type":"text","marks":[{"type":"code_inline"}]},{"text":" hook. The content comes from the worker's ","type":"text"},{"text":"GET /api/context/inject","type":"text","marks":[{"type":"code_inline"}]},{"text":" endpoint. Context is cached for 60 seconds per project to avoid re-fetching on every LLM turn. The cache is cleared on gateway restart.","type":"text"}]},{"type":"paragraph","content":[{"text":"This keeps MEMORY.md under the agent's control for curated long-term memory, while the observation timeline is delivered through the system prompt.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Observation recording","type":"text"}]},{"type":"paragraph","content":[{"text":"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 ","type":"text"},{"text":"memory_","type":"text","marks":[{"type":"code_inline"}]},{"text":" are skipped to avoid recursive recording.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Session lifecycle","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"before_agent_start","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — Creates a session in the worker.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"before_prompt_build","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — Fetches the observation timeline and returns it as ","type":"text"},{"text":"appendSystemContext","type":"text","marks":[{"type":"code_inline"}]},{"text":". Cached for 60s.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"tool_result_persist","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — Records observation (fire-and-forget). Tool responses are truncated to 1000 characters.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"agent_end","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — Sends the last assistant message for summarization, then completes the session. Both fire-and-forget.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"gateway_start","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — Clears all session tracking (session IDs, context cache) so agents start fresh.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Observation feed","type":"text"}]},{"type":"paragraph","content":[{"text":"A background service connects to the worker's SSE stream and forwards ","type":"text"},{"text":"new_observation","type":"text","marks":[{"type":"code_inline"}]},{"text":" events to a configured messaging channel. The connection auto-reconnects with exponential backoff (1s → 30s max).","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Troubleshooting","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Problem","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"What to check","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Worker health check fails","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Is bun installed? (","type":"text"},{"text":"bun --version","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Is something else on port 37777? (","type":"text"},{"text":"lsof -i :37777","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Try running directly: ","type":"text"},{"text":"bun plugin/scripts/worker-service.cjs start","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Worker started from Claude Code install but not responding","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Check ","type":"text"},{"text":"cd ~/.claude/plugins/marketplaces/thedotmack && npm run worker:status","type":"text","marks":[{"type":"code_inline"}]},{"text":". May need ","type":"text"},{"text":"npm run worker:restart","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Worker started from cloned repo but not responding","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Check ","type":"text"},{"text":"cd /path/to/claude-mem && npm run worker:status","type":"text","marks":[{"type":"code_inline"}]},{"text":". Make sure you ran ","type":"text"},{"text":"npm install && npm run build","type":"text","marks":[{"type":"code_inline"}]},{"text":" first.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"No context in agent system prompt","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Check that ","type":"text"},{"text":"syncMemoryFile","type":"text","marks":[{"type":"code_inline"}]},{"text":" is not set to ","type":"text"},{"text":"false","type":"text","marks":[{"type":"code_inline"}]},{"text":". Check that the agent's ID is not in ","type":"text"},{"text":"syncMemoryFileExclude","type":"text","marks":[{"type":"code_inline"}]},{"text":". Verify the worker is running and has observations.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Observations not being recorded","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Check gateway logs for ","type":"text"},{"text":"[claude-mem]","type":"text","marks":[{"type":"code_inline"}]},{"text":" messages. The worker must be running and reachable on localhost:37777.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Feed shows ","type":"text"},{"text":"disconnected","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Worker's ","type":"text"},{"text":"/stream","type":"text","marks":[{"type":"code_inline"}]},{"text":" endpoint not reachable. Check ","type":"text"},{"text":"workerPort","type":"text","marks":[{"type":"code_inline"}]},{"text":" matches the actual worker port.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Feed shows ","type":"text"},{"text":"reconnecting","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Connection dropped. The plugin auto-reconnects — wait up to 30 seconds.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Unknown channel type","type":"text","marks":[{"type":"code_inline"}]},{"text":" in logs","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"The channel plugin (e.g., telegram) isn't loaded on your gateway. Make sure the channel is configured and running.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Observation feed disabled","type":"text","marks":[{"type":"code_inline"}]},{"text":" in logs","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Set ","type":"text"},{"text":"observationFeed.enabled","type":"text","marks":[{"type":"code_inline"}]},{"text":" to ","type":"text"},{"text":"true","type":"text","marks":[{"type":"code_inline"}]},{"text":" in your config.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Observation feed misconfigured","type":"text","marks":[{"type":"code_inline"}]},{"text":" in logs","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Both ","type":"text"},{"text":"observationFeed.channel","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"observationFeed.to","type":"text","marks":[{"type":"code_inline"}]},{"text":" are required.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"No messages in channel despite ","type":"text"},{"text":"connected","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"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).","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Full Config Reference","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"{\n \"plugins\": {\n \"claude-mem\": {\n \"enabled\": true,\n \"config\": {\n \"project\": \"openclaw\",\n \"syncMemoryFile\": true,\n \"workerPort\": 37777,\n \"observationFeed\": {\n \"enabled\": false,\n \"channel\": \"telegram\",\n \"to\": \"123456789\"\n }\n }\n }\n }\n}","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Field","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Default","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Description","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"project","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"openclaw\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Project name scoping observations in the database","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"syncMemoryFile","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"boolean","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"true","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Inject observation context into agent system prompt","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"syncMemoryFileExclude","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string[]","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"[]","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Agent IDs excluded from context injection","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"workerPort","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"number","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"37777","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Claude-mem worker service port","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"observationFeed.enabled","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"boolean","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"false","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Stream observations to a messaging channel","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"observationFeed.channel","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Channel type: ","type":"text"},{"text":"telegram","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"discord","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"slack","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"signal","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"whatsapp","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"line","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"observationFeed.to","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"string","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Target chat/channel/user ID","type":"text"}]}]}]}]},{"type":"hr","attrs":{"markup":"---"}}]},"metadata":{"date":"2026-06-05","author":"@skillopedia","source":{"stars":80070,"repo_name":"claude-mem","origin_url":"https://github.com/thedotmack/claude-mem/blob/HEAD/openclaw/SKILL.md","repo_owner":"thedotmack","body_sha256":"b18621fb8746706b004de3b50f7097f1956cffc788d026e4674dc1a26e6fb425","cluster_key":"544f1d25b97a988be0669a01950bfad9042f4c1820602abdd92a2f193e5db2a0","clean_bundle":{"format":"clean-skill-bundle-v1","source":"thedotmack/claude-mem/openclaw/SKILL.md","attachments":[{"id":"6de61669-8b30-524a-9961-3c3ab8ed841e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6de61669-8b30-524a-9961-3c3ab8ed841e/attachment","path":".gitignore","size":20,"sha256":"790e3169bc19b046fe55ba1da6d13b976c28363c8c00462a2195fe5faf260910","contentType":"text/plain; charset=utf-8"},{"id":"9022b412-4c87-5c10-8bd6-dde712b35ab1","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9022b412-4c87-5c10-8bd6-dde712b35ab1/attachment","path":".npmignore","size":14,"sha256":"4d56952b0fb13bf8f9b6c13a6d4c34a075bac3af447636a1df4335d7576e2f97","contentType":"text/plain; charset=utf-8"},{"id":"03898071-d667-5122-8bdd-d5308f47a7e8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/03898071-d667-5122-8bdd-d5308f47a7e8/attachment.e2e","path":"Dockerfile.e2e","size":1332,"sha256":"579a2a1dc288559f54363d8058ff1f3460073659c9d6e1ae44c00eb970bfbc22","contentType":"text/plain; charset=utf-8"},{"id":"1eac6bea-e746-5cf3-9857-1ab9e54fcc65","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1eac6bea-e746-5cf3-9857-1ab9e54fcc65/attachment.md","path":"TESTING.md","size":6675,"sha256":"40f78c4c068cd050011c7d8364bcf3e366e4da865571adf69b7055ccfb997a8b","contentType":"text/markdown; charset=utf-8"},{"id":"2f9b02ba-aa9d-5d27-a615-11044a279b5e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2f9b02ba-aa9d-5d27-a615-11044a279b5e/attachment.sh","path":"e2e-verify.sh","size":5465,"sha256":"f3b0d97769c1800351a778b89424a4719bc4c280028abf148704b97eba5af740","contentType":"application/x-sh; charset=utf-8"},{"id":"d725c93a-9971-5e5d-a4c6-7fe9b3983aff","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d725c93a-9971-5e5d-a4c6-7fe9b3983aff/attachment.sh","path":"install.sh","size":52283,"sha256":"5aa43a6235c91539ba3f2592b375dcaf4a030098485d060d256076c2d56659ee","contentType":"application/x-sh; charset=utf-8"},{"id":"bc13d4b1-9dee-5bef-87f9-ee41faf33817","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/bc13d4b1-9dee-5bef-87f9-ee41faf33817/attachment.json","path":"openclaw.plugin.json","size":3902,"sha256":"ab466f824750b824d967b749c47766cb54cc4716710ea1ff0a8702d5dc6f5a92","contentType":"application/json; charset=utf-8"},{"id":"eb950dc0-f347-53cd-bf6e-a7dc709518ce","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/eb950dc0-f347-53cd-bf6e-a7dc709518ce/attachment.json","path":"package.json","size":400,"sha256":"001c6d12e06e5c551897b4fc63f6bc7fad816e089f7f0907ef0a3f558dfaef89","contentType":"application/json; charset=utf-8"},{"id":"fb8b1c6a-7579-58cb-b047-cdd9523678bc","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/fb8b1c6a-7579-58cb-b047-cdd9523678bc/attachment.ts","path":"src/index.test.ts","size":41795,"sha256":"bc02bf03194df63bc57bf3fc3ae16ccede78801b60c6e521699c185f18308710","contentType":"text/typescript; charset=utf-8"},{"id":"0badc5c7-ccec-5596-954c-f3fe3826ff20","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0badc5c7-ccec-5596-954c-f3fe3826ff20/attachment.ts","path":"src/index.ts","size":35704,"sha256":"a112e19e61c897f99034e59e9075b41a5176c8fb74a93e7f9d33708fa4f0a71f","contentType":"text/typescript; charset=utf-8"},{"id":"7a424e61-d482-5c87-a9c0-d249f7ab8800","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7a424e61-d482-5c87-a9c0-d249f7ab8800/attachment.sh","path":"test-e2e.sh","size":1204,"sha256":"146ee4b7507e7488796e422c1d26838c33ac3d5f5ec79ab10a690cd768b53ade","contentType":"application/x-sh; charset=utf-8"},{"id":"ccfe6116-1630-5461-9d66-cfcce2901183","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ccfe6116-1630-5461-9d66-cfcce2901183/attachment.sh","path":"test-install.sh","size":59722,"sha256":"756e49c1a659315e0abb04b0cae613a5c0016a27d88de7bc905e894b023deac4","contentType":"application/x-sh; charset=utf-8"},{"id":"6c950b9a-991c-565f-aaf4-57fc4eba55fb","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6c950b9a-991c-565f-aaf4-57fc4eba55fb/attachment.js","path":"test-sse-consumer.js","size":2871,"sha256":"5e77e90664a9a493c38fb670f88d9a1e428e2ef9538bee4aebc3fb5680a8bae6","contentType":"application/javascript; charset=utf-8"},{"id":"215830c2-7e8d-5d90-9918-f4e1c714f34e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/215830c2-7e8d-5d90-9918-f4e1c714f34e/attachment.json","path":"tsconfig.json","size":544,"sha256":"0fe0caed34e5daf2ec9cc5f2566f18135f35cd06c15d2ee41606059efb4cfc1b","contentType":"application/json; charset=utf-8"}],"bundle_sha256":"041b217daf1d14f2df57f5173758d0e41f4529db87d950a3ec5e04d23d42d71d","attachment_count":14,"text_attachments":11,"attachment_storage":"skillopedia-attachments-v1","binary_attachments":3,"excluded_attachments":[]},"cluster_size":1,"skill_md_path":"openclaw/SKILL.md","import_metadata":{"date":"2026-06-05","author":"@skillopedia","version":"v1","category":"general","category_label":"General"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"general","import_tag":"clean-skills-v1"}},"renderedAt":1782981866737}

Claude-Mem OpenClaw Plugin — Setup Guide This guide walks through setting up the claude-mem plugin on an OpenClaw gateway. By the end, your agents will have persistent memory across sessions via system prompt context injection, and optionally a real-time observation feed streaming to a messaging channel. Quick Install (Recommended) Run this one-liner to install everything automatically: 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 wi…