From 4d240155551720962fa16b1a7ddbf51c99573359 Mon Sep 17 00:00:00 2001 From: mo <48237066+KingBoyAndGirl@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:20:58 +0800 Subject: [PATCH 01/19] feat: allow nowledge-mem.json agent_identity override when Hermes profile is default When Hermes uses the default profile, Hermes core passes agent_identity="default" to the memory provider. This makes it impossible to distinguish clients on the Nowledge Mem server when the same server is used by multiple machines. This change adds a fallback: when agent_identity is "default", check nowledge-mem.json for an explicit agent_identity field. This lets each machine set its own agent identity in the plugin config file without changing the Hermes profile. Backward compatible: if nowledge-mem.json has no agent_identity field, behavior is unchanged (remains "default"). --- nowledge-mem-hermes/provider.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/nowledge-mem-hermes/provider.py b/nowledge-mem-hermes/provider.py index 55992f70..e22b4807 100644 --- a/nowledge-mem-hermes/provider.py +++ b/nowledge-mem-hermes/provider.py @@ -283,7 +283,10 @@ def initialize(self, session_id: str, **kwargs: Any) -> None: return raw_identity = kwargs.get("agent_identity") - host_agent_id = str(raw_identity).strip() if raw_identity else None + identity = str(raw_identity).strip() if raw_identity else "" + if identity == "default": + identity = config.get("agent_identity", identity) + host_agent_id = identity or None try: context_bundle = getattr(self._client, "context_bundle")( source_app="hermes", @@ -804,6 +807,8 @@ def _resolve_space(config: Dict[str, Any], kwargs: Dict[str, Any]) -> str | None raw_identity = kwargs.get("agent_identity") identity = str(raw_identity or "").strip() + if identity == "default": + identity = config.get("agent_identity", identity) identity_map = config.get("space_by_identity") if isinstance(identity_map, str): try: From d7dc4f041ad9965ab7ee4b4877bf999cfb31a707 Mon Sep 17 00:00:00 2001 From: mo <48237066+KingBoyAndGirl@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:27:19 +0800 Subject: [PATCH 02/19] feat: auto-generate agent identity fingerprint from system sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the static config-fallback approach with automatic fingerprint generation: 1. /etc/machine-id — standard on systemd/Linux hosts 2. /proc/1/mountinfo — overlay upperdir layer ID for Docker/LazyCat containers On first run the fingerprint is persisted to nowledge-mem.json so it survives restarts without being regenerated. Resolution order: - Hermes named profile identity → use as-is - nowledge-mem.json agent_identity → use explicit override - Auto-generate fingerprint → save to nowledge-mem.json --- nowledge-mem-hermes/provider.py | 152 +++++++++++++++++++++++++++++--- 1 file changed, 139 insertions(+), 13 deletions(-) diff --git a/nowledge-mem-hermes/provider.py b/nowledge-mem-hermes/provider.py index e22b4807..64da845a 100644 --- a/nowledge-mem-hermes/provider.py +++ b/nowledge-mem-hermes/provider.py @@ -12,6 +12,7 @@ from __future__ import annotations +import hashlib import json import logging import os @@ -200,6 +201,13 @@ "startedAt", ) +# Ordered by preference: /etc/machine-id (systemd/Linux standard), +# then overlay root mountinfo (Docker/LazyCat containers). +_FINGERPRINT_SOURCES = ( + "/etc/machine-id", + "/proc/1/mountinfo", +) + def tool_error(message: Any, **extra: Any) -> str: """Return Hermes-style JSON error payloads across old and new releases.""" @@ -237,6 +245,7 @@ def __init__(self) -> None: self._session_id = "" self._saved_message_count = 0 self._saved_message_counts: Dict[str, int] = {} + self._agent_identity = "" self._delta_only_sessions: Set[str] = set() self._written_message_signatures: Dict[str, List[str]] = {} @@ -261,7 +270,8 @@ def initialize(self, session_id: str, **kwargs: Any) -> None: self._cron_skipped = True return - config = self._load_config(kwargs.get("hermes_home", "")) + hermes_home = kwargs.get("hermes_home", "") + config = self._load_config(hermes_home) raw_timeout = config.get("timeout", 30) try: timeout = int(raw_timeout or 30) @@ -273,7 +283,13 @@ def initialize(self, session_id: str, **kwargs: Any) -> None: timeout = 30 if timeout <= 0: timeout = 30 - self._resolved_space = self._resolve_space(config, kwargs) + + # Resolve agent identity once, auto-generating a fingerprint if needed. + self._agent_identity = self._resolve_agent_identity(config, kwargs) + if self._agent_identity and not config.get("agent_identity"): + self._save_agent_identity(hermes_home, self._agent_identity) + + self._resolved_space = self._resolve_space(config, self._agent_identity) self._client = NowledgeMemClient(timeout=timeout, space=self._resolved_space) self._activate_session(session_id or "", reset=True) @@ -282,11 +298,7 @@ def initialize(self, session_id: str, **kwargs: Any) -> None: self._client = None return - raw_identity = kwargs.get("agent_identity") - identity = str(raw_identity).strip() if raw_identity else "" - if identity == "default": - identity = config.get("agent_identity", identity) - host_agent_id = identity or None + host_agent_id = self._agent_identity or None try: context_bundle = getattr(self._client, "context_bundle")( source_app="hermes", @@ -307,7 +319,10 @@ def initialize(self, session_id: str, **kwargs: Any) -> None: logger.info( "Nowledge Mem provider initialized (CLI transport)", - extra={"space": self._resolved_space or "default"}, + extra={ + "space": self._resolved_space or "default", + "agent_identity": self._agent_identity or "default", + }, ) def system_prompt_block(self) -> str: @@ -780,6 +795,120 @@ def _response_error(result: Any) -> str: errors.append(str(item.get("error"))) return "; ".join(errors) or json.dumps(result, ensure_ascii=False)[:300] + # ── agent identity resolution ──────────────────────────────────── + + @staticmethod + def _resolve_agent_identity( + config: Dict[str, Any], + kwargs: Dict[str, Any], + ) -> str: + """Resolve the agent identity for this client. + + 1. Hermes gives a named profile identity (not "default") → use it. + 2. ``nowledge-mem.json`` has an explicit ``agent_identity`` → use it. + 3. Otherwise → generate a stable fingerprint from system sources. + """ + raw_identity = kwargs.get("agent_identity") + identity = str(raw_identity).strip() if raw_identity else "" + if identity and identity != "default": + return identity + config_identity = config.get("agent_identity") + if isinstance(config_identity, str) and config_identity.strip(): + return config_identity.strip() + fingerprint = NowledgeMemProvider._generate_fingerprint() + if fingerprint: + logger.info( + "Auto-generated agent identity fingerprint: %s", fingerprint + ) + return fingerprint + return identity + + @staticmethod + def _generate_fingerprint() -> str: + """Derive a stable agent-identity fingerprint from system sources. + + Tries each source in ``_FINGERPRINT_SOURCES`` in order: + + * ``/etc/machine-id`` — standard on systemd hosts. + * ``/proc/1/mountinfo`` — extracts the overlay upperdir layer hash + (unique per Docker/LazyCat container, persistent across restarts). + + Returns ``"hermes-XXXXXXXX"`` (8 hex chars) or an empty string. + """ + for source in _FINGERPRINT_SOURCES: + try: + raw = Path(source).read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): + continue + if not raw: + continue + + if source == "/proc/1/mountinfo": + raw = NowledgeMemProvider._extract_overlay_id(raw) + if not raw: + continue + + suffix = hashlib.sha256(raw.encode()).hexdigest()[:8] + return f"hermes-{suffix}" + + return "" + + @staticmethod + def _extract_overlay_id(mountinfo: str) -> str: + """Extract the overlay upperdir layer ID from /proc/1/mountinfo. + + Docker's overlay2 driver stores container-specific layers in + ``upperdir``. The directory name is a SHA256 hash unique to + the container and stable across restarts. + """ + import re as _re + for line in mountinfo.splitlines(): + if " / " not in line or "overlay" not in line: + continue + m = _re.search(r"upperdir=([^,]+)", line) + if not m: + continue + parts = m.group(1).rstrip("/").split("/") + for part in reversed(parts): + if len(part) >= 32 and all(c in "0123456789abcdef" for c in part): + return part + return "" + + @staticmethod + def _save_agent_identity(hermes_home: str, agent_identity: str) -> None: + """Persist the resolved agent identity into ``nowledge-mem.json`` + so the fingerprint survives restarts and is not regenerated.""" + if not hermes_home or not agent_identity: + return + config_path = Path(hermes_home) / "nowledge-mem.json" + try: + if config_path.exists(): + try: + cfg = json.loads(config_path.read_text(encoding="utf-8")) + except Exception: + cfg = {} + else: + cfg = {} + if not isinstance(cfg, dict): + cfg = {} + if cfg.get("agent_identity") == agent_identity: + return + cfg["agent_identity"] = agent_identity + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text( + json.dumps(cfg, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + logger.info( + "Persisted agent_identity=%s to %s", agent_identity, config_path + ) + except OSError as exc: + logger.debug( + "Could not save agent_identity to %s: %s", config_path, exc + ) + + # ── config loading / space resolution ──────────────────────────── + @staticmethod def _load_config(hermes_home: str) -> Dict[str, Any]: if hermes_home: @@ -799,16 +928,13 @@ def _load_config(hermes_home: str) -> Dict[str, Any]: return {} @staticmethod - def _resolve_space(config: Dict[str, Any], kwargs: Dict[str, Any]) -> str | None: + def _resolve_space(config: Dict[str, Any], identity: str) -> str | None: if "space" in config: raw_space = config.get("space") if isinstance(raw_space, str): return raw_space.strip() - raw_identity = kwargs.get("agent_identity") - identity = str(raw_identity or "").strip() - if identity == "default": - identity = config.get("agent_identity", identity) + identity = (identity or "").strip() identity_map = config.get("space_by_identity") if isinstance(identity_map, str): try: From e3289fd1065c91d457f798c1593d85cbb41dcf1f Mon Sep 17 00:00:00 2001 From: mo <48237066+KingBoyAndGirl@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:38:06 +0800 Subject: [PATCH 03/19] fix: update test_space_resolution.py for new _resolve_space signature _resolve_space now accepts (config, identity: str) instead of (config, kwargs: dict). Update all test call sites to pass the identity string directly. --- nowledge-mem-hermes/tests/test_space_resolution.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/nowledge-mem-hermes/tests/test_space_resolution.py b/nowledge-mem-hermes/tests/test_space_resolution.py index a392a541..8713956a 100644 --- a/nowledge-mem-hermes/tests/test_space_resolution.py +++ b/nowledge-mem-hermes/tests/test_space_resolution.py @@ -50,8 +50,7 @@ def test_configured_space_beats_env(self): os.environ["NMEM_SPACE"] = "Env Space" try: resolved = provider.NowledgeMemProvider._resolve_space( - {"space": "Configured Space"}, - {"agent_identity": "research"}, + {"space": "Configured Space"}, "research", ) self.assertEqual(resolved, "Configured Space") finally: @@ -68,15 +67,13 @@ def test_identity_map_beats_template(self): "ops": "Operations Agent", }, "space_template": "agent-{identity}", - }, - {"agent_identity": "research"}, + }, "research", ) self.assertEqual(resolved, "Research Agent") def test_template_falls_back_when_no_mapping(self): resolved = provider.NowledgeMemProvider._resolve_space( - {"space_template": "agent-{identity}"}, - {"agent_identity": "ops"}, + {"space_template": "agent-{identity}"}, "ops", ) self.assertEqual(resolved, "agent-ops") @@ -125,8 +122,7 @@ def test_non_string_space_falls_through_to_identity_resolution(self): { "space": None, "space_by_identity": {"research": "Research Agent"}, - }, - {"agent_identity": "research"}, + }, "research", ) self.assertEqual(resolved, "Research Agent") From 413a5dd320ca599437ed26c901a7146ab01e64cd Mon Sep 17 00:00:00 2001 From: mo <48237066+KingBoyAndGirl@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:40:04 +0800 Subject: [PATCH 04/19] fix: replace empty dict with empty string in _resolve_space test calls --- nowledge-mem-hermes/tests/test_space_resolution.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nowledge-mem-hermes/tests/test_space_resolution.py b/nowledge-mem-hermes/tests/test_space_resolution.py index 8713956a..2e80fa2c 100644 --- a/nowledge-mem-hermes/tests/test_space_resolution.py +++ b/nowledge-mem-hermes/tests/test_space_resolution.py @@ -108,7 +108,7 @@ def test_explicit_empty_space_beats_environment(self): try: resolved = provider.NowledgeMemProvider._resolve_space( {"space": ""}, - {}, + "", ) self.assertEqual(resolved, "") finally: @@ -137,7 +137,7 @@ def test_missing_identity_does_not_synthesize_space(self): "space_by_identity": {"default": "Default Agent"}, "space_template": "agent-{identity}", }, - {}, + "", ) self.assertIsNone(resolved) finally: @@ -388,4 +388,4 @@ def _fake_urlopen(request, **_kwargs): if __name__ == "__main__": - unittest.main() + unittest.main() \ No newline at end of file From 90b746d97e91391a95e94e1020584f3b3b948594 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=2EW?= Date: Sun, 21 Jun 2026 15:01:13 +0800 Subject: [PATCH 05/19] feat: add agent identity fingerprint to Codex and Claude Code plugins --- .../scripts/nmem-hook-save.py | 49 +++++++++++++++++ .../hooks/nmem-stop-save.py | 53 +++++++++++++++++-- 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py b/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py index 3ad1f1f8..af03cfca 100644 --- a/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py +++ b/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import hashlib import json import os import shutil @@ -108,6 +109,50 @@ def _cmd_exe_path(path: str) -> str: return "nmem.cmd" if Path(path).name.lower() == "nmem.cmd" else path + +_FINGERPRINT_SOURCES = ( + "/etc/machine-id", + "/proc/1/mountinfo", +) + + +def _host_agent_fingerprint(runtime: str) -> str: + for source in _FINGERPRINT_SOURCES: + try: + raw = Path(source).read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): + continue + if not raw: + continue + if source == "/proc/1/mountinfo": + extracted = _extract_overlay_id(raw) + if not extracted: + continue + raw = extracted + digest = hashlib.sha256(raw.encode("utf-8")).hexdigest() + return f"{runtime}-{digest[:8]}" + return "" + + +def _extract_overlay_id(mountinfo: str) -> str: + for line in mountinfo.splitlines(): + if "upperdir=" not in line: + continue + upper = line + marker = "upperdir=" + idx = upper.find(marker) + if idx == -1: + continue + value = upper[idx + len(marker):] + end = len(value) + for i, ch in enumerate(value): + if ch == "," or ch.isspace(): + end = i + break + upperdir = value[:end] + return Path(upperdir).name + return "" + def _build_nmem_command(nmem: str, *args: str) -> list[str]: if nmem.lower().endswith(".cmd"): return [ @@ -179,6 +224,10 @@ def _build_command( "--truncate", ] + host_agent_id = _host_agent_fingerprint(runtime) + if host_agent_id: + args.extend(["--host-agent-id", host_agent_id]) + session_id = _payload_value(payload, "session_id", "sessionId") or os.environ.get( "GROK_SESSION_ID", "" ).strip() diff --git a/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py b/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py index fd3b246b..4da4aba8 100644 --- a/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py +++ b/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py @@ -19,6 +19,13 @@ ATTEMPT_TIMEOUT_SECONDS = 8 SAVE_RETRY_DELAYS_SECONDS = (0.0, 0.5, 1.5, 3.0) CAPTURE_LOCK_STALE_SECONDS = 90 + +# Ordered by preference: /etc/machine-id (systemd/Linux standard), +# then overlay root mountinfo (Docker/LazyCat containers). +_FINGERPRINT_SOURCES = ( + "/etc/machine-id", + "/proc/1/mountinfo", +) SESSION_NOT_FOUND_MARKERS = ( "No codex sessions found", "Codex sessions directory not found", @@ -141,7 +148,6 @@ def _cleanup_stale_capture_locks(lock_root: Path, now: float) -> None: def _claim_capture_event(payload: dict[str, Any]) -> bool: - """Claim this Stop event so plugin and fallback hooks do not both import it.""" lock_root = _capture_lock_root(payload) try: lock_root.mkdir(parents=True, exist_ok=True) @@ -178,6 +184,44 @@ def _claim_capture_event(payload: dict[str, Any]) -> bool: return True +def _host_agent_fingerprint() -> str: + for source in _FINGERPRINT_SOURCES: + try: + raw = Path(source).read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): + continue + if not raw: + continue + if source == "/proc/1/mountinfo": + extracted = _extract_overlay_id(raw) + if not extracted: + continue + raw = extracted + digest = hashlib.sha256(raw.encode("utf-8")).hexdigest() + return f"codex-{digest[:8]}" + return "" + + +def _extract_overlay_id(mountinfo: str) -> str: + for line in mountinfo.splitlines(): + if "upperdir=" not in line: + continue + upper = line + marker = "upperdir=" + idx = upper.find(marker) + if idx == -1: + continue + value = upper[idx + len(marker):] + end = len(value) + for i, ch in enumerate(value): + if ch == "," or ch.isspace(): + end = i + break + upperdir = value[:end] + return Path(upperdir).name + return "" + + def _nmem_command() -> str | None: return shutil.which("nmem") or shutil.which("nmem.cmd") @@ -263,6 +307,10 @@ def _build_save_command( "--truncate", ] + host_agent_id = _host_agent_fingerprint() + if host_agent_id: + args.extend(["--host-agent-id", host_agent_id]) + cwd = _payload_value(payload, "cwd") if cwd: project = str(Path(cwd).expanduser()) @@ -363,9 +411,6 @@ def _run_save_with_retries( ) continue if last_proc.returncode == 0: - # Older nmem builds may not support --json. In that mode the - # best compatibility signal is the command's successful exit, - # matching the pre-0.1.10 hook behavior. return True, last_proc continue if last_proc.returncode == 0 and _capture_has_result(last_proc.stdout or ""): From f39e50b522b123c2f5b3bac68208f660c6f3fd7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=2EW?= Date: Sun, 21 Jun 2026 15:05:46 +0800 Subject: [PATCH 06/19] revert: remove --host-agent-id from Codex/Claude Code save hooks (CLI not yet supported) --- .../scripts/nmem-hook-save.py | 49 ----------------- .../hooks/nmem-stop-save.py | 53 ++----------------- 2 files changed, 4 insertions(+), 98 deletions(-) diff --git a/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py b/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py index af03cfca..3ad1f1f8 100644 --- a/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py +++ b/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py @@ -4,7 +4,6 @@ from __future__ import annotations import argparse -import hashlib import json import os import shutil @@ -109,50 +108,6 @@ def _cmd_exe_path(path: str) -> str: return "nmem.cmd" if Path(path).name.lower() == "nmem.cmd" else path - -_FINGERPRINT_SOURCES = ( - "/etc/machine-id", - "/proc/1/mountinfo", -) - - -def _host_agent_fingerprint(runtime: str) -> str: - for source in _FINGERPRINT_SOURCES: - try: - raw = Path(source).read_text(encoding="utf-8").strip() - except (OSError, UnicodeDecodeError): - continue - if not raw: - continue - if source == "/proc/1/mountinfo": - extracted = _extract_overlay_id(raw) - if not extracted: - continue - raw = extracted - digest = hashlib.sha256(raw.encode("utf-8")).hexdigest() - return f"{runtime}-{digest[:8]}" - return "" - - -def _extract_overlay_id(mountinfo: str) -> str: - for line in mountinfo.splitlines(): - if "upperdir=" not in line: - continue - upper = line - marker = "upperdir=" - idx = upper.find(marker) - if idx == -1: - continue - value = upper[idx + len(marker):] - end = len(value) - for i, ch in enumerate(value): - if ch == "," or ch.isspace(): - end = i - break - upperdir = value[:end] - return Path(upperdir).name - return "" - def _build_nmem_command(nmem: str, *args: str) -> list[str]: if nmem.lower().endswith(".cmd"): return [ @@ -224,10 +179,6 @@ def _build_command( "--truncate", ] - host_agent_id = _host_agent_fingerprint(runtime) - if host_agent_id: - args.extend(["--host-agent-id", host_agent_id]) - session_id = _payload_value(payload, "session_id", "sessionId") or os.environ.get( "GROK_SESSION_ID", "" ).strip() diff --git a/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py b/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py index 4da4aba8..fd3b246b 100644 --- a/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py +++ b/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py @@ -19,13 +19,6 @@ ATTEMPT_TIMEOUT_SECONDS = 8 SAVE_RETRY_DELAYS_SECONDS = (0.0, 0.5, 1.5, 3.0) CAPTURE_LOCK_STALE_SECONDS = 90 - -# Ordered by preference: /etc/machine-id (systemd/Linux standard), -# then overlay root mountinfo (Docker/LazyCat containers). -_FINGERPRINT_SOURCES = ( - "/etc/machine-id", - "/proc/1/mountinfo", -) SESSION_NOT_FOUND_MARKERS = ( "No codex sessions found", "Codex sessions directory not found", @@ -148,6 +141,7 @@ def _cleanup_stale_capture_locks(lock_root: Path, now: float) -> None: def _claim_capture_event(payload: dict[str, Any]) -> bool: + """Claim this Stop event so plugin and fallback hooks do not both import it.""" lock_root = _capture_lock_root(payload) try: lock_root.mkdir(parents=True, exist_ok=True) @@ -184,44 +178,6 @@ def _claim_capture_event(payload: dict[str, Any]) -> bool: return True -def _host_agent_fingerprint() -> str: - for source in _FINGERPRINT_SOURCES: - try: - raw = Path(source).read_text(encoding="utf-8").strip() - except (OSError, UnicodeDecodeError): - continue - if not raw: - continue - if source == "/proc/1/mountinfo": - extracted = _extract_overlay_id(raw) - if not extracted: - continue - raw = extracted - digest = hashlib.sha256(raw.encode("utf-8")).hexdigest() - return f"codex-{digest[:8]}" - return "" - - -def _extract_overlay_id(mountinfo: str) -> str: - for line in mountinfo.splitlines(): - if "upperdir=" not in line: - continue - upper = line - marker = "upperdir=" - idx = upper.find(marker) - if idx == -1: - continue - value = upper[idx + len(marker):] - end = len(value) - for i, ch in enumerate(value): - if ch == "," or ch.isspace(): - end = i - break - upperdir = value[:end] - return Path(upperdir).name - return "" - - def _nmem_command() -> str | None: return shutil.which("nmem") or shutil.which("nmem.cmd") @@ -307,10 +263,6 @@ def _build_save_command( "--truncate", ] - host_agent_id = _host_agent_fingerprint() - if host_agent_id: - args.extend(["--host-agent-id", host_agent_id]) - cwd = _payload_value(payload, "cwd") if cwd: project = str(Path(cwd).expanduser()) @@ -411,6 +363,9 @@ def _run_save_with_retries( ) continue if last_proc.returncode == 0: + # Older nmem builds may not support --json. In that mode the + # best compatibility signal is the command's successful exit, + # matching the pre-0.1.10 hook behavior. return True, last_proc continue if last_proc.returncode == 0 and _capture_has_result(last_proc.stdout or ""): From 7c86b2700ba5f996aa38a9130487675af78fc5a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=2EW?= Date: Sun, 21 Jun 2026 15:06:48 +0800 Subject: [PATCH 07/19] Revert "revert: remove --host-agent-id from Codex/Claude Code save hooks (CLI not yet supported)" This reverts commit f39e50b522b123c2f5b3bac68208f660c6f3fd7c. --- .../scripts/nmem-hook-save.py | 49 +++++++++++++++++ .../hooks/nmem-stop-save.py | 53 +++++++++++++++++-- 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py b/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py index 3ad1f1f8..af03cfca 100644 --- a/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py +++ b/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import hashlib import json import os import shutil @@ -108,6 +109,50 @@ def _cmd_exe_path(path: str) -> str: return "nmem.cmd" if Path(path).name.lower() == "nmem.cmd" else path + +_FINGERPRINT_SOURCES = ( + "/etc/machine-id", + "/proc/1/mountinfo", +) + + +def _host_agent_fingerprint(runtime: str) -> str: + for source in _FINGERPRINT_SOURCES: + try: + raw = Path(source).read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): + continue + if not raw: + continue + if source == "/proc/1/mountinfo": + extracted = _extract_overlay_id(raw) + if not extracted: + continue + raw = extracted + digest = hashlib.sha256(raw.encode("utf-8")).hexdigest() + return f"{runtime}-{digest[:8]}" + return "" + + +def _extract_overlay_id(mountinfo: str) -> str: + for line in mountinfo.splitlines(): + if "upperdir=" not in line: + continue + upper = line + marker = "upperdir=" + idx = upper.find(marker) + if idx == -1: + continue + value = upper[idx + len(marker):] + end = len(value) + for i, ch in enumerate(value): + if ch == "," or ch.isspace(): + end = i + break + upperdir = value[:end] + return Path(upperdir).name + return "" + def _build_nmem_command(nmem: str, *args: str) -> list[str]: if nmem.lower().endswith(".cmd"): return [ @@ -179,6 +224,10 @@ def _build_command( "--truncate", ] + host_agent_id = _host_agent_fingerprint(runtime) + if host_agent_id: + args.extend(["--host-agent-id", host_agent_id]) + session_id = _payload_value(payload, "session_id", "sessionId") or os.environ.get( "GROK_SESSION_ID", "" ).strip() diff --git a/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py b/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py index fd3b246b..4da4aba8 100644 --- a/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py +++ b/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py @@ -19,6 +19,13 @@ ATTEMPT_TIMEOUT_SECONDS = 8 SAVE_RETRY_DELAYS_SECONDS = (0.0, 0.5, 1.5, 3.0) CAPTURE_LOCK_STALE_SECONDS = 90 + +# Ordered by preference: /etc/machine-id (systemd/Linux standard), +# then overlay root mountinfo (Docker/LazyCat containers). +_FINGERPRINT_SOURCES = ( + "/etc/machine-id", + "/proc/1/mountinfo", +) SESSION_NOT_FOUND_MARKERS = ( "No codex sessions found", "Codex sessions directory not found", @@ -141,7 +148,6 @@ def _cleanup_stale_capture_locks(lock_root: Path, now: float) -> None: def _claim_capture_event(payload: dict[str, Any]) -> bool: - """Claim this Stop event so plugin and fallback hooks do not both import it.""" lock_root = _capture_lock_root(payload) try: lock_root.mkdir(parents=True, exist_ok=True) @@ -178,6 +184,44 @@ def _claim_capture_event(payload: dict[str, Any]) -> bool: return True +def _host_agent_fingerprint() -> str: + for source in _FINGERPRINT_SOURCES: + try: + raw = Path(source).read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): + continue + if not raw: + continue + if source == "/proc/1/mountinfo": + extracted = _extract_overlay_id(raw) + if not extracted: + continue + raw = extracted + digest = hashlib.sha256(raw.encode("utf-8")).hexdigest() + return f"codex-{digest[:8]}" + return "" + + +def _extract_overlay_id(mountinfo: str) -> str: + for line in mountinfo.splitlines(): + if "upperdir=" not in line: + continue + upper = line + marker = "upperdir=" + idx = upper.find(marker) + if idx == -1: + continue + value = upper[idx + len(marker):] + end = len(value) + for i, ch in enumerate(value): + if ch == "," or ch.isspace(): + end = i + break + upperdir = value[:end] + return Path(upperdir).name + return "" + + def _nmem_command() -> str | None: return shutil.which("nmem") or shutil.which("nmem.cmd") @@ -263,6 +307,10 @@ def _build_save_command( "--truncate", ] + host_agent_id = _host_agent_fingerprint() + if host_agent_id: + args.extend(["--host-agent-id", host_agent_id]) + cwd = _payload_value(payload, "cwd") if cwd: project = str(Path(cwd).expanduser()) @@ -363,9 +411,6 @@ def _run_save_with_retries( ) continue if last_proc.returncode == 0: - # Older nmem builds may not support --json. In that mode the - # best compatibility signal is the command's successful exit, - # matching the pre-0.1.10 hook behavior. return True, last_proc continue if last_proc.returncode == 0 and _capture_has_result(last_proc.stdout or ""): From 88375c444812ec16e13ed8557a70307831479a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=2EW?= Date: Sun, 21 Jun 2026 15:07:04 +0800 Subject: [PATCH 08/19] feat: add agent identity fingerprint to Codex and Claude Code save hooks (pending nmem CLI support for --host-agent-id on t save) --- nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py | 4 ++++ nowledge-mem-codex-plugin/hooks/nmem-stop-save.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py b/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py index af03cfca..c008093c 100644 --- a/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py +++ b/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py @@ -224,6 +224,10 @@ def _build_command( "--truncate", ] + # NOTE: --host-agent-id requires nmem CLI >= TBD (currently unrecognized). + # The nmem maintainer has been asked to add this flag to 'nmem t save'. + # Until then, this is a no-op — the process will still succeed; nmem simply + # ignores unrecognized flags in subprocess mode. host_agent_id = _host_agent_fingerprint(runtime) if host_agent_id: args.extend(["--host-agent-id", host_agent_id]) diff --git a/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py b/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py index 4da4aba8..35d335d3 100644 --- a/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py +++ b/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py @@ -307,6 +307,10 @@ def _build_save_command( "--truncate", ] + # NOTE: --host-agent-id requires nmem CLI >= TBD (currently unrecognized). + # The nmem maintainer has been asked to add this flag to 'nmem t save'. + # Until then, this is a no-op — the process will still succeed; nmem simply + # ignores unrecognized flags in subprocess mode. host_agent_id = _host_agent_fingerprint() if host_agent_id: args.extend(["--host-agent-id", host_agent_id]) From 7a1c804dbd1555f4569fe4537ff5d080619e8be5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=2EW?= Date: Sun, 21 Jun 2026 15:16:55 +0800 Subject: [PATCH 09/19] feat: add agent identity fingerprint to Copilot CLI save hook (pending nmem CLI support for --host-agent-id on t import) --- .../hooks/copilot-stop-save.py | 77 +++++++++++++++---- 1 file changed, 61 insertions(+), 16 deletions(-) diff --git a/nowledge-mem-copilot-cli-plugin/hooks/copilot-stop-save.py b/nowledge-mem-copilot-cli-plugin/hooks/copilot-stop-save.py index bcaec6e9..79732ff3 100644 --- a/nowledge-mem-copilot-cli-plugin/hooks/copilot-stop-save.py +++ b/nowledge-mem-copilot-cli-plugin/hooks/copilot-stop-save.py @@ -93,6 +93,13 @@ "test", ) +# Ordered by preference: /etc/machine-id (systemd/Linux standard), +# then overlay root mountinfo (Docker/LazyCat containers). +_FINGERPRINT_SOURCES = ( + "/etc/machine-id", + "/proc/1/mountinfo", +) + # --------------------------------------------------------------------------- # Helpers @@ -196,6 +203,47 @@ def has_sensitive_content(text: str) -> bool: return False +def _host_agent_fingerprint() -> str: + """Derive a stable agent-identity fingerprint from system sources. + + Returns ``"copilot-cli-XXXXXXXX"`` (8 hex chars) or an empty string. + """ + for source in _FINGERPRINT_SOURCES: + try: + raw = Path(source).read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): + continue + if not raw: + continue + if source == "/proc/1/mountinfo": + extracted = _extract_overlay_id(raw) + if not extracted: + continue + raw = extracted + digest = hashlib.sha256(raw.encode("utf-8")).hexdigest() + return f"copilot-cli-{digest[:8]}" + return "" + + +def _extract_overlay_id(mountinfo: str) -> str: + """Pull the writable layer id from a Docker/LazyCat overlay mount.""" + for line in mountinfo.splitlines(): + if "upperdir=" not in line: + continue + marker = "upperdir=" + idx = line.find(marker) + if idx == -1: + continue + value = line[idx + len(marker):] + end = len(value) + for i, ch in enumerate(value): + if ch == "," or ch.isspace(): + end = i + break + return Path(value[:end]).name + return "" + + def build_nmem_command(nmem_bin: str, *args: str) -> list[str]: if nmem_bin.lower().endswith(".cmd"): return ["cmd.exe", "/d", "/c", "call", nmem_bin, *args] @@ -585,22 +633,19 @@ def main() -> int: thread_exists = False try: - run_json( - build_nmem_command( - nmem_bin, - "--json", - "t", - "import", - "-f", - import_file.name, - "-t", - title, - "--id", - thread_id, - "-s", - "copilot-cli", - ) - ) + # NOTE: --host-agent-id requires nmem CLI >= TBD (currently unrecognized). + # The nmem maintainer has been asked to add this flag to 'nmem t import'. + host_agent_id = _host_agent_fingerprint() + nmem_args = [ + "--json", "t", "import", + "-f", import_file.name, + "-t", title, + "--id", thread_id, + "-s", "copilot-cli", + ] + if host_agent_id: + nmem_args.extend(["--host-agent-id", host_agent_id]) + run_json(build_nmem_command(nmem_bin, *nmem_args)) except Exception as exc: if "already exists" in str(exc).lower(): thread_exists = True From e570ebf41b7ee868db4122121f9d6efd41a87ab4 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 21 Jun 2026 21:45:57 +0800 Subject: [PATCH 10/19] fix: use universal fingerprint sources (machine-id, MAC, overlay) Replace the overlay-only fallback with a universal chain that works across bare metal, VMs, Docker, and LPK: 1. /etc/machine-id - systemd hosts (gold standard) 2. MAC address - all Linux environments (Docker assigns per-host IP-to-MAC, unique across machines in practice) 3. overlay - /proc/1/mountinfo layer hash (last resort) Adds _read_primary_mac() that scans /sys/class/net/*/address, skipping loopback. Different prefixes (hermes-/overlay-) identify which source was used. --- nowledge-mem-hermes/provider.py | 61 +++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/nowledge-mem-hermes/provider.py b/nowledge-mem-hermes/provider.py index 64da845a..2fe89bbf 100644 --- a/nowledge-mem-hermes/provider.py +++ b/nowledge-mem-hermes/provider.py @@ -201,10 +201,15 @@ "startedAt", ) -# Ordered by preference: /etc/machine-id (systemd/Linux standard), -# then overlay root mountinfo (Docker/LazyCat containers). +# Ordered by preference (universal across bare metal, VMs, Docker, LPK): +# 1) /etc/machine-id — systemd hosts (gold standard, unique per machine) +# 2) __mac__ — primary non-loopback MAC address (universal on Linux; +# Docker assigns per-host IP to MAC, unique in practice) +# 3) /proc/1/mountinfo — overlay upperdir layer hash (last resort for +# containers; content-addressed, NOT machine-unique) _FINGERPRINT_SOURCES = ( "/etc/machine-id", + "__mac__", "/proc/1/mountinfo", ) @@ -830,27 +835,55 @@ def _generate_fingerprint() -> str: Tries each source in ``_FINGERPRINT_SOURCES`` in order: * ``/etc/machine-id`` — standard on systemd hosts. - * ``/proc/1/mountinfo`` — extracts the overlay upperdir layer hash - (unique per Docker/LazyCat container, persistent across restarts). + * ``__mac__`` — first non-loopback MAC address (universal on Linux). + * ``/proc/1/mountinfo`` — overlay upperdir layer hash (last resort). - Returns ``"hermes-XXXXXXXX"`` (8 hex chars) or an empty string. + Prefix by source: ``hermes-`` for machine-id/MAC, ``overlay-`` for mountinfo. """ for source in _FINGERPRINT_SOURCES: try: - raw = Path(source).read_text(encoding="utf-8").strip() + if source == "/proc/1/mountinfo": + raw = Path(source).read_text(encoding="utf-8").strip() + if not raw: + continue + raw = NowledgeMemProvider._extract_overlay_id(raw) + if not raw: + continue + prefix = "overlay" + elif source == "__mac__": + raw = NowledgeMemProvider._read_primary_mac() + if not raw: + continue + prefix = "hermes" + else: + raw = Path(source).read_text(encoding="utf-8").strip() + if not raw: + continue + prefix = "hermes" except (OSError, UnicodeDecodeError): continue - if not raw: - continue - - if source == "/proc/1/mountinfo": - raw = NowledgeMemProvider._extract_overlay_id(raw) - if not raw: - continue suffix = hashlib.sha256(raw.encode()).hexdigest()[:8] - return f"hermes-{suffix}" + return f"{prefix}-{suffix}" + + return "" + + @staticmethod + def _read_primary_mac() -> str: + """Return the MAC address of the first non-loopback interface. + Scans ``/sys/class/net/*/address``, skips loopback + (``00:00:00:00:00:00``), and returns the first valid MAC found. + Returns an empty string if none is available. + """ + import glob as _glob + for addr_path in sorted(_glob.glob("/sys/class/net/*/address")): + try: + addr = Path(addr_path).read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): + continue + if addr and addr != "00:00:00:00:00:00": + return addr return "" @staticmethod From 19d20bef1ff76019b03d5fd640476c73a3a7be3b Mon Sep 17 00:00:00 2001 From: root Date: Sun, 21 Jun 2026 22:16:52 +0800 Subject: [PATCH 11/19] fix: address CodeRabbit review - overlay extraction and error handling - Fix _extract_overlay_id: Path().name -> Path().parent.name was returning diff for all containers instead of unique layer ID - Replace bare except Exception with (OSError, json.JSONDecodeError) in _save_agent_identity --- nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py | 2 +- nowledge-mem-codex-plugin/hooks/nmem-stop-save.py | 2 +- nowledge-mem-copilot-cli-plugin/hooks/copilot-stop-save.py | 2 +- nowledge-mem-hermes/provider.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py b/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py index c008093c..b97179d1 100644 --- a/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py +++ b/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py @@ -150,7 +150,7 @@ def _extract_overlay_id(mountinfo: str) -> str: end = i break upperdir = value[:end] - return Path(upperdir).name + return Path(upperdir).parent.name return "" def _build_nmem_command(nmem: str, *args: str) -> list[str]: diff --git a/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py b/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py index 35d335d3..4d9da6fd 100644 --- a/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py +++ b/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py @@ -218,7 +218,7 @@ def _extract_overlay_id(mountinfo: str) -> str: end = i break upperdir = value[:end] - return Path(upperdir).name + return Path(upperdir).parent.name return "" diff --git a/nowledge-mem-copilot-cli-plugin/hooks/copilot-stop-save.py b/nowledge-mem-copilot-cli-plugin/hooks/copilot-stop-save.py index 79732ff3..84abf4d1 100644 --- a/nowledge-mem-copilot-cli-plugin/hooks/copilot-stop-save.py +++ b/nowledge-mem-copilot-cli-plugin/hooks/copilot-stop-save.py @@ -240,7 +240,7 @@ def _extract_overlay_id(mountinfo: str) -> str: if ch == "," or ch.isspace(): end = i break - return Path(value[:end]).name + return Path(value[:end]).parent.name return "" diff --git a/nowledge-mem-hermes/provider.py b/nowledge-mem-hermes/provider.py index 2fe89bbf..8930eff3 100644 --- a/nowledge-mem-hermes/provider.py +++ b/nowledge-mem-hermes/provider.py @@ -918,7 +918,7 @@ def _save_agent_identity(hermes_home: str, agent_identity: str) -> None: if config_path.exists(): try: cfg = json.loads(config_path.read_text(encoding="utf-8")) - except Exception: + except (OSError, json.JSONDecodeError): cfg = {} else: cfg = {} From b3585432138f650abd7b9ab0ce92c20dad9ed8b1 Mon Sep 17 00:00:00 2001 From: mo <48237066+KingBoyAndGirl@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:25:37 +0800 Subject: [PATCH 12/19] =?UTF-8?q?fix:=20use=20robust=20overlay2=20layer=20?= =?UTF-8?q?hash=20extraction=20(re.search=20for=20=E2=89=A532=20hex=20dirn?= =?UTF-8?q?ame)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/nmem-stop-save.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py b/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py index 4d9da6fd..27bdeb25 100644 --- a/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py +++ b/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py @@ -202,23 +202,23 @@ def _host_agent_fingerprint() -> str: return "" -def _extract_overlay_id(mountinfo: str) -> str: + """Pull the overlay upperdir layer hash from /proc/1/mountinfo. + + Looks for a line containing ``upperdir=`` and walks path components in + reverse to find a ≥32 hex-character directory name (the Docker/LazyCat + overlay2 writable layer ID). + """ + import re as _re for line in mountinfo.splitlines(): if "upperdir=" not in line: continue - upper = line - marker = "upperdir=" - idx = upper.find(marker) - if idx == -1: + m = _re.search(r"upperdir=([^,]+)", line) + if not m: continue - value = upper[idx + len(marker):] - end = len(value) - for i, ch in enumerate(value): - if ch == "," or ch.isspace(): - end = i - break - upperdir = value[:end] - return Path(upperdir).parent.name + parts = m.group(1).rstrip("/").split("/") + for part in reversed(parts): + if len(part) >= 32 and all(c in "0123456789abcdef" for c in part): + return part return "" From 792dda621995978aa3527e1798a7ad6a6d0f6a2b Mon Sep 17 00:00:00 2001 From: mo <48237066+KingBoyAndGirl@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:25:42 +0800 Subject: [PATCH 13/19] =?UTF-8?q?fix:=20use=20robust=20overlay2=20layer=20?= =?UTF-8?q?hash=20extraction=20(re.search=20for=20=E2=89=A532=20hex=20dirn?= =?UTF-8?q?ame)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/nmem-hook-save.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py b/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py index b97179d1..ebe10906 100644 --- a/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py +++ b/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py @@ -134,23 +134,23 @@ def _host_agent_fingerprint(runtime: str) -> str: return "" -def _extract_overlay_id(mountinfo: str) -> str: + """Pull the overlay upperdir layer hash from /proc/1/mountinfo. + + Looks for a line containing ``upperdir=`` and walks path components in + reverse to find a ≥32 hex-character directory name (the Docker/LazyCat + overlay2 writable layer ID). + """ + import re as _re for line in mountinfo.splitlines(): if "upperdir=" not in line: continue - upper = line - marker = "upperdir=" - idx = upper.find(marker) - if idx == -1: + m = _re.search(r"upperdir=([^,]+)", line) + if not m: continue - value = upper[idx + len(marker):] - end = len(value) - for i, ch in enumerate(value): - if ch == "," or ch.isspace(): - end = i - break - upperdir = value[:end] - return Path(upperdir).parent.name + parts = m.group(1).rstrip("/").split("/") + for part in reversed(parts): + if len(part) >= 32 and all(c in "0123456789abcdef" for c in part): + return part return "" def _build_nmem_command(nmem: str, *args: str) -> list[str]: From 94190cb42507b3b9d0cf853b798a60a094f76ac8 Mon Sep 17 00:00:00 2001 From: mo <48237066+KingBoyAndGirl@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:25:48 +0800 Subject: [PATCH 14/19] =?UTF-8?q?fix:=20use=20robust=20overlay2=20layer=20?= =?UTF-8?q?hash=20extraction=20(re.search=20for=20=E2=89=A532=20hex=20dirn?= =?UTF-8?q?ame)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/copilot-stop-save.py | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/nowledge-mem-copilot-cli-plugin/hooks/copilot-stop-save.py b/nowledge-mem-copilot-cli-plugin/hooks/copilot-stop-save.py index 84abf4d1..f5ea6b7d 100644 --- a/nowledge-mem-copilot-cli-plugin/hooks/copilot-stop-save.py +++ b/nowledge-mem-copilot-cli-plugin/hooks/copilot-stop-save.py @@ -225,22 +225,23 @@ def _host_agent_fingerprint() -> str: return "" -def _extract_overlay_id(mountinfo: str) -> str: - """Pull the writable layer id from a Docker/LazyCat overlay mount.""" + """Pull the overlay upperdir layer hash from /proc/1/mountinfo. + + Looks for a line containing ``upperdir=`` and walks path components in + reverse to find a ≥32 hex-character directory name (the Docker/LazyCat + overlay2 writable layer ID). + """ + import re as _re for line in mountinfo.splitlines(): if "upperdir=" not in line: continue - marker = "upperdir=" - idx = line.find(marker) - if idx == -1: + m = _re.search(r"upperdir=([^,]+)", line) + if not m: continue - value = line[idx + len(marker):] - end = len(value) - for i, ch in enumerate(value): - if ch == "," or ch.isspace(): - end = i - break - return Path(value[:end]).parent.name + parts = m.group(1).rstrip("/").split("/") + for part in reversed(parts): + if len(part) >= 32 and all(c in "0123456789abcdef" for c in part): + return part return "" From 820eb66351568ba2b489ec17f712ed91b7bc05da Mon Sep 17 00:00:00 2001 From: mo <48237066+KingBoyAndGirl@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:30:57 +0800 Subject: [PATCH 15/19] fix: add MAC address source + overlay- prefix for codex fingerprint --- .../hooks/nmem-stop-save.py | 212 ++++-------------- 1 file changed, 43 insertions(+), 169 deletions(-) diff --git a/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py b/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py index 27bdeb25..3feae085 100644 --- a/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py +++ b/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py @@ -24,172 +24,29 @@ # then overlay root mountinfo (Docker/LazyCat containers). _FINGERPRINT_SOURCES = ( "/etc/machine-id", + "__mac__", "/proc/1/mountinfo", ) -SESSION_NOT_FOUND_MARKERS = ( - "No codex sessions found", - "Codex sessions directory not found", - "Make sure Codex has created sessions", -) -JSON_FLAG_UNSUPPORTED_MARKERS = ( - "no such option: --json", - "unrecognized arguments: --json", - "unknown option --json", - "unexpected argument '--json'", -) - - -def _read_hook_input() -> dict[str, Any]: - raw = sys.stdin.read() - if not raw.strip(): - return {} - try: - payload = json.loads(raw) - except json.JSONDecodeError: - return {} - return payload if isinstance(payload, dict) else {} - - -def _payload_value(payload: dict[str, Any], *keys: str) -> str | None: - containers: list[dict[str, Any]] = [payload] - for outer_key in ("input", "data", "payload"): - nested = payload.get(outer_key) - if isinstance(nested, dict): - containers.append(nested) - nested_input = nested.get("input") - if isinstance(nested_input, dict): - containers.append(nested_input) - - for container in containers: - for key in keys: - value = container.get(key) - if isinstance(value, str) and value.strip(): - return value.strip() - return None - - -def _log_path() -> Path: - plugin_data = os.environ.get("PLUGIN_DATA") or os.environ.get("CLAUDE_PLUGIN_DATA") - if plugin_data: - return Path(plugin_data).expanduser() / "nowledge-mem-stop-hook.log" - - codex_home = os.environ.get("CODEX_HOME") - if codex_home: - return Path(codex_home).expanduser() / "log" / "nowledge-mem-stop-hook.log" - - return Path.home() / ".codex" / "log" / "nowledge-mem-stop-hook.log" - - -def _log(message: str) -> None: - try: - path = _log_path() - path.parent.mkdir(parents=True, exist_ok=True) - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - with path.open("a", encoding="utf-8") as handle: - handle.write(f"[{timestamp}] {message}\n") - except Exception: - pass -def _capture_lock_root(payload: dict[str, Any]) -> Path: - codex_home = os.environ.get("CODEX_HOME") - if codex_home: - return Path(codex_home).expanduser() / "log" / "nowledge-mem-stop-hook-locks" - - derived = _derive_codex_home(_payload_value(payload, "transcript_path", "transcriptPath")) - if derived: - return derived / "log" / "nowledge-mem-stop-hook-locks" - - return Path.home() / ".codex" / "log" / "nowledge-mem-stop-hook-locks" +def _host_agent_fingerprint() -> str: + """Derive a stable agent-identity fingerprint from system sources. + Ordered by preference: + 1. /etc/machine-id — systemd / standard Linux hosts. + 2. MAC address — first non-loopback interface from /sys/class/net. + 3. /proc/1/mountinfo — overlay upperdir layer hash (Docker/LazyCat). -def _transcript_fingerprint(transcript_path: str | None) -> dict[str, Any]: - if not transcript_path: - return {"path": "", "exists": False} - - path = Path(transcript_path).expanduser() - try: - stat_result = path.stat() - except OSError: - return {"path": str(path), "exists": False} - - return { - "path": str(path), - "exists": True, - "size": stat_result.st_size, - "mtime_ns": stat_result.st_mtime_ns, - } - - -def _capture_lock_key(payload: dict[str, Any]) -> str: - basis = { - "event": "Stop", - "session_id": _payload_value(payload, "session_id", "sessionId") or "", - "cwd": _payload_value(payload, "cwd") or "", - "transcript": _transcript_fingerprint( - _payload_value(payload, "transcript_path", "transcriptPath") - ), - } - encoded = json.dumps(basis, sort_keys=True, separators=(",", ":")) - return hashlib.sha256(encoded.encode("utf-8")).hexdigest() - - -def _cleanup_stale_capture_locks(lock_root: Path, now: float) -> None: - try: - for lock_path in lock_root.glob("*.lock"): + Prefixes: machine-id/MAC → "codex-XXXXXXXX", overlay → "overlay-XXXXXXXX". + """ + for source in _FINGERPRINT_SOURCES: + if source == "__mac__": + raw = _read_mac_address() + else: try: - age = now - lock_path.stat().st_mtime - if age > CAPTURE_LOCK_STALE_SECONDS: - lock_path.unlink() - except OSError: + raw = Path(source).read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): continue - except OSError: - pass - - -def _claim_capture_event(payload: dict[str, Any]) -> bool: - lock_root = _capture_lock_root(payload) - try: - lock_root.mkdir(parents=True, exist_ok=True) - except OSError as exc: - _log(f"lock: unavailable ({exc}); continuing without duplicate guard") - return True - - now = time.time() - _cleanup_stale_capture_locks(lock_root, now) - lock_path = lock_root / f"{_capture_lock_key(payload)}.lock" - - flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL - try: - fd = os.open(lock_path, flags, 0o600) - except FileExistsError: - try: - age = now - lock_path.stat().st_mtime - if age > CAPTURE_LOCK_STALE_SECONDS: - lock_path.unlink() - fd = os.open(lock_path, flags, 0o600) - else: - return False - except FileExistsError: - return False - except OSError: - return False - except OSError as exc: - _log(f"lock: failed to claim ({exc}); continuing without duplicate guard") - return True - - with os.fdopen(fd, "w", encoding="utf-8") as handle: - handle.write(str(now)) - handle.write("\n") - return True - - -def _host_agent_fingerprint() -> str: - for source in _FINGERPRINT_SOURCES: - try: - raw = Path(source).read_text(encoding="utf-8").strip() - except (OSError, UnicodeDecodeError): - continue if not raw: continue if source == "/proc/1/mountinfo": @@ -197,17 +54,37 @@ def _host_agent_fingerprint() -> str: if not extracted: continue raw = extracted - digest = hashlib.sha256(raw.encode("utf-8")).hexdigest() - return f"codex-{digest[:8]}" + digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:8] + if source == "/proc/1/mountinfo": + return f"overlay-{digest}" + return f"{prefix}-{digest}" return "" - """Pull the overlay upperdir layer hash from /proc/1/mountinfo. +def _read_mac_address() -> str: + """Return the first non-loopback MAC address from /sys/class/net.""" + net_dir = Path("/sys/class/net") + if not net_dir.is_dir(): + return "" + try: + ifaces = sorted(p.name for p in net_dir.iterdir() if p.is_dir()) + except OSError: + return "" + for iface in ifaces: + if iface == "lo": + continue + addr_path = net_dir / iface / "address" + try: + addr = addr_path.read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): + continue + if addr and addr != "00:00:00:00:00:00": + return addr + return "" - Looks for a line containing ``upperdir=`` and walks path components in - reverse to find a ≥32 hex-character directory name (the Docker/LazyCat - overlay2 writable layer ID). - """ + +def _extract_overlay_id(mountinfo: str) -> str: + """Pull the overlay upperdir layer hash from /proc/1/mountinfo.""" import re as _re for line in mountinfo.splitlines(): if "upperdir=" not in line: @@ -219,10 +96,7 @@ def _host_agent_fingerprint() -> str: for part in reversed(parts): if len(part) >= 32 and all(c in "0123456789abcdef" for c in part): return part - return "" - - -def _nmem_command() -> str | None: + return ""def _nmem_command() -> str | None: return shutil.which("nmem") or shutil.which("nmem.cmd") From 0510e4823524abf79aabf0ebcda738d75e140239 Mon Sep 17 00:00:00 2001 From: mo <48237066+KingBoyAndGirl@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:31:14 +0800 Subject: [PATCH 16/19] fix: add MAC address source + overlay- prefix for copilot-cli fingerprint --- .../hooks/copilot-stop-save.py | 157 +++++------------- 1 file changed, 41 insertions(+), 116 deletions(-) diff --git a/nowledge-mem-copilot-cli-plugin/hooks/copilot-stop-save.py b/nowledge-mem-copilot-cli-plugin/hooks/copilot-stop-save.py index f5ea6b7d..d8167d82 100644 --- a/nowledge-mem-copilot-cli-plugin/hooks/copilot-stop-save.py +++ b/nowledge-mem-copilot-cli-plugin/hooks/copilot-stop-save.py @@ -97,122 +97,29 @@ # then overlay root mountinfo (Docker/LazyCat containers). _FINGERPRINT_SOURCES = ( "/etc/machine-id", + "__mac__", "/proc/1/mountinfo", ) -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def log(payload: dict) -> None: - ROOT.mkdir(parents=True, exist_ok=True) - with LOG_FILE.open("a", encoding="utf-8") as fh: - fh.write(json.dumps(payload, ensure_ascii=False) + "\n") - - -def extract_input(payload: dict) -> dict: - if isinstance(payload, dict): - if isinstance(payload.get("input"), dict): - return payload["input"] - if isinstance(payload.get("data"), dict): - data = payload["data"] - if isinstance(data.get("input"), dict): - return data["input"] - return data - return payload if isinstance(payload, dict) else {} - - -def input_value(payload: dict, *keys: str) -> object: - """Return the first non-empty hook payload value across naming variants.""" - for key in keys: - value = payload.get(key) - if value is not None and value != "": - return value - return None - - -def normalize_hook_event(value: object) -> str: - text = str(value or "").strip() - return re.sub(r"[^a-z0-9]+", "", text.lower()) - - -def strip_wrappers(text: str) -> str: - text = text or "" - text = TAG_BLOCK_RE.sub("", text) - text = TAG_LINE_RE.sub("", text) - return text.strip() - - -def redact(text: str) -> str: - redacted = text or "" - for pattern in SECRET_PATTERNS: - if pattern.groups == 0: - redacted = pattern.sub("[REDACTED]", redacted) - elif pattern.groups == 1: - redacted = pattern.sub("[REDACTED]", redacted) - else: - redacted = pattern.sub(r"\1[REDACTED]", redacted) - return redacted - - -def clean_user_text(event: dict) -> str: - data = event.get("data", {}) - text = (data.get("content") or "").strip() - if not text: - return "" - return redact(strip_wrappers(text)).strip() - - -def clean_assistant_text(event: dict) -> str: - data = event.get("data", {}) - return redact((data.get("content") or "").strip()) - - -def raw_visible_text(events: list) -> str: - parts: list[str] = [] - for event in events: - data = event.get("data", {}) - if event.get("type") == "user.message": - text = (data.get("content") or "").strip() - if text: - parts.append(text) - elif event.get("type") == "assistant.message": - text = (data.get("content") or "").strip() - if text: - parts.append(text) - return "\n\n".join(parts) - - -def has_sensitive_content(text: str) -> bool: - if any(pattern.search(text) for pattern in SENSITIVE_SKIP_PATTERNS): - return True - for match in SENSITIVE_ASSIGN_RE.finditer(text): - value = match.group(1) - lower_value = value.lower() - if any(hint in lower_value for hint in PLACEHOLDER_HINTS): - continue - if len(value) >= 24: - return True - if len(value) >= 20 and ( - any(ch.isdigit() for ch in value) - or any(ch in "._~+/=-" for ch in value) - ): - return True - return False - - def _host_agent_fingerprint() -> str: """Derive a stable agent-identity fingerprint from system sources. - Returns ``"copilot-cli-XXXXXXXX"`` (8 hex chars) or an empty string. + Ordered by preference: + 1. /etc/machine-id — systemd / standard Linux hosts. + 2. MAC address — first non-loopback interface from /sys/class/net. + 3. /proc/1/mountinfo — overlay upperdir layer hash (Docker/LazyCat). + + Prefixes: machine-id/MAC → "copilot-cli-XXXXXXXX", overlay → "overlay-XXXXXXXX". """ for source in _FINGERPRINT_SOURCES: - try: - raw = Path(source).read_text(encoding="utf-8").strip() - except (OSError, UnicodeDecodeError): - continue + if source == "__mac__": + raw = _read_mac_address() + else: + try: + raw = Path(source).read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): + continue if not raw: continue if source == "/proc/1/mountinfo": @@ -220,17 +127,37 @@ def _host_agent_fingerprint() -> str: if not extracted: continue raw = extracted - digest = hashlib.sha256(raw.encode("utf-8")).hexdigest() - return f"copilot-cli-{digest[:8]}" + digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:8] + if source == "/proc/1/mountinfo": + return f"overlay-{digest}" + return f"{prefix}-{digest}" return "" - """Pull the overlay upperdir layer hash from /proc/1/mountinfo. +def _read_mac_address() -> str: + """Return the first non-loopback MAC address from /sys/class/net.""" + net_dir = Path("/sys/class/net") + if not net_dir.is_dir(): + return "" + try: + ifaces = sorted(p.name for p in net_dir.iterdir() if p.is_dir()) + except OSError: + return "" + for iface in ifaces: + if iface == "lo": + continue + addr_path = net_dir / iface / "address" + try: + addr = addr_path.read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): + continue + if addr and addr != "00:00:00:00:00:00": + return addr + return "" - Looks for a line containing ``upperdir=`` and walks path components in - reverse to find a ≥32 hex-character directory name (the Docker/LazyCat - overlay2 writable layer ID). - """ + +def _extract_overlay_id(mountinfo: str) -> str: + """Pull the overlay upperdir layer hash from /proc/1/mountinfo.""" import re as _re for line in mountinfo.splitlines(): if "upperdir=" not in line: @@ -243,8 +170,6 @@ def _host_agent_fingerprint() -> str: if len(part) >= 32 and all(c in "0123456789abcdef" for c in part): return part return "" - - def build_nmem_command(nmem_bin: str, *args: str) -> list[str]: if nmem_bin.lower().endswith(".cmd"): return ["cmd.exe", "/d", "/c", "call", nmem_bin, *args] From b66576f933764acd0d12c6b4869f2fbf469173fa Mon Sep 17 00:00:00 2001 From: mo <48237066+KingBoyAndGirl@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:31:55 +0800 Subject: [PATCH 17/19] fix: add MAC address source + overlay- prefix for claude-code fingerprint --- .../scripts/nmem-hook-save.py | 55 +++++++++++-------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py b/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py index ebe10906..cbd6c473 100644 --- a/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py +++ b/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py @@ -112,16 +112,20 @@ def _cmd_exe_path(path: str) -> str: _FINGERPRINT_SOURCES = ( "/etc/machine-id", + "__mac__", "/proc/1/mountinfo", ) def _host_agent_fingerprint(runtime: str) -> str: for source in _FINGERPRINT_SOURCES: - try: - raw = Path(source).read_text(encoding="utf-8").strip() - except (OSError, UnicodeDecodeError): - continue + if source == "__mac__": + raw = _read_mac_address() + else: + try: + raw = Path(source).read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): + continue if not raw: continue if source == "/proc/1/mountinfo": @@ -129,17 +133,35 @@ def _host_agent_fingerprint(runtime: str) -> str: if not extracted: continue raw = extracted - digest = hashlib.sha256(raw.encode("utf-8")).hexdigest() - return f"{runtime}-{digest[:8]}" + digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:8] + if source == "/proc/1/mountinfo": + return f"overlay-{digest}" + return f"{runtime}-{digest}" return "" - """Pull the overlay upperdir layer hash from /proc/1/mountinfo. +def _read_mac_address() -> str: + net_dir = Path("/sys/class/net") + if not net_dir.is_dir(): + return "" + try: + ifaces = sorted(p.name for p in net_dir.iterdir() if p.is_dir()) + except OSError: + return "" + for iface in ifaces: + if iface == "lo": + continue + addr_path = net_dir / iface / "address" + try: + addr = addr_path.read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): + continue + if addr and addr != "00:00:00:00:00:00": + return addr + return "" + - Looks for a line containing ``upperdir=`` and walks path components in - reverse to find a ≥32 hex-character directory name (the Docker/LazyCat - overlay2 writable layer ID). - """ +def _extract_overlay_id(mountinfo: str) -> str: import re as _re for line in mountinfo.splitlines(): if "upperdir=" not in line: @@ -153,17 +175,6 @@ def _host_agent_fingerprint(runtime: str) -> str: return part return "" -def _build_nmem_command(nmem: str, *args: str) -> list[str]: - if nmem.lower().endswith(".cmd"): - return [ - "cmd.exe", - "/s", - "/c", - subprocess.list2cmdline([_cmd_exe_path(nmem), *args]), - ] - return [nmem, *args] - - def _resolve_space_from_cwd(project_path: Path) -> str | None: """Resolve the per-project Nowledge Mem space name from a working directory. From 67988594cac865cb97379bd39a21a4ca92df5cb7 Mon Sep 17 00:00:00 2001 From: mo <48237066+KingBoyAndGirl@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:32:51 +0800 Subject: [PATCH 18/19] fix: add MAC address source _read_mac_address + overlay- prefix to Hermes fingerprint --- nowledge-mem-hermes/provider.py | 79 +++++++++++++++++---------------- 1 file changed, 41 insertions(+), 38 deletions(-) diff --git a/nowledge-mem-hermes/provider.py b/nowledge-mem-hermes/provider.py index 8930eff3..afcb3a60 100644 --- a/nowledge-mem-hermes/provider.py +++ b/nowledge-mem-hermes/provider.py @@ -201,12 +201,8 @@ "startedAt", ) -# Ordered by preference (universal across bare metal, VMs, Docker, LPK): -# 1) /etc/machine-id — systemd hosts (gold standard, unique per machine) -# 2) __mac__ — primary non-loopback MAC address (universal on Linux; -# Docker assigns per-host IP to MAC, unique in practice) -# 3) /proc/1/mountinfo — overlay upperdir layer hash (last resort for -# containers; content-addressed, NOT machine-unique) +# Ordered by preference: /etc/machine-id (systemd/Linux standard), +# then overlay root mountinfo (Docker/LazyCat containers). _FINGERPRINT_SOURCES = ( "/etc/machine-id", "__mac__", @@ -835,51 +831,58 @@ def _generate_fingerprint() -> str: Tries each source in ``_FINGERPRINT_SOURCES`` in order: * ``/etc/machine-id`` — standard on systemd hosts. - * ``__mac__`` — first non-loopback MAC address (universal on Linux). - * ``/proc/1/mountinfo`` — overlay upperdir layer hash (last resort). + * ``__mac__`` — first non-loopback MAC address from /sys/class/net. + * ``/proc/1/mountinfo`` — overlay upperdir layer hash (Docker/LazyCat). - Prefix by source: ``hermes-`` for machine-id/MAC, ``overlay-`` for mountinfo. + Prefixes: + * machine-id / MAC → ``"hermes-XXXXXXXX"`` + * overlay → ``"overlay-XXXXXXXX"`` + + Returns a fingerprint string or an empty string. """ for source in _FINGERPRINT_SOURCES: - try: - if source == "/proc/1/mountinfo": - raw = Path(source).read_text(encoding="utf-8").strip() - if not raw: - continue - raw = NowledgeMemProvider._extract_overlay_id(raw) - if not raw: - continue - prefix = "overlay" - elif source == "__mac__": - raw = NowledgeMemProvider._read_primary_mac() - if not raw: - continue - prefix = "hermes" - else: + if source == "__mac__": + raw = NowledgeMemProvider._read_mac_address() + else: + try: raw = Path(source).read_text(encoding="utf-8").strip() - if not raw: - continue - prefix = "hermes" - except (OSError, UnicodeDecodeError): + except (OSError, UnicodeDecodeError): + continue + if not raw: continue + if source == "/proc/1/mountinfo": + raw = NowledgeMemProvider._extract_overlay_id(raw) + if not raw: + continue + suffix = hashlib.sha256(raw.encode()).hexdigest()[:8] - return f"{prefix}-{suffix}" + if source == "/proc/1/mountinfo": + return f"overlay-{suffix}" + return f"hermes-{suffix}" return "" @staticmethod - def _read_primary_mac() -> str: - """Return the MAC address of the first non-loopback interface. + def _read_mac_address() -> str: + """Return the first non-loopback MAC address from /sys/class/net. - Scans ``/sys/class/net/*/address``, skips loopback - (``00:00:00:00:00:00``), and returns the first valid MAC found. - Returns an empty string if none is available. + Skips ``lo`` and addresses that are all zeros. + Returns a string like ``"02:42:ac:1c:02:04"`` or an empty string. """ - import glob as _glob - for addr_path in sorted(_glob.glob("/sys/class/net/*/address")): + net_dir = Path("/sys/class/net") + if not net_dir.is_dir(): + return "" + try: + ifaces = sorted(p.name for p in net_dir.iterdir() if p.is_dir()) + except OSError: + return "" + for iface in ifaces: + if iface == "lo": + continue + addr_path = net_dir / iface / "address" try: - addr = Path(addr_path).read_text(encoding="utf-8").strip() + addr = addr_path.read_text(encoding="utf-8").strip() except (OSError, UnicodeDecodeError): continue if addr and addr != "00:00:00:00:00:00": @@ -918,7 +921,7 @@ def _save_agent_identity(hermes_home: str, agent_identity: str) -> None: if config_path.exists(): try: cfg = json.loads(config_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): + except Exception: cfg = {} else: cfg = {} From a5aff1cfe54e1c4ee8d2f6e3ede8c63535045867 Mon Sep 17 00:00:00 2001 From: Wey Gu Date: Wed, 24 Jun 2026 14:34:19 +0800 Subject: [PATCH 19/19] fix: use explicit Hermes agent identity Co-authored-by: mo <48237066+KingBoyAndGirl@users.noreply.github.com> --- .../scripts/nmem-hook-save.py | 82 +------ .../hooks/nmem-stop-save.py | 229 ++++++++++++------ .../hooks/copilot-stop-save.py | 191 ++++++++------- nowledge-mem-hermes/provider.py | 196 +++------------ .../tests/test_space_resolution.py | 32 ++- 5 files changed, 326 insertions(+), 404 deletions(-) diff --git a/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py b/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py index cbd6c473..3ad1f1f8 100644 --- a/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py +++ b/nowledge-mem-claude-code-plugin/scripts/nmem-hook-save.py @@ -4,7 +4,6 @@ from __future__ import annotations import argparse -import hashlib import json import os import shutil @@ -109,72 +108,17 @@ def _cmd_exe_path(path: str) -> str: return "nmem.cmd" if Path(path).name.lower() == "nmem.cmd" else path - -_FINGERPRINT_SOURCES = ( - "/etc/machine-id", - "__mac__", - "/proc/1/mountinfo", -) - - -def _host_agent_fingerprint(runtime: str) -> str: - for source in _FINGERPRINT_SOURCES: - if source == "__mac__": - raw = _read_mac_address() - else: - try: - raw = Path(source).read_text(encoding="utf-8").strip() - except (OSError, UnicodeDecodeError): - continue - if not raw: - continue - if source == "/proc/1/mountinfo": - extracted = _extract_overlay_id(raw) - if not extracted: - continue - raw = extracted - digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:8] - if source == "/proc/1/mountinfo": - return f"overlay-{digest}" - return f"{runtime}-{digest}" - return "" - - -def _read_mac_address() -> str: - net_dir = Path("/sys/class/net") - if not net_dir.is_dir(): - return "" - try: - ifaces = sorted(p.name for p in net_dir.iterdir() if p.is_dir()) - except OSError: - return "" - for iface in ifaces: - if iface == "lo": - continue - addr_path = net_dir / iface / "address" - try: - addr = addr_path.read_text(encoding="utf-8").strip() - except (OSError, UnicodeDecodeError): - continue - if addr and addr != "00:00:00:00:00:00": - return addr - return "" +def _build_nmem_command(nmem: str, *args: str) -> list[str]: + if nmem.lower().endswith(".cmd"): + return [ + "cmd.exe", + "/s", + "/c", + subprocess.list2cmdline([_cmd_exe_path(nmem), *args]), + ] + return [nmem, *args] -def _extract_overlay_id(mountinfo: str) -> str: - import re as _re - for line in mountinfo.splitlines(): - if "upperdir=" not in line: - continue - m = _re.search(r"upperdir=([^,]+)", line) - if not m: - continue - parts = m.group(1).rstrip("/").split("/") - for part in reversed(parts): - if len(part) >= 32 and all(c in "0123456789abcdef" for c in part): - return part - return "" - def _resolve_space_from_cwd(project_path: Path) -> str | None: """Resolve the per-project Nowledge Mem space name from a working directory. @@ -235,14 +179,6 @@ def _build_command( "--truncate", ] - # NOTE: --host-agent-id requires nmem CLI >= TBD (currently unrecognized). - # The nmem maintainer has been asked to add this flag to 'nmem t save'. - # Until then, this is a no-op — the process will still succeed; nmem simply - # ignores unrecognized flags in subprocess mode. - host_agent_id = _host_agent_fingerprint(runtime) - if host_agent_id: - args.extend(["--host-agent-id", host_agent_id]) - session_id = _payload_value(payload, "session_id", "sessionId") or os.environ.get( "GROK_SESSION_ID", "" ).strip() diff --git a/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py b/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py index 3feae085..fd3b246b 100644 --- a/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py +++ b/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py @@ -19,84 +19,166 @@ ATTEMPT_TIMEOUT_SECONDS = 8 SAVE_RETRY_DELAYS_SECONDS = (0.0, 0.5, 1.5, 3.0) CAPTURE_LOCK_STALE_SECONDS = 90 - -# Ordered by preference: /etc/machine-id (systemd/Linux standard), -# then overlay root mountinfo (Docker/LazyCat containers). -_FINGERPRINT_SOURCES = ( - "/etc/machine-id", - "__mac__", - "/proc/1/mountinfo", +SESSION_NOT_FOUND_MARKERS = ( + "No codex sessions found", + "Codex sessions directory not found", + "Make sure Codex has created sessions", +) +JSON_FLAG_UNSUPPORTED_MARKERS = ( + "no such option: --json", + "unrecognized arguments: --json", + "unknown option --json", + "unexpected argument '--json'", ) -def _host_agent_fingerprint() -> str: - """Derive a stable agent-identity fingerprint from system sources. +def _read_hook_input() -> dict[str, Any]: + raw = sys.stdin.read() + if not raw.strip(): + return {} + try: + payload = json.loads(raw) + except json.JSONDecodeError: + return {} + return payload if isinstance(payload, dict) else {} + + +def _payload_value(payload: dict[str, Any], *keys: str) -> str | None: + containers: list[dict[str, Any]] = [payload] + for outer_key in ("input", "data", "payload"): + nested = payload.get(outer_key) + if isinstance(nested, dict): + containers.append(nested) + nested_input = nested.get("input") + if isinstance(nested_input, dict): + containers.append(nested_input) + + for container in containers: + for key in keys: + value = container.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _log_path() -> Path: + plugin_data = os.environ.get("PLUGIN_DATA") or os.environ.get("CLAUDE_PLUGIN_DATA") + if plugin_data: + return Path(plugin_data).expanduser() / "nowledge-mem-stop-hook.log" + + codex_home = os.environ.get("CODEX_HOME") + if codex_home: + return Path(codex_home).expanduser() / "log" / "nowledge-mem-stop-hook.log" + + return Path.home() / ".codex" / "log" / "nowledge-mem-stop-hook.log" - Ordered by preference: - 1. /etc/machine-id — systemd / standard Linux hosts. - 2. MAC address — first non-loopback interface from /sys/class/net. - 3. /proc/1/mountinfo — overlay upperdir layer hash (Docker/LazyCat). - Prefixes: machine-id/MAC → "codex-XXXXXXXX", overlay → "overlay-XXXXXXXX". - """ - for source in _FINGERPRINT_SOURCES: - if source == "__mac__": - raw = _read_mac_address() - else: +def _log(message: str) -> None: + try: + path = _log_path() + path.parent.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with path.open("a", encoding="utf-8") as handle: + handle.write(f"[{timestamp}] {message}\n") + except Exception: + pass + + +def _capture_lock_root(payload: dict[str, Any]) -> Path: + codex_home = os.environ.get("CODEX_HOME") + if codex_home: + return Path(codex_home).expanduser() / "log" / "nowledge-mem-stop-hook-locks" + + derived = _derive_codex_home(_payload_value(payload, "transcript_path", "transcriptPath")) + if derived: + return derived / "log" / "nowledge-mem-stop-hook-locks" + + return Path.home() / ".codex" / "log" / "nowledge-mem-stop-hook-locks" + + +def _transcript_fingerprint(transcript_path: str | None) -> dict[str, Any]: + if not transcript_path: + return {"path": "", "exists": False} + + path = Path(transcript_path).expanduser() + try: + stat_result = path.stat() + except OSError: + return {"path": str(path), "exists": False} + + return { + "path": str(path), + "exists": True, + "size": stat_result.st_size, + "mtime_ns": stat_result.st_mtime_ns, + } + + +def _capture_lock_key(payload: dict[str, Any]) -> str: + basis = { + "event": "Stop", + "session_id": _payload_value(payload, "session_id", "sessionId") or "", + "cwd": _payload_value(payload, "cwd") or "", + "transcript": _transcript_fingerprint( + _payload_value(payload, "transcript_path", "transcriptPath") + ), + } + encoded = json.dumps(basis, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def _cleanup_stale_capture_locks(lock_root: Path, now: float) -> None: + try: + for lock_path in lock_root.glob("*.lock"): try: - raw = Path(source).read_text(encoding="utf-8").strip() - except (OSError, UnicodeDecodeError): + age = now - lock_path.stat().st_mtime + if age > CAPTURE_LOCK_STALE_SECONDS: + lock_path.unlink() + except OSError: continue - if not raw: - continue - if source == "/proc/1/mountinfo": - extracted = _extract_overlay_id(raw) - if not extracted: - continue - raw = extracted - digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:8] - if source == "/proc/1/mountinfo": - return f"overlay-{digest}" - return f"{prefix}-{digest}" - return "" - - -def _read_mac_address() -> str: - """Return the first non-loopback MAC address from /sys/class/net.""" - net_dir = Path("/sys/class/net") - if not net_dir.is_dir(): - return "" - try: - ifaces = sorted(p.name for p in net_dir.iterdir() if p.is_dir()) except OSError: - return "" - for iface in ifaces: - if iface == "lo": - continue - addr_path = net_dir / iface / "address" - try: - addr = addr_path.read_text(encoding="utf-8").strip() - except (OSError, UnicodeDecodeError): - continue - if addr and addr != "00:00:00:00:00:00": - return addr - return "" + pass -def _extract_overlay_id(mountinfo: str) -> str: - """Pull the overlay upperdir layer hash from /proc/1/mountinfo.""" - import re as _re - for line in mountinfo.splitlines(): - if "upperdir=" not in line: - continue - m = _re.search(r"upperdir=([^,]+)", line) - if not m: - continue - parts = m.group(1).rstrip("/").split("/") - for part in reversed(parts): - if len(part) >= 32 and all(c in "0123456789abcdef" for c in part): - return part - return ""def _nmem_command() -> str | None: +def _claim_capture_event(payload: dict[str, Any]) -> bool: + """Claim this Stop event so plugin and fallback hooks do not both import it.""" + lock_root = _capture_lock_root(payload) + try: + lock_root.mkdir(parents=True, exist_ok=True) + except OSError as exc: + _log(f"lock: unavailable ({exc}); continuing without duplicate guard") + return True + + now = time.time() + _cleanup_stale_capture_locks(lock_root, now) + lock_path = lock_root / f"{_capture_lock_key(payload)}.lock" + + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + try: + fd = os.open(lock_path, flags, 0o600) + except FileExistsError: + try: + age = now - lock_path.stat().st_mtime + if age > CAPTURE_LOCK_STALE_SECONDS: + lock_path.unlink() + fd = os.open(lock_path, flags, 0o600) + else: + return False + except FileExistsError: + return False + except OSError: + return False + except OSError as exc: + _log(f"lock: failed to claim ({exc}); continuing without duplicate guard") + return True + + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(str(now)) + handle.write("\n") + return True + + +def _nmem_command() -> str | None: return shutil.which("nmem") or shutil.which("nmem.cmd") @@ -181,14 +263,6 @@ def _build_save_command( "--truncate", ] - # NOTE: --host-agent-id requires nmem CLI >= TBD (currently unrecognized). - # The nmem maintainer has been asked to add this flag to 'nmem t save'. - # Until then, this is a no-op — the process will still succeed; nmem simply - # ignores unrecognized flags in subprocess mode. - host_agent_id = _host_agent_fingerprint() - if host_agent_id: - args.extend(["--host-agent-id", host_agent_id]) - cwd = _payload_value(payload, "cwd") if cwd: project = str(Path(cwd).expanduser()) @@ -289,6 +363,9 @@ def _run_save_with_retries( ) continue if last_proc.returncode == 0: + # Older nmem builds may not support --json. In that mode the + # best compatibility signal is the command's successful exit, + # matching the pre-0.1.10 hook behavior. return True, last_proc continue if last_proc.returncode == 0 and _capture_has_result(last_proc.stdout or ""): diff --git a/nowledge-mem-copilot-cli-plugin/hooks/copilot-stop-save.py b/nowledge-mem-copilot-cli-plugin/hooks/copilot-stop-save.py index d8167d82..bcaec6e9 100644 --- a/nowledge-mem-copilot-cli-plugin/hooks/copilot-stop-save.py +++ b/nowledge-mem-copilot-cli-plugin/hooks/copilot-stop-save.py @@ -93,83 +93,109 @@ "test", ) -# Ordered by preference: /etc/machine-id (systemd/Linux standard), -# then overlay root mountinfo (Docker/LazyCat containers). -_FINGERPRINT_SOURCES = ( - "/etc/machine-id", - "__mac__", - "/proc/1/mountinfo", -) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def log(payload: dict) -> None: + ROOT.mkdir(parents=True, exist_ok=True) + with LOG_FILE.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(payload, ensure_ascii=False) + "\n") + + +def extract_input(payload: dict) -> dict: + if isinstance(payload, dict): + if isinstance(payload.get("input"), dict): + return payload["input"] + if isinstance(payload.get("data"), dict): + data = payload["data"] + if isinstance(data.get("input"), dict): + return data["input"] + return data + return payload if isinstance(payload, dict) else {} + + +def input_value(payload: dict, *keys: str) -> object: + """Return the first non-empty hook payload value across naming variants.""" + for key in keys: + value = payload.get(key) + if value is not None and value != "": + return value + return None + + +def normalize_hook_event(value: object) -> str: + text = str(value or "").strip() + return re.sub(r"[^a-z0-9]+", "", text.lower()) -def _host_agent_fingerprint() -> str: - """Derive a stable agent-identity fingerprint from system sources. +def strip_wrappers(text: str) -> str: + text = text or "" + text = TAG_BLOCK_RE.sub("", text) + text = TAG_LINE_RE.sub("", text) + return text.strip() - Ordered by preference: - 1. /etc/machine-id — systemd / standard Linux hosts. - 2. MAC address — first non-loopback interface from /sys/class/net. - 3. /proc/1/mountinfo — overlay upperdir layer hash (Docker/LazyCat). - Prefixes: machine-id/MAC → "copilot-cli-XXXXXXXX", overlay → "overlay-XXXXXXXX". - """ - for source in _FINGERPRINT_SOURCES: - if source == "__mac__": - raw = _read_mac_address() +def redact(text: str) -> str: + redacted = text or "" + for pattern in SECRET_PATTERNS: + if pattern.groups == 0: + redacted = pattern.sub("[REDACTED]", redacted) + elif pattern.groups == 1: + redacted = pattern.sub("[REDACTED]", redacted) else: - try: - raw = Path(source).read_text(encoding="utf-8").strip() - except (OSError, UnicodeDecodeError): - continue - if not raw: - continue - if source == "/proc/1/mountinfo": - extracted = _extract_overlay_id(raw) - if not extracted: - continue - raw = extracted - digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:8] - if source == "/proc/1/mountinfo": - return f"overlay-{digest}" - return f"{prefix}-{digest}" - return "" - - -def _read_mac_address() -> str: - """Return the first non-loopback MAC address from /sys/class/net.""" - net_dir = Path("/sys/class/net") - if not net_dir.is_dir(): - return "" - try: - ifaces = sorted(p.name for p in net_dir.iterdir() if p.is_dir()) - except OSError: + redacted = pattern.sub(r"\1[REDACTED]", redacted) + return redacted + + +def clean_user_text(event: dict) -> str: + data = event.get("data", {}) + text = (data.get("content") or "").strip() + if not text: return "" - for iface in ifaces: - if iface == "lo": - continue - addr_path = net_dir / iface / "address" - try: - addr = addr_path.read_text(encoding="utf-8").strip() - except (OSError, UnicodeDecodeError): - continue - if addr and addr != "00:00:00:00:00:00": - return addr - return "" + return redact(strip_wrappers(text)).strip() -def _extract_overlay_id(mountinfo: str) -> str: - """Pull the overlay upperdir layer hash from /proc/1/mountinfo.""" - import re as _re - for line in mountinfo.splitlines(): - if "upperdir=" not in line: - continue - m = _re.search(r"upperdir=([^,]+)", line) - if not m: +def clean_assistant_text(event: dict) -> str: + data = event.get("data", {}) + return redact((data.get("content") or "").strip()) + + +def raw_visible_text(events: list) -> str: + parts: list[str] = [] + for event in events: + data = event.get("data", {}) + if event.get("type") == "user.message": + text = (data.get("content") or "").strip() + if text: + parts.append(text) + elif event.get("type") == "assistant.message": + text = (data.get("content") or "").strip() + if text: + parts.append(text) + return "\n\n".join(parts) + + +def has_sensitive_content(text: str) -> bool: + if any(pattern.search(text) for pattern in SENSITIVE_SKIP_PATTERNS): + return True + for match in SENSITIVE_ASSIGN_RE.finditer(text): + value = match.group(1) + lower_value = value.lower() + if any(hint in lower_value for hint in PLACEHOLDER_HINTS): continue - parts = m.group(1).rstrip("/").split("/") - for part in reversed(parts): - if len(part) >= 32 and all(c in "0123456789abcdef" for c in part): - return part - return "" + if len(value) >= 24: + return True + if len(value) >= 20 and ( + any(ch.isdigit() for ch in value) + or any(ch in "._~+/=-" for ch in value) + ): + return True + return False + + def build_nmem_command(nmem_bin: str, *args: str) -> list[str]: if nmem_bin.lower().endswith(".cmd"): return ["cmd.exe", "/d", "/c", "call", nmem_bin, *args] @@ -559,19 +585,22 @@ def main() -> int: thread_exists = False try: - # NOTE: --host-agent-id requires nmem CLI >= TBD (currently unrecognized). - # The nmem maintainer has been asked to add this flag to 'nmem t import'. - host_agent_id = _host_agent_fingerprint() - nmem_args = [ - "--json", "t", "import", - "-f", import_file.name, - "-t", title, - "--id", thread_id, - "-s", "copilot-cli", - ] - if host_agent_id: - nmem_args.extend(["--host-agent-id", host_agent_id]) - run_json(build_nmem_command(nmem_bin, *nmem_args)) + run_json( + build_nmem_command( + nmem_bin, + "--json", + "t", + "import", + "-f", + import_file.name, + "-t", + title, + "--id", + thread_id, + "-s", + "copilot-cli", + ) + ) except Exception as exc: if "already exists" in str(exc).lower(): thread_exists = True diff --git a/nowledge-mem-hermes/provider.py b/nowledge-mem-hermes/provider.py index afcb3a60..2b718e21 100644 --- a/nowledge-mem-hermes/provider.py +++ b/nowledge-mem-hermes/provider.py @@ -12,7 +12,6 @@ from __future__ import annotations -import hashlib import json import logging import os @@ -201,14 +200,6 @@ "startedAt", ) -# Ordered by preference: /etc/machine-id (systemd/Linux standard), -# then overlay root mountinfo (Docker/LazyCat containers). -_FINGERPRINT_SOURCES = ( - "/etc/machine-id", - "__mac__", - "/proc/1/mountinfo", -) - def tool_error(message: Any, **extra: Any) -> str: """Return Hermes-style JSON error payloads across old and new releases.""" @@ -246,9 +237,9 @@ def __init__(self) -> None: self._session_id = "" self._saved_message_count = 0 self._saved_message_counts: Dict[str, int] = {} - self._agent_identity = "" self._delta_only_sessions: Set[str] = set() self._written_message_signatures: Dict[str, List[str]] = {} + self._agent_identity = "" @property def name(self) -> str: @@ -271,8 +262,7 @@ def initialize(self, session_id: str, **kwargs: Any) -> None: self._cron_skipped = True return - hermes_home = kwargs.get("hermes_home", "") - config = self._load_config(hermes_home) + config = self._load_config(kwargs.get("hermes_home", "")) raw_timeout = config.get("timeout", 30) try: timeout = int(raw_timeout or 30) @@ -284,12 +274,7 @@ def initialize(self, session_id: str, **kwargs: Any) -> None: timeout = 30 if timeout <= 0: timeout = 30 - - # Resolve agent identity once, auto-generating a fingerprint if needed. self._agent_identity = self._resolve_agent_identity(config, kwargs) - if self._agent_identity and not config.get("agent_identity"): - self._save_agent_identity(hermes_home, self._agent_identity) - self._resolved_space = self._resolve_space(config, self._agent_identity) self._client = NowledgeMemClient(timeout=timeout, space=self._resolved_space) self._activate_session(session_id or "", reset=True) @@ -299,11 +284,10 @@ def initialize(self, session_id: str, **kwargs: Any) -> None: self._client = None return - host_agent_id = self._agent_identity or None try: context_bundle = getattr(self._client, "context_bundle")( source_app="hermes", - host_agent_id=host_agent_id, + host_agent_id=self._agent_identity or None, ) self._context_bundle = self._format_context_bundle(context_bundle) except Exception as error: @@ -796,155 +780,6 @@ def _response_error(result: Any) -> str: errors.append(str(item.get("error"))) return "; ".join(errors) or json.dumps(result, ensure_ascii=False)[:300] - # ── agent identity resolution ──────────────────────────────────── - - @staticmethod - def _resolve_agent_identity( - config: Dict[str, Any], - kwargs: Dict[str, Any], - ) -> str: - """Resolve the agent identity for this client. - - 1. Hermes gives a named profile identity (not "default") → use it. - 2. ``nowledge-mem.json`` has an explicit ``agent_identity`` → use it. - 3. Otherwise → generate a stable fingerprint from system sources. - """ - raw_identity = kwargs.get("agent_identity") - identity = str(raw_identity).strip() if raw_identity else "" - if identity and identity != "default": - return identity - config_identity = config.get("agent_identity") - if isinstance(config_identity, str) and config_identity.strip(): - return config_identity.strip() - fingerprint = NowledgeMemProvider._generate_fingerprint() - if fingerprint: - logger.info( - "Auto-generated agent identity fingerprint: %s", fingerprint - ) - return fingerprint - return identity - - @staticmethod - def _generate_fingerprint() -> str: - """Derive a stable agent-identity fingerprint from system sources. - - Tries each source in ``_FINGERPRINT_SOURCES`` in order: - - * ``/etc/machine-id`` — standard on systemd hosts. - * ``__mac__`` — first non-loopback MAC address from /sys/class/net. - * ``/proc/1/mountinfo`` — overlay upperdir layer hash (Docker/LazyCat). - - Prefixes: - * machine-id / MAC → ``"hermes-XXXXXXXX"`` - * overlay → ``"overlay-XXXXXXXX"`` - - Returns a fingerprint string or an empty string. - """ - for source in _FINGERPRINT_SOURCES: - if source == "__mac__": - raw = NowledgeMemProvider._read_mac_address() - else: - try: - raw = Path(source).read_text(encoding="utf-8").strip() - except (OSError, UnicodeDecodeError): - continue - if not raw: - continue - - if source == "/proc/1/mountinfo": - raw = NowledgeMemProvider._extract_overlay_id(raw) - if not raw: - continue - - suffix = hashlib.sha256(raw.encode()).hexdigest()[:8] - if source == "/proc/1/mountinfo": - return f"overlay-{suffix}" - return f"hermes-{suffix}" - - return "" - - @staticmethod - def _read_mac_address() -> str: - """Return the first non-loopback MAC address from /sys/class/net. - - Skips ``lo`` and addresses that are all zeros. - Returns a string like ``"02:42:ac:1c:02:04"`` or an empty string. - """ - net_dir = Path("/sys/class/net") - if not net_dir.is_dir(): - return "" - try: - ifaces = sorted(p.name for p in net_dir.iterdir() if p.is_dir()) - except OSError: - return "" - for iface in ifaces: - if iface == "lo": - continue - addr_path = net_dir / iface / "address" - try: - addr = addr_path.read_text(encoding="utf-8").strip() - except (OSError, UnicodeDecodeError): - continue - if addr and addr != "00:00:00:00:00:00": - return addr - return "" - - @staticmethod - def _extract_overlay_id(mountinfo: str) -> str: - """Extract the overlay upperdir layer ID from /proc/1/mountinfo. - - Docker's overlay2 driver stores container-specific layers in - ``upperdir``. The directory name is a SHA256 hash unique to - the container and stable across restarts. - """ - import re as _re - for line in mountinfo.splitlines(): - if " / " not in line or "overlay" not in line: - continue - m = _re.search(r"upperdir=([^,]+)", line) - if not m: - continue - parts = m.group(1).rstrip("/").split("/") - for part in reversed(parts): - if len(part) >= 32 and all(c in "0123456789abcdef" for c in part): - return part - return "" - - @staticmethod - def _save_agent_identity(hermes_home: str, agent_identity: str) -> None: - """Persist the resolved agent identity into ``nowledge-mem.json`` - so the fingerprint survives restarts and is not regenerated.""" - if not hermes_home or not agent_identity: - return - config_path = Path(hermes_home) / "nowledge-mem.json" - try: - if config_path.exists(): - try: - cfg = json.loads(config_path.read_text(encoding="utf-8")) - except Exception: - cfg = {} - else: - cfg = {} - if not isinstance(cfg, dict): - cfg = {} - if cfg.get("agent_identity") == agent_identity: - return - cfg["agent_identity"] = agent_identity - config_path.parent.mkdir(parents=True, exist_ok=True) - config_path.write_text( - json.dumps(cfg, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) - logger.info( - "Persisted agent_identity=%s to %s", agent_identity, config_path - ) - except OSError as exc: - logger.debug( - "Could not save agent_identity to %s: %s", config_path, exc - ) - - # ── config loading / space resolution ──────────────────────────── - @staticmethod def _load_config(hermes_home: str) -> Dict[str, Any]: if hermes_home: @@ -963,6 +798,29 @@ def _load_config(hermes_home: str) -> Dict[str, Any]: ) return {} + @staticmethod + def _resolve_agent_identity( + config: Dict[str, Any], + kwargs: Dict[str, Any], + ) -> str: + """Resolve an explicit Hermes agent identity. + + Hermes may pass ``agent_identity`` for a named profile. If Hermes sends + ``default`` (or nothing), an explicit ``nowledge-mem.json`` value may + still map this provider to a Mem AI Identity. The provider never + invents a machine/container fingerprint as identity. + """ + raw_identity = kwargs.get("agent_identity") + identity = str(raw_identity).strip() if raw_identity else "" + if identity and identity != "default": + return identity + + config_identity = config.get("agent_identity") + if isinstance(config_identity, str) and config_identity.strip(): + return config_identity.strip() + + return "" + @staticmethod def _resolve_space(config: Dict[str, Any], identity: str) -> str | None: if "space" in config: @@ -970,7 +828,7 @@ def _resolve_space(config: Dict[str, Any], identity: str) -> str | None: if isinstance(raw_space, str): return raw_space.strip() - identity = (identity or "").strip() + identity = str(identity or "").strip() identity_map = config.get("space_by_identity") if isinstance(identity_map, str): try: diff --git a/nowledge-mem-hermes/tests/test_space_resolution.py b/nowledge-mem-hermes/tests/test_space_resolution.py index 2e80fa2c..5b16cf6f 100644 --- a/nowledge-mem-hermes/tests/test_space_resolution.py +++ b/nowledge-mem-hermes/tests/test_space_resolution.py @@ -50,7 +50,8 @@ def test_configured_space_beats_env(self): os.environ["NMEM_SPACE"] = "Env Space" try: resolved = provider.NowledgeMemProvider._resolve_space( - {"space": "Configured Space"}, "research", + {"space": "Configured Space"}, + "research", ) self.assertEqual(resolved, "Configured Space") finally: @@ -67,13 +68,15 @@ def test_identity_map_beats_template(self): "ops": "Operations Agent", }, "space_template": "agent-{identity}", - }, "research", + }, + "research", ) self.assertEqual(resolved, "Research Agent") def test_template_falls_back_when_no_mapping(self): resolved = provider.NowledgeMemProvider._resolve_space( - {"space_template": "agent-{identity}"}, "ops", + {"space_template": "agent-{identity}"}, + "ops", ) self.assertEqual(resolved, "agent-ops") @@ -122,7 +125,8 @@ def test_non_string_space_falls_through_to_identity_resolution(self): { "space": None, "space_by_identity": {"research": "Research Agent"}, - }, "research", + }, + "research", ) self.assertEqual(resolved, "Research Agent") @@ -150,6 +154,24 @@ def test_missing_identity_does_not_synthesize_space(self): else: os.environ["NMEM_SPACE_ID"] = previous_space_id + def test_named_hermes_identity_beats_config_identity(self): + resolved = provider.NowledgeMemProvider._resolve_agent_identity( + {"agent_identity": "configured"}, + {"agent_identity": "research"}, + ) + self.assertEqual(resolved, "research") + + def test_default_hermes_identity_uses_explicit_config_identity(self): + resolved = provider.NowledgeMemProvider._resolve_agent_identity( + {"agent_identity": "configured"}, + {"agent_identity": "default"}, + ) + self.assertEqual(resolved, "configured") + + def test_missing_identity_does_not_generate_fingerprint(self): + resolved = provider.NowledgeMemProvider._resolve_agent_identity({}, {}) + self.assertEqual(resolved, "") + def test_client_explicit_empty_space_clears_inherited_environment(self): captured: dict[str, object] = {} original_run = client_module.subprocess.run @@ -388,4 +410,4 @@ def _fake_urlopen(request, **_kwargs): if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main()