feat(chat): add WebUI prefill script hook

This commit is contained in:
AJV20 committed 2026-05-24 20:05:20 -04:00
1 parent e50d6ccc8c
commit fa57868431
5 files changed
+196 -15

No files matched your search

+3
View File
@@ -122,6 +122,9 @@ Environment variables controlling behavior:
HERMES_WEBUI_DEFAULT_MODEL Optional model override; unset means provider default HERMES_WEBUI_DEFAULT_MODEL Optional model override; unset means provider default
HERMES_WEBUI_PASSWORD Optional: enable password auth (off by default) HERMES_WEBUI_PASSWORD Optional: enable password auth (off by default)
HERMES_WEBUI_SKIP_ONBOARDING Optional: bypass the first-run onboarding wizard 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) HERMES_HOME Base directory for Hermes state (~/.hermes by default)
Test isolation environment variables (set by conftest.py): Test isolation environment variables (set by conftest.py):
+4
View File
@@ -3,6 +3,10 @@
## [Unreleased] ## [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) ## [v0.51.131] — 2026-05-24 — Release DC (stage-batch13 — 6-PR notes-drawer + context-parity + PWA-swipe + locale polish)
### Added ### Added
+32
View File
@@ -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`. `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: 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`). 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`).
+92 -14
View File
@@ -10,7 +10,9 @@ import mimetypes
import os import os
import queue import queue
import re import re
import shlex
import sys import sys
import subprocess
import threading import threading
import time import time
import traceback import traceback
@@ -285,29 +287,105 @@ def _resolve_prefill_path(raw: str) -> Path:
return 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( def _load_webui_prefill_context(
config_data: Optional[dict] = None, config_data: Optional[dict] = None,
) -> dict: ) -> dict:
"""Load configured WebUI session prefill messages. """Load configured WebUI session prefill messages.
Supports the same bounded JSON-file shape used by Hermes Agent. WebUI does Supports the same bounded JSON-file shape used by Hermes Agent. WebUI also
not execute a configured prefill script here; session recall that requires supports its own explicitly opt-in script hook so admins can bridge Joplin,
code execution should go through the normal MCP/tool path instead of an Obsidian, Notion, llm-wiki, or another local notes source into ephemeral
always-on per-turn subprocess before SSE starts. turn context without baking any one note provider into the WebUI.
""" """
cfg = config_data if isinstance(config_data, dict) else get_config() 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 "") file_raw = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "") or str(cfg.get("prefill_messages_file") or "")
if file_raw: if file_raw:
path = _resolve_prefill_path(file_raw) return _load_prefill_messages_file(file_raw)
label = path.name or "prefill file" return _prefill_not_configured()
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}
def _public_prefill_context_status(prefill_context: dict) -> dict: def _public_prefill_context_status(prefill_context: dict) -> dict:
+65 -1
View File
@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
import json import json
import sys
from pathlib import Path
def test_prefill_json_file_keeps_valid_roles_and_drops_invalid_items(tmp_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 from api.streaming import _load_webui_prefill_context
script = tmp_path / "recall.py" 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(): def test_public_prefill_status_strips_message_bodies():
from api.streaming import _public_prefill_context_status from api.streaming import _public_prefill_context_status