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_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):
+4
View File
@@ -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
+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`.
### 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`).
+92 -14
View File
@@ -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:
+65 -1
View File
@@ -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