From fa57868431b8dc6231ca7e962653356046c70401 Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Sun, 24 May 2026 20:05:20 -0400 Subject: [PATCH] feat(chat): add WebUI prefill script hook --- ARCHITECTURE.md | 3 + CHANGELOG.md | 4 ++ README.md | 32 +++++++++ api/streaming.py | 106 ++++++++++++++++++++++++---- tests/test_webui_prefill_context.py | 66 ++++++++++++++++- 5 files changed, 196 insertions(+), 15 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6b865018..c9834c36 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -122,6 +122,9 @@ Environment variables controlling behavior: HERMES_WEBUI_DEFAULT_MODEL Optional model override; unset means provider default HERMES_WEBUI_PASSWORD Optional: enable password auth (off by default) HERMES_WEBUI_SKIP_ONBOARDING Optional: bypass the first-run onboarding wizard + HERMES_PREFILL_MESSAGES_FILE Optional JSON message list for browser-turn prefill context + HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT Optional command that prints JSON messages or text prefill context + HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT_TIMEOUT Optional script timeout in seconds (default 5, max 30) HERMES_HOME Base directory for Hermes state (~/.hermes by default) Test isolation environment variables (set by conftest.py): diff --git a/CHANGELOG.md b/CHANGELOG.md index a1f805e7..bbc2614a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Added + +- WebUI can now opt into a `webui_prefill_messages_script` / `HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT` hook for dynamic browser-turn prefill context from local notes or recall systems. The script output is normalized to ephemeral prefill messages and browser status still hides message bodies while redacting script errors. + ## [v0.51.131] — 2026-05-24 — Release DC (stage-batch13 — 6-PR notes-drawer + context-parity + PWA-swipe + locale polish) ### Added diff --git a/README.md b/README.md index ccaca241..489f7b71 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,38 @@ For self-hosted VM or homelab installs, `ctl.sh` wraps the common daemon lifecyc `ctl.sh start` runs the bootstrap in foreground/no-browser mode behind the daemon wrapper, writes logs to `~/.hermes/webui.log`, and respects `.env` plus inline overrides such as `HERMES_WEBUI_HOST=0.0.0.0 ./ctl.sh start`. +### Optional session recall prefill + +WebUI can attach ephemeral prefill messages to new browser-originated +agent turns. This is useful when a deployment already has a local recall script +for Joplin, Obsidian, Notion, llm-wiki, or another third-party notes source and +wants the browser chat to receive the same high-level context as other Hermes +surfaces. + +Static JSON remains supported through `prefill_messages_file` or +`HERMES_PREFILL_MESSAGES_FILE`. For dynamic recall, opt in explicitly with a +WebUI-specific script hook: + +```yaml +webui_prefill_messages_script: + - python3 + - /path/to/notes_recall.py +webui_prefill_messages_script_timeout: 5 +``` + +or: + +```bash +HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT="python3 /path/to/notes_recall.py" \ +HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT_TIMEOUT=5 \ +./ctl.sh restart +``` + +The script may print either an OpenAI-style JSON message list, a JSON object with +a `messages` list, or plain text; plain text is wrapped as one `system` prefill +message. The browser only receives a compact status event (`source`, `label`, +message count, and redacted errors), never the prefill message bodies. + The bootstrap will: 1. Detect Hermes Agent and, if missing, attempt the official installer (`curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash`). diff --git a/api/streaming.py b/api/streaming.py index d319d9d7..6a46311e 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -10,7 +10,9 @@ import mimetypes import os import queue import re +import shlex import sys +import subprocess import threading import time import traceback @@ -285,29 +287,105 @@ def _resolve_prefill_path(raw: str) -> Path: return path +def _prefill_not_configured() -> dict: + return {"status": "not_configured", "source": "none", "label": "", "messages": [], "message_count": 0} + + +def _load_prefill_messages_file(file_raw: str, *, source: str = "file", status: str = "loaded") -> dict: + path = _resolve_prefill_path(file_raw) + label = path.name or "prefill file" + if not path.exists(): + return {"status": "error", "source": source, "label": label, "messages": [], "message_count": 0, "error": "prefill file not found"} + try: + messages = _valid_prefill_messages(json.loads(path.read_text(encoding="utf-8"))) + return {"status": status, "source": source, "label": label, "messages": messages, "message_count": len(messages)} + except Exception as exc: + return {"status": "error", "source": source, "label": label, "messages": [], "message_count": 0, "error": _redact_prefill_status_text(str(exc))} + + +def _prefill_script_timeout(config_data: dict) -> float: + raw = os.getenv("HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT_TIMEOUT", "") or str(config_data.get("webui_prefill_messages_script_timeout") or "") + try: + return max(0.1, min(float(raw or 5), 30.0)) + except Exception: + return 5.0 + + +def _prefill_script_command(raw) -> list[str]: + if isinstance(raw, (list, tuple)): + return [str(part) for part in raw if str(part)] + parts = shlex.split(str(raw or "")) + if not parts: + return [] + # A single script path mirrors prefill_messages_file path resolution. More + # complex commands keep their argv untouched so admins can pass arguments. + if len(parts) == 1: + parts[0] = str(_resolve_prefill_path(parts[0])) + return parts + + +def _messages_from_prefill_script_output(text: str) -> list[dict]: + stripped = str(text or "").strip() + if not stripped: + return [] + try: + payload = json.loads(stripped) + except Exception: + payload = None + if isinstance(payload, dict): + payload = payload.get("messages") + messages = _valid_prefill_messages(payload) + if messages: + return messages + return [{"role": "system", "content": stripped}] + + +def _load_prefill_messages_script(config_data: dict) -> dict: + script_raw = os.getenv("HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT", "") or config_data.get("webui_prefill_messages_script") + if not script_raw: + return _prefill_not_configured() + command = _prefill_script_command(script_raw) + label = Path(command[0]).name if command else "prefill script" + if not command: + return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": "prefill script is empty"} + try: + proc = subprocess.run( + command, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=_prefill_script_timeout(config_data), + check=False, + ) + except subprocess.TimeoutExpired: + return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": "prefill script timed out"} + except Exception as exc: + return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": _redact_prefill_status_text(str(exc))} + if proc.returncode != 0: + err = _redact_prefill_status_text(proc.stderr or proc.stdout or f"prefill script exited {proc.returncode}") + return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": err} + messages = _messages_from_prefill_script_output(proc.stdout) + return {"status": "loaded", "source": "script", "label": label, "messages": messages, "message_count": len(messages)} + + def _load_webui_prefill_context( config_data: Optional[dict] = None, ) -> dict: """Load configured WebUI session prefill messages. - Supports the same bounded JSON-file shape used by Hermes Agent. WebUI does - not execute a configured prefill script here; session recall that requires - code execution should go through the normal MCP/tool path instead of an - always-on per-turn subprocess before SSE starts. + Supports the same bounded JSON-file shape used by Hermes Agent. WebUI also + supports its own explicitly opt-in script hook so admins can bridge Joplin, + Obsidian, Notion, llm-wiki, or another local notes source into ephemeral + turn context without baking any one note provider into the WebUI. """ cfg = config_data if isinstance(config_data, dict) else get_config() + script_context = _load_prefill_messages_script(cfg) + if script_context.get("status") != "not_configured": + return script_context file_raw = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "") or str(cfg.get("prefill_messages_file") or "") if file_raw: - path = _resolve_prefill_path(file_raw) - label = path.name or "prefill file" - if not path.exists(): - return {"status": "error", "source": "file", "label": label, "messages": [], "message_count": 0, "error": "prefill file not found"} - try: - messages = _valid_prefill_messages(json.loads(path.read_text(encoding="utf-8"))) - return {"status": "loaded", "source": "file", "label": label, "messages": messages, "message_count": len(messages)} - except Exception as exc: - return {"status": "error", "source": "file", "label": label, "messages": [], "message_count": 0, "error": _redact_prefill_status_text(str(exc))} - return {"status": "not_configured", "source": "none", "label": "", "messages": [], "message_count": 0} + return _load_prefill_messages_file(file_raw) + return _prefill_not_configured() def _public_prefill_context_status(prefill_context: dict) -> dict: diff --git a/tests/test_webui_prefill_context.py b/tests/test_webui_prefill_context.py index 06a18e0c..3584aba2 100644 --- a/tests/test_webui_prefill_context.py +++ b/tests/test_webui_prefill_context.py @@ -2,6 +2,8 @@ from __future__ import annotations import json +import sys +from pathlib import Path def test_prefill_json_file_keeps_valid_roles_and_drops_invalid_items(tmp_path): @@ -32,7 +34,7 @@ def test_prefill_json_file_keeps_valid_roles_and_drops_invalid_items(tmp_path): ] -def test_prefill_script_config_is_ignored_in_webui(tmp_path): +def test_prefill_script_config_is_not_used_without_webui_opt_in(tmp_path): from api.streaming import _load_webui_prefill_context script = tmp_path / "recall.py" @@ -49,6 +51,68 @@ def test_prefill_script_config_is_ignored_in_webui(tmp_path): } +def test_webui_prefill_script_loads_json_messages(tmp_path): + from api.streaming import _load_webui_prefill_context + + script = tmp_path / "recall.py" + script.write_text( + "import json\n" + "print(json.dumps([{'role': 'system', 'content': 'Joplin recall'}, {'role': 'tool', 'content': 'drop me'}]))\n", + encoding="utf-8", + ) + + result = _load_webui_prefill_context({"webui_prefill_messages_script": [sys.executable, str(script)]}) + + assert result["status"] == "loaded" + assert result["source"] == "script" + assert result["label"] == Path(sys.executable).name + assert result["messages"] == [{"role": "system", "content": "Joplin recall"}] + + +def test_webui_prefill_script_wraps_plain_text_for_any_notes_source(tmp_path): + from api.streaming import _load_webui_prefill_context + + script = tmp_path / "obsidian_recall.py" + script.write_text("print('Obsidian project note context')\n", encoding="utf-8") + + result = _load_webui_prefill_context({"webui_prefill_messages_script": [sys.executable, str(script)]}) + + assert result["status"] == "loaded" + assert result["source"] == "script" + assert result["messages"] == [{"role": "system", "content": "Obsidian project note context"}] + + +def test_webui_prefill_script_errors_are_redacted(tmp_path): + from api.streaming import _load_webui_prefill_context + + script = tmp_path / "bad_recall.py" + script.write_text("import sys; print('token=redaction-test-placeholder', file=sys.stderr); raise SystemExit(2)\n", encoding="utf-8") + + result = _load_webui_prefill_context({"webui_prefill_messages_script": [sys.executable, str(script)]}) + + assert result["status"] == "error" + assert result["source"] == "script" + assert "redaction-test-placeholder" not in result["error"] + assert "[REDACTED]" in result["error"] + + +def test_webui_prefill_script_takes_precedence_over_static_file(tmp_path): + from api.streaming import _load_webui_prefill_context + + prefill = tmp_path / "prefill.json" + prefill.write_text(json.dumps([{"role": "system", "content": "static"}]), encoding="utf-8") + script = tmp_path / "recall.py" + script.write_text("print('dynamic')\n", encoding="utf-8") + + result = _load_webui_prefill_context({ + "prefill_messages_file": str(prefill), + "webui_prefill_messages_script": [sys.executable, str(script)], + }) + + assert result["source"] == "script" + assert result["messages"] == [{"role": "system", "content": "dynamic"}] + + def test_public_prefill_status_strips_message_bodies(): from api.streaming import _public_prefill_context_status