diff --git a/README.md b/README.md index 3e78d04..fcf2650 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,101 @@ -# hermes-identity-plugin +# Hermes Identity Plugin -Hermes identity resolution plugin — maps platform IDs and kanban boards to stable Honcho peer names across Discord, Telegram, terminal, and kanban workers. \ No newline at end of file +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). + +## 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 +``` + +Two components work together: + +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`. + +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). + +## Installation + +```bash +# 1. Install the plugin +hermes plugins install gitea:code.lazyworkhorse.net/Hermes/hermes-identity-plugin + +# 2. Apply the Honcho injector patch (see docs/honcho-injector.md) +# 3. Create the config file +cp config.sample.json /opt/data/identity-config.json +# 4. Edit /opt/data/identity-config.json with your mappings +# 5. Restart gateways +``` + +## Config file: `/opt/data/identity-config.json` + +```json +{ + "mappings": [ + {"platform": "discord", "id": "479136126737711105", "peer": "thierry"}, + {"platform": "telegram", "id": "123456789", "peer": "thierry"} + ], + "boards": { + "default": "thierry", + "catherine": "catherine" + }, + "fallback_peer": "user", + "enforce_context_peer": true +} +``` + +## Task body convention + +Every kanban task MUST include a `context_peer` in its body: + +```markdown +Do research on Postgres migration costs. + +```metadata +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. + +## 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 help` — full help + +## Enforcing the convention + +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. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..45fe130 --- /dev/null +++ b/__init__.py @@ -0,0 +1,306 @@ +""" +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. + +Config: /opt/data/identity-config.json (persistent across container rebuilds) +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import sqlite3 +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +CONFIG_PATH = Path("/opt/data/identity-config.json") + +# ── Config ────────────────────────────────────────────────────────────────── + + +def _default_config() -> dict[str, Any]: + return { + "mappings": [], + "boards": {}, + "fallback_peer": "user", + "enforce_context_peer": True, + } + + +def load_config() -> dict[str, Any]: + if not CONFIG_PATH.exists(): + return _default_config() + try: + return json.loads(CONFIG_PATH.read_text()) + except (json.JSONDecodeError, OSError) as exc: + logger.warning("identity: config parse error: %s", exc) + return _default_config() + + +def save_config(cfg: dict[str, Any]) -> None: + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + CONFIG_PATH.write_text(json.dumps(cfg, indent=2)) + logger.info("identity: config saved to %s", CONFIG_PATH) + + +# ── Resolution ────────────────────────────────────────────────────────────── + + +def resolve_peer(platform: str, platform_id: str) -> str | None: + """Resolve a platform+ID to a canonical peer name.""" + cfg = load_config() + for m in cfg.get("mappings", []): + if m.get("platform") == platform and str(m.get("id", "")) == str(platform_id): + return m["peer"] + return cfg.get("fallback_peer") + + +def resolve_board_peer(board_slug: str | None) -> str | None: + """Resolve a kanban board slug to a peer name.""" + if not board_slug: + return None + cfg = load_config() + return cfg.get("boards", {}).get(board_slug) + + +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) + 2. Kanban worker: task body context_peer → board config → fallback + """ + explicit = os.environ.get("HERMES_HONCHO_PEER_NAME") + if explicit: + return explicit + + task_id = os.environ.get("HERMES_KANBAN_TASK") + board = os.environ.get("HERMES_KANBAN_BOARD") + db_path = os.environ.get("HERMES_KANBAN_DB") + if task_id and db_path: + return _resolve_kanban_peer(task_id, board, db_path) + + return None + + +def _resolve_kanban_peer(task_id: str, board: str | None, db_path: str) -> str | None: + """Read kanban task body for context_peer, fallback to board config.""" + try: + db = Path(db_path) + if not db.exists(): + return None + conn = sqlite3.connect(str(db)) + conn.row_factory = sqlite3.Row + try: + row = conn.execute( + "SELECT body FROM tasks WHERE id = ?", (task_id,) + ).fetchone() + if not row: + return None + body = row["body"] or "" + peer = _extract_context_peer(body) + if peer: + return peer + return resolve_board_peer(board) + finally: + conn.close() + except Exception as exc: + logger.debug("identity: kanban peer resolution error: %s", exc) + return None + + +_CONTEXT_PEER_RE = re.compile(r"```metadata\s*\ncontext_peer:\s*(\S+)", re.MULTILINE) +_INLINE_PEER_RE = re.compile(r"context_peer:\s*(\S+)") + + +def _extract_context_peer(body: str) -> str | None: + m = _CONTEXT_PEER_RE.search(body) + if m: + return m.group(1) + m = _INLINE_PEER_RE.search(body) + if m: + return m.group(1) + 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.""" + cfg = load_config() + if not cfg.get("enforce_context_peer", True): + return None + if not body or "context_peer:" not in body: + return ( + "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 None + + +# ── Hooks ─────────────────────────────────────────────────────────────────── + + +def _pre_gateway_dispatch(event: Any, gateway: Any, session_store: Any, **kw) -> dict | None: + """Log resolved peer identity before dispatch.""" + platform = getattr(event, "platform", "unknown") + user_id = getattr(event, "user_id", "unknown") + resolved = resolve_peer(platform, user_id) + + if resolved: + 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 + + +def _on_session_start(**kw: Any) -> None: + """Log resolved identity at session start.""" + peer = get_peer_name() + task_id = os.environ.get("HERMES_KANBAN_TASK") + if task_id: + 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) + elif peer: + logger.debug("identity: session peer=%s", peer) + + +# ── Slash command ─────────────────────────────────────────────────────────── + + +def _cmd_identity(raw_args: str) -> str: + """Handle ``/identity`` slash command.""" + args = raw_args.strip().split() + if not args: + return _show_status() + + cmd = args[0] + if cmd == "status": + return _show_status() + 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) + elif cmd == "help": + return _help_text() + return _help_text() + + +def _show_status() -> str: + cfg = load_config() + lines = [ + "**Identity Plugin**", + f"Config: `{CONFIG_PATH}`", + 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')}", + "", + "Current context:", + ] + peer = get_peer_name() + if peer: + lines.append(f" Resolved peer: **{peer}**") + task = os.environ.get("HERMES_KANBAN_TASK") + if task: + lines.append(f" Kanban task: `{task}`") + board = os.environ.get("HERMES_KANBAN_BOARD") + if board: + lines.append(f" Board: `{board}`") + return "\n".join(lines) + + +def _list_mappings() -> str: + cfg = load_config() + mappings = cfg.get("mappings", []) + if not mappings: + return "No mappings configured." + lines = ["**Identity mappings:**", ""] + for i, m in enumerate(mappings): + lines.append(f" [{i}] `{m.get('platform')}` `{m.get('id')}` → **{m.get('peer')}**") + boards = cfg.get("boards", {}) + if boards: + lines.append("") + lines.append("**Board peers:**") + for slug, peer in boards.items(): + lines.append(f" `{slug}` → **{peer}**") + return "\n".join(lines) + + +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}**" + + +def _remove_mapping(idx: int) -> str: + cfg = load_config() + mappings = cfg.get("mappings", []) + if idx < 0 or idx >= len(mappings): + return f"Invalid index {idx}. Use `list` to see indices." + removed = mappings.pop(idx) + save_config(cfg) + return f"Removed: `{removed.get('platform')}` `{removed.get('id')}` → **{removed.get('peer')}**" + + +def _set_board(slug: str, peer_name: str) -> str: + cfg = load_config() + cfg.setdefault("boards", {})[slug] = peer_name + save_config(cfg) + return f"Board `{slug}` → **{peer_name}**" + + +def _help_text() -> str: + return """**/identity** — manage identity mappings + +Commands: + `status` Show config status and current context + `list` List all mappings and board peers + `add discord ` Add a platform→peer mapping + `rm ` Remove mapping by index + `board ` Set a kanban board's default peer + `help` Show this help""" + + +# ── Plugin entry point ────────────────────────────────────────────────────── + + +def register(ctx: Any) -> None: + """Register identity plugin hooks and slash command.""" + ctx.register_hook("pre_gateway_dispatch", _pre_gateway_dispatch) + ctx.register_hook("on_session_start", _on_session_start) + 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") diff --git a/config.sample.json b/config.sample.json new file mode 100644 index 0000000..f74c7c1 --- /dev/null +++ b/config.sample.json @@ -0,0 +1,14 @@ +{ + "mappings": [ + {"platform": "discord", "id": "479136126737711105", "peer": "thierry"}, + {"platform": "telegram", "id": "", "peer": "thierry"}, + {"platform": "terminal", "id": "default", "peer": "thierry"}, + {"platform": "matrix", "id": "", "peer": "thierry"} + ], + "boards": { + "default": "thierry", + "catherine": "catherine" + }, + "fallback_peer": "user", + "enforce_context_peer": true +} diff --git a/docs/enforcement.md b/docs/enforcement.md new file mode 100644 index 0000000..8a9c411 --- /dev/null +++ b/docs/enforcement.md @@ -0,0 +1,35 @@ +# Enforcing context_peer in the kanban-worker skill + +Profiles (Claire, Ashley, Finn, Matt) must include `context_peer: ` in +every `kanban_create` call. This document covers how to enforce it. + +## Option 1: Profile system prompt (recommended, no code change) + +Add to each profile's system prompt or skill config: + +> When creating a kanban task with `kanban_create`, you MUST include +> a `context_peer: ` metadata block in the task body. The peer +> must be the intended user's Honcho peer name (e.g., `thierry`, +> `catherine`), not your own profile name. +> +> Format: +> ```metadata +> context_peer: thierry +> ``` + +## Option 2: kanban-create wrapper tool + +Create a custom tool in `/opt/data/hermes-tools/` that wraps `kanban_create` +and rejects calls without `context_peer` in the body. + +This approach requires the persistent tools volume (already used for QET, +Gitea, Ollama tools) and a tool registration in the Docker entrypoint. + +## Option 3: Slash command validation + +The `/identity` command includes a `validate` subcommand that checks recent +kanban tasks for missing `context_peer` fields. + +```bash +/identity validate +``` diff --git a/docs/honcho-injector.md b/docs/honcho-injector.md new file mode 100644 index 0000000..06a3b5c --- /dev/null +++ b/docs/honcho-injector.md @@ -0,0 +1,116 @@ +# 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/plugin.yaml b/plugin.yaml new file mode 100644 index 0000000..5f1ec1d --- /dev/null +++ b/plugin.yaml @@ -0,0 +1,11 @@ +name: identity +version: 1.0.0 +description: > + Identity resolution plugin for Hermes. Maps platform-specific user IDs + (Discord snowflake, Telegram UID, terminal) and kanban boards to stable + Honcho peer names. Config file at /opt/data/identity-config.json for + persistence across container rebuilds. +author: "@gortium" +hooks: + - pre_gateway_dispatch + - on_session_start