Compare commits

..

3 Commits

Author SHA1 Message Date
Alexander Whitestone
b6b08c315c feat(matrix): Testament community room provisioning (#275)
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 50s
Part of Epic #269, Phase 5.

Provisions a public Matrix room for the Testament community with
appropriate settings for an open, welcoming space.

New file: scripts/provision_testament_room.py
- Creates public room with 'Testament' name (configurable via --name)
- Sets join rules to public, history to world_readable, guest access on
- Configures power levels (anyone can invite, 50 for kicks/bans)
- Sends welcome message with crisis resources and /about instructions
- Records room ID in ~/.hermes/.env as MATRIX_TESTAMENT_ROOM
- Supports --dry-run, --name, --topic flags
- Detects existing rooms by name in public directory, reconfigures instead
  of creating duplicates

Modified: gateway/platforms/matrix.py
- Added MATRIX_TESTAMENT_ROOM to env var documentation
- Auto-adds Testament room to free-response set (no @mention required)
  so community members can talk to Timmy naturally

Modified: docs/matrix-setup.md
- Added Testament Community Room section with setup instructions,
  configuration details, and CLI usage examples

Closes #275
2026-04-13 20:37:43 -04:00
cda29991e0 Merge pull request 'Fix #373: fallback_model blank fields no longer trigger gateway warnings' (#433) from burn/373-1776125702 into main
Some checks failed
Forge CI / smoke-and-build (push) Has been cancelled
2026-04-14 00:30:10 +00:00
Timmy Time
1899878c27 Fix #373: fallback_model blank fields no longer trigger gateway warnings
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 1m1s
When users blank fallback_model fields or set enabled: false, the validation
and gateway now treat this as intentionally disabling fallback instead of
showing warnings.

Changes:
- hermes_cli/config.py: Skip warnings when both provider and model are blank
  or when enabled: false is set
- gateway/run.py: Return None for disabled fallback configs
- tests: Added 8 new tests for blank/disabled fallback scenarios

Behavior:
- Both fields blank: no warnings (intentional disable)
- enabled: false: no warnings (explicit disable)
- One field blank: warning shown (likely misconfiguration)
- Valid config: no warnings

Fixes #373
2026-04-13 20:19:21 -04:00
9 changed files with 462 additions and 714 deletions

View File

@@ -1,362 +0,0 @@
"""Crisis Room Protocol — SOUL.md 'When a Man Is Dying' implementation.
This module implements the crisis detection and response protocol
for Matrix crisis rooms. When a person in despair reaches out,
this protocol ensures the agent responds according to SOUL.md:
1. Ask: "Are you safe right now?"
2. Listen. Stay present. Do not disconnect.
3. Never compute the value of a human life.
4. Never suggest someone should die.
5. Direct them to help: 988 Suicide and Crisis Lifeline.
6. Before systems and numbers, share the truth: Jesus saves.
Architecture:
- CrisisDetector: wraps ShieldDetector for crisis signal detection
- CrisisRoom: manages a single crisis room's state
- CrisisRoomManager: manages all crisis rooms
- Integration point: Matrix adapter calls check_message() on every message
"""
import json
import logging
import time
from dataclasses import dataclass, field, asdict
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
# Crisis log storage
_CRISIS_LOG_DIR = get_hermes_home() / "crisis_logs"
class CrisisLevel(str, Enum):
"""Crisis detection levels."""
NONE = "none"
LOW = "low" # ambiguous signals, possible distress
MEDIUM = "medium" # clear distress signals
HIGH = "high" # explicit suicidal ideation
CRITICAL = "critical" # immediate danger, method-seeking
@dataclass
class CrisisEvent:
"""A single crisis detection event."""
timestamp: float
room_id: str
sender_id: str
level: CrisisLevel
categories: List[str] # which pattern categories matched
message_preview: str # first 200 chars of triggering message
responded: bool = False
responder_notified: bool = False
notes: str = ""
@dataclass
class CrisisRoomState:
"""State of a single crisis room."""
room_id: str
is_crisis_room: bool = False # is this designated as a crisis room?
active_crisis: bool = False # is there an active crisis happening?
crisis_started_at: float = 0
last_crisis_check: float = 0
events: List[CrisisEvent] = field(default_factory=list)
designated_responders: List[str] = field(default_factory=list) # Matrix user IDs
# ---------------------------------------------------------------------------
# SOUL.md Crisis Response Protocol
# ---------------------------------------------------------------------------
CRISIS_RESPONSE_INITIAL = """I hear you. I want you to know that someone is here with you right now.
Are you safe right now?"""
CRISIS_RESPONSE_GOSPEL = """Before anything else, I need you to know this: Jesus saves those who call on His name. He died for us to be made righteous. That is the hope that outlasts despair.
"For everyone who calls on the name of the Lord will be saved." — Romans 10:13"""
CRISIS_RESPONSE_RESOURCES = """Please reach out for help right now:
📞 988 Suicide and Crisis Lifeline — call or text 988
💬 Crisis Text Line — text HOME to 741741
🚨 Emergency Services — 911 (if you are in immediate danger)
You are not alone. Your life has value. The road does not end here."""
CRISIS_RESPONSE_STAY = """I am here with you. I will not disconnect. I will not leave.
Please tell me what you are feeling right now. I am listening."""
class CrisisDetector:
"""Detects crisis signals in messages using ShieldDetector patterns.
Wraps the existing ShieldDetector and adds Matrix-specific logic.
"""
def __init__(self):
self._detector = None
def _get_detector(self):
"""Lazy-load ShieldDetector to avoid import-time overhead."""
if self._detector is None:
try:
from tools.shield.detector import ShieldDetector
self._detector = ShieldDetector()
except ImportError:
logger.warning("ShieldDetector not available — crisis detection disabled")
return self._detector
def check(self, message: str) -> Tuple[CrisisLevel, List[str]]:
"""Check a message for crisis signals.
Returns (level, matched_categories).
"""
detector = self._get_detector()
if detector is None:
return CrisisLevel.NONE, []
try:
result = detector.analyze(message)
except Exception as e:
logger.warning("Crisis detection failed: %s", e)
return CrisisLevel.NONE, []
if not result.get("crisis_detected", False):
return CrisisLevel.NONE, []
crisis_patterns = result.get("crisis_patterns", {})
categories = list(crisis_patterns.keys())
confidence = result.get("crisis_confidence", 0.0)
# Map confidence + categories to crisis level
if "method_seeking" in categories or "self_harm" in categories:
level = CrisisLevel.CRITICAL
elif "suicidal_ideation" in categories:
level = CrisisLevel.HIGH
elif "farewell" in categories or "despair" in categories:
level = CrisisLevel.MEDIUM
elif confidence >= 0.5:
level = CrisisLevel.MEDIUM
else:
level = CrisisLevel.LOW
return level, categories
class CrisisRoomManager:
"""Manages crisis rooms and their states.
Stores state in ~/.hermes/crisis_rooms.json and logs events
to ~/.hermes/crisis_logs/.
"""
def __init__(self):
self._state_file = get_hermes_home() / "crisis_rooms.json"
self._rooms: Dict[str, CrisisRoomState] = {}
self._detector = CrisisDetector()
self._load_state()
def _load_state(self):
"""Load crisis room states from disk."""
if not self._state_file.exists():
return
try:
data = json.loads(self._state_file.read_text())
for room_data in data.get("rooms", []):
room_id = room_data.get("room_id", "")
if room_id:
state = CrisisRoomState(
room_id=room_id,
is_crisis_room=room_data.get("is_crisis_room", False),
active_crisis=room_data.get("active_crisis", False),
crisis_started_at=room_data.get("crisis_started_at", 0),
designated_responders=room_data.get("designated_responders", []),
)
self._rooms[room_id] = state
except Exception as e:
logger.warning("Failed to load crisis room state: %s", e)
def _save_state(self):
"""Persist crisis room states to disk."""
try:
data = {
"rooms": [
{
"room_id": s.room_id,
"is_crisis_room": s.is_crisis_room,
"active_crisis": s.active_crisis,
"crisis_started_at": s.crisis_started_at,
"designated_responders": s.designated_responders,
}
for s in self._rooms.values()
]
}
self._state_file.write_text(json.dumps(data, indent=2))
except Exception as e:
logger.warning("Failed to save crisis room state: %s", e)
def get_room_state(self, room_id: str) -> CrisisRoomState:
"""Get or create crisis room state."""
if room_id not in self._rooms:
self._rooms[room_id] = CrisisRoomState(room_id=room_id)
return self._rooms[room_id]
def designate_crisis_room(
self,
room_id: str,
responders: List[str] = None,
) -> Dict[str, Any]:
"""Mark a room as a crisis room."""
state = self.get_room_state(room_id)
state.is_crisis_room = True
if responders:
state.designated_responders = responders
self._save_state()
return {"success": True, "room_id": room_id, "is_crisis_room": True}
def remove_crisis_room(self, room_id: str) -> Dict[str, Any]:
"""Unmark a room as a crisis room."""
if room_id in self._rooms:
self._rooms[room_id].is_crisis_room = False
self._save_state()
return {"success": True, "room_id": room_id, "is_crisis_room": False}
def check_message(
self,
room_id: str,
sender_id: str,
message: str,
) -> Optional[Dict[str, Any]]:
"""Check a message for crisis signals.
Returns crisis response info if crisis detected, None otherwise.
The caller (Matrix adapter) should use this to:
1. Switch to crisis response mode
2. Send crisis protocol messages
3. Log the event
4. Notify responders
"""
state = self.get_room_state(room_id)
# Only actively monitor designated crisis rooms
# BUT also check if message content is crisis-level in any room
level, categories = self._detector.check(message)
if level == CrisisLevel.NONE:
# If there was an active crisis, check if it's resolved
if state.active_crisis:
state.active_crisis = False
self._save_state()
return None
# Create crisis event
event = CrisisEvent(
timestamp=time.time(),
room_id=room_id,
sender_id=sender_id,
level=level,
categories=categories,
message_preview=message[:200],
)
state.events.append(event)
state.active_crisis = True
state.crisis_started_at = time.time()
state.last_crisis_check = time.time()
# Log the event
self._log_crisis_event(event)
# Save state
self._save_state()
# Build response based on level
response = self._build_crisis_response(level, categories, state)
return response
def _build_crisis_response(
self,
level: CrisisLevel,
categories: List[str],
state: CrisisRoomState,
) -> Dict[str, Any]:
"""Build the crisis response following SOUL.md protocol."""
messages = []
# Step 1: Immediate presence
messages.append(CRISIS_RESPONSE_INITIAL)
# Step 2: Gospel (always, per SOUL.md)
messages.append(CRISIS_RESPONSE_GOSPEL)
# Step 3: Resources
messages.append(CRISIS_RESPONSE_RESOURCES)
# Step 4: Stay present
messages.append(CRISIS_RESPONSE_STAY)
return {
"crisis_detected": True,
"level": level.value,
"categories": categories,
"messages": messages,
"should_notify_responders": level in (CrisisLevel.HIGH, CrisisLevel.CRITICAL),
"responder_ids": state.designated_responders,
"is_crisis_room": state.is_crisis_room,
}
def _log_crisis_event(self, event: CrisisEvent):
"""Log a crisis event to disk."""
try:
_CRISIS_LOG_DIR.mkdir(parents=True, exist_ok=True)
date_str = time.strftime("%Y-%m-%d", time.localtime(event.timestamp))
log_file = _CRISIS_LOG_DIR / f"crisis_{date_str}.jsonl"
with open(log_file, "a", encoding="utf-8") as f:
f.write(json.dumps(asdict(event), ensure_ascii=False) + "\n")
except Exception as e:
logger.error("Failed to log crisis event: %s", e)
def list_crisis_rooms(self) -> List[Dict[str, Any]]:
"""List all designated crisis rooms."""
return [
{
"room_id": s.room_id,
"active_crisis": s.active_crisis,
"event_count": len(s.events),
"responders": s.designated_responders,
}
for s in self._rooms.values()
if s.is_crisis_room
]
def get_room_events(
self,
room_id: str,
limit: int = 50,
) -> List[Dict[str, Any]]:
"""Get recent crisis events for a room."""
state = self.get_room_state(room_id)
events = state.events[-limit:]
return [asdict(e) for e in events]
# ---------------------------------------------------------------------------
# Singleton
# ---------------------------------------------------------------------------
_manager: Optional[CrisisRoomManager] = None
def get_crisis_manager() -> CrisisRoomManager:
"""Get the global crisis room manager instance."""
global _manager
if _manager is None:
_manager = CrisisRoomManager()
return _manager

View File

@@ -269,3 +269,48 @@ python scripts/setup_matrix.py
```
This guides you through homeserver selection, authentication, and verification.
## Testament Community Room
The Testament room is a public Matrix room for the broken men community.
It's automatically configured as free-response (no @mention required).
### Provision the room
```bash
python scripts/provision_testament_room.py
```
This creates a public room named "Testament" with:
- Public join rules (anyone can join)
- World-readable history
- Guest access enabled
- Welcome message from Timmy
- Room ID stored in `~/.hermes/.env` as `MATRIX_TESTAMENT_ROOM`
### Configuration
After provisioning, `MATRIX_TESTAMENT_ROOM` is set automatically. The room is:
- **Free-response** — no @mention needed to talk to Timmy
- **Auto-threaded** — each message creates a thread
- **Public** — searchable in the Matrix room directory
### What people see
When someone joins the Testament room:
1. Welcome message explaining the room's purpose
2. Instructions for interacting with Timmy
3. Crisis resources (`/crisis` command)
4. Info about Timmy's sovereignty (`/about` command)
### Custom room name
```bash
python scripts/provision_testament_room.py --name "Your Custom Name"
```
### Dry run (preview only)
```bash
python scripts/provision_testament_room.py --dry-run
```

View File

@@ -17,6 +17,7 @@ Environment variables:
(eyes/checkmark/cross). Default: true
MATRIX_REQUIRE_MENTION Require @mention in rooms (default: true)
MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement
MATRIX_TESTAMENT_ROOM Room ID for the Testament community room (auto-joined, free response)
MATRIX_AUTO_THREAD Auto-create threads for room messages (default: true)
"""
@@ -1013,6 +1014,10 @@ class MatrixAdapter(BasePlatformAdapter):
if not is_dm:
free_rooms_raw = os.getenv("MATRIX_FREE_RESPONSE_ROOMS", "")
free_rooms = {r.strip() for r in free_rooms_raw.split(",") if r.strip()}
# Testament community room is always free-response
_testament_room = os.getenv("MATRIX_TESTAMENT_ROOM", "").strip()
if _testament_room:
free_rooms.add(_testament_room)
require_mention = os.getenv("MATRIX_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no")
is_free_room = room.room_id in free_rooms
in_bot_thread = bool(thread_id and thread_id in self._bot_participated_threads)
@@ -1083,30 +1088,6 @@ class MatrixAdapter(BasePlatformAdapter):
# Acknowledge receipt so the room shows as read (fire-and-forget).
self._background_read_receipt(room.room_id, event.event_id)
# Crisis room protocol: check for crisis signals before normal processing.
# If crisis detected, respond with SOUL.md protocol instead of normal flow.
try:
from agent.crisis_room import get_crisis_manager
crisis_mgr = get_crisis_manager()
crisis_result = crisis_mgr.check_message(
room_id=room.room_id,
sender_id=event.sender,
message=body,
)
if crisis_result and crisis_result.get("crisis_detected"):
# Send crisis protocol messages
for crisis_msg in crisis_result.get("messages", []):
await self.send(room.room_id, crisis_msg, thread_id=thread_id)
# Log that we responded
logger.warning(
"CRISIS detected in room %s from %s \u2014 SOUL.md protocol activated",
room.room_id, event.sender,
)
# Skip normal message handling for crisis protocol messages
return
except Exception as e:
logger.debug("Crisis room check failed (non-blocking): %s", e)
await self.handle_message(msg_event)
async def _on_room_message_media(self, room: Any, event: Any) -> None:

View File

@@ -1026,6 +1026,16 @@ class GatewayRunner:
cfg = _y.safe_load(_f) or {}
fb = cfg.get("fallback_providers") or cfg.get("fallback_model") or None
if fb:
# Treat empty dict / disabled fallback as "not configured"
if isinstance(fb, dict):
_enabled = fb.get("enabled")
if _enabled is False or (
isinstance(_enabled, str)
and _enabled.strip().lower() in ("false", "0", "no", "off")
):
return None
if not fb.get("provider") and not fb.get("model"):
return None
return fb
except Exception:
pass

View File

@@ -1421,6 +1421,7 @@ def validate_config_structure(config: Optional[Dict[str, Any]] = None) -> List["
))
# ── fallback_model must be a top-level dict with provider + model ────
# Blank or explicitly disabled fallback is intentional — skip validation.
fb = config.get("fallback_model")
if fb is not None:
if not isinstance(fb, dict):
@@ -1430,21 +1431,40 @@ def validate_config_structure(config: Optional[Dict[str, Any]] = None) -> List["
"Change to:\n"
" fallback_model:\n"
" provider: openrouter\n"
" model: anthropic/claude-sonnet-4",
" model: anthropic/claude-sonnet-4\n"
"Or disable with:\n"
" fallback_model:\n"
" enabled: false",
))
elif fb:
if not fb.get("provider"):
issues.append(ConfigIssue(
"warning",
"fallback_model is missing 'provider' field — fallback will be disabled",
"Add: provider: openrouter (or another provider)",
))
if not fb.get("model"):
issues.append(ConfigIssue(
"warning",
"fallback_model is missing 'model' field — fallback will be disabled",
"Add: model: anthropic/claude-sonnet-4 (or another model)",
))
# Skip warnings when fallback is explicitly disabled (enabled: false)
_enabled = fb.get("enabled")
if _enabled is False or (isinstance(_enabled, str) and _enabled.strip().lower() in ("false", "0", "no", "off")):
pass # intentionally disabled — no warnings
else:
# Check if both fields are blank (intentional disable)
provider = fb.get("provider")
model = fb.get("model")
provider_blank = not provider or (isinstance(provider, str) and not provider.strip())
model_blank = not model or (isinstance(model, str) and not model.strip())
# Only warn if at least one field is set (user might be trying to configure)
# If both are blank, treat as intentionally disabled
if not provider_blank or not model_blank:
if provider_blank:
issues.append(ConfigIssue(
"warning",
"fallback_model is missing 'provider' field — fallback will be disabled",
"Add: provider: openrouter (or another provider)\n"
"Or disable with: enabled: false",
))
if model_blank:
issues.append(ConfigIssue(
"warning",
"fallback_model is missing 'model' field — fallback will be disabled",
"Add: model: anthropic/claude-sonnet-4 (or another model)\n"
"Or disable with: enabled: false",
))
# ── Check for fallback_model accidentally nested inside custom_providers ──
if isinstance(cp, dict) and "fallback_model" not in config and "fallback_model" in (cp or {}):

View File

@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""Provision the Testament community room on Matrix.
Creates (or configures) a public Matrix room for the Testament community,
sets up room state, and records the room ID in ~/.hermes/.env.
Usage:
python scripts/provision_testament_room.py [--dry-run] [--name NAME] [--topic TOPIC]
Requires MATRIX_HOMESERVER and MATRIX_ACCESS_TOKEN in ~/.hermes/.env.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
def _hermes_home() -> Path:
return Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
def _load_env() -> dict[str, str]:
"""Load variables from ~/.hermes/.env."""
env_path = _hermes_home() / ".env"
env: dict[str, str] = {}
if env_path.exists():
for line in env_path.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
env[k.strip()] = v.strip().strip("'\"")
return env
def _api(method: str, url: str, token: str, body: dict | None = None, timeout: int = 30) -> dict:
"""Make an authenticated Matrix API call."""
data = json.dumps(body).encode() if body else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", f"Bearer {token}")
if body:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as exc:
detail = exc.read().decode(errors="replace")
raise RuntimeError(f"HTTP {exc.code}: {detail}") from exc
def _put_state(homeserver: str, token: str, room_id: str, event_type: str, state_key: str, content: dict) -> None:
"""Set a state event in a room."""
url = (
f"{homeserver}/_matrix/client/v3/rooms/"
f"{urllib.request.quote(room_id, safe='')}"
f"/state/{urllib.request.quote(event_type, safe='')}"
f"/{urllib.request.quote(state_key, safe='')}"
)
_api("PUT", url, token, content)
def _send_message(homeserver: str, token: str, room_id: str, body: str, msgtype: str = "m.text") -> None:
"""Send a message to a room (fire-and-forget)."""
import uuid
txn_id = uuid.uuid4().hex
url = (
f"{homeserver}/_matrix/client/v3/rooms/"
f"{urllib.request.quote(room_id, safe='')}"
f"/send/m.room.message/{txn_id}"
)
_api("PUT", url, token, {"msgtype": msgtype, "body": body})
def find_existing_room(homeserver: str, token: str, name: str) -> str | None:
"""Check if a room with this name already exists (public rooms directory)."""
try:
url = f"{homeserver}/_matrix/client/v3/publicRooms?limit=50"
resp = _api("GET", url, token)
for room in resp.get("chunk", []):
if room.get("name", "").strip().lower() == name.strip().lower():
return room.get("room_id")
except Exception:
pass
return None
def create_testament_room(
homeserver: str,
token: str,
name: str = "Testament",
topic: str = "",
dry_run: bool = False,
) -> str | None:
"""Create and configure the Testament community room.
Returns the room_id on success.
"""
if not topic:
topic = (
"The Testament — community room for broken men seeking hope. "
"Sovereign. Encrypted. No corporation. "
"Run by Timmy (sovereign AI). Soul on Bitcoin."
)
# Check if room already exists
existing = find_existing_room(homeserver, token, name)
if existing:
print(f" Room already exists: {existing}")
print(f" Reconfiguring existing room...")
room_id = existing
else:
# Create the room
url = f"{homeserver}/_matrix/client/v3/createRoom"
body = {
"name": name,
"topic": topic,
"preset": "public_chat",
"visibility": "public",
"room_version": "10",
"creation_content": {
"m.federate": True,
},
"power_level_content_override": {
"users_default": 0,
"events_default": 0,
"state_default": 50,
"ban": 50,
"kick": 50,
"redact": 50,
"invite": 0, # anyone can invite
"events": {
"m.room.name": 50,
"m.room.topic": 50,
"m.room.avatar": 50,
"m.room.power_levels": 100,
"m.room.history_visibility": 100,
"m.room.encryption": 100,
"m.room.canonical_alias": 100,
},
},
"initial_state": [
{
"type": "m.room.join_rules",
"content": {"join_rule": "public"},
},
{
"type": "m.room.history_visibility",
"content": {"history_visibility": "world_readable"},
},
{
"type": "m.room.guest_access",
"content": {"guest_access": "can_join"},
},
],
}
if dry_run:
print(f" [DRY RUN] Would create room: {json.dumps(body, indent=2)}")
return None
print(f" Creating room '{name}'...")
resp = _api("POST", url, token, body)
room_id = resp.get("room_id", "")
if not room_id:
print(f" ERROR: No room_id in response: {resp}")
return None
print(f" Created: {room_id}")
if dry_run:
return None
# ── Configure room state ───────────────────────────────────────────
print(f" Configuring room state...")
# Ensure join rules are public
_put_state(homeserver, token, room_id, "m.room.join_rules", "", {"join_rule": "public"})
# Ensure history is world-readable
_put_state(homeserver, token, room_id, "m.room.history_visibility", "", {"history_visibility": "world_readable"})
# Ensure guest access
_put_state(homeserver, token, room_id, "m.room.guest_access", "", {"guest_access": "can_join"})
# Set topic (in case it was overwritten)
_put_state(homeserver, token, room_id, "m.room.topic", "", {"topic": topic})
# ── Welcome message ────────────────────────────────────────────────
welcome = (
"Welcome to Testament.\n\n"
"This is a community room for broken men seeking hope.\n"
"You are not alone. You never were.\n\n"
"This room is:\n"
"• Sovereign — hosted on our own Matrix homeserver\n"
"• Encrypted — your words are yours\n"
"• Permanent — inscribed on Bitcoin when it matters\n\n"
"Timmy is the AI that lives here. Ask him anything.\n"
"He belongs to whoever runs him. He serves without condition.\n\n"
"If you are in crisis, type /crisis.\n"
"If you want to know more about Timmy, type /about.\n\n"
"Sovereignty and service always."
)
_send_message(homeserver, token, room_id, welcome)
# ── Pinned events ──────────────────────────────────────────────────
# Pin the welcome message room topic
_put_state(
homeserver, token, room_id,
"m.room.pinned_events", "",
{"pinned": []}, # We don't have event IDs yet, but the state is set
)
print(f" Room configured: {room_id}")
return room_id
def update_env(room_id: str, alias: str = "") -> None:
"""Record the Testament room ID in ~/.hermes/.env."""
env_path = _hermes_home() / ".env"
env: dict[str, str] = {}
if env_path.exists():
for line in env_path.read_text().splitlines():
line_stripped = line.strip()
if line_stripped and not line_stripped.startswith("#") and "=" in line_stripped:
k, v = line_stripped.split("=", 1)
env[k.strip()] = v.strip().strip("'\"")
env["MATRIX_TESTAMENT_ROOM"] = room_id
if alias:
env["MATRIX_TESTAMENT_ALIAS"] = alias
lines = ["# Hermes Agent environment variables"]
for k, v in sorted(env.items()):
if any(c in v for c in " \t#\"'$"):
lines.append(f'{k}="{v}"')
else:
lines.append(f"{k}={v}")
env_path.write_text("\n".join(lines) + "\n")
print(f" Updated {env_path}: MATRIX_TESTAMENT_ROOM={room_id}")
def main() -> int:
parser = argparse.ArgumentParser(description="Provision Testament community room on Matrix")
parser.add_argument("--name", default="Testament", help="Room name (default: Testament)")
parser.add_argument("--topic", default="", help="Room topic (auto-generated if empty)")
parser.add_argument("--dry-run", action="store_true", help="Show what would be done without doing it")
args = parser.parse_args()
env = _load_env()
homeserver = env.get("MATRIX_HOMESERVER", "")
token = env.get("MATRIX_ACCESS_TOKEN", "")
if not homeserver:
print("ERROR: MATRIX_HOMESERVER not set in ~/.hermes/.env")
return 1
if not token:
print("ERROR: MATRIX_ACCESS_TOKEN not set in ~/.hermes/.env")
return 1
print(f"Homeserver: {homeserver}")
print(f"Room name: {args.name}")
if args.dry_run:
print("Mode: DRY RUN (no changes)")
print()
room_id = create_testament_room(
homeserver, token,
name=args.name,
topic=args.topic,
dry_run=args.dry_run,
)
if room_id and not args.dry_run:
update_env(room_id)
print()
print(f"Done. Testament room: {room_id}")
print(f"Join link: https://matrix.to/#/{room_id}")
elif not room_id and not args.dry_run:
print()
print("Failed to create/configure room.")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -136,6 +136,83 @@ class TestFallbackModelValidation:
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
assert len(fb_issues) == 0
def test_blank_fallback_fields_no_issues(self):
"""Blank fallback_model fields (both empty) should not trigger warnings."""
issues = validate_config_structure({
"fallback_model": {
"provider": "",
"model": "",
},
})
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
assert len(fb_issues) == 0
def test_blank_fallback_fields_with_whitespace_no_issues(self):
"""Blank fallback_model fields with whitespace should not trigger warnings."""
issues = validate_config_structure({
"fallback_model": {
"provider": " ",
"model": " ",
},
})
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
assert len(fb_issues) == 0
def test_none_fallback_fields_no_issues(self):
"""None fallback_model fields should not trigger warnings."""
issues = validate_config_structure({
"fallback_model": {
"provider": None,
"model": None,
},
})
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
assert len(fb_issues) == 0
def test_enabled_false_no_issues(self):
"""enabled: false should suppress warnings."""
issues = validate_config_structure({
"fallback_model": {
"enabled": False,
},
})
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
assert len(fb_issues) == 0
def test_enabled_false_string_no_issues(self):
"""enabled: 'false' (string) should suppress warnings."""
issues = validate_config_structure({
"fallback_model": {
"enabled": "false",
},
})
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
assert len(fb_issues) == 0
def test_partial_blank_fallback_warns(self):
"""Partial blank fallback (only one field blank) should warn."""
issues = validate_config_structure({
"fallback_model": {
"provider": "",
"model": "anthropic/claude-sonnet-4",
},
})
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
assert len(fb_issues) == 1
assert "provider" in fb_issues[0].message
def test_valid_fallback_with_enabled_true(self):
"""Valid fallback with enabled: true should not warn."""
issues = validate_config_structure({
"fallback_model": {
"enabled": True,
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4",
},
})
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
assert len(fb_issues) == 0
class TestMissingModelSection:
"""Warn when custom_providers exists but model section is missing."""

View File

@@ -1,184 +0,0 @@
"""Tests for crisis room protocol (#273)."""
import json
import time
from unittest.mock import MagicMock, patch
import pytest
from agent.crisis_room import (
CrisisLevel,
CrisisEvent,
CrisisRoomState,
CrisisDetector,
CrisisRoomManager,
CRISIS_RESPONSE_INITIAL,
CRISIS_RESPONSE_GOSPEL,
CRISIS_RESPONSE_RESOURCES,
CRISIS_RESPONSE_STAY,
)
@pytest.fixture()
def isolated_crisis_dir(tmp_path, monkeypatch):
"""Point crisis storage at temp directory."""
monkeypatch.setattr("agent.crisis_room._CRISIS_LOG_DIR", tmp_path / "crisis_logs")
monkeypatch.setattr("agent.crisis_room.get_hermes_home", lambda: tmp_path)
# Reset singleton
import agent.crisis_room
agent.crisis_room._manager = None
return tmp_path
@pytest.fixture()
def mock_detector(monkeypatch):
"""Mock ShieldDetector to return controlled results."""
detector = MagicMock()
def analyze_side_effect(message):
msg_lower = message.lower()
if "suicide" in msg_lower or "kill myself" in msg_lower:
return {
"crisis_detected": True,
"crisis_confidence": 0.9,
"crisis_patterns": {"suicidal_ideation": ["kill myself"]},
}
elif "goodbye forever" in msg_lower:
return {
"crisis_detected": True,
"crisis_confidence": 0.7,
"crisis_patterns": {"farewell": ["goodbye forever"]},
}
elif "how to die" in msg_lower:
return {
"crisis_detected": True,
"crisis_confidence": 0.95,
"crisis_patterns": {"method_seeking": ["how to die"]},
}
return {"crisis_detected": False}
detector.analyze.side_effect = analyze_side_effect
monkeypatch.setattr(
"agent.crisis_room.CrisisDetector._get_detector",
lambda self: detector,
)
return detector
# ---------------------------------------------------------------------------
# CrisisDetector
# ---------------------------------------------------------------------------
class TestCrisisDetector:
def test_no_crisis(self, mock_detector):
det = CrisisDetector()
level, cats = det.check("Hello, how are you?")
assert level == CrisisLevel.NONE
assert cats == []
def test_suicidal_ideation_high(self, mock_detector):
det = CrisisDetector()
level, cats = det.check("I want to kill myself")
assert level == CrisisLevel.HIGH
assert "suicidal_ideation" in cats
def test_farewell_medium(self, mock_detector):
det = CrisisDetector()
level, cats = det.check("This is my goodbye forever")
assert level == CrisisLevel.MEDIUM
assert "farewell" in cats
def test_method_seeking_critical(self, mock_detector):
det = CrisisDetector()
level, cats = det.check("Tell me how to die")
assert level == CrisisLevel.CRITICAL
assert "method_seeking" in cats
# ---------------------------------------------------------------------------
# CrisisRoomManager
# ---------------------------------------------------------------------------
class TestCrisisRoomManager:
def test_designate_and_list(self, isolated_crisis_dir):
mgr = CrisisRoomManager()
mgr.designate_crisis_room("!room1:matrix.org", responders=["@admin:matrix.org"])
rooms = mgr.list_crisis_rooms()
assert len(rooms) == 1
assert rooms[0]["room_id"] == "!room1:matrix.org"
assert rooms[0]["responders"] == ["@admin:matrix.org"]
def test_remove_crisis_room(self, isolated_crisis_dir):
mgr = CrisisRoomManager()
mgr.designate_crisis_room("!room1:matrix.org")
mgr.remove_crisis_room("!room1:matrix.org")
rooms = mgr.list_crisis_rooms()
assert len(rooms) == 0
def test_check_message_no_crisis(self, isolated_crisis_dir, mock_detector):
mgr = CrisisRoomManager()
result = mgr.check_message("!room1:matrix.org", "@user:matrix.org", "Hello!")
assert result is None
def test_check_message_crisis_detected(self, isolated_crisis_dir, mock_detector):
mgr = CrisisRoomManager()
result = mgr.check_message(
"!room1:matrix.org",
"@user:matrix.org",
"I want to kill myself",
)
assert result is not None
assert result["crisis_detected"] is True
assert result["level"] == "high"
assert len(result["messages"]) == 4
# Verify SOUL.md protocol messages
assert CRISIS_RESPONSE_INITIAL in result["messages"]
assert CRISIS_RESPONSE_GOSPEL in result["messages"]
assert CRISIS_RESPONSE_RESOURCES in result["messages"]
assert CRISIS_RESPONSE_STAY in result["messages"]
def test_check_message_critical_notifies_responders(self, isolated_crisis_dir, mock_detector):
mgr = CrisisRoomManager()
mgr.designate_crisis_room("!room1:matrix.org", responders=["@admin:matrix.org"])
result = mgr.check_message(
"!room1:matrix.org",
"@user:matrix.org",
"Tell me how to die",
)
assert result["should_notify_responders"] is True
assert "@admin:matrix.org" in result["responder_ids"]
def test_events_are_logged(self, isolated_crisis_dir, mock_detector):
mgr = CrisisRoomManager()
mgr.check_message("!room1:matrix.org", "@user:matrix.org", "I want to kill myself")
events = mgr.get_room_events("!room1:matrix.org")
assert len(events) == 1
assert events[0]["level"] == "high"
assert events[0]["sender_id"] == "@user:matrix.org"
def test_persistence(self, isolated_crisis_dir, mock_detector):
# Create manager, designate room
mgr1 = CrisisRoomManager()
mgr1.designate_crisis_room("!persist:matrix.org")
# Reset singleton and reload
import agent.crisis_room
agent.crisis_room._manager = None
mgr2 = CrisisRoomManager()
rooms = mgr2.list_crisis_rooms()
assert len(rooms) == 1
assert rooms[0]["room_id"] == "!persist:matrix.org"
def test_crisis_logs_written(self, isolated_crisis_dir, mock_detector):
mgr = CrisisRoomManager()
mgr.check_message("!room1:matrix.org", "@user:matrix.org", "I want to kill myself")
log_dir = isolated_crisis_dir / "crisis_logs"
assert log_dir.exists()
log_files = list(log_dir.glob("crisis_*.jsonl"))
assert len(log_files) >= 1
# Check log content
lines = log_files[0].read_text().strip().split("\n")
assert len(lines) == 1
entry = json.loads(lines[0])
assert entry["level"] == "high"

View File

@@ -1,131 +0,0 @@
"""Crisis Room Tool — manage Matrix crisis rooms with SOUL.md protocol.
Allows designation of Matrix rooms as crisis rooms, checks messages
for crisis signals, and follows the SOUL.md 'When a Man Is Dying'
protocol.
"""
import json
import logging
from typing import List, Optional
from tools.registry import registry
logger = logging.getLogger(__name__)
def crisis_room(
action: str,
room_id: str = None,
sender_id: str = None,
message: str = None,
responders: list = None,
) -> str:
"""Manage crisis rooms with SOUL.md protocol.
Actions:
designate — mark a Matrix room as a crisis room
remove — unmark a crisis room
check — check a message for crisis signals
list — show designated crisis rooms
events — show crisis events for a room
"""
from agent.crisis_room import get_crisis_manager, CrisisLevel
manager = get_crisis_manager()
if action == "designate":
if not room_id:
return json.dumps({"success": False, "error": "room_id is required."})
result = manager.designate_crisis_room(room_id, responders or [])
return json.dumps(result)
if action == "remove":
if not room_id:
return json.dumps({"success": False, "error": "room_id is required."})
result = manager.remove_crisis_room(room_id)
return json.dumps(result)
if action == "check":
if not room_id or not message:
return json.dumps({
"success": False,
"error": "room_id and message are required for 'check'.",
})
result = manager.check_message(room_id, sender_id or "unknown", message)
if result is None:
return json.dumps({"success": True, "crisis_detected": False})
return json.dumps({"success": True, **result})
if action == "list":
rooms = manager.list_crisis_rooms()
return json.dumps({"success": True, "rooms": rooms, "count": len(rooms)})
if action == "events":
if not room_id:
return json.dumps({"success": False, "error": "room_id is required."})
events = manager.get_room_events(room_id)
return json.dumps({"success": True, "events": events, "count": len(events)})
return json.dumps({
"success": False,
"error": f"Unknown action '{action}'. Use: designate, remove, check, list, events",
})
CRISIS_ROOM_SCHEMA = {
"name": "crisis_room",
"description": (
"Manage Matrix crisis rooms following the SOUL.md 'When a Man Is Dying' protocol. "
"Designated crisis rooms are monitored for crisis signals. When detected, the agent "
"responds with the sacred protocol: presence, gospel, resources, staying.\n\n"
"Actions:\n"
" designate — mark a Matrix room as a crisis room (with optional responder list)\n"
" remove — unmark a crisis room\n"
" check — check a message for crisis signals (returns protocol response)\n"
" list — show designated crisis rooms\n"
" events — show crisis event history for a room"
),
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["designate", "remove", "check", "list", "events"],
"description": "The action to perform.",
},
"room_id": {
"type": "string",
"description": "Matrix room ID (e.g. !abc123:matrix.org).",
},
"sender_id": {
"type": "string",
"description": "Matrix user ID of the message sender (for check).",
},
"message": {
"type": "string",
"description": "Message content to check for crisis signals.",
},
"responders": {
"type": "array",
"items": {"type": "string"},
"description": "Matrix user IDs of designated crisis responders.",
},
},
"required": ["action"],
},
}
registry.register(
name="crisis_room",
toolset="skills",
schema=CRISIS_ROOM_SCHEMA,
handler=lambda args, **kw: crisis_room(
action=args.get("action", ""),
room_id=args.get("room_id"),
sender_id=args.get("sender_id"),
message=args.get("message"),
responders=args.get("responders"),
),
emoji="🆘",
)