memos/apps/memos-local-plugin/install.sh

1099 lines
42 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 `~/.<agent>/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<pad_total; i++)); do padding+=" "; done
echo
printf " ${BOLD}${BLUE}┌──────────────────────────────────────────────────┐${NC}\n"
printf " ${BOLD}${BLUE}${NC} ${BOLD}%s${NC}%s ${BOLD}${BLUE}${NC}\n" "${text}" "${padding}"
printf " ${BOLD}${BLUE}└──────────────────────────────────────────────────┘${NC}\n"
echo
}
STEP_CURRENT=0
step() {
STEP_CURRENT=$((STEP_CURRENT + 1))
printf " ${BOLD}${CYAN}[%d]${NC} %s\n" "${STEP_CURRENT}" "$*"
}
banner() {
local ver="${VERSION_ARG:-latest}"
echo
printf " ${BOLD}${BLUE}┌──────────────────────────────────────────────────┐${NC}\n"
printf " ${BOLD}${BLUE}${NC} ${BOLD}${BLUE}${NC}\n"
printf " ${BOLD}${BLUE}${NC} 🧠 ${BOLD}MemOS Local Plugin Installer${NC} ${BOLD}${BLUE}${NC}\n"
printf " ${BOLD}${BLUE}${NC} ${BOLD}${BLUE}${NC}\n"
printf " ${BOLD}${BLUE}└──────────────────────────────────────────────────┘${NC}\n"
printf " ${DIM}Package: ${NPM_PACKAGE} · Version: ${ver}${NC}\n"
echo
}
# ─── Constants ─────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/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 <<EOF
Usage:
bash install.sh # latest from npm
bash install.sh --version X.Y.Z # specific npm version
bash install.sh --version ./pkg.tgz # local tarball
bash install.sh --agent hermes # install one target
bash install.sh --agent openclaw|hermes|all
Each agent runs its viewer on a fixed port:
openclaw → http://127.0.0.1:${OPENCLAW_PORT}
hermes → http://127.0.0.1:${HERMES_PORT}
EOF
exit 0
;;
*) die "Unknown argument: $1 (only --version is supported)" ;;
esac
done
# ─── Platform ─────────────────────────────────────────────────────────────
OS_NAME="$(uname -s)"
case "${OS_NAME}" in
Darwin|Linux) ;;
*) die "Unsupported platform: ${OS_NAME}. macOS and Linux only." ;;
esac
# ─── Node bootstrap ───────────────────────────────────────────────────────
node_major() {
command -v node >/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" <<EOF
{
"id": "${PLUGIN_ID}",
"name": "MemOS Local Memory (V7)",
"description": "Reflect2Evolve V7 memory — L1/L2/L3 + skill crystallization + tier 1/2/3 retrieval + decision repair.",
"kind": "memory",
"version": "${plugin_version}",
"homepage": "https://github.com/MemTensor/MemOS",
"requirements": { "node": ">=${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/<legacy-id>/ 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 <name>` 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