diff --git a/README.md b/README.md index fcf2650..3d3d6d3 100644 --- a/README.md +++ b/README.md @@ -3,51 +3,60 @@ Maps platform-specific user IDs (Discord snowflake, Telegram UID, terminal session) and kanban boards to stable Honcho peer names. Eliminates `user-default-t_*` peers from kanban workers and unifies a user's identity -across platforms (Discord + Telegram → same Honcho peer). +across platforms. + +**Zero modifications to the Hermes repo.** No fork required. The plugin +uses runtime monkey-patching at plugin load time to wrap Honcho's session +initialization — no files are changed on disk. ## Architecture ``` - ┌──────────────────────┐ - │ identity-config.json │ - │ /opt/data/ │ - └──────┬───────────────┘ - │ read on init - ┌──────────────────┼──────────────────┐ - │ │ │ - ▼ ▼ ▼ - ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ - │ pre_gateway │ │ on_session │ │ Honcho │ - │ dispatch │ │ start hook │ │ injector │ - │ hook (log) │ │ (log/track) │ │ (session.py)│ - └──────────────┘ └──────────────┘ └──────┬───────┘ - │ - resolves peer_name - before Honcho init + ┌──────────────────────────┐ + │ identity-config.json │ + │ /opt/data/ │ + └──────┬───────────────────┘ + │ reads on every resolve + ┌──────────────────┼───────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ + │ pre_gateway │ │ /identity │ │ Honcho monkey-patch │ + │ dispatch │ │ slash cmd │ │ (runtime, no files) │ + │ hook (log) │ │ (manage) │ └──────────┬───────────┘ + └──────────────┘ └──────────────┘ │ + injects peer_name + into kwargs["user_id"] + BEFORE Honcho session init ``` -Two components work together: +## How it works -1. **This plugin** (user-installed) — provides `/identity` slash command for - config management, `pre_gateway_dispatch` logging, and `on_session_start` - tracking. Installed via `hermes plugins install`. +1. **Plugin loads** → `register()` is called → monkey-patches + `HonchoMemoryProvider._do_session_init` with a wrapper. -2. **Honcho injector** (~6 lines in `plugins/memory/honcho/session.py`) — reads - the config file and `HERMES_KANBAN_TASK` env var to inject the correct - `runtime_user_peer_name` into Honcho's session initialization. See - [`docs/honcho-injector.md`](docs/honcho-injector.md). +2. **Kanban worker starts** → Honcho tries to init → no `user_id` from + gateway → the wrapper calls `get_peer_name()` → reads the task body + from `HERMES_KANBAN_DB` → extracts `context_peer: ` → injects + it as `user_id` → Honcho creates the session with the correct peer. + +3. **Gateway session starts** (Discord/Telegram) → normal flow with + platform user ID → monkey-patch is bypassed (user_id already set). + +4. **Missing config or env** → returns None → Honcho behaves exactly as + before (creates `user-default-*` peers — safe fallback). ## Installation ```bash -# 1. Install the plugin -hermes plugins install gitea:code.lazyworkhorse.net/Hermes/hermes-identity-plugin +# 1. Install the plugin from Gitea +hermes plugins install ssh://git@code.lazyworkhorse.net:2222/Hermes/hermes-identity-plugin.git -# 2. Apply the Honcho injector patch (see docs/honcho-injector.md) -# 3. Create the config file +# 2. Create the config file (persistent volume) cp config.sample.json /opt/data/identity-config.json -# 4. Edit /opt/data/identity-config.json with your mappings -# 5. Restart gateways + +# 3. Edit /opt/data/identity-config.json with your mappings +# 4. Restart gateways to pick up the plugin ``` ## Config file: `/opt/data/identity-config.json` @@ -56,7 +65,9 @@ cp config.sample.json /opt/data/identity-config.json { "mappings": [ {"platform": "discord", "id": "479136126737711105", "peer": "thierry"}, - {"platform": "telegram", "id": "123456789", "peer": "thierry"} + {"platform": "telegram", "id": "123456789", "peer": "thierry"}, + {"platform": "matrix", "id": "@thierry:example.org", "peer": "thierry"}, + {"platform": "terminal", "id": "default", "peer": "thierry"} ], "boards": { "default": "thierry", @@ -69,7 +80,7 @@ cp config.sample.json /opt/data/identity-config.json ## Task body convention -Every kanban task MUST include a `context_peer` in its body: +Every kanban task SHOULD include a `context_peer` in its body: ```markdown Do research on Postgres migration costs. @@ -79,23 +90,25 @@ context_peer: thierry ``` ``` -Profiles (Claire, Ashley, Finn, Matt) MUST include this when calling -`kanban_create`. The `enforce_context_peer` config flag controls whether -missing `context_peer` causes an error. +Profiles (Claire, Ashley, Finn, Matt) set this when calling `kanban_create`. + +**If `context_peer` is missing**, the plugin falls back to the board +config (e.g., `boards.default` → `thierry`). If no board config either, +it uses `fallback_peer`. If that's also unset → `user-default-*` (safe). ## Commands - `/identity status` — show current config and resolved peer - `/identity list` — list all mappings - `/identity add discord ` — add a new mapping -- `/identity rm ` — remove mapping -- `/identity board ` — set board peer +- `/identity rm ` — remove mapping by index +- `/identity board ` — set board's default peer - `/identity help` — full help -## Enforcing the convention +## Safety -Add this to the kanban-worker skill or the profile's system prompt: - -> When creating a kanban task with `kanban_create`, you MUST include -> `context_peer: ` in the task body as a metadata block. -> Use the user's Honcho peer name — not your own profile name. +- The monkey-patch is applied ONLY at plugin load time, before any session. +- If the plugin is uninstalled or fails to load, Honcho is untouched. +- If the config file is missing, all resolution returns None → Honcho + creates `user-default-*` as today. +- No data loss risk. No changes to any file in the Hermes repo. diff --git a/__init__.py b/__init__.py index 45fe130..d344780 100644 --- a/__init__.py +++ b/__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: `` 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: ` 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: " + 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") diff --git a/docs/honcho-injector.md b/docs/honcho-injector.md deleted file mode 100644 index 06a3b5c..0000000 --- a/docs/honcho-injector.md +++ /dev/null @@ -1,116 +0,0 @@ -# Honcho Injector Patch - -The identity plugin cannot directly inject `runtime_user_peer_name` into -Honcho from a user-installed hook (the hook system only allows message -rewrite/skip, not user_id modification). Instead, we add ~6 lines in the -bundled Honcho plugin's session initialization. - -## File - -`plugins/memory/honcho/__init__.py` — line ~362 - -## The change - -### Before - -```python -runtime_user_peer_name=kwargs.get("user_id") or None, -``` - -### After - -```python -runtime_user_peer_name=kwargs.get("user_id") or _resolve_identity_peer(), -``` - -### Add this function in the same file (or import from the identity plugin) - -```python -def _resolve_identity_peer() -> str | None: - """Resolve peer name from env vars and identity config. - - Priority: HERMES_HONCHO_PEER_NAME → kanban task body context_peer - → kanban board config → None (falls through to Honcho default). - """ - import json, os, sqlite3, re - from pathlib import Path - - # 1. Explicit env override - explicit = os.environ.get("HERMES_HONCHO_PEER_NAME") - if explicit: - return explicit - - # 2. Kanban worker: read task body for context_peer - task_id = os.environ.get("HERMES_KANBAN_TASK") - db_path = os.environ.get("HERMES_KANBAN_DB") - if task_id and db_path: - db = Path(db_path) - if db.exists(): - try: - conn = sqlite3.connect(str(db)) - conn.row_factory = sqlite3.Row - try: - row = conn.execute( - "SELECT body FROM tasks WHERE id = ?", (task_id,) - ).fetchone() - if row and row["body"]: - m = re.search(r"context_peer:\s*(\S+)", row["body"]) - if m: - return m.group(1) - finally: - conn.close() - except Exception: - pass - - # 3. Identity config file - cfg_path = Path("/opt/data/identity-config.json") - if cfg_path.exists(): - try: - cfg = json.loads(cfg_path.read_text()) - # Check kanban board config - board = os.environ.get("HERMES_KANBAN_BOARD") - if board and board in cfg.get("boards", {}): - return cfg["boards"][board] - # Use fallback - return cfg.get("fallback_peer") - except Exception: - pass - - return None -``` - -## How it works - -1. When a **gateway session** starts, `kwargs["user_id"]` carries the platform - ID (Discord snowflake, Telegram UID). The identity config file maps these - to canonical peer names. The injector is bypassed — normal flow. - -2. When a **kanban worker** starts, `kwargs["user_id"]` is None (no gateway). - The injector kicks in: - - a. Checks `HERMES_HONCHO_PEER_NAME` env var (set by a future dispatcher - enhancement or manually). - - b. Reads the kanban task body from the SQLite database, extracts - `context_peer: ` from the body. - - c. Falls back to the board-level config from identity-config.json. - - d. If nothing resolves → returns None → Honcho creates `user-default-*` - as today (safe fallback). - -## Why not modify the kanban dispatcher? - -The kanban dispatcher (`hermes_cli/kanban_db.py:_default_spawn`) is core -Hermes code. We avoid touching it. Instead, the Honcho injector reads the -task directly from the kanban DB using env vars that are already set -(`HERMES_KANBAN_TASK`, `HERMES_KANBAN_DB`, `HERMES_KANBAN_BOARD`). - -This adds ~10 microseconds to worker startup — negligible. - -## Compatibility - -- If the identity plugin is removed, the injector function returns None and - Honcho behaves exactly as before. -- If the config file is missing, same safe fallback. -- No data loss risk. diff --git a/docs/how-it-works.md b/docs/how-it-works.md new file mode 100644 index 0000000..7cdfe5e --- /dev/null +++ b/docs/how-it-works.md @@ -0,0 +1,82 @@ +# Architecture: Why monkey-patch instead of patching files? + +The identity plugin uses **runtime monkey-patching** — no files in the +Hermes repo are modified. This is the key design decision that eliminates +the need for a fork. + +## The problem + +Kanban workers start with NO gateway user identity. The spawn function +runs `hermes -p chat -q "work kanban task "`. When Honcho +initializes, `kwargs["user_id"]` is None, so it creates `user-default-*` +peers. + +There is no Hermes plugin hook that fires at agent initialization before +Honcho creates the session. The hooks available are: +- `pre_gateway_dispatch` — only fires for gateway sessions (Discord, etc.) +- `on_session_start` — fires AFTER Honcho has already initialized +- `pre_tool_call` / `post_tool_call` — fire during tool execution, too late + +## The solution: runtime monkey-patch + +Python allows replacing any method on any class at runtime. The identity +plugin does exactly this in its `register()` function: + +1. At plugin load time, it imports `HonchoMemoryProvider` +2. Stores a reference to the original `_do_session_init` method +3. Replaces it with a wrapper that checks the identity config first +4. If the wrapper finds a peer name, it injects it via `kwargs["user_id"]` +5. Otherwise, it calls the original method unchanged + +```python +# In the plugin's register() function: +provider_cls = plugins.memory.honcho.HonchoMemoryProvider +_original_init = provider_cls._do_session_init + +@wraps(_original_init) +def wrapper(self, cfg, session_id, **kwargs): + if not kwargs.get("user_id"): + resolved = get_peer_name() # reads config + env vars + if resolved: + kwargs["user_id"] = resolved + return _original_init(self, cfg, session_id, **kwargs) + +provider_cls._do_session_init = wrapper +``` + +## Advantages over file patching + +| Aspect | File patching | Monkey-patching | +|--------|--------------|-----------------| +| Repo modifications | Yes (edits plugin file) | Zero | +| Fork needed | Yes (git diff to maintain) | No | +| Upgrades | Merge conflict on every pull | Unchanged | +| Uninstall | Need to revert file changes | Plugin removed → clean | +| Reliability | Works across restarts | Applied at every load | +| Complexity | Simple file edit | Wrapping pattern | + +## When does the patch apply? + +- At plugin discovery time (Hermes startup) +- Before any Honcho session is created +- Before any agent is initialized +- Before any kanban worker is spawned + +The Hermes plugin loader calls `register()` during gateway/agent startup, +which means the patch is in place before any session can begin. + +## Safety guarantees + +1. **Non-destructive**: The original method is preserved via closure — + removing the plugin restores original behavior with zero cleanup. + +2. **Idempotent**: `_apply_honcho_patch()` guards against double-patching + via the `_original_init` sentinel. + +3. **Graceful degradation**: If the config file is missing, parsing fails, + or the kanban DB is unavailable, the wrapper returns None → Honcho + falls back to its default behavior (creating `user-default-*` peers). + +4. **No data loss**: The injected `kwargs["user_id"]` only affects what + peer name Honcho assigns to the session's messages. It doesn't touch + any existing data.