Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
ensure_viewer_daemon,
kill_zombie_bridges,
)
from hermes_home import resolve_hermes_home # noqa: E402
from shared_bridge_runtime import ( # noqa: E402
HERMES_HOOK_DISPATCHER,
SHARED_BRIDGE_REGISTRY,
Expand Down Expand Up @@ -129,7 +130,9 @@ def _resolved_memos_runtime_home() -> Path:
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()
# Fallback: use the canonical Hermes home (HERMES_HOME → LOCALAPPDATA →
# ~/.hermes on POSIX). See hermes_home.resolve_hermes_home and issue #2221.
return (resolve_hermes_home() / "memos-plugin").resolve()


def _memos_runtime_env_snapshot(runtime_home: Path | None = None) -> dict[str, str]:
Expand Down Expand Up @@ -1291,7 +1294,7 @@ def _extract_child_tool_calls(self, child_session_id: str = "") -> list[dict[str
sessions_dir = (
Path(self._hermes_home).expanduser() / "sessions"
if self._hermes_home
else Path.home() / ".hermes" / "sessions"
else resolve_hermes_home() / "sessions"
)
session_path = sessions_dir / f"session_{child_session_id}.json"
try:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any

from hermes_home import resolve_hermes_home


if TYPE_CHECKING:
from collections.abc import Callable
Expand Down Expand Up @@ -66,8 +68,15 @@ 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}"
default_home = Path(env.get("HOME", "") or Path.home()) / agent_home / "memos-plugin"
# Hermes gets a platform-aware home (HERMES_HOME → LOCALAPPDATA on
# Windows → ~/.hermes on POSIX). Other agents keep the ~/.<agent>
# convention because they do not participate in the HERMES_HOME
# contract. See issue #2221.
if agent == "hermes":
default_home = resolve_hermes_home(env=env) / "memos-plugin"
else:
agent_home = f".{agent}"
default_home = Path(env.get("HOME", "") or Path.home()) / agent_home / "memos-plugin"
return _expanded_path(str(default_home), env)


Expand Down
102 changes: 102 additions & 0 deletions apps/memos-local-plugin/adapters/hermes/memos_provider/hermes_home.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Canonical Hermes-home resolver used by the memos-local-plugin adapter.

Mirrors Hermes' own ``_get_platform_default_hermes_home`` so the plugin's
runtime data, PID files, and native import sources land inside the same
directory the Hermes daemon reads on every platform.

Resolution precedence:

1. ``HERMES_HOME`` environment variable (path is expanded + resolved).
2. On win32: ``%LOCALAPPDATA%\\hermes`` (with fallback
``~/AppData/Local/hermes`` when ``LOCALAPPDATA`` is unset).
3. On any other platform: ``~/.hermes``.

Regression: issue #2221 — the plugin previously hard-coded ``~/.hermes``
even on Windows, which put its data outside Hermes' real home.
"""

from __future__ import annotations

import os
import sys

from pathlib import Path


def _expand(
value: str,
env: dict[str, str] | None = None,
platform: str | None = None,
) -> Path:
"""Expand ~ using the platform-appropriate current-user home.

Only the bare ``~``, ``~/…`` and ``~\\…`` forms are supported: the
plugin resolves the *current* user's Hermes home, so shell-style
``~username/…`` values (which point at a different user's home) are
rejected with :class:`ValueError` rather than being silently resolved
against the current working directory.
"""
src = value
effective_env = dict(os.environ) if env is None else env
effective_platform = platform if platform is not None else sys.platform
if effective_platform == "win32":
home = effective_env.get("USERPROFILE", "").strip()
home = home or effective_env.get("HOME", "").strip()
else:
home = effective_env.get("HOME", "").strip()
home = home or effective_env.get("USERPROFILE", "").strip()
if src.startswith("~"):
base = home or str(Path.home())
if src == "~":
src = base
elif src.startswith(("~/", "~\\")):
src = str(Path(base) / src[2:])
else:
# e.g. "~alice/hermes" — POSIX-style named-user expansion is
# deliberately out of scope. Fail loudly so callers get a
# clear signal instead of a path resolved against CWD.
raise ValueError(f"named-user tilde paths are not supported: {value!r}")
return Path(src).resolve()


def resolve_hermes_home(
env: dict[str, str] | None = None,
platform: str | None = None,
) -> Path:
"""Return the canonical Hermes home directory for the given env/platform.

``env`` and ``platform`` default to ``os.environ`` and ``sys.platform``
respectively; callers pass them explicitly to keep the resolver
unit-testable without process mutation.
"""
effective_env = dict(os.environ) if env is None else dict(env)
effective_platform = platform if platform is not None else sys.platform

hermes_home = effective_env.get("HERMES_HOME", "").strip()
if hermes_home:
return _expand(hermes_home, effective_env, effective_platform)

if effective_platform == "win32":
local_appdata = effective_env.get("LOCALAPPDATA", "").strip()
if local_appdata:
return _expand(local_appdata, effective_env, effective_platform) / "hermes"
# Match Hermes' fallback: <home>/AppData/Local/hermes. Use
# ``.strip()`` guards so an env var that is *set* to an empty
# string (common in scrubbed subprocess envs) still falls back
# to ``Path.home()`` rather than resolving CWD.
home = (
effective_env.get("USERPROFILE", "").strip()
or effective_env.get("HOME", "").strip()
or str(Path.home())
)
return _expand(home, effective_env, effective_platform) / "AppData" / "Local" / "hermes"

home = (
effective_env.get("HOME", "").strip()
or effective_env.get("USERPROFILE", "").strip()
or str(Path.home())
)
return _expand(home, effective_env, effective_platform) / ".hermes"


__all__ = ["resolve_hermes_home"]
24 changes: 8 additions & 16 deletions apps/memos-local-plugin/bridge.cts
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,8 @@ 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 {
const agentHome = agent === "hermes" ? ".hermes" : ".openclaw";
return path.join(
process.env.HOME ?? "/tmp",
agentHome,
"memos-plugin",
"daemon",
filename,
);
function pidFilePath(runtimeHome: string, filename: string = PID_FILENAME): string {
return path.join(runtimeHome, "daemon", filename);
}

function readPidFile(pidPath: string): number | null {
Expand Down Expand Up @@ -160,13 +153,17 @@ function killExistingBridge(pidPath: string, timeoutMs = 5000): void {

async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const { resolveHome } = (await importEsm(
runtimeModule("core/config/paths.ts", "dist/core/config/paths.js")
)) as typeof import("./core/config/paths.js");
const pidRuntimeHome = resolveHome(args.agent, args.home).root;

// ─── Singleton: kill previous bridge that owns the viewer port ───
const pidPath = pidFilePath(args.agent);
const pidPath = pidFilePath(pidRuntimeHome);
const stdioPidFilename = args.runtimeScope
? `bridge-stdio-${args.runtimeScope}.pid`
: STDIO_PID_FILENAME;
const stdioPidPath = pidFilePath(args.agent, stdioPidFilename);
const stdioPidPath = pidFilePath(pidRuntimeHome, stdioPidFilename);
const ownsViewerPort = args.daemon || !args.noViewer;
const removeOwnedPidFile = () => {
if (ownsViewerPort) removePidFile(pidPath);
Expand Down Expand Up @@ -272,11 +269,6 @@ async function main(): Promise<void> {
runtimeModule("core/telemetry/index.ts", "dist/core/telemetry/index.js")
)) as typeof import("./core/telemetry/index.js");

// Resolve home early so we can use resolveHome with explicit defaultHome
const { resolveHome } = (await importEsm(
runtimeModule("core/config/paths.ts", "dist/core/config/paths.js")
)) as typeof import("./core/config/paths.js");

const resolvedHome = args.home
? resolveHome(args.agent, args.home)
: undefined;
Expand Down
19 changes: 6 additions & 13 deletions apps/memos-local-plugin/bridge.mts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";

import { resolveHome } from "./core/config/paths.js";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

Expand Down Expand Up @@ -78,15 +80,8 @@ function parseArgs(argv: readonly string[]): BridgeArgs {

const PID_FILENAME = "bridge.pid";

function pidFilePath(agent: string): string {
const agentHome = agent === "hermes" ? ".hermes" : ".openclaw";
return path.join(
process.env.HOME ?? "/tmp",
agentHome,
"memos-plugin",
"daemon",
PID_FILENAME,
);
function pidFilePath(runtimeHome: string): string {
return path.join(runtimeHome, "daemon", PID_FILENAME);
}

function readPidFile(pidPath: string): number | null {
Expand Down Expand Up @@ -146,9 +141,10 @@ function killExistingBridge(pidPath: string, timeoutMs = 5000): void {

async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const pidRuntimeHome = resolveHome(args.agent, args.home).root;

// ─── Singleton: kill previous bridge that owns the viewer port ───
const pidPath = pidFilePath(args.agent);
const pidPath = pidFilePath(pidRuntimeHome);
const ownsViewerPort = args.daemon || !args.noViewer;
const removeOwnedPidFile = () => {
if (ownsViewerPort) removePidFile(pidPath);
Expand Down Expand Up @@ -235,9 +231,6 @@ async function main(): Promise<void> {

const { Telemetry } = await import("./core/telemetry/index.js");

// Resolve home early so we can use resolveHome with explicit defaultHome
const { resolveHome } = await import("./core/config/paths.js");

const resolvedHome = args.home
? resolveHome(args.agent, args.home)
: undefined;
Expand Down
91 changes: 91 additions & 0 deletions apps/memos-local-plugin/core/config/hermes-home.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Canonical Hermes home resolver, shared by the plugin's bridge entries,
* `core/config/paths.ts`, and the server routes that reach into the
* Hermes host workspace-src-admin-dashboard for legacy/native imports.
*
* Mirrors Hermes' own `_get_platform_default_hermes_home` so the
* plugin's runtime state, PID files, and import sources land inside the
* directory the Hermes daemon actually reads:
*
* 1. `HERMES_HOME` env override (works on every platform).
* 2. On win32: `%LOCALAPPDATA%\hermes` (fallback: `<home>/AppData/Local/hermes`).
* 3. On any other platform: `~/.hermes`.
*
* Callers should not hardcode `~/.hermes`; issue #2221 exists because
* they historically did.
*/

import { homedir } from "node:os";
import { posix, win32 } from "node:path";

/**
* Structural env type; matches `process.env` but stays usable in tests
* that build a plain object without pulling in the whole `NodeJS.ProcessEnv`
* shape. Exported so callers who construct typed mock envs can share it.
*/
export type EnvLike = Record<string, string | undefined>;

/**
* Resolve the current user's home directory using the same precedence as
* Hermes' Python resolver: on Windows prefer `USERPROFILE`, on POSIX
* prefer `HOME`, falling back to node's `homedir()` on both. Empty
* strings are treated as unset so a scrubbed env doesn't collapse the
* fallback chain.
*/
function resolveHome(env: EnvLike, platform: NodeJS.Platform): string {
const homeEnv = (env["HOME"] ?? "").trim();
const userProfile = (env["USERPROFILE"] ?? "").trim();
if (platform === "win32") {
if (userProfile) return userProfile;
if (homeEnv) return homeEnv;
} else {
if (homeEnv) return homeEnv;
if (userProfile) return userProfile;
}
return homedir();
}

/**
* Expand a leading `~` against the supplied `home`. Only the bare `~`,
* `~/…` and `~\…` forms are supported — POSIX-style `~username/…`
* expansion is deliberately out of scope, so it raises rather than
* silently resolving relative to CWD.
*/
function expandHomePath(
value: string,
home: string,
platform: NodeJS.Platform,
): string {
const path = platform === "win32" ? win32 : posix;
let out = value;
if (out === "~") {
out = home;
} else if (out.startsWith("~/") || out.startsWith("~\\")) {
out = path.join(home, out.slice(2));
} else if (out.startsWith("~")) {
throw new Error(
`named-user tilde paths are not supported: ${JSON.stringify(value)}`,
);
}
return path.resolve(out);
}

export function resolveHermesHome(
env: EnvLike = process.env,
platform: NodeJS.Platform = process.platform,
): string {
const path = platform === "win32" ? win32 : posix;
const home = resolveHome(env, platform);

const hermesHome = (env["HERMES_HOME"] ?? "").trim();
if (hermesHome) return expandHomePath(hermesHome, home, platform);

if (platform === "win32") {
const localAppData = (env["LOCALAPPDATA"] ?? "").trim();
if (localAppData) return path.resolve(path.join(localAppData, "hermes"));
// Match Hermes' own fallback when LOCALAPPDATA is unset.
return path.resolve(path.join(home, "AppData", "Local", "hermes"));
}

return path.resolve(path.join(home, ".hermes"));
}
13 changes: 10 additions & 3 deletions apps/memos-local-plugin/core/config/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { homedir } from "node:os";
import { resolve as pathResolve, join } from "node:path";

import type { AgentKind } from "../types.js";
import { resolveHermesHome } from "./hermes-home.js";

export interface ResolvedHome {
/** Absolute path to the runtime root (e.g. ~/.openclaw/memos-plugin). */
Expand All @@ -29,7 +30,8 @@ export interface ResolvedHome {

const DEFAULT_HOME_BY_AGENT: Record<string, string> = {
openclaw: "{HOME}/.openclaw/memos-plugin",
hermes: "{HOME}/.hermes/memos-plugin",
// `hermes` is resolved dynamically via `resolveHermesHome()` so it
// honours `HERMES_HOME` and platform conventions (issue #2221).
};

/**
Expand All @@ -39,7 +41,9 @@ const DEFAULT_HOME_BY_AGENT: Record<string, string> = {
* 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. Built-in default for `agent`:
* - `hermes` → `resolveHermesHome() + /memos-plugin`
* - other → `~/.<agent>/memos-plugin`
*/
export function resolveHome(agent: AgentKind, defaultHome?: string): ResolvedHome {
const env = process.env;
Expand All @@ -58,8 +62,11 @@ export function resolveHome(agent: AgentKind, defaultHome?: string): ResolvedHom
} else if (defaultHome && defaultHome.trim()) {
root = pathResolve(expandHome(defaultHome));
configFile = join(root, "config.yaml");
} else if (agent === "hermes") {
root = pathResolve(join(resolveHermesHome(), "memos-plugin"));
configFile = join(root, "config.yaml");
} else {
const tmpl = DEFAULT_HOME_BY_AGENT[String(agent)] ?? `{HOME}/.${agent}/memos-plugin`;
const tmpl = DEFAULT_HOME_BY_AGENT[agent] ?? `{HOME}/.${agent}/memos-plugin`;
root = pathResolve(expandHome(tmpl));
configFile = join(root, "config.yaml");
}
Expand Down
Loading
Loading