Skip to content
Merged
Changes from 2 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4d24015
feat: allow nowledge-mem.json agent_identity override when Hermes pro…
KingBoyAndGirl Jun 21, 2026
d7dc4f0
feat: auto-generate agent identity fingerprint from system sources
KingBoyAndGirl Jun 21, 2026
e3289fd
fix: update test_space_resolution.py for new _resolve_space signature
KingBoyAndGirl Jun 21, 2026
413a5dd
fix: replace empty dict with empty string in _resolve_space test calls
KingBoyAndGirl Jun 21, 2026
90b746d
feat: add agent identity fingerprint to Codex and Claude Code plugins
KingBoyAndGirl Jun 21, 2026
f39e50b
revert: remove --host-agent-id from Codex/Claude Code save hooks (CLI…
KingBoyAndGirl Jun 21, 2026
7c86b27
Revert "revert: remove --host-agent-id from Codex/Claude Code save ho…
KingBoyAndGirl Jun 21, 2026
88375c4
feat: add agent identity fingerprint to Codex and Claude Code save ho…
KingBoyAndGirl Jun 21, 2026
7a1c804
feat: add agent identity fingerprint to Copilot CLI save hook (pendin…
KingBoyAndGirl Jun 21, 2026
e570ebf
fix: use universal fingerprint sources (machine-id, MAC, overlay)
Jun 21, 2026
19d20be
fix: address CodeRabbit review - overlay extraction and error handling
Jun 21, 2026
b358543
fix: use robust overlay2 layer hash extraction (re.search for ≥32 hex…
KingBoyAndGirl Jun 21, 2026
792dda6
fix: use robust overlay2 layer hash extraction (re.search for ≥32 hex…
KingBoyAndGirl Jun 21, 2026
94190cb
fix: use robust overlay2 layer hash extraction (re.search for ≥32 hex…
KingBoyAndGirl Jun 21, 2026
820eb66
fix: add MAC address source + overlay- prefix for codex fingerprint
KingBoyAndGirl Jun 21, 2026
0510e48
fix: add MAC address source + overlay- prefix for copilot-cli fingerp…
KingBoyAndGirl Jun 21, 2026
b66576f
fix: add MAC address source + overlay- prefix for claude-code fingerp…
KingBoyAndGirl Jun 21, 2026
6798859
fix: add MAC address source _read_mac_address + overlay- prefix to He…
KingBoyAndGirl Jun 21, 2026
a5aff1c
fix: use explicit Hermes agent identity
wey-gu Jun 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 139 additions & 8 deletions nowledge-mem-hermes/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from __future__ import annotations

import hashlib
import json
import logging
import os
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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]] = {}

Expand All @@ -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)
Expand All @@ -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)

Expand All @@ -282,8 +298,7 @@ def initialize(self, session_id: str, **kwargs: Any) -> None:
self._client = None
return

raw_identity = kwargs.get("agent_identity")
host_agent_id = str(raw_identity).strip() if raw_identity else None
host_agent_id = self._agent_identity or None
try:
context_bundle = getattr(self._client, "context_bundle")(
source_app="hermes",
Expand All @@ -304,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:
Expand Down Expand Up @@ -777,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:
Expand All @@ -796,14 +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()
identity = (identity or "").strip()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
identity_map = config.get("space_by_identity")
if isinstance(identity_map, str):
try:
Expand Down