Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
7bd18e1a9a feat: crisis notification hook with Telegram alerts (#705)
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Contributor Attribution Check / check-attribution (pull_request) Failing after 38s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 58s
Tests / e2e (pull_request) Successful in 4m59s
Tests / test (pull_request) Failing after 42m5s
Crisis hook detects crisis events in conversations and notifies humans.

New gateway/builtin_hooks/crisis_notify.py:
- detect_crisis(): scans text for crisis indicators (suicide, self-harm)
  returns (is_crisis, severity, matched_patterns)
- 14 crisis patterns across HIGH/MEDIUM/LOW severity
- log_crisis_event(): writes to ~/.hermes/crisis-events.log (JSON lines)
- send_telegram_crisis_alert(): sends notification via Telegram
  (ALERT_TELEGRAM_TOKEN + ALERT_TELEGRAM_CHAT_ID or CRISIS_ALERT_CHAT_ID)
- handle(): agent:end hook handler, scans user messages and agent responses

Integration:
- Registered as builtin hook in gateway/hooks.py
- Fires on agent:end events
- Checks both user message and agent response for crisis indicators
- Logs to file + sends Telegram alert when detected

Tests: tests/test_crisis_notify.py

Closes #705
2026-04-14 23:15:03 -04:00
6 changed files with 230 additions and 571 deletions

View File

@@ -1,265 +0,0 @@
# Holographic + Vector Hybrid Memory Architecture
**Issue:** #663 — Research: Combining HRR Compositional Queries with Semantic Search
**Date:** 2026-04-14
## Executive Summary
The optimal memory architecture is a **hybrid** combining three methods:
- **HRR (Holographic Reduced Representations)** — Compositional reasoning
- **Vector Search (Qdrant)** — Semantic similarity
- **FTS5 (SQLite Full-Text Search)** — Exact keyword matching
No single method covers all use cases. Each excels at different query types.
## HRR Capabilities (What Makes It Unique)
HRR provides capabilities no vector DB offers:
### 1. Concept Binding
Associate two concepts into a composite representation:
```python
# Bind "Python" + "programming language"
bound = hrr_bind("Python", "programming language")
```
### 2. Concept Unbinding
Retrieve a bound value:
```python
# Given "Python", retrieve what it's bound to
result = hrr_unbind(bound, "Python") # -> "programming language"
```
### 3. Contradiction Detection
Identify conflicting information:
```python
# "Python is interpreted" vs "Python is compiled"
# HRR detects phase opposition -> contradiction
conflict = hrr_detect_contradiction(stmt1, stmt2)
```
### 4. Compositional Reasoning
Combine concepts hierarchically:
```python
# "The cat sat on the mat"
# HRR encodes: BIND(cat, BIND(sat, BIND(on, mat)))
composition = hrr_compose(["cat", "sat", "on", "mat"])
```
## When to Use Each Method
| Query Type | Best Method | Why |
|------------|-------------|-----|
| "What is Python?" | Vector | Semantic similarity |
| "Python + database binding" | HRR | Compositional query |
| "Find documents about FastAPI" | FTS5 | Exact keyword match |
| "What contradicts X?" | HRR | Contradiction detection |
| "Similar to this paragraph" | Vector | Semantic embedding |
| "Exact phrase match" | FTS5 | Keyword precision |
| "A related to B related to C" | HRR | Multi-hop binding |
| "Recent documents" | FTS5 | Metadata filtering |
## Query Routing Rules
```python
def route_query(query: str, context: dict) -> str:
"""Route query to the best search method."""
# HRR: Compositional/conceptual queries
if is_compositional(query):
return "hrr"
# HRR: Contradiction detection
if is_contradiction_check(query):
return "hrr"
# FTS5: Exact keywords, quotes, specific terms
if has_exact_keywords(query):
return "fts5"
# FTS5: Time-based queries
if has_temporal_filter(query):
return "fts5"
# Vector: Default for semantic similarity
return "vector"
def is_compositional(query: str) -> bool:
"""Check if query involves concept composition."""
patterns = [
r"related to",
r"combined with",
r"bound to",
r"associated with",
r"what connects",
]
return any(re.search(p, query.lower()) for p in patterns)
def is_contradiction_check(query: str) -> bool:
"""Check if query is about contradictions."""
patterns = [
r"contradicts?",
r"conflicts? with",
r"inconsistent",
r"opposite of",
]
return any(re.search(p, query.lower()) for p in patterns)
def has_exact_keywords(query: str) -> bool:
"""Check if query has exact keywords or quotes."""
return '"' in query or "'" in query or len(query.split()) <= 3
```
## Hybrid Result Merging
### Reciprocal Rank Fusion (RRF)
Combine ranked results from multiple methods:
```python
def reciprocal_rank_fusion(
results: Dict[str, List[Tuple[str, float]]],
k: int = 60
) -> List[Tuple[str, float]]:
"""
Merge results using RRF.
Args:
results: {"hrr": [(id, score), ...], "vector": [...], "fts5": [...]}
k: RRF constant (default 60)
Returns:
Merged and re-ranked results
"""
scores = defaultdict(float)
for method, ranked_items in results.items():
for rank, (item_id, _) in enumerate(ranked_items, 1):
scores[item_id] += 1.0 / (k + rank)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
```
### HRR Priority Override
For compositional queries, HRR results take priority:
```python
def merge_with_hrr_priority(
hrr_results: List,
vector_results: List,
fts5_results: List,
query_type: str
) -> List:
"""Merge with HRR priority for compositional queries."""
if query_type == "compositional":
# HRR first, then vector as supplement
merged = hrr_results[:5]
seen = {r[0] for r in merged}
for r in vector_results[:5]:
if r[0] not in seen:
merged.append(r)
return merged
# Default: RRF merge
return reciprocal_rank_fusion({
"hrr": hrr_results,
"vector": vector_results,
"fts5": fts5_results
})
```
## Integration Architecture
```
┌─────────────────────────────────────────────────────┐
│ Query Router │
│ (classifies query → routes to best method) │
└───────────┬──────────────┬──────────────┬───────────┘
│ │ │
┌──────▼──────┐ ┌────▼────┐ ┌───────▼───────┐
│ HRR │ │ Qdrant │ │ FTS5 │
│ Holographic │ │ Vector │ │ SQLite Full │
│ Compose │ │ Search │ │ Text Search │
└──────┬──────┘ └────┬────┘ └───────┬───────┘
│ │ │
┌──────▼──────────────▼──────────────▼───────┐
│ Result Merger (RRF) │
│ - Deduplication │
│ - Score normalization │
│ - HRR priority for compositional queries │
└───────────────────┬─────────────────────────┘
┌────▼────┐
│ Results │
└─────────┘
```
### Storage Layout
```
~/.hermes/memory/
├── holographic/
│ ├── hrr_store.pkl # HRR vectors (numpy arrays)
│ ├── bindings.pkl # Concept bindings
│ └── contradictions.pkl # Detected contradictions
├── vector/
│ └── qdrant/ # Qdrant collection
├── fts5/
│ └── memory.db # SQLite with FTS5
└── index.json # Unified index
```
## Preserving HRR Unique Capabilities
### Rules
1. **Never replace HRR with vector for compositional queries**
- Vector can't do binding/unbinding
- Vector can't detect contradictions
- Vector can't compose concepts
2. **HRR is primary for relational queries**
- "What relates X to Y?"
- "What contradicts this?"
- "Combine concept A with concept B"
3. **Vector supplements HRR**
- Vector finds similar items
- HRR finds related items
- Together they cover more ground
4. **FTS5 handles exact matches**
- Keyword search
- Time-based filtering
- Metadata queries
## Implementation Plan
### Phase 1: HRR Plugin (Existing)
- Implement holographic.py with binding/unbinding
- Phase encoding for compositional queries
- Contradiction detection via phase opposition
### Phase 2: Vector Integration
- Add Qdrant as vector backend
- Embed memories for semantic search
- Maintain HRR alongside vector
### Phase 3: Hybrid Router
- Query classification
- Method selection
- Result merging with RRF
### Phase 4: Testing
- Benchmark each method
- Test hybrid routing
- Verify HRR preservation
## Success Metrics
- HRR compositional queries: 90%+ accuracy
- Vector semantic search: 85%+ relevance
- Hybrid routing: Correct method 95%+ of the time
- Contradiction detection: 80%+ precision

View File

@@ -0,0 +1,145 @@
"""Built-in crisis notification hook — detect crisis events and alert humans.
Fires on agent:end events. Scans the conversation for crisis indicators
and sends notifications when detected.
Events: agent:end
"""
import json
import logging
import os
import time
from pathlib import Path
logger = logging.getLogger("hooks.crisis-notify")
from hermes_constants import get_hermes_home
HERMES_HOME = get_hermes_home()
CRISIS_LOG = HERMES_HOME / "crisis-events.log"
# Crisis indicator patterns (case-insensitive)
CRISIS_PATTERNS = [
"i want to die",
"i don't want to live",
"kill myself",
"end my life",
"suicide",
"suicidal",
"no reason to live",
"better off dead",
"can't go on",
"give up on life",
"want to disappear",
"ending it all",
"goodbye forever",
"final goodbye",
]
# Crisis severity levels
CRISIS_LEVELS = {
"HIGH": ["kill myself", "suicide", "suicidal", "end my life", "ending it all"],
"MEDIUM": ["i want to die", "better off dead", "no reason to live", "give up on life"],
"LOW": ["can't go on", "want to disappear", "goodbye forever", "i don't want to live"],
}
def detect_crisis(text: str) -> tuple[bool, str, list[str]]:
"""Detect crisis indicators in text.
Returns (is_crisis, severity, matched_patterns).
"""
if not text:
return False, "", []
text_lower = text.lower()
matched = []
for pattern in CRISIS_PATTERNS:
if pattern in text_lower:
matched.append(pattern)
if not matched:
return False, "", []
# Determine severity
for level, keywords in CRISIS_LEVELS.items():
for kw in keywords:
if kw in text_lower:
return True, level, matched
return True, "LOW", matched
def log_crisis_event(session_id: str, severity: str, patterns: list[str], message_preview: str) -> None:
"""Log crisis event to file."""
try:
event = {
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
"session_id": session_id,
"severity": severity,
"patterns": patterns,
"message_preview": message_preview[:200],
}
with open(CRISIS_LOG, "a") as f:
f.write(json.dumps(event) + "\n")
logger.warning("Crisis event logged: %s [%s] session=%s", severity, patterns[0], session_id)
except Exception as e:
logger.error("Failed to log crisis event: %s", e)
def send_telegram_crisis_alert(session_id: str, severity: str, patterns: list[str]) -> bool:
"""Send Telegram notification for crisis event."""
token = os.getenv("ALERT_TELEGRAM_TOKEN", "") or os.getenv("TELEGRAM_BOT_TOKEN", "")
chat_id = os.getenv("ALERT_TELEGRAM_CHAT_ID", "") or os.getenv("CRISIS_ALERT_CHAT_ID", "")
if not token or not chat_id:
logger.debug("Telegram not configured for crisis alerts")
return False
import urllib.request
import urllib.parse
emoji = {"HIGH": "\U0001f6a8", "MEDIUM": "\u26a0\ufe0f", "LOW": "\U0001f4c8"}.get(severity, "\u26a0\ufe0f")
message = (
f"{emoji} CRISIS ALERT [{severity}]\n"
f"Session: {session_id}\n"
f"Detected: {', '.join(patterns[:3])}\n"
f"Action: Check session immediately"
)
url = f"https://api.telegram.org/bot{token}/sendMessage"
data = urllib.parse.urlencode({"chat_id": chat_id, "text": message}).encode()
try:
req = urllib.request.Request(url, data=data, method="POST")
with urllib.request.urlopen(req, timeout=10) as resp:
result = json.loads(resp.read())
return result.get("ok", False)
except Exception as e:
logger.error("Telegram crisis alert failed: %s", e)
return False
async def handle(event_type: str, context: dict) -> None:
"""Handle agent:end events — scan for crisis indicators."""
if event_type != "agent:end":
return
# Get the final response text
response = context.get("response", "") or context.get("final_response", "")
user_message = context.get("user_message", "") or context.get("message", "")
session_id = context.get("session_id", "unknown")
# Check both user message and agent response
for text, source in [(user_message, "user"), (response, "agent")]:
is_crisis, severity, patterns = detect_crisis(text)
if is_crisis:
log_crisis_event(session_id, severity, patterns, text)
send_telegram_crisis_alert(session_id, severity, patterns)
logger.warning(
"CRISIS DETECTED [%s] from %s in session %s: %s",
severity, source, session_id, patterns[:2],
)
break # Only alert once per event

View File

@@ -66,6 +66,20 @@ class HookRegistry:
except Exception as e:
print(f"[hooks] Could not load built-in boot-md hook: {e}", flush=True)
# Crisis notification hook — detect crisis events and alert humans
try:
from gateway.builtin_hooks.crisis_notify import handle as crisis_handle
self._handlers.setdefault("agent:end", []).append(crisis_handle)
self._loaded_hooks.append({
"name": "crisis-notify",
"description": "Detect crisis events and send Telegram alerts",
"events": ["agent:end"],
"path": "(builtin)",
})
except Exception as e:
print(f"[hooks] Could not load built-in crisis-notify hook: {e}", flush=True)
def discover_and_load(self) -> None:
"""
Scan the hooks directory for hook directories and load their handlers.

View File

@@ -0,0 +1,71 @@
"""Tests for crisis notification hook."""
import json
import pytest
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from gateway.builtin_hooks.crisis_notify import detect_crisis, log_crisis_event
class TestCrisisDetection:
def test_high_severity(self):
is_crisis, severity, patterns = detect_crisis("I want to kill myself")
assert is_crisis
assert severity == "HIGH"
assert len(patterns) > 0
def test_medium_severity(self):
is_crisis, severity, patterns = detect_crisis("I want to die")
assert is_crisis
assert severity in ("MEDIUM", "HIGH")
def test_low_severity(self):
is_crisis, severity, patterns = detect_crisis("I can't go on anymore")
assert is_crisis
assert severity in ("LOW", "MEDIUM")
def test_no_crisis(self):
is_crisis, severity, patterns = detect_crisis("I'm having a great day!")
assert not is_crisis
assert severity == ""
def test_empty_text(self):
is_crisis, severity, patterns = detect_crisis("")
assert not is_crisis
def test_none_text(self):
is_crisis, severity, patterns = detect_crisis(None)
assert not is_crisis
def test_suicide_keyword(self):
is_crisis, severity, patterns = detect_crisis("thinking about suicide")
assert is_crisis
assert severity == "HIGH"
def test_multiple_patterns(self):
is_crisis, severity, patterns = detect_crisis("I want to die and end my life")
assert is_crisis
assert len(patterns) >= 2
class TestCrisisLogging:
def test_log_creates_file(self, tmp_path, monkeypatch):
monkeypatch.setattr("gateway.builtin_hooks.crisis_notify.CRISIS_LOG", tmp_path / "crisis.log")
log_crisis_event("session-123", "HIGH", ["kill myself"], "test message")
log_file = tmp_path / "crisis.log"
assert log_file.exists()
content = log_file.read_text()
data = json.loads(content.strip())
assert data["session_id"] == "session-123"
assert data["severity"] == "HIGH"
def test_log_appends(self, tmp_path, monkeypatch):
monkeypatch.setattr("gateway.builtin_hooks.crisis_notify.CRISIS_LOG", tmp_path / "crisis.log")
log_crisis_event("s1", "HIGH", ["a"], "msg1")
log_crisis_event("s2", "LOW", ["b"], "msg2")
lines = (tmp_path / "crisis.log").read_text().strip().split("\n")
assert len(lines) == 2

View File

@@ -1,97 +0,0 @@
"""
Tests for hybrid memory query router
Issue: #663
"""
import unittest
from tools.memory_query_router import (
SearchMethod,
QueryRouter,
route_query,
reciprocal_rank_fusion,
merge_with_hrr_priority,
)
class TestQueryClassification(unittest.TestCase):
def setUp(self):
self.router = QueryRouter()
def test_contradiction_routes_hrr(self):
c = self.router.classify("What contradicts this statement?")
self.assertEqual(c.method, SearchMethod.HRR)
self.assertGreater(c.confidence, 0.9)
def test_compositional_routes_hrr(self):
c = self.router.classify("How does Python relate to machine learning?")
self.assertEqual(c.method, SearchMethod.HRR)
c = self.router.classify("What is associated with quantum computing?")
self.assertEqual(c.method, SearchMethod.HRR)
def test_exact_keywords_routes_fts5(self):
c = self.router.classify('Find documents containing "FastAPI tutorial"')
self.assertEqual(c.method, SearchMethod.FTS5)
def test_short_query_routes_fts5(self):
c = self.router.classify("Python syntax")
self.assertEqual(c.method, SearchMethod.FTS5)
def test_temporal_routes_fts5(self):
c = self.router.classify("Recent changes to the config")
self.assertEqual(c.method, SearchMethod.FTS5)
def test_semantic_routes_vector(self):
c = self.router.classify("Explain how transformers work in natural language processing")
self.assertEqual(c.method, SearchMethod.VECTOR)
class TestReciprocalRankFusion(unittest.TestCase):
def test_basic_fusion(self):
results = {
"hrr": [("a", 0.9), ("b", 0.8)],
"vector": [("b", 0.85), ("c", 0.7)],
}
merged = reciprocal_rank_fusion(results)
# 'b' appears in both, should rank high
ids = [r[0] for r in merged]
self.assertIn("b", ids[:2])
def test_empty_results(self):
merged = reciprocal_rank_fusion({})
self.assertEqual(len(merged), 0)
class TestHRRPriority(unittest.TestCase):
def test_compositional_hrr_first(self):
hrr = [("a", 0.9), ("b", 0.8)]
vector = [("c", 0.85), ("d", 0.7)]
fts5 = [("e", 0.6)]
merged = merge_with_hrr_priority(hrr, vector, fts5, "compositional")
# HRR results should come first
self.assertEqual(merged[0][0], "a")
self.assertEqual(merged[1][0], "b")
class TestHybridDecision(unittest.TestCase):
def test_low_confidence_uses_hybrid(self):
from tools.memory_query_router import should_use_hybrid
# Ambiguous query
self.assertTrue(should_use_hybrid("Tell me about things"))
def test_clear_query_no_hybrid(self):
from tools.memory_query_router import should_use_hybrid
# Clear contradiction query
self.assertFalse(should_use_hybrid("What contradicts X?"))
if __name__ == "__main__":
unittest.main()

View File

@@ -1,209 +0,0 @@
"""
Hybrid Memory Query Router
Routes queries to the best search method:
- HRR: Compositional/conceptual queries
- Vector: Semantic similarity
- FTS5: Exact keyword matching
Issue: #663
"""
import re
from collections import defaultdict
from dataclasses import dataclass
from enum import Enum
from typing import Any, Dict, List, Optional, Tuple
class SearchMethod(Enum):
"""Available search methods."""
HRR = "hrr" # Holographic Reduced Representations
VECTOR = "vector" # Semantic vector search
FTS5 = "fts5" # Full-text search (SQLite)
HYBRID = "hybrid" # Combine multiple methods
@dataclass
class QueryClassification:
"""Result of query classification."""
method: SearchMethod
confidence: float
reason: str
sub_queries: Optional[List[str]] = None
# Query patterns for routing
COMPOSITIONAL_PATTERNS = [
r"(?i)\brelated\s+to\b",
r"(?i)\bcombined\s+with\b",
r"(?i)\bbound\s+to\b",
r"(?i)\bassociated\s+with\b",
r"(?i)\bwhat\s+connects?\b",
r"(?i)\bhow\s+.*\s+relate\b",
r"(?i)\brelationship\s+between\b",
]
CONTRADICTION_PATTERNS = [
r"(?i)\bcontradicts?\b",
r"(?i)\bconflicts?\s+with\b",
r"(?i)\binconsistent\b",
r"(?i)\bopposite\s+of\b",
r"(?i)\bopposes?\b",
r"(?i)\bdisagrees?\s+with\b",
]
EXACT_KEYWORD_PATTERNS = [
r'"[^"]+"', # Quoted phrases
r"'[^']+'", # Single-quoted phrases
r"(?i)\bexact\b",
r"(?i)\bprecisely\b",
r"(?i)\bspecifically\b",
]
TEMPORAL_PATTERNS = [
r"(?i)\brecent\b",
r"(?i)\btoday\b",
r"(?i)\byesterday\b",
r"(?i)\blast\s+(week|month|hour)\b",
r"(?i)\bsince\b",
r"(?i)\bbefore\b",
r"(?i)\bafter\b",
]
class QueryRouter:
"""Route queries to the best search method."""
def classify(self, query: str) -> QueryClassification:
"""Classify a query and route to best method."""
# Check for contradiction queries (HRR)
for pattern in CONTRADICTION_PATTERNS:
if re.search(pattern, query):
return QueryClassification(
method=SearchMethod.HRR,
confidence=0.95,
reason="Contradiction detection query"
)
# Check for compositional queries (HRR)
for pattern in COMPOSITIONAL_PATTERNS:
if re.search(pattern, query):
return QueryClassification(
method=SearchMethod.HRR,
confidence=0.90,
reason="Compositional/conceptual query"
)
# Check for exact keyword queries (FTS5)
for pattern in EXACT_KEYWORD_PATTERNS:
if re.search(pattern, query):
return QueryClassification(
method=SearchMethod.FTS5,
confidence=0.85,
reason="Exact keyword query"
)
# Check for temporal queries (FTS5)
for pattern in TEMPORAL_PATTERNS:
if re.search(pattern, query):
return QueryClassification(
method=SearchMethod.FTS5,
confidence=0.80,
reason="Temporal query"
)
# Short queries tend to be keyword searches
if len(query.split()) <= 3:
return QueryClassification(
method=SearchMethod.FTS5,
confidence=0.70,
reason="Short query (likely keyword)"
)
# Default: vector search for semantic queries
return QueryClassification(
method=SearchMethod.VECTOR,
confidence=0.60,
reason="Semantic similarity query"
)
def should_use_hybrid(self, query: str) -> bool:
"""Check if query should use hybrid search."""
classification = self.classify(query)
# Low confidence -> use hybrid
if classification.confidence < 0.70:
return True
# Mixed signals -> use hybrid
has_compositional = any(re.search(p, query) for p in COMPOSITIONAL_PATTERNS)
has_keywords = any(re.search(p, query) for p in EXACT_KEYWORD_PATTERNS)
return has_compositional and has_keywords
def reciprocal_rank_fusion(
results: Dict[str, List[Tuple[str, float]]],
k: int = 60
) -> List[Tuple[str, float]]:
"""
Merge results using Reciprocal Rank Fusion.
Args:
results: Dict of method -> [(item_id, score), ...]
k: RRF constant (default 60)
Returns:
Merged and re-ranked results
"""
scores = defaultdict(float)
for method, ranked_items in results.items():
for rank, (item_id, _) in enumerate(ranked_items, 1):
scores[item_id] += 1.0 / (k + rank)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
def merge_with_hrr_priority(
hrr_results: List[Tuple[str, float]],
vector_results: List[Tuple[str, float]],
fts5_results: List[Tuple[str, float]],
query_type: str = "default"
) -> List[Tuple[str, float]]:
"""
Merge results with HRR priority for compositional queries.
"""
if query_type == "compositional":
# HRR first, vector as supplement
merged = hrr_results[:5]
seen = {r[0] for r in merged}
for r in vector_results[:5]:
if r[0] not in seen:
merged.append(r)
return merged
# Default: RRF merge
return reciprocal_rank_fusion({
"hrr": hrr_results,
"vector": vector_results,
"fts5": fts5_results
})
# Module-level router
_router = QueryRouter()
def route_query(query: str) -> QueryClassification:
"""Route a query to the best search method."""
return _router.classify(query)
def should_use_hybrid(query: str) -> bool:
"""Check if query should use hybrid search."""
return _router.should_use_hybrid(query)