feat: runtime monkey-patch instead of file patching — zero fork required
The plugin now monkey-patches HonchoMemoryProvider._do_session_init at plugin load time instead of requiring changes to any file in the Hermes repo. This means: - No modifications to plugins/memory/honcho/__init__.py or any other file - No fork needed — pure plugin approach - Survives Hermes upgrades without merge conflicts - Removed docs/honcho-injector.md (replaced by docs/how-it-works.md) - Updated README with new architecture description Config file at /opt/data/identity-config.json remains the single source of truth for mappings.
This commit is contained in:
129
__init__.py
129
__init__.py
@@ -2,11 +2,10 @@
|
||||
hermes-identity-plugin (identity)
|
||||
|
||||
Resolves platform-specific user IDs and kanban workers to stable Honcho
|
||||
peer names. The plugin provides hooks + slash command. The actual peer name
|
||||
injection into Honcho requires a ~6-line change in the bundled Honcho plugin
|
||||
(plugins/memory/honcho/session.py) — see docs/honcho-injector.md.
|
||||
peer names. NO modifications to the Hermes repo required — the plugin
|
||||
monkey-patches Honcho's session initialization at plugin load time.
|
||||
|
||||
Config: /opt/data/identity-config.json (persistent across container rebuilds)
|
||||
Config: /opt/data/identity-config.json (persistent volume)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,8 +15,9 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -51,11 +51,11 @@ def save_config(cfg: dict[str, Any]) -> None:
|
||||
logger.info("identity: config saved to %s", CONFIG_PATH)
|
||||
|
||||
|
||||
# ── Resolution ──────────────────────────────────────────────────────────────
|
||||
# ── Peer resolution ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def resolve_peer(platform: str, platform_id: str) -> str | None:
|
||||
"""Resolve a platform+ID to a canonical peer name."""
|
||||
"""Resolve a platform+ID to a canonical peer name from config."""
|
||||
cfg = load_config()
|
||||
for m in cfg.get("mappings", []):
|
||||
if m.get("platform") == platform and str(m.get("id", "")) == str(platform_id):
|
||||
@@ -75,8 +75,9 @@ def get_peer_name() -> str | None:
|
||||
"""Resolve peer name for the current process context.
|
||||
|
||||
Priority:
|
||||
1. HERMES_HONCHO_PEER_NAME env var (set by kanban dispatcher or manually)
|
||||
1. HERMES_HONCHO_PEER_NAME env var (explicit override or manual)
|
||||
2. Kanban worker: task body context_peer → board config → fallback
|
||||
3. None (Honcho behaves as before)
|
||||
"""
|
||||
explicit = os.environ.get("HERMES_HONCHO_PEER_NAME")
|
||||
if explicit:
|
||||
@@ -88,7 +89,8 @@ def get_peer_name() -> str | None:
|
||||
if task_id and db_path:
|
||||
return _resolve_kanban_peer(task_id, board, db_path)
|
||||
|
||||
return None
|
||||
cfg = load_config()
|
||||
return cfg.get("fallback_peer")
|
||||
|
||||
|
||||
def _resolve_kanban_peer(task_id: str, board: str | None, db_path: str) -> str | None:
|
||||
@@ -104,7 +106,7 @@ def _resolve_kanban_peer(task_id: str, board: str | None, db_path: str) -> str |
|
||||
"SELECT body FROM tasks WHERE id = ?", (task_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return resolve_board_peer(board)
|
||||
body = row["body"] or ""
|
||||
peer = _extract_context_peer(body)
|
||||
if peer:
|
||||
@@ -131,14 +133,8 @@ def _extract_context_peer(body: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
# ── Enforce context_peer on kanban task creation ────────────────────────────
|
||||
# Profiles (Claire, Ashley, etc.) MUST include ``context_peer: <name>`` in
|
||||
# every kanban task body. The kanban-worker skill references this convention.
|
||||
# The Honcho injector (see docs/honcho-injector.md) reads it at worker start.
|
||||
|
||||
|
||||
def enforce_context_peer(body: str | None) -> str | None:
|
||||
"""Return error message if body lacks context_peer, None if OK."""
|
||||
"""Return error string if body lacks context_peer, None if OK."""
|
||||
cfg = load_config()
|
||||
if not cfg.get("enforce_context_peer", True):
|
||||
return None
|
||||
@@ -147,14 +143,81 @@ def enforce_context_peer(body: str | None) -> str | None:
|
||||
"Missing required `context_peer: <name>` in task body. "
|
||||
"Add a metadata block:\n"
|
||||
"```metadata\ncontext_peer: thierry\n```\n"
|
||||
"Replace `thierry` with the intended user's peer name."
|
||||
)
|
||||
peer = _extract_context_peer(body)
|
||||
if not peer:
|
||||
return "context_peer: found in body but could not be parsed. Use format: context_peer: <name>"
|
||||
return "context_peer: found in body but could not be parsed."
|
||||
return None
|
||||
|
||||
|
||||
# ── Runtime monkey-patch of Honcho session init ─────────────────────────────
|
||||
# This is the KEY mechanism: no files modified, no fork needed.
|
||||
# The plugin wraps HonchoMemoryProvider._do_session_init at plugin load time
|
||||
# to inject our resolved peer name before Honcho creates the session.
|
||||
|
||||
_original_init: Callable | None = None
|
||||
|
||||
|
||||
def _patched_do_session_init(self, cfg, session_id: str, **kwargs):
|
||||
"""Wrapper around Honcho's _do_session_init.
|
||||
|
||||
If no user_id was provided by the gateway (kanban worker / CLI context),
|
||||
resolve one from the identity config and inject it.
|
||||
"""
|
||||
if not kwargs.get("user_id"):
|
||||
resolved = get_peer_name()
|
||||
if resolved:
|
||||
logger.info(
|
||||
"identity: injecting peer '%s' into Honcho session (task=%s, board=%s)",
|
||||
resolved,
|
||||
os.environ.get("HERMES_KANBAN_TASK", "-"),
|
||||
os.environ.get("HERMES_KANBAN_BOARD", "-"),
|
||||
)
|
||||
kwargs["user_id"] = resolved
|
||||
return _original_init(self, cfg, session_id, **kwargs) # type: ignore[misc]
|
||||
|
||||
|
||||
def _apply_honcho_patch():
|
||||
"""Apply monkey-patch to Honcho's session initialization.
|
||||
|
||||
Must be called after Honcho is loaded but before any session starts.
|
||||
Safe to call multiple times - only patches on first call.
|
||||
"""
|
||||
global _original_init
|
||||
|
||||
if _original_init is not None:
|
||||
return # already patched
|
||||
|
||||
try:
|
||||
import plugins.memory.honcho # type: ignore[import-untyped]
|
||||
# Use string-based patching to avoid import-time issues
|
||||
honcho_mod = __import__(
|
||||
"plugins.memory.honcho",
|
||||
fromlist=["HonchoMemoryProvider"],
|
||||
)
|
||||
provider_cls = getattr(honcho_mod, "HonchoMemoryProvider", None)
|
||||
if provider_cls is None:
|
||||
logger.warning("identity: HonchoMemoryProvider not found, cannot patch")
|
||||
return
|
||||
|
||||
orig = getattr(provider_cls, "_do_session_init", None)
|
||||
if orig is None:
|
||||
logger.warning("identity: HonchoMemoryProvider._do_session_init not found")
|
||||
return
|
||||
|
||||
# Store original and replace with wrapper
|
||||
_original_init = orig
|
||||
|
||||
@wraps(orig)
|
||||
def wrapper(self, cfg, session_id: str, **kwargs):
|
||||
return _patched_do_session_init(self, cfg, session_id, **kwargs)
|
||||
|
||||
setattr(provider_cls, "_do_session_init", wrapper)
|
||||
logger.info("identity: patched HonchoMemoryProvider._do_session_init ✓")
|
||||
except Exception as exc:
|
||||
logger.warning("identity: failed to patch Honcho init: %s", exc)
|
||||
|
||||
|
||||
# ── Hooks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -168,7 +231,7 @@ def _pre_gateway_dispatch(event: Any, gateway: Any, session_store: Any, **kw) ->
|
||||
logger.debug("identity: gateway platform=%s user_id=%s → peer=%s", platform, user_id, resolved)
|
||||
else:
|
||||
logger.info("identity: unmapped gateway user platform=%s user_id=%s", platform, user_id)
|
||||
return None # allow normal dispatch
|
||||
return None
|
||||
|
||||
|
||||
def _on_session_start(**kw: Any) -> None:
|
||||
@@ -179,7 +242,7 @@ def _on_session_start(**kw: Any) -> None:
|
||||
if peer:
|
||||
logger.info("identity: kanban worker task=%s peer=%s", task_id, peer)
|
||||
else:
|
||||
logger.info("identity: kanban worker task=%s no peer → user-default", task_id)
|
||||
logger.info("identity: kanban worker task=%s → user-default (no mapping)", task_id)
|
||||
elif peer:
|
||||
logger.debug("identity: session peer=%s", peer)
|
||||
|
||||
@@ -199,15 +262,13 @@ def _cmd_identity(raw_args: str) -> str:
|
||||
elif cmd == "list":
|
||||
return _list_mappings()
|
||||
elif cmd in ("add", "set") and len(args) >= 4:
|
||||
# /identity add discord 479136126737711105 thierry
|
||||
platform, pid, peer_name = args[1], args[2], args[3]
|
||||
return _add_mapping(platform, pid, peer_name)
|
||||
elif cmd == "rm" and len(args) >= 2:
|
||||
idx = int(args[1]) if args[1].isdigit() else -1
|
||||
return _remove_mapping(idx)
|
||||
elif cmd == "board" and len(args) >= 3:
|
||||
slug, peer_name = args[1], args[2]
|
||||
return _set_board(slug, peer_name)
|
||||
return _set_board(args[1], args[2])
|
||||
elif cmd == "help":
|
||||
return _help_text()
|
||||
return _help_text()
|
||||
@@ -221,13 +282,13 @@ def _show_status() -> str:
|
||||
f"Mappings: {len(cfg.get('mappings', []))}",
|
||||
f"Boards: {len(cfg.get('boards', {}))}",
|
||||
f"Enforce context_peer: {cfg.get('enforce_context_peer', True)}",
|
||||
f"Fallback peer: {cfg.get('fallback_peer', 'user')}",
|
||||
f"Fallback: {cfg.get('fallback_peer', 'user')}",
|
||||
f"Honcho patch: {'✓' if _original_init else '✗ not applied'}",
|
||||
"",
|
||||
"Current context:",
|
||||
]
|
||||
peer = get_peer_name()
|
||||
if peer:
|
||||
lines.append(f" Resolved peer: **{peer}**")
|
||||
lines.append(f" Resolved peer: **{peer or '(none — will use user-default)'}**")
|
||||
task = os.environ.get("HERMES_KANBAN_TASK")
|
||||
if task:
|
||||
lines.append(f" Kanban task: `{task}`")
|
||||
@@ -258,7 +319,7 @@ def _add_mapping(platform: str, pid: str, peer_name: str) -> str:
|
||||
cfg = load_config()
|
||||
cfg.setdefault("mappings", []).append({"platform": platform, "id": pid, "peer": peer_name})
|
||||
save_config(cfg)
|
||||
return f"Added mapping: `{platform}` `{pid}` → **{peer_name}**"
|
||||
return f"Added: `{platform}` `{pid}` → **{peer_name}**"
|
||||
|
||||
|
||||
def _remove_mapping(idx: int) -> str:
|
||||
@@ -294,13 +355,21 @@ Commands:
|
||||
|
||||
|
||||
def register(ctx: Any) -> None:
|
||||
"""Register identity plugin hooks and slash command."""
|
||||
"""Register identity plugin hooks and apply Honcho session patch."""
|
||||
# Apply the runtime monkey-patch to Honcho's session init
|
||||
# NO files modified, NO fork needed — pure Python at import time
|
||||
_apply_honcho_patch()
|
||||
|
||||
# Register hooks
|
||||
ctx.register_hook("pre_gateway_dispatch", _pre_gateway_dispatch)
|
||||
ctx.register_hook("on_session_start", _on_session_start)
|
||||
|
||||
# Register slash command
|
||||
ctx.register_command(
|
||||
name="identity",
|
||||
handler=_cmd_identity,
|
||||
description="Manage identity peer mappings",
|
||||
args_hint="status|list|add|rm|board|help",
|
||||
)
|
||||
logger.info("identity-plugin: registered hooks (pre_gateway_dispatch, on_session_start) and /identity command")
|
||||
|
||||
logger.info("identity-plugin: registered ✓ (honcho patch=%s)", "applied" if _original_init else "not-applied")
|
||||
|
||||
Reference in New Issue
Block a user