diff --git a/_test_hermes_env.py b/_test_hermes_env.py new file mode 100644 index 0000000..831af21 --- /dev/null +++ b/_test_hermes_env.py @@ -0,0 +1,192 @@ +"""Test profile-aware path resolution (scripts/hermes_env.py + icarus/state.py mirrors). + +Covers the resolution logic behind the profile-isolated memory feature: +default (no HERMES_HOME) layout, modern (/profiles/) layout, +legacy (~/.hermes-) layout, and the FABRIC_DIR override that lets +profiles deliberately share a fabric. +""" +import os +import subprocess +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "scripts")) + +import hermes_env # noqa: E402 + +all_ok = True + + +def check(name, cond): + global all_ok + if not cond: + print(f"FAIL: {name}") + all_ok = False + + +class env: + """Context manager that patches os.environ for the block, then restores it.""" + + def __init__(self, **kw): + self.kw = kw + self.saved = {} + + def __enter__(self): + self.saved = {k: os.environ.get(k) for k in self.kw} + for k, v in self.kw.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + def __exit__(self, *exc): + for k, v in self.saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +HOME = os.path.expanduser("~") + +# ── hermes_env.hermes_home() ───────────────────────────────────────────── + +with env(HERMES_HOME=None): + check("hermes_home: default falls back to ~/.hermes", + str(hermes_env.hermes_home()) == f"{HOME}/.hermes") + +with env(HERMES_HOME="/tmp/hh/profiles/coder"): + check("hermes_home: honors HERMES_HOME when set", + str(hermes_env.hermes_home()) == "/tmp/hh/profiles/coder") + +with env(HERMES_HOME=" "): + check("hermes_home: blank/whitespace HERMES_HOME treated as unset", + str(hermes_env.hermes_home()) == f"{HOME}/.hermes") + +with env(HERMES_HOME="~/.hermes/profiles/coder"): + check("hermes_home: expands ~ in HERMES_HOME", + str(hermes_env.hermes_home()) == f"{HOME}/.hermes/profiles/coder") + +# ── hermes_env.profile_name() / is_profile() ───────────────────────────── + +with env(HERMES_HOME=None): + check("profile_name: default profile is ''", hermes_env.profile_name() == "") + check("is_profile: False for default profile", hermes_env.is_profile() is False) + +with env(HERMES_HOME="/home/u/.hermes/profiles/coder"): + check("profile_name: modern layout /profiles/", + hermes_env.profile_name() == "coder") + check("is_profile: True for modern profile layout", hermes_env.is_profile() is True) + +with env(HERMES_HOME="/home/u/.hermes-reviewer"): + check("profile_name: legacy .hermes- layout", + hermes_env.profile_name() == "reviewer") + check("is_profile: True for legacy profile layout", hermes_env.is_profile() is True) + +with env(HERMES_HOME="/home/u/.hermes-reviewer/"): + check("profile_name: legacy layout strips trailing slash", + hermes_env.profile_name() == "reviewer") + +with env(HERMES_HOME="/some/custom/hermes-dir"): + check("profile_name: non-profile-shaped HERMES_HOME is treated as default ('')", + hermes_env.profile_name() == "") + check("is_profile: False for non-profile-shaped HERMES_HOME", + hermes_env.is_profile() is False) + +# ── hermes_env.fabric_dir() ─────────────────────────────────────────────── + +with env(HERMES_HOME=None, FABRIC_DIR=None): + check("fabric_dir: default profile falls back to ~/fabric", + str(hermes_env.fabric_dir()) == f"{HOME}/fabric") + +with env(HERMES_HOME="/home/u/.hermes/profiles/coder", FABRIC_DIR=None): + check("fabric_dir: profile without override gets its own /fabric", + str(hermes_env.fabric_dir()) == "/home/u/.hermes/profiles/coder/fabric") + +with env(HERMES_HOME="/home/u/.hermes/profiles/reviewer", FABRIC_DIR=None): + check("fabric_dir: a second profile gets a *different* fabric than the first", + str(hermes_env.fabric_dir()) != "/home/u/.hermes/profiles/coder/fabric") + +with env(HERMES_HOME="/home/u/.hermes/profiles/coder", FABRIC_DIR="/shared/fabric"): + check("fabric_dir: explicit FABRIC_DIR always wins over profile default", + str(hermes_env.fabric_dir()) == "/shared/fabric") + +with env(HERMES_HOME=None, FABRIC_DIR="/shared/fabric"): + check("fabric_dir: explicit FABRIC_DIR wins for default profile too", + str(hermes_env.fabric_dir()) == "/shared/fabric") + +# ── hermes_env.*_db() / logs / wiki / dlq path composition ─────────────── + +with env(HERMES_HOME="/home/u/.hermes/profiles/coder"): + hh = hermes_env.hermes_home() + check("state_db: under profile home", hermes_env.state_db() == hh / "state.db") + check("memory_store_db: under profile home", + hermes_env.memory_store_db() == hh / "memory_store.db") + check("logs_dir: under profile home", hermes_env.logs_dir() == hh / "logs") + check("soul_path: under profile home", hermes_env.soul_path() == hh / "SOUL.md") + check("wiki_state_file: under profile home", + hermes_env.wiki_state_file() == hh / "wiki_ingest_state.json") + check("wiki_failures_file: under profile home", + hermes_env.wiki_failures_file() == hh / "wiki_ingest_failures.json") + check("dlq_report_log: under profile home", + hermes_env.dlq_report_log() == hh / "cron" / "output" / "dlq_reports.jsonl") + check("dlq_report_dir: under profile home", + hermes_env.dlq_report_dir() == hh / "cron" / "output" / "quality_report") + check("query_telemetry_log: under profile home/logs", + hermes_env.query_telemetry_log() == hh / "logs" / "query-telemetry.jsonl") + check("reflection_log: under profile home/logs", + hermes_env.reflection_log() == hh / "logs" / "reflection_trigger.log") + + +# ── icarus/state.py mirrors the same resolution (module-level constants, +# so exercised via subprocess — each scenario needs a fresh import) ──── + +def state_py(env_overrides, expr): + """Run `expr` against a freshly-imported icarus.state under env_overrides.""" + child_env = dict(os.environ) + for k, v in env_overrides.items(): + if v is None: + child_env.pop(k, None) + else: + child_env[k] = v + proc = subprocess.run( + [sys.executable, "-c", f"import icarus.state as s; print({expr})"], + cwd=os.path.dirname(__file__) or ".", + env=child_env, + capture_output=True, + text=True, + ) + check(f"state.py subprocess ok ({expr} under {env_overrides})", proc.returncode == 0) + return proc.stdout.strip() + + +check("icarus.state: default FABRIC_DIR == ~/fabric", + state_py({"HERMES_HOME": None}, "s.FABRIC_DIR") == f"{HOME}/fabric") + +check("icarus.state: profile FABRIC_DIR == /fabric", + state_py({"HERMES_HOME": "/tmp/hh/profiles/coder"}, "s.FABRIC_DIR") + == "/tmp/hh/profiles/coder/fabric") + +check("icarus.state: profile AGENT_NAME auto-detected from HERMES_HOME", + state_py({"HERMES_HOME": "/tmp/hh/profiles/coder", "HERMES_AGENT_NAME": None}, + "s.AGENT_NAME") == "coder") + +check("icarus.state: explicit HERMES_AGENT_NAME wins over auto-detection", + state_py({"HERMES_HOME": "/tmp/hh/profiles/coder", "HERMES_AGENT_NAME": "custom"}, + "s.AGENT_NAME") == "custom") + +check("icarus.state: legacy .hermes- AGENT_NAME auto-detected", + state_py({"HERMES_HOME": "/home/u/.hermes-reviewer", "HERMES_AGENT_NAME": None}, + "s.AGENT_NAME") == "reviewer") + +with env(HERMES_HOME="/tmp/hh/profiles/coder", FABRIC_DIR=None): + check("icarus.state and hermes_env agree on FABRIC_DIR for the same profile", + state_py({"HERMES_HOME": "/tmp/hh/profiles/coder", "FABRIC_DIR": None}, + "s.FABRIC_DIR") == str(hermes_env.fabric_dir())) + +if all_ok: + print("=== ALL HERMES_ENV TESTS PASS ===") + sys.exit(0) +else: + print("=== HERMES_ENV TESTS FAILED ===") + sys.exit(1) diff --git a/_test_setup_profile.py b/_test_setup_profile.py new file mode 100644 index 0000000..6030c21 --- /dev/null +++ b/_test_setup_profile.py @@ -0,0 +1,137 @@ +"""Test the --profile argument handling in setup.sh in isolation. + +setup.sh itself needs Docker/pip/network to run end-to-end, so these tests +extract just the self-contained pieces this PR touches (profile-name +parsing/validation, HERMES_HOME export, per-profile cron marker) and run +them under bash directly, without executing the rest of the installer. +""" +import os +import re +import subprocess +import sys + +SETUP_SH = os.path.join(os.path.dirname(__file__), "setup.sh") + +all_ok = True + + +def check(name, cond): + global all_ok + if not cond: + print(f"FAIL: {name}") + all_ok = False + + +def extract(text, start_marker, end_marker): + start = text.index(start_marker) + end = text.index(end_marker, start) + return text[start:end] + + +with open(SETUP_SH) as f: + SCRIPT = f.read() + +PROFILE_BLOCK = extract(SCRIPT, 'PROFILE_NAME=""', 'VAULT_PATH="${VAULT_PATH:-${HOME}/vault}"') +CRON_MARKER_BLOCK = extract(SCRIPT, "CRON_ENTRY=", '\n\nif crontab -l') + +check("extracted profile block looks right", + "export HERMES_HOME" in PROFILE_BLOCK and "PROFILE_NAME" in PROFILE_BLOCK) +check("extracted cron marker block looks right", + "CRON_MARKER=" in CRON_MARKER_BLOCK) + + +def run_profile_block(args, extra_prelude=""): + """Run the profile-parsing block with $@ set to `args`; return (rc, stdout, stderr).""" + script = f"""#!/usr/bin/env bash +set -euo pipefail +FAIL=0 +fail() {{ printf "FAIL_CALL: %s\\n" "$1" >&2; FAIL=$((FAIL + 1)); }} +HOME="{os.environ.get('HOME', '/home/testuser')}" +{extra_prelude} +{PROFILE_BLOCK} +echo "PROFILE_NAME=${{PROFILE_NAME}}" +echo "HERMES_HOME=${{HERMES_HOME}}" +python3 -c 'import os; print("HERMES_HOME_EXPORTED=" + os.environ.get("HERMES_HOME", ""))' +""" + proc = subprocess.run( + ["bash", "-c", script, "--"] + args, + capture_output=True, text=True, + ) + return proc.returncode, proc.stdout, proc.stderr + + +# ── No --profile: default HERMES_HOME, still exported ──────────────────── + +rc, out, err = run_profile_block([]) +check("no --profile: exits 0", rc == 0) +check("no --profile: HERMES_HOME defaults to $HOME/.hermes", + re.search(r"^HERMES_HOME=.*/\.hermes$", out, re.M) is not None) +check("no --profile: HERMES_HOME is exported to subprocesses", + "HERMES_HOME_EXPORTED=" not in out) + +# ── --profile (space form) ───────────────────────────────────────── + +rc, out, err = run_profile_block(["--profile", "coder"]) +check("--profile coder: exits 0", rc == 0) +check("--profile coder: PROFILE_NAME parsed", "PROFILE_NAME=coder" in out) +check("--profile coder: HERMES_HOME under profiles/coder", + re.search(r"^HERMES_HOME=.*/\.hermes/profiles/coder$", out, re.M) is not None) +check("--profile coder: HERMES_HOME exported to subprocess (regression: setup_db.py bug)", + "HERMES_HOME_EXPORTED=" not in out + and re.search(r"HERMES_HOME_EXPORTED=.*/profiles/coder$", out, re.M) is not None) + +# ── --profile= (equals form) ──────────────────────────────────────── + +rc, out, err = run_profile_block(["--profile=reviewer"]) +check("--profile=reviewer: exits 0", rc == 0) +check("--profile=reviewer: PROFILE_NAME parsed", "PROFILE_NAME=reviewer" in out) +check("--profile=reviewer: HERMES_HOME under profiles/reviewer", + re.search(r"^HERMES_HOME=.*/\.hermes/profiles/reviewer$", out, re.M) is not None) + +# ── Invalid profile names are rejected (path-traversal hardening) ──────── + +for bad in ["../../etc", "a/b", "a b", "coder;rm -rf /"]: + rc, out, err = run_profile_block(["--profile", bad]) + check(f"invalid profile name rejected: {bad!r}", rc != 0 and "FAIL_CALL:" in err) + +# ── Valid charset (letters, digits, _, -) is accepted ───────────────────── + +rc, out, err = run_profile_block(["--profile", "my-agent_2"]) +check("valid profile name with _ and - accepted", rc == 0 and "PROFILE_NAME=my-agent_2" in out) + + +# ── Cron marker is per-profile (regression: second profile install being +# silently skipped because it matched the first profile's marker) ────── + +def run_cron_marker(profile_name): + script = f"""#!/usr/bin/env bash +set -euo pipefail +PROFILE_NAME="{profile_name}" +REPO_DIR="/tmp/repo" +HERMES_HOME="/tmp/hh" +{CRON_MARKER_BLOCK} +echo "CRON_MARKER=${{CRON_MARKER}}" +""" + proc = subprocess.run(["bash", "-c", script], capture_output=True, text=True) + check(f"cron marker block runs cleanly for profile={profile_name!r}", proc.returncode == 0) + m = re.search(r"^CRON_MARKER=(.*)$", proc.stdout, re.M) + return m.group(1) if m else None + + +default_marker = run_cron_marker("") +coder_marker = run_cron_marker("coder") +reviewer_marker = run_cron_marker("reviewer") + +check("default install marker unchanged (upgrade compat)", + default_marker == "# memory-os wiki watcher") +check("profile markers differ from the default marker", + coder_marker not in (None, default_marker) and reviewer_marker not in (None, default_marker)) +check("two different profiles get two different markers", + coder_marker != reviewer_marker) + +if all_ok: + print("=== ALL SETUP.SH PROFILE TESTS PASS ===") + sys.exit(0) +else: + print("=== SETUP.SH PROFILE TESTS FAILED ===") + sys.exit(1) diff --git a/icarus/README.md b/icarus/README.md index 4767853..9b4ccdd 100644 --- a/icarus/README.md +++ b/icarus/README.md @@ -256,18 +256,37 @@ Export modes: - `normal` -- excludes low-value and skips noisy unstructured session notes unless grounded - `high-volume` -- everything -## Profiles (Hermes v0.6.0) +## Profiles (Hermes v0.6.0+) + +Hermes supports multiple profiles, and Memory OS is profile-aware. When a +non-default profile is active, Hermes sets `HERMES_HOME` to +`/profiles/` (e.g. `~/.hermes/profiles/coder`), and Memory OS +resolves every Hermes-owned path from it — fabric, `state.db`, +`memory_store.db`, logs, wiki state, DLQ, SOUL.md. Each profile gets its +own memory automatically. ```bash hermes profile create coder hermes profile create reviewer --clone -mkdir -p ~/.hermes-coder/plugins/icarus ~/.hermes-reviewer/plugins/icarus -cp -r icarus-plugin/* ~/.hermes-coder/plugins/icarus/ -cp -r icarus-plugin/* ~/.hermes-reviewer/plugins/icarus/ + +# Install Memory OS / Icarus into each profile +bash setup.sh --profile coder +bash setup.sh --profile reviewer + hermes -p coder chat ``` -Both profiles write to the same `FABRIC_DIR`, so the reviewer sees the coder's work. +By default each profile writes to its own `FABRIC_DIR` +(`/fabric`), so coder and reviewer memories stay isolated. + +To deliberately **share** a fabric between profiles (e.g. the reviewer reads +the coder's work), set `FABRIC_DIR` explicitly to the same absolute path in +each profile's `.env`: + +```bash +# ~/.hermes/profiles/coder/.env and ~/.hermes/profiles/reviewer/.env +FABRIC_DIR=/home/you/vault/fabric +``` ## Fallback models diff --git a/icarus/fabric-retrieve.py b/icarus/fabric-retrieve.py index fe3a0f3..e499ded 100644 --- a/icarus/fabric-retrieve.py +++ b/icarus/fabric-retrieve.py @@ -19,8 +19,19 @@ from datetime import datetime, timezone from pathlib import Path -FABRIC_DIR = Path(os.environ.get("FABRIC_DIR", Path.home() / "fabric")) -STATE_DB = Path(os.environ.get("STATE_DB_PATH", Path.home() / ".hermes" / "state.db")) +# Profile-aware paths: resolve through scripts/hermes_env when run standalone, +# else fall back to ~/.hermes for the default profile. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) +try: + from hermes_env import hermes_home, fabric_dir + _HH = hermes_home + _FD = fabric_dir +except ImportError: + _HH = lambda: Path.home() / ".hermes" + _FD = lambda: Path.home() / "fabric" + +FABRIC_DIR = Path(os.environ.get("FABRIC_DIR", str(_FD()))) +STATE_DB = Path(os.environ.get("STATE_DB_PATH", str(_HH() / "state.db"))) # ── SQLite fabric index (avoids glob+parse on every retrieval) ────────── diff --git a/icarus/hooks.py b/icarus/hooks.py index c2c4341..ccf5bef 100644 --- a/icarus/hooks.py +++ b/icarus/hooks.py @@ -309,12 +309,17 @@ def _search_qdrant(query, top_k=2, threshold=0.72): def _resolve_state_db(): - """Locate the Hermes session DB. Prefer state.HERMES_HOME, fall back to ~/.hermes.""" + """Locate the Hermes session DB (profile-aware). + + Resolves via state.hermes_home() — which follows HERMES_HOME when a + non-default profile is active — and falls back to the default profile's + ~/.hermes/state.db. + """ import sqlite3 # noqa: F401 (ensure available) candidates = [] - home = getattr(state, "HERMES_HOME", None) - if home: - candidates.append(Path(home) / "state.db") + home = getattr(state, "hermes_home", None) + if callable(home): + candidates.append(home() / "state.db") candidates.append(Path.home() / ".hermes" / "state.db") for c in candidates: if c and c.exists(): @@ -402,9 +407,9 @@ def _search_facts(query, top_k=3): """ import sqlite3 db = Path.home() / ".hermes" / "memory_store.db" - home = getattr(state, "HERMES_HOME", None) - if home and (Path(home) / "memory_store.db").exists(): - db = Path(home) / "memory_store.db" + home = getattr(state, "hermes_home", None) + if callable(home): + db = home() / "memory_store.db" if not db.exists(): return [] diff --git a/icarus/state.py b/icarus/state.py index 86390ec..4528431 100644 --- a/icarus/state.py +++ b/icarus/state.py @@ -7,6 +7,7 @@ import secrets import shutil import subprocess +import sys import tempfile import urllib.request import urllib.error @@ -16,13 +17,51 @@ logger = logging.getLogger(__name__) -FABRIC_DIR = Path(os.environ.get("FABRIC_DIR", Path.home() / "fabric")) +# ── Profile-aware paths ──────────────────────────────────────────────────── +# Hermes sets HERMES_HOME to /profiles/ when a non-default profile +# is active. Resolve every Hermes-owned path through state.hermes_home() so +# separate profiles get separate memories (state, fabric, logs, registry). +# An explicit FABRIC_DIR env var still wins — that is how you deliberately +# share a fabric between profiles. +# +# hermes_home/profile_name/is_profile live in scripts/hermes_env.py — import +# them from there (same pattern as fabric-retrieve.py) instead of duplicating +# the resolution logic, so there is one source of truth for profile layout. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) +try: + from hermes_env import hermes_home, profile_name, is_profile +except ImportError: + def hermes_home() -> Path: + env = os.environ.get("HERMES_HOME", "").strip() + return Path(env).expanduser() if env else Path.home() / ".hermes" + + def profile_name() -> str: + home = hermes_home() + if home.parent.name == "profiles": + return home.name + text = str(home) + if ".hermes-" in text: + return text.split(".hermes-")[-1].rstrip("/") + return "" + + def is_profile() -> bool: + return bool(profile_name()) + + HERMES_HOME = Path(os.environ.get("HERMES_HOME", "")) if os.environ.get("HERMES_HOME") else None AGENT_NAME = os.environ.get("HERMES_AGENT_NAME", "") PLUGIN_DIR = Path(__file__).parent -if not AGENT_NAME and HERMES_HOME and ".hermes-" in str(HERMES_HOME): - AGENT_NAME = str(HERMES_HOME).split(".hermes-")[-1].rstrip("/") +if not AGENT_NAME and HERMES_HOME: + AGENT_NAME = profile_name() + +FABRIC_DIR = Path( + os.environ.get( + "FABRIC_DIR", + # default: per-profile fabric when a profile is active, else ~/fabric + str(hermes_home() / "fabric") if is_profile() else str(Path.home() / "fabric"), + ) +) # ── Shared regexes (used by hooks.py and scoring) ──────── DECISION_RE = re.compile( @@ -40,7 +79,7 @@ exchanges: list = [] # ── Training job tracking ──────────────────────────────── -_JOB_FILE = (HERMES_HOME or Path.home()) / ".icarus-training-job.txt" +_JOB_FILE = hermes_home() / ".icarus-training-job.txt" def _last_job_id(): @@ -54,7 +93,7 @@ def _save_job_id(jid): # ── Model registry ─────────────────────────────────────── -_REGISTRY_FILE = (HERMES_HOME or Path.home()) / ".icarus-models.json" +_REGISTRY_FILE = hermes_home() / ".icarus-models.json" def _load_registry(): @@ -78,7 +117,7 @@ def list_models(): # ── Retrieval telemetry ────────────────────────────────── -_TELEMETRY_FILE = (HERMES_HOME or Path.home()) / ".icarus-telemetry.jsonl" +_TELEMETRY_FILE = hermes_home() / ".icarus-telemetry.jsonl" # in-memory buffer for current session _recall_log: list = [] @@ -260,7 +299,7 @@ def build_brief(): # ── Creative state ─────────────────────────────────────── -_STATE_FILE = (HERMES_HOME or Path.home()) / ".icarus-state.json" +_STATE_FILE = hermes_home() / ".icarus-state.json" def load_creative(): diff --git a/scripts/backfill_decay_metadata.py b/scripts/backfill_decay_metadata.py index 2f501a7..3d9f1e7 100644 --- a/scripts/backfill_decay_metadata.py +++ b/scripts/backfill_decay_metadata.py @@ -44,12 +44,14 @@ from urllib.request import Request, urlopen from urllib.error import URLError +from hermes_env import logs_dir + # ─── Config ────────────────────────────────────────────────────────────────── QDRANT_URL = "http://localhost:6333" COLLECTION = "knowledge_base" BATCH_SIZE = 200 SCROLL_LIMIT = 200 -LOG_FILE = Path.home() / ".hermes/logs/decay_scanner.log" +LOG_FILE = logs_dir() / "decay_scanner.log" VAULT_ROOT = Path(os.environ.get("VAULT_PATH", str(Path.home() / "vault"))) # ─── Heuristics ────────────────────────────────────────────────────────────── diff --git a/scripts/context_enhancer.py b/scripts/context_enhancer.py index 1b5dc62..9d04a36 100755 --- a/scripts/context_enhancer.py +++ b/scripts/context_enhancer.py @@ -58,17 +58,18 @@ ) BM25_MODEL = "Qdrant/bm25" -# Lineage config -LINEAGE_DB = os.environ.get( - "STATE_DB_PATH", - os.path.expanduser("~/.hermes/state.db") -) +# Lineage config (profile-aware) +# Ensure the scripts dir is importable both when run standalone and when +# imported as a package module (e.g. from the repo root in tests). +_SCRIPTS_DIR = str(Path(__file__).resolve().parent) +if _SCRIPTS_DIR not in sys.path: + sys.path.insert(0, _SCRIPTS_DIR) +from hermes_env import hermes_home, state_db, query_telemetry_log + +LINEAGE_DB = os.environ.get("STATE_DB_PATH", str(state_db())) # Telemetry config -TELEMETRY_LOG = os.environ.get( - "TELEMETRY_LOG_PATH", - os.path.expanduser("~/.hermes/logs/query-telemetry.jsonl") -) +TELEMETRY_LOG = os.environ.get("TELEMETRY_LOG_PATH", str(query_telemetry_log())) TELEMETRY_MAX_BYTES = 10 * 1024 * 1024 # 10MB rotation # ─── Lineage Registration ─────────────────────────────────────────────────── diff --git a/scripts/decay_scanner.py b/scripts/decay_scanner.py index 44e5eae..91651af 100755 --- a/scripts/decay_scanner.py +++ b/scripts/decay_scanner.py @@ -27,11 +27,13 @@ from datetime import datetime, timezone from pathlib import Path -# ─── Config ──────────────────────────────────────────────────────────────── +# ─── Config (profile-aware) ──────────────────────────────────────────────── +from hermes_env import hermes_home, logs_dir + QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333") COLLECTION = os.environ.get("QDRANT_COLLECTION", "knowledge_base") SCROLL_LIMIT = 100 # paginação Qdrant -LOG_DIR = Path.home() / ".hermes" / "logs" +LOG_DIR = logs_dir() LOG_FILE = LOG_DIR / "decay_scanner.log" # ─── Helpers ────────────────────────────────────────────────────────────── diff --git a/scripts/dlq_manager.py b/scripts/dlq_manager.py index 9e3845d..8954279 100755 --- a/scripts/dlq_manager.py +++ b/scripts/dlq_manager.py @@ -17,10 +17,15 @@ from dataclasses import dataclass, asdict, field from collections import Counter -# ─── Config ──────────────────────────────────────────────────────────────── -DLQ_PATH = os.path.expanduser("~/.hermes/wiki_ingest_failures.json") -REPORT_LOG = os.path.expanduser("~/.hermes/cron/output/dlq_reports.jsonl") -REPORT_DIR = os.path.expanduser("~/.hermes/cron/output/quality_report") +# ─── Config (profile-aware) ──────────────────────────────────────────────── +# Hermes sets HERMES_HOME to /profiles/ when a non-default profile +# is active; hermes_env resolves all Hermes-owned paths from it. +from hermes_env import hermes_home + +_HH = hermes_home() +DLQ_PATH = _HH / "wiki_ingest_failures.json" +REPORT_LOG = _HH / "cron" / "output" / "dlq_reports.jsonl" +REPORT_DIR = _HH / "cron" / "output" / "quality_report" MAX_REPORT_HISTORY = 100 # entradas no JSONL # ─── Data Model ───────────────────────────────────────────────────────────── @@ -54,7 +59,7 @@ def load_dlq() -> List[DLQEntry]: return [] def save_dlq(entries: List[DLQEntry]): - tmp = DLQ_PATH + ".tmp" + tmp = str(DLQ_PATH) + ".tmp" with open(tmp, "w") as f: json.dump([asdict(e) for e in entries], f, indent=2) f.flush() diff --git a/scripts/hermes_env.py b/scripts/hermes_env.py new file mode 100644 index 0000000..7e521a4 --- /dev/null +++ b/scripts/hermes_env.py @@ -0,0 +1,112 @@ +"""Hermes home resolution for Memory OS scripts. + +Hermes Agent supports multiple profiles. When a non-default profile is +active, Hermes sets ``HERMES_HOME`` to ``/profiles/`` (e.g. +``~/.hermes/profiles/fpvdeals``). Every script and module in Memory OS must +resolve Hermes-owned paths through :func:`hermes_home` so that separate +profiles get separate state databases, fabric directories, logs, and wiki +state — instead of all profiles stomping on the default profile's files. + +Resolution order: + +1. ``HERMES_HOME`` environment variable (set by Hermes for the active + profile, and propagated into cron subprocess environments). +2. ``~/.hermes`` (default profile / standalone usage). +""" + +import os +from pathlib import Path + + +def hermes_home() -> Path: + """Return the active Hermes home directory (profile-aware).""" + env = os.environ.get("HERMES_HOME", "").strip() + if env: + return Path(env).expanduser() + return Path.home() / ".hermes" + + +def profile_name() -> str: + """Return the active profile name, or \"\" for the default profile. + + Handles both modern (``/profiles/``) and legacy + (``~/.hermes-``) layouts. + """ + home = hermes_home() + if home.parent.name == "profiles": + return home.name + text = str(home) + if ".hermes-" in text: + return text.split(".hermes-")[-1].rstrip("/") + return "" + + +def is_profile() -> bool: + """True when running under a non-default Hermes profile.""" + return bool(profile_name()) + + +def fabric_dir() -> Path: + """Return the fabric (cross-session memory) directory for this profile. + + An explicit ``FABRIC_DIR`` env var always wins (this is how you share a + fabric between profiles deliberately). Otherwise each profile gets its + own fabric under its Hermes home, and the default profile keeps the + historical ``~/fabric`` location for backwards compatibility. + """ + env = os.environ.get("FABRIC_DIR", "").strip() + if env: + return Path(env).expanduser() + if is_profile(): + return hermes_home() / "fabric" + return Path.home() / "fabric" + + +def state_db() -> Path: + """Return the profile's session database (state.db).""" + return hermes_home() / "state.db" + + +def memory_store_db() -> Path: + """Return the profile's fact store database (memory_store.db).""" + return hermes_home() / "memory_store.db" + + +def logs_dir() -> Path: + """Return the profile's log directory.""" + return hermes_home() / "logs" + + +def soul_path() -> Path: + """Return the profile's SOUL.md path.""" + return hermes_home() / "SOUL.md" + + +def wiki_state_file() -> Path: + """Return the profile's wiki ingestion state file.""" + return hermes_home() / "wiki_ingest_state.json" + + +def wiki_failures_file() -> Path: + """Return the profile's wiki DLQ (dead-letter queue) file.""" + return hermes_home() / "wiki_ingest_failures.json" + + +def dlq_report_log() -> Path: + """Return the profile's DLQ report log.""" + return hermes_home() / "cron" / "output" / "dlq_reports.jsonl" + + +def dlq_report_dir() -> Path: + """Return the profile's DLQ report output directory.""" + return hermes_home() / "cron" / "output" / "quality_report" + + +def query_telemetry_log() -> Path: + """Return the profile's query telemetry log.""" + return logs_dir() / "query-telemetry.jsonl" + + +def reflection_log() -> Path: + """Return the profile's reflection trigger log.""" + return logs_dir() / "reflection_trigger.log" diff --git a/scripts/reflection_trigger.py b/scripts/reflection_trigger.py index be9e89e..952c959 100755 --- a/scripts/reflection_trigger.py +++ b/scripts/reflection_trigger.py @@ -28,6 +28,8 @@ from arq.connections import RedisSettings import redis.asyncio as aioredis +from hermes_env import hermes_home, reflection_log + # ─── Config ──────────────────────────────────────────────────────────────── ENV_PATH = Path.home() / "ai-stack" / "cognitive-agent" / ".env" if ENV_PATH.exists(): @@ -44,7 +46,7 @@ password=REDIS_PASSWORD or None, ) -LOG_FILE = Path.home() / ".hermes" / "logs" / "reflection_trigger.log" +LOG_FILE = reflection_log() def log_message(msg: str): @@ -105,7 +107,7 @@ async def check_budget() -> tuple[bool, int, int]: """Retorna (permitido, used, max) baseado no contador da hora no SQLite.""" try: import sqlite3 - db_path = Path.home() / ".hermes" / "state.db" + db_path = hermes_home() / "state.db" hour_window = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H") conn = sqlite3.connect(str(db_path)) c = conn.cursor() @@ -123,7 +125,7 @@ def increment_budget(): """Incrementa o contador de reflections no SQLite.""" try: import sqlite3 - db_path = Path.home() / ".hermes" / "state.db" + db_path = hermes_home() / "state.db" hour_window = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H") conn = sqlite3.connect(str(db_path)) c = conn.cursor() diff --git a/scripts/semantic_dedup.py b/scripts/semantic_dedup.py index 9a11f6f..e52a939 100755 --- a/scripts/semantic_dedup.py +++ b/scripts/semantic_dedup.py @@ -31,14 +31,16 @@ except ImportError: _has_qdrant_client = False -# ─── Config ──────────────────────────────────────────────────────────────── +# ─── Config (profile-aware) ──────────────────────────────────────────────── +from hermes_env import hermes_home, logs_dir + QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333") COLLECTION = os.environ.get("QDRANT_COLLECTION", "knowledge_base") SCROLL_LIMIT = 50 # paginação Qdrant (evita timeout em coleções grandes) SIMILARITY_THRESHOLD = 0.92 TOP_NEIGHBORS = 10 -LOG_DIR = Path.home() / ".hermes" / "logs" +LOG_DIR = logs_dir() LOG_FILE = LOG_DIR / "semantic_dedup.log" REPORT_FILE = LOG_DIR / "semantic_dedup_report.json" diff --git a/scripts/verify_soul_config.py b/scripts/verify_soul_config.py index 9abdd54..ac79b21 100644 --- a/scripts/verify_soul_config.py +++ b/scripts/verify_soul_config.py @@ -7,7 +7,9 @@ import sys from pathlib import Path -SOUL_PATH = Path.home() / ".hermes" / "SOUL.md" +from hermes_env import soul_path + +SOUL_PATH = soul_path() MEMORY_OS_MARKER = "" CHECKS = { @@ -24,7 +26,7 @@ ], "fix_hint": ( "Add the Ground Truth hierarchy from " - "modifications/soul-rulebook.md to ~/.hermes/SOUL.md" + "modifications/soul-rulebook.md to " + str(SOUL_PATH) ), }, "context_injection": { @@ -37,7 +39,7 @@ ], "fix_hint": ( "Add the Context Injection Convention from " - "modifications/soul-rulebook.md to ~/.hermes/SOUL.md" + "modifications/soul-rulebook.md to " + str(SOUL_PATH) ), }, "fact_feedback": { @@ -49,7 +51,7 @@ ], "fix_hint": ( "Add the Fact Feedback Rule from " - "modifications/soul-rulebook.md to ~/.hermes/SOUL.md" + "modifications/soul-rulebook.md to " + str(SOUL_PATH) ), }, "honcho_deprecation": { @@ -61,7 +63,7 @@ ], "fix_hint": ( "Add the Honcho deprecation notice from " - "modifications/soul-rulebook.md to ~/.hermes/SOUL.md" + "modifications/soul-rulebook.md to " + str(SOUL_PATH) ), }, } diff --git a/scripts/wiki_continuous_ingest.py b/scripts/wiki_continuous_ingest.py index 6d70b2e..7d5ce0c 100644 --- a/scripts/wiki_continuous_ingest.py +++ b/scripts/wiki_continuous_ingest.py @@ -17,14 +17,16 @@ from arq.connections import RedisSettings import redis.asyncio as aioredis -# ─── Config ──────────────────────────────────────────────────────────────── +# ─── Config (profile-aware) ──────────────────────────────────────────────── +from hermes_env import hermes_home, wiki_state_file, wiki_failures_file + ENV_PATH = Path.home() / "memory-os" / "docker" / ".env" if ENV_PATH.exists(): load_dotenv(ENV_PATH) WIKI_ROOT = Path(os.environ.get("WIKI_ROOT", str(Path.home() / "vault" / "wiki"))) -STATE_FILE = Path.home() / ".hermes" / "wiki_ingest_state.json" -FAILURES_FILE = Path.home() / ".hermes" / "wiki_ingest_failures.json" +STATE_FILE = wiki_state_file() +FAILURES_FILE = wiki_failures_file() REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "") # Fallback: read REDIS_PASSWORD from Docker .env if not in environment (cron context) diff --git a/setup.sh b/setup.sh index 7605c32..736f819 100755 --- a/setup.sh +++ b/setup.sh @@ -64,7 +64,50 @@ fi REPO_URL="https://github.com/ClaudioDrews/memory-os.git" REPO_DIR="${HOME}/memory-os" -HERMES_HOME="${HOME}/.hermes" + +# ── Profile support ───────────────────────────────────────────────────────── +# Pass --profile to install into that Hermes profile instead of the +# default ~/.hermes. Each profile gets its own HERMES_HOME +# (/profiles/), so its memory (fabric, state.db, logs) stays +# isolated from other profiles. +PROFILE_NAME="" +_prev_arg="" +for arg in "$@"; do + case "${arg}" in + --profile) + _prev_arg="--profile" + ;; + --profile=*) + PROFILE_NAME="${arg#--profile=}" + ;; + *) + if [ "${_prev_arg}" = "--profile" ]; then + PROFILE_NAME="${arg}" + _prev_arg="" + fi + ;; + esac +done + +# Restrict to a safe charset — PROFILE_NAME becomes a directory component +# below, so reject anything that could escape ~/.hermes/profiles/. +case "${PROFILE_NAME}" in + *[!A-Za-z0-9_-]*) + fail "Invalid --profile name '${PROFILE_NAME}' — use only letters, numbers, '_' and '-'" + exit 1 + ;; +esac + +if [ -n "${PROFILE_NAME}" ]; then + HERMES_HOME="${HOME}/.hermes/profiles/${PROFILE_NAME}" +else + HERMES_HOME="${HOME}/.hermes" +fi +# Export so subprocesses (setup/setup_db.py, python3 invocations below) that +# read HERMES_HOME from the environment see the active profile instead of +# silently falling back to ~/.hermes. +export HERMES_HOME + VAULT_PATH="${VAULT_PATH:-${HOME}/vault}" ENV_FILE="${HERMES_HOME}/.env" @@ -201,6 +244,15 @@ chmod -R 755 "${VAULT_PATH}/fabric" ok "Directory structure created at ${VAULT_PATH}" info "Permissions set to 755 on wiki/ and fabric/ (ensures Docker worker read access)" +if [ -n "${PROFILE_NAME}" ]; then + # Profiles default to their own fabric under HERMES_HOME (see Phase 8 — + # we deliberately don't write FABRIC_DIR into the profile .env), so + # create it here rather than relying on the shared ${VAULT_PATH}/fabric. + mkdir -p "${HERMES_HOME}/fabric" + chmod -R 755 "${HERMES_HOME}/fabric" + ok "Profile fabric directory created at ${HERMES_HOME}/fabric" +fi + # ────────────────────────────────────────────────────────────────────────────── # Phase 7: Docker Stack # ────────────────────────────────────────────────────────────────────────────── @@ -295,8 +347,16 @@ cd "${REPO_DIR}" # ────────────────────────────────────────────────────────────────────────────── banner "Phase 7b: Wiki Watcher" -CRON_ENTRY="0 * * * * cd ${REPO_DIR} && python3 scripts/wiki_continuous_ingest.py >> ${HERMES_HOME}/logs/wiki-ingest.log 2>&1" -CRON_MARKER="# memory-os wiki watcher" +CRON_ENTRY="0 * * * * cd ${REPO_DIR} && HERMES_HOME=${HERMES_HOME} python3 scripts/wiki_continuous_ingest.py >> ${HERMES_HOME}/logs/wiki-ingest.log 2>&1" +# System crontab doesn't propagate Hermes's profile env the way Hermes cron +# does, so HERMES_HOME is set inline above. The marker is per-profile too — +# otherwise a second `setup.sh --profile X` run would see the first +# profile's marker and skip installing its own cron entry. +if [ -n "${PROFILE_NAME}" ]; then + CRON_MARKER="# memory-os wiki watcher (${PROFILE_NAME})" +else + CRON_MARKER="# memory-os wiki watcher" +fi if crontab -l 2>/dev/null | grep -qF "${CRON_MARKER}"; then ok "Wiki watcher cron already installed" @@ -325,7 +385,14 @@ add_env() { echo "${key}=${value}" >> "${ENV_FILE}" } -add_env "FABRIC_DIR" "${VAULT_PATH}/fabric" +if [ -z "${PROFILE_NAME}" ]; then + add_env "FABRIC_DIR" "${VAULT_PATH}/fabric" +else + # Leave FABRIC_DIR unset so each profile falls back to its own + # /fabric (hermes_env.fabric_dir()) instead of every + # profile colliding on the shared ${VAULT_PATH}/fabric. + info "FABRIC_DIR left unset — profile '${PROFILE_NAME}' defaults to ${HERMES_HOME}/fabric" +fi add_env "ICARUS_EXTRACTION_MAX_TOKENS" "4096" add_env "ICARUS_EXTRACTION_MODEL" "deepseek/deepseek-v4-flash" add_env "EMBEDDING_API_BASE" "https://openrouter.ai/api/v1" diff --git a/setup/setup_db.py b/setup/setup_db.py index a6ac453..53d47af 100644 --- a/setup/setup_db.py +++ b/setup/setup_db.py @@ -22,8 +22,10 @@ from pathlib import Path -# ── Default paths ────────────────────────────────────────────────────────── -DEFAULT_HERMES_HOME = Path.home() / ".hermes" +# ── Default paths (profile-aware) ───────────────────────────────────────── +# Honor HERMES_HOME when set (Hermes sets it to /profiles/ for a +# non-default profile) so setup_db creates per-profile databases. +DEFAULT_HERMES_HOME = Path(os.environ.get("HERMES_HOME", "").strip() or (Path.home() / ".hermes")) DEFAULT_STATE_DB = DEFAULT_HERMES_HOME / "state.db" DEFAULT_MEMORY_DB = DEFAULT_HERMES_HOME / "memory_store.db"