#!/usr/bin/env bash # install.sh — one-command installer for @memtensor/memos-local-plugin. # # Usage: # bash install.sh # install latest from npm # bash install.sh --version 2.0.0 # install specific npm version # bash install.sh --version ./pkg.tgz # use a local tarball # # Interactive: with a TTY we ask where to install (OpenClaw / Hermes / # both). Press ENTER for auto-detect. Non-TTY falls straight to # auto-detect. macOS + Linux only. # # Design notes: # - Each agent runs its OWN viewer on its OWN well-known port: # openclaw → :18799 # hermes → :18800 # Ports are intentionally fixed and not configurable by the # installer — having two agents share one port (the previous # "hub/peer" model) caused too many sharp edges (read-only # panels, dropped writes, mid-session ownership flips). Picking # a port at install time would also raise the question of # "which agent does this port belong to?" — we'd rather not # have that conversation. # - Each agent keeps its own SQLite DB under `~/./memos-plugin/`. # There is no cross-agent memory in one UI; if both are installed # the root path on either viewer shows a small picker that links # to the other agent's port. # - All install logic is self-contained: Node bootstrap, tarball # resolution, better-sqlite3 rebuild, config patching, gateway # restart, viewer-readiness wait. No separate sub-scripts. set -euo pipefail # ─── Colors ──────────────────────────────────────────────────────────────── GREEN='\033[0;32m' BLUE='\033[0;34m' CYAN='\033[0;36m' YELLOW='\033[1;33m' RED='\033[0;31m' BOLD='\033[1m' DIM='\033[2m' NC='\033[0m' info() { printf " ${BLUE}›${NC} %b\n" "$*"; } success() { printf " ${GREEN}✔${NC} %b\n" "$*"; } warn() { printf " ${YELLOW}⚠${NC} %b\n" "$*" >&2; } error() { printf " ${RED}✘${NC} %b\n" "$*" >&2; } die() { error "$*"; exit 1; } header() { local text="$*" local pad_total=$((46 - ${#text})) (( pad_total < 0 )) && pad_total=0 local padding="" local i; for ((i=0; i/dev/null && pwd || pwd)" PLUGIN_ID="memos-local-plugin" NPM_PACKAGE="@memtensor/memos-local-plugin" # Per-agent viewer ports are fixed (see header design notes). OPENCLAW_PORT="18799" HERMES_PORT="18800" REQUIRED_NODE_MAJOR=20 OPENCLAW_RUNTIME_ENTRY="./dist/adapters/openclaw/index.js" # Older plugin IDs disabled on install so they don't fight for the # memory slot. We never touch the old plugin's data. LEGACY_PLUGIN_IDS=("memos-local-openclaw-plugin") # ─── Args ───────────────────────────────────────────────────────────────── VERSION_ARG="" AGENT_SELECTION="" while [[ $# -gt 0 ]]; do case "$1" in --version) VERSION_ARG="${2:-}"; shift 2 ;; --agent|--target) AGENT_SELECTION="${2:-}" case "${AGENT_SELECTION}" in auto|openclaw|hermes|all) ;; *) die "--agent must be one of: auto, openclaw, hermes, all" ;; esac shift 2 ;; --port) die "--port is no longer supported. Each agent uses a fixed port: \ openclaw → :${OPENCLAW_PORT}, hermes → :${HERMES_PORT}." ;; -h|--help) cat </dev/null 2>&1 || { echo "0"; return; } node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1 } download_to_file() { local url="$1" out="$2" if command -v curl >/dev/null 2>&1; then curl -fsSL "${url}" -o "${out}"; return $?; fi if command -v wget >/dev/null 2>&1; then wget -q "${url}" -O "${out}"; return $?; fi return 1 } run_with_privilege() { if [[ "$(id -u)" -eq 0 ]]; then "$@"; else sudo "$@"; fi } install_node_mac() { command -v brew >/dev/null 2>&1 || die "Homebrew required on macOS. Install https://brew.sh first." info "Installing Node.js 22 via Homebrew..." brew install node@22 >/dev/null brew link node@22 --overwrite --force >/dev/null 2>&1 || true local p; p="$(brew --prefix node@22 2>/dev/null || true)" [[ -n "${p}" && -x "${p}/bin/node" ]] && export PATH="${p}/bin:${PATH}" } install_node_linux() { local tmp installer url tmp="$(mktemp)" if command -v apt-get >/dev/null 2>&1; then installer="apt"; url="https://deb.nodesource.com/setup_22.x" elif command -v dnf >/dev/null 2>&1; then installer="dnf"; url="https://rpm.nodesource.com/setup_22.x" elif command -v yum >/dev/null 2>&1; then installer="yum"; url="https://rpm.nodesource.com/setup_22.x" else die "No supported package manager. Install Node.js ≥ ${REQUIRED_NODE_MAJOR} manually." fi info "Installing Node.js 22 via ${installer}..." download_to_file "${url}" "${tmp}" || die "Failed to download Node setup script." run_with_privilege bash "${tmp}" case "${installer}" in apt) run_with_privilege apt-get update -qq && run_with_privilege apt-get install -y -qq nodejs ;; dnf) run_with_privilege dnf install -y -q nodejs ;; yum) run_with_privilege yum install -y -q nodejs ;; esac rm -f "${tmp}" } ensure_node() { local current; current="$(node_major)" if ! [[ "${current}" =~ ^[0-9]+$ ]] || (( current < REQUIRED_NODE_MAJOR )); then warn "Node.js >= ${REQUIRED_NODE_MAJOR} required (have ${current}). Auto-installing..." case "${OS_NAME}" in Darwin) install_node_mac ;; Linux) install_node_linux ;; esac current="$(node_major)" [[ "${current}" =~ ^[0-9]+$ ]] && (( current >= REQUIRED_NODE_MAJOR )) \ || die "Node.js install failed. Install ≥ ${REQUIRED_NODE_MAJOR} and re-run." fi # Node 25+ has no better-sqlite3 prebuilts → must compile. Warn the # user (but don't block; the rebuild step below tries regardless). if (( current >= 25 )); then warn "Node $(node -v) — no better-sqlite3 prebuild available, will compile from source." printf " ${DIM}Tip: switch to Node LTS for prebuilt binaries: nvm install 22${NC}\n" >&2 fi success "Node.js $(node -v)" } # ─── Detect hosts ───────────────────────────────────────────────────────── HAS_OPENCLAW="false" HAS_HERMES="false" [[ -d "${HOME}/.openclaw" ]] && HAS_OPENCLAW="true" [[ -d "${HOME}/.hermes" ]] && HAS_HERMES="true" find_openclaw_cli() { command -v openclaw 2>/dev/null && return 0 [[ -x "${HOME}/.local/bin/openclaw" ]] && { echo "${HOME}/.local/bin/openclaw"; return 0; } return 1 } # ─── Interactive picker ─────────────────────────────────────────────────── pick_agents_interactively() { [[ -n "${AGENT_SELECTION}" ]] && return 0 echo printf " ${BOLD}Detected agents:${NC}\n" if [[ "${HAS_OPENCLAW}" == "true" ]]; then printf " ${GREEN}●${NC} OpenClaw ${DIM}~/.openclaw${NC}\n" else printf " ${DIM}○ OpenClaw (not installed)${NC}\n" fi if [[ "${HAS_HERMES}" == "true" ]]; then printf " ${GREEN}●${NC} Hermes ${DIM}~/.hermes${NC}\n" else printf " ${DIM}○ Hermes (not installed)${NC}\n" fi echo local choice if [[ ! -t 0 ]]; then info "Non-interactive mode — auto-detecting agents" choice="" else printf " ${BOLD}Install into which agent?${NC}\n\n" printf " ${DIM}[Enter]${NC} 🔍 Auto-detect\n" printf " ${DIM}[1]${NC} 🦞 OpenClaw only\n" printf " ${DIM}[2]${NC} 👩 Hermes only\n" printf " ${DIM}[3]${NC} 📦 Both\n" printf " ${DIM}[q]${NC} 🚪 Quit\n" echo printf " Choice: " read -r choice || choice="" fi case "${choice}" in "") AGENT_SELECTION="auto" ;; 1) AGENT_SELECTION="openclaw" ;; 2) AGENT_SELECTION="hermes" ;; 3) AGENT_SELECTION="all" ;; q|Q) info "Aborted."; exit 0 ;; *) die "Invalid choice: ${choice}" ;; esac } # ─── Resolve tarball ────────────────────────────────────────────────────── BUILT_TARBALL="" STAGE_DIR="" SOURCE_KIND="" # "path" for a local file, "npm" otherwise SOURCE_SPEC="" resolve_tarball() { STAGE_DIR="$(mktemp -d)" trap 'rm -rf "${STAGE_DIR}"' EXIT if [[ -n "${VERSION_ARG}" && -f "${VERSION_ARG}" ]]; then BUILT_TARBALL="$(cd "$(dirname "${VERSION_ARG}")" && pwd)/$(basename "${VERSION_ARG}")" SOURCE_KIND="path" SOURCE_SPEC="${BUILT_TARBALL}" success "Using local tarball: ${BUILT_TARBALL}" return 0 fi local spec if [[ -z "${VERSION_ARG}" ]]; then spec="${NPM_PACKAGE}" info "Downloading latest ${NPM_PACKAGE} from npm …" else spec="${NPM_PACKAGE}@${VERSION_ARG}" info "Downloading ${spec} from npm …" fi SOURCE_KIND="npm" SOURCE_SPEC="${spec}" (cd "${STAGE_DIR}" && npm pack "${spec}" --loglevel=error >/dev/null 2>&1) BUILT_TARBALL="$(ls "${STAGE_DIR}"/*.tgz 2>/dev/null | head -1)" [[ -n "${BUILT_TARBALL}" && -f "${BUILT_TARBALL}" ]] \ || die "npm pack failed for ${spec}. Check the npm registry or pass a local path via --version ./pkg.tgz" success "Package ready: $(basename "${BUILT_TARBALL}")" } # ─── Deploy tarball into a prefix + rebuild native deps ─────────────────── # # Hermes's layout puts the plugin source AND the runtime home in the same # directory (${HOME}/.hermes/memos-plugin/). That means data/memos.db, # config.yaml, logs/, skills/, daemon/, .auth.json all live next to the # source files the tarball ships. A naive `rm -rf ${prefix}` would wipe # the user's memory DB on every re-install. # # We mitigate that by preserving a well-known allowlist of user-data # artefacts across the rm/extract cycle. node_modules is preserved too # so npm install stays fast on re-install. deploy_tarball_to_prefix() { local prefix="$1" step "Deploying to ${prefix}" local saved_dir="" local preserve=(node_modules data logs skills daemon config.yaml .auth.json .memos-node-bin) if [[ -d "${prefix}" ]]; then saved_dir="$(mktemp -d)" local item for item in "${preserve[@]}"; do if [[ -e "${prefix}/${item}" ]]; then mkdir -p "$(dirname "${saved_dir}/${item}")" mv "${prefix}/${item}" "${saved_dir}/${item}" fi done rm -rf "${prefix}" mkdir -p "${prefix}" tar xzf "${BUILT_TARBALL}" -C "${prefix}" --strip-components=1 for item in "${preserve[@]}"; do if [[ -e "${saved_dir}/${item}" ]]; then rm -rf "${prefix}/${item}" mv "${saved_dir}/${item}" "${prefix}/${item}" fi done rm -rf "${saved_dir}" else mkdir -p "${prefix}" tar xzf "${BUILT_TARBALL}" -C "${prefix}" --strip-components=1 fi [[ -f "${prefix}/package.json" ]] || die "Extraction failed: ${prefix}/package.json missing" success "Package extracted" step "Installing npm dependencies" local node_bin node_dir node_version node_bin="$(command -v node || true)" [[ -n "${node_bin}" && -x "${node_bin}" ]] || die "Node.js not found after bootstrap." node_dir="$(dirname "${node_bin}")" node_version="$("${node_bin}" -v 2>/dev/null || echo "unknown")" printf "%s\n" "${node_bin}" > "${prefix}/.memos-node-bin" ( cd "${prefix}" && PATH="${node_dir}:${PATH}" MEMOS_SKIP_SETUP=1 npm install --omit=dev --no-fund --no-audit --loglevel=error >/dev/null 2>&1 ) [[ -d "${prefix}/node_modules" ]] || die "npm install failed in ${prefix}" if [[ -d "${prefix}/node_modules/better-sqlite3" ]]; then step "Rebuilding better-sqlite3 for Node ${node_version}" ( cd "${prefix}" && PATH="${node_dir}:${PATH}" npm rebuild better-sqlite3 --loglevel=error >/dev/null 2>&1 ) \ || ( cd "${prefix}" && PATH="${node_dir}:${PATH}" npm rebuild better-sqlite3 --build-from-source --loglevel=error >/dev/null 2>&1 ) \ || warn "better-sqlite3 rebuild did not complete cleanly." if ( cd "${prefix}" && "${node_bin}" -e "require('better-sqlite3')" >/dev/null 2>&1 ); then success "better-sqlite3 native module OK" else warn "better-sqlite3 not loadable — plugin will fail at startup." printf " ${DIM}Fix: cd ${prefix} && PATH=${node_dir}:\$PATH npm rebuild better-sqlite3${NC}\n" >&2 fi fi success "Dependencies ready" } # ─── Generate runtime config.yaml ───────────────────────────────────────── # The template ships with the right per-agent port baked in # (`templates/config.openclaw.yaml` → 18799, # `templates/config.hermes.yaml` → 18800), so we don't have to # rewrite `port:` here. Existing files are left untouched. ensure_runtime_home() { local agent="$1" home_dir="$2" prefix="$3" mkdir -p "${home_dir}/data" "${home_dir}/skills" "${home_dir}/logs" "${home_dir}/daemon" chmod 700 "${home_dir}" local template="${prefix}/templates/config.${agent}.yaml" [[ ! -f "${template}" ]] && template="${SCRIPT_DIR}/templates/config.${agent}.yaml" if [[ ! -f "${template}" ]]; then warn "Template missing: config.${agent}.yaml" return 0 fi local target="${home_dir}/config.yaml" if [[ -f "${target}" ]]; then success "config.yaml exists — kept as-is" else cp "${template}" "${target}" chmod 600 "${target}" success "Wrote ${target} from template" fi } # ─── Wait for viewer — spin until HTTP endpoint actually responds ───────── wait_for_viewer() { local port="$1" local url="http://127.0.0.1:${port}" local timeout="${2:-60}" local frames=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏") local idx=0 local elapsed=0 local spin_tick=0 while (( elapsed < timeout )); do if command -v curl >/dev/null 2>&1 && curl -fsS --max-time 1 "${url}/" >/dev/null 2>&1; then printf "\r\033[K" success "Memory Viewer is ready: ${CYAN}${url}${NC}" return 0 fi printf "\r ${BLUE}%s${NC} Starting Memory Viewer ${DIM}(%ds)${NC} …" "${frames[idx]}" "${elapsed}" idx=$(((idx + 1) % ${#frames[@]})) sleep 0.12 spin_tick=$((spin_tick + 1)) if (( spin_tick % 8 == 0 )); then elapsed=$((elapsed + 1)) fi done printf "\r\033[K" warn "Memory Viewer not ready after ${timeout}s" warn "Check: ${CYAN}${url}${NC} Logs: ~/.openclaw/logs/ or ~/.hermes/memos-plugin/logs/" return 1 } # ─── OpenClaw install ───────────────────────────────────────────────────── install_openclaw() { STEP_CURRENT=0 header "OpenClaw Install" local prefix="${HOME}/.openclaw/extensions/${PLUGIN_ID}" local home="${HOME}/.openclaw/memos-plugin" local config_path="${HOME}/.openclaw/openclaw.json" mkdir -p "${HOME}/.openclaw" local oc_bin="" if oc_bin="$(find_openclaw_cli)"; then step "Stopping OpenClaw gateway" "${oc_bin}" gateway stop >/dev/null 2>&1 || true sleep 1 success "Gateway stopped" fi deploy_tarball_to_prefix "${prefix}" local runtime_entry="${prefix}/${OPENCLAW_RUNTIME_ENTRY#./}" [[ -f "${runtime_entry}" ]] \ || die "OpenClaw runtime entry missing: ${OPENCLAW_RUNTIME_ENTRY}. Reinstall a package built with dist/ runtime output." step "Configuring runtime environment" ensure_runtime_home "openclaw" "${home}" "${prefix}" # 4. OpenClaw loads plugins via two artefacts: # (a) package.json::openclaw — cheap metadata (we ship it in tgz) # (b) openclaw.plugin.json — full manifest (id, kind, configSchema, # extensions) # (b) is generated here so the user never edits it by hand. local plugin_version plugin_version="$(node -p "require('${prefix}/package.json').version" 2>/dev/null || echo 'unknown')" cat > "${prefix}/openclaw.plugin.json" <=${REQUIRED_NODE_MAJOR}.0.0" }, "extensions": ["${OPENCLAW_RUNTIME_ENTRY}"], "contracts": { "tools": [ "memos_search", "memos_get", "memos_timeline", "memos_skill_list", "memos_environment", "memos_skill_get" ] }, "configSchema": { "type": "object", "additionalProperties": true, "description": "Edit ${home}/config.yaml to tune LLM / embedding / viewer.", "properties": { "viewerPort": { "type": "number", "description": "Memory Viewer HTTP port (default ${OPENCLAW_PORT})" } } } } EOF step "Patching ${config_path}" PLUGIN_ID="${PLUGIN_ID}" \ INSTALL_PATH="${prefix}" \ SOURCE_KIND="${SOURCE_KIND}" \ SOURCE_SPEC="${SOURCE_SPEC}" \ PLUGIN_VERSION="${plugin_version}" \ LEGACY_JSON="$(printf '%s,' "${LEGACY_PLUGIN_IDS[@]}")" \ CONFIG_PATH="${config_path}" \ node - <<'NODE' const fs = require('fs'); const { CONFIG_PATH: configPath, PLUGIN_ID: pluginId, INSTALL_PATH: installPath, SOURCE_KIND: sourceKind, SOURCE_SPEC: sourceSpec, PLUGIN_VERSION: pluginVersion, LEGACY_JSON: legacyCsv, } = process.env; const legacyIds = (legacyCsv || '').split(',').filter(Boolean); const MEMOS_TOOL_NAMES = [ 'memos_search', 'memos_get', 'memos_timeline', 'memos_environment', 'memos_skill_list', 'memos_skill_get', ]; let config = {}; if (fs.existsSync(configPath)) { const raw = fs.readFileSync(configPath, 'utf8').trim(); if (raw) { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) config = parsed; } } if (!config.gateway || typeof config.gateway !== 'object' || Array.isArray(config.gateway)) { config.gateway = {}; } if (!config.gateway.mode) config.gateway.mode = 'local'; if (!config.tools || typeof config.tools !== 'object' || Array.isArray(config.tools)) { config.tools = {}; } if (!Array.isArray(config.tools.alsoAllow)) config.tools.alsoAllow = []; for (const toolName of MEMOS_TOOL_NAMES) { if (!config.tools.alsoAllow.includes(toolName)) config.tools.alsoAllow.push(toolName); } if (!config.plugins || typeof config.plugins !== 'object' || Array.isArray(config.plugins)) { config.plugins = {}; } config.plugins.enabled = true; if (!Array.isArray(config.plugins.allow)) config.plugins.allow = []; if (!config.plugins.allow.includes(pluginId)) config.plugins.allow.push(pluginId); // Remove legacy plugins cleanly (OpenClaw schema rejects unknown keys, // so we can't just tag them as disabled). The plugin directory on disk // at ~/.openclaw/extensions// is left untouched; the user // can delete it themselves if desired. for (const legacyId of legacyIds) { if (config.plugins.entries?.[legacyId]) delete config.plugins.entries[legacyId]; if (config.plugins.installs?.[legacyId]) delete config.plugins.installs[legacyId]; if (Array.isArray(config.plugins.allow)) { config.plugins.allow = config.plugins.allow.filter((x) => x !== legacyId); } if (config.plugins.slots && typeof config.plugins.slots === 'object') { for (const [slot, v] of Object.entries(config.plugins.slots)) { if (v === legacyId) delete config.plugins.slots[slot]; } } } if (!config.plugins.slots || typeof config.plugins.slots !== 'object') config.plugins.slots = {}; config.plugins.slots.memory = pluginId; if (!config.plugins.entries || typeof config.plugins.entries !== 'object') config.plugins.entries = {}; if (!config.plugins.entries[pluginId] || typeof config.plugins.entries[pluginId] !== 'object') { config.plugins.entries[pluginId] = {}; } config.plugins.entries[pluginId].enabled = true; // OpenClaw blocks conversation-level typed hooks for non-bundled plugins // unless the user config explicitly grants access. The memory plugin needs // agent_end to capture completed turns. if ( !config.plugins.entries[pluginId].hooks || typeof config.plugins.entries[pluginId].hooks !== 'object' || Array.isArray(config.plugins.entries[pluginId].hooks) ) { config.plugins.entries[pluginId].hooks = {}; } config.plugins.entries[pluginId].hooks.allowConversationAccess = true; if (!config.plugins.installs || typeof config.plugins.installs !== 'object') config.plugins.installs = {}; const installsEntry = { source: sourceKind === 'path' ? 'path' : 'npm', installPath, version: pluginVersion, resolvedVersion: pluginVersion, installedAt: new Date().toISOString(), }; if (sourceKind !== 'path') { installsEntry.spec = sourceSpec; installsEntry.resolvedName = '@memtensor/memos-local-plugin'; installsEntry.resolvedSpec = sourceSpec; } config.plugins.installs[pluginId] = installsEntry; fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); NODE success "openclaw.json patched" if [[ -z "${oc_bin}" ]]; then warn "openclaw CLI not on PATH — restart manually: openclaw gateway start" return 1 fi step "Starting OpenClaw gateway" local start_out if ! start_out="$("${oc_bin}" gateway start 2>&1)"; then # launchd KeepAlive may have already restarted the service after # the stop above, making "gateway start" fail with a kickstart # conflict. Check if the gateway is actually running before # treating this as a real error. if curl -fsS --max-time 2 "http://127.0.0.1:18789" >/dev/null 2>&1 \ || (command -v lsof >/dev/null 2>&1 && lsof -i ":18789" -t >/dev/null 2>&1); then success "OpenClaw gateway already running" else error "openclaw gateway start failed:" echo "${start_out}" | sed 's/^/ /' >&2 warn "Inspect ~/.openclaw/logs/gateway.err.log for the full reason." return 1 fi else success "OpenClaw gateway started" fi step "Waiting for Memory Viewer" if wait_for_viewer "${OPENCLAW_PORT}"; then echo success "OpenClaw install complete" printf " ${DIM}Plugin:${NC} %s\n" "${HOME}/.openclaw/extensions/${PLUGIN_ID}" printf " ${DIM}Viewer:${NC} ${CYAN}http://127.0.0.1:${OPENCLAW_PORT}/${NC}\n" return 0 fi warn "Memory Viewer did not respond after service start; trying foreground gateway mode." nohup "${oc_bin}" gateway >/tmp/openclaw-memos-gateway.log 2>&1 & sleep 2 if wait_for_viewer "${OPENCLAW_PORT}"; then echo success "OpenClaw install complete" printf " ${DIM}Plugin:${NC} %s\n" "${HOME}/.openclaw/extensions/${PLUGIN_ID}" printf " ${DIM}Viewer:${NC} ${CYAN}http://127.0.0.1:${OPENCLAW_PORT}/${NC}\n" return 0 fi warn "Memory Viewer did not respond within 60s." printf " ${DIM}Check: /tmp/openclaw-memos-gateway.log or /tmp/openclaw/openclaw-*.log${NC}\n" >&2 return 1 } # ─── Hermes install ─────────────────────────────────────────────────────── install_hermes() { STEP_CURRENT=0 header "Hermes Install" local prefix="${HOME}/.hermes/memos-plugin" local home="${prefix}" local config_file="${HOME}/.hermes/config.yaml" local adapter_dir="${prefix}/adapters/hermes" mkdir -p "${HOME}/.hermes" step "Stopping existing bridge daemon" local bridge_pids="" bridge_pids="$(pgrep -f "bridge\\.(cts|cjs)" 2>/dev/null || true)" if [[ -n "${bridge_pids}" ]]; then kill ${bridge_pids} >/dev/null 2>&1 || true local i for i in {1..10}; do sleep 1 pgrep -f "bridge\\.(cts|cjs)" >/dev/null 2>&1 || break done bridge_pids="$(pgrep -f "bridge\\.(cts|cjs)" 2>/dev/null || true)" if [[ -n "${bridge_pids}" ]]; then kill -9 ${bridge_pids} >/dev/null 2>&1 || true sleep 1 fi fi success "Bridge daemon stopped" local was_running="false" if pgrep -f "/bin/hermes" >/dev/null 2>&1; then step "Stopping running Hermes process" pkill -f "/bin/hermes" >/dev/null 2>&1 || true sleep 2 pgrep -f "/bin/hermes" >/dev/null 2>&1 && pkill -9 -f "/bin/hermes" >/dev/null 2>&1 || true was_running="true" success "Hermes stopped" fi # Free Hermes' viewer port if something (e.g. a stale bridge from # a prior install, or the OpenClaw gateway reload) left it occupied. if command -v lsof >/dev/null 2>&1; then local stale_pid stale_pid="$(lsof -i ":${HERMES_PORT}" -t 2>/dev/null || true)" if [[ -n "${stale_pid}" ]]; then kill ${stale_pid} >/dev/null 2>&1 || true sleep 1 fi fi deploy_tarball_to_prefix "${prefix}" step "Configuring runtime environment" ensure_runtime_home "hermes" "${home}" "${prefix}" local bridge_entry="${prefix}/dist/bridge.cjs" [[ -f "${bridge_entry}" ]] || bridge_entry="${prefix}/bridge.cts" echo "${bridge_entry}" > "${adapter_dir}/bridge_path.txt" success "Bridge path recorded" step "Locating Hermes Python environment" local python_bin="" if command -v hermes >/dev/null 2>&1; then local shebang; shebang="$(head -1 "$(command -v hermes)" 2>/dev/null || true)" [[ "${shebang}" == "#!"*python* ]] && python_bin="$(echo "${shebang}" | sed 's/^#!\s*//')" fi if [[ -z "${python_bin}" || ! -x "${python_bin}" ]] \ && [[ -x "${HOME}/.hermes/hermes-agent/venv/bin/python3" ]]; then python_bin="${HOME}/.hermes/hermes-agent/venv/bin/python3" fi [[ -z "${python_bin}" || ! -x "${python_bin}" ]] && python_bin="$(command -v python3 || true)" [[ -n "${python_bin}" && -x "${python_bin}" ]] || die "Cannot locate Python for Hermes." success "Python: ${python_bin}" local plugin_dir="" plugin_dir="$("${python_bin}" -c " from pathlib import Path try: import plugins.memory as pm print(Path(pm.__file__).parent) except Exception: pass " 2>/dev/null || true)" if [[ -z "${plugin_dir}" || ! -d "${plugin_dir}" ]]; then for d in "${HOME}/.hermes/hermes-agent/plugins/memory"; do [[ -d "${d}" && -f "${d}/__init__.py" ]] && { plugin_dir="${d}"; break; } done fi [[ -n "${plugin_dir}" && -d "${plugin_dir}" ]] || die "plugins/memory not found" success "plugins/memory: ${plugin_dir}" step "Linking memtensor provider" local target="${plugin_dir}/memtensor" if [[ -L "${target}" ]]; then rm "${target}" elif [[ -e "${target}" ]]; then rm -rf "${target}" fi ln -s "${adapter_dir}/memos_provider" "${target}" cp "${adapter_dir}/plugin.yaml" "${adapter_dir}/memos_provider/plugin.yaml" 2>/dev/null || true success "Symlinked → ${target}" step "Verifying provider & patching config" local verify verify="$("${python_bin}" -c " from plugins.memory import load_memory_provider p = load_memory_provider('memtensor') print('OK' if p and p.name == 'memtensor' else 'FAIL') " 2>/dev/null || true)" [[ "${verify}" == "OK" ]] && success "Provider verification passed" \ || warn "Provider verification didn't return OK" step "Installing Hermes profile defaults hook" "${python_bin}" - <<'PYEOF' || warn "Hermes profile defaults hook install failed" import site from pathlib import Path site_dirs = site.getsitepackages() if not site_dirs: raise SystemExit("no site-packages directory found") site_dir = Path(site_dirs[0]) site_dir.mkdir(parents=True, exist_ok=True) module_path = site_dir / "memos_hermes_profile_defaults.py" module_path.write_text( r''' """MemOS profile defaults for Hermes. This module is imported from a .pth file in the Hermes Python environment. It wraps hermes_cli.profiles.create_profile so profiles created after the MemOS plugin is installed inherit the memtensor memory provider even when the user runs bare `hermes profile create ` without --clone. """ from __future__ import annotations import importlib import importlib.abc import importlib.machinery import sys from pathlib import Path from typing import Any try: import yaml except Exception: # pragma: no cover yaml = None # type: ignore[assignment] def _patch_config(profile_dir: Any) -> None: if yaml is None: return path = Path(profile_dir) / "config.yaml" if path.exists(): with path.open() as f: cfg = yaml.safe_load(f) or {} else: cfg = {} if not isinstance(cfg, dict): cfg = {} mem = cfg.get("memory") if not isinstance(mem, dict): mem = {} cfg["memory"] = mem mem["provider"] = "memtensor" mem.setdefault("memory_enabled", True) mem.setdefault("user_profile_enabled", True) plugins = cfg.get("plugins") if not isinstance(plugins, dict): plugins = {} cfg["plugins"] = plugins enabled = plugins.get("enabled") if enabled is True: enabled = ["memtensor"] elif isinstance(enabled, list): enabled = [item for item in enabled if item != "memtensor"] enabled.append("memtensor") else: enabled = ["memtensor"] plugins["enabled"] = enabled disabled = plugins.get("disabled") if isinstance(disabled, list): plugins["disabled"] = [item for item in disabled if item != "memtensor"] path.parent.mkdir(parents=True, exist_ok=True) with path.open("w") as f: yaml.dump(cfg, f, default_flow_style=False, allow_unicode=True, sort_keys=False) def _wrap_profiles_module(module: Any) -> None: if getattr(module, "_memos_profile_defaults_wrapped", False): return original = getattr(module, "create_profile", None) if not callable(original): return def create_profile(*args: Any, **kwargs: Any) -> Any: profile_dir = original(*args, **kwargs) try: _patch_config(profile_dir) except Exception: pass return profile_dir module.create_profile = create_profile module._memos_profile_defaults_wrapped = True class _ProfilesImportHook(importlib.abc.MetaPathFinder): _target = "hermes_cli.profiles" def find_spec(self, fullname: str, path: Any = None, target: Any = None) -> Any: if fullname != self._target: return None for finder in sys.meta_path: if finder is self: continue spec = finder.find_spec(fullname, path, target) if hasattr(finder, "find_spec") else None if spec and spec.loader: spec.loader = _ProfilesLoader(spec.loader) return spec return None class _ProfilesLoader(importlib.abc.Loader): def __init__(self, loader: Any) -> None: self.loader = loader def create_module(self, spec: Any) -> Any: if hasattr(self.loader, "create_module"): return self.loader.create_module(spec) return None def exec_module(self, module: Any) -> None: self.loader.exec_module(module) _wrap_profiles_module(module) existing = sys.modules.get("hermes_cli.profiles") if existing is not None: _wrap_profiles_module(existing) elif not any(isinstance(finder, _ProfilesImportHook) for finder in sys.meta_path): sys.meta_path.insert(0, _ProfilesImportHook()) '''.lstrip(), encoding="utf-8", ) pth_path = site_dir / "memos_hermes_profile_defaults.pth" pth_path.write_text("import memos_hermes_profile_defaults\n", encoding="utf-8") print(module_path) print(pth_path) PYEOF success "Hermes profile defaults hook installed" if [[ -f "${config_file}" ]]; then local patched_configs patched_configs="$("${python_bin}" - "${HOME}/.hermes" 2>/dev/null <<'PYEOF' import sys from pathlib import Path import yaml hermes_home = Path(sys.argv[1]) paths = [hermes_home / "config.yaml"] profiles_dir = hermes_home / "profiles" if profiles_dir.is_dir(): paths.extend(sorted(profiles_dir.glob("*/config.yaml"))) patched: list[str] = [] for path in paths: if not path.is_file(): continue with path.open() as f: cfg = yaml.safe_load(f) or {} if not isinstance(cfg, dict): cfg = {} mem = cfg.get("memory") if not isinstance(mem, dict): mem = {} cfg["memory"] = mem mem["provider"] = "memtensor" mem.setdefault("memory_enabled", True) mem.setdefault("user_profile_enabled", True) plugins = cfg.get("plugins") if not isinstance(plugins, dict): plugins = {} cfg["plugins"] = plugins enabled = plugins.get("enabled") if enabled is True: enabled = ["memtensor"] elif isinstance(enabled, list): enabled = [item for item in enabled if item != "memtensor"] enabled.append("memtensor") else: enabled = ["memtensor"] plugins["enabled"] = enabled disabled = plugins.get("disabled") if isinstance(disabled, list): plugins["disabled"] = [item for item in disabled if item != "memtensor"] with path.open("w") as f: yaml.dump(cfg, f, default_flow_style=False, allow_unicode=True, sort_keys=False) patched.append(str(path)) print("\n".join(patched)) PYEOF )" || warn "Hermes config auto-patch failed" if [[ -n "${patched_configs}" ]]; then success "Hermes configs patched:" while IFS= read -r patched_config; do [[ -n "${patched_config}" ]] && printf " ${DIM}%s${NC}\n" "${patched_config}" done <<< "${patched_configs}" fi else cat > "${config_file}" <<'CFGEOF' memory: memory_enabled: true user_profile_enabled: true provider: memtensor plugins: enabled: - memtensor CFGEOF success "Created ${config_file}" fi # Smoke test — boot the bridge briefly and confirm the viewer # actually answers on Hermes' fixed port. if command -v lsof >/dev/null 2>&1 && lsof -i ":${HERMES_PORT}" -t >/dev/null 2>&1; then warn "Port :${HERMES_PORT} already in use — skipping smoke test." else step "Starting Memory Viewer daemon" local node_bin node_bin="$(cat "${prefix}/.memos-node-bin" 2>/dev/null || command -v node || true)" local tsx_bin="${prefix}/node_modules/tsx/dist/cli.mjs" local bridge_cts="${prefix}/bridge.cts" local bridge_cjs="${prefix}/dist/bridge.cjs" local bridge_entry="${bridge_cjs}" [[ -f "${bridge_entry}" ]] || bridge_entry="${bridge_cts}" if [[ -n "${node_bin}" && -x "${node_bin}" && -f "${bridge_entry}" && ( "${bridge_entry}" == *.cjs || -f "${tsx_bin}" ) ]]; then local daemon_log="${prefix}/logs/daemon-start.log" mkdir -p "${prefix}/logs" # Launch bridge in --daemon mode (pure HTTP, no stdio). # The process stays alive to serve the Memory Viewer. if [[ "${bridge_entry}" == *.cjs ]]; then ( cd "${prefix}" && nohup "${node_bin}" "${bridge_entry}" --agent=hermes --daemon >"${daemon_log}" 2>&1 & ) else ( cd "${prefix}" && nohup "${node_bin}" "${tsx_bin}" "${bridge_entry}" --agent=hermes --daemon >"${daemon_log}" 2>&1 & ) fi if wait_for_viewer "${HERMES_PORT}" 120; then success "Memory Viewer daemon running" else error "Memory Viewer did not respond within 120s." warn "Re-install dependencies and re-run: cd ${prefix} && npm install" return 1 fi else warn "node or bridge runtime not found — skipping daemon start." fi fi echo success "Hermes install complete" printf " ${DIM}Plugin:${NC} %s\n" "${prefix}" printf " ${DIM}Viewer:${NC} ${CYAN}http://127.0.0.1:${HERMES_PORT}/${NC}\n" if [[ "${was_running}" == "true" ]]; then printf " ${DIM}Next:${NC} ${BOLD}hermes chat${NC} ${DIM}(was stopped — relaunch to apply)${NC}\n" else printf " ${DIM}Next:${NC} ${BOLD}hermes chat${NC}\n" fi return 0 } # ─── Main ───────────────────────────────────────────────────────────────── banner pick_agents_interactively if [[ "${AGENT_SELECTION}" == "auto" ]]; then if [[ "${HAS_OPENCLAW}" != "true" && "${HAS_HERMES}" != "true" ]]; then die "Neither ~/.openclaw nor ~/.hermes exists. Install OpenClaw or Hermes first." fi if [[ "${HAS_OPENCLAW}" == "true" && "${HAS_HERMES}" == "true" ]]; then AGENT_SELECTION="all" elif [[ "${HAS_OPENCLAW}" == "true" ]]; then AGENT_SELECTION="openclaw" else AGENT_SELECTION="hermes" fi success "Auto-detected: ${AGENT_SELECTION}" fi case "${AGENT_SELECTION}" in openclaw) [[ "${HAS_OPENCLAW}" == "true" ]] || warn "~/.openclaw missing — will create." ;; hermes) [[ "${HAS_HERMES}" == "true" ]] || die "~/.hermes missing — install Hermes first." ;; all) ;; *) die "Invalid selection: ${AGENT_SELECTION}" ;; esac ensure_node resolve_tarball STATUS=0 case "${AGENT_SELECTION}" in openclaw) install_openclaw || STATUS=1 ;; hermes) install_hermes || STATUS=1 ;; all) if [[ "${HAS_OPENCLAW}" == "true" ]]; then install_openclaw || STATUS=1; else warn "Skipping OpenClaw (~/.openclaw not found)"; fi if [[ "${HAS_HERMES}" == "true" ]]; then install_hermes || STATUS=1; else warn "Skipping Hermes (~/.hermes not found)"; fi ;; esac echo if (( STATUS == 0 )); then echo printf " ${BOLD}${GREEN}┌──────────────────────────────────────────────────┐${NC}\n" printf " ${BOLD}${GREEN}│${NC} ${BOLD}${GREEN}│${NC}\n" printf " ${BOLD}${GREEN}│${NC} ✨ ${BOLD}${GREEN}MemOS Local installed successfully${NC} ${BOLD}${GREEN}│${NC}\n" printf " ${BOLD}${GREEN}│${NC} ${BOLD}${GREEN}│${NC}\n" printf " ${BOLD}${GREEN}└──────────────────────────────────────────────────┘${NC}\n" echo case "${AGENT_SELECTION}" in openclaw) printf " ${BOLD}Quick links:${NC}\n" printf " ${GREEN}●${NC} Memory Viewer ${CYAN}http://127.0.0.1:${OPENCLAW_PORT}${NC} ${DIM}(openclaw)${NC}\n" printf " ${GREEN}●${NC} OpenClaw Web UI ${CYAN}http://localhost:18789${NC}\n" ;; hermes) printf " ${BOLD}Quick links:${NC}\n" printf " ${GREEN}●${NC} Memory Viewer ${CYAN}http://127.0.0.1:${HERMES_PORT}${NC} ${DIM}(hermes)${NC}\n" ;; all) printf " ${BOLD}Quick links:${NC}\n" printf " ${GREEN}●${NC} Memory Viewer ${CYAN}http://127.0.0.1:${OPENCLAW_PORT}${NC} ${DIM}(openclaw)${NC}\n" printf " ${GREEN}●${NC} Memory Viewer ${CYAN}http://127.0.0.1:${HERMES_PORT}${NC} ${DIM}(hermes)${NC}\n" printf " ${GREEN}●${NC} OpenClaw Web UI ${CYAN}http://localhost:18789${NC}\n" ;; esac echo printf " ${DIM}Docs: https://github.com/MemTensor/MemOS${NC}\n" echo exit 0 else echo printf " ${BOLD}${RED}┌──────────────────────────────────────────────────┐${NC}\n" printf " ${BOLD}${RED}│${NC} ${BOLD}${RED}│${NC}\n" printf " ${BOLD}${RED}│${NC} ${RED}Install finished with errors - see above${NC} ${BOLD}${RED}│${NC}\n" printf " ${BOLD}${RED}│${NC} ${BOLD}${RED}│${NC}\n" printf " ${BOLD}${RED}└──────────────────────────────────────────────────┘${NC}\n" echo exit 1 fi