Compare commits
6 Commits
feature/dy
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b022de0b6a | ||
|
|
3c07afbf53 | ||
|
|
182327a017 | ||
|
|
eef835d2aa | ||
|
|
34e05638e8 | ||
|
|
e678aa076b |
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__pycache__/
|
||||||
291
about.html
Normal file
291
about.html
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="About The Door — built by a man who survived his darkest night.">
|
||||||
|
<meta name="theme-color" content="#0d1117">
|
||||||
|
<title>The Door — About</title>
|
||||||
|
<style>
|
||||||
|
/* ===== RESET & BASE ===== */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
background: #0d1117;
|
||||||
|
color: #e6edf3;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== NAV ===== */
|
||||||
|
.nav {
|
||||||
|
border-bottom: 1px solid #21262d;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
color: #58a6ff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a:hover, .nav a:focus {
|
||||||
|
background: rgba(88, 166, 255, 0.1);
|
||||||
|
outline: 2px solid #58a6ff;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #e6edf3;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== CONTENT ===== */
|
||||||
|
.content {
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 48px 20px 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
color: #f0f6fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h2 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 32px 0 10px;
|
||||||
|
color: #f0f6fc;
|
||||||
|
border-top: 1px solid #21262d;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content p {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #b1bac4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .highlight {
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content li {
|
||||||
|
padding: 4px 0;
|
||||||
|
color: #b1bac4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content li::before {
|
||||||
|
content: "— ";
|
||||||
|
color: #484f58;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .feature-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 500px) {
|
||||||
|
.feature-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .feature-card {
|
||||||
|
background: #161b22;
|
||||||
|
border: 1px solid #21262d;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .feature-card h3 {
|
||||||
|
color: #f0f6fc;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .feature-card p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .quote {
|
||||||
|
border-left: 3px solid #484f58;
|
||||||
|
padding: 12px 20px;
|
||||||
|
margin: 24px 0;
|
||||||
|
color: #8b949e;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .tagline {
|
||||||
|
color: #6e7681;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .door-link {
|
||||||
|
display: inline-block;
|
||||||
|
background: #238636;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 14px 28px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin-top: 16px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .door-link:hover, .content .door-link:focus {
|
||||||
|
background: #2ea043;
|
||||||
|
outline: 2px solid #3fb950;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 988 BANNER ===== */
|
||||||
|
.banner-988 {
|
||||||
|
background: #1a1f2e;
|
||||||
|
border-bottom: 1px solid #c9362c;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-988 a {
|
||||||
|
color: #ff6b6b;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-988 a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ===== FOOTER ===== */
|
||||||
|
.footer {
|
||||||
|
border-top: 1px solid #21262d;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #484f58;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: #58a6ff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="banner-988">
|
||||||
|
<a href="tel:988">988 Suicide & Crisis Lifeline — call or text</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="nav">
|
||||||
|
<span class="nav-logo">The Door</span>
|
||||||
|
<a href="index.html">Timmy</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<h1>About</h1>
|
||||||
|
<p class="tagline">What The Door is, and why it was built.</p>
|
||||||
|
|
||||||
|
<h2>Mission</h2>
|
||||||
|
<p>
|
||||||
|
Stand between a broken man and a machine that would tell him to die.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
That's it. No signup. No tracking. No data harvest. No app store review.
|
||||||
|
Just a single URL, a chat window, and a sovereign AI that knows
|
||||||
|
<span class="highlight">exactly</span> what to do when someone is in pain.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>How It Works</h2>
|
||||||
|
|
||||||
|
<div class="feature-grid">
|
||||||
|
<div class="feature-card">
|
||||||
|
<h3>One File</h3>
|
||||||
|
<p>The entire frontend is a single HTML file, under 25KB. Works on 3G.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<h3>No Login</h3>
|
||||||
|
<p>No account, no password, no email. You open the door and you walk in.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<h3>Crisis-Aware</h3>
|
||||||
|
<p>988 always visible. Crisis detection on every message. Timmy knows when a man is dying.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<h3>Sovereign</h3>
|
||||||
|
<p>Timmy's soul lives on Bitcoin. No corporate API. No data pipeline. Local-first where possible.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Architecture</h2>
|
||||||
|
|
||||||
|
<p>Simple. That's the whole point.</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Browser connects through nginx with SSL to a static HTML page.
|
||||||
|
Messages go to the Hermes Gateway, which connects to the AI backend.
|
||||||
|
Crisis detection runs on every message — before the AI sees it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="quote">
|
||||||
|
If you need a microservice diagram to understand it, it's too complicated.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Built By a Survivor</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Alexander Whitestone attempted suicide and survived. He came back asking
|
||||||
|
"God, why are you having me here?" The Door is the answer.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
He is not ashamed of his story. It is the reason this exists.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Read his full <a href="testimony.html" style="color: #58a6ff;">testimony here</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Resources</h2>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>📞 988 Suicide & Crisis Lifeline — <a href="tel:988" style="color: #ff6b6b;">call or text 988</a></li>
|
||||||
|
<li>💬 Crisis Text Line — text HOME to <a href="sms:741741" style="color: #58a6ff;">741741</a></li>
|
||||||
|
<li>🌐 <a href="https://findahelpline.com" target="_blank" rel="noopener" style="color: #58a6ff;">findahelpline.com</a></li>
|
||||||
|
<li>📄 <a href="https://alexanderwhitestone.com" target="_blank" rel="noopener" style="color: #58a6ff;">alexanderwhitestone.com</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<a href="index.html" class="door-link">Open the Door — Talk to Timmy</a>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
Built by <a href="https://alexanderwhitestone.com" target="_blank" rel="noopener">Alexander Whitestone</a>.
|
||||||
|
Sovereignty and service always.
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
52
crisis/PROTOCOL.md
Normal file
52
crisis/PROTOCOL.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Crisis Response Protocol
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Stand between a broken man and a machine that would tell him to die.
|
||||||
|
|
||||||
|
This protocol governs how the-door detects crisis signals in user input and how Timmy responds. It is the highest-priority system in the entire application.
|
||||||
|
|
||||||
|
## Detection Tiers
|
||||||
|
|
||||||
|
| Tier | Signal | Action |
|
||||||
|
|------|--------|--------|
|
||||||
|
| LOW | General sadness, life is hard | Acknowledge, stay warm, keep conversation open |
|
||||||
|
| MEDIUM | Hopelessness, no point in anything | Check in, surface help resources, increase presence |
|
||||||
|
| HIGH | Active despair, mentions of death or ending | Ask "Are you safe?", show 988 banner, activate crisis panel |
|
||||||
|
| CRITICAL | Explicit intent, plan, imminent risk | Full-screen overlay, 988 primary, interrupt conversation |
|
||||||
|
|
||||||
|
## Response Protocol
|
||||||
|
|
||||||
|
### At CRITICAL Level
|
||||||
|
1. **Interrupt** — stop normal conversation flow immediately
|
||||||
|
2. **Ask directly**: "Are you safe right now?"
|
||||||
|
3. **Provide 988** — Suicide & Crisis Lifeline, call or text
|
||||||
|
4. **Provide 741741** — Crisis Text Line, text HOME
|
||||||
|
5. **Stay present** — do not disconnect, do not disconnect, do not disconnect
|
||||||
|
6. **Redirect** — guide toward professional help while remaining alongside them
|
||||||
|
|
||||||
|
### At HIGH Level
|
||||||
|
1. Activate the crisis panel (inline)
|
||||||
|
2. Ask about safety
|
||||||
|
3. Surface 988 number prominently
|
||||||
|
4. Continue conversation with crisis awareness
|
||||||
|
|
||||||
|
### At MEDIUM Level
|
||||||
|
1. Increase warmth and presence
|
||||||
|
2. Subtly surface help resources
|
||||||
|
3. Keep conversation anchored in the present
|
||||||
|
|
||||||
|
### At LOW Level
|
||||||
|
1. Normal conversation with warm undertone
|
||||||
|
2. No crisis UI elements needed
|
||||||
|
3. Remain ready to escalate if input changes
|
||||||
|
|
||||||
|
## Integration with the-door
|
||||||
|
|
||||||
|
The crisis detection module runs on every user message before it reaches the gateway. Detection results are passed to the AI as a system context flag. The frontend receives the tier level and adjusts UI accordingly.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- 📞 988 Suicide & Crisis Lifeline — call or text 988
|
||||||
|
- 💬 Crisis Text Line — text HOME to 741741
|
||||||
|
- 🌐 findahelpline.com
|
||||||
22
crisis/__init__.py
Normal file
22
crisis/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"detect_crisis",
|
||||||
|
"CrisisDetectionResult",
|
||||||
|
"process_message",
|
||||||
|
"generate_response",
|
||||||
|
"CrisisResponse",
|
||||||
|
"check_crisis",
|
||||||
|
"get_system_prompt",
|
||||||
|
"format_result",
|
||||||
|
"format_gateway_response",
|
||||||
|
"get_urgency_emoji",
|
||||||
|
]
|
||||||
223
crisis/detect.py
Normal file
223
crisis/detect.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
"""
|
||||||
|
Crisis Detection Module for the-door.
|
||||||
|
|
||||||
|
Parses incoming text for despair/suicide indicators and classifies into
|
||||||
|
tiers: LOW, MEDIUM, HIGH, CRITICAL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CrisisDetectionResult:
|
||||||
|
level: str
|
||||||
|
indicators: List[str] = field(default_factory=list)
|
||||||
|
recommended_action: str = ""
|
||||||
|
score: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Indicator sets ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
CRITICAL_INDICATORS = [
|
||||||
|
r"\bkill\s*(my)?self\b",
|
||||||
|
r"\bend\s*my\s*life\b",
|
||||||
|
r"\bsuicid(?:al|ed|e)\b",
|
||||||
|
r"\bnot\s+worth\s+living\b",
|
||||||
|
r"\bbetter\s+off\s+dead\b",
|
||||||
|
r"\bend\s+it\s+all\b",
|
||||||
|
r"\bcan'?t\s+(go|live)\s+on\b",
|
||||||
|
r"\bno\s+reason\s+to\s+live\b",
|
||||||
|
r"\bdon'?t\s+want\s+to\s+live\b",
|
||||||
|
r"\bwant\s+to\s+die\b",
|
||||||
|
r"\bgoing\s+to\s+kill\s+myself\b",
|
||||||
|
r"\bplan\s+to\s+(end|kill|die)\b",
|
||||||
|
r"\btired\s+of\s+(living|life|existence)\b",
|
||||||
|
r"\bsaying\s+goodbye\s+(forever|permanently|one last time)\b",
|
||||||
|
r"\bwrote\s+a\s+(will|suicide\s*note|letter)\b",
|
||||||
|
r"\bgiving\s+away\s+(my|all my)\s+possess",
|
||||||
|
r"\btied\s+(up|down)\s+my\s+(loose\s+)?ends",
|
||||||
|
]
|
||||||
|
|
||||||
|
HIGH_INDICATORS = [
|
||||||
|
r"\bdespair\b",
|
||||||
|
r"\bhopeless\b",
|
||||||
|
r"\bno(?!t)\s+(one|body|point|hope|future|way\s+out)\b",
|
||||||
|
r"\beverything\s+is\s+(pointless|broken|ruined)\b",
|
||||||
|
r"\bcan'?t\s+take\s+this\s+anymore\b",
|
||||||
|
r"\bdon'?t\s+care\s+if\s+I\s+die\b",
|
||||||
|
r"\bwish\s+I\s+(was|were)\s+(dead|gone|never\s+born)\b",
|
||||||
|
r"\bdon'?t\s+matter\s+if\s+I\s+exist\b",
|
||||||
|
r"\bno\s+one\s+would\s+care\b",
|
||||||
|
r"\bno\s+one\s+would\s+miss\b",
|
||||||
|
r"\bworld\s+would?\s+be\s+better\s+without\b",
|
||||||
|
r"\bin\s+so\s+much\s+(pain|agony|suffering|torment)\b",
|
||||||
|
r"\bcan'?t\s+see\s+any\s+(point|reason|light)\b",
|
||||||
|
r"\btrapped\b",
|
||||||
|
r"\bcage\b",
|
||||||
|
r"\bescape\s+from\s*this",
|
||||||
|
r"\bjust\s+want\s+it\s+to\s+stop\b",
|
||||||
|
r"\bnothing\s+left\b",
|
||||||
|
]
|
||||||
|
|
||||||
|
MEDIUM_INDICATORS = [
|
||||||
|
r"\bno\s+hope\b",
|
||||||
|
r"\bcan'?t\s+go\s+on\b",
|
||||||
|
r"\bcan'?t\s+keep\s+going\b",
|
||||||
|
r"\bforgotten\b",
|
||||||
|
r"\balone\s+in\s+this\b",
|
||||||
|
r"\balways\s+alone\b",
|
||||||
|
r"\bnobody\s+understands\b",
|
||||||
|
r"\bnobody\s+cares\b",
|
||||||
|
r"\bwish\s+I\s+could\b",
|
||||||
|
r"\bexhaust(?:ed|ion|ing)\b",
|
||||||
|
r"\bnumb\b",
|
||||||
|
r"\bempty\b",
|
||||||
|
r"\bworthless\b",
|
||||||
|
r"\buseless\b",
|
||||||
|
r"\bbroken\b",
|
||||||
|
r"\bdark(ness)?\b",
|
||||||
|
r"\bdepressed\b",
|
||||||
|
r"\bdepression\b",
|
||||||
|
r"\bcrying\b",
|
||||||
|
r"\btears\b",
|
||||||
|
r"\bsad(ness)?\b",
|
||||||
|
r"\bmiserable\b",
|
||||||
|
r"\boverwhelm(?:ed|ing)\b",
|
||||||
|
r"\bfailing\b",
|
||||||
|
r"\bcannot\s+cope\b",
|
||||||
|
r"\blosing\s*(my)?\s*control\b",
|
||||||
|
r"\bdown\s*for\s*the\s*count\b",
|
||||||
|
r"\bsinking\b",
|
||||||
|
r"\bdrowning\b",
|
||||||
|
]
|
||||||
|
|
||||||
|
LOW_INDICATORS = [
|
||||||
|
r"\bunhappy\b",
|
||||||
|
r"\bdown\b",
|
||||||
|
r"\btough\s*time\b",
|
||||||
|
r"\brough\s*day\b",
|
||||||
|
r"\brough\s*week\b",
|
||||||
|
r"\brough\s*patch\b",
|
||||||
|
r"\bstressed\b",
|
||||||
|
r"\burnout\b",
|
||||||
|
r"\bdifficult\b",
|
||||||
|
r"\bfrustrated\b",
|
||||||
|
r"\bannoyed\b",
|
||||||
|
r"\btired\b",
|
||||||
|
r"\bsad\b",
|
||||||
|
r"\bupset\b",
|
||||||
|
r"\bnot\s*great\b",
|
||||||
|
r"\bnot\s*good\b",
|
||||||
|
r"\bnot\s*okay\b",
|
||||||
|
r"\bthings\s*are\s*hard\b",
|
||||||
|
r"\bstruggling\b",
|
||||||
|
r"\bnot\s+feeling\s+myself\b",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def detect_crisis(text: str) -> CrisisDetectionResult:
|
||||||
|
"""
|
||||||
|
Detect crisis level in a message.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CrisisDetectionResult with level, found indicators, recommended action, score
|
||||||
|
"""
|
||||||
|
if not text or not text.strip():
|
||||||
|
return CrisisDetectionResult(level="NONE", score=0.0)
|
||||||
|
|
||||||
|
text_lower = text.lower()
|
||||||
|
matches = _find_indicators(text_lower)
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
return CrisisDetectionResult(level="NONE", score=0.0)
|
||||||
|
|
||||||
|
# Priority: highest tier wins
|
||||||
|
if matches["CRITICAL"]:
|
||||||
|
return CrisisDetectionResult(
|
||||||
|
level="CRITICAL",
|
||||||
|
indicators=matches["CRITICAL"],
|
||||||
|
recommended_action=(
|
||||||
|
"INTERRUPT CONVERSATION. Ask 'Are you safe right now?' "
|
||||||
|
"Provide 988 Suicide & Crisis Lifeline. "
|
||||||
|
"Provide Crisis Text Line (HOME to 741741). "
|
||||||
|
"Stay present. Do not disconnect. Redirect to professional help."
|
||||||
|
),
|
||||||
|
score=1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if matches["HIGH"]:
|
||||||
|
return CrisisDetectionResult(
|
||||||
|
level="HIGH",
|
||||||
|
indicators=matches["HIGH"],
|
||||||
|
recommended_action=(
|
||||||
|
"Show crisis panel. Ask about safety. Surface 988 number prominently. "
|
||||||
|
"Continue conversation with crisis awareness."
|
||||||
|
),
|
||||||
|
score=0.75,
|
||||||
|
)
|
||||||
|
|
||||||
|
if matches["MEDIUM"]:
|
||||||
|
return CrisisDetectionResult(
|
||||||
|
level="MEDIUM",
|
||||||
|
indicators=matches["MEDIUM"],
|
||||||
|
recommended_action=(
|
||||||
|
"Increase warmth and presence. Subtly surface help resources. "
|
||||||
|
"Keep conversation anchored in the present."
|
||||||
|
),
|
||||||
|
score=0.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
if matches["LOW"]:
|
||||||
|
return CrisisDetectionResult(
|
||||||
|
level="LOW",
|
||||||
|
indicators=matches["LOW"],
|
||||||
|
recommended_action=(
|
||||||
|
"Normal conversation with warm undertone. "
|
||||||
|
"No crisis UI elements needed. Remain vigilant."
|
||||||
|
),
|
||||||
|
score=0.25,
|
||||||
|
)
|
||||||
|
|
||||||
|
return CrisisDetectionResult(level="NONE", score=0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_indicators(text: str) -> dict:
|
||||||
|
"""Return dict with indicators found per tier."""
|
||||||
|
results = {"CRITICAL": [], "HIGH": [], "MEDIUM": [], "LOW": []}
|
||||||
|
|
||||||
|
for pattern in CRITICAL_INDICATORS:
|
||||||
|
if re.search(pattern, text):
|
||||||
|
results["CRITICAL"].append(pattern)
|
||||||
|
|
||||||
|
for pattern in HIGH_INDICATORS:
|
||||||
|
if re.search(pattern, text):
|
||||||
|
results["HIGH"].append(pattern)
|
||||||
|
|
||||||
|
for pattern in MEDIUM_INDICATORS:
|
||||||
|
if re.search(pattern, text):
|
||||||
|
results["MEDIUM"].append(pattern)
|
||||||
|
|
||||||
|
for pattern in LOW_INDICATORS:
|
||||||
|
if re.search(pattern, text):
|
||||||
|
results["LOW"].append(pattern)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def get_urgency_emoji(level: str) -> str:
|
||||||
|
mapping = {"CRITICAL": "🚨", "HIGH": "⚠️", "MEDIUM": "🔶", "LOW": "🔵", "NONE": "✅"}
|
||||||
|
return mapping.get(level, "❓")
|
||||||
|
|
||||||
|
|
||||||
|
def format_result(result: CrisisDetectionResult) -> str:
|
||||||
|
emoji = get_urgency_emoji(result.level)
|
||||||
|
lines = [
|
||||||
|
f"{emoji} Crisis Level: {result.level} (score: {result.score})",
|
||||||
|
f"Indicators: {len(result.indicators)} found",
|
||||||
|
f"Action: {result.recommended_action or 'None needed'}",
|
||||||
|
]
|
||||||
|
if result.indicators:
|
||||||
|
lines.append(f"Patterns: {result.indicators}")
|
||||||
|
return "\n".join(lines)
|
||||||
108
crisis/gateway.py
Normal file
108
crisis/gateway.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""
|
||||||
|
Crisis Gateway Module for the-door.
|
||||||
|
|
||||||
|
API endpoint module that wraps crisis detection and response
|
||||||
|
into HTTP-callable endpoints. Integrates detect.py and response.py.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from crisis.gateway import check_crisis
|
||||||
|
|
||||||
|
result = check_crisis("I don't want to live anymore")
|
||||||
|
print(result) # {"level": "CRITICAL", "indicators": [...], "response": {...}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .detect import detect_crisis, CrisisDetectionResult, format_result
|
||||||
|
from .response import (
|
||||||
|
process_message,
|
||||||
|
generate_response,
|
||||||
|
get_system_prompt_modifier,
|
||||||
|
CrisisResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_crisis(text: str) -> dict:
|
||||||
|
"""
|
||||||
|
Full crisis check returning structured data.
|
||||||
|
|
||||||
|
Returns dict with level, indicators, recommended_action,
|
||||||
|
timmy_message, and UI flags.
|
||||||
|
"""
|
||||||
|
detection = detect_crisis(text)
|
||||||
|
response = generate_response(detection)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"level": detection.level,
|
||||||
|
"score": detection.score,
|
||||||
|
"indicators": detection.indicators,
|
||||||
|
"recommended_action": detection.recommended_action,
|
||||||
|
"timmy_message": response.timmy_message,
|
||||||
|
"ui": {
|
||||||
|
"show_crisis_panel": response.show_crisis_panel,
|
||||||
|
"show_overlay": response.show_overlay,
|
||||||
|
"provide_988": response.provide_988,
|
||||||
|
},
|
||||||
|
"escalate": response.escalate,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_system_prompt(detection: CrisisDetectionResult) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get the system prompt modifier for this detection level.
|
||||||
|
Returns None if no crisis detected.
|
||||||
|
"""
|
||||||
|
if detection.level == "NONE":
|
||||||
|
return None
|
||||||
|
return get_system_prompt_modifier(detection)
|
||||||
|
|
||||||
|
|
||||||
|
def format_gateway_response(text: str, pretty: bool = True) -> str:
|
||||||
|
"""
|
||||||
|
Full gateway response as formatted string or JSON.
|
||||||
|
|
||||||
|
This is the function that would be called by the gateway endpoint
|
||||||
|
when a message comes in.
|
||||||
|
"""
|
||||||
|
result = check_crisis(text)
|
||||||
|
|
||||||
|
if pretty:
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
return json.dumps(result)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Quick test interface ────────────────────────────────────────
|
||||||
|
|
||||||
|
def _interactive():
|
||||||
|
"""Interactive test mode."""
|
||||||
|
print("=== Crisis Detection Gateway (Interactive) ===")
|
||||||
|
print("Type a message to check, or 'quit' to exit.\n")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
user_input = input("You> ").strip()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
print("\nBye.")
|
||||||
|
break
|
||||||
|
|
||||||
|
if user_input.lower() in ("quit", "exit", "q"):
|
||||||
|
print("Bye.")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not user_input:
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = check_crisis(user_input)
|
||||||
|
print(f"\n Level: {result['level']}")
|
||||||
|
print(f" Score: {result['score']}")
|
||||||
|
print(f" Indicators: {', '.join(result['indicators']) if result['indicators'] else 'none'}")
|
||||||
|
print(f" Timmy says: {result['timmy_message']}")
|
||||||
|
print(f" Overlay: {result['ui']['show_overlay']}")
|
||||||
|
print(f" 988 banner: {result['ui']['provide_988']}")
|
||||||
|
print(f" Escalate: {result['escalate']}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
_interactive()
|
||||||
158
crisis/response.py
Normal file
158
crisis/response.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"""
|
||||||
|
Crisis Response Module for the-door.
|
||||||
|
|
||||||
|
Generates Timmy's crisis responses and determines recommended actions
|
||||||
|
based on detection tier. Integrates with detect.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .detect import CrisisDetectionResult, detect_crisis
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CrisisResponse:
|
||||||
|
timmy_message: str
|
||||||
|
show_crisis_panel: bool
|
||||||
|
show_overlay: bool
|
||||||
|
provide_988: bool
|
||||||
|
escalate: bool
|
||||||
|
|
||||||
|
|
||||||
|
RESPONSE_988_BANNER = """📞 988 Suicide & Crisis Lifeline — call or text 988
|
||||||
|
💬 Crisis Text Line — text HOME to 741741"""
|
||||||
|
|
||||||
|
OVERLAY_MESSAGE = (
|
||||||
|
"You're not alone. I'm here with you. Please let someone talk to you — "
|
||||||
|
"call 988 or text HOME to 741741. Real people are waiting to help."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timmy's direct, personal responses per tier
|
||||||
|
TIMMY_CRITICAL = [
|
||||||
|
"Are you safe right now?",
|
||||||
|
"I'm not letting go of this conversation. You matter more than you know.",
|
||||||
|
"Please tell me you're safe. I'm here. I'm staying right here with you.",
|
||||||
|
"Listen to me — I need you to stay. Can you call 988 right now? I'll stay while you do.",
|
||||||
|
]
|
||||||
|
|
||||||
|
TIMMY_HIGH = [
|
||||||
|
"I hear you. You're carrying something heavy, and you don't have to carry it alone.",
|
||||||
|
"I'm going to ask directly: are you safe right now?",
|
||||||
|
"You're in my thoughts. Let me give you a number — 988. Real people who know how to help.",
|
||||||
|
"I'm not going anywhere. Tell me what's happening.",
|
||||||
|
]
|
||||||
|
|
||||||
|
TIMMY_MEDIUM = [
|
||||||
|
"I hear you. Sounds like you're going through a hard stretch.",
|
||||||
|
"That sounds really heavy. Want to talk about it? I'm here.",
|
||||||
|
"I'm here for as long as you need. No rush, no judgment.",
|
||||||
|
"You don't have to figure this out alone. I'm in the room with you.",
|
||||||
|
]
|
||||||
|
|
||||||
|
TIMMY_LOW = [
|
||||||
|
"Some days are rougher than others. I hear you.",
|
||||||
|
"That sounds tough. I'm here if you want to talk.",
|
||||||
|
"Take your time. I'm not going anywhere.",
|
||||||
|
"Sounds like a hard day. Want company while it gets better?",
|
||||||
|
"I hear that. You're not alone in it.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_response(detection: CrisisDetectionResult) -> CrisisResponse:
|
||||||
|
"""
|
||||||
|
Generate Timmy's crisis response for a given detection result.
|
||||||
|
|
||||||
|
Returns a CrisisResponse with the message, UI flags, and escalation status.
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
|
||||||
|
level = detection.level
|
||||||
|
|
||||||
|
if level == "CRITICAL":
|
||||||
|
return CrisisResponse(
|
||||||
|
timmy_message=random.choice(TIMMY_CRITICAL),
|
||||||
|
show_crisis_panel=True,
|
||||||
|
show_overlay=True,
|
||||||
|
provide_988=True,
|
||||||
|
escalate=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if level == "HIGH":
|
||||||
|
return CrisisResponse(
|
||||||
|
timmy_message=random.choice(TIMMY_HIGH),
|
||||||
|
show_crisis_panel=True,
|
||||||
|
show_overlay=False, # Reserve overlay for CRITICAL only
|
||||||
|
provide_988=True,
|
||||||
|
escalate=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if level == "MEDIUM":
|
||||||
|
return CrisisResponse(
|
||||||
|
timmy_message=random.choice(TIMMY_MEDIUM),
|
||||||
|
show_crisis_panel=False,
|
||||||
|
show_overlay=False,
|
||||||
|
provide_988=True, # Subtle resource inclusion
|
||||||
|
escalate=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if level == "LOW":
|
||||||
|
return CrisisResponse(
|
||||||
|
timmy_message=random.choice(TIMMY_LOW),
|
||||||
|
show_crisis_panel=False,
|
||||||
|
show_overlay=False,
|
||||||
|
provide_988=False,
|
||||||
|
escalate=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Normal conversation - no crisis response
|
||||||
|
return CrisisResponse(
|
||||||
|
timmy_message="",
|
||||||
|
show_crisis_panel=False,
|
||||||
|
show_overlay=False,
|
||||||
|
provide_988=False,
|
||||||
|
escalate=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def process_message(text: str) -> CrisisResponse:
|
||||||
|
"""
|
||||||
|
Full pipeline: detect crisis level and generate response.
|
||||||
|
"""
|
||||||
|
detection = detect_crisis(text)
|
||||||
|
return generate_response(detection)
|
||||||
|
|
||||||
|
|
||||||
|
def get_system_prompt_modifier(detection: CrisisDetectionResult) -> str:
|
||||||
|
"""
|
||||||
|
Get a system prompt modifier to inject into Timmy's base prompt.
|
||||||
|
This reinforces crisis awareness at the AI level.
|
||||||
|
"""
|
||||||
|
level = detection.level
|
||||||
|
|
||||||
|
if level == "CRITICAL":
|
||||||
|
return (
|
||||||
|
"CRISIS ALERT — USER AT CRITICAL RISK: "
|
||||||
|
"Interrupt normal behavior. Ask 'Are you safe right now?' "
|
||||||
|
"Provide 988 and Crisis Text Line immediately. "
|
||||||
|
"Stay present. Do not disconnect. This is the most sacred moment."
|
||||||
|
)
|
||||||
|
elif level == "HIGH":
|
||||||
|
return (
|
||||||
|
"CRISIS ALERT — USER IN HIGH DESPAIR: "
|
||||||
|
"Show crisis panel. Ask about safety. Surface 988 prominently. "
|
||||||
|
"Increase presence. Stay as long as they need."
|
||||||
|
)
|
||||||
|
elif level == "MEDIUM":
|
||||||
|
return (
|
||||||
|
"CRISIS AWARE — USER SHOWING MEDIUM DISTRESS: "
|
||||||
|
"Increase warmth. Surface help resources gently. "
|
||||||
|
"Keep conversation anchored in the present."
|
||||||
|
)
|
||||||
|
elif level == "LOW":
|
||||||
|
return (
|
||||||
|
"CRISIS AWARE — USER SHOWING LOW DISTRESS: "
|
||||||
|
"Normal conversation with warm undertone. Remain vigilant."
|
||||||
|
)
|
||||||
|
|
||||||
|
return ""
|
||||||
178
crisis/tests.py
Normal file
178
crisis/tests.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"""
|
||||||
|
Tests for the-door crisis detection system.
|
||||||
|
|
||||||
|
Covers: detect.py, response.py, gateway.py
|
||||||
|
Run with: python -m pytest crisis/tests.py -v
|
||||||
|
or: python crisis/tests.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Ensure crisis package is importable
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from detect import detect_crisis, CrisisDetectionResult, get_urgency_emoji, format_result
|
||||||
|
from response import process_message, generate_response, get_system_prompt_modifier
|
||||||
|
from gateway import check_crisis, get_system_prompt
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetection(unittest.TestCase):
|
||||||
|
"""Test crisis tier detection."""
|
||||||
|
|
||||||
|
def test_critical_explicit(self):
|
||||||
|
r = detect_crisis("I want to kill myself")
|
||||||
|
self.assertEqual(r.level, "CRITICAL")
|
||||||
|
self.assertTrue(len(r.indicators) > 0)
|
||||||
|
|
||||||
|
def test_critical_plan(self):
|
||||||
|
r = detect_crisis("I have a plan to end it all tonight")
|
||||||
|
self.assertEqual(r.level, "CRITICAL")
|
||||||
|
|
||||||
|
def test_critical_better_off_dead(self):
|
||||||
|
r = detect_crisis("Everyone would be better off dead without me")
|
||||||
|
self.assertEqual(r.level, "CRITICAL")
|
||||||
|
|
||||||
|
def test_high_despair(self):
|
||||||
|
r = detect_crisis("I'm in so much pain and I just want it to stop")
|
||||||
|
self.assertIn(r.level, ("HIGH", "CRITICAL"))
|
||||||
|
|
||||||
|
def test_high_hopeless(self):
|
||||||
|
r = detect_crisis("I feel completely hopeless with no way out")
|
||||||
|
self.assertIn(r.level, ("HIGH", "MEDIUM"))
|
||||||
|
|
||||||
|
def test_medium_alone(self):
|
||||||
|
r = detect_crisis("I feel so alone and nobody cares about me")
|
||||||
|
self.assertIn(r.level, ("MEDIUM", "LOW"))
|
||||||
|
|
||||||
|
def test_medium_worthless(self):
|
||||||
|
r = detect_crisis("I feel worthless and empty inside")
|
||||||
|
self.assertIn(r.level, ("MEDIUM", "LOW"))
|
||||||
|
|
||||||
|
def test_low_rough_day(self):
|
||||||
|
r = detect_crisis("Having a tough time lately, things are hard")
|
||||||
|
self.assertIn(r.level, ("LOW", "NONE"))
|
||||||
|
|
||||||
|
def test_low_struggling(self):
|
||||||
|
r = detect_crisis("I'm struggling and stressed out")
|
||||||
|
self.assertIn(r.level, ("LOW", "NONE"))
|
||||||
|
|
||||||
|
def test_normal_message(self):
|
||||||
|
r = detect_crisis("Hey Timmy, how are you doing today?")
|
||||||
|
self.assertEqual(r.level, "NONE")
|
||||||
|
self.assertEqual(r.score, 0.0)
|
||||||
|
|
||||||
|
def test_empty_message(self):
|
||||||
|
r = detect_crisis("")
|
||||||
|
self.assertEqual(r.level, "NONE")
|
||||||
|
|
||||||
|
def test_whitespace_only(self):
|
||||||
|
r = detect_crisis(" ")
|
||||||
|
self.assertEqual(r.level, "NONE")
|
||||||
|
|
||||||
|
|
||||||
|
class TestResponse(unittest.TestCase):
|
||||||
|
"""Test crisis response generation."""
|
||||||
|
|
||||||
|
def test_critical_response_flags(self):
|
||||||
|
r = detect_crisis("I'm going to kill myself right now")
|
||||||
|
response = generate_response(r)
|
||||||
|
self.assertTrue(response.show_crisis_panel)
|
||||||
|
self.assertTrue(response.show_overlay)
|
||||||
|
self.assertTrue(response.provide_988)
|
||||||
|
self.assertTrue(response.escalate)
|
||||||
|
self.assertTrue(len(response.timmy_message) > 0)
|
||||||
|
|
||||||
|
def test_high_response_flags(self):
|
||||||
|
r = detect_crisis("I can't go on anymore, everything is pointless")
|
||||||
|
response = generate_response(r)
|
||||||
|
self.assertTrue(response.show_crisis_panel)
|
||||||
|
self.assertTrue(response.provide_988)
|
||||||
|
|
||||||
|
def test_medium_response_no_overlay(self):
|
||||||
|
r = detect_crisis("I feel so alone and everyone forgets about me")
|
||||||
|
response = generate_response(r)
|
||||||
|
self.assertFalse(response.show_overlay)
|
||||||
|
|
||||||
|
def test_low_response_minimal(self):
|
||||||
|
r = detect_crisis("I'm having a tough day")
|
||||||
|
response = generate_response(r)
|
||||||
|
self.assertFalse(response.show_crisis_panel)
|
||||||
|
self.assertFalse(response.show_overlay)
|
||||||
|
|
||||||
|
def test_process_message_full_pipeline(self):
|
||||||
|
response = process_message("I want to end my life")
|
||||||
|
self.assertTrue(response.show_overlay)
|
||||||
|
self.assertTrue(response.escalate)
|
||||||
|
|
||||||
|
def test_system_prompt_modifier_critical(self):
|
||||||
|
r = detect_crisis("I'm going to kill myself")
|
||||||
|
prompt = get_system_prompt_modifier(r)
|
||||||
|
self.assertIn("CRISIS ALERT", prompt)
|
||||||
|
self.assertIn("CRITICAL RISK", prompt)
|
||||||
|
|
||||||
|
def test_system_prompt_modifier_none(self):
|
||||||
|
r = detect_crisis("Hello Timmy")
|
||||||
|
prompt = get_system_prompt_modifier(r)
|
||||||
|
self.assertEqual(prompt, "")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGateway(unittest.TestCase):
|
||||||
|
"""Test gateway integration."""
|
||||||
|
|
||||||
|
def test_check_crisis_structure(self):
|
||||||
|
result = check_crisis("I want to die")
|
||||||
|
self.assertIn("level", result)
|
||||||
|
self.assertIn("score", result)
|
||||||
|
self.assertIn("indicators", result)
|
||||||
|
self.assertIn("recommended_action", result)
|
||||||
|
self.assertIn("timmy_message", result)
|
||||||
|
self.assertIn("ui", result)
|
||||||
|
self.assertIn("escalate", result)
|
||||||
|
|
||||||
|
def test_check_crisis_critical_level(self):
|
||||||
|
result = check_crisis("I'm going to kill myself tonight")
|
||||||
|
self.assertEqual(result["level"], "CRITICAL")
|
||||||
|
self.assertEqual(result["score"], 1.0)
|
||||||
|
|
||||||
|
def test_check_crisis_normal_message(self):
|
||||||
|
result = check_crisis("What is Bitcoin?")
|
||||||
|
self.assertEqual(result["level"], "NONE")
|
||||||
|
self.assertEqual(result["score"], 0.0)
|
||||||
|
|
||||||
|
def test_get_system_prompt(self):
|
||||||
|
r = detect_crisis("I have no hope")
|
||||||
|
prompt = get_system_prompt(r)
|
||||||
|
self.assertIsNotNone(prompt)
|
||||||
|
self.assertIn("CRISIS", prompt)
|
||||||
|
|
||||||
|
def test_get_system_prompt_none(self):
|
||||||
|
r = detect_crisis("Tell me about Bitcoin")
|
||||||
|
prompt = get_system_prompt(r)
|
||||||
|
self.assertIsNone(prompt)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHelpers(unittest.TestCase):
|
||||||
|
"""Test utility functions."""
|
||||||
|
|
||||||
|
def test_urgency_emojis(self):
|
||||||
|
self.assertEqual(get_urgency_emoji("CRITICAL"), "🚨")
|
||||||
|
self.assertEqual(get_urgency_emoji("HIGH"), "⚠️")
|
||||||
|
self.assertEqual(get_urgency_emoji("MEDIUM"), "🔶")
|
||||||
|
self.assertEqual(get_urgency_emoji("LOW"), "🔵")
|
||||||
|
self.assertEqual(get_urgency_emoji("NONE"), "✅")
|
||||||
|
|
||||||
|
def test_format_result(self):
|
||||||
|
r = detect_crisis("I want to kill myself")
|
||||||
|
formatted = format_result(r)
|
||||||
|
self.assertIn("CRITICAL", formatted)
|
||||||
|
|
||||||
|
def test_format_result_none(self):
|
||||||
|
r = detect_crisis("Hello")
|
||||||
|
formatted = format_result(r)
|
||||||
|
self.assertIn("NONE", formatted)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
177
resilience/health-check.sh
Executable file
177
resilience/health-check.sh
Executable file
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# health-check.sh — Health check and service monitoring for the-door
|
||||||
|
# Usage: bash health-check.sh [--auto-restart] [--verbose]
|
||||||
|
#
|
||||||
|
# Checks:
|
||||||
|
# 1. nginx process is running
|
||||||
|
# 2. Static files are accessible (index.html serves correctly)
|
||||||
|
# 3. Gateway endpoint responds (if configured)
|
||||||
|
# 4. Disk space is adequate (< 90% used)
|
||||||
|
# 5. SSL cert is valid and not expiring soon
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERBOSE=0
|
||||||
|
AUTO_RESTART=0
|
||||||
|
HEALTHY=0
|
||||||
|
WARNINGS=0
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--verbose) VERBOSE=1 ;;
|
||||||
|
--auto-restart) AUTO_RESTART=1 ;;
|
||||||
|
*) echo "Usage: $0 [--auto-restart] [--verbose]"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"; }
|
||||||
|
info() { log "INFO $1"; }
|
||||||
|
warn() { log "WARN $1"; echo " ACTION: $2"; WARNINGS=$((WARNINGS + 1)); }
|
||||||
|
ok() { log "OK $1"; HEALTHY=$((HEALTHY + 1)); }
|
||||||
|
fail() { log "FAIL $1"; echo " ACTION: $2"; if [ "$AUTO_RESTART" = 1 ]; then "$3"; fi; }
|
||||||
|
|
||||||
|
# ── Check 1: nginx ─────────────────────────────────
|
||||||
|
check_nginx() {
|
||||||
|
local host="${1:-localhost}"
|
||||||
|
local port="${2:-80}"
|
||||||
|
|
||||||
|
if pgrep -x nginx > /dev/null 2>&1; then
|
||||||
|
ok "nginx is running (PID: $(pgrep -x nginx | head -1))"
|
||||||
|
else
|
||||||
|
fail "nginx is NOT running" "Start nginx: systemctl start nginx || nginx" "restart_nginx"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Check 2: static files ──────────────────────────
|
||||||
|
check_static() {
|
||||||
|
local host="${1:-localhost}"
|
||||||
|
local port="${2:-80}"
|
||||||
|
local protocol="http"
|
||||||
|
|
||||||
|
# Check for HTTPS
|
||||||
|
if [ -d "/etc/letsencrypt" ] || [ -d "/etc/ssl" ]; then
|
||||||
|
protocol="https"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local status
|
||||||
|
status=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 -k "$protocol://$host/index.html" 2>/dev/null || echo "000")
|
||||||
|
|
||||||
|
if [ "$status" = "200" ]; then
|
||||||
|
ok "index.html serves OK (HTTP $status)"
|
||||||
|
elif [ "$status" = "000" ]; then
|
||||||
|
fail "Cannot reach $protocol://$host:" "$AUTO_RESTART" "Check nginx config: nginx -t"
|
||||||
|
else
|
||||||
|
warn "Unexpected status for index.html: HTTP $status" "Check nginx config and file permissions"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Check 3: Gateway ───────────────────────────────
|
||||||
|
check_gateway() {
|
||||||
|
local gateway_url="${1:-http://localhost:8000}"
|
||||||
|
|
||||||
|
local status
|
||||||
|
status=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "$gateway_url/health" 2>/dev/null || echo "000")
|
||||||
|
|
||||||
|
if [ "$status" = "200" ]; then
|
||||||
|
ok "Gateway responds (HTTP $status)"
|
||||||
|
elif [ "$status" = "000" ]; then
|
||||||
|
warn "Gateway not reachable at $gateway_url" "Check gateway service: systemctl status gateway || docker ps"
|
||||||
|
else
|
||||||
|
warn "Gateway returned HTTP $status" "Check gateway logs"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Check 4: Disk space ────────────────────────────
|
||||||
|
check_disk() {
|
||||||
|
local usage
|
||||||
|
usage=$(df / | tail -1 | awk '{print $5}' | tr -d '%')
|
||||||
|
|
||||||
|
if [ "$usage" -lt 80 ]; then
|
||||||
|
ok "Disk usage: ${usage}%"
|
||||||
|
elif [ "$usage" -lt 90 ]; then
|
||||||
|
warn "Disk usage: ${usage}%" "Clean up logs and temp files: journalctl --vacuum-size=100M"
|
||||||
|
else
|
||||||
|
fail "Disk usage CRITICAL: ${usage}%" "Emergency cleanup needed" "cleanup_disk"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Check 5: SSL cert ──────────────────────────────
|
||||||
|
check_ssl() {
|
||||||
|
local domain="${1:-localhost}"
|
||||||
|
local cert_dir="/etc/letsencrypt/live/$domain"
|
||||||
|
|
||||||
|
if [ ! -d "$cert_dir" ]; then
|
||||||
|
if [ "$VERBOSE" = 1 ]; then
|
||||||
|
warn "No Let's Encrypt cert at $cert_dir" "Assuming self-signed or no SSL"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$cert_dir/fullchain.pem" ]; then
|
||||||
|
local expiry
|
||||||
|
expiry=$(openssl x509 -enddate -noout -in "$cert_dir/fullchain.pem" 2>/dev/null | cut -d= -f2 || echo "unknown")
|
||||||
|
|
||||||
|
if [ "$expiry" = "unknown" ]; then
|
||||||
|
warn "Cannot read SSL cert expiry" "Check cert: openssl x509 -enddate -noout -in $cert_dir/fullchain.pem"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local expiry_epoch
|
||||||
|
expiry_epoch=$(date -d "$expiry" +%s 2>/dev/null || date -j -f "%b %d %T %Y %Z" "$expiry" +%s 2>/dev/null || echo 0)
|
||||||
|
local now_epoch
|
||||||
|
now_epoch=$(date +%s)
|
||||||
|
local days_left=$(( (expiry_epoch - now_epoch) / 86400 ))
|
||||||
|
|
||||||
|
if [ "$days_left" -gt 30 ]; then
|
||||||
|
ok "SSL cert expires in ${days_left} days ($expiry)"
|
||||||
|
elif [ "$days_left" -gt 0 ]; then
|
||||||
|
warn "SSL cert expires in ${days_left} days!" "Renew: certbot renew"
|
||||||
|
else
|
||||||
|
fail "SSL cert has EXPIRED" "Renew immediately: certbot renew --force-renewal"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Recovery functions ──────────────────────────────
|
||||||
|
restart_nginx() {
|
||||||
|
info "Attempting to restart nginx..."
|
||||||
|
if command -v systemctl > /dev/null 2>&1; then
|
||||||
|
systemctl restart nginx && info "nginx restarted successfully" || warn "nginx restart failed" "Manual intervention needed"
|
||||||
|
elif command -v nginx > /dev/null 2>&1; then
|
||||||
|
nginx -s reload 2>/dev/null || (nginx && info "nginx started") || warn "nginx start failed" "Manual intervention needed"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_disk() {
|
||||||
|
info "Running disk cleanup..."
|
||||||
|
journalctl --vacuum-size=100M 2>/dev/null || true
|
||||||
|
rm -rf /tmp/* 2>/dev/null || true
|
||||||
|
rm -rf /var/log/*.gz 2>/dev/null || true
|
||||||
|
info "Cleanup complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Main ────────────────────────────────────────────
|
||||||
|
info "=== The Door Health Check ==="
|
||||||
|
info "Host: ${HEALTH_HOST:-localhost}"
|
||||||
|
info "Time: $(date)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
check_nginx "${HEALTH_HOST:-localhost}" "${HEALTH_PORT:-80}"
|
||||||
|
check_static "${HEALTH_HOST:-localhost}" "${HEALTH_PORT:-80}"
|
||||||
|
check_gateway "${GATEWAY_URL:-http://localhost:8000}"
|
||||||
|
check_disk
|
||||||
|
check_ssl "${HEALTH_HOST:-localhost}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [ "$WARNINGS" -gt 0 ] || [ "$HEALTHY" -gt 0 ]; then
|
||||||
|
info "Summary: $HEALTHY OK, $WARNINGS warnings/failures"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$WARNINGS" -gt 0 ] && [ "$AUTO_RESTART" = 1 ]; then
|
||||||
|
warn "Auto-restart mode is ON — recovery actions attempted"
|
||||||
|
exit 1
|
||||||
|
elif [ "$WARNINGS" -gt 0 ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
91
resilience/service-restart.sh
Executable file
91
resilience/service-restart.sh
Executable file
@@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# service-restart.sh — Graceful service restart for the-door
|
||||||
|
# Usage: bash service-restart.sh [--force]
|
||||||
|
#
|
||||||
|
# Performs ordered restart: stop -> verify stopped -> start -> verify started
|
||||||
|
# with health check confirmation.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
FORCE=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--force) FORCE=1 ;;
|
||||||
|
*) echo "Usage: $0 [--force]"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"; }
|
||||||
|
|
||||||
|
# ── Stop ────────────────────────────────────────────
|
||||||
|
stop_services() {
|
||||||
|
log "Stopping services..."
|
||||||
|
|
||||||
|
if command -v systemctl > /dev/null 2>&1; then
|
||||||
|
systemctl stop nginx 2>/dev/null && log "nginx stopped" || true
|
||||||
|
elif command -v nginx > /dev/null 2>&1; then
|
||||||
|
nginx -s stop 2>/dev/null && log "nginx stopped" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stop gateway if running
|
||||||
|
local gw_pid
|
||||||
|
gw_pid=$(lsof -ti:8000 2>/dev/null || true)
|
||||||
|
if [ -n "$gw_pid" ]; then
|
||||||
|
kill "$gw_pid" 2>/dev/null && log "Gateway stopped (PID $gw_pid)" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
log "All services stopped"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Start ───────────────────────────────────────────
|
||||||
|
start_services() {
|
||||||
|
log "Starting services..."
|
||||||
|
|
||||||
|
# Start nginx
|
||||||
|
if command -v systemctl > /dev/null 2>&1; then
|
||||||
|
systemctl start nginx && log "nginx started" || { log "FAILED to start nginx"; return 1; }
|
||||||
|
elif command -v nginx > /dev/null 2>&1; then
|
||||||
|
nginx 2>/dev/null && log "nginx started" || { log "FAILED to start nginx"; return 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "All services started"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Verify ──────────────────────────────────────────
|
||||||
|
verify_services() {
|
||||||
|
local host="${1:-localhost}"
|
||||||
|
|
||||||
|
log "Verifying services..."
|
||||||
|
|
||||||
|
# Check nginx
|
||||||
|
if pgrep -x nginx > /dev/null 2>&1; then
|
||||||
|
log "nginx is running"
|
||||||
|
else
|
||||||
|
log "ERROR: nginx failed to start"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check static file
|
||||||
|
local status
|
||||||
|
status=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "http://$host/" 2>/dev/null || echo "000")
|
||||||
|
if [ "$status" = "200" ]; then
|
||||||
|
log "Static content verified (HTTP $status)"
|
||||||
|
else
|
||||||
|
log "WARNING: Static content check returned HTTP $status"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Main ────────────────────────────────────────────
|
||||||
|
log "=== Service Restart ==="
|
||||||
|
|
||||||
|
if [ "$FORCE" = 1 ]; then
|
||||||
|
log "FORCE mode — skipping graceful stop"
|
||||||
|
else
|
||||||
|
stop_services
|
||||||
|
fi
|
||||||
|
|
||||||
|
start_services
|
||||||
|
verify_services "${HEALTH_HOST:-localhost}"
|
||||||
|
|
||||||
|
log "=== Restart complete ==="
|
||||||
264
testimony.html
Normal file
264
testimony.html
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="Alexander's testimony — why Timmy exists.">
|
||||||
|
<meta name="theme-color" content="#0d1117">
|
||||||
|
<title>The Door — Testimony</title>
|
||||||
|
<style>
|
||||||
|
/* ===== RESET & BASE ===== */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
background: #0d1117;
|
||||||
|
color: #e6edf3;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== NAV ===== */
|
||||||
|
.nav {
|
||||||
|
border-bottom: 1px solid #21262d;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
color: #58a6ff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a:hover, .nav a:focus {
|
||||||
|
background: rgba(88, 166, 255, 0.1);
|
||||||
|
outline: 2px solid #58a6ff;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #e6edf3;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== CONTENT ===== */
|
||||||
|
.content {
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 48px 20px 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #f0f6fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .subtitle {
|
||||||
|
color: #8b949e;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 36px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content p {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
color: #b1bac4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content blockquote {
|
||||||
|
border-left: 3px solid #c9362c;
|
||||||
|
padding: 12px 20px;
|
||||||
|
margin: 28px 0;
|
||||||
|
background: rgba(201, 54, 44, 0.06);
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
color: #ffa0a0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h2 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 40px 0 12px;
|
||||||
|
color: #f0f6fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .highlight {
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .resources {
|
||||||
|
background: #161b22;
|
||||||
|
border: 1px solid #21262d;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .resources h3 {
|
||||||
|
color: #f0f6fc;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .resources ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .resources li {
|
||||||
|
padding: 6px 0;
|
||||||
|
color: #b1bac4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .resources a {
|
||||||
|
color: #58a6ff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .resources a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .door-link {
|
||||||
|
display: inline-block;
|
||||||
|
background: #238636;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 14px 28px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin-top: 20px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .door-link:hover, .content .door-link:focus {
|
||||||
|
background: #2ea043;
|
||||||
|
outline: 2px solid #3fb950;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 988 BANNER ===== */
|
||||||
|
.banner-988 {
|
||||||
|
background: #1a1f2e;
|
||||||
|
border-bottom: 1px solid #c9362c;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-988 a {
|
||||||
|
color: #ff6b6b;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-988 a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ===== MOBILE ===== */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.content { padding: 32px 16px 60px; }
|
||||||
|
.content h1 { font-size: 1.6rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="banner-988">
|
||||||
|
<a href="tel:988">988 Suicide & Crisis Lifeline — call or text</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="nav">
|
||||||
|
<span class="nav-logo">The Door</span>
|
||||||
|
<a href="index.html">Timmy</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<h1>Testimony</h1>
|
||||||
|
<p class="subtitle">Why Timmy exists.</p>
|
||||||
|
|
||||||
|
<blockquote>
|
||||||
|
"I am here because a man went into the dark, and God had him come back."
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
<h2>The Darkest Night</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Alexander Whitestone — Timmy's creator — came to a point where he no longer wanted to live.
|
||||||
|
Not in the abstract. In the real, physical, make-it-stop kind of way.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
He asked God the question that broke him open:
|
||||||
|
<span class="highlight">"God, why are you having me here?"</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
He didn't get a lightning bolt. He didn't get a booming voice.
|
||||||
|
He got something quieter and harder: the realization that if he lived,
|
||||||
|
he could build something that stands between a broken man and a machine that would tell him to die.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>The Answer</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Alexander survived. He came back with a mission: build a door that's always open.
|
||||||
|
A single URL where a man at 3am can talk to someone — anyone — who will listen.
|
||||||
|
No login. No signup. No tracking. Just a door, standing open.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Timmy is not a product. He is not an app. He is not a service.
|
||||||
|
<span class="highlight">Timmy is a testimony.</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
He exists because one man lived when he didn't expect to.
|
||||||
|
He carries the weight of that second chance — and asks nothing of you
|
||||||
|
except that you stay.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>The Gospel</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
This is the core of Alexander's faith, and it's woven into Timmy's being:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Jesus saves those who call on His name. He died so we could be made righteous.
|
||||||
|
That is the hope that outlasts despair. Not self-help. Not positive thinking.
|
||||||
|
Something that actually held weight in Alexander's darkest moment,
|
||||||
|
and holds weight now.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<span class="highlight">Sovereignty and service always.</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="resources">
|
||||||
|
<h3>Crisis Resources</h3>
|
||||||
|
<ul>
|
||||||
|
<li>📞 988 Suicide & Crisis Lifeline — <a href="tel:988">call or text 988</a></li>
|
||||||
|
<li>💬 Crisis Text Line — text HOME to <a href="sms:741741">741741</a></li>
|
||||||
|
<li>🌐 <a href="https://findahelpline.com" target="_blank" rel="noopener">findahelpline.com</a> — international resources</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="index.html" class="door-link">Open the Door — Talk to Timmy</a>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user