# CONTRIBUTING — svrnty-hermes-webui-plugin Recipes for adding features to the plugin. The contract lives in `hermes/docs/SVRNTY-PLUGIN-PROTOCOL.md`; this file is the **how-to** layer. **Before anything else:** read `CLAUDE.md` (the Karpathy 4 rules) and the protocol PRD §4 (the four hard rules). Every recipe assumes those are honored. --- ## Decision flowchart — where does my change belong? ``` Q1. Does my change need new backend Python (a new /api/* endpoint, a server-side hook into streaming/agent, file I/O, DB access)? │ ├── NO → goes in plugin/static/ (CSS, JS, fonts, assets) │ Recipe B │ └── YES → Q2. Can the 7-method loader API express it? (api.register_route · register_static · inject_script · inject_stylesheet · config_get · logger · register_audio_attachment_processor) │ ├── YES → goes in plugin/routes/.py │ Recipe A │ └── NO → Q3. Would extending the loader API help all future plugin features (not just this one)? │ ├── YES → propose new API method │ Recipe C (needs PRD bump + JP review) │ └── NO → forced-internal escape hatch Recipe E (CONNECTION-MAP entry + risk) ``` If you're unsure, default to plugin (Rule 1: never touch upstream unless forced). --- ## Recipe A — Add a new `/api/*` endpoint 1. Create `routes/.py`. Skeleton: ```python """GET|POST /api/.""" import json def register(api): log = api.logger("svrnty.routes.") api.register_route("/api/", "GET", _handle) log.info(" endpoint registered") def _handle(handler, parsed): payload = {"ok": True, "data": ...} # your logic here body = json.dumps(payload).encode("utf-8") handler.send_response(200) handler.send_header("Content-Type", "application/json; charset=utf-8") handler.send_header("Content-Length", str(len(body))) handler.send_header("Cache-Control", "no-store") handler.end_headers() handler.wfile.write(body) return True ``` 2. Register the module in `plugin.py` `_phase2_routes()`: ```python return [ "transcribe", "vault_status", "", # ← add here ] ``` 3. Add `tests/unit/test_.py` — at minimum: - `test_register_wires_one_route` — `api.register_route` called once with right path/method - `test_handler_returns__on_` — success path - `test_handler_handles_` — error path returns clean response 4. Regenerate connection map: ```bash python3 scripts/ast-connection-map.py ``` 5. Update `manifest.yaml` `routes:` section with the new path + status. 6. Run full suite + commit: ```bash pytest tests/ -q # must be all green git add routes/.py plugin.py tests/unit/test_.py manifest.yaml CONNECTION-MAP.md git commit -m "feat(plugin): endpoint at /api/" ``` **Reference implementation:** `routes/vault_status.py` + `tests/unit/test_vault_status.py`. --- ## Recipe B — Add a new UI element (CSS / JS / asset) 1. Decide where it goes in `static/`: - **CSS rules** → `static/app.css` (loaded after upstream → wins via cascade) - **JS behavior** → `static/app.js` (loaded as `defer`) - **Fonts/images** → `static//` 2. For JS UI additions, follow the existing pattern in `static/app.js`: - Idempotent guard inside the IIFE (`window.__svrntyExtLoaded` already covers re-load) - Use a per-element `dataset.svrntyXxx="1"` sentinel to prevent double-install - Hook into existing DOM via `MutationObserver` (the WebUI rebuilds panels on tab switches) - Reference existing IDs only — never invent new IDs that upstream might use later 3. Test (lightweight static checks in `tests/unit/test_app_js.py`): - File present + non-empty - Idempotent guard present - Any `/api/*` URL string the JS calls matches a registered plugin route 4. CONNECTION-MAP auto-picks up `/api/*` URLs from JS — regen + commit. **Reference implementations:** - Vault status panel: `static/app.js:_injectVaultPanel + _loadVaultStatus` - Voice-message mic: `static/app.js:_vmStart + _vmAttachAndSend` --- ## Recipe C — Extend the loader API (add an 8th method) **Justification bar:** the new method must enable a CLASS of features (audio processors enabled STT + future processors), not just one feature. Single-feature needs go through Recipe E (forced-internal). 1. Open the PRD: `hermes/docs/SVRNTY-PLUGIN-PROTOCOL.md`. Edit §5.1's API table to add the new method with one-line purpose. Bump the row count in any "7-method" references. 2. Open `hermes-webui/api/svrnty_plugin_loader.py` (the lone fork commit). Add: - Module-level registry: `_NEW_THING: List[X] = []` - Method on `_PluginAPI`: validates input, appends to registry, logs at INFO - Public accessor: `def plugin_new_things() -> List[X]: return list(_NEW_THING)` - Update the `load_plugin()` summary log to count the new dimension 3. Wire the consumer in upstream (the hook point that fires the registered fns): - Find the loosely-coupled spot in upstream code (streaming, session, request lifecycle) - Add a small loop calling registered things; wrap each in try/except so a buggy plugin can't crash the agent - Keep the loop in the SAME loader commit (still 1 fork commit total) 4. Update `scripts/ast-connection-map.py`'s `PUBLIC_API` set to include the new method name. 5. Update `tests/evals/test_features.py:test_eval_loader_contract_unchanged` — add the new method to `required`. 6. Use the new method from the first consumer route under `routes/`. 7. Amend the lone fork commit (`hermes-webui/api/svrnty_plugin_loader.py` + new hook site): ```bash cd hermes-webui git add api/svrnty_plugin_loader.py api/.py git commit --amend --no-edit git push openharbor jp --force-with-lease # own fork branch ``` 8. Commit + push the consumer plugin route + PRD update. 9. Run `protocol-validate.sh` — must still be 7/7 (the §10.1 check counts commits, not LOC; amending leaves the count at 1). **Reference implementation:** the STT migration that added `register_audio_attachment_processor` — see hermes-webui commit `79c6665f` + plugin commit `37123f57`. --- ## Recipe D — Add a config value 1. Define the env var with the **`SVRNTY__`** convention OR keep an upstream-style `HERMES_` if the value semantically belongs to the upstream layer. 2. Document it in the **plugin** `.env.example` (when one is added) or `README.md` install section. Never in upstream's `.env.example`. 3. Read at call time via `api.config_get(key, default)` (which wraps `os.environ.get`). Never cache across calls — env can be edited + WebUI restarted. 4. If the value gates a feature on/off, the absence path must return a clean 503 or skip gracefully — see `routes/transcribe.py:_handle_transcribe` (returns 503 when `HERMES_WEBUI_STT_URL` unset). 5. Add a test that asserts the absence path doesn't crash + returns a useful signal. --- ## Recipe E — Forced-internal escape hatch (use sparingly) When the public API can't express what you need AND the change isn't worth extending the API for (e.g. one-off, deep upstream internal). 1. Import the upstream symbol directly. Add a `# CONNECTION:` comment ABOVE the import naming: - **Why** the public API can't handle this - **Risk** if upstream renames or removes the symbol - **Detection** — how `protocol-validate.sh` / drift CI would catch breakage ```python # CONNECTION: needs hermes-webui's internal session state — no public API # exposes this; risk medium (upstream renames _active_state_db_path roughly # 1-2× per year per git log); detection via tests/integration on each upstream tag. from api.profiles import get_active_hermes_home # noqa: E402 ``` 2. The AST walker picks up the import + the comment → CONNECTION-MAP shows the row under **forced internal dependencies** with your justification. 3. Add an `tests/evals/test_.py` row that explicitly asserts the symbol still exists + behaves expected. This is the early-warning siren when upstream changes. 4. Audit quarterly: every forced-internal row > 6 months old is a candidate for promotion to a public-API method (Recipe C) or refactor to remove the dep. **Discipline:** if `forced_internal` count exceeds **5 rows total**, stop adding features and refactor — protocol §11 risk row. --- ## Commit message conventions (mirrors PRD §8.1) | Prefix | When | |---|---| | `feat(plugin): ` | new feature inside plugin | | `fix(plugin): ` | bug fix inside plugin | | `sync(upstream): rebase to ` | manifest.yaml min_upstream_version bumped | | `map(connection): ` | manual CONNECTION-MAP touch (rare; usually auto) | | `ci(drift): ` | `.github/workflows/*` changes | | `docs(protocol): ` | protocol PRD updates | | `feat(svrnty): loader …` | the lone fork commit (in `hermes-webui`, not here) | Body should answer: WHAT the change does, WHY (which recipe), and the test status (`X/Y PASS`). --- ## PR checklist (drop into PR description) ``` - [ ] Recipe followed: [A | B | C | D | E] - [ ] CONNECTION-MAP.md regenerated + committed (or N/A for static-only changes) - [ ] tests/ added or extended; full suite passes locally - [ ] manifest.yaml updated (routes section or upstream pin) if applicable - [ ] No fork edits unless Recipe C (loader API extension) - [ ] If Recipe C: PRD §5.1 bumped + hermes-webui loader commit amended - [ ] No new forced-internal entries unless Recipe E (justified in source) - [ ] Karpathy 4 rules followed (Think · Simple · Surgical · Goal-driven) ``` --- ## Quick reference | Need | Read | |---|---| | The hard rules | `CLAUDE.md` (Karpathy) + protocol PRD §4 | | What "done" looks like | protocol PRD §10 + `protocol-validate.sh` | | Existing patterns | `routes/vault_status.py` · `routes/transcribe.py` · `static/app.js` | | Map of what touches what | `CONNECTION-MAP.md` (auto-generated) | | One-command upgrade | `make sync-upstream` | | One-command smoke | `make smoke` | If a recipe is missing or unclear, that's a CONTRIBUTING bug — open a PR against this file.