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:
101
README.md
101
README.md
@@ -3,51 +3,60 @@
|
|||||||
Maps platform-specific user IDs (Discord snowflake, Telegram UID, terminal
|
Maps platform-specific user IDs (Discord snowflake, Telegram UID, terminal
|
||||||
session) and kanban boards to stable Honcho peer names. Eliminates
|
session) and kanban boards to stable Honcho peer names. Eliminates
|
||||||
`user-default-t_*` peers from kanban workers and unifies a user's identity
|
`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
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────┐
|
┌──────────────────────────┐
|
||||||
│ identity-config.json │
|
│ identity-config.json │
|
||||||
│ /opt/data/ │
|
│ /opt/data/ │
|
||||||
└──────┬───────────────┘
|
└──────┬───────────────────┘
|
||||||
│ read on init
|
│ reads on every resolve
|
||||||
┌──────────────────┼──────────────────┐
|
┌──────────────────┼───────────────────────┐
|
||||||
│ │ │
|
│ │ │
|
||||||
▼ ▼ ▼
|
▼ ▼ ▼
|
||||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐
|
||||||
│ pre_gateway │ │ on_session │ │ Honcho │
|
│ pre_gateway │ │ /identity │ │ Honcho monkey-patch │
|
||||||
│ dispatch │ │ start hook │ │ injector │
|
│ dispatch │ │ slash cmd │ │ (runtime, no files) │
|
||||||
│ hook (log) │ │ (log/track) │ │ (session.py)│
|
│ hook (log) │ │ (manage) │ └──────────┬───────────┘
|
||||||
└──────────────┘ └──────────────┘ └──────┬───────┘
|
└──────────────┘ └──────────────┘ │
|
||||||
│
|
injects peer_name
|
||||||
resolves peer_name
|
into kwargs["user_id"]
|
||||||
before Honcho init
|
BEFORE Honcho session init
|
||||||
```
|
```
|
||||||
|
|
||||||
Two components work together:
|
## How it works
|
||||||
|
|
||||||
1. **This plugin** (user-installed) — provides `/identity` slash command for
|
1. **Plugin loads** → `register()` is called → monkey-patches
|
||||||
config management, `pre_gateway_dispatch` logging, and `on_session_start`
|
`HonchoMemoryProvider._do_session_init` with a wrapper.
|
||||||
tracking. Installed via `hermes plugins install`.
|
|
||||||
|
|
||||||
2. **Honcho injector** (~6 lines in `plugins/memory/honcho/session.py`) — reads
|
2. **Kanban worker starts** → Honcho tries to init → no `user_id` from
|
||||||
the config file and `HERMES_KANBAN_TASK` env var to inject the correct
|
gateway → the wrapper calls `get_peer_name()` → reads the task body
|
||||||
`runtime_user_peer_name` into Honcho's session initialization. See
|
from `HERMES_KANBAN_DB` → extracts `context_peer: <name>` → injects
|
||||||
[`docs/honcho-injector.md`](docs/honcho-injector.md).
|
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
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Install the plugin
|
# 1. Install the plugin from Gitea
|
||||||
hermes plugins install gitea:code.lazyworkhorse.net/Hermes/hermes-identity-plugin
|
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)
|
# 2. Create the config file (persistent volume)
|
||||||
# 3. Create the config file
|
|
||||||
cp config.sample.json /opt/data/identity-config.json
|
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`
|
## Config file: `/opt/data/identity-config.json`
|
||||||
@@ -56,7 +65,9 @@ cp config.sample.json /opt/data/identity-config.json
|
|||||||
{
|
{
|
||||||
"mappings": [
|
"mappings": [
|
||||||
{"platform": "discord", "id": "479136126737711105", "peer": "thierry"},
|
{"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": {
|
"boards": {
|
||||||
"default": "thierry",
|
"default": "thierry",
|
||||||
@@ -69,7 +80,7 @@ cp config.sample.json /opt/data/identity-config.json
|
|||||||
|
|
||||||
## Task body convention
|
## 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
|
```markdown
|
||||||
Do research on Postgres migration costs.
|
Do research on Postgres migration costs.
|
||||||
@@ -79,23 +90,25 @@ context_peer: thierry
|
|||||||
```
|
```
|
||||||
```
|
```
|
||||||
|
|
||||||
Profiles (Claire, Ashley, Finn, Matt) MUST include this when calling
|
Profiles (Claire, Ashley, Finn, Matt) set this when calling `kanban_create`.
|
||||||
`kanban_create`. The `enforce_context_peer` config flag controls whether
|
|
||||||
missing `context_peer` causes an error.
|
**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
|
## Commands
|
||||||
|
|
||||||
- `/identity status` — show current config and resolved peer
|
- `/identity status` — show current config and resolved peer
|
||||||
- `/identity list` — list all mappings
|
- `/identity list` — list all mappings
|
||||||
- `/identity add discord <id> <peer>` — add a new mapping
|
- `/identity add discord <id> <peer>` — add a new mapping
|
||||||
- `/identity rm <index>` — remove mapping
|
- `/identity rm <index>` — remove mapping by index
|
||||||
- `/identity board <slug> <peer>` — set board peer
|
- `/identity board <slug> <peer>` — set board's default peer
|
||||||
- `/identity help` — full help
|
- `/identity help` — full help
|
||||||
|
|
||||||
## Enforcing the convention
|
## Safety
|
||||||
|
|
||||||
Add this to the kanban-worker skill or the profile's system prompt:
|
- 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.
|
||||||
> When creating a kanban task with `kanban_create`, you MUST include
|
- If the config file is missing, all resolution returns None → Honcho
|
||||||
> `context_peer: <peer_name>` in the task body as a metadata block.
|
creates `user-default-*` as today.
|
||||||
> Use the user's Honcho peer name — not your own profile name.
|
- No data loss risk. No changes to any file in the Hermes repo.
|
||||||
|
|||||||
129
__init__.py
129
__init__.py
@@ -2,11 +2,10 @@
|
|||||||
hermes-identity-plugin (identity)
|
hermes-identity-plugin (identity)
|
||||||
|
|
||||||
Resolves platform-specific user IDs and kanban workers to stable Honcho
|
Resolves platform-specific user IDs and kanban workers to stable Honcho
|
||||||
peer names. The plugin provides hooks + slash command. The actual peer name
|
peer names. NO modifications to the Hermes repo required — the plugin
|
||||||
injection into Honcho requires a ~6-line change in the bundled Honcho plugin
|
monkey-patches Honcho's session initialization at plugin load time.
|
||||||
(plugins/memory/honcho/session.py) — see docs/honcho-injector.md.
|
|
||||||
|
|
||||||
Config: /opt/data/identity-config.json (persistent across container rebuilds)
|
Config: /opt/data/identity-config.json (persistent volume)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -16,8 +15,9 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from functools import wraps
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Callable
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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)
|
logger.info("identity: config saved to %s", CONFIG_PATH)
|
||||||
|
|
||||||
|
|
||||||
# ── Resolution ──────────────────────────────────────────────────────────────
|
# ── Peer resolution ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def resolve_peer(platform: str, platform_id: str) -> str | None:
|
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()
|
cfg = load_config()
|
||||||
for m in cfg.get("mappings", []):
|
for m in cfg.get("mappings", []):
|
||||||
if m.get("platform") == platform and str(m.get("id", "")) == str(platform_id):
|
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.
|
"""Resolve peer name for the current process context.
|
||||||
|
|
||||||
Priority:
|
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
|
2. Kanban worker: task body context_peer → board config → fallback
|
||||||
|
3. None (Honcho behaves as before)
|
||||||
"""
|
"""
|
||||||
explicit = os.environ.get("HERMES_HONCHO_PEER_NAME")
|
explicit = os.environ.get("HERMES_HONCHO_PEER_NAME")
|
||||||
if explicit:
|
if explicit:
|
||||||
@@ -88,7 +89,8 @@ def get_peer_name() -> str | None:
|
|||||||
if task_id and db_path:
|
if task_id and db_path:
|
||||||
return _resolve_kanban_peer(task_id, board, 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:
|
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,)
|
"SELECT body FROM tasks WHERE id = ?", (task_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
return None
|
return resolve_board_peer(board)
|
||||||
body = row["body"] or ""
|
body = row["body"] or ""
|
||||||
peer = _extract_context_peer(body)
|
peer = _extract_context_peer(body)
|
||||||
if peer:
|
if peer:
|
||||||
@@ -131,14 +133,8 @@ def _extract_context_peer(body: str) -> str | None:
|
|||||||
return 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:
|
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()
|
cfg = load_config()
|
||||||
if not cfg.get("enforce_context_peer", True):
|
if not cfg.get("enforce_context_peer", True):
|
||||||
return None
|
return None
|
||||||
@@ -147,14 +143,81 @@ def enforce_context_peer(body: str | None) -> str | None:
|
|||||||
"Missing required `context_peer: <name>` in task body. "
|
"Missing required `context_peer: <name>` in task body. "
|
||||||
"Add a metadata block:\n"
|
"Add a metadata block:\n"
|
||||||
"```metadata\ncontext_peer: thierry\n```\n"
|
"```metadata\ncontext_peer: thierry\n```\n"
|
||||||
"Replace `thierry` with the intended user's peer name."
|
|
||||||
)
|
)
|
||||||
peer = _extract_context_peer(body)
|
peer = _extract_context_peer(body)
|
||||||
if not peer:
|
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
|
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 ───────────────────────────────────────────────────────────────────
|
# ── 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)
|
logger.debug("identity: gateway platform=%s user_id=%s → peer=%s", platform, user_id, resolved)
|
||||||
else:
|
else:
|
||||||
logger.info("identity: unmapped gateway user platform=%s user_id=%s", platform, user_id)
|
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:
|
def _on_session_start(**kw: Any) -> None:
|
||||||
@@ -179,7 +242,7 @@ def _on_session_start(**kw: Any) -> None:
|
|||||||
if peer:
|
if peer:
|
||||||
logger.info("identity: kanban worker task=%s peer=%s", task_id, peer)
|
logger.info("identity: kanban worker task=%s peer=%s", task_id, peer)
|
||||||
else:
|
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:
|
elif peer:
|
||||||
logger.debug("identity: session peer=%s", peer)
|
logger.debug("identity: session peer=%s", peer)
|
||||||
|
|
||||||
@@ -199,15 +262,13 @@ def _cmd_identity(raw_args: str) -> str:
|
|||||||
elif cmd == "list":
|
elif cmd == "list":
|
||||||
return _list_mappings()
|
return _list_mappings()
|
||||||
elif cmd in ("add", "set") and len(args) >= 4:
|
elif cmd in ("add", "set") and len(args) >= 4:
|
||||||
# /identity add discord 479136126737711105 thierry
|
|
||||||
platform, pid, peer_name = args[1], args[2], args[3]
|
platform, pid, peer_name = args[1], args[2], args[3]
|
||||||
return _add_mapping(platform, pid, peer_name)
|
return _add_mapping(platform, pid, peer_name)
|
||||||
elif cmd == "rm" and len(args) >= 2:
|
elif cmd == "rm" and len(args) >= 2:
|
||||||
idx = int(args[1]) if args[1].isdigit() else -1
|
idx = int(args[1]) if args[1].isdigit() else -1
|
||||||
return _remove_mapping(idx)
|
return _remove_mapping(idx)
|
||||||
elif cmd == "board" and len(args) >= 3:
|
elif cmd == "board" and len(args) >= 3:
|
||||||
slug, peer_name = args[1], args[2]
|
return _set_board(args[1], args[2])
|
||||||
return _set_board(slug, peer_name)
|
|
||||||
elif cmd == "help":
|
elif cmd == "help":
|
||||||
return _help_text()
|
return _help_text()
|
||||||
return _help_text()
|
return _help_text()
|
||||||
@@ -221,13 +282,13 @@ def _show_status() -> str:
|
|||||||
f"Mappings: {len(cfg.get('mappings', []))}",
|
f"Mappings: {len(cfg.get('mappings', []))}",
|
||||||
f"Boards: {len(cfg.get('boards', {}))}",
|
f"Boards: {len(cfg.get('boards', {}))}",
|
||||||
f"Enforce context_peer: {cfg.get('enforce_context_peer', True)}",
|
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:",
|
"Current context:",
|
||||||
]
|
]
|
||||||
peer = get_peer_name()
|
peer = get_peer_name()
|
||||||
if peer:
|
lines.append(f" Resolved peer: **{peer or '(none — will use user-default)'}**")
|
||||||
lines.append(f" Resolved peer: **{peer}**")
|
|
||||||
task = os.environ.get("HERMES_KANBAN_TASK")
|
task = os.environ.get("HERMES_KANBAN_TASK")
|
||||||
if task:
|
if task:
|
||||||
lines.append(f" Kanban task: `{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 = load_config()
|
||||||
cfg.setdefault("mappings", []).append({"platform": platform, "id": pid, "peer": peer_name})
|
cfg.setdefault("mappings", []).append({"platform": platform, "id": pid, "peer": peer_name})
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
return f"Added mapping: `{platform}` `{pid}` → **{peer_name}**"
|
return f"Added: `{platform}` `{pid}` → **{peer_name}**"
|
||||||
|
|
||||||
|
|
||||||
def _remove_mapping(idx: int) -> str:
|
def _remove_mapping(idx: int) -> str:
|
||||||
@@ -294,13 +355,21 @@ Commands:
|
|||||||
|
|
||||||
|
|
||||||
def register(ctx: Any) -> None:
|
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("pre_gateway_dispatch", _pre_gateway_dispatch)
|
||||||
ctx.register_hook("on_session_start", _on_session_start)
|
ctx.register_hook("on_session_start", _on_session_start)
|
||||||
|
|
||||||
|
# Register slash command
|
||||||
ctx.register_command(
|
ctx.register_command(
|
||||||
name="identity",
|
name="identity",
|
||||||
handler=_cmd_identity,
|
handler=_cmd_identity,
|
||||||
description="Manage identity peer mappings",
|
description="Manage identity peer mappings",
|
||||||
args_hint="status|list|add|rm|board|help",
|
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")
|
||||||
|
|||||||
@@ -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: <name>` 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.
|
|
||||||
82
docs/how-it-works.md
Normal file
82
docs/how-it-works.md
Normal file
@@ -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 <profile> chat -q "work kanban task <id>"`. 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.
|
||||||
Reference in New Issue
Block a user