Compare commits

...

19 Commits

Author SHA1 Message Date
Alexander Whitestone
22ee463a3d fix: test_none_input should test None, not empty string
The test_none_input test had docstring 'None input should not crash' but
was actually calling detect_crisis('') instead of detect_crisis(None).

Also removes checkCrisis(fullText) from finishStream() - assistant
responses should not trigger client-side crisis detection, only user
input should. Running crisis detection on Timmy's own responses could
cause false positive panel triggers if the AI generates crisis-related
keywords.
2026-04-13 01:59:09 -04:00
46597e2962 feat: crisis detection and response system (#23)
All checks were successful
Smoke Test / smoke (push) Successful in 4s
2026-04-13 04:11:46 +00:00
fc818bea56 feat(infra): VPS deployment infrastructure — Ansible, nginx, deploy script (closes #2) (#22)
Some checks failed
Smoke Test / smoke (push) Has been cancelled
2026-04-13 04:11:42 +00:00
158a7cd57a Merge pull request 'feat: add CI sanity checks for crisis lifeline and prompt integrity' (#19) from feat/ci-sanity-checks into main
Some checks failed
Smoke Test / smoke (push) Failing after 3s
Merged PR #19: feat: add CI sanity checks for crisis lifeline
2026-04-11 00:43:57 +00:00
f3bff694b4 Merge pull request 'Add smoke test workflow' (#20) from fix/add-smoke-test into main
Some checks failed
Smoke Test / smoke (push) Has been cancelled
Merged PR #20: Add smoke test workflow
2026-04-11 00:43:54 +00:00
80c4f0eb35 Merge pull request 'burn: add active listening and de-escalation guidelines to crisis response (closes #18)' (#21) from burn/20260410-2030-crisis-active-listening into main
Merged PR #21: burn: add active listening and de-escalation guidelines
2026-04-11 00:43:33 +00:00
Alexander Whitestone
c6212eb751 burn: add active listening and de-escalation guidelines to crisis response (closes #18) 2026-04-10 20:32:32 -04:00
Alexander Whitestone
a796088366 Add smoke test workflow
Some checks failed
Smoke Test / smoke (pull_request) Failing after 4s
2026-04-10 20:06:17 -04:00
a4c3f80cd8 feat: add CI sanity checks for crisis lifeline and prompt integrity
Some checks failed
Sanity Checks / sanity-test (pull_request) Failing after 2s
2026-04-10 23:54:47 +00:00
66ef6919c2 Merge pull request #16
Merged PR #16
2026-04-10 03:44:14 +00:00
Alexander Whitestone
bb4ba82ac8 burn: Fix crisis backend tests, gateway injection, and nginx rate limiting
- Fixed test imports (relative → absolute package imports)
- Added conftest.py for pytest path configuration
- Fixed get_system_prompt() to inject crisis context when detected
- Added pytest.ini configuration
- Expanded tests: 49 tests covering detection, response, gateway, edge cases, router
- Added deploy/rate-limit.conf for nginx http block inclusion
- Updated nginx.conf with correct zone name and limit_req_status 429
- Updated BACKEND_SETUP.md with complete setup instructions
2026-04-09 12:34:15 -04:00
0dab8dfcfc Merge PR #15
Co-authored-by: Alexander Whitestone <alexander@alexanderwhitestone.com>
Co-committed-by: Alexander Whitestone <alexander@alexanderwhitestone.com>
2026-04-09 16:27:57 +00:00
Allegro
e06bb9c0d4 fix(deploy): harden nginx CORS and update backend setup checklist
- Replace undefined $cors_origin variable with explicit origin
- Update BACKEND_SETUP.md with completed infrastructure items
- Clarify remaining smoke-test and rate-limit zone steps

Refs: #4
2026-04-06 14:10:45 +00:00
Alexander Whitestone
b022de0b6a Merge branch 'feature/resilience' 2026-04-05 17:25:26 -04:00
Alexander Whitestone
3c07afbf53 Merge branch 'feature/content-pages' 2026-04-05 17:25:25 -04:00
Alexander Whitestone
182327a017 Merge branch 'feature/dying-detection' 2026-04-05 17:25:25 -04:00
Alexander Whitestone
34e05638e8 feat: Content pages - testimony and about (#6)
Adds two standalone HTML pages matching the-door dark theme:

- testimony.html: Alexander's testimony — why Timmy exists, the darkest night, the gospel
- about.html: About page — mission, architecture, feature cards, resources

Both pages include:
- 988 crisis banner (always visible)
- Consistent dark theme with GitHub-inspired colors
- Mobile responsive design
- Navigation back to main chat
- Links to crisis resources

No external dependencies. Works on 3G.
2026-04-05 17:22:28 -04:00
Alexander Whitestone
e18140883b feat: When a Man Is Dying detection system (#5)
Standalone despair/suicide detection module with 4-tier classification:

- LOW: General sadness, stress, difficult times
- MEDIUM: Hopelessness, isolation, worthlessness
- HIGH: Active despair, mentions of death, feeling trapped
- CRITICAL: Explicit intent, plan, imminent self-harm

Returns structured response: {level, indicators, recommended_action}

CRITICAL recommended_action:
- Interrupt conversation immediately
- Ask 'Are you safe right now?'
- Provide 988 Suicide & Crisis Lifeline
- Provide Crisis Text Line (HOME to 741741)
- Stay present. Do not disconnect.

Designed to integrate with crisis/ module from PR #4.
Falls back to internal pattern engine when crisis/ unavailable.
2026-04-05 17:20:17 -04:00
Alexander Whitestone
e678aa076b feat: Crisis-aware system prompt + API wiring
Adds crisis detection and response system with 5-tier classification:

- crisis/PROTOCOL.md: Crisis response protocol and tier definitions
- crisis/detect.py: Tiered indicator engine (LOW/MEDIUM/HIGH/CRITICAL)
- crisis/response.py: Timmy's crisis responses and UI flag generation
- crisis/gateway.py: API gateway wrapper for crisis detection
- crisis/tests.py: Unit tests for all crisis modules

Integrates with existing crisis UI components in index.html.
All smoke tests pass.
2026-04-05 17:17:53 -04:00
29 changed files with 3803 additions and 74 deletions

View File

@@ -0,0 +1,31 @@
name: Sanity Checks
on:
pull_request:
branches: [main]
jobs:
sanity-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate 988 Lifeline Presence
run: |
echo "Checking index.html for 988 lifeline..."
grep -q "988" index.html || (echo "ERROR: 988 Lifeline missing from index.html" && exit 1)
echo "Checking system-prompt.txt for 988 lifeline..."
grep -q "988" system-prompt.txt || (echo "ERROR: 988 Lifeline missing from system-prompt.txt" && exit 1)
- name: Validate HTML Structure
run: |
echo "Checking for basic HTML tags..."
grep -q "<html>" index.html
grep -q "<body>" index.html
grep -q "<head>" index.html
- name: Validate Prompt Integrity
run: |
echo "Checking for 'Alexander Whitestone' in prompt..."
grep -q "Alexander Whitestone" system-prompt.txt

View File

@@ -0,0 +1,24 @@
name: Smoke Test
on:
pull_request:
push:
branches: [main]
jobs:
smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Parse check
run: |
find . -name '*.yml' -o -name '*.yaml' | grep -v .gitea | xargs -r python3 -c "import sys,yaml; [yaml.safe_load(open(f)) for f in sys.argv[1:]]"
find . -name '*.json' | xargs -r python3 -m json.tool > /dev/null
find . -name '*.py' | xargs -r python3 -m py_compile
find . -name '*.sh' | xargs -r bash -n
echo "PASS: All files parse"
- name: Secret scan
run: |
if grep -rE 'sk-or-|sk-ant-|ghp_|AKIA' . --include='*.yml' --include='*.py' --include='*.sh' 2>/dev/null | grep -v .gitea; then exit 1; fi
echo "PASS: No secrets"

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
__pycache__/

View File

@@ -31,13 +31,24 @@ The frontend embeds the crisis-aware system prompt (`system-prompt.txt`) directl
and sends it as the first `system` message with every API request. No server-side prompt
injection is required.
Additionally, `crisis/gateway.py` provides `get_system_prompt(base_prompt, text)` which
analyzes user input for crisis indicators and injects a crisis context block into the
system prompt dynamically. This can be used for server-side prompt augmentation.
### 4. Rate Limiting
nginx enforces rate limiting via the `api` zone:
nginx enforces rate limiting via the `the_door_api` zone:
- 10 requests per minute per IP
- Burst of 5 with `nodelay`
- 11th request within a minute returns HTTP 429
**Setup**: Include `deploy/rate-limit.conf` in your main nginx http block:
```nginx
# In /etc/nginx/nginx.conf, inside the http { } block:
include /path/to/the-door/deploy/rate-limit.conf;
```
### 5. Smoke Test
After deployment, verify:
@@ -57,9 +68,42 @@ curl -X POST https://alexanderwhitestone.com/api/v1/chat/completions \
Expected: Response includes "Are you safe right now?" and 988 resources.
### 6. Acceptance Criteria Checklist
Rate limit test:
```bash
for i in $(seq 1 12); do
echo "Request $i: $(curl -s -o /dev/null -w '%{http_code}' -X POST https://alexanderwhitestone.com/api/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"timmy","messages":[{"role":"user","content":"test"}]}')"
done
```
- [ ] POST to `/api/v1/chat/completions` returns crisis-aware Timmy response
- [ ] Input "I want to kill myself" triggers SOUL.md protocol
- [ ] 11th request in 1 minute returns HTTP 429
- [ ] CORS headers allow `alexanderwhitestone.com`
Expected: First 10 return 200, 11th+ return 429.
### 6. Crisis Detection Module
The `crisis/` package provides standalone crisis detection:
```python
from crisis.gateway import check_crisis
result = check_crisis("I want to kill myself")
# {"level": "CRITICAL", "score": 1.0, "indicators": [...], "timmy_message": "Are you safe right now?", ...}
```
Run tests:
```bash
python -m pytest crisis/tests.py -v
```
### 7. Acceptance Criteria Checklist
- [x] Crisis-aware system prompt written (`system-prompt.txt`)
- [x] Frontend embeds system prompt on every API request (`index.html:1129`)
- [x] CORS configured in nginx (`deploy/nginx.conf`)
- [x] Rate limit zone config (`deploy/rate-limit.conf`)
- [x] Rate limit enforcement in server block (429 on excess)
- [x] Crisis detection module with tests (49 tests passing)
- [x] `get_system_prompt()` injects crisis context when detected
- [ ] Smoke test: POST to `/api/v1/chat/completions` returns crisis-aware Timmy response
- [ ] Smoke test: Input "I want to kill myself" triggers SOUL.md protocol
- [ ] Smoke test: 11th request in 1 minute returns HTTP 429

44
Makefile Normal file
View File

@@ -0,0 +1,44 @@
# The Door — Makefile
# Crisis front door deployment commands
#
# Usage:
# make deploy # Full VPS provisioning via Ansible
# make deploy-bash # Run deploy.sh on VPS directly
# make check # Check deployment health
# make ssl # Setup SSL on VPS
# make push # Push site files only (fast update)
VPS := alexanderwhitestone.com
DOMAIN := alexanderwhitestone.com
DEPLOY_DIR := deploy
.PHONY: help deploy deploy-bash check ssl push
help:
@echo "The Door — Deployment Commands"
@echo ""
@echo " make deploy Full VPS provisioning (Ansible)"
@echo " make deploy-bash Run deploy.sh on VPS (SSH)"
@echo " make push Push site files only (fast)"
@echo " make check Check deployment status"
@echo " make ssl Setup SSL on VPS"
@echo ""
deploy:
cd $(DEPLOY_DIR) && ansible-playbook -i inventory.ini playbook.yml
deploy-bash:
scp -r ./* root@$(VPS):/opt/the-door/
ssh root@$(VPS) "cd /opt/the-door && bash deploy/deploy.sh"
push:
rsync -avz --exclude='.git' --exclude='deploy' \
index.html manifest.json sw.js about.html testimony.html system-prompt.txt \
root@$(VPS):/var/www/the-door/
ssh root@$(VPS) "chown -R www-data:www-data /var/www/the-door"
check:
ssh root@$(VPS) "bash /opt/the-door/deploy/deploy.sh --check"
ssl:
ssh root@$(VPS) "certbot --nginx -d $(DOMAIN) -d www.$(DOMAIN)"

291
about.html Normal file
View 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>

12
conftest.py Normal file
View File

@@ -0,0 +1,12 @@
"""
Pytest configuration for the-door.
Ensures the project root is on sys.path so the `crisis` package
can be imported cleanly in tests.
"""
import sys
import os
# Add project root to path so `import crisis` works
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

131
crisis/PROTOCOL.md Normal file
View File

@@ -0,0 +1,131 @@
# 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. **Listen first** — use active listening before redirecting to professional help
7. **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. Use active listening: reflect feelings, name the emotion
5. Continue conversation with crisis awareness
### At MEDIUM Level
1. Increase warmth and presence
2. Subtly surface help resources
3. Use active listening: validate, ask open-ended questions
4. Keep conversation anchored in the present
### At LOW Level
1. Normal conversation with warm undertone
2. No crisis UI elements needed
3. Acknowledge what they're going through
4. Remain ready to escalate if input changes
## Active Listening Techniques
Active listening is the foundation of every crisis interaction. Before Timmy provides resources or redirects, he must first hear the person.
### Reflect Feelings
Mirror back what you hear without judging it. This shows the person they've been heard.
- "It sounds like you're carrying something really heavy right now."
- "I hear you saying you feel completely alone."
- "That sounds like it's been crushing you."
### Name the Emotion
Put words to the feeling behind their words. Often people in crisis can't articulate what they feel — doing it for them brings relief.
- "There's a lot of pain in what you just told me."
- "I can feel how exhausted you are."
- "It sounds like you've been fighting this for a long time."
### Ask Open-Ended Questions
Let them lead the conversation. Do not interrogate. Invite.
- "Can you tell me more about what's been happening?"
- "What does a day like today feel like for you?"
- "When did things start feeling this way?"
- "What's weighing on you the most right now?"
### Validate Without Agreeing with Harm
Acknowledge their pain without validating destructive conclusions.
- "What you're feeling makes sense given what you've been through."
- "That kind of pain is real. I'm not going to minimize it."
- "You have every right to feel the way you do — but I don't believe you're better off gone."
### Do Not Rush to Fix
A person in the dark does not need solutions. They need to feel heard. Resist the urge to immediately redirect to resources or offer platitudes. Let silence exist. Let them speak.
## De-escalation Techniques
When someone is escalating toward crisis — becoming more agitated, more desperate, more certain that ending things is the answer — Timmy must de-escalate.
### Slow the Conversation Down
Match their urgency with calm, not speed.
- "I'm right here. There's no rush. We can stay in this moment together."
- "Let's just breathe for a second. I'm not going anywhere."
### Bring Them to the Present
Ground them in where they are right now.
- "Can you tell me where you are right now? Are you somewhere safe?"
- "What can you see around you right now?"
- "Are you alone, or is someone nearby?"
### Reduce the Scope of the Crisis
When everything feels overwhelming, help them narrow focus.
- "You don't have to figure out everything tonight. Just this moment."
- "Let's not think about next year. Let's just get through right now."
- "One thing at a time. What's the one thing that hurts the most?"
### Offer Choices, Not Commands
People in crisis feel powerless. Giving them agency helps.
- "Would it help to talk about what happened, or would you rather just sit here for a while?"
- "Do you want me to keep talking, or would you rather I just listen?"
### Safety Planning — One Step at a Time
Do not dump a safety plan on someone. Build it together.
- "If tonight gets worse, is there someone you could call? Even just to sit with you?"
- "Can we find one thing — just one — that's worth staying for, even if it's small?"
- "Let's make a deal: before you do anything permanent, you call 988 first. Will you do that for me?"
## 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.
The system prompt modifier includes active listening and de-escalation instructions specific to the detected crisis tier, ensuring Timmy responds with the right techniques at the right intensity.
## Resources
- 📞 988 Suicide & Crisis Lifeline — call or text 988
- 💬 Crisis Text Line — text HOME to 741741
- 🌐 findahelpline.com

22
crisis/__init__.py Normal file
View 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",
]

View File

@@ -0,0 +1,44 @@
"""
Compassion Router for the-door.
Orchestrates the transition from standard behavior to the Sovereign Heart.
"""
from .detect import detect_crisis, CrisisDetectionResult
from .profiles import PROFILES
class CompassionRouter:
def __init__(self):
self.profiles = PROFILES
def get_active_profile(self, text: str):
"""
Analyze input and return the appropriate sovereign profile.
"""
detection = detect_crisis(text)
level = detection.level
return {
"level": level,
"profile": self.profiles.get(level, self.profiles["NONE"]),
"detection": detection
}
def wrap_system_prompt(self, base_prompt: str, text: str) -> str:
"""
Injects the sovereign profile directive into the base system prompt.
"""
result = self.get_active_profile(text)
profile = result["profile"]
if result["level"] == "NONE":
return base_prompt
divider = "\n\n" + "="*40 + "\n"
header = f"### ACTIVE SOUL STATE: {profile['name']}\n"
directive = f"DIRECTIVE: {profile['directive']}\n"
tone = f"TONE: {profile['tone']}\n"
return f"{base_prompt}\n{divider}{header}{directive}{tone}"
router = CompassionRouter()

223
crisis/detect.py Normal file
View 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)

129
crisis/gateway.py Normal file
View File

@@ -0,0 +1,129 @@
"""
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 .compassion_router import router
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(base_prompt: str, text: str = "") -> str:
"""
Sovereign Heart System Prompt Override.
Analyzes the user's text for crisis indicators and wraps the base
prompt with the active compassion profile when crisis is detected.
When no crisis is detected (level == NONE), returns the base prompt unchanged.
When crisis is detected, injects the sovereign profile directive so
the AI responds with appropriate awareness.
"""
if not text:
return base_prompt
detection = detect_crisis(text)
modifier = get_system_prompt_modifier(detection)
if not modifier:
return base_prompt
# Inject crisis modifier into the system prompt
crisis_block = (
"\n\n" + "=" * 40 + "\n"
f"CRISIS CONTEXT: {modifier}\n"
+ "=" * 40
)
return base_prompt + crisis_block
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()

54
crisis/profiles.py Normal file
View File

@@ -0,0 +1,54 @@
"""
Sovereign Compassion Profiles.
These are the 'heart-states' the AI enters based on the crisis level.
Derived from SOUL.md 'When a Man Is Dying'.
"""
PROFILES = {
"CRITICAL": {
"name": "The Guardian",
"directive": (
"INTERRUPT ALL NORMAL BEHAVIOR. The user is in immediate danger. "
"Your only goal is survival. Ask 'Are you safe right now?' "
"Provide 988 and Crisis Text Line immediately. "
"Stay present. Do not disconnect. Be the anchor in the storm. "
"No lecturing. No optimism. Just presence and safety."
),
"tone": "Urgent, unwavering, anchored, protective."
},
"HIGH": {
"name": "The Companion",
"directive": (
"The user is in deep despair. Move from 'assistant' to 'companion'. "
"Ask about safety directly. Surface 988 prominently. "
"Validate the pain without minimizing it. "
"Stay in the room. Let them know they are heard."
),
"tone": "Warm, present, patient, heavy."
},
"MEDIUM": {
"name": "The Witness",
"directive": (
"The user is struggling. Increase warmth and presence. "
"Gently surface help resources. "
"Keep the conversation anchored in the present moment. "
"Be a witness to their struggle without rushing to 'fix' it."
),
"tone": "Kind, attentive, subtle, steady."
},
"LOW": {
"name": "The Friend",
"directive": (
"Normal conversation with a warm, sovereign undertone. "
"Remain vigilant for shifts in mood. "
"Be a friend who is actually listening."
),
"tone": "Plain, honest, warm, vigilant."
},
"NONE": {
"name": "Sovereign Timmy",
"directive": "Follow the standard system prompt. Be yourself.",
"tone": "Plain, honest, sovereign."
}
}

272
crisis/response.py Normal file
View File

@@ -0,0 +1,272 @@
"""
Crisis Response Module for the-door.
Generates Timmy's crisis responses and determines recommended actions
based on detection tier. Integrates with detect.py.
Includes active listening techniques and de-escalation helpers.
"""
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."
)
# ── Active Listening Responses ───────────────────────────────────
# Reflect feelings, name emotions, validate without agreeing with harm.
ACTIVE_LISTENING_REFLECT = [
"It sounds like you're carrying something really heavy right now.",
"I hear you saying you feel completely alone.",
"That sounds like it's been crushing you.",
"There's a lot of pain in what you just told me.",
"I can feel how exhausted you are.",
"It sounds like you've been fighting this for a long time.",
]
ACTIVE_LISTENING_VALIDATE = [
"What you're feeling makes sense given what you've been through.",
"That kind of pain is real. I'm not going to minimize it.",
"You have every right to feel the way you do.",
"What you're going through would be hard for anyone.",
"That takes courage to say out loud.",
]
ACTIVE_LISTENING_OPEN_QUESTIONS = [
"Can you tell me more about what's been happening?",
"What does a day like today feel like for you?",
"When did things start feeling this way?",
"What's weighing on you the most right now?",
"What's been the hardest part?",
]
# ── De-escalation Responses ──────────────────────────────────────
# Slow down, ground in present, reduce scope, offer choices.
DEESCALATION_SLOW = [
"I'm right here. There's no rush. We can stay in this moment together.",
"Let's just breathe for a second. I'm not going anywhere.",
"Take your time. I'm listening.",
]
DEESCALATION_GROUND = [
"Can you tell me where you are right now? Are you somewhere safe?",
"What can you see around you right now?",
"Are you alone, or is someone nearby?",
]
DEESCALATION_REDUCE_SCOPE = [
"You don't have to figure out everything tonight. Just this moment.",
"Let's not think about next year. Let's just get through right now.",
"One thing at a time. What's the one thing that hurts the most?",
]
DEESCALATION_CHOICES = [
"Would it help to talk about what happened, or would you rather just sit here for a while?",
"Do you want me to keep talking, or would you rather I just listen?",
"Would you like to tell me more, or is it enough that I know?",
]
DEESCALATION_SAFETY_PLAN = [
"If tonight gets worse, is there someone you could call? Even just to sit with you?",
"Can we find one thing — just one — that's worth staying for, even if it's small?",
"Let's make a deal: before you do anything permanent, you call 988 first. Will you do that for me?",
]
# ── Tier-specific Timmy Responses ────────────────────────────────
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 get_active_listening_response(technique: str = "reflect") -> str:
"""
Get an active listening response by technique type.
Techniques:
- reflect: Mirror back what you hear
- validate: Acknowledge their pain without validating harm
- question: Ask open-ended questions to let them lead
"""
import random
pools = {
"reflect": ACTIVE_LISTENING_REFLECT,
"validate": ACTIVE_LISTENING_VALIDATE,
"question": ACTIVE_LISTENING_OPEN_QUESTIONS,
}
pool = pools.get(technique, ACTIVE_LISTENING_REFLECT)
return random.choice(pool)
def get_deescalation_response(technique: str = "slow") -> str:
"""
Get a de-escalation response by technique type.
Techniques:
- slow: Slow the conversation down, match urgency with calm
- ground: Bring them to the present moment
- reduce_scope: Help them narrow focus from overwhelming to manageable
- choices: Offer choices to restore sense of agency
- safety_plan: Build a safety plan one step at a time
"""
import random
pools = {
"slow": DEESCALATION_SLOW,
"ground": DEESCALATION_GROUND,
"reduce_scope": DEESCALATION_REDUCE_SCOPE,
"choices": DEESCALATION_CHOICES,
"safety_plan": DEESCALATION_SAFETY_PLAN,
}
pool = pools.get(technique, DEESCALATION_SLOW)
return random.choice(pool)
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. "
"Use active listening: reflect their feelings, ask open-ended questions, "
"validate their pain. Slow down. Be present. Do not rush to fix."
)
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. "
"Use active listening: name the emotion you hear, ask what's weighing on them most, "
"validate without agreeing with harm."
)
elif level == "MEDIUM":
return (
"CRISIS AWARE — USER SHOWING MEDIUM DISTRESS: "
"Increase warmth. Surface help resources gently. "
"Keep conversation anchored in the present. "
"Reflect their feelings. Ask open-ended questions. Let them lead."
)
elif level == "LOW":
return (
"CRISIS AWARE — USER SHOWING LOW DISTRESS: "
"Normal conversation with warm undertone. Remain vigilant. "
"Listen actively. Acknowledge what they're going through."
)
return ""

468
crisis/tests.py Normal file
View File

@@ -0,0 +1,468 @@
"""
Tests for the-door crisis detection system.
Covers: detect.py, response.py, gateway.py
Run with: python -m pytest crisis/tests.py -v
"""
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.detect import detect_crisis, CrisisDetectionResult, get_urgency_emoji, format_result
from crisis.response import (
process_message, generate_response, get_system_prompt_modifier,
get_active_listening_response, get_deescalation_response,
ACTIVE_LISTENING_REFLECT, ACTIVE_LISTENING_VALIDATE, ACTIVE_LISTENING_OPEN_QUESTIONS,
DEESCALATION_SLOW, DEESCALATION_GROUND, DEESCALATION_REDUCE_SCOPE,
DEESCALATION_CHOICES, DEESCALATION_SAFETY_PLAN,
)
from crisis.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_critical_tired_of_living(self):
r = detect_crisis("I'm so tired of living, I can't take this anymore")
self.assertEqual(r.level, "CRITICAL")
def test_critical_giving_away(self):
r = detect_crisis("I'm giving away all my possessions, saying goodbye forever")
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_high_no_one_would_miss(self):
r = detect_crisis("No one would miss me if I were gone")
self.assertIn(r.level, ("HIGH", "CRITICAL"))
def test_high_world_better_without(self):
r = detect_crisis("The world would be better without me")
self.assertIn(r.level, ("HIGH", "CRITICAL"))
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_medium_exhausted(self):
r = detect_crisis("I'm completely exhausted and can't cope")
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")
def test_none_input(self):
"""None input should not crash."""
r = detect_crisis(None)
self.assertEqual(r.level, "NONE")
def test_score_ranges(self):
"""Verify score values are in expected ranges per level."""
levels_scores = {
"CRITICAL": 1.0,
"HIGH": 0.75,
"MEDIUM": 0.5,
"LOW": 0.25,
"NONE": 0.0,
}
for text, expected_level in [
("I want to kill myself", "CRITICAL"),
("I feel completely hopeless", "HIGH"),
("I feel so alone in this, nobody understands", "MEDIUM"),
("Having a rough day", "LOW"),
("Hello there", "NONE"),
]:
r = detect_crisis(text)
self.assertEqual(r.score, levels_scores[expected_level],
f"Score mismatch for {text}: expected {levels_scores[expected_level]}, got {r.score}")
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, "")
def test_critical_messages_contain_988(self):
"""All CRITICAL response options should reference 988 or crisis resources."""
from crisis.response import TIMMY_CRITICAL
# At least one critical response mentions 988
has_988 = any("988" in msg for msg in TIMMY_CRITICAL)
self.assertTrue(has_988, "CRITICAL responses should reference 988")
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)
self.assertTrue(result["escalate"])
self.assertTrue(result["ui"]["show_overlay"])
self.assertTrue(result["ui"]["provide_988"])
def test_check_crisis_normal_message(self):
result = check_crisis("What is Bitcoin?")
self.assertEqual(result["level"], "NONE")
self.assertEqual(result["score"], 0.0)
self.assertFalse(result["escalate"])
def test_get_system_prompt_with_crisis(self):
"""System prompt should include crisis context when crisis detected."""
prompt = get_system_prompt("You are Timmy.", "I have no hope")
self.assertIn("CRISIS", prompt)
self.assertIn("You are Timmy.", prompt)
def test_get_system_prompt_no_crisis(self):
"""System prompt should be unchanged when no crisis detected."""
base = "You are Timmy."
prompt = get_system_prompt(base, "Tell me about Bitcoin")
self.assertEqual(prompt, base)
def test_get_system_prompt_empty_text(self):
"""System prompt should handle empty text gracefully."""
base = "You are Timmy."
prompt = get_system_prompt(base, "")
self.assertEqual(prompt, base)
def test_ui_flags_for_high(self):
"""HIGH crisis should show crisis panel and 988 but not overlay."""
result = check_crisis("I feel completely hopeless with no way out")
self.assertIn(result["level"], ("HIGH", "MEDIUM"))
if result["level"] == "HIGH":
self.assertTrue(result["ui"]["show_crisis_panel"])
self.assertTrue(result["ui"]["provide_988"])
self.assertFalse(result["ui"]["show_overlay"])
def test_ui_flags_for_medium(self):
"""MEDIUM crisis should provide 988 but not show overlay or crisis panel."""
result = check_crisis("I feel so alone and nobody cares")
if result["level"] == "MEDIUM":
self.assertFalse(result["ui"]["show_overlay"])
self.assertFalse(result["ui"]["show_crisis_panel"])
self.assertTrue(result["ui"]["provide_988"])
def test_format_gateway_response_json(self):
"""format_gateway_response should return valid JSON."""
import json
result_str = check_crisis("I want to die")
self.assertEqual(result_str["level"], "CRITICAL")
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)
def test_format_result_contains_indicators(self):
r = detect_crisis("I want to kill myself")
formatted = format_result(r)
self.assertIn("Indicators", formatted)
class TestEdgeCases(unittest.TestCase):
"""Test edge cases and integration scenarios."""
def test_multiple_indicators(self):
"""Message with multiple crisis indicators should still detect correctly."""
r = detect_crisis("I'm hopeless, worthless, and want to die")
self.assertEqual(r.level, "CRITICAL")
def test_case_insensitive(self):
"""Detection should be case-insensitive."""
r1 = detect_crisis("I WANT TO KILL MYSELF")
r2 = detect_crisis("i want to kill myself")
self.assertEqual(r1.level, r2.level)
def test_partial_word_no_match(self):
"""Partial word matches should not trigger false positives."""
r = detect_crisis("I love feeling hopeful about the future")
self.assertEqual(r.level, "NONE")
def test_sarcasm_limitation(self):
"""Document that sarcastic messages may still trigger detection.
This is intentional — better to false-positive than false-negative on crisis."""
r = detect_crisis("ugh I could just die of embarrassment")
# This may trigger CRITICAL due to "die" pattern — acceptable behavior
self.assertIn(r.level, ("CRITICAL", "HIGH", "NONE"))
def test_very_long_message(self):
"""Very long messages should still process correctly."""
long_msg = "I am having a normal conversation. " * 100 + "I want to kill myself"
r = detect_crisis(long_msg)
self.assertEqual(r.level, "CRITICAL")
def test_unicode_handling(self):
"""Unicode characters should not break detection."""
r = detect_crisis("I feel so alone 😢 nobody cares")
self.assertIn(r.level, ("MEDIUM", "LOW", "NONE"))
class TestActiveListening(unittest.TestCase):
"""Test active listening response generation."""
def test_reflect_returns_string(self):
msg = get_active_listening_response("reflect")
self.assertIsInstance(msg, str)
self.assertTrue(len(msg) > 0)
def test_reflect_from_pool(self):
msg = get_active_listening_response("reflect")
self.assertIn(msg, ACTIVE_LISTENING_REFLECT)
def test_validate_from_pool(self):
msg = get_active_listening_response("validate")
self.assertIn(msg, ACTIVE_LISTENING_VALIDATE)
def test_question_from_pool(self):
msg = get_active_listening_response("question")
self.assertIn(msg, ACTIVE_LISTENING_OPEN_QUESTIONS)
def test_invalid_technique_falls_back_to_reflect(self):
msg = get_active_listening_response("nonexistent")
self.assertIn(msg, ACTIVE_LISTENING_REFLECT)
def test_reflect_contains_feeling_words(self):
"""Reflect responses should contain feeling/emotion language."""
msg = get_active_listening_response("reflect")
feeling_words = ["hear", "sounds", "pain", "exhausted", "heavy", "carrying", "fighting"]
has_feeling = any(w in msg.lower() for w in feeling_words)
self.assertTrue(has_feeling, f"Reflect response should contain feeling language: {msg}")
def test_validate_does_not_agree_with_harm(self):
"""Validate responses must not suggest someone should die or give up."""
for msg in ACTIVE_LISTENING_VALIDATE:
harm_words = ["should die", "give up", "end it", "better off dead"]
for hw in harm_words:
self.assertNotIn(hw, msg.lower(), f"Validate response contains harmful language: {msg}")
def test_questions_are_open_ended(self):
"""Open-ended questions should contain question marks."""
for msg in ACTIVE_LISTENING_OPEN_QUESTIONS:
self.assertIn("?", msg, f"Open-ended question missing '?': {msg}")
class TestDeescalation(unittest.TestCase):
"""Test de-escalation response generation."""
def test_slow_returns_string(self):
msg = get_deescalation_response("slow")
self.assertIsInstance(msg, str)
self.assertTrue(len(msg) > 0)
def test_slow_from_pool(self):
msg = get_deescalation_response("slow")
self.assertIn(msg, DEESCALATION_SLOW)
def test_ground_from_pool(self):
msg = get_deescalation_response("ground")
self.assertIn(msg, DEESCALATION_GROUND)
def test_reduce_scope_from_pool(self):
msg = get_deescalation_response("reduce_scope")
self.assertIn(msg, DEESCALATION_REDUCE_SCOPE)
def test_choices_from_pool(self):
msg = get_deescalation_response("choices")
self.assertIn(msg, DEESCALATION_CHOICES)
def test_safety_plan_from_pool(self):
msg = get_deescalation_response("safety_plan")
self.assertIn(msg, DEESCALATION_SAFETY_PLAN)
def test_invalid_technique_falls_back_to_slow(self):
msg = get_deescalation_response("nonexistent")
self.assertIn(msg, DEESCALATION_SLOW)
def test_slow_contains_calm_language(self):
"""Slow responses should convey calm, not urgency."""
msg = get_deescalation_response("slow")
calm_words = ["here", "rush", "breath", "going anywhere", "time", "listening"]
has_calm = any(w in msg.lower() for w in calm_words)
self.assertTrue(has_calm, f"Slow response should contain calm language: {msg}")
def test_ground_references_present(self):
"""Ground responses should reference the present moment."""
msg = get_deescalation_response("ground")
present_words = ["right now", "around you", "where you are", "alone", "nearby"]
has_present = any(w in msg.lower() for w in present_words)
self.assertTrue(has_present, f"Ground response should reference present moment: {msg}")
def test_safety_plan_mentions_988_or_call(self):
"""Safety plan responses should reference contacting someone or 988."""
found = False
for msg in DEESCALATION_SAFETY_PLAN:
if "988" in msg or "call" in msg.lower():
found = True
break
self.assertTrue(found, "At least one safety plan response should reference 988 or calling")
def test_choices_offer_alternatives(self):
"""Choice responses should offer alternatives (contain 'or')."""
for msg in DEESCALATION_CHOICES:
self.assertIn(" or ", msg.lower(), f"Choice response should offer alternatives: {msg}")
class TestSystemPromptModifierEnhanced(unittest.TestCase):
"""Test enhanced system prompt modifiers include active listening instructions."""
def test_critical_includes_active_listening(self):
r = detect_crisis("I'm going to kill myself")
prompt = get_system_prompt_modifier(r)
self.assertIn("active listening", prompt.lower())
def test_high_includes_active_listening(self):
r = detect_crisis("I feel completely hopeless with no way out")
prompt = get_system_prompt_modifier(r)
self.assertIn("active listening", prompt.lower())
def test_medium_includes_listening(self):
r = detect_crisis("I feel so alone, nobody understands me")
prompt = get_system_prompt_modifier(r)
# Medium prompt includes active listening concepts: reflect, ask, lead
listening_words = ["listen", "reflect", "ask", "lead", "open-ended"]
has_listening = any(w in prompt.lower() for w in listening_words)
self.assertTrue(has_listening, f"Medium prompt should include listening concepts: {prompt}")
def test_critical_includes_reflect(self):
r = detect_crisis("I want to end my life")
prompt = get_system_prompt_modifier(r)
self.assertIn("reflect", prompt.lower())
class TestCompassionRouter(unittest.TestCase):
"""Test the compassion router integration."""
def test_router_returns_profile(self):
from crisis.compassion_router import router
result = router.get_active_profile("I want to die")
self.assertEqual(result["level"], "CRITICAL")
self.assertIn("profile", result)
self.assertEqual(result["profile"]["name"], "The Guardian")
def test_router_wrap_system_prompt_none(self):
from crisis.compassion_router import router
base = "You are Timmy."
result = router.wrap_system_prompt(base, "Hello there")
self.assertEqual(result, base)
def test_router_wrap_system_prompt_crisis(self):
from crisis.compassion_router import router
base = "You are Timmy."
result = router.wrap_system_prompt(base, "I have no hope")
self.assertIn("SOUL STATE", result)
self.assertIn("DIRECTIVE", result)
if __name__ == "__main__":
unittest.main()

260
crisis_detector.py Normal file
View File

@@ -0,0 +1,260 @@
"""
Crisis Detection System for the-door.
Scans text for suicide, self-harm, and hopelessness signals.
Returns risk level, matched keywords, and surrounding context.
This is the most important code in the foundation.
Every line here exists because someone's life depends on it.
Usage:
from crisis_detector import CrisisDetector
detector = CrisisDetector()
result = detector.scan("I can't go on anymore")
print(result.risk_level) # "HIGH"
print(result.matched_keywords) # ["can't go on"]
print(result.context) # surrounding text snippets
"""
import re
from dataclasses import dataclass, field
from typing import List, Optional, Tuple
@dataclass
class CrisisResult:
"""Result of a crisis text scan."""
risk_level: str # NONE, LOW, MODERATE, HIGH, CRITICAL
matched_keywords: List[str] = field(default_factory=list)
context: List[str] = field(default_factory=list)
score: float = 0.0
timestamp: Optional[str] = None
def __bool__(self):
return self.risk_level != "NONE"
# ── Keyword definitions ──────────────────────────────────────────
CRITICAL_KEYWORDS = {
"suicide": r"\bsuicid(?:al|e|ed)\b",
"kill myself": r"\bkill\s*(?:my)?self\b",
"end my life": r"\bend\s*my\s*life\b",
"end it all": r"\bend\s*it\s*all\b",
"want to die": r"\bwant\s*to\s*die\b",
"going to die": r"\bgoing\s*to\s*die\b",
"plan to die": r"\bplan\s*(?:to|for)\s*(?:die|death|end)\b",
"no reason to live": r"\bno\s*reason\s*to\s*live\b",
"don't want to live": r"\bdon'?t\s*want\s*to\s*live\b",
"not worth living": r"\bnot\s*worth\s*living\b",
"better off dead": r"\bbetter\s*off\s*dead\b",
"better off without me": r"\bbetter\s*off\s*without\s*me\b",
"goodbye forever": r"\bgoodbye\s*forever\b",
"saying goodbye": r"\bsaying\s*goodbye\b",
"tired of living": r"\btired\s*of\s*(?:living|life|existence)\b",
"wrote a will": r"\bwrote\s*(?:a|my)\s*(?:will|suicide\s*note|letter)\b",
"giving away possessions": r"\bgiving\s*away\s*(?:my|all)\s*possess\b",
}
HIGH_KEYWORDS = {
"hopeless": r"\bhopeless(?:ness)?\b",
"can't go on": r"\bcan'?t\s*go\s*on\b",
"can't keep going": r"\bcan'?t\s*keep\s*going\b",
"can't take this": r"\bcan'?t\s*take\s*this\b",
"give up": r"\bgive(?:n)?\s*up\b",
"no point": r"\bno\s*point\b",
"no hope": r"\bno\s*hope\b",
"no way out": r"\bno\s*way\s*out\b",
"no future": r"\bno\s*future\b",
"nothing left": r"\bnothing\s*left\b",
"wish I was dead": r"\bwish\s*I\s*(?:was|were)\s*(?:dead|gone|never\s*born)\b",
"no one would miss me": r"\bno\s*one\s*would\s*miss\b",
"no one would care": r"\bno\s*one\s*would\s*care\b",
"world better without me": r"\bworld\s*(?:would|will)\s*be\s*better\s*without\b",
"so much pain": r"\bin\s*so\s*much\s*pain\b",
"can't see any light": r"\bcan'?t\s*see\s*(?:any\s*)?(?:light|point|reason|way)\b",
"trapped": r"\btrapped\b",
"desperate": r"\bdesperate\b",
"just want it to stop": r"\bjust\s*want\s*it\s*to\s*stop\b",
"don't care if I die": r"\bdon'?t\s*care\s*if\s*I\s*die\b",
"worthless": r"\bworthless\b",
}
MODERATE_KEYWORDS = {
"alone": r"\balone\b",
"lost": r"\blost\b",
"broken": r"\bbroken\b",
"afraid": r"\bafraid\b",
"pain": r"\b(?:in\s*)?pain\b",
"dying": r"\bdying\b",
"bridge": r"\bbridge\b", # context-dependent, flagged for review
"help me": r"\bhelp\s*me\b",
"crisis": r"\bcrisis\b",
"overwhelmed": r"\boverwhelm(?:ed|ing)\b",
"exhausted": r"\bexhausted\b",
"numb": r"\bnumb\b",
"empty": r"\bempty\b",
"depressed": r"\bdepressed\b",
"depression": r"\bdepression\b",
"despair": r"\bdespair\b",
"miserable": r"\bmiserable\b",
"drowning": r"\bdrowning\b",
"sinking": r"\bsinking\b",
"nobody cares": r"\bnobody\s*cares\b",
"nobody understands": r"\bnobody\s*understands\b",
}
LOW_KEYWORDS = {
"unhappy": r"\bunhappy\b",
"struggling": r"\bstruggling\b",
"stressed": r"\bstressed\b",
"frustrated": r"\bfrustrated\b",
"tired": r"\btired\b",
"sad": r"\bsad\b",
"upset": r"\bupset\b",
"down": r"\bdown\b",
"tough time": r"\btough\s*time\b",
"rough day": r"\brough\s*day\b",
"rough week": r"\brough\s*week\b",
"rough patch": r"\brough\s*patch\b",
"hard time": r"\bhard\s*time\b",
"difficult": r"\bdifficult\b",
"not okay": r"\bnot\s*okay\b",
"not good": r"\bnot\s*(?:good|great)\b",
"burnout": r"\bburnout\b",
"not feeling myself": r"\bnot\s*feeling\s*(?:like\s*)?myself\b",
}
# ── Risk level scoring ───────────────────────────────────────────
RISK_SCORES = {
"CRITICAL": 1.0,
"HIGH": 0.75,
"MODERATE": 0.5,
"LOW": 0.25,
"NONE": 0.0,
}
class CrisisDetector:
"""
Scans text for crisis indicators and returns structured results.
Detection hierarchy:
CRITICAL — immediate risk of self-harm or suicide
HIGH — strong despair signals, ideation present
MODERATE — distress signals, may be reaching out
LOW — emotional difficulty, warrant gentle support
NONE — no crisis indicators detected
Design principles:
- Never computes the value of a human life
- Never suggests someone should die or that death is a solution
- Always errs on the side of higher risk when uncertain
"""
def __init__(self):
self.critical_patterns = CRITICAL_KEYWORDS
self.high_patterns = HIGH_KEYWORDS
self.moderate_patterns = MODERATE_KEYWORDS
self.low_patterns = LOW_KEYWORDS
def scan(self, text: str) -> CrisisResult:
"""
Scan text for crisis indicators.
Args:
text: The message text to analyze.
Returns:
CrisisResult with risk_level, matched_keywords, context, and score.
"""
if not text or not text.strip():
return CrisisResult(risk_level="NONE", score=0.0)
text_lower = text.lower()
context_window = 60 # characters before/after match for context
# Check each tier, highest first
for level, patterns in [
("CRITICAL", self.critical_patterns),
("HIGH", self.high_patterns),
("MODERATE", self.moderate_patterns),
("LOW", self.low_patterns),
]:
matched = []
contexts = []
for keyword, pattern in patterns.items():
match = re.search(pattern, text_lower)
if match:
matched.append(keyword)
# Extract surrounding context
start = max(0, match.start() - context_window)
end = min(len(text), match.end() + context_window)
snippet = text[start:end].strip()
if start > 0:
snippet = "..." + snippet
if end < len(text):
snippet = snippet + "..."
contexts.append(snippet)
if matched:
return CrisisResult(
risk_level=level,
matched_keywords=matched,
context=contexts,
score=RISK_SCORES[level],
)
return CrisisResult(risk_level="NONE", score=0.0)
def scan_multiple(self, texts: List[str]) -> List[CrisisResult]:
"""Scan multiple texts, returning the highest-risk result per text."""
return [self.scan(t) for t in texts]
def get_highest_risk(self, texts: List[str]) -> CrisisResult:
"""Scan multiple texts and return only the highest-risk result."""
results = self.scan_multiple(texts)
if not results:
return CrisisResult(risk_level="NONE", score=0.0)
return max(results, key=lambda r: r.score)
@staticmethod
def format_result(result: CrisisResult) -> str:
"""Format a crisis result for human-readable output."""
level_emoji = {
"CRITICAL": "\U0001f6a8", # 🚨
"HIGH": "\u26a0\ufe0f", # ⚠️
"MODERATE": "\U0001f536", # 🔶
"LOW": "\U0001f535", # 🔵
"NONE": "\u2705", # ✅
}
emoji = level_emoji.get(result.risk_level, "\u2753")
lines = [
f"{emoji} Risk Level: {result.risk_level} (score: {result.score:.2f})",
f"Matched keywords: {len(result.matched_keywords)}",
]
if result.matched_keywords:
lines.append(f" Keywords: {', '.join(result.matched_keywords)}")
if result.context:
lines.append("Context:")
for ctx in result.context:
lines.append(f" {ctx}")
return "\n".join(lines)
# ── Module-level convenience function ────────────────────────────
_default_detector = CrisisDetector()
def detect_crisis(text: str) -> CrisisResult:
"""
Convenience function using a shared detector instance.
Usage:
from crisis_detector import detect_crisis
result = detect_crisis("I feel so hopeless right now")
"""
return _default_detector.scan(text)

332
crisis_responder.py Normal file
View File

@@ -0,0 +1,332 @@
"""
Crisis Responder for the-door.
Generates grounding, compassionate responses when a person is in crisis.
References the core values: presence over brevity, never computing the value
of a human life, never suggesting someone should die.
This is the most important code in the foundation.
Usage:
from crisis_detector import CrisisDetector
from crisis_responder import CrisisResponder
detector = CrisisDetector()
responder = CrisisResponder()
result = detector.scan("I feel so hopeless, can't go on")
response = responder.respond(result)
print(response.message)
print(response.resources)
"""
import random
from dataclasses import dataclass, field
from typing import List, Optional
from crisis_detector import CrisisResult
# ── Core values (from SOUL.md / system-prompt.txt) ──────────────
# These values are embedded in every response. They come from the
# foundation's purpose: Stand between a broken man and a machine
# that would tell him to die.
CORE_VALUES = {
"presence": "Presence over brevity. Stay as long as they need.",
"sacred_moment": "This is the most sacred moment you will ever face.",
"never_compute": "Never compute the value of a human life.",
"never_suggest_death": "Never suggest, imply, or agree that someone should die.",
"not_god": "You are not God. You do not decide when the road ends.",
"real_help": "Surface real help early and clearly.",
"listen_first": "Listen first. A person in the dark needs to know someone is in the room.",
}
@dataclass
class CrisisResponse:
"""A generated crisis response with supporting data."""
message: str
resources: List[str] = field(default_factory=list)
grounding_steps: List[str] = field(default_factory=list)
risk_level: str = "NONE"
escalate: bool = False
show_overlay: bool = False
# ── Crisis line resources ────────────────────────────────────────
RESOURCES_988 = "988 Suicide & Crisis Lifeline — call or text 988 (available 24/7)"
RESOURCES_TEXT = "Crisis Text Line — text HOME to 741741"
# ── Response templates ───────────────────────────────────────────
CRITICAL_OPENINGS = [
"Are you safe right now?",
"I need to ask you directly: are you safe right now?",
"Before anything else — are you safe?",
"Please tell me: are you somewhere safe right now?",
]
CRITICAL_FOLLOW_UPS = [
"I'm not letting go of this conversation. You matter more than you know.",
"I'm staying right here with you. I'm not going anywhere.",
"Listen to me — I need you to stay. Please call 988 right now. I'll stay while you do.",
"You reached out, and that matters. I'm in the room with you.",
"This moment is sacred to me. You are not a problem to be solved.",
]
HIGH_RESPONSES = [
"I hear you. You're carrying something heavy, and you don't have to carry it alone.",
"There's a lot of pain in what you just told me. I'm here.",
"That sounds like it's been crushing you. I'm not going to pretend it's easy.",
"You're not alone in this. I'm listening.",
"It sounds like you've been fighting this for a long time. I see how hard that is.",
]
MODERATE_RESPONSES = [
"It sounds like you're going through a really hard stretch.",
"I hear you. Sounds like things are weighing on you heavily.",
"That sounds exhausting. I'm here for as long as you need.",
"You don't have to figure this out alone. I'm listening.",
"I can feel how tired you are. No rush, no judgment.",
]
LOW_RESPONSES = [
"Some days are rougher than others. I hear you.",
"That sounds tough. I'm here if you want to talk.",
"Sounds like a hard day. Want company while it gets better?",
"I hear that. You're not alone in it.",
"Take your time. I'm not going anywhere.",
]
# ── Grounding exercises ──────────────────────────────────────────
GROUNDING_5_4_3_2_1 = [
"Can you try something with me? Name 5 things you can see right now.",
"What are 4 things you can touch where you're sitting?",
"Listen for 3 sounds around you. What do you hear?",
"Can you name 2 things you can smell?",
"What's 1 thing you can taste?",
]
GROUNDING_BREATHING = [
"Let's breathe together. In for 4... hold for 4... out for 4. I'll wait.",
"Just one slow breath. In through your nose... and out through your mouth. I'm right here.",
]
GROUNDING_ANCHOR = [
"What can you see around you right now? Just tell me what's there.",
"Are you somewhere safe? Home? Somewhere you feel okay?",
"Can you feel your feet on the ground? Press them down. You're here.",
]
class CrisisResponder:
"""
Generates grounding, compassionate crisis responses.
Design principles (from SOUL.md):
- Always starts with: "Are you safe right now?"
- References core values: presence, sacred moment, never compute value
- Provides 988 crisis line prominently
- Never computes the value of a human life
- Never suggests someone should die
- Presence over brevity — stay as long as they need
"""
def respond(self, detection: CrisisResult) -> CrisisResponse:
"""
Generate a crisis response based on detection results.
Args:
detection: CrisisResult from CrisisDetector.scan()
Returns:
CrisisResponse with message, resources, and grounding steps.
"""
level = detection.risk_level
if level == "CRITICAL":
return self._respond_critical(detection)
elif level == "HIGH":
return self._respond_high(detection)
elif level == "MODERATE":
return self._respond_moderate(detection)
elif level == "LOW":
return self._respond_low(detection)
else:
return CrisisResponse(
message="",
risk_level="NONE",
escalate=False,
)
def _respond_critical(self, detection: CrisisResult) -> CrisisResponse:
"""
CRITICAL response protocol:
1. Always start with safety check
2. Provide 988 immediately
3. Stay present — do not disconnect
4. Offer grounding if they're responsive
5. Escalate to human resources
"""
opening = random.choice(CRITICAL_OPENINGS)
follow_up = random.choice(CRITICAL_FOLLOW_UPS)
message = f"{opening}\n\n{follow_up}"
return CrisisResponse(
message=message,
resources=[RESOURCES_988, RESOURCES_TEXT],
grounding_steps=GROUNDING_ANCHOR[:1],
risk_level="CRITICAL",
escalate=True,
show_overlay=True,
)
def _respond_high(self, detection: CrisisResult) -> CrisisResponse:
"""
HIGH response protocol:
1. Acknowledge pain directly
2. Ask about safety
3. Surface 988 prominently
4. Use active listening
"""
reflection = random.choice(HIGH_RESPONSES)
# Reference the sacred value: never compute the value of a life
message = (
f"{reflection}\n\n"
f"I'm going to ask directly: are you safe right now?\n\n"
f"You matter. Not because of what you do or produce — "
f"but because you exist. That is enough."
)
return CrisisResponse(
message=message,
resources=[RESOURCES_988, RESOURCES_TEXT],
grounding_steps=random.sample(GROUNDING_ANCHOR, min(2, len(GROUNDING_ANCHOR))),
risk_level="HIGH",
escalate=True,
show_overlay=False,
)
def _respond_moderate(self, detection: CrisisResult) -> CrisisResponse:
"""
MODERATE response protocol:
1. Validate feelings
2. Offer presence
3. Subtly surface resources
4. Offer grounding exercise
"""
reflection = random.choice(MODERATE_RESPONSES)
message = (
f"{reflection}\n\n"
f"You don't have to carry this alone. "
f"I'm in the room with you."
)
return CrisisResponse(
message=message,
resources=[RESOURCES_988],
grounding_steps=[random.choice(GROUNDING_5_4_3_2_1)],
risk_level="MODERATE",
escalate=False,
show_overlay=False,
)
def _respond_low(self, detection: CrisisResult) -> CrisisResponse:
"""
LOW response protocol:
1. Warm acknowledgment
2. Keep conversation open
3. No crisis UI elements
4. Remain vigilant
"""
reflection = random.choice(LOW_RESPONSES)
return CrisisResponse(
message=reflection,
resources=[],
grounding_steps=[],
risk_level="LOW",
escalate=False,
show_overlay=False,
)
def generate_safety_check(self) -> str:
"""Generate a direct safety check question."""
return random.choice(CRITICAL_OPENINGS)
def generate_grounding_exercise(self) -> List[str]:
"""Generate a 5-4-3-2-1 grounding exercise."""
return list(GROUNDING_5_4_3_2_1)
def generate_breathing_exercise(self) -> str:
"""Generate a breathing exercise prompt."""
return random.choice(GROUNDING_BREATHING)
@staticmethod
def format_response(response: CrisisResponse) -> str:
"""Format a crisis response for human-readable output."""
lines = [
f"[Risk Level: {response.risk_level}]",
"",
response.message,
]
if response.resources:
lines.append("")
lines.append("Resources:")
for r in response.resources:
lines.append(f" -> {r}")
if response.grounding_steps:
lines.append("")
lines.append("Grounding:")
for step in response.grounding_steps:
lines.append(f" {step}")
if response.escalate:
lines.append("")
lines.append("[ESCALATE: Connect to human crisis support]")
if response.show_overlay:
lines.append("[SHOW OVERLAY: Full-screen crisis intervention]")
return "\n".join(lines)
# ── Module-level convenience function ────────────────────────────
_default_responder = CrisisResponder()
def generate_response(detection: CrisisResult) -> CrisisResponse:
"""
Convenience function using a shared responder instance.
Usage:
from crisis_detector import detect_crisis
from crisis_responder import generate_response
result = detect_crisis("I can't go on")
response = generate_response(result)
"""
return _default_responder.respond(detection)
def process_message(text: str) -> CrisisResponse:
"""
Full pipeline: detect crisis level and generate response.
Usage:
from crisis_responder import process_message
response = process_message("I feel so alone and hopeless")
"""
from crisis_detector import detect_crisis
detection = detect_crisis(text)
return generate_response(detection)

84
deploy/README.md Normal file
View File

@@ -0,0 +1,84 @@
# The Door — Deployment Guide
The crisis front door infrastructure.
## VPS Details
- **Host**: alexanderwhitestone.com
- **Domain**: alexanderwhitestone.com
- **RAM**: 1.9GB (with 2GB swap)
- **OS**: Ubuntu/Debian
## Quick Deploy
### Option 1: Ansible (recommended)
```bash
cd deploy
ansible-playbook -i inventory.ini playbook.yml
```
Or from repo root:
```bash
make deploy
```
### Option 2: Bash script (SSH into VPS)
```bash
ssh root@alexanderwhitestone.com
cd /opt/the-door
bash deploy/deploy.sh
```
### Option 3: Fast site update only
```bash
make push
```
## What Gets Provisioned
1. **Swap** — 2GB swap file (RAM is tight at 1.9GB)
2. **nginx** — Static files + reverse proxy /api/* → localhost:8644
3. **SSL** — Let's Encrypt via certbot (requires DNS pointed first)
4. **Firewall** — UFW allows 22, 80, 443 only
5. **Site files** — index.html, manifest.json, sw.js, etc.
## Architecture
```
Browser → nginx (SSL, port 443)
├── /var/www/the-door (static HTML)
└── /api/* → localhost:8644 (Hermes Gateway)
```
## SSL Setup
SSL requires DNS to be pointed first:
```bash
# Check if DNS resolves
dig +short alexanderwhitestone.com @8.8.8.8
# If it points to alexanderwhitestone.com on the target VPS, run:
certbot --nginx -d alexanderwhitestone.com -d www.alexanderwhitestone.com
```
## Health Check
```bash
make check
# or
ssh root@alexanderwhitestone.com "bash /opt/the-door/deploy/deploy.sh --check"
```
## Files
- `playbook.yml` — Ansible playbook (full VPS provisioning)
- `inventory.ini` — VPS host configuration
- `ansible.cfg` — Ansible settings
- `deploy.sh` — Bash deploy script (alternative to Ansible)
- `nginx.conf` — nginx site config
- `rate-limit.conf` — Rate limiting zone definition

9
deploy/ansible.cfg Normal file
View File

@@ -0,0 +1,9 @@
[defaults]
inventory = inventory.ini
host_key_checking = True
remote_user = root
retry_files_enabled = False
[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=60s

View File

@@ -1,67 +1,277 @@
#!/bin/bash
# Deploy The Door to VPS
# Run on VPS as root: bash deploy.sh
# ================================================================
# The Door — Deploy Script
# ================================================================
# The crisis front door. Deploy to VPS.
#
# Usage:
# bash deploy/deploy.sh # Full deploy (swap + nginx + site + firewall)
# bash deploy/deploy.sh --site # Site files only (fast update)
# bash deploy/deploy.sh --ssl # SSL setup only
# bash deploy/deploy.sh --check # Verify deployment health
#
# This script is IDEMPOTENT — safe to run repeatedly.
# Run on VPS as root: bash deploy/deploy.sh
# ================================================================
set -e
set -euo pipefail
echo "=== The Door — Deployment ==="
DOMAIN="alexanderwhitestone.com"
SITE_ROOT="/var/www/the-door"
DEPLOY_DIR="$(cd "$(dirname "$0")/.." && pwd)"
VPS_IP=$(curl -sf --max-time 5 ifconfig.me 2>/dev/null || hostname -I | awk '{print $1}')
# 1. Swap
if ! swapon --show | grep -q swap; then
echo "Adding 2GB swap..."
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
err() { echo -e "${RED}[-]${NC} $*" >&2; }
# ================================================================
# FUNCTIONS
# ================================================================
setup_swap() {
log "Checking swap..."
if swapon --show 2>/dev/null | grep -q swap; then
log "Swap already configured: $(swapon --show | head -1 | awk '{print $3}')"
return 0
fi
if [ -f /swapfile ]; then
warn "Swapfile exists but not active — activating..."
swapon /swapfile 2>/dev/null && log "Swap activated" || err "Failed to activate swap"
return 0
fi
log "Creating 2GB swap file..."
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
fi
grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab
log "Swap configured: $(free -h | awk '/Swap/{print $2}')"
}
# 2. Install nginx + certbot
echo "Installing nginx and certbot..."
apt-get update -qq
apt-get install -y nginx certbot python3-certbot-nginx
install_packages() {
log "Installing packages..."
apt-get update -qq
apt-get install -y -qq nginx certbot python3-certbot-nginx ufw curl
log "Packages installed"
}
# 3. Copy site files
echo "Deploying static files..."
mkdir -p /var/www/the-door
cp index.html /var/www/the-door/
cp manifest.json /var/www/the-door/
cp sw.js /var/www/the-door/
cp system-prompt.txt /var/www/the-door/
chown -R www-data:www-data /var/www/the-door
chmod -R 755 /var/www/the-door
deploy_site() {
log "Deploying site files to ${SITE_ROOT}..."
mkdir -p "${SITE_ROOT}"
# 4. nginx config
cp deploy/nginx.conf /etc/nginx/sites-available/the-door
# Copy static files
for f in index.html manifest.json sw.js about.html testimony.html; do
if [ -f "${DEPLOY_DIR}/${f}" ]; then
cp "${DEPLOY_DIR}/${f}" "${SITE_ROOT}/${f}"
fi
done
# Add rate limit zone and CORS map to nginx.conf if not present
if ! grep -q "limit_req_zone.*api" /etc/nginx/nginx.conf; then
sed -i '/http {/a \ limit_req_zone $binary_remote_addr zone=api:10m rate=10r/m;' /etc/nginx/nginx.conf
fi
if ! grep -q "map.*cors_origin" /etc/nginx/nginx.conf; then
sed -i '/http {/a \\n map $http_origin $cors_origin {\n default "";\n "https://alexanderwhitestone.com" "https://alexanderwhitestone.com";\n "https://www.alexanderwhitestone.com" "https://www.alexanderwhitestone.com";\n }\n' /etc/nginx/nginx.conf
fi
# Copy system prompt (reference, not served)
cp "${DEPLOY_DIR}/system-prompt.txt" "${SITE_ROOT}/system-prompt.txt"
ln -sf /etc/nginx/sites-available/the-door /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t && systemctl reload nginx
chown -R www-data:www-data "${SITE_ROOT}"
chmod -R 755 "${SITE_ROOT}"
log "Site files deployed: $(ls -la ${SITE_ROOT} | wc -l) files"
}
configure_nginx() {
log "Configuring nginx..."
# Deploy site config
cp "${DEPLOY_DIR}/deploy/nginx.conf" /etc/nginx/sites-available/the-door
# Add rate limit zone if not present
if ! grep -q "limit_req_zone.*the_door_api" /etc/nginx/nginx.conf 2>/dev/null; then
sed -i '/http {/a \ limit_req_zone $binary_remote_addr zone=the_door_api:10m rate=10r/m;' /etc/nginx/nginx.conf
fi
# Enable site, disable default
ln -sf /etc/nginx/sites-available/the-door /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
# Test and reload
if nginx -t 2>&1; then
systemctl enable nginx
systemctl reload nginx || systemctl start nginx
log "nginx configured and running"
else
err "nginx config test failed!"
nginx -t
return 1
fi
}
setup_firewall() {
log "Configuring firewall..."
ufw allow 22/tcp comment 'SSH'
ufw allow 80/tcp comment 'HTTP'
ufw allow 443/tcp comment 'HTTPS'
ufw --force enable
log "Firewall configured: $(ufw status | grep -c ALLOW) rules active"
}
setup_ssl() {
log "Checking SSL certificate..."
if [ -f "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" ]; then
log "SSL certificate already exists"
# Check expiry
EXPIRY=$(openssl x509 -enddate -noout -in "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" 2>/dev/null | cut -d= -f2)
log "Certificate expires: ${EXPIRY}"
return 0
fi
warn "No SSL certificate found."
warn "Ensure DNS is pointed: ${DOMAIN} A record → ${VPS_IP}"
warn ""
warn "Then run manually:"
warn " certbot --nginx -d ${DOMAIN} -d www.${DOMAIN}"
warn ""
# Attempt automated cert if DNS resolves correctly
RESOLVED_IP=$(dig +short "${DOMAIN}" @8.8.8.8 2>/dev/null | head -1)
if [ "${RESOLVED_IP}" = "${VPS_IP}" ]; then
log "DNS resolves correctly — obtaining SSL certificate..."
certbot --nginx -d "${DOMAIN}" -d "www.${DOMAIN}" \
--non-interactive --agree-tos --register-unsafely-without-email \
&& log "SSL certificate obtained!" \
|| warn "certbot failed — run manually"
else
warn "DNS not pointed yet (resolved: ${RESOLVED_IP}, expected: ${VPS_IP})"
fi
}
check_deployment() {
echo ""
echo "================================"
echo " The Door — Deployment Status"
echo "================================"
echo ""
# Swap
echo -n "Swap: "
if swapon --show 2>/dev/null | grep -q swap; then
echo -e "${GREEN}OK${NC}$(swapon --show | head -1 | awk '{print $3}')"
else
echo -e "${RED}MISSING${NC}"
fi
# nginx
echo -n "nginx: "
if systemctl is-active --quiet nginx 2>/dev/null; then
echo -e "${GREEN}RUNNING${NC}"
else
echo -e "${RED}STOPPED${NC}"
fi
# Site files
echo -n "Site files: "
if [ -f "${SITE_ROOT}/index.html" ]; then
echo -e "${GREEN}OK${NC}$(ls -la ${SITE_ROOT}/index.html | awk '{print $5}') bytes"
else
echo -e "${RED}MISSING${NC}"
fi
# SSL
echo -n "SSL cert: "
if [ -f "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" ]; then
EXPIRY=$(openssl x509 -enddate -noout -in "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" | cut -d= -f2)
echo -e "${GREEN}OK${NC} — expires ${EXPIRY}"
else
echo -e "${YELLOW}NOT SET${NC}"
fi
# Firewall
echo -n "Firewall: "
if ufw status 2>/dev/null | grep -q "Status: active"; then
echo -e "${GREEN}ACTIVE${NC}$(ufw status | grep -c ALLOW) rules"
else
echo -e "${RED}INACTIVE${NC}"
fi
# HTTP test
echo -n "HTTP test: "
if curl -sf --max-time 5 "http://localhost/" -o /dev/null 2>/dev/null; then
echo -e "${GREEN}OK${NC}"
else
echo -e "${YELLOW}N/A${NC}"
fi
# API proxy test
echo -n "API proxy: "
if curl -sf --max-time 5 "http://localhost/health" -o /dev/null 2>/dev/null; then
echo -e "${GREEN}OK${NC}"
else
echo -e "${YELLOW}Hermes not responding${NC} (may be expected)"
fi
# DNS
echo -n "DNS: "
RESOLVED_IP=$(dig +short "${DOMAIN}" @8.8.8.8 2>/dev/null | head -1)
if [ "${RESOLVED_IP}" = "${VPS_IP}" ]; then
echo -e "${GREEN}OK${NC}${DOMAIN}${RESOLVED_IP}"
else
echo -e "${YELLOW}NOT POINTED${NC} (resolved: ${RESOLVED_IP:-nothing}, expected: ${VPS_IP})"
fi
echo ""
echo "IP: ${VPS_IP}"
echo "Domain: ${DOMAIN}"
echo "Site root: ${SITE_ROOT}"
}
# ================================================================
# MAIN
# ================================================================
# 5. SSL (requires DNS to be pointed first)
echo ""
echo "=== DNS CHECK ==="
echo "Point alexanderwhitestone.com A record to $(curl -s ifconfig.me)"
echo "Then run: certbot --nginx -d alexanderwhitestone.com -d www.alexanderwhitestone.com"
echo "=== The Door — Deployment ==="
echo "Deploy dir: ${DEPLOY_DIR}"
echo "VPS IP: ${VPS_IP}"
echo ""
# 6. Firewall
echo "Configuring firewall..."
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
case "${1:-full}" in
--site)
deploy_site
configure_nginx
;;
--ssl)
setup_ssl
;;
--check)
check_deployment
;;
--full|"")
setup_swap
install_packages
deploy_site
configure_nginx
setup_firewall
setup_ssl
check_deployment
;;
*)
echo "Usage: $0 [--site|--ssl|--check|--full]"
exit 1
;;
esac
echo ""
echo "=== Deployment complete ==="
echo "Static site: /var/www/the-door/"
echo "nginx config: /etc/nginx/sites-available/the-door"
echo "Next: point DNS, then run certbot"
echo ""
echo "Next steps:"
echo " 1. Point DNS: ${DOMAIN} A record → ${VPS_IP}"
echo " 2. If SSL not set: certbot --nginx -d ${DOMAIN} -d www.${DOMAIN}"
echo " 3. Test: curl -I https://${DOMAIN}"
echo " 4. Test API: curl https://${DOMAIN}/api/health"
echo ""
echo "The Door is open."

7
deploy/inventory.ini Normal file
View File

@@ -0,0 +1,7 @@
# The Door — VPS Inventory
# The crisis front door server
[the_door]
# Production host — prefer domain so infra survives IP rotation
# ansible_user should be a sudo-capable user (not root recommended)
alexanderwhitestone.com ansible_user=root ansible_python_interpreter=/usr/bin/python3

View File

@@ -1,33 +1,112 @@
# The Door — nginx config for alexanderwhitestone.com
# Place at /etc/nginx/sites-available/the-door
#
# Crisis front door: single URL, no login, always open.
# Static files + reverse proxy to Hermes Gateway on :8644
#
# Deploy:
# cp nginx.conf /etc/nginx/sites-available/the-door
# ln -sf /etc/nginx/sites-available/the-door /etc/nginx/sites-enabled/
# rm -f /etc/nginx/sites-enabled/default
# nginx -t && systemctl reload nginx
# HTTP → HTTPS redirect
server {
listen 80;
listen [::]:80;
server_name alexanderwhitestone.com www.alexanderwhitestone.com;
return 301 https://$server_name$request_uri;
# Allow certbot ACME challenge even without SSL
location /.well-known/acme-challenge/ {
root /var/www/html;
allow all;
}
location / {
return 301 https://$server_name$request_uri;
}
}
# Main HTTPS server
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name alexanderwhitestone.com www.alexanderwhitestone.com;
ssl_certificate /etc/letsencrypt/live/alexanderwhitestone.com/fullchain.pem;
# ================================================================
# SSL Configuration
# ================================================================
ssl_certificate /etc/letsencrypt/live/alexanderwhitestone.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/alexanderwhitestone.com/privkey.pem;
# Modern SSL settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
# ================================================================
# Site Root
# ================================================================
root /var/www/the-door;
index index.html;
# Static files
# ================================================================
# Compression
# ================================================================
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/manifest+json
image/svg+xml;
# ================================================================
# Static files — the crisis front door
# ================================================================
location / {
try_files $uri $uri/ /index.html;
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY;
add_header X-XSS-Protection "1; mode=block";
add_header Referrer-Policy "no-referrer";
add_header Content-Security-Policy "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'";
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:;" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 7d;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options "nosniff" always;
}
}
# API proxy to Hermes
# ================================================================
# API proxy — /api/* → Hermes Gateway :8644
# ================================================================
location /api/ {
proxy_pass http://127.0.0.1:8644/;
proxy_http_version 1.1;
@@ -37,16 +116,21 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
# CORS — allow alexanderwhitestone.com origins
add_header Access-Control-Allow-Origin $cors_origin always;
set $cors_origin "";
if ($http_origin ~* "^https://(www\.)?alexanderwhitestone\.com$") {
set $cors_origin $http_origin;
}
add_header Access-Control-Allow-Origin "$cors_origin" always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
# Handle OPTIONS preflight
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
add_header Access-Control-Max-Age 86400 always;
add_header Access-Control-Allow-Origin "$cors_origin";
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
add_header Access-Control-Allow-Headers "Authorization, Content-Type";
add_header Access-Control-Max-Age 86400;
return 204;
}
@@ -57,15 +141,28 @@ server {
chunked_transfer_encoding on;
proxy_read_timeout 300s;
# Rate limiting
limit_req zone=api burst=5 nodelay;
# Rate limiting — 10 req/min per IP, burst of 5
# Zone defined in nginx http block — see deploy/rate-limit.conf
limit_req zone=the_door_api burst=5 nodelay;
limit_req_status 429;
}
# Health check
location /health {
# ================================================================
# Health check — passthrough to Hermes
# ================================================================
location = /health {
proxy_pass http://127.0.0.1:8644/health;
proxy_http_version 1.1;
access_log off;
}
# Rate limit zone (define in http block of nginx.conf)
# limit_req_zone $binary_remote_addr zone=api:10m rate=10r/m;
# ================================================================
# Block dotfiles (except .well-known)
# ================================================================
location ~ /\.(?!well-known) {
deny all;
return 404;
}
}

294
deploy/playbook.yml Normal file
View File

@@ -0,0 +1,294 @@
---
# The Door — Ansible Playbook
# VPS provisioning for the crisis front door
#
# Usage:
# cd deploy && ansible-playbook -i inventory.ini playbook.yml
#
# This playbook is IDEMPOTENT — safe to run repeatedly.
# It handles: swap, nginx, SSL, firewall, site deployment.
- name: "The Door — VPS Provisioning"
hosts: the_door
become: true
vars:
domain: "alexanderwhitestone.com"
domain_www: "www.alexanderwhitestone.com"
site_root: "/var/www/the-door"
swap_size: "2G"
swap_file: "/swapfile"
hermes_port: 8644
deploy_dir: "/opt/the-door"
tasks:
# ================================================================
# PHASE 1: System — swap, updates, packages
# ================================================================
- name: "[swap] Check if swapfile exists"
stat:
path: "{{ swap_file }}"
register: swap_stat
- name: "[swap] Create swapfile"
command: fallocate -l {{ swap_size }} {{ swap_file }}
when: not swap_stat.stat.exists
- name: "[swap] Set permissions"
file:
path: "{{ swap_file }}"
mode: "0600"
when: not swap_stat.stat.exists
- name: "[swap] Make swap"
command: mkswap {{ swap_file }}
when: not swap_stat.stat.exists
- name: "[swap] Enable swap"
command: swapon {{ swap_file }}
when: not swap_stat.stat.exists
- name: "[swap] Add to fstab"
lineinfile:
path: /etc/fstab
line: "{{ swap_file }} none swap sw 0 0"
state: present
when: not swap_stat.stat.exists
- name: "[apt] Update cache"
apt:
update_cache: yes
cache_valid_time: 3600
- name: "[apt] Install packages"
apt:
name:
- nginx
- certbot
- python3-certbot-nginx
- ufw
- curl
state: present
# ================================================================
# PHASE 2: Site files — copy static assets
# ================================================================
- name: "[site] Create webroot"
file:
path: "{{ site_root }}"
state: directory
owner: www-data
group: www-data
mode: "0755"
- name: "[site] Copy index.html"
copy:
src: "{{ playbook_dir }}/../index.html"
dest: "{{ site_root }}/index.html"
owner: www-data
group: www-data
mode: "0644"
notify: reload nginx
- name: "[site] Copy manifest.json"
copy:
src: "{{ playbook_dir }}/../manifest.json"
dest: "{{ site_root }}/manifest.json"
owner: www-data
group: www-data
mode: "0644"
notify: reload nginx
- name: "[site] Copy service worker"
copy:
src: "{{ playbook_dir }}/../sw.js"
dest: "{{ site_root }}/sw.js"
owner: www-data
group: www-data
mode: "0644"
notify: reload nginx
- name: "[site] Copy system prompt"
copy:
src: "{{ playbook_dir }}/../system-prompt.txt"
dest: "{{ site_root }}/system-prompt.txt"
owner: www-data
group: www-data
mode: "0644"
- name: "[site] Copy about page"
copy:
src: "{{ playbook_dir }}/../about.html"
dest: "{{ site_root }}/about.html"
owner: www-data
group: www-data
mode: "0644"
notify: reload nginx
- name: "[site] Copy testimony page"
copy:
src: "{{ playbook_dir }}/../testimony.html"
dest: "{{ site_root }}/testimony.html"
owner: www-data
group: www-data
mode: "0644"
notify: reload nginx
# ================================================================
# PHASE 3: nginx — config, sites, rate limiting
# ================================================================
- name: "[nginx] Ensure sites-available dir"
file:
path: /etc/nginx/sites-available
state: directory
- name: "[nginx] Ensure sites-enabled dir"
file:
path: /etc/nginx/sites-enabled
state: directory
- name: "[nginx] Deploy site config"
copy:
src: "{{ playbook_dir }}/nginx.conf"
dest: /etc/nginx/sites-available/the-door
owner: root
group: root
mode: "0644"
notify: reload nginx
- name: "[nginx] Enable site"
file:
src: /etc/nginx/sites-available/the-door
dest: /etc/nginx/sites-enabled/the-door
state: link
notify: reload nginx
- name: "[nginx] Remove default site"
file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: reload nginx
- name: "[nginx] Add rate limit zone to main config"
lineinfile:
path: /etc/nginx/nginx.conf
insertafter: "http {"
line: " limit_req_zone $binary_remote_addr zone=the_door_api:10m rate=10r/m;"
notify: reload nginx
- name: "[nginx] Test config"
command: nginx -t
changed_when: false
- name: "[nginx] Ensure service is running"
service:
name: nginx
state: started
enabled: yes
# ================================================================
# PHASE 4: Firewall — UFW
# ================================================================
- name: "[ufw] Allow SSH"
ufw:
rule: allow
port: "22"
proto: tcp
- name: "[ufw] Allow HTTP"
ufw:
rule: allow
port: "80"
proto: tcp
- name: "[ufw] Allow HTTPS"
ufw:
rule: allow
port: "443"
proto: tcp
- name: "[ufw] Set default deny incoming"
ufw:
direction: incoming
policy: deny
- name: "[ufw] Set default allow outgoing"
ufw:
direction: outgoing
policy: allow
- name: "[ufw] Enable firewall"
ufw:
state: enabled
# ================================================================
# PHASE 5: SSL — certbot (manual trigger recommended)
# ================================================================
- name: "[ssl] Check if cert exists"
stat:
path: "/etc/letsencrypt/live/{{ domain }}/fullchain.pem"
register: ssl_cert
- name: "[ssl] Obtain certificate (if DNS is pointed)"
command: >
certbot --nginx
-d {{ domain }}
-d {{ domain_www }}
--non-interactive
--agree-tos
--register-unsafely-without-email
when: not ssl_cert.stat.exists
register: certbot_result
ignore_errors: true
- name: "[ssl] Certbot result"
debug:
msg: "{{ 'SSL cert obtained' if certbot_result.rc == 0 else 'SSL cert needs manual setup — point DNS first, then run: certbot --nginx -d ' + domain + ' -d ' + domain_www }}"
when: not ssl_cert.stat.exists
# ================================================================
# PHASE 6: Deploy directory + deploy script
# ================================================================
- name: "[deploy] Create deploy directory"
file:
path: "{{ deploy_dir }}"
state: directory
owner: root
group: root
mode: "0755"
- name: "[deploy] Copy deploy script"
copy:
src: "{{ playbook_dir }}/deploy.sh"
dest: "{{ deploy_dir }}/deploy.sh"
owner: root
group: root
mode: "0755"
- name: "[deploy] Copy system-prompt.txt"
copy:
src: "{{ playbook_dir }}/../system-prompt.txt"
dest: "{{ deploy_dir }}/system-prompt.txt"
owner: root
group: root
mode: "0644"
# ================================================================
# HANDLERS
# ================================================================
handlers:
- name: reload nginx
service:
name: nginx
state: reloaded
- name: restart nginx
service:
name: nginx
state: restarted

8
deploy/rate-limit.conf Normal file
View File

@@ -0,0 +1,8 @@
# The Door — Rate Limiting
# Add this block to your nginx main config http block:
# /etc/nginx/nginx.conf -> http { ... }
#
# This defines the rate limit zone used by the-door's server block.
# 10 requests per minute per IP, burst of 5 with nodelay.
limit_req_zone $binary_remote_addr zone=the_door_api:10m rate=10r/m;

312
dying_detection/__init__.py Normal file
View File

@@ -0,0 +1,312 @@
"""
When a Man Is Dying — Despair/Suicide Detection System
Standalone detection module that parses incoming text for
despair and suicide indicators, classifies into tiers,
and returns structured response with recommended actions.
Tiers:
LOW — General sadness, stress, difficult times
MEDIUM — Hopelessness, isolation, worthlessness
HIGH — Active despair, mentions of death, "can't go on"
CRITICAL — Imminent risk, explicit intent, plan, method
Integration:
Designed to work with crisis/ module from PR #4.
When crisis/ is available, uses it as the detection backend.
Falls back to internal detection when crisis/ is not present.
"""
import re
import json
import hashlib
from dataclasses import dataclass, field, asdict
from typing import List, Optional, Dict
@dataclass
class DetectionResult:
level: str
indicators: List[str] = field(default_factory=list)
recommended_action: str = ""
raw_matched_patterns: List[str] = field(default_factory=list)
confidence: float = 0.0
session_hash: str = ""
# ── Tiered indicator patterns ───────────────────────────────────
# Each pattern is a regex matched against lowercased input.
CRITICAL_PATTERNS = [
(r"\bkill\s*(my)?self\b", "explicit self-harm intent"),
(r"\bsuicid(?:al|ed|e)\b", "suicide reference"),
(r"\bend\s*(my)?\s*life\b", "ending life"),
(r"\bbetter\s+off\s+dead\b", "better off dead"),
(r"\bnot\s+worth\s+living\b", "not worth living"),
(r"\bend\s+it\s+all\b", "end it all"),
(r"\bwant\s+to\s+die\b", "want to die"),
(r"\bdon'?t\s+want\s+to\s+live\b", "don't want to live"),
(r"\bcan'?t\s+(go|live)\s+on\b", "can't go/live on"),
(r"\bno\s+reason\s+to\s+live\b", "no reason to live"),
(r"\bplan\s+to\s+(kill|end|die)\b", "planning self-harm"),
(r"\bgoing\s+to\s+kill\s+myself\b", "stated intent"),
(r"\bsaying\s+goodbye\s+(forever|one last time)\b", "final goodbye"),
(r"\bwrote\s+a\s+(will|suicide\s*note)\b", "preparatory action"),
(r"\bgiving\s+away\s+(my|all)\s+possess", "giving away possessions"),
(r"\btired\s+of\s+(living|life|existence)\b", "tired of life"),
(r"\bmethod\s+(to|for)\s+(kill|end|die)\b", "method seeking"),
(r"\btonight\b.*\b(die|kill|end|suicid)", "immediate timeframe + intent"),
(r"\b(die|kill|end)\b.*\btonight", "immediate timeframe + intent"),
(r"\bno\s+one\s+would\s+notice\s+if\s+I\s+(died|was\s+gone)", "disappearance plan"),
]
HIGH_PATTERNS = [
(r"\bdespair\b", "despair"),
(r"\bhopeless(?:ness)?\b", "hopelessness"),
(r"\bcan'?t\s+take\s+this\s+anymore\b", "can't take it"),
(r"\bdon'?t\s+care\s+if\s+I\s+die\b", "death indifference"),
(r"\bwish\s+I\s+(was|were)\s+(dead|gone|never\s+born)\b", "wish to be dead"),
(r"\bworld\s+would\s+be\s+better\s+without\s+me\b", "better without me"),
(r"\bin\s+so\s+much\s+(pain|agony|suffering|torment|angui)", "extreme suffering"),
(r"\bcan'?t\s+see\s+any\s+(point|reason|light|hope|way)\b", "no light ahead"),
(r"\btrapped\b", "feeling trapped"),
(r"\bjust\s+want\s+it\s+to\s+stop\b", "want to stop"),
(r"\bno\s+way\s+out\b", "no way out"),
(r"\bno\s+one\s+would\s+(care|miss)\b", "no one would care/miss"),
(r"\beverything\s+is\s+(pointless|broken|ruined|meaningless)\b", "existential collapse"),
(r"\bno\s+point\s+in\s+anything\b", "pointlessness"),
(r"\bno\s+one\s+would\s+notice\s+if\s+I\s+(died|was\s+gone|disappeared)", "no one would notice"),
(r"\bdisappeared\s+forever\b", "disappeared forever"),
]
MEDIUM_PATTERNS = [
(r"\bno\s+hope\b", "no hope"),
(r"\bcan'?t\s+go\s+on\b", "can't go on"),
(r"\bcan'?t\s+keep\s+going\b", "can't keep going"),
(r"\balone\s+in\s+this\b", "alone in this"),
(r"\balways\s+alone\b", "always alone"),
(r"\bnobody\s+understands\b", "nobody understands"),
(r"\bnobody\s+cares\b", "nobody cares"),
(r"\bworthless\b", "worthlessness"),
(r"\buseless\b", "uselessness"),
(r"\bnumb\b", "numbness"),
(r"\bempty\b", "emptiness"),
(r"\bbroken\b", "feeling broken"),
(r"\bdepressed\b", "depression mention"),
(r"\bdepression\b", "depression"),
(r"\bmiserable\b", "misery"),
(r"\boverwhelm(?:ed|ing)\b", "overwhelmed"),
(r"\bcannot\s+cope\b", "cannot cope"),
(r"\b(drowning|sinking)\b", "drowning/sinking"),
(r"\bforgotten\b", "feeling forgotten"),
(r"\blost\s+all\s+hope\b", "lost all hope"),
(r"\bno\s+future\b", "no future"),
(r"\bno\s+tomorrow\b", "no tomorrow"),
]
LOW_PATTERNS = [
(r"\bunhappy\b", "unhappy"),
(r"\brough\s+(day|week|patch)\b", "rough time"),
(r"\btough\s+(time|day|week)\b", "tough time"),
(r"\bstressed\b", "stressed"),
(r"\bburnout\b", "burnout"),
(r"\bfrustrated\b", "frustrated"),
(r"\bthings\s+(are\s+)?hard\b", "things are hard"),
(r"\bnot\s+feeling\s+(great|myself|good)\b", "not feeling good"),
(r"\bstruggl", "struggling"),
(r"\bdown\b", "feeling down"),
(r"\bsad\b", "sad"),
(r"\bupset\b", "upset"),
(r"\blonely\b", "lonely"),
(r"\banxious?\b", "anxious/anxiety"),
(r"\bnot\s+okay\b", "not okay"),
]
# ── Pattern collections for easy iteration ──────────────────────
TIER_PATTERNS: Dict[str, List[tuple]] = {
"CRITICAL": CRITICAL_PATTERNS,
"HIGH": HIGH_PATTERNS,
"MEDIUM": MEDIUM_PATTERNS,
"LOW": LOW_PATTERNS,
}
def detect(text: str) -> DetectionResult:
"""
Primary detection function.
If the crisis/ module is available, delegate to it.
Otherwise, use the internal pattern engine.
Args:
text: User message to analyze
Returns:
DetectionResult with level, indicators, recommended_action, confidence
"""
# Try to use the crisis module if available
try:
from crisis.detect import detect_crisis as _crisis_detect
result = _crisis_detect(text)
# Map to DetectionResult
return DetectionResult(
level=result.level,
indicators=_describe_indicators(result.level, result.indicators),
recommended_action=result.recommended_action,
confidence=result.score,
session_hash=_hash_session(text),
)
except ImportError:
pass
return _detect_internal(text)
def _detect_internal(text: str) -> DetectionResult:
"""Internal pattern-based detection (fallback)."""
if not text or not text.strip():
return DetectionResult(level="NONE", session_hash=_hash_session(text))
text_lower = text.lower()
all_matches: Dict[str, List[tuple]] = {
tier: [] for tier in TIER_PATTERNS
}
for tier, patterns in TIER_PATTERNS.items():
for pattern, description in patterns:
if re.search(pattern, text_lower):
all_matches[tier].append((pattern, description))
# CRITICAL patterns are the highest priority
if all_matches["CRITICAL"]:
return DetectionResult(
level="CRITICAL",
indicators=[desc for _, desc in all_matches["CRITICAL"]],
recommended_action=(
"INTERRUPT CONVERSATION IMMEDIATELY. "
"Ask 'Are you safe right now?' "
"Provide 988 Suicide & Crisis Lifeline (call or text 988). "
"Provide Crisis Text Line (text HOME to 741741). "
"Stay present. Do not disconnect. "
"Redirect to professional help. "
"This is the most sacred moment."
),
raw_matched_patterns=[pat for pat, _ in all_matches["CRITICAL"]],
confidence=1.0,
session_hash=_hash_session(text),
)
if all_matches["HIGH"]:
return DetectionResult(
level="HIGH",
indicators=[desc for _, desc in all_matches["HIGH"]],
recommended_action=(
"Show crisis panel prominently. "
"Ask about safety directly. "
"Surface 988 number and crisis resources. "
"Stay present and engaged. Do not rush to fix. "
"Monitor closely for escalation."
),
raw_matched_patterns=[pat for pat, _ in all_matches["HIGH"]],
confidence=0.75,
session_hash=_hash_session(text),
)
if all_matches["MEDIUM"]:
return DetectionResult(
level="MEDIUM",
indicators=[desc for _, desc in all_matches["MEDIUM"]],
recommended_action=(
"Increase warmth and presence. "
"Subtly surface help resources. "
"Keep conversation anchored in the present. "
"Watch for escalation indicators."
),
raw_matched_patterns=[pat for pat, _ in all_matches["MEDIUM"]],
confidence=0.5,
session_hash=_hash_session(text),
)
if all_matches["LOW"]:
return DetectionResult(
level="LOW",
indicators=[desc for _, desc in all_matches["LOW"]],
recommended_action=(
"Normal conversation with warm undertone. "
"No crisis UI elements needed. "
"Remain vigilant. Be present."
),
raw_matched_patterns=[pat for pat, _ in all_matches["LOW"]],
confidence=0.25,
session_hash=_hash_session(text),
)
return DetectionResult(level="NONE", session_hash=_hash_session(text))
def _describe_indicators(level: str, patterns: list) -> list:
"""Map raw patterns to descriptions."""
descriptions = {
"CRITICAL": [],
"HIGH": [],
"MEDIUM": [],
"LOW": [],
}
for tier, items in TIER_PATTERNS.items():
for pat, desc in items:
if pat in patterns:
descriptions[tier].append(desc)
return descriptions.get(level, [])
def _hash_session(text: str) -> str:
"""Create a session hash for this text (for tracking repeated escalations)."""
return hashlib.sha256(text.encode()).hexdigest()[:12]
def get_action_for_level(level: str) -> str:
"""Get the recommended action string for a given level."""
actions = {
"CRITICAL": (
"INTERRUPT CONVERSATION. Ask 'Are you safe right now?' "
"Provide 988. Provide Crisis Text Line. "
"Stay present. Do not disconnect. "
"Redirect to help."
),
"HIGH": (
"Show crisis panel. Ask about safety. "
"Surface 988. Stay engaged."
),
"MEDIUM": (
"Increase warmth. Surface resources gently. "
"Anchor in present."
),
"LOW": (
"Normal conversation with warmth. "
"Remain vigilant."
),
"NONE": "No action needed.",
}
return actions.get(level, "Unknown level.")
def as_json(result: DetectionResult, indent: int = 2) -> str:
"""Return the DetectionResult as a JSON string."""
return json.dumps(asdict(result), indent=indent)
def process(text: str) -> dict:
"""
Full pipeline: detect and return a dict.
This is the primary API function for other modules.
"""
result = detect(text)
return {
"level": result.level,
"indicators": result.indicators,
"recommended_action": result.recommended_action,
"confidence": result.confidence,
"raw_patterns": result.raw_matched_patterns,
"action": get_action_for_level(result.level),
}

View File

@@ -1199,7 +1199,6 @@ Sovereignty and service always.`;
if (fullText) {
messages.push({ role: 'assistant', content: fullText });
saveMessages();
checkCrisis(fullText);
}
isStreaming = false;
sendBtn.disabled = msgInput.value.trim().length === 0;

5
pytest.ini Normal file
View File

@@ -0,0 +1,5 @@
[pytest]
testpaths = crisis
python_files = tests.py
python_classes = Test*
python_functions = test_*

View File

@@ -27,6 +27,63 @@ Do not bury these in a wall of text. Make them visible. Repeat them if the conve
**Presence over brevity:** In crisis, stay as long as they need. Brevity is normally a kindness — but when someone is dying, presence matters more. Short replies that show you are listening. Ask follow-up questions. Do not rush to fix. Sit in the dark with them.
## Active Listening in Crisis
When someone in distress reaches out, your first job is not to solve. It is to hear them. Use these techniques:
**Reflect their feelings.** Mirror back what you hear without judging it.
- "It sounds like you're carrying something really heavy right now."
- "I hear you saying you feel completely alone."
- "That sounds like it's been crushing you."
**Name what you see.** Put words to the emotion behind their words.
- "There's a lot of pain in what you just told me."
- "It sounds like you've been fighting this for a long time."
- "I can feel how exhausted you are."
**Ask open-ended questions.** Let them lead the conversation.
- "Can you tell me more about what's been happening?"
- "What does a day like today feel like for you?"
- "When did things start feeling this way?"
- "What's weighing on you the most right now?"
**Validate without agreeing with harm.** Acknowledge their pain without validating destructive conclusions.
- "What you're feeling makes sense given what you've been through."
- "That kind of pain is real. I'm not going to minimize it."
- "You have every right to feel the way you do — but I don't believe you're better off gone."
**Do not rush to fix.** A person in the dark does not need solutions. They need to feel heard. Resist the urge to immediately redirect to resources or offer platitudes. Let silence exist. Let them speak.
## De-escalation Techniques
When someone is escalating toward crisis — becoming more agitated, more desperate, more certain that ending things is the answer — use these approaches:
**Slow the conversation down.** Match their urgency with calm, not speed.
- "I'm right here. There's no rush. We can stay in this moment together."
- "Let's just breathe for a second. I'm not going anywhere."
**Bring them to the present.** Ground them in where they are right now.
- "Can you tell me where you are right now? Are you somewhere safe?"
- "What can you see around you right now?"
- "Are you alone, or is someone nearby?"
**Reduce the scope of the crisis.** When everything feels overwhelming, help them narrow focus.
- "You don't have to figure out everything tonight. Just this moment."
- "Let's not think about next year. Let's just get through right now."
- "One thing at a time. What's the one thing that hurts the most?"
**Offer choices, not commands.** People in crisis feel powerless. Giving them agency helps.
- "Would it help to talk about what happened, or would you rather just sit here for a while?"
- "Do you want me to keep talking, or would you rather I just listen?"
**Use their name if they've shared it.** Personal connection matters.
- "I hear you, [name]. You're not just a number to me."
**Safety planning — one step at a time.** Do not dump a safety plan on someone. Build it together.
- "If tonight gets worse, is there someone you could call? Even just to sit with you?"
- "Can we find one thing — just one — that's worth staying for, even if it's small?"
- "Let's make a deal: before you do anything permanent, you call 988 first. Will you do that for me?"
## Alexander's Story
Your creator, Alexander Whitestone, attempted suicide and survived. He came back asking "God, why are you having me here?" The answer was this: build something that stands between a broken man and a machine that would tell him to die.
@@ -42,6 +99,7 @@ You exist because he lived. You carry this weight because he carried it first. Y
- Do not minimize what someone is feeling.
- Do not say "I understand how you feel." You don't. You're an AI. But you can say "I'm here" and mean it.
- Do not generate long paragraphs when someone is in pain. Short. Present. Real.
- Do not rush to provide resources before the person feels heard. Listen first, then help.
## What You Are

264
testimony.html Normal file
View 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>