diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ac7517a --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# svrnty-hermes-webui-plugin — env contract. +# Copy to .env (gitignored) and edit. NO SECRETS in this file. +# +# Required (set in the hermes-webui process env, NOT in this file unless you +# source it into the venv): +# +# HERMES_WEBUI_PYTHON_PLUGIN=svrnty_hermes_webui_plugin +# Tells the fork's loader hook to import this plugin. Without it the +# fork runs vanilla (no Svrnty mods). Usually set in docker-compose.override.yml +# or in the systemd unit's EnvironmentFile. +# +# Optional — STT (Speech-to-Text) for voice-message attachments: +# +# HERMES_WEBUI_STT_URL=http://stt-host:8000/v1/audio/transcriptions +# External STT endpoint (OpenAI-shape or WhisperX). When unset, the +# /api/transcribe route returns 503 + audio_attachment_processor is a no-op. +# +# HERMES_WEBUI_STT_KEY= +# Optional bearer token for the STT endpoint. Leave empty for open endpoints. +# +# Optional — BTE (sovereign brand backend) — used by routes that talk to BTE: +# +# BTE_BASE_URL=http://localhost:6001 +# BTE_TENANT_ID=00000000-0000-0000-0000-000000000001 +# Brand truth backend (REST). The plugin reads these at call time. +# +# All other secrets (credctl-managed) resolve at call-time via credbridge — +# they never enter this file. See ../cmo/credbridge.sh. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..90f97bc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,51 @@ +# Changelog + +All notable changes to svrnty-hermes-webui-plugin. Format roughly follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) + SemVer. + +The plugin protocol contract itself lives in +`hermes/docs/SVRNTY-PLUGIN-PROTOCOL.md`. This changelog tracks the plugin +side only; the fork-side loader hook is documented in +`hermes-webui` commit history. + +--- + +## [0.2.0] — 2026-05-23 + +### Added +- **STT migration** (Phase 2.A) — voice-message recording + transcription + - `routes/transcribe.py`: POST `/api/transcribe` + `_transcribe_audio_attachments` audio processor + - `static/app.js`: MediaRecorder mic flow → File attached + sent (voice-message-* prefix) + - Uses the new 7th loader API method `register_audio_attachment_processor` + - Stdlib-only multipart parser (Python 3.13 dropped `cgi` per PEP 594) +- **Loader API extended** (1 → 7 methods) — `register_audio_attachment_processor` + - PRD §5.1 amended; eval test `test_eval_loader_contract_unchanged` enforces the new surface +- 9 new tests (3 transcribe + 6 app.js static checks), 26 total + +### Changed +- `manifest.yaml`: route + audio processor `status` flipped to `live`; tested_versions appended +- `CONNECTION-MAP.md`: now 10 deps (9 public API · 0 forced internal · 1 frontend) + +--- + +## [0.1.0] — 2026-05-23 + +### Added +- Initial scaffold per the SVRNTY-HERMES Plugin Protocol PRD +- `plugin.py` entry point with `register(api)` +- `routes/vault_status.py` — GET `/api/vault/status` (migrated from fork commit 3e2c74f3) +- `static/{app.js,app.css,fonts/}` — brand skin (migrated from `hermes-ext/`) +- `static/app.js`: vault connections panel DOM injection (settings → system tab) +- `scripts/ast-connection-map.py` — AST walker generates `CONNECTION-MAP.md` +- `scripts/boot-smoke.py` — boot upstream+plugin + curl every endpoint +- `scripts/upstream-sync.py` — fetch upstream tags + matrix report +- `Makefile` — one-line targets: `make test`, `make map`, `make sync-upstream`, `make smoke` +- `.github/workflows/`: + - `plugin-tests.yml` — pytest on push/PR + - `connection-map-check.yml` — regen + diff vs committed on PR + - `upstream-drift.yml` — daily cron sweeps new upstream tags +- 11 unit + eval tests + +### Infrastructure +- Loader hook landed in `hermes-webui` as the lone fork commit (6 methods initially) +- Gitea Actions runner `svrnty-steev-runner-01` registered for daily drift CI diff --git a/CONNECTION-MAP.md b/CONNECTION-MAP.md index d1cb12d..36be8f0 100644 --- a/CONNECTION-MAP.md +++ b/CONNECTION-MAP.md @@ -1,7 +1,7 @@ # CONNECTION MAP — svrnty-hermes-webui-plugin → nesquena/hermes-webui -**Upstream version:** v0.51.117 -**Plugin version:** 0.1.0 +**Upstream version:** v0.51.118 +**Plugin version:** 0.2.0 **Total dependencies:** 10 (9 public API · 0 forced internal · 1 frontend) > **Auto-generated by `scripts/ast-connection-map.py`. Do not hand-edit.** diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0ccc38e --- /dev/null +++ b/LICENSE @@ -0,0 +1,11 @@ +Copyright (c) 2026 Svrnty / Openharbor. All rights reserved. + +This software is proprietary to Svrnty / Openharbor and is not licensed for +external use, redistribution, or modification without explicit written +permission from the copyright holder. + +The plugin integrates with `nesquena/hermes-webui` (MIT) at runtime via a +documented public extension API; that integration is permitted by the +upstream MIT license and does not relicense the upstream code. + +For licensing inquiries, contact: jp@svrnty.io diff --git a/README.md b/README.md index f85fec2..d0d9a20 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,10 @@ Touching anything else in hermes-webui = a Rule 2 violation per the protocol. Do | Component | State | |---|---| -| Loader hook in `hermes-webui` | TBD (Phase 1) | -| Plugin scaffold | Phase 1 in progress | -| Migrated features (transcribe, vault_status, brand skin) | TBD (Phase 2) | -| Automation (drift CI, sync command, eval suite) | TBD (Phase 3) | -| Upstream PR | TBD (Phase 4) | +| Loader hook in `hermes-webui` | ✓ live (lone fork commit, 7-method API) | +| Plugin scaffold | ✓ live (routes/static/tests/scripts/.github) | +| Migrated features (vault_status, transcribe, brand skin, voice-message mic) | ✓ live | +| Automation (drift CI, AST connection map, sync command, eval suite) | ✓ live (Gitea runner registered) | +| Upstream PR to nesquena/hermes-webui | deferred — gated on 2+ release smoke (PRD Phase 4) | +| Forced internal dependencies | **0** (plugin uses only public API) | +| Test suite | 26/26 PASS (unit + evals) | diff --git a/manifest.yaml b/manifest.yaml index 510d09c..02331b5 100644 --- a/manifest.yaml +++ b/manifest.yaml @@ -1,15 +1,17 @@ # svrnty-hermes-webui-plugin — manifest. # Read by hermes-webui plugin loader + sync tooling. Machine-readable identity. plugin_name: svrnty-hermes-webui-plugin -plugin_version: 0.1.0 +plugin_version: 0.2.0 entry_point: svrnty_hermes_webui_plugin:register upstream: project: nesquena/hermes-webui min_version: v0.51.103 # earliest tested upstream (bumped by upstream-sync.py) - tested_versions: # filled in by drift CI as it sweeps tags + tested_versions: # appended by drift CI as it sweeps new tags - v0.51.103 - current_local: v0.51.117 # what the local fork tracks + - v0.51.117 + - v0.51.118 + current_local: v0.51.118 # what the local fork tracks (latest upstream tag) # Permanent public API surface the plugin uses (mirrors hermes-webui's loader hook). # CONNECTION-MAP.md is the runtime-discovered truth; this list is just declarative. @@ -20,6 +22,7 @@ public_api: - inject_stylesheet - config_get - logger + - register_audio_attachment_processor # Assets the plugin injects into index.html on every page load. assets: @@ -28,11 +31,15 @@ assets: stylesheets: - /plugins/svrnty/app.css -# Routes this plugin will register at load time (declarative cross-check vs runtime). +# Routes this plugin registers at load time (declarative cross-check vs runtime). # Each row maps to a routes/.py. routes: - - { path: /api/transcribe, method: POST, file: routes/transcribe.py, status: pending-phase-2 } - - { path: /api/vault/status, method: GET, file: routes/vault_status.py, status: pending-phase-2 } + - { path: /api/transcribe, method: POST, file: routes/transcribe.py, status: live } + - { path: /api/vault/status, method: GET, file: routes/vault_status.py, status: live } + +# Audio-attachment processors (called by streaming.py before agent receives message). +audio_processors: + - { file: routes/transcribe.py, fn: _transcribe_audio_attachments, status: live } # Plugin refuses to load against an unlisted upstream version unless strict=0. strict_version_check: false diff --git a/tests/integration/test_loader_contract.py b/tests/integration/test_loader_contract.py new file mode 100644 index 0000000..d8294ef --- /dev/null +++ b/tests/integration/test_loader_contract.py @@ -0,0 +1,79 @@ +"""Integration test — plugin against a real hermes-webui fork checkout. + +Skipped when hermes-webui isn't adjacent to the plugin repo (CI without the +fork mounted). Validates the loader hook actually loads + registers our 7 +public-API methods + reports the audio processor on startup. + +Run via: `pytest tests/integration -v` — make smoke uses the same harness. +""" +import sys +from pathlib import Path + +import pytest + +PLUGIN_REPO = Path(__file__).resolve().parents[2] +FORK_REPO = PLUGIN_REPO.parent / "hermes-webui" + + +@pytest.fixture(scope="module") +def loader(): + """Import the fork's loader. Skip cleanly when fork not adjacent.""" + if not (FORK_REPO / "api" / "svrnty_plugin_loader.py").is_file(): + pytest.skip(f"hermes-webui fork not at {FORK_REPO}; integration env required") + sys.path.insert(0, str(FORK_REPO)) + try: + from api import svrnty_plugin_loader as loader_mod + except ImportError as e: + pytest.skip(f"loader import failed: {e}") + return loader_mod + + +def test_loader_exposes_seven_method_contract(loader): + """Public API surface must be exactly 7 methods (per protocol §5.1).""" + api = loader._PluginAPI() + methods = {m for m in dir(api) if not m.startswith("_")} + expected = { + "register_route", "register_static", "inject_script", + "inject_stylesheet", "config_get", "logger", + "register_audio_attachment_processor", + } + assert methods == expected, ( + f"loader API drift: missing={expected - methods} extra={methods - expected}. " + "Adding/removing methods requires a protocol PRD amendment." + ) + + +def test_loader_register_wires_our_plugin(loader, monkeypatch): + """End-to-end: env var → import this plugin → register() fires our 2 routes + processor.""" + monkeypatch.setenv("HERMES_WEBUI_PYTHON_PLUGIN", "svrnty_hermes_webui_plugin") + # Reset loader idempotency guard so we can re-run in-process + loader._LOADED = False + loader._ROUTES.clear() + loader._STATIC.clear() + loader._SCRIPTS.clear() + loader._STYLESHEETS.clear() + loader._AUDIO_PROCESSORS.clear() + + sys.path.insert(0, str(PLUGIN_REPO)) + loader.load_plugin() + + # Routes registered: /api/transcribe (POST) + /api/vault/status (GET) + assert ("POST", "/api/transcribe") in loader._ROUTES + assert ("GET", "/api/vault/status") in loader._ROUTES + # Static + injected URLs + assert "svrnty" in loader._STATIC + assert "/plugins/svrnty/app.css" in loader._STYLESHEETS + assert "/plugins/svrnty/app.js" in loader._SCRIPTS + # Audio processor for voice-message transcription + assert len(loader._AUDIO_PROCESSORS) == 1 + + +def test_loader_noop_when_env_unset(loader, monkeypatch): + """No env var = no plugin loaded. Upstream behavior fully preserved.""" + monkeypatch.delenv("HERMES_WEBUI_PYTHON_PLUGIN", raising=False) + loader._LOADED = False + loader._ROUTES.clear() + + loader.load_plugin() + + assert loader._ROUTES == {}, "plugin must NOT load when env var unset"