diff --git a/apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py b/apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py index 79b88cbf4..e2389e498 100644 --- a/apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py +++ b/apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py @@ -71,6 +71,7 @@ ensure_viewer_daemon, kill_zombie_bridges, ) +from runtime_home import resolve_runtime_home # noqa: E402 from shared_bridge_runtime import ( # noqa: E402 HERMES_HOOK_DISPATCHER, SHARED_BRIDGE_REGISTRY, @@ -123,13 +124,7 @@ def _shared_bridge_enabled() -> bool: def _resolved_memos_runtime_home() -> Path: """Mirror the Node resolver closely enough to isolate distinct databases.""" - memos_home = os.environ.get("MEMOS_HOME", "").strip() - if memos_home: - return Path(memos_home).expanduser().resolve() - config_file = os.environ.get("MEMOS_CONFIG_FILE", "").strip() - if config_file: - return Path(config_file).expanduser().resolve().parent - return (Path.home() / ".hermes" / "memos-plugin").resolve() + return resolve_runtime_home(plugin_root=_PLUGIN_DIR.parents[2]) def _memos_runtime_env_snapshot(runtime_home: Path | None = None) -> dict[str, str]: @@ -144,11 +139,8 @@ def _memos_runtime_env_snapshot(runtime_home: Path | None = None) -> dict[str, s "MEMOS_HOME": "", "MEMOS_CONFIG_FILE": str(Path(config_file).expanduser().resolve()), } - return { - "MEMOS_HOME": "", - "MEMOS_CONFIG_FILE": "", - "HOME": os.environ.get("HOME", "").strip() or str(Path.home()), - } + resolved_home = runtime_home or _resolved_memos_runtime_home() + return {"MEMOS_HOME": str(resolved_home)} def _shared_bridge_runtime_key(runtime_home: Path | None = None) -> tuple[str, ...]: @@ -157,7 +149,11 @@ def _shared_bridge_runtime_key(runtime_home: Path | None = None) -> tuple[str, . return (str(resolved_home), "hermes", "stdio") -def _prepare_shared_bridge(*, cleanup_legacy_zombies: bool = False) -> None: +def _prepare_shared_bridge( + runtime_home: Path | None = None, + *, + cleanup_legacy_zombies: bool = False, +) -> None: """Prepare bridge/viewer state without crossing data-home boundaries.""" ensure_bridge_running() if cleanup_legacy_zombies: @@ -169,7 +165,7 @@ def _prepare_shared_bridge(*, cleanup_legacy_zombies: bool = False) -> None: if zombies: logger.info("MemOS: killed %d zombie bridge(s)", zombies) try: - ensure_viewer_daemon() + ensure_viewer_daemon(runtime_home=runtime_home) except Exception as err: logger.warning("MemOS: viewer daemon check failed — %s", err) @@ -520,13 +516,13 @@ def initialize(self, session_id: str, **kwargs: Any) -> None: # type: ignore[ov extra_env=env, ) ), - before_spawn=_prepare_shared_bridge, + before_spawn=lambda home=runtime_home: _prepare_shared_bridge(home), host_handlers={ "host.llm.complete": self._handle_host_llm_complete, }, ) else: - _prepare_shared_bridge(cleanup_legacy_zombies=True) + _prepare_shared_bridge(runtime_home, cleanup_legacy_zombies=True) new_bridge = MemosBridgeClient( runtime_home=str(runtime_home), extra_env=runtime_env, @@ -1687,8 +1683,8 @@ def get_config_schema(self) -> list[dict[str, Any]]: # type: ignore[override] return [ { "key": "viewer_port", - "description": "Local HTTP port for the MemOS viewer.", - "default": 18910, + "description": "Fixed local HTTP port for the MemOS Hermes viewer.", + "default": 18800, "required": False, }, { @@ -1727,13 +1723,21 @@ def save_config(self, values: dict[str, Any], hermes_home: str) -> None: # type return import yaml # lazy import — hermes already ships pyyaml - target_dir = Path(hermes_home) / "memos-plugin" + if self._runtime_home is not None: + target_dir = self._runtime_home + elif os.name == "nt": + target_dir = resolve_runtime_home(plugin_root=_PLUGIN_DIR.parents[2]) + else: + target_dir = Path(hermes_home) / "memos-plugin" target_dir.mkdir(parents=True, exist_ok=True) target = target_dir / "config.yaml" payload: dict[str, Any] = {"version": 1} if "viewer_port" in values: - payload["viewer"] = {"port": int(values["viewer_port"])} + # Keep the legacy setup field for host compatibility, but the + # Hermes adapter owns :18800. Persist the effective value so the + # YAML file never advertises a port the runtime will not bind. + payload["viewer"] = {"port": 18800} if "llm_provider" in values: llm: dict[str, Any] = {"provider": values["llm_provider"]} if values.get("llm_provider") != "local_only": @@ -2193,7 +2197,7 @@ def _reconnect_bridge(self, session_id: str = "", *, timeout: float = 30.0) -> N extra_env=env, ) ), - before_spawn=_prepare_shared_bridge, + before_spawn=lambda home=runtime_home: _prepare_shared_bridge(home), host_handlers={ "host.llm.complete": self._handle_host_llm_complete, }, @@ -2248,7 +2252,8 @@ def _reconnect_bridge(self, session_id: str = "", *, timeout: float = 30.0) -> N old_bridge.close() logger.info("MemOS: old bridge closed (pid=%s)", old_pid) - _prepare_shared_bridge(cleanup_legacy_zombies=True) + runtime_home = self._runtime_home or _resolved_memos_runtime_home() + _prepare_shared_bridge(runtime_home, cleanup_legacy_zombies=True) new_bridge: MemosBridgeClient | None = None try: runtime_home = self._runtime_home or _resolved_memos_runtime_home() diff --git a/apps/memos-local-plugin/adapters/hermes/memos_provider/bridge_client.py b/apps/memos-local-plugin/adapters/hermes/memos_provider/bridge_client.py index cbb2fa84c..e137729ed 100644 --- a/apps/memos-local-plugin/adapters/hermes/memos_provider/bridge_client.py +++ b/apps/memos-local-plugin/adapters/hermes/memos_provider/bridge_client.py @@ -25,6 +25,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any +from runtime_home import resolve_runtime_home + if TYPE_CHECKING: from collections.abc import Callable @@ -66,7 +68,10 @@ def _resolved_runtime_home(agent: str, env: dict[str, str]) -> Path: config_file = env.get("MEMOS_CONFIG_FILE", "").strip() if config_file: return _expanded_path(config_file, env).parent - agent_home = ".hermes" if agent == "hermes" else f".{agent}" + if agent == "hermes": + plugin_root = Path(__file__).resolve().parent.parent.parent.parent + return resolve_runtime_home(env=env, plugin_root=plugin_root) + agent_home = f".{agent}" default_home = Path(env.get("HOME", "") or Path.home()) / agent_home / "memos-plugin" return _expanded_path(str(default_home), env) diff --git a/apps/memos-local-plugin/adapters/hermes/memos_provider/daemon_manager.py b/apps/memos-local-plugin/adapters/hermes/memos_provider/daemon_manager.py index 0666fde72..d7e6b6e8d 100644 --- a/apps/memos-local-plugin/adapters/hermes/memos_provider/daemon_manager.py +++ b/apps/memos-local-plugin/adapters/hermes/memos_provider/daemon_manager.py @@ -22,6 +22,7 @@ import re import shutil import signal +import socket import subprocess import threading import time @@ -29,6 +30,7 @@ import urllib.request from pathlib import Path +from typing import Literal logger = logging.getLogger(__name__) @@ -40,6 +42,16 @@ _viewer_last_probe_at = 0.0 _viewer_process: subprocess.Popen | None = None +ViewerProbeStatus = Literal["free", "running_memos", "occupied", "unknown"] +LoopbackProbeResult = dict | Literal["free", "occupied", "unknown"] + +# Viewer discovery is always a loopback operation. An explicit empty proxy +# handler makes that invariant independent of WinINet, macOS System +# Configuration, and HTTP(S)_PROXY/NO_PROXY environment settings. Do not use +# this opener for model-provider endpoints; those retain the user's proxy +# configuration. +_LOOPBACK_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) + HERMES_VIEWER_PORT = 18800 VIEWER_PROBE_TTL_SEC = 30.0 VIEWER_START_LOCK_TIMEOUT_SEC = 20.0 @@ -52,9 +64,12 @@ @contextlib.contextmanager -def _viewer_start_lock(timeout: float = VIEWER_START_LOCK_TIMEOUT_SEC): +def _viewer_start_lock( + runtime_home: Path | None = None, + timeout: float = VIEWER_START_LOCK_TIMEOUT_SEC, +): """Cross-process guard for the Hermes viewer daemon startup path.""" - lock_dir = _plugin_root() / "daemon" / "viewer-start.lock" + lock_dir = (runtime_home or _plugin_root()) / "daemon" / "viewer-start.lock" lock_dir.parent.mkdir(parents=True, exist_ok=True) deadline = time.time() + timeout acquired = False @@ -149,7 +164,7 @@ def _node_binary() -> str | None: ) -def _bridge_command(*, daemon: bool) -> list[str]: +def _bridge_command(*, daemon: bool, runtime_home: Path | None = None) -> list[str]: plugin_root = _plugin_root() node = _node_binary() if not node: @@ -158,6 +173,8 @@ def _bridge_command(*, daemon: bool) -> list[str]: script = str(script_path) tsx_cli = plugin_root / "node_modules" / "tsx" / "dist" / "cli.mjs" bridge_args = [script, "--agent=hermes"] + if runtime_home is not None: + bridge_args.append(f"--home={runtime_home.resolve()}") if daemon: bridge_args.append("--daemon") if script_path.suffix in (".mjs", ".cjs"): @@ -256,23 +273,23 @@ def ensure_bridge_running(*, probe_only: bool = False) -> bool: return False -def _probe_viewer() -> str: - """Classify the service currently listening on Hermes' viewer port.""" - ping_url = f"http://127.0.0.1:{HERMES_VIEWER_PORT}/api/v1/ping" - ping_status = _probe_json_url(ping_url) - if ping_status == "free": - return "free" - if isinstance(ping_status, dict) and ping_status.get("service") == "memos-local-plugin": - return "running_memos" +def _probe_viewer() -> ViewerProbeStatus: + """Classify the service currently listening on Hermes' viewer port. - # Backwards compatibility for already-running viewers installed before - # `/api/v1/ping` carried a service marker. + ``unknown`` is intentionally separate from ``occupied``: timeouts and + unexpected transport failures prove neither that the port is free nor + that another service owns it. Callers must handle both states + conservatively and avoid starting a competing daemon. + """ + # `/api/v1/health` predates the identity-bearing ping route, so probing it + # directly preserves compatibility with older Viewer processes while + # avoiding a second request and the race between two independent probes. health_url = f"http://127.0.0.1:{HERMES_VIEWER_PORT}/api/v1/health" health_status = _probe_json_url(health_url) - if health_status == "free": - return "free" - if not isinstance(health_status, dict): - return "blocked" + if isinstance(health_status, str): + if health_status == "unknown": + return _probe_loopback_port(HERMES_VIEWER_PORT) + return health_status if ( health_status.get("service") == "memos-local-plugin" and health_status.get("agent") == "hermes" @@ -280,46 +297,77 @@ def _probe_viewer() -> str: return "running_memos" if health_status.get("agent") == "hermes" and isinstance(health_status.get("version"), str): return "running_memos" - return "blocked" + return "occupied" + +def _probe_loopback_port(port: int) -> Literal["free", "occupied", "unknown"]: + """Confirm whether a loopback port can be bound after an HTTP timeout. -def _probe_json_url(url: str) -> dict | str: + Some Windows firewall/network configurations drop a connect attempt to an + unused loopback port instead of returning WSAECONNREFUSED (10061). A + successful bind is stronger evidence that the port is free. Expected + address-in-use errors prove occupancy; permission and other errors stay + unknown so callers still fail closed. + """ + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.bind(("127.0.0.1", port)) + except OSError as err: + error_codes = {getattr(err, "errno", None), getattr(err, "winerror", None)} + if error_codes & {48, 98, 10048}: + return "occupied" + return "unknown" + return "free" + + +def _probe_json_url(url: str) -> LoopbackProbeResult: + """Probe a loopback JSON endpoint without consulting any proxy settings.""" req = urllib.request.Request(url, headers={"Accept": "application/json"}) try: - with urllib.request.urlopen(req, timeout=1.5) as resp: + with _LOOPBACK_OPENER.open(req, timeout=1.5) as resp: content_type = resp.headers.get("content-type", "") raw = resp.read(8192) + except urllib.error.HTTPError: + # A complete HTTP response (including 401/403/404) proves something is + # listening. It does not prove that it is this MemOS Viewer. + return "occupied" except urllib.error.URLError as err: reason = getattr(err, "reason", None) - errno = getattr(reason, "errno", None) - if errno in {61, 111}: # macOS/Linux connection refused - return "free" - msg = str(err).lower() - if "connection refused" in msg or "failed to establish" in msg: + error_codes = { + getattr(reason, "errno", None), + getattr(reason, "winerror", None), + } + if isinstance(reason, ConnectionRefusedError) or error_codes & {61, 111, 10061}: return "free" - return "blocked" + return "unknown" except TimeoutError: - return "blocked" + return "unknown" except Exception: - return "blocked" + return "unknown" if "json" not in content_type.lower() and raw[:1] not in (b"{", b"["): - return "blocked" + return "occupied" try: import json - return json.loads(raw.decode("utf-8", errors="replace")) - except Exception: - return "blocked" + payload = json.loads(raw.decode("utf-8", errors="replace")) + return payload if isinstance(payload, dict) else "occupied" + except (json.JSONDecodeError, UnicodeError): + return "occupied" -def ensure_viewer_daemon(*, probe_only: bool = False) -> bool: +def ensure_viewer_daemon( + *, + probe_only: bool = False, + runtime_home: Path | None = None, +) -> bool: """Ensure the singleton Hermes Viewer daemon owns :18800. Returns True when the MemOS Hermes Viewer is already running or was - started. Returns False when the port is occupied by another service, Node - is unavailable, or the daemon did not become healthy quickly. This status - must not affect stdio memory capture. + started. Returns False when the port is occupied by another service, its + state cannot be determined safely, Node is unavailable, or the daemon did + not become healthy quickly. This status must not affect stdio memory + capture. """ global _viewer_last_probe_at, _viewer_process, _viewer_status with _lock: @@ -336,28 +384,45 @@ def ensure_viewer_daemon(*, probe_only: bool = False) -> bool: _viewer_last_probe_at = now if status == "running_memos": return True - if status == "blocked": + if status == "occupied": logger.warning( "MemOS: viewer port %d is occupied by a non-MemOS service; " "memory capture will continue without the web panel", HERMES_VIEWER_PORT, ) return False + if status == "unknown": + logger.warning( + "MemOS: unable to determine viewer port %d state safely; " + "memory capture will continue without the web panel", + HERMES_VIEWER_PORT, + ) + return False if probe_only: return False - with _viewer_start_lock() as lock_acquired: + lock_context = ( + _viewer_start_lock(runtime_home) if runtime_home is not None else _viewer_start_lock() + ) + with lock_context as lock_acquired: status = _probe_viewer() _viewer_status = status _viewer_last_probe_at = time.time() if status == "running_memos": return True - if status == "blocked": + if status == "occupied": logger.warning( "MemOS: viewer port %d is occupied by a non-MemOS service; " "memory capture will continue without the web panel", HERMES_VIEWER_PORT, ) return False + if status == "unknown": + logger.warning( + "MemOS: unable to determine viewer port %d state safely; " + "memory capture will continue without the web panel", + HERMES_VIEWER_PORT, + ) + return False if not lock_acquired: logger.warning( "MemOS: timed out waiting for viewer daemon startup lock; " @@ -368,19 +433,23 @@ def ensure_viewer_daemon(*, probe_only: bool = False) -> bool: return False plugin_root = _plugin_root() - logs_dir = plugin_root / "logs" + logs_dir = (runtime_home or plugin_root) / "logs" logs_dir.mkdir(parents=True, exist_ok=True) log_file = logs_dir / "daemon-start.log" try: log_handle = log_file.open("a", encoding="utf-8") _viewer_process = subprocess.Popen( - _bridge_command(daemon=True), + _bridge_command(daemon=True, runtime_home=runtime_home), cwd=str(plugin_root), stdout=log_handle, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, text=True, start_new_session=True, + env={ + **os.environ, + **({"MEMOS_HOME": str(runtime_home.resolve())} if runtime_home else {}), + }, ) log_handle.close() except Exception as err: @@ -406,7 +475,7 @@ def ensure_viewer_daemon(*, probe_only: bool = False) -> bool: if status == "running_memos": logger.info("MemOS: viewer daemon running on port %d", HERMES_VIEWER_PORT) return True - if status == "blocked": + if status == "occupied": logger.warning( "MemOS: viewer port %d became occupied by a non-MemOS service", HERMES_VIEWER_PORT, @@ -425,10 +494,11 @@ def shutdown_bridge() -> None: _bridge_ok_at = 0.0 -def probe_viewer_status() -> str: +def probe_viewer_status() -> ViewerProbeStatus: """Return the current viewer daemon status without side effects. - Returns one of: ``"running_memos"``, ``"free"``, ``"blocked"``. + Returns one of: ``"running_memos"``, ``"free"``, ``"occupied"``, + ``"unknown"``. This is a cheap, lock-free probe suitable for deciding whether to spawn a new stdio bridge or connect to the existing daemon over HTTP. """ diff --git a/apps/memos-local-plugin/adapters/hermes/memos_provider/runtime_home.py b/apps/memos-local-plugin/adapters/hermes/memos_provider/runtime_home.py new file mode 100644 index 000000000..a2a67275a --- /dev/null +++ b/apps/memos-local-plugin/adapters/hermes/memos_provider/runtime_home.py @@ -0,0 +1,139 @@ +"""Stable runtime-home selection for Hermes on Windows. + +The Windows installer lives under LocalAppData, but releases before this +resolver could still create the SQLite database under the legacy user-profile +home. Selection is deliberately non-destructive: old data stays in place and +only a small marker under the install root records which home owns it. +""" + +from __future__ import annotations + +import contextlib +import json +import os + +from pathlib import Path +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from collections.abc import Mapping + + +RUNTIME_HOME_MARKER = ".memos-runtime-home" + + +def resolve_runtime_home( + *, + env: Mapping[str, str] | None = None, + platform_name: str | None = None, + plugin_root: Path | None = None, + user_home: Path | None = None, + persist: bool = True, +) -> Path: + """Resolve one runtime home without copying or merging user data.""" + values = os.environ if env is None else env + selected = values.get("MEMOS_HOME", "").strip() + if selected: + return Path(selected).expanduser().resolve() + config_file = values.get("MEMOS_CONFIG_FILE", "").strip() + if config_file: + return Path(config_file).expanduser().resolve().parent + + platform_name = os.name if platform_name is None else platform_name + legacy_home = (user_home or Path.home()) / ".hermes" / "memos-plugin" + if platform_name != "nt": + return legacy_home.resolve() + + local_app_data = values.get("LOCALAPPDATA", "").strip() + install_root = ( + Path(local_app_data) / "hermes" / "memos-plugin" if local_app_data else plugin_root + ) + if install_root is None: + return legacy_home.resolve() + return select_windows_runtime_home( + legacy_home=legacy_home, + install_root=install_root, + persist=persist, + ) + + +def select_windows_runtime_home( + *, + legacy_home: Path, + install_root: Path, + persist: bool = True, +) -> Path: + """Apply legacy-data-first selection and persist the result atomically.""" + legacy_home = legacy_home.resolve() + install_root = install_root.resolve() + marker = install_root / RUNTIME_HOME_MARKER + marked = _read_marker(marker) + if marked is not None: + return marked + + legacy_db = (legacy_home / "data" / "memos.db").is_file() + canonical_db = (install_root / "data" / "memos.db").is_file() + if legacy_db and canonical_db: + raise RuntimeError( + "both Windows Hermes runtime homes contain a database; " + f"set MEMOS_HOME explicitly ({legacy_home} or {install_root})" + ) + + if legacy_db: + selected, source = legacy_home, "legacy-database" + elif canonical_db: + selected, source = install_root, "canonical-database" + elif _has_meaningful_data(legacy_home): + selected, source = legacy_home, "legacy-data" + else: + selected, source = install_root, "new-install" + + if persist: + _write_marker(marker, selected, source) + return selected + + +def _read_marker(marker: Path) -> Path | None: + try: + payload = json.loads(marker.read_text(encoding="utf-8")) + except (OSError, ValueError, TypeError): + return None + if not isinstance(payload, dict): + return None + value = payload.get("path") + if ( + payload.get("version") != 1 + or not isinstance(value, str) + or not value.strip() + or not Path(value).is_absolute() + ): + return None + return Path(value).resolve() + + +def _write_marker(marker: Path, selected: Path, source: str) -> None: + marker.parent.mkdir(parents=True, exist_ok=True) + temp = marker.with_name(f"{marker.name}.{os.getpid()}.tmp") + temp.write_text( + json.dumps( + {"version": 1, "path": str(selected), "source": source}, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + with contextlib.suppress(PermissionError): + temp.chmod(0o600) + temp.replace(marker) + + +def _has_meaningful_data(root: Path) -> bool: + if (root / "config.yaml").is_file() or (root / ".auth.json").is_file(): + return True + skills = root / "skills" + try: + return next(skills.iterdir(), None) is not None + except OSError: + return False diff --git a/apps/memos-local-plugin/adapters/hermes/plugin.yaml b/apps/memos-local-plugin/adapters/hermes/plugin.yaml index dd9517d18..27ae2ee62 100644 --- a/apps/memos-local-plugin/adapters/hermes/plugin.yaml +++ b/apps/memos-local-plugin/adapters/hermes/plugin.yaml @@ -1,5 +1,5 @@ name: memtensor -version: 2.0.12-beta.1 +version: 2.0.14-beta.1 description: >- MemOS Local — Reflect2Evolve V7 memory for hermes-agent. Layered L1/L2/L3 traces, reflection-weighted reward backprop, diff --git a/apps/memos-local-plugin/bridge.cts b/apps/memos-local-plugin/bridge.cts index acc1ccb29..132af85f7 100644 --- a/apps/memos-local-plugin/bridge.cts +++ b/apps/memos-local-plugin/bridge.cts @@ -17,8 +17,8 @@ * - openclaw → :18799 * - hermes → :18800 * - * The viewer port is read from the agent's `~/./memos-plugin/ - * config.yaml::viewer.port`. We just call `startHttpServer` once; + * The viewer port is adapter-owned (OpenClaw 18799, Hermes 18800). + * We just call `startHttpServer` once; * if the port is already in use we surface the EADDRINUSE error to * stderr and keep running stdio-RPC headless (capture / retrieval * still work). There's no port-sharing or auto-promotion logic — @@ -29,7 +29,6 @@ const path = require("node:path") as typeof import("node:path"); // eslint-disable-next-line @typescript-eslint/no-require-imports const fs = require("node:fs") as typeof import("node:fs"); // eslint-disable-next-line @typescript-eslint/no-require-imports -const childProcess = require("node:child_process") as typeof import("node:child_process"); // eslint-disable-next-line @typescript-eslint/no-require-imports const url = require("node:url") as typeof import("node:url"); @@ -92,7 +91,17 @@ function parseArgs(argv: readonly string[]): BridgeArgs { const PID_FILENAME = "bridge.pid"; const STDIO_PID_FILENAME = "bridge-stdio.pid"; -function pidFilePath(agent: string, filename: string = PID_FILENAME): string { +function pidFilePath( + agent: string, + filename: string = PID_FILENAME, + explicitHome?: string, +): string { + const configuredHome = explicitHome?.trim() + || process.env.MEMOS_HOME?.trim() + || (process.env.MEMOS_CONFIG_FILE?.trim() + ? path.dirname(process.env.MEMOS_CONFIG_FILE.trim()) + : ""); + if (configuredHome) return path.join(path.resolve(configuredHome), "daemon", filename); const agentHome = agent === "hermes" ? ".hermes" : ".openclaw"; return path.join( process.env.HOME ?? "/tmp", @@ -149,7 +158,7 @@ function killExistingBridge(pidPath: string, timeoutMs = 5000): void { } catch { return; // gone } - childProcess.spawnSync("sleep", ["0.5"]); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500); } try { process.kill(existingPid, "SIGKILL"); @@ -162,11 +171,11 @@ async function main(): Promise { const args = parseArgs(process.argv.slice(2)); // ─── Singleton: kill previous bridge that owns the viewer port ─── - const pidPath = pidFilePath(args.agent); + const pidPath = pidFilePath(args.agent, PID_FILENAME, args.home); const stdioPidFilename = args.runtimeScope ? `bridge-stdio-${args.runtimeScope}.pid` : STDIO_PID_FILENAME; - const stdioPidPath = pidFilePath(args.agent, stdioPidFilename); + const stdioPidPath = pidFilePath(args.agent, stdioPidFilename, args.home); const ownsViewerPort = args.daemon || !args.noViewer; const removeOwnedPidFile = () => { if (ownsViewerPort) removePidFile(pidPath); diff --git a/apps/memos-local-plugin/bridge.mts b/apps/memos-local-plugin/bridge.mts index 2d967c597..718172af2 100644 --- a/apps/memos-local-plugin/bridge.mts +++ b/apps/memos-local-plugin/bridge.mts @@ -21,8 +21,8 @@ * - openclaw → :18799 * - hermes → :18800 * - * The viewer port is read from the agent's `~/./memos-plugin/ - * config.yaml::viewer.port`. We just call `startHttpServer` once; + * The viewer port is adapter-owned (OpenClaw 18799, Hermes 18800). + * We just call `startHttpServer` once; * if the port is already in use we surface the EADDRINUSE error to * stderr and keep running stdio-RPC headless (capture / retrieval * still work). There's no port-sharing or auto-promotion logic — @@ -78,7 +78,13 @@ function parseArgs(argv: readonly string[]): BridgeArgs { const PID_FILENAME = "bridge.pid"; -function pidFilePath(agent: string): string { +function pidFilePath(agent: string, explicitHome?: string): string { + const configuredHome = explicitHome?.trim() + || process.env.MEMOS_HOME?.trim() + || (process.env.MEMOS_CONFIG_FILE?.trim() + ? path.dirname(process.env.MEMOS_CONFIG_FILE.trim()) + : ""); + if (configuredHome) return path.join(path.resolve(configuredHome), "daemon", PID_FILENAME); const agentHome = agent === "hermes" ? ".hermes" : ".openclaw"; return path.join( process.env.HOME ?? "/tmp", @@ -135,7 +141,7 @@ function killExistingBridge(pidPath: string, timeoutMs = 5000): void { } catch { return; // gone } - childProcess.spawnSync("sleep", ["0.5"]); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500); } try { process.kill(existingPid, "SIGKILL"); @@ -148,7 +154,7 @@ async function main(): Promise { const args = parseArgs(process.argv.slice(2)); // ─── Singleton: kill previous bridge that owns the viewer port ─── - const pidPath = pidFilePath(args.agent); + const pidPath = pidFilePath(args.agent, args.home); const ownsViewerPort = args.daemon || !args.noViewer; const removeOwnedPidFile = () => { if (ownsViewerPort) removePidFile(pidPath); diff --git a/apps/memos-local-plugin/core/config/defaults.ts b/apps/memos-local-plugin/core/config/defaults.ts index 5c9dff305..6f06210d0 100644 --- a/apps/memos-local-plugin/core/config/defaults.ts +++ b/apps/memos-local-plugin/core/config/defaults.ts @@ -6,6 +6,16 @@ import type { ResolvedConfig } from "./schema.js"; +const FIXED_VIEWER_PORTS: Readonly> = Object.freeze({ + openclaw: 18799, + hermes: 18800, +}); + +/** Runtime adapters own these well-known ports even for legacy YAML files. */ +export function effectiveViewerPort(agent?: string): number | undefined { + return agent ? FIXED_VIEWER_PORTS[agent] : undefined; +} + export const DEFAULT_CONFIG: ResolvedConfig = { version: 1, viewer: { diff --git a/apps/memos-local-plugin/core/config/index.ts b/apps/memos-local-plugin/core/config/index.ts index d7cb6638d..6d529d960 100644 --- a/apps/memos-local-plugin/core/config/index.ts +++ b/apps/memos-local-plugin/core/config/index.ts @@ -18,7 +18,8 @@ import { MemosError } from "../../agent-contract/errors.js"; import type { ResolvedHome } from "./paths.js"; import { resolveHome } from "./paths.js"; import { ConfigSchema, type ResolvedConfig } from "./schema.js"; -import { DEFAULT_CONFIG } from "./defaults.js"; +import { DEFAULT_CONFIG, effectiveViewerPort } from "./defaults.js"; +import { migrateHermesViewerPort } from "./migrations.js"; import { parseYaml } from "./yaml.js"; export type { ResolvedConfig } from "./schema.js"; @@ -36,11 +37,13 @@ export interface LoadConfigResult { source: string; } -export async function loadConfig(home: ResolvedHome): Promise { +export async function loadConfig(home: ResolvedHome, agent?: string): Promise { let raw: unknown = {}; let fromDisk = false; const warnings: string[] = []; + if (agent === "hermes") await migrateHermesViewerPort(home); + try { const text = await fs.readFile(home.configFile, "utf8"); raw = parseYaml(text, home.configFile); @@ -62,7 +65,7 @@ export async function loadConfig(home: ResolvedHome): Promise } } - const config = resolveConfig(raw, warnings); + const config = resolveConfig(raw, warnings, agent); return { config, fromDisk, warnings, source: home.configFile }; } @@ -70,10 +73,14 @@ export async function loadConfig(home: ResolvedHome): Promise * Merge an arbitrary raw object over `DEFAULT_CONFIG` and validate. Used in * tests and by `writer.ts`. `warnings` is mutated in place if provided. */ -export function resolveConfig(raw: unknown, warnings?: string[]): ResolvedConfig { +export function resolveConfig(raw: unknown, warnings?: string[], agent?: string): ResolvedConfig { const cleaned = pruneUnknown(raw, DEFAULT_CONFIG, "", warnings); const merged = deepMerge(DEFAULT_CONFIG as Record, cleaned); stripUnsupportedEmbeddingDimensions(merged); + const viewerPort = effectiveViewerPort(agent); + if (viewerPort !== undefined && isPlainObject(merged.viewer)) { + merged.viewer.port = viewerPort; + } // Apply Typebox defaults + coerce types as much as possible. const completed = Value.Default(ConfigSchema, merged) as ResolvedConfig; @@ -188,7 +195,7 @@ export async function loadConfigForAgent( defaultHome?: string, ): Promise<{ home: ResolvedHome } & LoadConfigResult> { const home = resolveHome(agent, defaultHome); - const result = await loadConfig(home); + const result = await loadConfig(home, agent); return { home, ...result }; } diff --git a/apps/memos-local-plugin/core/config/migrations.ts b/apps/memos-local-plugin/core/config/migrations.ts new file mode 100644 index 000000000..b1366f55e --- /dev/null +++ b/apps/memos-local-plugin/core/config/migrations.ts @@ -0,0 +1,109 @@ +import { promises as fs } from "node:fs"; +import { dirname, join } from "node:path"; + +import { MemosError } from "../../agent-contract/errors.js"; +import type { ResolvedHome } from "./paths.js"; +import { parseDoc } from "./yaml.js"; + +const MIGRATION_ID = "hermes-viewer-port-v1"; +const OLD_HERMES_PORT = 18799; +const HERMES_PORT = 18800; + +export async function migrateHermesViewerPort(home: ResolvedHome): Promise { + const migrationsDir = join(home.root, ".migrations"); + const markerFile = join(migrationsDir, `${MIGRATION_ID}.json`); + if (await pathExists(markerFile)) return; + if (!await pathExists(home.configFile)) return; + + await fs.mkdir(migrationsDir, { recursive: true }); + const lockDir = join(migrationsDir, `${MIGRATION_ID}.lock`); + const acquired = await acquireMigrationLock(lockDir, markerFile); + if (!acquired) return; + + try { + if (await pathExists(markerFile)) return; + const original = await fs.readFile(home.configFile, "utf8"); + const doc = parseDoc(original, home.configFile); + const port = doc.getIn(["viewer", "port"]); + if (port !== OLD_HERMES_PORT) { + await atomicWrite(markerFile, `${JSON.stringify({ + version: 1, + migration: MIGRATION_ID, + result: "not-needed", + }, null, 2)}\n`); + return; + } + + const backupName = `${MIGRATION_ID}.config.yaml.bak`; + const backupFile = join(migrationsDir, backupName); + await fs.writeFile(backupFile, original, { encoding: "utf8", mode: 0o600, flag: "wx" }) + .catch(async (err: NodeJS.ErrnoException) => { + if (err.code !== "EEXIST") throw err; + const existing = await fs.readFile(backupFile, "utf8"); + if (existing !== original) { + throw new MemosError( + "config_write_failed", + `migration backup already exists with different content: ${backupFile}`, + ); + } + }); + + doc.setIn(["viewer", "port"], HERMES_PORT); + await atomicWrite(home.configFile, doc.toString({ lineWidth: 0 })); + await atomicWrite(markerFile, `${JSON.stringify({ + version: 1, + migration: MIGRATION_ID, + from: OLD_HERMES_PORT, + to: HERMES_PORT, + backup: backupName, + }, null, 2)}\n`); + } finally { + await fs.rm(lockDir, { recursive: true, force: true }); + } +} + +async function acquireMigrationLock(lockDir: string, markerFile: string): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (await pathExists(markerFile)) return false; + try { + await fs.mkdir(lockDir); + return true; + } catch (err) { + const e = err as NodeJS.ErrnoException; + if (e.code !== "EEXIST") throw e; + try { + const stat = await fs.stat(lockDir); + if (Date.now() - stat.mtimeMs > 60_000) { + await fs.rm(lockDir, { recursive: true, force: true }); + continue; + } + } catch { + continue; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + throw new MemosError("config_write_failed", `timed out waiting for migration lock: ${lockDir}`); +} + +async function atomicWrite(target: string, text: string): Promise { + await fs.mkdir(dirname(target), { recursive: true }); + const tempFile = join(dirname(target), `.${process.pid}.${Date.now()}.tmp`); + await fs.writeFile(tempFile, text, { encoding: "utf8", mode: 0o600 }); + try { + await fs.rename(tempFile, target); + } catch (err) { + await fs.unlink(tempFile).catch(() => undefined); + throw err; + } + await fs.chmod(target, 0o600).catch(() => undefined); +} + +async function pathExists(path: string): Promise { + try { + await fs.access(path); + return true; + } catch { + return false; + } +} diff --git a/apps/memos-local-plugin/core/config/paths.ts b/apps/memos-local-plugin/core/config/paths.ts index 66f8553ea..2d3bbd65e 100644 --- a/apps/memos-local-plugin/core/config/paths.ts +++ b/apps/memos-local-plugin/core/config/paths.ts @@ -6,8 +6,16 @@ * this file needs to know. */ +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + writeFileSync, +} from "node:fs"; import { homedir } from "node:os"; -import { resolve as pathResolve, join } from "node:path"; +import { isAbsolute, resolve as pathResolve, join } from "node:path"; import type { AgentKind } from "../types.js"; @@ -32,6 +40,108 @@ const DEFAULT_HOME_BY_AGENT: Record = { hermes: "{HOME}/.hermes/memos-plugin", }; +export const WINDOWS_RUNTIME_HOME_MARKER = ".memos-runtime-home"; + +export interface WindowsHermesHomeSelection { + root: string; + source: "marker" | "legacy-database" | "canonical-database" | "legacy-data" | "new-install"; +} + +interface WindowsHermesHomeOptions { + legacyHome: string; + installRoot: string; + persist?: boolean; +} + +/** + * Select the Windows Hermes runtime without moving any data. + * + * Releases before the Windows installer/runtime paths were aligned may have + * a live DB under `%USERPROFILE%\\.hermes\\memos-plugin`, while the package is + * installed under `%LOCALAPPDATA%\\hermes\\memos-plugin`. A persisted marker + * makes the decision stable across upgrades. Existing legacy data wins; two + * live databases are treated as a conflict instead of being merged or + * silently choosing one. + */ +export function selectWindowsHermesRuntimeHome( + options: WindowsHermesHomeOptions, +): WindowsHermesHomeSelection { + const legacyHome = pathResolve(options.legacyHome); + const installRoot = pathResolve(options.installRoot); + const markerFile = join(installRoot, WINDOWS_RUNTIME_HOME_MARKER); + const markedHome = readRuntimeHomeMarker(markerFile); + if (markedHome) return { root: markedHome, source: "marker" }; + + const legacyDb = existsSync(join(legacyHome, "data", "memos.db")); + const canonicalDb = existsSync(join(installRoot, "data", "memos.db")); + if (legacyDb && canonicalDb) { + throw new Error( + "both Windows Hermes runtime homes contain a database; " + + `set MEMOS_HOME explicitly (${legacyHome} or ${installRoot})`, + ); + } + + let selection: WindowsHermesHomeSelection; + if (legacyDb) { + selection = { root: legacyHome, source: "legacy-database" }; + } else if (canonicalDb) { + selection = { root: installRoot, source: "canonical-database" }; + } else if (hasMeaningfulRuntimeData(legacyHome)) { + selection = { root: legacyHome, source: "legacy-data" }; + } else { + selection = { root: installRoot, source: "new-install" }; + } + + if (options.persist !== false) writeRuntimeHomeMarker(markerFile, selection); + return selection; +} + +function readRuntimeHomeMarker(markerFile: string): string | null { + try { + const value = JSON.parse(readFileSync(markerFile, "utf8")) as { + version?: unknown; + path?: unknown; + }; + if ( + value.version !== 1 || + typeof value.path !== "string" || + !value.path.trim() || + !isAbsolute(value.path) + ) { + return null; + } + return pathResolve(value.path); + } catch { + return null; + } +} + +function writeRuntimeHomeMarker( + markerFile: string, + selection: WindowsHermesHomeSelection, +): void { + mkdirSync(pathResolve(markerFile, ".."), { recursive: true }); + const tempFile = `${markerFile}.${process.pid}.${Date.now()}.tmp`; + const payload = `${JSON.stringify({ + version: 1, + path: selection.root, + source: selection.source, + }, null, 2)}\n`; + writeFileSync(tempFile, payload, { encoding: "utf8", mode: 0o600 }); + renameSync(tempFile, markerFile); +} + +function hasMeaningfulRuntimeData(root: string): boolean { + if (existsSync(join(root, "config.yaml")) || existsSync(join(root, ".auth.json"))) { + return true; + } + try { + return readdirSync(join(root, "skills")).length > 0; + } catch { + return false; + } +} + /** * Resolve the runtime home for `agent`. Override precedence (highest first): * @@ -39,7 +149,8 @@ const DEFAULT_HOME_BY_AGENT: Record = { * 2. `MEMOS_CONFIG_FILE` environment variable (covers only the config file * path; data/skills/logs still derive from the same parent dir). * 3. `defaultHome` argument. - * 4. Built-in default for `agent` (`~/.openclaw/memos-plugin/` etc.). + * 4. Windows Hermes marker / existing-data selection. + * 5. Built-in default for `agent` (`~/.openclaw/memos-plugin/` etc.). */ export function resolveHome(agent: AgentKind, defaultHome?: string): ResolvedHome { const env = process.env; @@ -59,8 +170,15 @@ export function resolveHome(agent: AgentKind, defaultHome?: string): ResolvedHom root = pathResolve(expandHome(defaultHome)); configFile = join(root, "config.yaml"); } else { - const tmpl = DEFAULT_HOME_BY_AGENT[String(agent)] ?? `{HOME}/.${agent}/memos-plugin`; - root = pathResolve(expandHome(tmpl)); + if (agent === "hermes" && process.platform === "win32" && env.LOCALAPPDATA?.trim()) { + root = selectWindowsHermesRuntimeHome({ + legacyHome: join(homedir(), ".hermes", "memos-plugin"), + installRoot: join(env.LOCALAPPDATA.trim(), "hermes", "memos-plugin"), + }).root; + } else { + const tmpl = DEFAULT_HOME_BY_AGENT[String(agent)] ?? `{HOME}/.${agent}/memos-plugin`; + root = pathResolve(expandHome(tmpl)); + } configFile = join(root, "config.yaml"); } diff --git a/apps/memos-local-plugin/core/config/writer.ts b/apps/memos-local-plugin/core/config/writer.ts index 512d2e052..34f431f70 100644 --- a/apps/memos-local-plugin/core/config/writer.ts +++ b/apps/memos-local-plugin/core/config/writer.ts @@ -17,7 +17,8 @@ import { isMap, YAMLMap } from "yaml"; import { MemosError } from "../../agent-contract/errors.js"; import type { ResolvedHome } from "./paths.js"; import { resolveConfig, type ResolvedConfig } from "./index.js"; -import { DEFAULT_CONFIG } from "./defaults.js"; +import { DEFAULT_CONFIG, effectiveViewerPort } from "./defaults.js"; +import { migrateHermesViewerPort } from "./migrations.js"; import { parseDoc, stringifyYaml } from "./yaml.js"; export interface PatchConfigResult { @@ -38,7 +39,9 @@ export interface PatchConfigResult { export async function patchConfig( home: ResolvedHome, patch: Record, + agent?: string, ): Promise { + if (agent === "hermes") await migrateHermesViewerPort(home); let existingText = ""; let created = false; try { @@ -55,12 +58,23 @@ export async function patchConfig( // Parse (or seed) the YAML document. const doc = existingText ? parseDoc(existingText, home.configFile) : parseDoc(stringifyYaml(DEFAULT_CONFIG), ""); + if (!existingText) { + const initialPort = effectiveViewerPort(agent); + if (initialPort !== undefined) doc.setIn(["viewer", "port"], initialPort); + } applyPatch(doc, patch); + if ( + agent === "hermes" && + isPlainObject(patch.viewer) && + Object.hasOwn(patch.viewer, "port") + ) { + doc.setIn(["viewer", "port"], effectiveViewerPort("hermes")); + } removeUnsupportedUserConfig(doc); // Validate against schema using the merged JS view. const merged = doc.toJS({ maxAliasCount: -1 }) as Record; - const config = resolveConfig(merged); + const config = resolveConfig(merged, undefined, agent); // Atomic write. await fs.mkdir(dirname(home.configFile), { recursive: true }); diff --git a/apps/memos-local-plugin/core/pipeline/memory-core.ts b/apps/memos-local-plugin/core/pipeline/memory-core.ts index a0354bf64..01402a86b 100644 --- a/apps/memos-local-plugin/core/pipeline/memory-core.ts +++ b/apps/memos-local-plugin/core/pipeline/memory-core.ts @@ -218,7 +218,7 @@ export async function bootstrapMemoryCoreFull( const home = options.home ?? resolveHome(options.agent); const configResult = options.config ? { config: options.config, fromDisk: true, warnings: [], source: home.configFile } - : await loadConfig(home); + : await loadConfig(home, options.agent); const config = configResult.config; // Standalone daemon: wire the global logger from config (timezone, level, @@ -588,6 +588,22 @@ export function createMemoryCore( let shutDown = false; /** Per-episode monotonic step counter for tool outcomes. */ const toolStepByEpisode = new Map(); + // `handle.onTurnStart` is idempotent for a host-provided turnKey, but this + // facade still performs its own post-processing and api_logs write around + // that call. Keep only the latest successful/logged key per session so an + // adapter retry cannot create a second memos_search row for the same turn. + const turnStartApiLogBySession = new Map< + string, + { turnKey: string; userText: string } + >(); + const disposeTurnStartApiLogSessionListener = handle.buses.session.on( + "session.closed", + (event) => { + if (event.kind === "session.closed") { + turnStartApiLogBySession.delete(event.sessionId); + } + }, + ); let hubRuntime: HubRuntime | null = null; let hubRuntimeConfig: ResolvedConfig = handle.config; const skillStartedAtByPolicy = new Map(); @@ -1930,6 +1946,8 @@ export function createMemoryCore( } await handle.shutdown("memory-core.shutdown"); } finally { + disposeTurnStartApiLogSessionListener(); + turnStartApiLogBySession.clear(); if (telemetry) { await telemetry.shutdown(); } @@ -1945,7 +1963,7 @@ export function createMemoryCore( let diskConfig: ResolvedConfig | null = null; try { const { loadConfig } = await import("../config/index.js"); - const { config } = await loadConfig(handle.home); + const { config } = await loadConfig(handle.home, handle.agent); diskConfig = config; } catch { /* fall through to in-memory */ @@ -2050,6 +2068,7 @@ export function createMemoryCore( ); } handle.sessionManager.closeSession(sessionId, "client"); + turnStartApiLogBySession.delete(sessionId); try { await handle.flush(); } catch (err) { @@ -2115,8 +2134,23 @@ export function createMemoryCore( turn: Parameters[0], ): Promise { ensureLive(); + const turnKey = turn.turnKey?.trim(); + let shouldWriteApiLog = true; + let apiLogClaim: { turnKey: string; userText: string } | null = null; + if (turnKey) { + const existing = turnStartApiLogBySession.get(turn.sessionId); + if (existing?.turnKey === turnKey) { + // The orchestrator will reject a reused key with different text. Keep + // that genuine conflict observable while suppressing exact replays. + shouldWriteApiLog = existing.userText !== turn.userText; + } else { + apiLogClaim = { turnKey, userText: turn.userText }; + turnStartApiLogBySession.set(turn.sessionId, apiLogClaim); + } + } const startedAt = Date.now(); let ok = true; + let apiLogWritten = false; let packet: Awaited> | null = null; let hubCandidates: Array<{ tier: number; @@ -2218,49 +2252,61 @@ export function createMemoryCore( // each real agent turn. Without this, `memos_search` rows // only showed up when the viewer's search box was used. try { - const localStages = buildLocalRetrievalLogStages(packet); - const filtered = hubCandidates.length > 0 - ? finalFilteredCandidates - : localStages.filtered; - const dropped = hubCandidates.length > 0 - ? [...localStages.dropped, ...finalDroppedCandidates] - : localStages.dropped; - const stats = packet ? handle.consumeRetrievalStats(packet.packetId) : null; - handle.repos.apiLogs.insert({ - toolName: "memos_search", - input: { - type: "turn_start", - agent: turn.agent, - query: turn.userText.slice(0, 2_000), - sessionId: packet?.sessionId ?? turn.sessionId ?? null, - episodeId: packet?.episodeId ?? turn.episodeId ?? null, - }, - output: ok - ? { - candidates: localStages.candidates, - hubCandidates, - filtered, - droppedByLlm: dropped, - stats: stats - ? withHubStats( - retrievalStatsPayload(stats), - hubCandidates.length, - filtered.length, - finalHubKept, - finalFilterStats, - ) - : undefined, - } - : { error: "turn_start_retrieval_failed" }, - durationMs: Date.now() - startedAt, - success: ok, - calledAt: startedAt, - }); + if (shouldWriteApiLog) { + const localStages = buildLocalRetrievalLogStages(packet); + const filtered = hubCandidates.length > 0 + ? finalFilteredCandidates + : localStages.filtered; + const dropped = hubCandidates.length > 0 + ? [...localStages.dropped, ...finalDroppedCandidates] + : localStages.dropped; + const stats = packet ? handle.consumeRetrievalStats(packet.packetId) : null; + handle.repos.apiLogs.insert({ + toolName: "memos_search", + input: { + type: "turn_start", + agent: turn.agent, + query: turn.userText.slice(0, 2_000), + sessionId: packet?.sessionId ?? turn.sessionId ?? null, + episodeId: packet?.episodeId ?? turn.episodeId ?? null, + }, + output: ok + ? { + candidates: localStages.candidates, + hubCandidates, + filtered, + droppedByLlm: dropped, + stats: stats + ? withHubStats( + retrievalStatsPayload(stats), + hubCandidates.length, + filtered.length, + finalHubKept, + finalFilterStats, + ) + : undefined, + } + : { error: "turn_start_retrieval_failed" }, + durationMs: Date.now() - startedAt, + success: ok, + calledAt: startedAt, + }); + apiLogWritten = true; + } } catch (logErr) { log.debug("apiLogs.memos_search.turn_start.skipped", { err: logErr instanceof Error ? logErr.message : String(logErr), }); } + if ( + apiLogClaim + && turnStartApiLogBySession.get(turn.sessionId) === apiLogClaim + && (!ok || !apiLogWritten) + ) { + // A terminal retrieval failure must remain retryable. Likewise, if + // persistence itself failed, let a later replay make another attempt. + turnStartApiLogBySession.delete(turn.sessionId); + } if (telemetry && ok) { telemetry.trackTurnStart( turn.agent, @@ -4804,7 +4850,7 @@ export function createMemoryCore( // the cached snapshot so settings never appear blank mid-edit. try { const { loadConfig } = await import("../config/index.js"); - const { config } = await loadConfig(handle.home); + const { config } = await loadConfig(handle.home, handle.agent); return maskSecrets(config as unknown as Record); } catch (err) { log.warn("config.read_from_disk_failed", { @@ -4822,7 +4868,7 @@ export function createMemoryCore( // Drop blank strings on secret fields so the user can leave them // empty in the UI without wiping their existing value. const filtered = stripEmptySecrets(patch); - const result = await applyPatch(handle.home, filtered); + const result = await applyPatch(handle.home, filtered, handle.agent); if (patchTouchesHub(filtered)) { await restartHubRuntime(result.config); } diff --git a/apps/memos-local-plugin/install.ps1 b/apps/memos-local-plugin/install.ps1 index ed8d3a17b..4604e0b47 100644 --- a/apps/memos-local-plugin/install.ps1 +++ b/apps/memos-local-plugin/install.ps1 @@ -35,6 +35,31 @@ function Write-Success($msg) { Write-Host " [OK] $msg" -ForegroundColor Green } function Write-Warn($msg) { Write-Host " [WARN] $msg" -ForegroundColor Yellow } function Stop-Die($msg) { Write-Host " [ERROR] $msg" -ForegroundColor Red; exit 1 } +function Invoke-NativeChecked { + param( + [string]$Command, + [string[]]$Arguments, + [string]$FailureMessage + ) + & $Command @Arguments | Out-Host + $ExitCode = $LASTEXITCODE + if ($ExitCode -ne 0) { + throw "$FailureMessage (exit code $ExitCode)" + } +} + +function Test-BetterSqlite3 { + param([string]$NodeBin, [string]$Prefix) + $SmokeScript = "const Database=require('better-sqlite3');const db=new Database(':memory:');db.exec('SELECT 1');db.close();" + Push-Location $Prefix + try { + & $NodeBin -e $SmokeScript *> $null + return $LASTEXITCODE -eq 0 + } finally { + Pop-Location + } +} + $PluginId = "memos-local-plugin" $NpmPackage = "@memtensor/memos-local-plugin" $OpenClawPort = 18799 @@ -118,7 +143,12 @@ if ($Version) { if (-not $BuiltTarball) { Push-Location $StageDir try { - cmd /c "npm pack $SourceSpec --loglevel=error" + $NpmPackCommand = (Get-Command "npm.cmd" -ErrorAction SilentlyContinue).Source + if (-not $NpmPackCommand) { $NpmPackCommand = (Get-Command "npm" -ErrorAction SilentlyContinue).Source } + if (-not $NpmPackCommand) { throw "npm executable not found" } + Invoke-NativeChecked -Command $NpmPackCommand -Arguments @( + "pack", $SourceSpec, "--loglevel=error" + ) -FailureMessage "npm pack failed" $BuiltTarball = (Get-ChildItem -Filter *.tgz | Select-Object -First 1).FullName } finally { Pop-Location @@ -128,63 +158,170 @@ if (-not $BuiltTarball) { } function Deploy-Tarball { - param([string]$Prefix) + param( + [string]$Prefix, + [scriptblock]$BeforeSwap + ) Write-Info "Deploying to $Prefix" - - $Preserve = @("node_modules", "data", "logs", "skills", "daemon", "config.yaml", ".auth.json") - - if (Test-Path $Prefix) { - $SavedDir = New-Item -ItemType Directory -Path (Join-Path $env:TEMP ([guid]::NewGuid().ToString())) -Force - foreach ($Item in $Preserve) { - $Src = Join-Path $Prefix $Item - if (Test-Path $Src) { - $Dst = Join-Path $SavedDir $Item - New-Item -ItemType Directory -Force -Path (Split-Path $Dst -Parent) -ErrorAction SilentlyContinue | Out-Null - Move-Item -Path $Src -Destination $Dst -Force - } + $Preserve = @("data", "logs", "skills", "daemon", ".migrations", "config.yaml", ".auth.json", ".memos-runtime-home") + $StagedPrefix = Prepare-StagedPackage + $BackupDir = "$Prefix.memos-backup-$([guid]::NewGuid().ToString('N'))" + $HadExisting = Test-Path $Prefix + $LiveMovedToBackup = $false + $StagedMovedLive = $false + $DeploySucceeded = $false + + try { + # Staging can take minutes. Re-check immediately before swapping the + # live tree so an active Hermes session cannot re-lock native modules. + if ($BeforeSwap) { + & $BeforeSwap } - Remove-Item -Recurse -Force $Prefix -ErrorAction SilentlyContinue - New-Item -ItemType Directory -Force -Path $Prefix | Out-Null - - tar xzf $BuiltTarball -C $Prefix --strip-components=1 - - foreach ($Item in $Preserve) { - $SavedItem = Join-Path $SavedDir $Item - if (Test-Path $SavedItem) { - $Dst = Join-Path $Prefix $Item - if (Test-Path $Dst) { Remove-Item -Recurse -Force $Dst } - Move-Item -Path $SavedItem -Destination $Dst -Force + Stop-WindowsPluginBridges -Prefix $Prefix + if ($HadExisting) { + Move-Item -Path $Prefix -Destination $BackupDir -Force + $LiveMovedToBackup = $true + } + New-Item -ItemType Directory -Force -Path (Split-Path $Prefix -Parent) | Out-Null + Move-Item -Path $StagedPrefix -Destination $Prefix -Force + $StagedMovedLive = $true + + if ($HadExisting) { + foreach ($Item in $Preserve) { + $SavedItem = Join-Path $BackupDir $Item + if (Test-Path $SavedItem) { + $Dst = Join-Path $Prefix $Item + if (Test-Path $Dst) { Remove-Item -Recurse -Force $Dst } + Copy-Item -Path $SavedItem -Destination $Dst -Recurse -Force + } } } - Remove-Item -Recurse -Force $SavedDir -ErrorAction SilentlyContinue - } else { - New-Item -ItemType Directory -Force -Path $Prefix | Out-Null - tar xzf $BuiltTarball -C $Prefix --strip-components=1 - } - - if (-not (Test-Path (Join-Path $Prefix "package.json"))) { Stop-Die "Extraction failed" } - Write-Success "Package extracted" - - Write-Info "Installing npm dependencies" - Push-Location $Prefix - try { - $env:MEMOS_SKIP_SETUP = "1" - cmd /c "npm install --omit=dev --no-fund --no-audit --loglevel=error" - - if (Test-Path "node_modules\better-sqlite3") { - Write-Info "Rebuilding better-sqlite3..." - cmd /c "npm rebuild better-sqlite3 --loglevel=error" + + if (-not (Test-Path (Join-Path $Prefix "package.json"))) { + throw "Extraction failed: package.json missing after staged deploy" } + Write-Success "Package extracted" + Write-Success "Dependencies ready" + $DeploySucceeded = $true + } catch { + $DeployError = $_ + if ($StagedMovedLive -and (Test-Path $Prefix)) { + Remove-Item -Recurse -Force $Prefix -ErrorAction SilentlyContinue + } + if ($LiveMovedToBackup -and (Test-Path $BackupDir)) { + Move-Item -Path $BackupDir -Destination $Prefix -Force + } + if (Test-Path $StagedPrefix) { + Remove-Item -Recurse -Force $StagedPrefix -ErrorAction SilentlyContinue + } + throw $DeployError } finally { - Pop-Location + if ($DeploySucceeded -and (Test-Path $BackupDir)) { + Remove-Item -Recurse -Force $BackupDir -ErrorAction SilentlyContinue + } } - +} + +function Prepare-StagedPackage { + $StagedPrefix = Join-Path $env:TEMP ("memos-package-" + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Force -Path $StagedPrefix | Out-Null $SystemNode = Join-Path $env:ProgramFiles "nodejs\node.exe" - $NodeForBridge = if (Test-Path $SystemNode) { $SystemNode } else { (Get-Command "node.exe" -ErrorAction SilentlyContinue).Source } - if ($NodeForBridge) { - Set-Content -Path (Join-Path $Prefix ".memos-node-bin") -Value $NodeForBridge -Encoding UTF8 + $NodeForBridge = if (Test-Path $SystemNode) { + $SystemNode + } else { + (Get-Command "node.exe" -ErrorAction SilentlyContinue).Source + } + if (-not $NodeForBridge) { + Remove-Item -Recurse -Force $StagedPrefix -ErrorAction SilentlyContinue + throw "Node.js executable not found for staged install" + } + $NpmCommand = (Get-Command "npm.cmd" -ErrorAction SilentlyContinue).Source + if (-not $NpmCommand) { $NpmCommand = (Get-Command "npm" -ErrorAction SilentlyContinue).Source } + if (-not $NpmCommand) { + Remove-Item -Recurse -Force $StagedPrefix -ErrorAction SilentlyContinue + throw "npm executable not found for staged install" + } + + try { + Invoke-NativeChecked -Command "tar" -Arguments @( + "xzf", $BuiltTarball, "-C", $StagedPrefix, "--strip-components=1" + ) -FailureMessage "Package extraction failed" + if (-not (Test-Path (Join-Path $StagedPrefix "package.json"))) { + throw "Package extraction failed: package.json missing" + } + + Write-Info "Installing npm dependencies in staging" + $PreviousSkipSetup = $env:MEMOS_SKIP_SETUP + Push-Location $StagedPrefix + try { + $env:MEMOS_SKIP_SETUP = "1" + Invoke-NativeChecked -Command $NpmCommand -Arguments @( + "install", "--omit=dev", "--no-fund", "--no-audit", "--loglevel=error" + ) -FailureMessage "npm install failed" + } finally { + if ($null -eq $PreviousSkipSetup) { + Remove-Item Env:MEMOS_SKIP_SETUP -ErrorAction SilentlyContinue + } else { + $env:MEMOS_SKIP_SETUP = $PreviousSkipSetup + } + Pop-Location + } + + if (-not (Test-BetterSqlite3 -NodeBin $NodeForBridge -Prefix $StagedPrefix)) { + Write-Info "Rebuilding better-sqlite3 in staging..." + Push-Location $StagedPrefix + try { + Invoke-NativeChecked -Command $NpmCommand -Arguments @( + "rebuild", "better-sqlite3", "--loglevel=error" + ) -FailureMessage "better-sqlite3 rebuild failed" + } finally { + Pop-Location + } + if (-not (Test-BetterSqlite3 -NodeBin $NodeForBridge -Prefix $StagedPrefix)) { + throw "better-sqlite3 is not loadable after rebuild" + } + } + + Set-Content -Path (Join-Path $StagedPrefix ".memos-node-bin") -Value $NodeForBridge -Encoding UTF8 + return $StagedPrefix + } catch { + Remove-Item -Recurse -Force $StagedPrefix -ErrorAction SilentlyContinue + throw } - Write-Success "Dependencies ready" +} + +function Stop-WindowsPluginBridges { + param([string]$Prefix) + $ResolvedPrefix = [IO.Path]::GetFullPath($Prefix) + $BridgePattern = 'bridge\.(cts|cjs|mts|mjs)' + $Deadline = [DateTime]::UtcNow.AddSeconds(15) + $QuietSince = $null + + do { + $BridgeProcesses = @( + Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" -ErrorAction SilentlyContinue | + Where-Object { + $_.CommandLine -and + $_.CommandLine.IndexOf($ResolvedPrefix, [StringComparison]::OrdinalIgnoreCase) -ge 0 -and + $_.CommandLine -match $BridgePattern + } + ) + foreach ($Process in $BridgeProcesses) { + Stop-Process -Id $Process.ProcessId -Force -ErrorAction SilentlyContinue + } + if ($BridgeProcesses.Count -eq 0) { + if ($null -eq $QuietSince) { + $QuietSince = [DateTime]::UtcNow + } elseif (([DateTime]::UtcNow - $QuietSince).TotalSeconds -ge 1) { + return + } + } else { + $QuietSince = $null + } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $Deadline) + + throw "MemOS bridge processes are still running. Close Hermes completely and run the installer again." } function Ensure-RuntimeHome { @@ -211,6 +348,73 @@ function Ensure-RuntimeHome { } } +function Test-HermesRuntimeData { + param([string]$HomeDir) + if (Test-Path (Join-Path $HomeDir "config.yaml") -PathType Leaf) { return $true } + if (Test-Path (Join-Path $HomeDir ".auth.json") -PathType Leaf) { return $true } + $SkillsDir = Join-Path $HomeDir "skills" + if (Test-Path $SkillsDir -PathType Container) { + return $null -ne (Get-ChildItem -Path $SkillsDir -Force -ErrorAction SilentlyContinue | Select-Object -First 1) + } + return $false +} + +function Resolve-HermesRuntimeHome { + param([string]$InstallRoot) + + if ($env:MEMOS_HOME -and $env:MEMOS_HOME.Trim()) { + return [PSCustomObject]@{ Path = [IO.Path]::GetFullPath($env:MEMOS_HOME); Source = "environment"; Persist = $true } + } + if ($env:MEMOS_CONFIG_FILE -and $env:MEMOS_CONFIG_FILE.Trim()) { + $ConfigParent = Split-Path -Parent ([IO.Path]::GetFullPath($env:MEMOS_CONFIG_FILE)) + return [PSCustomObject]@{ Path = $ConfigParent; Source = "config-environment"; Persist = $true } + } + + $MarkerFile = Join-Path $InstallRoot ".memos-runtime-home" + if (Test-Path $MarkerFile -PathType Leaf) { + try { + $Marker = Get-Content -Path $MarkerFile -Raw -Encoding UTF8 | ConvertFrom-Json + if ($Marker.version -eq 1 -and $Marker.path -and $Marker.path.Trim()) { + return [PSCustomObject]@{ Path = [IO.Path]::GetFullPath($Marker.path); Source = "marker"; Persist = $false } + } + } catch { + Write-Warn "Ignoring invalid runtime-home marker: $MarkerFile" + } + } + + $LegacyHome = Join-Path $env:USERPROFILE ".hermes\memos-plugin" + $LegacyDb = Join-Path $LegacyHome "data\memos.db" + $CanonicalDb = Join-Path $InstallRoot "data\memos.db" + $HasLegacyDb = Test-Path $LegacyDb -PathType Leaf + $HasCanonicalDb = Test-Path $CanonicalDb -PathType Leaf + if ($HasLegacyDb -and $HasCanonicalDb) { + Stop-Die "both Windows Hermes runtime homes contain a database. Set MEMOS_HOME to '$LegacyHome' or '$InstallRoot', then run the installer again." + } + + if ($HasLegacyDb) { + return [PSCustomObject]@{ Path = $LegacyHome; Source = "legacy-database"; Persist = $true } + } + if ($HasCanonicalDb) { + return [PSCustomObject]@{ Path = $InstallRoot; Source = "canonical-database"; Persist = $true } + } + if (Test-HermesRuntimeData -HomeDir $LegacyHome) { + return [PSCustomObject]@{ Path = $LegacyHome; Source = "legacy-data"; Persist = $true } + } + return [PSCustomObject]@{ Path = $InstallRoot; Source = "new-install"; Persist = $true } +} + +function Write-RuntimeHomeMarker { + param([string]$InstallRoot, [string]$RuntimeHome, [string]$Source) + New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null + $MarkerFile = Join-Path $InstallRoot ".memos-runtime-home" + $TempFile = "$MarkerFile.$PID.tmp" + $Payload = [ordered]@{ version = 1; path = [IO.Path]::GetFullPath($RuntimeHome); source = $Source } + $Json = ($Payload | ConvertTo-Json) + "`n" + $Utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [IO.File]::WriteAllText($TempFile, $Json, $Utf8NoBom) + Move-Item -Path $TempFile -Destination $MarkerFile -Force +} + function Wait-ForViewer { param([int]$Port, [int]$Timeout = 60) $Url = "http://127.0.0.1:$Port/" @@ -283,23 +487,13 @@ function Install-OpenClaw { Write-Info "Patching openclaw.json" $LegacyIds = @("memos-local-openclaw-plugin") $LegacyJson = ($LegacyIds -join ',') - $SourceKindStr = if ($SourceKind -eq 'path') { 'path' } else { 'npm' } - $env:PLUGIN_ID = $PluginId - $env:INSTALL_PATH = $Prefix - $env:SOURCE_KIND = $SourceKindStr - $env:SOURCE_SPEC = $SourceSpec - $env:PLUGIN_VERSION = $PluginVersion $env:LEGACY_JSON = $LegacyJson $env:CONFIG_PATH = $ConfigPath $NodeScript = @" 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 { CONFIG_PATH: configPath, PLUGIN_ID: pluginId, LEGACY_JSON: legacyCsv } = process.env; const legacyIds = (legacyCsv || '').split(',').filter(Boolean); const MEMOS_TOOL_NAMES = [ 'memos_search', @@ -342,7 +536,6 @@ if (!config.plugins.allow.includes(pluginId)) config.plugins.allow.push(pluginId 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); } @@ -353,6 +546,25 @@ for (const legacyId of legacyIds) { } } +// `plugins.installs` was optional through OpenClaw 2026.4.24 and moved to +// machine-managed plugin index state in 2026.4.25. The extension already lives +// in OpenClaw's standard discovery directory, so neither generation requires a +// hand-written MemOS install record. Remove only records owned by this installer +// so older OpenClaw releases retain metadata for unrelated plugins. +if ( + config.plugins.installs && + typeof config.plugins.installs === 'object' && + !Array.isArray(config.plugins.installs) +) { + delete config.plugins.installs[pluginId]; + for (const legacyId of legacyIds) delete config.plugins.installs[legacyId]; + if (Object.keys(config.plugins.installs).length === 0) delete config.plugins.installs; +} else if (Object.prototype.hasOwnProperty.call(config.plugins, 'installs')) { + // A malformed legacy value is invalid on old hosts and cannot carry records + // worth preserving. + delete config.plugins.installs; +} + if (!config.plugins.slots || typeof config.plugins.slots !== 'object') config.plugins.slots = {}; config.plugins.slots.memory = pluginId; @@ -361,22 +573,18 @@ if (!config.plugins.entries[pluginId] || typeof config.plugins.entries[pluginId] config.plugins.entries[pluginId] = {}; } config.plugins.entries[pluginId].enabled = true; -if (config.plugins.entries[pluginId].hooks) delete config.plugins.entries[pluginId].hooks; - -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; +// OpenClaw requires an explicit opt-in before conversation-bearing hooks can +// inject memories at prompt time or capture completed turns at agent_end. +// Preserve any existing hook settings while enforcing the permission MemOS +// needs; replacing the object would silently discard user/host configuration. +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.installs[pluginId] = installsEntry; +config.plugins.entries[pluginId].hooks.allowConversationAccess = true; fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); "@ @@ -402,14 +610,19 @@ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); function Install-Hermes { Write-Host "`n=== Hermes Install ===" -ForegroundColor Cyan $Prefix = Join-Path $env:LOCALAPPDATA "hermes\memos-plugin" - $HomeDir = $Prefix + $RuntimeSelection = Resolve-HermesRuntimeHome -InstallRoot $Prefix + $HomeDir = $RuntimeSelection.Path $ConfigFile = Join-Path $env:LOCALAPPDATA "hermes\config.yaml" $AdapterDir = Join-Path $Prefix "adapters\hermes" - Get-Process -Name "node" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -match "bridge\.(cts|cjs)" } | Stop-Process -Force -ErrorAction SilentlyContinue - Get-Process -Name "hermes" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue - - Deploy-Tarball -Prefix $Prefix + Deploy-Tarball -Prefix $Prefix -BeforeSwap { + Get-Process -Name "hermes" -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue + } + if ($RuntimeSelection.Persist) { + Write-RuntimeHomeMarker -InstallRoot $Prefix -RuntimeHome $HomeDir -Source $RuntimeSelection.Source + } + Write-Success "Runtime home: $HomeDir ($($RuntimeSelection.Source))" Ensure-RuntimeHome -Agent "hermes" -HomeDir $HomeDir -Prefix $Prefix $BridgeEntry = Join-Path $Prefix "dist\bridge.cjs" @@ -509,9 +722,11 @@ memory: $DaemonLog = Join-Path $Prefix "logs\daemon-start.log" $DaemonLogErr = Join-Path $Prefix "logs\daemon-start-err.log" if ($BridgeEntry.EndsWith(".cjs")) { - Start-Process -FilePath $NodeBin -ArgumentList "$BridgeEntry --agent=hermes --daemon" -WindowStyle Hidden -RedirectStandardOutput $DaemonLog -RedirectStandardError $DaemonLogErr + $DaemonArgs = "`"$BridgeEntry`" --agent=hermes --daemon --home=`"$HomeDir`"" + Start-Process -FilePath $NodeBin -ArgumentList $DaemonArgs -WindowStyle Hidden -RedirectStandardOutput $DaemonLog -RedirectStandardError $DaemonLogErr } else { - Start-Process -FilePath $NodeBin -ArgumentList "$TsxBin $BridgeEntry --agent=hermes --daemon" -WindowStyle Hidden -RedirectStandardOutput $DaemonLog -RedirectStandardError $DaemonLogErr + $DaemonArgs = "`"$TsxBin`" `"$BridgeEntry`" --agent=hermes --daemon --home=`"$HomeDir`"" + Start-Process -FilePath $NodeBin -ArgumentList $DaemonArgs -WindowStyle Hidden -RedirectStandardOutput $DaemonLog -RedirectStandardError $DaemonLogErr } if (Wait-ForViewer -Port $HermesPort -Timeout 120) { diff --git a/apps/memos-local-plugin/install.sh b/apps/memos-local-plugin/install.sh index f74e5e089..9c208e15c 100755 --- a/apps/memos-local-plugin/install.sh +++ b/apps/memos-local-plugin/install.sh @@ -310,7 +310,7 @@ 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) + local preserve=(node_modules data logs skills daemon .migrations config.yaml .auth.json .memos-node-bin) if [[ -d "${prefix}" ]]; then saved_dir="$(mktemp -d)" local item diff --git a/apps/memos-local-plugin/package-lock.json b/apps/memos-local-plugin/package-lock.json index 46db4c00b..b8df7a75d 100644 --- a/apps/memos-local-plugin/package-lock.json +++ b/apps/memos-local-plugin/package-lock.json @@ -1,12 +1,12 @@ { "name": "@memtensor/memos-local-plugin", - "version": "2.0.12-beta.1", + "version": "2.0.14-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@memtensor/memos-local-plugin", - "version": "2.0.12-beta.1", + "version": "2.0.14-beta.1", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/apps/memos-local-plugin/package.json b/apps/memos-local-plugin/package.json index 48e07b94b..125b7388c 100644 --- a/apps/memos-local-plugin/package.json +++ b/apps/memos-local-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@memtensor/memos-local-plugin", - "version": "2.0.12-beta.1", + "version": "2.0.14-beta.1", "description": "Reflect2Evolve memory plugin: layered L1/L2/L3 memory, reflection-weighted value backprop, cross-task policy induction, skill crystallization, three-tier retrieval. Adapters for OpenClaw and Hermes Agent via a shared algorithm core.", "type": "module", "main": "dist/core/index.js", diff --git a/apps/memos-local-plugin/server/routes/admin.ts b/apps/memos-local-plugin/server/routes/admin.ts index f70d52b77..3089d937e 100644 --- a/apps/memos-local-plugin/server/routes/admin.ts +++ b/apps/memos-local-plugin/server/routes/admin.ts @@ -10,23 +10,82 @@ * * POST /api/v1/admin/restart * Agent-aware restart. For OpenClaw the plugin lives inside the - * gateway process, which is managed by macOS launchd — calling - * `process.exit(0)` causes launchd to respawn it automatically. - * For Hermes, terminate the active `hermes chat`, then ask the bridge - * to shut down gracefully. launchd/systemd owns replacement when the - * viewer is supervised; portable viewers retain the detached fallback. + * gateway process. Windows Scheduled Tasks do not respawn a process + * that exits successfully, so Windows returns a manual handoff while + * supervised Unix installs retain the process-exit restart path. + * For Hermes on Unix, terminate the active `hermes chat`, then ask the + * bridge to shut down gracefully. launchd/systemd owns replacement when + * supervised; portable viewers retain the detached fallback. Windows + * returns an explicit manual handoff and keeps the responding process + * alive so the route cannot self-destruct before a replacement exists. */ import { spawn } from "node:child_process"; +import type { ServerResponse } from "node:http"; import type { ServerDeps, ServerOptions } from "../types.js"; import type { Routes } from "./registry.js"; export function registerAdminRoutes(routes: Routes, deps: ServerDeps, options: ServerOptions = {}): void { - routes.set("POST /api/v1/admin/clear-data", async (_ctx) => { + routes.set("POST /api/v1/admin/clear-data", async (ctx) => { const dbFile = deps.home?.dbFile; if (!dbFile) { return { ok: false, error: "database path not configured" }; } const agent = options.agent ?? "unknown"; + const platform = options.lifecycle?.platform ?? process.platform; + + if (platform === "win32") { + if (agent === "hermes") { + const bridge = deps.bridgeStatus?.(); + if (!bridge || bridge.status !== "disconnected") { + return { + ok: false, + cleared: false, + restarting: false, + manualCloseRequired: true, + platform, + message: "Close Hermes completely, then retry clearing data.", + }; + } + } + + try { + await deps.core.shutdown(); + } catch (err) { + scheduleWindowsShutdownAfterResponse(ctx.res, options); + return { + ok: false, + cleared: false, + restarting: false, + manualRestartRequired: true, + platform, + error: `Memory core did not shut down cleanly: ${errorMessage(err)}`, + message: manualClearRestartMessage(agent, false), + }; + } + + const failures = await removeWindowsRuntimeFiles(dbFile, deps.home?.root); + scheduleWindowsShutdownAfterResponse(ctx.res, options); + if (failures.length > 0) { + return { + ok: false, + cleared: false, + restarting: false, + manualRestartRequired: true, + platform, + error: `Could not remove: ${failures.join(", ")}`, + message: manualClearRestartMessage(agent, false), + }; + } + return { + ok: true, + cleared: true, + restarting: false, + manualRestartRequired: true, + platform, + message: manualClearRestartMessage(agent, true), + }; + } + let killedHermes = false; if (agent === "hermes") { // The viewer daemon and an active Hermes chat have separate Node @@ -46,7 +105,7 @@ export function registerAdminRoutes(routes: Routes, deps: ServerDeps, options: S } if (agent !== "openclaw" && !isSupervisorManaged(options)) { // Portable Hermes: there is no supervisor to replace this process. - await spawnReplacementDaemon(agent); + await spawnReplacementDaemon(agent, deps.home?.root); } if (agent === "hermes") { scheduleHermesShutdown(options, 200); @@ -59,14 +118,38 @@ export function registerAdminRoutes(routes: Routes, deps: ServerDeps, options: S routes.set("POST /api/v1/admin/restart", async (_ctx) => { const agent = options.agent ?? "unknown"; if (agent === "openclaw") { + const platform = options.lifecycle?.platform ?? process.platform; + if (platform === "win32") { + return { + ok: true, + restarting: false, + manualRestartRequired: true, + platform, + message: + "Configuration saved. In PowerShell, run openclaw gateway stop, " + + "then openclaw gateway start.", + }; + } setTimeout(() => process.exit(0), 300); return { ok: true, restarting: true }; } if (agent === "hermes") { + const platform = options.lifecycle?.platform ?? process.platform; + if (platform === "win32") { + return { + ok: true, + restarting: false, + manualRestartRequired: true, + platform, + message: + `Configuration saved. Close Hermes, run Stop-Process -Id ${process.pid} ` + + "in PowerShell to stop Memory Viewer, then start Hermes again.", + }; + } const killed = await terminateHermesChat(); if (!isSupervisorManaged(options)) { - await spawnReplacementDaemon(agent); + await spawnReplacementDaemon(agent, deps.home?.root); } scheduleHermesShutdown(options, 200); return { ok: true, restarting: true, killed }; @@ -76,6 +159,73 @@ export function registerAdminRoutes(routes: Routes, deps: ServerDeps, options: S }); } +async function removeWindowsRuntimeFiles(dbFile: string, home?: string): Promise { + const fs = await import("node:fs/promises"); + const failures: string[] = []; + for (const suffix of ["", "-wal", "-shm"]) { + const target = dbFile + suffix; + try { + await fs.unlink(target); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") failures.push(target); + continue; + } + try { + await fs.access(target); + failures.push(target); + } catch { + /* absent as required */ + } + } + if (home) { + try { + await fs.unlink(`${home}/bridge-status.json`); + } catch { + /* status is diagnostic only */ + } + } + return [...new Set(failures)]; +} + +function manualClearRestartMessage(agent: string, cleared: boolean): string { + const subject = agent === "openclaw" ? "OpenClaw" : "Hermes"; + return cleared + ? `Data cleared. Start ${subject} again to restart Memory Viewer.` + : `Data was not fully cleared. Start ${subject} again before retrying.`; +} + +function scheduleWindowsShutdownAfterResponse( + res: ServerResponse, + options: ServerOptions, +): void { + let scheduled = false; + const schedule = (delayMs: number): void => { + if (scheduled) return; + scheduled = true; + setTimeout(() => { + if (options.lifecycle?.requestShutdown) { + options.lifecycle.requestShutdown(); + return; + } + process.exit(0); + }, delayMs); + }; + + // Route handlers return their payload to the HTTP dispatcher, so starting + // the exit timer inside the handler races JSON serialization on Windows. + // Wait until ServerResponse has flushed the result before handing off. + res.once("finish", () => schedule(300)); + res.once("close", () => schedule(res.writableFinished ? 300 : 1_000)); + res.once("error", () => schedule(1_000)); + if (res.writableFinished) { + schedule(300); + } +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + /** * Detect the supervisors used by supported desktop/server installs. * @@ -109,7 +259,7 @@ function scheduleHermesShutdown(options: ServerOptions, delayMs: number): void { }, delayMs); } -async function spawnReplacementDaemon(agent: string): Promise { +async function spawnReplacementDaemon(agent: string, home?: string): Promise { const fs = await import("node:fs"); const nodePath = await import("node:path"); const { fileURLToPath } = await import("node:url"); @@ -127,6 +277,7 @@ async function spawnReplacementDaemon(agent: string): Promise { detached: true, stdio: "ignore", cwd: pluginRoot, + env: home ? { ...process.env, MEMOS_HOME: home } : process.env, }); child.unref(); } diff --git a/apps/memos-local-plugin/server/types.ts b/apps/memos-local-plugin/server/types.ts index 9667e8a99..ee5d6753d 100644 --- a/apps/memos-local-plugin/server/types.ts +++ b/apps/memos-local-plugin/server/types.ts @@ -50,6 +50,8 @@ export interface ServerOptions { lifecycle?: { /** Override launchd/systemd detection. */ supervised?: boolean; + /** Override the platform for embedders and deterministic lifecycle tests. */ + platform?: NodeJS.Platform; /** Request graceful host shutdown after the HTTP response is returned. */ requestShutdown?: () => void; }; diff --git a/apps/memos-local-plugin/tests/python/test_bridge_client.py b/apps/memos-local-plugin/tests/python/test_bridge_client.py index b5c9eb917..5ec1aabe4 100644 --- a/apps/memos-local-plugin/tests/python/test_bridge_client.py +++ b/apps/memos-local-plugin/tests/python/test_bridge_client.py @@ -11,13 +11,16 @@ from __future__ import annotations import contextlib +import http.server import io import json +import os import sys import tempfile import threading import time import unittest +import urllib.error from pathlib import Path from unittest.mock import patch @@ -1143,6 +1146,8 @@ def test_get_config_schema_describes_known_fields(self) -> None: keys = {item["key"] for item in schema} self.assertIn("llm_provider", keys) self.assertIn("embedding_provider", keys) + viewer = next(item for item in schema if item["key"] == "viewer_port") + self.assertEqual(viewer["default"], 18800) def test_save_config_writes_yaml_with_correct_mode(self) -> None: import tempfile @@ -1164,9 +1169,23 @@ def test_save_config_writes_yaml_with_correct_mode(self) -> None: mode = cfg_path.stat().st_mode & 0o777 self.assertEqual(mode, 0o600) loaded = yaml.safe_load(cfg_path.read_text()) - self.assertEqual(loaded["viewer"]["port"], 18920) + self.assertEqual(loaded["viewer"]["port"], 18800) self.assertEqual(loaded["llm"]["provider"], "openai_compatible") + def test_save_config_reuses_the_initialized_runtime_home(self) -> None: + import tempfile + + p = self._provider_mod.MemTensorProvider() + with tempfile.TemporaryDirectory() as tmp: + selected_home = Path(tmp) / "selected-runtime" + host_home = Path(tmp) / "different-hermes-home" + p._runtime_home = selected_home + + p.save_config({"viewer_port": 18799}, str(host_home)) + + self.assertTrue((selected_home / "config.yaml").exists()) + self.assertFalse((host_home / "memos-plugin" / "config.yaml").exists()) + # ─── Long-operation RPC timeouts (issue #2028) ────────────────────── # # After 1-2 hours of Hermes use the memory / capture / reflection @@ -1301,7 +1320,15 @@ def test_existing_memos_viewer_is_reused(self) -> None: def test_non_memos_port_occupant_blocks_daemon_start(self) -> None: with ( - patch.object(daemon_manager_mod, "_probe_viewer", return_value="blocked"), + patch.object(daemon_manager_mod, "_probe_viewer", return_value="occupied"), + patch.object(daemon_manager_mod.subprocess, "Popen") as popen, + ): + self.assertFalse(daemon_manager_mod.ensure_viewer_daemon()) + popen.assert_not_called() + + def test_unknown_probe_result_is_handled_conservatively(self) -> None: + with ( + patch.object(daemon_manager_mod, "_probe_viewer", return_value="unknown"), patch.object(daemon_manager_mod.subprocess, "Popen") as popen, ): self.assertFalse(daemon_manager_mod.ensure_viewer_daemon()) @@ -1339,6 +1366,51 @@ def poll(self): self.assertTrue(daemon_manager_mod.ensure_viewer_daemon()) popen.assert_called_once() + def test_windows_manual_restart_starts_viewer_with_explicit_runtime_home(self) -> None: + class FakeDaemon: + returncode = None + + def poll(self): + return None + + @contextlib.contextmanager + def acquired_lock(_runtime_home=None): + yield True + + with tempfile.TemporaryDirectory() as tmp: + runtime_home = Path(tmp) / "legacy-hermes-home" + runtime_home.mkdir() + with ( + patch.object( + daemon_manager_mod, + "_probe_viewer", + side_effect=["free", "free", "running_memos"], + ), + patch.object(daemon_manager_mod, "_viewer_start_lock", acquired_lock), + patch.object(daemon_manager_mod, "ensure_bridge_running", return_value=True), + patch.object( + daemon_manager_mod, + "_bridge_command", + return_value=[ + "node.exe", + "bridge.cjs", + "--agent=hermes", + "--daemon", + f"--home={runtime_home.resolve()}", + ], + ), + patch.object( + daemon_manager_mod.subprocess, + "Popen", + return_value=FakeDaemon(), + ) as popen, + ): + self.assertTrue(daemon_manager_mod.ensure_viewer_daemon(runtime_home=runtime_home)) + + kwargs = popen.call_args.kwargs + self.assertEqual(kwargs["env"]["MEMOS_HOME"], str(runtime_home.resolve())) + self.assertIn(f"--home={runtime_home.resolve()}", popen.call_args.args[0]) + def test_start_lock_reprobes_before_spawning_daemon(self) -> None: @contextlib.contextmanager def acquired_lock(): @@ -1370,6 +1442,154 @@ def busy_lock(): popen.assert_not_called() +class ViewerProbeTests(unittest.TestCase): + def test_loopback_probe_bypasses_proxy_on_all_supported_platforms(self) -> None: + class JsonHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 - required by BaseHTTPRequestHandler + body = b'{"service":"memos-local-plugin","agent":"hermes"}' + self.send_response(200) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, _format: str, *_args: object) -> None: + return + + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), JsonHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + url = f"http://127.0.0.1:{server.server_port}/api/v1/health" + for platform_name in ("windows", "macos", "linux"): + with ( + self.subTest(platform=platform_name), + patch.dict( + os.environ, + { + "HTTP_PROXY": "http://127.0.0.1:9", + "HTTPS_PROXY": "http://127.0.0.1:9", + "ALL_PROXY": "http://127.0.0.1:9", + "NO_PROXY": "", + "no_proxy": "", + }, + clear=False, + ), + patch.object( + daemon_manager_mod.urllib.request, + "urlopen", + side_effect=AssertionError("proxy-aware urlopen must not be used"), + ), + ): + self.assertEqual( + daemon_manager_mod._probe_json_url(url), + {"service": "memos-local-plugin", "agent": "hermes"}, + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + def test_connection_refused_codes_are_free(self) -> None: + for errno in (61, 111, 10061): + with ( + self.subTest(errno=errno), + patch.object( + daemon_manager_mod._LOOPBACK_OPENER, + "open", + side_effect=urllib.error.URLError(OSError(errno, "refused")), + ), + ): + self.assertEqual( + daemon_manager_mod._probe_json_url("http://127.0.0.1:18800/test"), + "free", + ) + + def test_http_error_means_the_port_is_occupied(self) -> None: + error = urllib.error.HTTPError( + "http://127.0.0.1:18800/test", + 401, + "Unauthorized", + {}, + None, + ) + try: + with patch.object( + daemon_manager_mod._LOOPBACK_OPENER, + "open", + side_effect=error, + ): + self.assertEqual( + daemon_manager_mod._probe_json_url("http://127.0.0.1:18800/test"), + "occupied", + ) + finally: + error.close() + + def test_timeout_is_unknown(self) -> None: + with patch.object( + daemon_manager_mod._LOOPBACK_OPENER, + "open", + side_effect=TimeoutError("timed out"), + ): + self.assertEqual( + daemon_manager_mod._probe_json_url("http://127.0.0.1:18800/test"), + "unknown", + ) + + def test_viewer_probe_exposes_all_four_states(self) -> None: + cases = ( + ({"service": "memos-local-plugin", "agent": "hermes"}, None, "running_memos"), + ({"agent": "hermes", "version": "2.0.12"}, None, "running_memos"), + ({"service": "some-other-service"}, None, "occupied"), + ("free", None, "free"), + ("unknown", "free", "free"), + ("unknown", "occupied", "occupied"), + ("unknown", "unknown", "unknown"), + ) + for health_result, bind_result, expected in cases: + with ( + self.subTest(health_result=health_result, bind_result=bind_result), + patch.object( + daemon_manager_mod, + "_probe_json_url", + return_value=health_result, + ), + patch.object( + daemon_manager_mod, + "_probe_loopback_port", + return_value=bind_result, + ) as bind_probe, + ): + self.assertEqual(daemon_manager_mod._probe_viewer(), expected) + if health_result == "unknown": + bind_probe.assert_called_once_with(daemon_manager_mod.HERMES_VIEWER_PORT) + else: + bind_probe.assert_not_called() + + def test_bind_probe_confirms_a_free_loopback_port(self) -> None: + with tempfile.TemporaryDirectory(): + self.assertEqual(daemon_manager_mod._probe_loopback_port(0), "free") + + def test_bind_probe_confirms_an_occupied_loopback_port(self) -> None: + with daemon_manager_mod.socket.socket( + daemon_manager_mod.socket.AF_INET, + daemon_manager_mod.socket.SOCK_STREAM, + ) as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen() + port = listener.getsockname()[1] + self.assertEqual(daemon_manager_mod._probe_loopback_port(port), "occupied") + + def test_bind_probe_keeps_unexpected_socket_errors_unknown(self) -> None: + with patch.object( + daemon_manager_mod.socket, + "socket", + side_effect=OSError(13, "permission denied"), + ): + self.assertEqual(daemon_manager_mod._probe_loopback_port(18800), "unknown") + + class BridgeOkCacheTests(unittest.TestCase): """Regression tests for issue #1797. diff --git a/apps/memos-local-plugin/tests/python/test_runtime_home.py b/apps/memos-local-plugin/tests/python/test_runtime_home.py new file mode 100644 index 000000000..8caa1dc7f --- /dev/null +++ b/apps/memos-local-plugin/tests/python/test_runtime_home.py @@ -0,0 +1,81 @@ +import json +import sys +import tempfile +import unittest + +from pathlib import Path +from unittest.mock import patch + + +PROVIDER_DIR = Path(__file__).resolve().parents[2] / "adapters" / "hermes" / "memos_provider" +sys.path.insert(0, str(PROVIDER_DIR)) + +import daemon_manager # noqa: E402 + +from runtime_home import resolve_runtime_home, select_windows_runtime_home # noqa: E402 + + +class RuntimeHomeTests(unittest.TestCase): + def test_legacy_database_wins_and_marker_is_reused(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + legacy = root / "legacy" + install = root / "local" / "hermes" / "memos-plugin" + (legacy / "data").mkdir(parents=True) + (legacy / "data" / "memos.db").write_bytes(b"legacy") + + selected = select_windows_runtime_home( + legacy_home=legacy, + install_root=install, + ) + self.assertEqual(selected, legacy.resolve()) + marker = json.loads((install / ".memos-runtime-home").read_text("utf-8")) + self.assertEqual(marker["source"], "legacy-database") + + (install / "data").mkdir() + (install / "data" / "memos.db").write_bytes(b"new") + self.assertEqual( + select_windows_runtime_home(legacy_home=legacy, install_root=install), + legacy.resolve(), + ) + + def test_both_databases_without_marker_is_an_explicit_conflict(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + legacy = root / "legacy" + install = root / "install" + for home in (legacy, install): + (home / "data").mkdir(parents=True) + (home / "data" / "memos.db").write_bytes(b"db") + with self.assertRaisesRegex(RuntimeError, "both Windows Hermes runtime homes"): + select_windows_runtime_home(legacy_home=legacy, install_root=install) + + def test_environment_override_precedes_windows_marker(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + forced = Path(tmp) / "forced" + selected = resolve_runtime_home( + env={"MEMOS_HOME": str(forced), "LOCALAPPDATA": str(Path(tmp) / "local")}, + platform_name="nt", + user_home=Path(tmp) / "user", + ) + self.assertEqual(selected, forced.resolve()) + + def test_daemon_command_and_environment_use_the_selected_home(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + runtime_home = Path(tmp) / "legacy" + bridge = Path(tmp) / "bridge.cjs" + bridge.write_text("", encoding="utf-8") + with ( + patch.object(daemon_manager, "_plugin_root", return_value=Path(tmp)), + patch.object(daemon_manager, "_bridge_script", return_value=bridge), + patch.object(daemon_manager, "_node_binary", return_value="node"), + ): + command = daemon_manager._bridge_command( + daemon=True, + runtime_home=runtime_home, + ) + self.assertIn(f"--home={runtime_home.resolve()}", command) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/memos-local-plugin/tests/unit/config/hermes-migration.test.ts b/apps/memos-local-plugin/tests/unit/config/hermes-migration.test.ts new file mode 100644 index 000000000..e21ab5499 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/config/hermes-migration.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { promises as fs } from "node:fs"; +import { join } from "node:path"; + +import { loadConfig } from "../../../core/config/index.js"; +import { makeTmpHome } from "../../helpers/tmp-home.js"; + +describe("Hermes viewer-port migration", () => { + let cleanup: (() => Promise) | null = null; + afterEach(async () => { if (cleanup) await cleanup(); cleanup = null; }); + + it("backs up and migrates the known bad 18799 value exactly once", async () => { + const original = "# keep me\nviewer:\n port: 18799 # wrong v2 value\nllm:\n endpoint: https://example.test/v1\n"; + const ctx = await makeTmpHome({ agent: "hermes", configYaml: original }); + cleanup = ctx.cleanup; + + const loaded = await loadConfig(ctx.home, "hermes"); + expect(loaded.config.viewer.port).toBe(18800); + expect(await fs.readFile(ctx.home.configFile, "utf8")).toMatch(/port:\s*18800/); + + const migrationsDir = join(ctx.home.root, ".migrations"); + const marker = JSON.parse(await fs.readFile( + join(migrationsDir, "hermes-viewer-port-v1.json"), + "utf8", + )); + expect(marker).toMatchObject({ version: 1, from: 18799, to: 18800 }); + const backupPath = join(migrationsDir, marker.backup); + expect(await fs.readFile(backupPath, "utf8")).toBe(original); + if (process.platform !== "win32") { + expect((await fs.stat(backupPath)).mode & 0o777).toBe(0o600); + } + + await loadConfig(ctx.home, "hermes"); + expect(await fs.readFile(backupPath, "utf8")).toBe(original); + }); + + it("does not rewrite a custom port during the one-time migration", async () => { + const original = "viewer:\n port: 19000\n"; + const ctx = await makeTmpHome({ agent: "hermes", configYaml: original }); + cleanup = ctx.cleanup; + + const loaded = await loadConfig(ctx.home, "hermes"); + + expect(loaded.config.viewer.port).toBe(18800); + expect(await fs.readFile(ctx.home.configFile, "utf8")).toBe(original); + }); + + it("serializes concurrent first loads into one backup and migration", async () => { + const original = "viewer:\n port: 18799\n"; + const ctx = await makeTmpHome({ agent: "hermes", configYaml: original }); + cleanup = ctx.cleanup; + + const results = await Promise.all([ + loadConfig(ctx.home, "hermes"), + loadConfig(ctx.home, "hermes"), + loadConfig(ctx.home, "hermes"), + ]); + + expect(results.every((result) => result.config.viewer.port === 18800)).toBe(true); + const files = await fs.readdir(join(ctx.home.root, ".migrations")); + expect(files.filter((name) => name.endsWith(".bak"))).toHaveLength(1); + expect(files.some((name) => name.endsWith(".lock"))).toBe(false); + }); + + it("leaves invalid YAML untouched and does not mark migration complete", async () => { + const original = "viewer: [\n"; + const ctx = await makeTmpHome({ agent: "hermes" }); + cleanup = ctx.cleanup; + await fs.writeFile(ctx.home.configFile, original, "utf8"); + + await expect(loadConfig(ctx.home, "hermes")).rejects.toThrow(); + expect(await fs.readFile(ctx.home.configFile, "utf8")).toBe(original); + await expect(fs.access(join(ctx.home.root, ".migrations", "hermes-viewer-port-v1.json"))) + .rejects.toThrow(); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/config/paths.test.ts b/apps/memos-local-plugin/tests/unit/config/paths.test.ts index 98622d2fb..b0e19036f 100644 --- a/apps/memos-local-plugin/tests/unit/config/paths.test.ts +++ b/apps/memos-local-plugin/tests/unit/config/paths.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { resolve as pathResolve, join } from "node:path"; +import { tmpdir } from "node:os"; -import { resolveHome, expandHome } from "../../../core/config/paths.js"; +import { + resolveHome, + expandHome, + selectWindowsHermesRuntimeHome, +} from "../../../core/config/paths.js"; const SAVED = { ...process.env }; function restoreEnv() { @@ -51,4 +57,83 @@ describe("config/paths", () => { const home = resolveHome("custom"); expect(home.root.endsWith(".custom/memos-plugin")).toBe(true); }); + + it("keeps a legacy Windows Hermes database in place and persists the choice", () => { + const root = mkdtempSync(join(tmpdir(), "memos-win-home-")); + const legacyHome = join(root, "legacy", "memos-plugin"); + const installRoot = join(root, "local", "hermes", "memos-plugin"); + mkdirSync(join(legacyHome, "data"), { recursive: true }); + writeFileSync(join(legacyHome, "data", "memos.db"), "legacy"); + + const selected = selectWindowsHermesRuntimeHome({ legacyHome, installRoot }); + + expect(selected).toMatchObject({ root: legacyHome, source: "legacy-database" }); + expect(JSON.parse(readFileSync(join(installRoot, ".memos-runtime-home"), "utf8"))) + .toMatchObject({ version: 1, path: legacyHome, source: "legacy-database" }); + }); + + it("uses LocalAppData for a new Windows install and reuses its marker", () => { + const root = mkdtempSync(join(tmpdir(), "memos-win-home-")); + const legacyHome = join(root, "legacy", "memos-plugin"); + const installRoot = join(root, "local", "hermes", "memos-plugin"); + + const first = selectWindowsHermesRuntimeHome({ legacyHome, installRoot }); + mkdirSync(legacyHome, { recursive: true }); + writeFileSync(join(legacyHome, "config.yaml"), "viewer:\n port: 18800\n", { flag: "a" }); + const second = selectWindowsHermesRuntimeHome({ legacyHome, installRoot }); + + expect(first).toMatchObject({ root: installRoot, source: "new-install" }); + expect(second).toMatchObject({ root: installRoot, source: "marker" }); + }); + + it("keeps meaningful legacy config even when neither home has a database", () => { + const root = mkdtempSync(join(tmpdir(), "memos-win-home-")); + const legacyHome = join(root, "legacy", "memos-plugin"); + const installRoot = join(root, "local", "hermes", "memos-plugin"); + mkdirSync(legacyHome, { recursive: true }); + writeFileSync(join(legacyHome, "config.yaml"), "viewer:\n port: 18800\n"); + + expect(selectWindowsHermesRuntimeHome({ legacyHome, installRoot })) + .toMatchObject({ root: legacyHome, source: "legacy-data" }); + }); + + it("uses the canonical Windows home when it is the only database owner", () => { + const root = mkdtempSync(join(tmpdir(), "memos-win-home-")); + const legacyHome = join(root, "legacy", "memos-plugin"); + const installRoot = join(root, "local", "hermes", "memos-plugin"); + mkdirSync(join(installRoot, "data"), { recursive: true }); + writeFileSync(join(installRoot, "data", "memos.db"), "canonical"); + + expect(selectWindowsHermesRuntimeHome({ legacyHome, installRoot })) + .toMatchObject({ root: installRoot, source: "canonical-database" }); + }); + + it("refuses to guess when both Windows homes contain a database", () => { + const root = mkdtempSync(join(tmpdir(), "memos-win-home-")); + const legacyHome = join(root, "legacy", "memos-plugin"); + const installRoot = join(root, "local", "hermes", "memos-plugin"); + for (const home of [legacyHome, installRoot]) { + mkdirSync(join(home, "data"), { recursive: true }); + writeFileSync(join(home, "data", "memos.db"), home); + } + + expect(() => selectWindowsHermesRuntimeHome({ legacyHome, installRoot })) + .toThrow(/both Windows Hermes runtime homes contain a database/); + }); + + it("honours an existing marker before inspecting newly-created data", () => { + const root = mkdtempSync(join(tmpdir(), "memos-win-home-")); + const legacyHome = join(root, "legacy", "memos-plugin"); + const installRoot = join(root, "local", "hermes", "memos-plugin"); + mkdirSync(installRoot, { recursive: true }); + writeFileSync( + join(installRoot, ".memos-runtime-home"), + JSON.stringify({ version: 1, path: legacyHome, source: "legacy-config" }), + ); + mkdirSync(join(installRoot, "data"), { recursive: true }); + writeFileSync(join(installRoot, "data", "memos.db"), "newer"); + + expect(selectWindowsHermesRuntimeHome({ legacyHome, installRoot })) + .toMatchObject({ root: legacyHome, source: "marker" }); + }); }); diff --git a/apps/memos-local-plugin/tests/unit/config/writer.test.ts b/apps/memos-local-plugin/tests/unit/config/writer.test.ts index fd89ea053..e230acc14 100644 --- a/apps/memos-local-plugin/tests/unit/config/writer.test.ts +++ b/apps/memos-local-plugin/tests/unit/config/writer.test.ts @@ -28,6 +28,50 @@ describe("config/patchConfig", () => { } }); + it("seeds a missing Hermes config from the Hermes defaults", async () => { + const ctx = await makeTmpHome({ agent: "hermes" }); + cleanup = ctx.cleanup; + await fs.rm(ctx.home.configFile, { force: true }); + + const result = await patchConfig(ctx.home, { llm: { temperature: 0.3 } }, "hermes"); + + expect(result.config.viewer.port).toBe(18800); + expect(await fs.readFile(ctx.home.configFile, "utf8")).toMatch(/port:\s*18800/); + }); + + it("preserves an unedited Hermes port on disk while returning the effective runtime port", async () => { + const ctx = await makeTmpHome({ + agent: "hermes", + configYaml: + "viewer:\n port: 19000\n" + + "embedding:\n endpoint: https://old.example/v1\n" + + "llm:\n temperature: 0\n", + }); + cleanup = ctx.cleanup; + + const result = await patchConfig(ctx.home, { llm: { temperature: 0.4 } }, "hermes"); + + expect(result.config.viewer.port).toBe(18800); + const text = await fs.readFile(ctx.home.configFile, "utf8"); + expect(text).toMatch(/port:\s*19000/); + expect(text).toMatch(/endpoint:\s*https:\/\/old\.example\/v1/); + }); + + it("compatibly normalizes an explicitly patched Hermes port", async () => { + const ctx = await makeTmpHome({ agent: "hermes" }); + cleanup = ctx.cleanup; + + const result = await patchConfig(ctx.home, { + viewer: { port: 18799 }, + llm: { temperature: 0.4 }, + }, "hermes"); + + expect(result.config.viewer.port).toBe(18800); + const text = await fs.readFile(ctx.home.configFile, "utf8"); + expect(text).toMatch(/port:\s*18800/); + expect(text).toMatch(/temperature:\s*0\.4/); + }); + it("preserves user comments and field ordering when patching", async () => { const original = `# my notes viewer: diff --git a/apps/memos-local-plugin/tests/unit/install/hermes-provider-link.test.ts b/apps/memos-local-plugin/tests/unit/install/hermes-provider-link.test.ts index 581304d85..81ab18a1b 100644 --- a/apps/memos-local-plugin/tests/unit/install/hermes-provider-link.test.ts +++ b/apps/memos-local-plugin/tests/unit/install/hermes-provider-link.test.ts @@ -39,6 +39,57 @@ describe("Hermes provider install links", () => { expect(source).toContain("Failed to create junction at $Target"); }); + it("PowerShell installer preserves and explicitly passes the selected runtime home", () => { + const source = readFileSync(path.join(repoRoot, "install.ps1"), "utf8"); + + expect(source).toContain(".memos-runtime-home"); + expect(source).toContain(".hermes\\memos-plugin"); + expect(source).toContain("both Windows Hermes runtime homes contain a database"); + expect(source).toContain('--home=`"$HomeDir`"'); + expect(source).toContain('".migrations"'); + expect(source).toContain('Source = "environment"; Persist = $true'); + + const unixSource = readFileSync(path.join(repoRoot, "install.sh"), "utf8"); + expect(unixSource).toContain("daemon .migrations config.yaml"); + }); + + it("PowerShell installer stages dependencies and fails closed on native command errors", () => { + const source = readFileSync(path.join(repoRoot, "install.ps1"), "utf8"); + + expect(source).toContain("function Invoke-NativeChecked"); + expect(source).toContain("function Test-BetterSqlite3"); + expect(source).toContain("function Prepare-StagedPackage"); + expect(source).toContain("Get-CimInstance Win32_Process"); + expect(source).toMatch(/bridge\\\.\(cts\|cjs\|mts\|mjs\)/); + expect(source).toContain("npm install failed"); + expect(source).toContain("better-sqlite3 is not loadable"); + + const preserveLine = source.split("\n").find((line) => line.includes("$Preserve = @(")); + expect(preserveLine).toBeDefined(); + expect(preserveLine).not.toContain('"node_modules"'); + }); + + it("PowerShell installer stops Hermes only after staging succeeds", () => { + const source = readFileSync(path.join(repoRoot, "install.ps1"), "utf8"); + const deployStart = source.indexOf("function Deploy-Tarball"); + const deployEnd = source.indexOf("function Prepare-StagedPackage"); + const deploySource = source.slice(deployStart, deployEnd); + const preparePos = deploySource.indexOf("Prepare-StagedPackage"); + const handoffPos = deploySource.indexOf("& $BeforeSwap"); + + expect(preparePos).toBeGreaterThan(0); + expect(handoffPos).toBeGreaterThan(preparePos); + + const hermesStart = source.indexOf("function Install-Hermes"); + const hermesEnd = source.indexOf("if ($AgentSelection", hermesStart); + const hermesSource = source.slice(hermesStart, hermesEnd); + const deployPos = hermesSource.indexOf("Deploy-Tarball"); + const stopHermesPos = hermesSource.indexOf('Get-Process -Name "hermes"'); + + expect(deployPos).toBeGreaterThan(0); + expect(stopHermesPos).toBeGreaterThan(deployPos); + }); + it("Unix adapter installer guards HOME and cleans stale symlink targets", () => { const source = readFileSync( path.join(repoRoot, "adapters/hermes/install.hermes.sh"), diff --git a/apps/memos-local-plugin/tests/unit/install/install-ps1.test.ts b/apps/memos-local-plugin/tests/unit/install/install-ps1.test.ts new file mode 100644 index 000000000..303754579 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/install/install-ps1.test.ts @@ -0,0 +1,135 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +const REPO_ROOT = path.resolve(__dirname, "..", "..", ".."); +const SCRIPT = path.join(REPO_ROOT, "install.ps1"); + +function extractOpenClawConfigPatch(script: string): string { + const match = script.match(/\$NodeScript = @"\r?\n([\s\S]*?)\r?\n"@\r?\n/); + if (!match?.[1]) throw new Error("OpenClaw config patch script not found"); + return match[1]; +} + +describe("install.ps1 — OpenClaw config patch", () => { + it("preserves existing hook settings and enables conversation access", () => { + const script = readFileSync(SCRIPT, "utf8"); + + expect(script).toContain( + "typeof config.plugins.entries[pluginId].hooks !== 'object'", + ); + expect(script).toContain("Array.isArray(config.plugins.entries[pluginId].hooks)"); + expect(script).toContain("config.plugins.entries[pluginId].hooks = {};"); + expect(script).toContain( + "config.plugins.entries[pluginId].hooks.allowConversationAccess = true;", + ); + expect(script).not.toContain( + "delete config.plugins.entries[pluginId].hooks", + ); + }); + + it("keeps unrelated existing hook fields when patching openclaw.json", () => { + const tempDir = mkdtempSync(path.join(tmpdir(), "memos-install-ps1-")); + const configPath = path.join(tempDir, "openclaw.json"); + writeFileSync( + configPath, + JSON.stringify({ + plugins: { + entries: { + "memos-local-plugin": { + hooks: { customHostSetting: "keep", allowConversationAccess: false }, + }, + }, + }, + }), + ); + + try { + const result = spawnSync( + process.execPath, + ["-e", extractOpenClawConfigPatch(readFileSync(SCRIPT, "utf8"))], + { + encoding: "utf8", + env: { + ...process.env, + CONFIG_PATH: configPath, + PLUGIN_ID: "memos-local-plugin", + INSTALL_PATH: path.join(tempDir, "plugin"), + SOURCE_KIND: "path", + SOURCE_SPEC: "local.tgz", + PLUGIN_VERSION: "test-version", + LEGACY_JSON: "memos-local-openclaw-plugin", + }, + }, + ); + expect(result.status, result.stderr).toBe(0); + + const patched = JSON.parse(readFileSync(configPath, "utf8")) as { + plugins: { + entries: Record }>; + installs?: Record; + }; + }; + expect(patched.plugins.entries["memos-local-plugin"].hooks).toEqual({ + customHostSetting: "keep", + allowConversationAccess: true, + }); + expect(patched.plugins.installs).toBeUndefined(); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("removes only MemOS legacy install records and preserves unrelated old-version records", () => { + const tempDir = mkdtempSync(path.join(tmpdir(), "memos-install-ps1-")); + const configPath = path.join(tempDir, "openclaw.json"); + writeFileSync( + configPath, + JSON.stringify({ + plugins: { + installs: { + "memos-local-plugin": { source: "path", installPath: "old-memos" }, + "memos-local-openclaw-plugin": { + source: "path", + installPath: "legacy-memos", + }, + "another-plugin": { source: "npm", spec: "another-plugin@1.0.0" }, + }, + }, + }), + ); + + try { + const result = spawnSync( + process.execPath, + ["-e", extractOpenClawConfigPatch(readFileSync(SCRIPT, "utf8"))], + { + encoding: "utf8", + env: { + ...process.env, + CONFIG_PATH: configPath, + PLUGIN_ID: "memos-local-plugin", + INSTALL_PATH: path.join(tempDir, "plugin"), + SOURCE_KIND: "path", + SOURCE_SPEC: "local.tgz", + PLUGIN_VERSION: "test-version", + LEGACY_JSON: "memos-local-openclaw-plugin", + }, + }, + ); + expect(result.status, result.stderr).toBe(0); + + const patched = JSON.parse(readFileSync(configPath, "utf8")) as { + plugins: { installs?: Record }; + }; + expect(patched.plugins.installs).toEqual({ + "another-plugin": { source: "npm", spec: "another-plugin@1.0.0" }, + }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts b/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts index 52853ff4a..523ca9c3b 100644 --- a/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts +++ b/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts @@ -8,7 +8,7 @@ import net from "node:net"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createMemoryCore, @@ -391,6 +391,69 @@ describe("MemoryCore façade", () => { expect(res.query.query).toBe("how do I build this project?"); }); + it("writes one memos_search api log when turn.start reuses the same turn key", async () => { + pipeline = createPipeline(buildDeps(db!)); + core = createMemoryCore( + pipeline, + resolveHome("openclaw", "/tmp/memos-mc-test"), + "test", + ); + await core.init(); + + const turn = { + agent: "openclaw" as const, + sessionId: "s-turn-start-log-idempotency", + userText: "你知道快乐星球吗", + ts: 1_700_000_000_000, + turnKey: "s-turn-start-log-idempotency:1", + }; + const first = await core.onTurnStart(turn); + const replay = await core.onTurnStart({ ...turn, ts: turn.ts + 1 }); + + expect(replay.query.episodeId).toBe(first.query.episodeId); + const { logs } = await core.listApiLogs({ + toolName: "memos_search", + limit: 10, + }); + expect(logs).toHaveLength(1); + expect(JSON.parse(logs[0]!.inputJson)).toMatchObject({ + type: "turn_start", + sessionId: first.query.sessionId, + episodeId: first.query.episodeId, + }); + }); + + it("allows a failed turn.start with a turn key to be retried and logged", async () => { + pipeline = createPipeline(buildDeps(db!)); + const originalOnTurnStart = pipeline.onTurnStart.bind(pipeline); + vi.spyOn(pipeline, "onTurnStart") + .mockRejectedValueOnce(new Error("simulated retrieval failure")) + .mockImplementation(originalOnTurnStart); + core = createMemoryCore( + pipeline, + resolveHome("openclaw", "/tmp/memos-mc-test"), + "test", + ); + await core.init(); + + const turn = { + agent: "openclaw" as const, + sessionId: "s-turn-start-log-retry", + userText: "retry this retrieval", + ts: 1_700_000_000_000, + turnKey: "s-turn-start-log-retry:1", + }; + await expect(core.onTurnStart(turn)).rejects.toThrow("simulated retrieval failure"); + await expect(core.onTurnStart({ ...turn, ts: turn.ts + 1 })).resolves.toBeDefined(); + + const { logs } = await core.listApiLogs({ + toolName: "memos_search", + limit: 10, + }); + expect(logs).toHaveLength(2); + expect(logs.map((log) => log.success).sort()).toEqual([false, true]); + }); + it("scopes shared traces to creator, same framework, or hub team", async () => { pipeline = createPipeline(buildDeps(db!)); core = createMemoryCore( diff --git a/apps/memos-local-plugin/tests/unit/server/admin.test.ts b/apps/memos-local-plugin/tests/unit/server/admin.test.ts index dc026650e..014f3ac14 100644 --- a/apps/memos-local-plugin/tests/unit/server/admin.test.ts +++ b/apps/memos-local-plugin/tests/unit/server/admin.test.ts @@ -1,4 +1,7 @@ import { EventEmitter } from "node:events"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -18,6 +21,8 @@ vi.mock("node:child_process", () => ({ })); describe("admin lifecycle routes", () => { + const tempDirs: string[] = []; + beforeEach(() => { vi.useFakeTimers(); spawnMock.mockReset(); @@ -34,8 +39,27 @@ describe("admin lifecycle routes", () => { afterEach(() => { vi.clearAllTimers(); vi.useRealTimers(); + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } }); + function makeDbFixture(): { root: string; dbFile: string } { + const root = mkdtempSync(join(tmpdir(), "memos-admin-clear-")); + tempDirs.push(root); + const dataDir = join(root, "data"); + mkdirSync(dataDir, { recursive: true }); + const dbFile = join(dataDir, "memos.db"); + for (const suffix of ["", "-wal", "-shm"]) { + writeFileSync(dbFile + suffix, suffix || "db"); + } + return { root, dbFile }; + } + + function makeResponse(): EventEmitter & { writableFinished: boolean } { + return Object.assign(new EventEmitter(), { writableFinished: false }); + } + it("lets the supervisor replace a managed Hermes viewer", async () => { const requestShutdown = vi.fn(); const routes = new Routes(); @@ -98,6 +122,234 @@ describe("admin lifecycle routes", () => { expect(requestShutdown).toHaveBeenCalledOnce(); }); + it("keeps an unsupervised Windows Hermes viewer alive for manual restart", async () => { + const requestShutdown = vi.fn(); + const routes = new Routes(); + registerAdminRoutes( + routes, + { core: {} as MemoryCore }, + { + agent: "hermes", + lifecycle: { supervised: false, platform: "win32", requestShutdown }, + }, + ); + + const restart = routes.getExact("POST /api/v1/admin/restart"); + const result = await restart!({} as never); + + expect(result).toMatchObject({ + ok: true, + restarting: false, + manualRestartRequired: true, + platform: "win32", + }); + expect(spawnMock).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(500); + expect(requestShutdown).not.toHaveBeenCalled(); + }); + + it("keeps Windows OpenClaw alive and returns manual gateway restart instructions", async () => { + const requestShutdown = vi.fn(); + const routes = new Routes(); + registerAdminRoutes( + routes, + { core: {} as MemoryCore }, + { + agent: "openclaw", + lifecycle: { platform: "win32", requestShutdown }, + }, + ); + + const restart = routes.getExact("POST /api/v1/admin/restart"); + const result = await restart!({} as never); + + expect(result).toMatchObject({ + ok: true, + restarting: false, + manualRestartRequired: true, + platform: "win32", + message: expect.stringContaining("openclaw gateway stop"), + }); + await vi.advanceTimersByTimeAsync(500); + expect(requestShutdown).not.toHaveBeenCalled(); + }); + + it("retains the supervised restart response for Unix OpenClaw", async () => { + const routes = new Routes(); + registerAdminRoutes( + routes, + { core: {} as MemoryCore }, + { agent: "openclaw", lifecycle: { platform: "linux", supervised: true } }, + ); + + const restart = routes.getExact("POST /api/v1/admin/restart"); + const result = await restart!({} as never); + + expect(result).toEqual({ ok: true, restarting: true }); + }); + + it("refuses Windows clear-data while the Hermes bridge is still connected", async () => { + const shutdown = vi.fn(); + const requestShutdown = vi.fn(); + const { root, dbFile } = makeDbFixture(); + const routes = new Routes(); + registerAdminRoutes( + routes, + { + core: { shutdown } as unknown as MemoryCore, + home: { root, dbFile }, + bridgeStatus: () => ({ + status: "connected", + lastOkAt: Date.now(), + lastErrorAt: null, + lastError: null, + }), + }, + { + agent: "hermes", + lifecycle: { platform: "win32", requestShutdown }, + }, + ); + + const clearData = routes.getExact("POST /api/v1/admin/clear-data"); + const result = await clearData!({} as never); + + expect(result).toMatchObject({ + ok: false, + manualCloseRequired: true, + platform: "win32", + }); + expect(shutdown).not.toHaveBeenCalled(); + expect(requestShutdown).not.toHaveBeenCalled(); + expect(existsSync(dbFile)).toBe(true); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it("clears Windows data only after Hermes disconnects and requests a manual restart", async () => { + const shutdown = vi.fn().mockResolvedValue(undefined); + const requestShutdown = vi.fn(); + const { root, dbFile } = makeDbFixture(); + writeFileSync(join(root, "bridge-status.json"), "{}"); + const routes = new Routes(); + const res = makeResponse(); + registerAdminRoutes( + routes, + { + core: { shutdown } as unknown as MemoryCore, + home: { root, dbFile }, + bridgeStatus: () => ({ + status: "disconnected", + lastOkAt: null, + lastErrorAt: Date.now(), + lastError: "Hermes chat disconnected", + }), + }, + { + agent: "hermes", + lifecycle: { platform: "win32", requestShutdown }, + }, + ); + + const clearData = routes.getExact("POST /api/v1/admin/clear-data"); + const result = await clearData!({ res } as never); + + expect(result).toMatchObject({ + ok: true, + cleared: true, + restarting: false, + manualRestartRequired: true, + platform: "win32", + }); + expect(shutdown).toHaveBeenCalledOnce(); + for (const suffix of ["", "-wal", "-shm"]) { + expect(existsSync(dbFile + suffix)).toBe(false); + } + expect(existsSync(join(root, "bridge-status.json"))).toBe(false); + expect(spawnMock).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(2_000); + expect(requestShutdown).not.toHaveBeenCalled(); + + res.writableFinished = true; + res.emit("finish"); + res.emit("close"); + await vi.advanceTimersByTimeAsync(299); + expect(requestShutdown).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(requestShutdown).toHaveBeenCalledOnce(); + }); + + it("does not report Windows clear-data success when a database path cannot be removed", async () => { + const shutdown = vi.fn().mockResolvedValue(undefined); + const requestShutdown = vi.fn(); + const root = mkdtempSync(join(tmpdir(), "memos-admin-clear-fail-")); + tempDirs.push(root); + const dbFile = join(root, "data", "memos.db"); + mkdirSync(dbFile, { recursive: true }); + const routes = new Routes(); + const res = makeResponse(); + registerAdminRoutes( + routes, + { + core: { shutdown } as unknown as MemoryCore, + home: { root, dbFile }, + bridgeStatus: () => ({ + status: "disconnected", + lastOkAt: null, + lastErrorAt: Date.now(), + lastError: "Hermes chat disconnected", + }), + }, + { + agent: "hermes", + lifecycle: { platform: "win32", requestShutdown }, + }, + ); + + const clearData = routes.getExact("POST /api/v1/admin/clear-data"); + const result = await clearData!({ res } as never); + + expect(result).toMatchObject({ + ok: false, + cleared: false, + manualRestartRequired: true, + platform: "win32", + }); + expect(String((result as { error?: unknown }).error)).toContain("memos.db"); + expect(spawnMock).not.toHaveBeenCalled(); + + res.emit("close"); + await vi.advanceTimersByTimeAsync(999); + expect(requestShutdown).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(requestShutdown).toHaveBeenCalledOnce(); + }); + + it("keeps the existing Unix clear-data replacement path", async () => { + const shutdown = vi.fn().mockResolvedValue(undefined); + const requestShutdown = vi.fn(); + const { root, dbFile } = makeDbFixture(); + const routes = new Routes(); + registerAdminRoutes( + routes, + { core: { shutdown } as unknown as MemoryCore, home: { root, dbFile } }, + { + agent: "hermes", + lifecycle: { platform: "linux", supervised: false, requestShutdown }, + }, + ); + + const clearData = routes.getExact("POST /api/v1/admin/clear-data"); + const result = await clearData!({} as never); + + expect(result).toMatchObject({ ok: true, restarting: true }); + expect(spawnMock).toHaveBeenCalledWith( + "bash", + expect.anything(), + expect.anything(), + ); + }); + it("recognises launchd and systemd without treating the macOS shell sentinel as supervised", () => { expect(isSupervisorManagedProcess({ XPC_SERVICE_NAME: "ai.memtensor.memos-local-hermes" })).toBe(true); expect(isSupervisorManagedProcess({ XPC_SERVICE_NAME: "ai.memtensor.memos-local-hermes.nova" })).toBe(true); diff --git a/apps/memos-local-plugin/tests/unit/viewer/model-test-error.test.ts b/apps/memos-local-plugin/tests/unit/viewer/model-test-error.test.ts new file mode 100644 index 000000000..2bd3c34aa --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/viewer/model-test-error.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; + +import { ApiError } from "../../../viewer/src/api/client"; +import { classifyModelTestFailure } from "../../../viewer/src/model-test-error"; + +describe("classifyModelTestFailure", () => { + it("keeps backend model errors distinct from Viewer availability", async () => { + const healthProbe = vi.fn(); + const result = await classifyModelTestFailure( + new ApiError("model_test_failed", "upstream model rejected the request", 502), + healthProbe, + ); + + expect(result).toBe("model_failure"); + expect(healthProbe).not.toHaveBeenCalled(); + }); + + it("reports a model failure when Viewer still answers health", async () => { + const result = await classifyModelTestFailure( + new TypeError("Failed to fetch"), + async () => ({ ok: true }), + ); + + expect(result).toBe("model_failure"); + }); + + it("treats an HTTP health error as proof that Viewer is online", async () => { + const result = await classifyModelTestFailure( + new TypeError("Failed to fetch"), + async () => { + throw new ApiError("unauthorized", "Unauthorized", 401); + }, + ); + + expect(result).toBe("model_failure"); + }); + + it("reports Viewer offline only when both requests have a transport failure", async () => { + const result = await classifyModelTestFailure( + new TypeError("Failed to fetch"), + async () => { + throw new TypeError("Failed to fetch"); + }, + ); + + expect(result).toBe("viewer_offline"); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/viewer/restart.test.ts b/apps/memos-local-plugin/tests/unit/viewer/restart.test.ts index 88e8e426e..574d3ee3e 100644 --- a/apps/memos-local-plugin/tests/unit/viewer/restart.test.ts +++ b/apps/memos-local-plugin/tests/unit/viewer/restart.test.ts @@ -13,7 +13,14 @@ const fakeWindow = { }; import { health } from "../../../viewer/src/stores/health"; -import { triggerRestart } from "../../../viewer/src/stores/restart"; +import { + beginClearData, + markClearResultUnknown, + resolveRestartAgent, + restartState, + triggerCleared, + triggerRestart, +} from "../../../viewer/src/stores/restart"; describe("viewer restart flow", () => { const originalFetch = globalThis.fetch; @@ -22,6 +29,7 @@ describe("viewer restart flow", () => { vi.useFakeTimers(); fakeWindow.location.href = ""; health.value = { ok: true, agent: "hermes" }; + restartState.value = { phase: "idle" }; }); afterEach(() => { @@ -50,4 +58,141 @@ describe("viewer restart flow", () => { expect(healthChecks).toBe(2); expect(fakeWindow.location.href).toMatch(/^\/\?_t=\d+$/); }); + + it("stops polling when Windows requires a manual restart", async () => { + globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({ + ok: true, + restarting: false, + manualRestartRequired: true, + platform: "win32", + message: "Close Hermes and start it again.", + }), { status: 200 })) as typeof fetch; + + await triggerRestart(); + + expect(globalThis.fetch).toHaveBeenCalledOnce(); + expect(restartState.value).toEqual({ + phase: "manualRestartRequired", + message: "Close Hermes and start it again.", + }); + expect(fakeWindow.location.href).toBe(""); + }); + + it("shows manual restart instructions returned by Windows OpenClaw", async () => { + health.value = { ok: true, agent: "openclaw" }; + globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({ + ok: true, + restarting: false, + manualRestartRequired: true, + platform: "win32", + message: "Run openclaw gateway stop && openclaw gateway start.", + }), { status: 200 })) as typeof fetch; + + await triggerRestart(); + + expect(globalThis.fetch).toHaveBeenCalledOnce(); + expect(restartState.value).toEqual({ + phase: "manualRestartRequired", + message: "Run openclaw gateway stop && openclaw gateway start.", + }); + expect(resolveRestartAgent()).toBe("openclaw"); + expect(fakeWindow.location.href).toBe(""); + }); + + it("asks the user to close Hermes before retrying Windows clear-data", async () => { + await triggerCleared({ + ok: false, + manualCloseRequired: true, + platform: "win32", + message: "Close Hermes completely, then retry clearing data.", + }); + + expect(restartState.value).toEqual({ + phase: "manualCloseRequired", + }); + expect(fakeWindow.location.href).toBe(""); + }); + + it("stops polling after Windows clear-data requires a manual restart", async () => { + await triggerCleared({ + ok: true, + cleared: true, + restarting: false, + manualRestartRequired: true, + platform: "win32", + message: "Data cleared. Start Hermes again to restart Memory Viewer.", + }); + + expect(restartState.value).toEqual({ + phase: "manualClearRestartRequired", + }); + expect(fakeWindow.location.href).toBe(""); + }); + + it("does not report success when Windows could not fully clear the database", async () => { + await triggerCleared({ + ok: false, + cleared: false, + restarting: false, + manualRestartRequired: true, + platform: "win32", + message: "Data was not fully cleared.", + }); + + expect(restartState.value).toEqual({ phase: "clearFailed" }); + }); + + it("clears a stale close-Hermes prompt as soon as clear-data is retried", () => { + restartState.value = { + phase: "manualCloseRequired", + message: "stale first-attempt message", + }; + + beginClearData(); + + expect(restartState.value).toEqual({ phase: "clearing" }); + }); + + it("uses a conservative unknown-result state when the clear request disconnects", () => { + restartState.value = { + phase: "manualCloseRequired", + message: "stale first-attempt message", + }; + + beginClearData(); + markClearResultUnknown(); + + expect(restartState.value).toEqual({ phase: "clearResultUnknown" }); + }); + + it("keeps OpenClaw restart instructions after health goes offline", () => { + health.value = { ok: true, agent: "openclaw" }; + + beginClearData(); + health.value = null; + markClearResultUnknown(); + + expect(resolveRestartAgent()).toBe("openclaw"); + }); + + it("keeps OpenClaw restart instructions when save-and-restart times out", async () => { + health.value = { ok: true, agent: "openclaw" }; + globalThis.fetch = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + if (init?.method === "POST") { + return new Response(JSON.stringify({ ok: true, restarting: true }), { + status: 200, + }); + } + throw new TypeError("gateway is down"); + }) as typeof fetch; + + const restarting = triggerRestart(); + const rejected = expect(restarting).rejects.toThrow("restart did not complete"); + health.value = null; + await vi.runAllTimersAsync(); + await rejected; + + expect(restartState.value).toEqual({ phase: "restartFailed" }); + expect(resolveRestartAgent()).toBe("openclaw"); + }); }); diff --git a/apps/memos-local-plugin/viewer/src/components/RestartOverlay.tsx b/apps/memos-local-plugin/viewer/src/components/RestartOverlay.tsx index c1aea0701..9869c5beb 100644 --- a/apps/memos-local-plugin/viewer/src/components/RestartOverlay.tsx +++ b/apps/memos-local-plugin/viewer/src/components/RestartOverlay.tsx @@ -7,28 +7,21 @@ * online. Both flows use this full-screen overlay so the user sees the * same blocking restart affordance instead of a passive success card. */ -import { restartState, dismissRestartBanner } from "../stores/restart"; -import { health } from "../stores/health"; +import { + restartState, + dismissRestartBanner, + resolveRestartAgent, + type RestartPhase, +} from "../stores/restart"; import { t } from "../stores/i18n"; import { Icon } from "./Icon"; function FullScreenSpinner() { const s = restartState.value; - const agentType = health.value?.agent === "openclaw" ? "openclaw" : "hermes"; - - const message = - s.phase === "restartFailed" - ? t("restart.failed") - : s.phase === "waitingUp" - ? t("restart.waitingUp") - : agentType === "hermes" - ? t("restart.restarting.hermes") - : t("restart.restarting"); - - const hint = - s.phase === "restartFailed" - ? t(`restart.failedHint.${agentType}` as any) - : t("restart.autoRefresh"); + const agentType = resolveRestartAgent(); + const message = overlayMessage(s.phase, agentType, s.message); + const hint = overlayHint(s.phase, agentType); + const terminal = isTerminalPhase(s.phase); return (
- {s.phase !== "restartFailed" ? ( + {!terminal ? (
{message}
{hint}
- {s.phase === "restartFailed" && ( + {terminal && (