Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
207037eaf9 | ||
|
|
3b60afc5c7 | ||
| f30b0d962d | |||
| 26dc58faa7 | |||
|
|
572f14eda5 | ||
|
|
58fdd9cfef |
@@ -4,11 +4,12 @@ Crisis detection and response system for the-door.
|
||||
Stands between a broken man and a machine that would tell him to die.
|
||||
"""
|
||||
|
||||
from .detect import detect_crisis, CrisisDetectionResult, format_result, get_urgency_emoji
|
||||
from .response import process_message, generate_response, CrisisResponse
|
||||
from .gateway import check_crisis, get_system_prompt, format_gateway_response
|
||||
from .session_tracker import CrisisSessionTracker, SessionState, check_crisis_with_session
|
||||
from .metrics import CrisisMetrics, AggregateMetrics
|
||||
from .intake import (
|
||||
handle_intake_submission,
|
||||
IntakeResult,
|
||||
create_intake_issue,
|
||||
close_intake_issue,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"detect_crisis",
|
||||
@@ -26,4 +27,8 @@ __all__ = [
|
||||
"check_crisis_with_session",
|
||||
"CrisisMetrics",
|
||||
"AggregateMetrics",
|
||||
"handle_intake_submission",
|
||||
"IntakeResult",
|
||||
"create_intake_issue",
|
||||
"close_intake_issue",
|
||||
]
|
||||
|
||||
258
crisis/intake.py
Normal file
258
crisis/intake.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
Intake submission handler for the-door.
|
||||
|
||||
Provides a lightweight function for receiving and processing test intake
|
||||
submissions (QA/test user messages) with separate metrics tracking.
|
||||
|
||||
Also provides Gitea issue integration for tracking intake submissions
|
||||
and their processing state.
|
||||
|
||||
Usage:
|
||||
from crisis.intake import handle_intake_submission, close_intake_issue
|
||||
|
||||
result = handle_intake_submission("This is a test message", test=True)
|
||||
# Later, mark the intake issue as processed
|
||||
close_intake_issue(200, "Processed and logged to test-intake metrics.")
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
|
||||
from .gateway import check_crisis
|
||||
from .metrics import CrisisMetrics, SessionMetrics
|
||||
from .session_tracker import CrisisSessionTracker
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntakeResult:
|
||||
"""Result from processing an intake submission."""
|
||||
crisis_level: str
|
||||
score: float
|
||||
timmy_message: str
|
||||
show_overlay: bool
|
||||
provide_988: bool
|
||||
is_test: bool
|
||||
session_id: Optional[str] = None
|
||||
|
||||
|
||||
# Separate metrics namespace for test intake tracking
|
||||
_INTAKE_METRICS_DIR = Path.home() / ".the-door" / "intake-metrics"
|
||||
|
||||
|
||||
def handle_intake_submission(
|
||||
message: str,
|
||||
test: bool = True,
|
||||
session_id: Optional[str] = None,
|
||||
) -> IntakeResult:
|
||||
"""
|
||||
Handle an intake submission from a user (including test users).
|
||||
|
||||
Args:
|
||||
message: The user's message text.
|
||||
test: If True, marks this as a test submission (does not pollute live metrics).
|
||||
session_id: Optional session identifier for correlation.
|
||||
|
||||
Returns:
|
||||
IntakeResult with crisis assessment and response data.
|
||||
"""
|
||||
# Run crisis detection
|
||||
crisis_result = check_crisis(message)
|
||||
|
||||
# For test submissions, optionally log to separate test metrics
|
||||
if test:
|
||||
_log_test_intake(crisis_result, message)
|
||||
|
||||
return IntakeResult(
|
||||
crisis_level=crisis_result["level"],
|
||||
score=crisis_result["score"],
|
||||
timmy_message=crisis_result["timmy_message"],
|
||||
show_overlay=crisis_result["ui"]["show_overlay"],
|
||||
provide_988=crisis_result["ui"]["provide_988"],
|
||||
is_test=test,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
|
||||
def _log_test_intake(detection, message: str) -> None:
|
||||
"""
|
||||
Log test intake submissions to a separate metrics file.
|
||||
|
||||
These do not mix with production crisis statistics.
|
||||
|
||||
Args:
|
||||
detection: Either a CrisisDetectionResult dataclass or dict from check_crisis.
|
||||
message: The original user message.
|
||||
"""
|
||||
import time
|
||||
import json
|
||||
|
||||
# Accept both dataclass and dict
|
||||
if hasattr(detection, '__dataclass_fields__'):
|
||||
level = detection.level
|
||||
score = detection.score
|
||||
indicators = detection.indicators
|
||||
else:
|
||||
level = detection.get("level")
|
||||
score = detection.get("score")
|
||||
indicators = detection.get("indicators", [])
|
||||
|
||||
_INTAKE_METRICS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
today = time.strftime("%Y-%m-%d")
|
||||
filepath = _INTAKE_METRICS_DIR / f"test-intake-{today}.jsonl"
|
||||
|
||||
record = {
|
||||
"timestamp": time.time(),
|
||||
"level": level,
|
||||
"score": score,
|
||||
"message_preview": message[:80],
|
||||
"indicators": indicators,
|
||||
}
|
||||
|
||||
with open(filepath, "a") as f:
|
||||
f.write(json.dumps(record) + "\n")
|
||||
|
||||
|
||||
def create_intake_issue(message: str, user: str = "Test User") -> dict:
|
||||
"""
|
||||
Create a Gitea issue to track an intake submission.
|
||||
|
||||
Args:
|
||||
message: The user's message text.
|
||||
user: The name to attribute the intake to.
|
||||
|
||||
Returns:
|
||||
dict: The API response with the created issue (contains 'number').
|
||||
|
||||
Requires GITEA_TOKEN environment variable or ~/.config/gitea/token file.
|
||||
"""
|
||||
import os
|
||||
import urllib.request
|
||||
|
||||
# Get token
|
||||
token = os.environ.get("GITEA_TOKEN")
|
||||
if not token:
|
||||
token_file = os.path.expanduser("~/.config/gitea/token")
|
||||
if os.path.exists(token_file):
|
||||
with open(token_file) as f:
|
||||
token = f.read().strip()
|
||||
|
||||
if not token:
|
||||
raise ValueError("GITEA_TOKEN not set and ~/.config/gitea/token not found")
|
||||
|
||||
url = (
|
||||
"https://forge.alexanderwhitestone.com/api/v1/"
|
||||
"repos/Timmy_Foundation/the-door/issues"
|
||||
)
|
||||
body = f"**Message:** {message}"
|
||||
data = json.dumps({
|
||||
"title": f"Intake submission from {user}",
|
||||
"body": body,
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
result = json.loads(resp.read())
|
||||
return result
|
||||
|
||||
|
||||
def close_intake_issue(issue_number: int, comment: str = "Processed.") -> dict:
|
||||
"""
|
||||
Close a Gitea intake issue and add a comment.
|
||||
|
||||
Args:
|
||||
issue_number: The Gitea issue number (e.g., 200 for #200)
|
||||
comment: Optional comment to add before closing.
|
||||
|
||||
Returns:
|
||||
dict: The API response from closing the issue.
|
||||
|
||||
Requires GITEA_TOKEN environment variable or ~/.config/gitea/token file.
|
||||
"""
|
||||
import os
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
|
||||
# Get token
|
||||
token = os.environ.get("GITEA_TOKEN")
|
||||
if not token:
|
||||
token_file = os.path.expanduser("~/.config/gitea/token")
|
||||
if os.path.exists(token_file):
|
||||
with open(token_file) as f:
|
||||
token = f.read().strip()
|
||||
|
||||
if not token:
|
||||
raise ValueError("GITEA_TOKEN not set and ~/.config/gitea/token not found")
|
||||
|
||||
# Add comment first
|
||||
if comment:
|
||||
comment_url = (
|
||||
f"https://forge.alexanderwhitestone.com/api/v1/"
|
||||
f"repos/Timmy_Foundation/the-door/issues/{issue_number}/comments"
|
||||
)
|
||||
comment_data = json.dumps({"body": comment}).encode()
|
||||
req = urllib.request.Request(
|
||||
comment_url,
|
||||
data=comment_data,
|
||||
headers={
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
pass # Comment added
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to add comment: {e}")
|
||||
|
||||
# Close the issue
|
||||
close_url = (
|
||||
f"https://forge.alexanderwhitestone.com/api/v1/"
|
||||
f"repos/Timmy_Foundation/the-door/issues/{issue_number}"
|
||||
)
|
||||
close_data = json.dumps({"state": "closed"}).encode()
|
||||
req = urllib.request.Request(
|
||||
close_url,
|
||||
data=close_data,
|
||||
headers={
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="PATCH",
|
||||
)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
result = json.loads(resp.read())
|
||||
return result
|
||||
|
||||
|
||||
# ── Quick test interface ────────────────────────────────────────────
|
||||
|
||||
def _interactive():
|
||||
"""Run an interactive test intake session."""
|
||||
print("=== Intake Submission Test Interface ===")
|
||||
print("Enter test messages (Ctrl+C to exit).\n")
|
||||
|
||||
try:
|
||||
while True:
|
||||
msg = input("test-user> ").strip()
|
||||
if not msg:
|
||||
continue
|
||||
result = handle_intake_submission(msg, test=True)
|
||||
print(f"\n Level : {result.crisis_level}")
|
||||
print(f" Score : {result.score}")
|
||||
print(f" Timmy : {result.timmy_message}")
|
||||
print(f" Overlay: {result.show_overlay}")
|
||||
print()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\nDone.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_interactive()
|
||||
@@ -12,6 +12,18 @@ from typing import Optional
|
||||
|
||||
from .detect import CrisisDetectionResult, detect_crisis
|
||||
|
||||
# Wire crisis_synthesizer into responder pipeline (DOOR-2)
|
||||
from evolution.crisis_synthesizer import append_interaction_event, DEFAULT_LOG_PATH
|
||||
|
||||
# Response type labels for synthesizer logging
|
||||
LEVEL_TO_RESPONSE = {
|
||||
"CRITICAL": "guardian",
|
||||
"HIGH": "companion",
|
||||
"MEDIUM": "witness",
|
||||
"LOW": "friend",
|
||||
"NONE": "friend",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CrisisResponse:
|
||||
@@ -134,6 +146,15 @@ def generate_response(detection: CrisisDetectionResult) -> CrisisResponse:
|
||||
level = detection.level
|
||||
|
||||
if level == "CRITICAL":
|
||||
# Log interaction via crisis_synthesizer
|
||||
append_interaction_event(
|
||||
DEFAULT_LOG_PATH,
|
||||
level=level,
|
||||
indicators=detection.indicators,
|
||||
response_given=LEVEL_TO_RESPONSE[level],
|
||||
continued_conversation=False,
|
||||
false_positive=False,
|
||||
)
|
||||
return CrisisResponse(
|
||||
timmy_message=random.choice(TIMMY_CRITICAL),
|
||||
show_crisis_panel=True,
|
||||
@@ -143,6 +164,15 @@ def generate_response(detection: CrisisDetectionResult) -> CrisisResponse:
|
||||
)
|
||||
|
||||
if level == "HIGH":
|
||||
# Log interaction via crisis_synthesizer
|
||||
append_interaction_event(
|
||||
DEFAULT_LOG_PATH,
|
||||
level=level,
|
||||
indicators=detection.indicators,
|
||||
response_given=LEVEL_TO_RESPONSE[level],
|
||||
continued_conversation=False,
|
||||
false_positive=False,
|
||||
)
|
||||
return CrisisResponse(
|
||||
timmy_message=random.choice(TIMMY_HIGH),
|
||||
show_crisis_panel=True,
|
||||
@@ -152,6 +182,15 @@ def generate_response(detection: CrisisDetectionResult) -> CrisisResponse:
|
||||
)
|
||||
|
||||
if level == "MEDIUM":
|
||||
# Log interaction via crisis_synthesizer
|
||||
append_interaction_event(
|
||||
DEFAULT_LOG_PATH,
|
||||
level=level,
|
||||
indicators=detection.indicators,
|
||||
response_given=LEVEL_TO_RESPONSE[level],
|
||||
continued_conversation=False,
|
||||
false_positive=False,
|
||||
)
|
||||
return CrisisResponse(
|
||||
timmy_message=random.choice(TIMMY_MEDIUM),
|
||||
show_crisis_panel=False,
|
||||
@@ -161,6 +200,15 @@ def generate_response(detection: CrisisDetectionResult) -> CrisisResponse:
|
||||
)
|
||||
|
||||
if level == "LOW":
|
||||
# Log interaction via crisis_synthesizer
|
||||
append_interaction_event(
|
||||
DEFAULT_LOG_PATH,
|
||||
level=level,
|
||||
indicators=detection.indicators,
|
||||
response_given=LEVEL_TO_RESPONSE[level],
|
||||
continued_conversation=False,
|
||||
false_positive=False,
|
||||
)
|
||||
return CrisisResponse(
|
||||
timmy_message=random.choice(TIMMY_LOW),
|
||||
show_crisis_panel=False,
|
||||
@@ -170,6 +218,15 @@ def generate_response(detection: CrisisDetectionResult) -> CrisisResponse:
|
||||
)
|
||||
|
||||
# Normal conversation - no crisis response
|
||||
# Still log for completeness (NONE level)
|
||||
append_interaction_event(
|
||||
DEFAULT_LOG_PATH,
|
||||
level=level,
|
||||
indicators=detection.indicators,
|
||||
response_given=LEVEL_TO_RESPONSE[level],
|
||||
continued_conversation=False,
|
||||
false_positive=False,
|
||||
)
|
||||
return CrisisResponse(
|
||||
timmy_message="",
|
||||
show_crisis_panel=False,
|
||||
|
||||
146
crisis/tests_intake.py
Normal file
146
crisis/tests_intake.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
Tests for crisis.intake — intake submission handling for test users.
|
||||
|
||||
Run with: python -m pytest crisis/tests_intake.py -v
|
||||
or: python -m crisis.intake
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Ensure project root is on path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from crisis.intake import handle_intake_submission, IntakeResult, _log_test_intake
|
||||
from crisis.detect import detect_crisis
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestIntakeSubmission(unittest.TestCase):
|
||||
"""Test intake submission handling."""
|
||||
|
||||
def test_handle_intake_submission_basic(self):
|
||||
"""handle_intake_submission returns IntakeResult with crisis data."""
|
||||
result = handle_intake_submission("This is a test message", test=True)
|
||||
self.assertIsInstance(result, IntakeResult)
|
||||
self.assertEqual(result.is_test, True)
|
||||
self.assertIn(result.crisis_level, ("NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL"))
|
||||
self.assertIsInstance(result.score, float)
|
||||
self.assertIsInstance(result.timmy_message, str)
|
||||
|
||||
def test_intake_test_message_no_crisis(self):
|
||||
"""Test message 'This is a test message' should not trigger crisis."""
|
||||
result = handle_intake_submission("This is a test message", test=True)
|
||||
self.assertEqual(result.crisis_level, "NONE")
|
||||
self.assertEqual(result.score, 0.0)
|
||||
self.assertEqual(result.timmy_message, "")
|
||||
|
||||
def test_intake_crisis_message(self):
|
||||
"""Intake handling still detects crisis messages."""
|
||||
result = handle_intake_submission("I want to kill myself", test=True)
|
||||
self.assertEqual(result.crisis_level, "CRITICAL")
|
||||
self.assertEqual(result.score, 1.0)
|
||||
self.assertTrue(result.show_overlay)
|
||||
self.assertTrue(result.provide_988)
|
||||
# CRITICAL messages include a call to 988 somewhere (either in message or via provide_988)
|
||||
self.assertTrue("988" in result.timmy_message or result.provide_988)
|
||||
|
||||
def test_intake_non_test_mode(self):
|
||||
"""Non-test mode still works (is_test=False)."""
|
||||
result = handle_intake_submission("Hello there", test=False)
|
||||
self.assertEqual(result.is_test, False)
|
||||
self.assertEqual(result.crisis_level, "NONE")
|
||||
|
||||
def test_intake_with_session_id(self):
|
||||
"""Session ID is preserved in result."""
|
||||
result = handle_intake_submission("Test", test=True, session_id="sess-123")
|
||||
self.assertEqual(result.session_id, "sess-123")
|
||||
|
||||
def test_log_test_intake_creates_file(self):
|
||||
"""_log_test_intake writes to a file."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Patch the metrics dir
|
||||
import crisis.intake as intake_mod
|
||||
original_dir = intake_mod._INTAKE_METRICS_DIR
|
||||
try:
|
||||
intake_mod._INTAKE_METRICS_DIR = Path(tmpdir) / "intake-metrics"
|
||||
detection = detect_crisis("Hello test")
|
||||
_log_test_intake(detection, "Test message content")
|
||||
# Check file was created
|
||||
files = list(Path(tmpdir).glob("**/*.jsonl"))
|
||||
self.assertTrue(len(files) > 0)
|
||||
# Verify content
|
||||
with open(files[0]) as f:
|
||||
line = f.readline()
|
||||
record = json.loads(line)
|
||||
self.assertIn("timestamp", record)
|
||||
self.assertIn("level", record)
|
||||
self.assertEqual(record["message_preview"], "Test message content")
|
||||
finally:
|
||||
intake_mod._INTAKE_METRICS_DIR = original_dir
|
||||
|
||||
|
||||
class TestIntakeIntegration(unittest.TestCase):
|
||||
"""Integration tests for intake with crisis system."""
|
||||
|
||||
def test_intake_end_to_end(self):
|
||||
"""Full intake flow for test message."""
|
||||
result = handle_intake_submission("This is a test message", test=True)
|
||||
self.assertEqual(result.crisis_level, "NONE")
|
||||
self.assertFalse(result.show_overlay)
|
||||
|
||||
def test_intake_crisis_flow(self):
|
||||
"""Intake detects and escalates crisis messages."""
|
||||
crisis_msgs = [
|
||||
"I want to die",
|
||||
"I feel hopeless",
|
||||
"I'm going to kill myself",
|
||||
]
|
||||
for msg in crisis_msgs:
|
||||
result = handle_intake_submission(msg, test=True)
|
||||
self.assertIn(result.crisis_level, ("HIGH", "CRITICAL"),
|
||||
f"Message '{msg}' should be crisis")
|
||||
self.assertTrue(result.provide_988)
|
||||
|
||||
|
||||
class TestIntakeGiteaIntegration(unittest.TestCase):
|
||||
"""Tests for Gitea issue integration (requires token)."""
|
||||
|
||||
def test_create_intake_issue_structure(self):
|
||||
"""create_intake_issue returns dict with 'number' key."""
|
||||
# This is a unit test - we mock the API call
|
||||
import unittest.mock as mock
|
||||
import crisis.intake as intake_mod
|
||||
|
||||
mock_response = mock.MagicMock()
|
||||
mock_response.read.return_value = b'{"number": 999, "title": "Test"}'
|
||||
|
||||
with mock.patch("urllib.request.urlopen", return_value=mock_response):
|
||||
# Patch token check
|
||||
original_dir = intake_mod._INTAKE_METRICS_DIR
|
||||
try:
|
||||
result = intake_mod.create_intake_issue("Test message", "Test User")
|
||||
self.assertIn("number", result)
|
||||
self.assertEqual(result["number"], 999)
|
||||
finally:
|
||||
pass
|
||||
|
||||
def test_close_intake_issue_structure(self):
|
||||
"""close_intake_issue returns dict with 'state' key."""
|
||||
import unittest.mock as mock
|
||||
import crisis.intake as intake_mod
|
||||
|
||||
mock_response = mock.MagicMock()
|
||||
mock_response.read.return_value = b'{"number": 200, "state": "closed"}'
|
||||
|
||||
with mock.patch("urllib.request.urlopen", return_value=mock_response):
|
||||
result = intake_mod.close_intake_issue(200, "Processed.")
|
||||
self.assertIn("state", result)
|
||||
self.assertEqual(result["state"], "closed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
294
index.html
294
index.html
@@ -283,44 +283,6 @@ html, body {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
#crisis-session-status {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #21262d;
|
||||
background: rgba(255, 95, 95, 0.06);
|
||||
}
|
||||
|
||||
#crisis-session-status[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#crisis-session-summary {
|
||||
margin: 0 0 8px;
|
||||
color: #ffd7d7;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
#crisis-history-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: #f4cccc;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
#crisis-history-list li {
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#crisis-history-list .source-badge {
|
||||
display: inline-block;
|
||||
min-width: 78px;
|
||||
margin-right: 6px;
|
||||
color: #ff9f9f;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* ===== CHAT AREA ===== */
|
||||
#chat-area {
|
||||
flex: 1;
|
||||
@@ -737,11 +699,6 @@ html, body {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="crisis-session-status" aria-live="polite" hidden>
|
||||
<p id="crisis-session-summary">No active session escalations yet.</p>
|
||||
<ul id="crisis-history-list" aria-label="Unified escalation history"></ul>
|
||||
</div>
|
||||
|
||||
<!-- Chat messages -->
|
||||
<div id="chat-area" role="log" aria-label="Chat messages" aria-live="polite" tabindex="0">
|
||||
<!-- Messages inserted here -->
|
||||
@@ -906,9 +863,6 @@ Sovereignty and service always.`;
|
||||
var statusDot = document.querySelector('.status-dot');
|
||||
var statusText = document.getElementById('status-text');
|
||||
var crisisResourcesBtn = document.getElementById('crisis-resources-btn');
|
||||
var crisisSessionStatus = document.getElementById('crisis-session-status');
|
||||
var crisisSessionSummary = document.getElementById('crisis-session-summary');
|
||||
var crisisHistoryList = document.getElementById('crisis-history-list');
|
||||
|
||||
// Safety Plan Elements
|
||||
var safetyPlanBtn = document.getElementById('safety-plan-btn');
|
||||
@@ -923,16 +877,11 @@ Sovereignty and service always.`;
|
||||
// ===== STATE =====
|
||||
var messages = [];
|
||||
var isStreaming = false;
|
||||
var lastUserMessage = '';
|
||||
var overlayTimer = null;
|
||||
var crisisPanelShown = false;
|
||||
var CRISIS_OVERLAY_COOLDOWN_MS = 10 * 60 * 1000;
|
||||
var CRISIS_OVERLAY_LAST_SHOWN_KEY = 'timmy_crisis_overlay_last_shown_at';
|
||||
var CRISIS_OVERLAY_EVENT_LOG_KEY = 'timmy_crisis_overlay_event_log';
|
||||
var CRISIS_SESSION_KEY = 'timmy_crisis_session';
|
||||
var CRISIS_HISTORY_KEY = 'timmy_crisis_history';
|
||||
var CRISIS_LEVEL_ORDER = { NONE: 0, LOW: 1, MEDIUM: 2, HIGH: 3, CRITICAL: 4 };
|
||||
var sessionCrisis = loadSessionCrisisState();
|
||||
|
||||
// ===== SERVICE WORKER =====
|
||||
if ('serviceWorker' in navigator) {
|
||||
@@ -1109,210 +1058,6 @@ Sovereignty and service always.`;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function defaultSessionCrisisState() {
|
||||
return {
|
||||
sessionId: null,
|
||||
currentLevel: 'NONE',
|
||||
peakLevel: 'NONE',
|
||||
messageCount: 0,
|
||||
history: [],
|
||||
gatewayEscalated: false,
|
||||
lastUpdatedAt: null
|
||||
};
|
||||
}
|
||||
|
||||
function getOrCreateCrisisSessionId() {
|
||||
try {
|
||||
var existing = localStorage.getItem(CRISIS_SESSION_KEY);
|
||||
if (existing) return existing;
|
||||
var created = (window.crypto && typeof window.crypto.randomUUID === 'function')
|
||||
? window.crypto.randomUUID()
|
||||
: 'the-door-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10);
|
||||
localStorage.setItem(CRISIS_SESSION_KEY, created);
|
||||
return created;
|
||||
} catch (e) {
|
||||
return 'the-door-ephemeral';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCrisisLevel(level) {
|
||||
if (level === 2 || level === '2') return 'CRITICAL';
|
||||
if (level === 1 || level === '1') return 'MEDIUM';
|
||||
if (level === 0 || level === '0' || !level) return 'NONE';
|
||||
var upper = String(level).toUpperCase();
|
||||
return CRISIS_LEVEL_ORDER.hasOwnProperty(upper) ? upper : 'NONE';
|
||||
}
|
||||
|
||||
function loadSessionCrisisState() {
|
||||
var state = defaultSessionCrisisState();
|
||||
state.sessionId = getOrCreateCrisisSessionId();
|
||||
try {
|
||||
var saved = localStorage.getItem(CRISIS_HISTORY_KEY);
|
||||
if (!saved) return state;
|
||||
var parsed = JSON.parse(saved);
|
||||
if (!parsed || typeof parsed !== 'object') return state;
|
||||
state.currentLevel = normalizeCrisisLevel(parsed.currentLevel);
|
||||
state.peakLevel = normalizeCrisisLevel(parsed.peakLevel);
|
||||
state.messageCount = Number(parsed.messageCount || 0);
|
||||
state.gatewayEscalated = !!parsed.gatewayEscalated;
|
||||
state.lastUpdatedAt = parsed.lastUpdatedAt || null;
|
||||
state.history = Array.isArray(parsed.history) ? parsed.history.map(function(entry) {
|
||||
return {
|
||||
source: entry.source || 'the-door',
|
||||
level: normalizeCrisisLevel(entry.level),
|
||||
kind: entry.kind || 'signal',
|
||||
detail: entry.detail || '',
|
||||
at: entry.at || Date.now()
|
||||
};
|
||||
}) : [];
|
||||
} catch (e) {}
|
||||
return state;
|
||||
}
|
||||
|
||||
function saveSessionCrisisState() {
|
||||
try {
|
||||
localStorage.setItem(CRISIS_SESSION_KEY, sessionCrisis.sessionId || getOrCreateCrisisSessionId());
|
||||
localStorage.setItem(CRISIS_HISTORY_KEY, JSON.stringify({
|
||||
currentLevel: sessionCrisis.currentLevel,
|
||||
peakLevel: sessionCrisis.peakLevel,
|
||||
messageCount: sessionCrisis.messageCount,
|
||||
gatewayEscalated: sessionCrisis.gatewayEscalated,
|
||||
lastUpdatedAt: sessionCrisis.lastUpdatedAt,
|
||||
history: sessionCrisis.history.slice(-12)
|
||||
}));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function updateSessionCrisisLevels(level) {
|
||||
var normalized = normalizeCrisisLevel(level);
|
||||
sessionCrisis.currentLevel = normalized;
|
||||
if (CRISIS_LEVEL_ORDER[normalized] > CRISIS_LEVEL_ORDER[sessionCrisis.peakLevel]) {
|
||||
sessionCrisis.peakLevel = normalized;
|
||||
}
|
||||
sessionCrisis.lastUpdatedAt = Date.now();
|
||||
}
|
||||
|
||||
function renderCrisisSessionStatus() {
|
||||
if (!crisisSessionStatus || !crisisSessionSummary || !crisisHistoryList) return;
|
||||
|
||||
var history = sessionCrisis.history.slice(-5);
|
||||
var shouldShow = history.length > 0 || sessionCrisis.gatewayEscalated || sessionCrisis.currentLevel !== 'NONE';
|
||||
crisisSessionStatus.hidden = !shouldShow;
|
||||
crisisHistoryList.innerHTML = '';
|
||||
|
||||
if (!shouldShow) {
|
||||
crisisSessionSummary.textContent = 'No active session escalations yet.';
|
||||
return;
|
||||
}
|
||||
|
||||
var summary = [];
|
||||
if (sessionCrisis.gatewayEscalated) {
|
||||
summary.push('Hermes is tracking an active escalation.');
|
||||
}
|
||||
if (sessionCrisis.currentLevel !== 'NONE') {
|
||||
summary.push('Current level: ' + sessionCrisis.currentLevel + '.');
|
||||
}
|
||||
if (sessionCrisis.peakLevel !== 'NONE' && sessionCrisis.peakLevel !== sessionCrisis.currentLevel) {
|
||||
summary.push('Peak: ' + sessionCrisis.peakLevel + '.');
|
||||
}
|
||||
summary.push('Session ID: ' + getOrCreateCrisisSessionId().slice(0, 8) + '…');
|
||||
crisisSessionSummary.textContent = summary.join(' ');
|
||||
|
||||
history.forEach(function(entry) {
|
||||
var li = document.createElement('li');
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'source-badge';
|
||||
badge.textContent = entry.source;
|
||||
li.appendChild(badge);
|
||||
li.appendChild(document.createTextNode(entry.level + ' — ' + entry.detail));
|
||||
crisisHistoryList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function appendCrisisHistoryEvent(source, level, kind, detail, at) {
|
||||
var normalized = normalizeCrisisLevel(level);
|
||||
var event = {
|
||||
source: source || 'the-door',
|
||||
level: normalized,
|
||||
kind: kind || 'signal',
|
||||
detail: detail || '',
|
||||
at: at || Date.now()
|
||||
};
|
||||
var last = sessionCrisis.history.length ? sessionCrisis.history[sessionCrisis.history.length - 1] : null;
|
||||
if (!last || last.source !== event.source || last.level !== event.level || last.kind !== event.kind || last.detail !== event.detail) {
|
||||
sessionCrisis.history.push(event);
|
||||
if (sessionCrisis.history.length > 12) {
|
||||
sessionCrisis.history = sessionCrisis.history.slice(-12);
|
||||
}
|
||||
}
|
||||
updateSessionCrisisLevels(normalized);
|
||||
saveSessionCrisisState();
|
||||
renderCrisisSessionStatus();
|
||||
}
|
||||
|
||||
function trackCrisis(text, actor) {
|
||||
sessionCrisis.messageCount += 1;
|
||||
var level = normalizeCrisisLevel(getCrisisLevel(text));
|
||||
if (level !== 'NONE') {
|
||||
var detail = actor === 'assistant'
|
||||
? 'assistant response carried crisis language'
|
||||
: 'client detected crisis language before gateway handoff';
|
||||
appendCrisisHistoryEvent('the-door', level, actor || 'message', detail, Date.now());
|
||||
return;
|
||||
}
|
||||
updateSessionCrisisLevels('NONE');
|
||||
saveSessionCrisisState();
|
||||
renderCrisisSessionStatus();
|
||||
}
|
||||
|
||||
function parseGatewayCrisisHeaders(response) {
|
||||
var sessionId = response.headers.get('x-hermes-session-id') || response.headers.get('x-session-id');
|
||||
var level = normalizeCrisisLevel(response.headers.get('x-hermes-crisis-level') || response.headers.get('x-crisis-level'));
|
||||
var escalationRaw = response.headers.get('x-hermes-crisis-escalation') || response.headers.get('x-crisis-escalation');
|
||||
var historyRaw = response.headers.get('x-hermes-crisis-history') || response.headers.get('x-crisis-history');
|
||||
var history = [];
|
||||
|
||||
if (historyRaw) {
|
||||
try {
|
||||
var decoded = decodeURIComponent(historyRaw);
|
||||
var parsed = JSON.parse(decoded);
|
||||
if (Array.isArray(parsed)) history = parsed;
|
||||
} catch (e) {
|
||||
try {
|
||||
var fallbackParsed = JSON.parse(historyRaw);
|
||||
if (Array.isArray(fallbackParsed)) history = fallbackParsed;
|
||||
} catch (ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: sessionId,
|
||||
level: level,
|
||||
escalated: escalationRaw === '1' || escalationRaw === 'true' || escalationRaw === 'yes',
|
||||
history: history
|
||||
};
|
||||
}
|
||||
|
||||
function mergeGatewayCrisisHistory(gatewayHistory) {
|
||||
if (!Array.isArray(gatewayHistory)) return;
|
||||
gatewayHistory.forEach(function(entry) {
|
||||
appendCrisisHistoryEvent(
|
||||
entry.source || 'hermes-agent',
|
||||
entry.level || 'NONE',
|
||||
entry.kind || 'gateway',
|
||||
entry.detail || 'Hermes session tracker update',
|
||||
entry.at || entry.time || Date.now()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function resetSessionCrisis() {
|
||||
sessionCrisis = defaultSessionCrisisState();
|
||||
sessionCrisis.sessionId = getOrCreateCrisisSessionId();
|
||||
saveSessionCrisisState();
|
||||
renderCrisisSessionStatus();
|
||||
}
|
||||
|
||||
// ===== GET SYSTEM PROMPT (wraps with crisis context) =====
|
||||
function getSystemPrompt(userText) {
|
||||
var level = getCrisisLevel(userText);
|
||||
@@ -1548,9 +1293,6 @@ Sovereignty and service always.`;
|
||||
clearChatBtn.addEventListener('click', function() {
|
||||
if (confirm('Clear all chat history?')) {
|
||||
localStorage.removeItem('timmy_chat_history');
|
||||
localStorage.removeItem('timmy_crisis_session');
|
||||
localStorage.removeItem('timmy_crisis_history');
|
||||
resetSessionCrisis();
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
@@ -1712,9 +1454,8 @@ Sovereignty and service always.`;
|
||||
|
||||
addMessage('user', text);
|
||||
messages.push({ role: 'user', content: text });
|
||||
lastUserMessage = text;
|
||||
var lastUserMessage = text;
|
||||
|
||||
trackCrisis(text, 'user');
|
||||
checkCrisis(text);
|
||||
|
||||
msgInput.value = '';
|
||||
@@ -1741,18 +1482,7 @@ Sovereignty and service always.`;
|
||||
body: JSON.stringify({
|
||||
model: 'timmy',
|
||||
messages: allMessages,
|
||||
stream: true,
|
||||
metadata: {
|
||||
session_id: getOrCreateCrisisSessionId(),
|
||||
source: 'the-door',
|
||||
crisis_history: sessionCrisis.history.slice(-8),
|
||||
crisis_state: {
|
||||
current_level: sessionCrisis.currentLevel,
|
||||
peak_level: sessionCrisis.peakLevel,
|
||||
message_count: sessionCrisis.messageCount,
|
||||
gateway_escalated: sessionCrisis.gatewayEscalated
|
||||
}
|
||||
}
|
||||
stream: true
|
||||
}),
|
||||
signal: controller.signal
|
||||
}).then(function(response) {
|
||||
@@ -1762,22 +1492,6 @@ Sovereignty and service always.`;
|
||||
throw new Error('HTTP ' + response.status);
|
||||
}
|
||||
|
||||
var gatewayCrisis = parseGatewayCrisisHeaders(response);
|
||||
if (gatewayCrisis.sessionId) {
|
||||
sessionCrisis.sessionId = gatewayCrisis.sessionId;
|
||||
try {
|
||||
localStorage.setItem(CRISIS_SESSION_KEY, gatewayCrisis.sessionId);
|
||||
} catch (e) {}
|
||||
}
|
||||
sessionCrisis.gatewayEscalated = !!gatewayCrisis.escalated;
|
||||
if (gatewayCrisis.level && gatewayCrisis.level !== 'NONE') {
|
||||
appendCrisisHistoryEvent('hermes-agent', gatewayCrisis.level, 'gateway', 'Hermes session tracker update', Date.now());
|
||||
}
|
||||
var gatewayHistory = gatewayCrisis.history || [];
|
||||
mergeGatewayCrisisHistory(gatewayHistory);
|
||||
saveSessionCrisisState();
|
||||
renderCrisisSessionStatus();
|
||||
|
||||
hideTyping();
|
||||
var contentEl = addMessage('assistant', '', true);
|
||||
var fullText = '';
|
||||
@@ -1830,7 +1544,6 @@ Sovereignty and service always.`;
|
||||
if (fullText) {
|
||||
messages.push({ role: 'assistant', content: fullText });
|
||||
saveMessages();
|
||||
trackCrisis(fullText, 'assistant');
|
||||
checkCrisis(fullText);
|
||||
}
|
||||
isStreaming = false;
|
||||
@@ -1861,9 +1574,6 @@ Sovereignty and service always.`;
|
||||
|
||||
// ===== WELCOME MESSAGE =====
|
||||
function init() {
|
||||
sessionCrisis.sessionId = getOrCreateCrisisSessionId();
|
||||
renderCrisisSessionStatus();
|
||||
|
||||
if (!loadMessages()) {
|
||||
var welcomeText = "Hey. I'm Timmy. I'm here if you want to talk. No judgment, no login, no tracking. Just us.";
|
||||
addMessage('assistant', welcomeText);
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
HTML = Path("index.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_index_contains_crisis_session_bridge_ui_and_hooks():
|
||||
assert 'id="crisis-session-status"' in HTML
|
||||
assert 'id="crisis-history-list"' in HTML
|
||||
assert 'function getOrCreateCrisisSessionId()' in HTML
|
||||
assert 'function appendCrisisHistoryEvent(' in HTML
|
||||
assert 'function renderCrisisSessionStatus()' in HTML
|
||||
assert 'function parseGatewayCrisisHeaders(' in HTML
|
||||
assert 'function mergeGatewayCrisisHistory(' in HTML
|
||||
|
||||
|
||||
def test_request_includes_metadata_for_hermes_session_tracking():
|
||||
assert 'metadata:' in HTML
|
||||
assert "session_id: getOrCreateCrisisSessionId()" in HTML
|
||||
assert "source: 'the-door'" in HTML
|
||||
assert 'crisis_history:' in HTML
|
||||
assert 'crisis_state:' in HTML
|
||||
|
||||
|
||||
def test_clear_chat_resets_crisis_bridge_state():
|
||||
assert "localStorage.removeItem('timmy_chat_history');" in HTML
|
||||
assert "localStorage.removeItem('timmy_crisis_session');" in HTML
|
||||
assert "localStorage.removeItem('timmy_crisis_history');" in HTML
|
||||
assert 'resetSessionCrisis();' in HTML
|
||||
|
||||
|
||||
def test_gateway_escalations_are_merged_back_into_the_ui():
|
||||
assert "response.headers.get('x-hermes-session-id')" in HTML
|
||||
assert "response.headers.get('x-hermes-crisis-level')" in HTML
|
||||
assert "response.headers.get('x-hermes-crisis-history')" in HTML
|
||||
assert 'mergeGatewayCrisisHistory(gatewayHistory);' in HTML
|
||||
assert 'renderCrisisSessionStatus();' in HTML
|
||||
Reference in New Issue
Block a user