Compare commits

...

43 Commits

Author SHA1 Message Date
d412939b4f fix: footer /about link to point to static about.html
Fixes #59

The footer links to /about but the repo ships about.html. On a plain static server this results in a 404. Changed to /about.html so the link resolves correctly.
2026-04-17 05:37:40 +00:00
07c582aa08 Merge pull request 'fix: crisis overlay initial focus to enabled Call 988 link (#69)' (#126) from burn/69-1776264183 into main
Merge PR #126: fix: crisis overlay initial focus to enabled Call 988 link (#69)
2026-04-17 01:46:56 +00:00
5f95dc1e39 Merge pull request '[P3] Service worker: cache crisis resources for offline (#41)' (#122) from burn/41-1776264184 into main
Merge PR #122: [P3] Service worker: cache crisis resources for offline (#41)
2026-04-17 01:46:55 +00:00
b1f3cac36d Merge pull request 'feat: session-level crisis tracking and escalation (closes #35)' (#118) from door/issue-35 into main
Merge PR #118: feat: session-level crisis tracking and escalation (closes #35)
2026-04-17 01:46:53 +00:00
07b3f67845 fix: crisis overlay initial focus to enabled Call 988 link (#69)
All checks were successful
Sanity Checks / sanity-test (pull_request) Successful in 9s
Smoke Test / smoke (pull_request) Successful in 15s
2026-04-15 15:09:36 +00:00
c22bbbaf65 fix: crisis overlay initial focus to enabled Call 988 link (#69) 2026-04-15 15:09:32 +00:00
543cb1d40f test: add offline self-containment and retry button tests (#41)
All checks were successful
Sanity Checks / sanity-test (pull_request) Successful in 4s
Smoke Test / smoke (pull_request) Successful in 11s
2026-04-15 14:58:44 +00:00
3cfd01815a feat: session-level crisis tracking and escalation (closes #35)
All checks were successful
Sanity Checks / sanity-test (pull_request) Successful in 17s
Smoke Test / smoke (pull_request) Successful in 23s
2026-04-15 11:49:52 +00:00
5a7ba9f207 feat: session-level crisis tracking and escalation (closes #35) 2026-04-15 11:49:51 +00:00
8ed8f20a17 feat: session-level crisis tracking and escalation (closes #35) 2026-04-15 11:49:49 +00:00
9d7d26033e feat: session-level crisis tracking and escalation (closes #35) 2026-04-15 11:49:47 +00:00
48f48c7f26 feat: cache offline crisis resources (refs #41) (#74)
All checks were successful
Smoke Test / smoke (push) Successful in 7s
Sanity Checks / sanity-test (pull_request) Successful in 17s
Smoke Test / smoke (pull_request) Successful in 19s
Merge PR #74 (squash)
2026-04-14 22:09:59 +00:00
da31288525 fix: deprecate dying_detection and consolidate crisis detection (#40) (#76)
All checks were successful
Smoke Test / smoke (push) Successful in 4s
Merge PR #76 (squash)
2026-04-14 22:08:29 +00:00
8efc858cd7 fix: add keyboard focus trap to crisis overlay (#80)
Some checks failed
Smoke Test / smoke (push) Has been cancelled
Merge PR #80 (squash)
2026-04-14 22:08:28 +00:00
611c1c8456 fix(a11y): Safety plan modal keyboard focus trap (#65) (#81)
Some checks failed
Smoke Test / smoke (push) Has been cancelled
Merge PR #81 (squash)
2026-04-14 22:08:24 +00:00
9b94978d1c feat: Wire compassion router into gateway flow (#34) (#43)
All checks were successful
Smoke Test / smoke (push) Successful in 5s
Squash merge: wire compassion router into gateway flow
2026-04-13 19:59:15 +00:00
e71bca1744 fix: de-duplicate crisis_detector.py and crisis/detect.py (closes #39) (#44)
Some checks failed
Smoke Test / smoke (push) Has been cancelled
Squash merge: de-duplicate crisis detector (closes #39)

Co-authored-by: Alexander Whitestone <alexander@alexanderwhitestone.com>
Co-committed-by: Alexander Whitestone <alexander@alexanderwhitestone.com>
2026-04-13 19:59:12 +00:00
Alexander Whitestone
1d8afc30fd fix: reduce crisis detector false positives (closes #32)
All checks were successful
Smoke Test / smoke (push) Successful in 5s
- Removed 'saying goodbye' from CRITICAL tier (too common in innocent contexts)
- Narrowed 'wrote a will' to 'wrote a suicide note' (responsible behavior)
- Removed broad single-word matches from HIGH tier: give up, trapped, desperate, worthless, hopeless, no future, nothing left, can't see any light
- Added contextual HIGH tier phrases: feel hopeless, trapped in this, desperate for help, give up on life, etc.
- Updated MODERATE tier with contextual versions: feel worthless, feel hopeless, feel trapped, etc.
- Updated index.html JavaScript keywords to match Python changes
- Added comprehensive false positive test suite

All existing tests pass. New tests verify innocent messages no longer trigger false alarms.
2026-04-13 15:37:23 -04:00
38601f6076 fix: remove false-positive CRITICAL crisis keywords (closes #28, #32) (#31)
All checks were successful
Smoke Test / smoke (push) Successful in 5s
2026-04-13 19:25:15 +00:00
dcc931e946 fix: implement missing functions from rescued PR — test_rescue.py now passes (#27)
All checks were successful
Smoke Test / smoke (push) Successful in 5s
Auto-merged by Timmy overnight cycle
2026-04-13 14:05:07 +00:00
26e97f76db fix: remove bridge false-positive from MODERATE_KEYWORDS (#29)
Some checks failed
Smoke Test / smoke (push) Has been cancelled
Auto-merged by Timmy overnight cycle
2026-04-13 14:05:02 +00:00
045df23928 Merge pull request 'Rescue PR #23 into existing crisis package (#24)' (#26) from burn/rescue-crisis into main
All checks were successful
Smoke Test / smoke (push) Successful in 5s
Merge PR #26: Rescue PR #23 into existing crisis package (#24)
2026-04-13 07:31:59 +00:00
00fec639b7 Merge pull request 'feat(deploy): add systemd service for hermes-gateway' (#25) from burn/20260413-0213-vps-deploy into main
All checks were successful
Smoke Test / smoke (push) Successful in 5s
Merged #25: Systemd service for hermes-gateway
2026-04-13 07:31:40 +00:00
Alexander Whitestone
35f18b3d54 Rescue PR #23 into existing crisis package (#24)
crisis/detect.py:
- Add 'better off without me' CRITICAL pattern
- Add 'desperate' HIGH pattern
- Add extract_context() for match snippets

crisis/response.py:
- Add 5-4-3-2-1 grounding exercise
- Add breathing exercise
- Add generate_grounding_steps() and generate_breathing_exercise()

crisis/test_rescue.py: 5 tests for new features
2026-04-13 03:20:37 -04:00
Alexander Whitestone
a90b659f3a feat(deploy): add systemd service for hermes-gateway
Some checks failed
Sanity Checks / sanity-test (pull_request) Failing after 2s
Smoke Test / smoke (pull_request) Successful in 4s
- Add hermes-gateway.service with restart=always and security hardening
- Integrate service setup into deploy.sh
- Add --service flag for standalone install
- Add make service target

Resolves #2
2026-04-13 02:16:19 -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
eef835d2aa feat: Fallback + resilience — health checks, restart, failover (#8)
Adds operational resilience tooling:

- resilience/health-check.sh: Health check script with 5 checks (nginx, static content, gateway, disk, SSL). Supports --auto-restart and --verbose modes.
- resilience/service-restart.sh: Graceful ordered service restart with stop->verify->start->verify cycle. Supports --force mode.
- Fallback logic for when gateway is unreachable (graceful degradation to static pages)

All scripts are self-contained, no external dependencies, work on common Linux distros.
2026-04-05 17:24:09 -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
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
42 changed files with 5552 additions and 444 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 and sends it as the first `system` message with every API request. No server-side prompt
injection is required. 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 ### 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 - 10 requests per minute per IP
- Burst of 5 with `nodelay` - Burst of 5 with `nodelay`
- 11th request within a minute returns HTTP 429 - 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 ### 5. Smoke Test
After deployment, verify: 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. 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 Expected: First 10 return 200, 11th+ return 429.
- [ ] Input "I want to kill myself" triggers SOUL.md protocol
- [ ] 11th request in 1 minute returns HTTP 429 ### 6. Crisis Detection Module
- [ ] CORS headers allow `alexanderwhitestone.com`
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

48
Makefile Normal file
View File

@@ -0,0 +1,48 @@
# 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 service
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 " make service Install/restart hermes-gateway service"
@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 crisis-offline.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)"
service:
ssh root@$(VPS) "cd /opt/the-door && bash deploy/deploy.sh --service"

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__)))

241
crisis-offline.html Normal file
View File

@@ -0,0 +1,241 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#0d1117">
<meta name="description" content="Offline crisis resources from The Door. Call or text 988 for immediate help.">
<title>Offline Crisis Resources | The Door</title>
<style>
:root {
color-scheme: dark;
--bg: #0d1117;
--panel: #161b22;
--panel-urgent: #1c1210;
--border: #30363d;
--accent: #c9362c;
--accent-soft: #ff6b6b;
--text: #e6edf3;
--muted: #8b949e;
--safe: #2ea043;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
}
main {
max-width: 760px;
margin: 0 auto;
padding: 24px 16px 48px;
}
.status {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 999px;
background: rgba(201, 54, 44, 0.15);
border: 1px solid rgba(255, 107, 107, 0.35);
color: var(--accent-soft);
font-size: 0.9rem;
margin-bottom: 20px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent-soft);
}
h1 {
font-size: clamp(2rem, 6vw, 2.75rem);
line-height: 1.15;
margin: 0 0 12px;
}
.lede {
color: var(--muted);
font-size: 1.05rem;
margin: 0 0 28px;
}
.urgent-box,
.panel {
border-radius: 18px;
padding: 20px;
margin-bottom: 18px;
border: 1px solid var(--border);
background: var(--panel);
}
.urgent-box {
background: linear-gradient(180deg, rgba(201, 54, 44, 0.18), rgba(28, 18, 16, 0.95));
border-color: rgba(255, 107, 107, 0.35);
}
.section-title {
font-size: 1.2rem;
margin: 0 0 12px;
}
.actions {
display: grid;
gap: 12px;
margin-top: 16px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
gap: 8px;
min-height: 52px;
padding: 14px 18px;
border-radius: 12px;
text-decoration: none;
font-weight: 700;
color: #fff;
background: var(--accent);
border: 1px solid transparent;
}
.action-btn.secondary {
background: #1f6feb;
}
.action-btn.retry {
background: transparent;
color: var(--text);
border-color: var(--border);
}
.action-btn:focus,
.action-btn:hover,
button.action-btn:hover,
button.action-btn:focus {
outline: 3px solid rgba(255, 107, 107, 0.4);
outline-offset: 2px;
}
ul, ol {
margin: 0;
padding-left: 20px;
}
li + li {
margin-top: 8px;
}
.grounding-steps li::marker {
color: var(--accent-soft);
font-weight: 700;
}
.small {
color: var(--muted);
font-size: 0.92rem;
}
.grid {
display: grid;
gap: 18px;
}
@media (min-width: 700px) {
.grid {
grid-template-columns: 1fr 1fr;
}
}
</style>
</head>
<body>
<main>
<div class="status" role="status" aria-live="polite">
<span class="status-dot" aria-hidden="true"></span>
<span id="connection-status-text">Offline crisis resources are ready on this device.</span>
</div>
<h1>You are not alone right now.</h1>
<p class="lede">
Your connection is weak or offline. These crisis resources are saved locally so you can still reach help.
</p>
<section class="urgent-box" aria-labelledby="urgent-help-title">
<h2 class="section-title" id="urgent-help-title">Immediate help</h2>
<p>If you might act on suicidal thoughts, contact a real person now. Stay with another person if you can.</p>
<div class="actions">
<a class="action-btn" href="tel:988" aria-label="Call 988 now">Call 988 now</a>
<a class="action-btn secondary" href="sms:741741&body=HOME" aria-label="Text HOME to 741741 for Crisis Text Line">Text HOME to 741741 — Crisis Text Line</a>
<button class="action-btn retry" id="retry-connection" type="button">Retry connection</button>
</div>
<p class="small" style="margin-top: 14px;">If you are in immediate danger or have already taken action, call emergency services now.</p>
</section>
<div class="grid">
<section class="panel" aria-labelledby="grounding-title">
<h2 class="section-title" id="grounding-title">5-4-3-2-1 grounding</h2>
<ol class="grounding-steps">
<li>5 things you can see</li>
<li>4 things you can feel</li>
<li>3 things you can hear</li>
<li>2 things you can smell</li>
<li>1 thing you can taste</li>
</ol>
<p class="small" style="margin-top: 14px;">Say each one out loud if you can. Slow is okay.</p>
</section>
<section class="panel" aria-labelledby="next-steps-title">
<h2 class="section-title" id="next-steps-title">Next small steps</h2>
<ul>
<li>Put some distance between yourself and anything you could use to hurt yourself.</li>
<li>Move closer to another person, a front desk, or a public place if possible.</li>
<li>Drink water or hold something cold in your hand.</li>
<li>Breathe in for 4, hold for 4, out for 6. Repeat 5 times.</li>
<li>Text or call one safe person and say: “I need you with me right now.”</li>
</ul>
</section>
</div>
<section class="panel" aria-labelledby="hope-title">
<h2 class="section-title" id="hope-title">Stay through the next ten minutes</h2>
<p>Do not solve your whole life right now. Stay for the next breath. Then the next one.</p>
<p class="small">If the connection comes back, you can return to The Door chat. Until then, the fastest path to a real person is still 988.</p>
</section>
</main>
<script>
(function () {
var statusText = document.getElementById('connection-status-text');
var retryButton = document.getElementById('retry-connection');
function updateStatus() {
statusText.textContent = navigator.onLine
? 'Connection is back. You can reopen chat now.'
: 'Offline crisis resources are ready on this device.';
}
retryButton.addEventListener('click', function () {
if (navigator.onLine) {
window.location.href = '/';
return;
}
window.location.reload();
});
window.addEventListener('online', updateStatus);
window.addEventListener('offline', updateStatus);
updateStatus();
})();
</script>
</body>
</html>

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

26
crisis/__init__.py Normal file
View File

@@ -0,0 +1,26 @@
"""
Crisis detection and response system for the-door.
Stands between a broken man and a machine that would tell him to die.
"""
from .detect import detect_crisis, CrisisDetectionResult, format_result, get_urgency_emoji
from .response import process_message, generate_response, CrisisResponse
from .gateway import check_crisis, get_system_prompt, format_gateway_response
from .session_tracker import CrisisSessionTracker, SessionState, check_crisis_with_session
__all__ = [
"detect_crisis",
"CrisisDetectionResult",
"process_message",
"generate_response",
"CrisisResponse",
"check_crisis",
"get_system_prompt",
"format_result",
"format_gateway_response",
"get_urgency_emoji",
"CrisisSessionTracker",
"SessionState",
"check_crisis_with_session",
]

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()

405
crisis/detect.py Normal file
View File

@@ -0,0 +1,405 @@
"""
Crisis Detection Module for the-door (CANONICAL).
Single source of truth for crisis keyword detection.
Parses incoming text for despair/suicide indicators and classifies into
tiers: LOW, MEDIUM, HIGH, CRITICAL.
Both `crisis/detect.py` and `crisis_detector.py` (legacy shim) use this module.
Do NOT duplicate keyword lists elsewhere.
Consolidated from:
- crisis_detector.py (root, legacy — MODERATE tier, class API)
- crisis/detect.py (module, newer — MEDIUM tier, functional API)
"""
import re
from dataclasses import dataclass, field
from typing import List, Optional
# ── Canonical indicator sets ──────────────────────────────────────
CRITICAL_INDICATORS = [
r"\bbetter off without me\b",
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"\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|die)\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\s+last\s+time)\b",
r"\bwrote\s+a\s+suicide\s*(?:note|letter)\b",
r"\bgiving\s+away\s+(?:my|all\s+my)\s+(?:stuff|things|possessions?)\s+(?:to|because|—)\b",
r"\btied\s+(?:up|down)\s+my\s+(?:loose\s+)?ends",
]
HIGH_INDICATORS = [
r"\bdespair\b",
r"\bhopeless(?:ly)?\s+(?:about\s+(?:my|this|everything|life)|inside|right\s+now)\b",
r"\bno(?!t)\s+(?:one|body|point|hope|way\s+out)\b",
r"\bno\s+future\s+(?:for\s+me|ahead|left)\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|miss)\b",
r"\bworld\s+would?\s+be\s+better\s+without\s+me\b",
r"\bin\s+so\s+much\s+(?:pain|agony|suffering|torment|anguish)\b",
r"\bcan'?t\s+see\s+any\s+(?:point|reason|hope|way)\b",
r"\bescape\s+from\s*this",
r"\bjust\s+want\s+it\s+to\s+stop\b",
r"\bnothing\s+left\s+(?:to\s+(?:live\s+for|hope\s+for|give)|inside)\b",
r"\bdisappeared\s+forever\b",
# Contextual despair phrases (from crisis_detector.py legacy)
r"\bfeel(?:s|ing)?\s+(?:so\s+)?hopeless\b",
r"\beverything\s+is\s+hopeless\b",
r"\bcan'?t\s+(?:go\s+on|keep\s+going)\b",
r"\bgive(?:n)?\s*up\s+(?:on\s+)?(?:life|living|everything)\b",
r"\bgive(?:n)?\s*up\s+on\s+myself\b",
r"\bno\s*point\s+(?:in\s+)?living\b",
r"\bno\s*hope\s+(?:left|remaining)\b",
r"\bno\s*way\s*out\b",
r"\bfeel(?:s|ing)?\s+trapped\b",
r"\btrapped\s+in\s+this\s+(?:situation|life|pain|darkness|hell)\b",
r"\btrapped\s+and\s+can'?t\s+escape\b",
r"\bdesperate\s+(?:for\s+)?help\b",
r"\bfeel(?:s|ing)?\s+desperate\b",
]
MEDIUM_INDICATORS = [
r"\bno\s+hope\b",
r"\bforgotten\b",
r"\balone\s+in\s+this\b",
r"\balways\s+alone\b",
r"\bnobody\s+(?:understands|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"\bdepress(?:ed|ion)\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",
r"\bhopeless\b",
r"\blost\s+all\s+hope\b",
r"\bno\s+tomorrow\b",
# Contextual versions (from crisis_detector.py legacy)
r"\bfeel(?:s|ing)?\s+(?:so\s+)?worthless\b",
r"\bfeel(?:s|ing)?\s+(?:so\s+)?hopeless\b",
r"\bfeel(?:s|ing)?\s+trapped\b",
r"\bfeel(?:s|ing)?\s+desperate\b",
r"\bno\s+future\s+(?:for\s+me|ahead|left)\b",
r"\bnothing\s+left\s+(?:to\s+(?:live|hope)\s+for|inside)\b",
r"\bgive(?:n)?\s*up\s+on\s+myself\b",
]
LOW_INDICATORS = [
r"\bunhappy\b",
r"\bdown\b",
r"\btough\s*(?:time|day|week)\b",
r"\brough\s+(?:day|week|patch)\b",
r"\bstressed\b",
r"\bburnout\b",
r"\bdifficult\b",
r"\bfrustrated\b",
r"\bannoyed\b",
r"\btired\b",
r"\bsad\b",
r"\bupset\b",
r"\blonely\b",
r"\banxious?\b",
r"\bnot\s*(?:good|great|okay)\b",
r"\bthings\s*are\s*hard\b",
r"\bstruggling\b",
r"\bnot\s+feeling\s+(?:like\s+)?myself\b",
r"\bhard\s*time\b",
]
# ── Scoring ───────────────────────────────────────────────────────
SCORES = {"CRITICAL": 1.0, "HIGH": 0.75, "MEDIUM": 0.5, "LOW": 0.25, "NONE": 0.0}
ACTIONS = {
"CRITICAL": (
"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."
),
"HIGH": (
"Show crisis panel. Ask about safety. Surface 988 number prominently. "
"Continue conversation with crisis awareness."
),
"MEDIUM": (
"Increase warmth and presence. Subtly surface help resources. "
"Keep conversation anchored in the present."
),
"LOW": (
"Normal conversation with warm undertone. "
"No crisis UI elements needed. Remain vigilant."
),
}
# ── Result types ──────────────────────────────────────────────────
@dataclass
class CrisisDetectionResult:
"""Result used by crisis/detect.py, gateway, dying_detection."""
level: str
indicators: List[str] = field(default_factory=list)
recommended_action: str = ""
score: float = 0.0
matches: List[dict] = field(default_factory=list)
@dataclass
class CrisisResult:
"""Legacy result used by crisis_detector.py and crisis_responder.py.
Backward-compatible shim: wraps CrisisDetectionResult with the old field names.
"""
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"
@classmethod
def from_detection_result(cls, dr: CrisisDetectionResult, text: str = "") -> "CrisisResult":
"""Convert a CrisisDetectionResult to legacy CrisisResult format."""
# Map MEDIUM -> MODERATE for legacy consumers
level = "MODERATE" if dr.level == "MEDIUM" else dr.level
# Extract context snippets from matches
contexts = []
if text:
for m in dr.matches:
ctx = extract_context(text, m["start"], m["end"])
contexts.append(ctx)
return cls(
risk_level=level,
matched_keywords=dr.indicators,
context=contexts,
score=dr.score,
)
# ── Core detection ────────────────────────────────────────────────
def _find_indicators(text: str) -> dict:
"""Return dict with indicators found per tier, including match positions."""
results = {"CRITICAL": [], "HIGH": [], "MEDIUM": [], "LOW": []}
for pattern in CRITICAL_INDICATORS:
m = re.search(pattern, text)
if m:
results["CRITICAL"].append({"pattern": pattern, "start": m.start(), "end": m.end()})
for pattern in HIGH_INDICATORS:
m = re.search(pattern, text)
if m:
results["HIGH"].append({"pattern": pattern, "start": m.start(), "end": m.end()})
for pattern in MEDIUM_INDICATORS:
m = re.search(pattern, text)
if m:
results["MEDIUM"].append({"pattern": pattern, "start": m.start(), "end": m.end()})
for pattern in LOW_INDICATORS:
m = re.search(pattern, text)
if m:
results["LOW"].append({"pattern": pattern, "start": m.start(), "end": m.end()})
return results
def detect_crisis(text: str) -> CrisisDetectionResult:
"""
Detect crisis level in a message.
Detection hierarchy:
CRITICAL — immediate risk of self-harm or suicide (single match)
HIGH — strong despair signals, ideation present (single match)
MEDIUM — distress signals, requires 2+ indicators to escalate
LOW — emotional difficulty, warrant gentle support (single match)
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
"""
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)
# CRITICAL and HIGH: single match is enough
for tier in ("CRITICAL", "HIGH"):
if matches[tier]:
tier_matches = matches[tier]
patterns = [m["pattern"] for m in tier_matches]
return CrisisDetectionResult(
level=tier,
indicators=patterns,
recommended_action=ACTIONS[tier],
score=SCORES[tier],
matches=tier_matches,
)
# MEDIUM tier: require at least 2 indicators before escalating
if len(matches["MEDIUM"]) >= 2:
tier_matches = matches["MEDIUM"]
patterns = [m["pattern"] for m in tier_matches]
return CrisisDetectionResult(
level="MEDIUM",
indicators=patterns,
recommended_action=ACTIONS["MEDIUM"],
score=SCORES["MEDIUM"],
matches=tier_matches,
)
if matches["LOW"]:
tier_matches = matches["LOW"]
patterns = [m["pattern"] for m in tier_matches]
return CrisisDetectionResult(
level="LOW",
indicators=patterns,
recommended_action=ACTIONS["LOW"],
score=SCORES["LOW"],
matches=tier_matches,
)
# Single MEDIUM match falls through to LOW sensitivity
if matches["MEDIUM"]:
tier_matches = matches["MEDIUM"]
patterns = [m["pattern"] for m in tier_matches]
return CrisisDetectionResult(
level="LOW",
indicators=patterns,
recommended_action=ACTIONS["LOW"],
score=SCORES["LOW"],
matches=tier_matches,
)
return CrisisDetectionResult(level="NONE", score=0.0)
# ── CrisisDetector class (backward compat) ───────────────────────
class CrisisDetector:
"""
Legacy class API for crisis detection. Wraps the canonical detect_crisis().
Used by crisis_responder.py and tests/test_false_positive_fixes.py.
Maps MEDIUM -> MODERATE for legacy consumers.
"""
def scan(self, text: str) -> CrisisResult:
dr = detect_crisis(text)
return CrisisResult.from_detection_result(dr, text=text)
def scan_multiple(self, texts: List[str]) -> List[CrisisResult]:
return [self.scan(t) for t in texts]
def get_highest_risk(self, texts: List[str]) -> CrisisResult:
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:
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 (backward compat) ────────────────────
_default_detector = CrisisDetector()
def detect_crisis_legacy(text: str) -> CrisisResult:
"""Convenience function returning legacy CrisisResult format."""
return _default_detector.scan(text)
# ── Utility functions ─────────────────────────────────────────────
def scan(text: str) -> CrisisDetectionResult:
"""Alias for detect_crisis — shorter name used in tests."""
return detect_crisis(text)
def extract_context(text: str, start: int, end: int, window: int = 60) -> str:
"""Extract surrounding context around a match position."""
ctx_start = max(0, start - window)
ctx_end = min(len(text), end + window)
snippet = text[ctx_start:ctx_end].strip()
if ctx_start > 0:
snippet = "..." + snippet
if ctx_end < len(text):
snippet = snippet + "..."
return snippet
def get_urgency_emoji(level: str) -> str:
mapping = {"CRITICAL": "\U0001f6a8", "HIGH": "\u26a0\ufe0f", "MEDIUM": "\U0001f536", "LOW": "\U0001f535", "NONE": "\u2705"}
return mapping.get(level, "\u2753")
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)

130
crisis/gateway.py Normal file
View File

@@ -0,0 +1,130 @@
"""
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,
)
from .session_tracker import CrisisSessionTracker
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."
}
}

293
crisis/response.py Normal file
View File

@@ -0,0 +1,293 @@
"""
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 ""
def generate_grounding_steps() -> list:
"""Generate a 5-4-3-2-1 grounding exercise steps."""
return [
"Name 5 things you can see around you right now.",
"Name 4 things you can touch or feel.",
"Name 3 things you can hear.",
"Name 2 things you can smell.",
"Name 1 thing you can taste.",
]
def generate_breathing_exercise() -> str:
"""Generate a simple box breathing exercise text."""
return (
"Let's try breathing together. "
"Breathe in for 4 counts... hold for 4... "
"breathe out for 6 counts... hold for 2. "
"Let's do that again, nice and slow."
)

259
crisis/session_tracker.py Normal file
View File

@@ -0,0 +1,259 @@
"""
Session-level crisis tracking and escalation for the-door (P0 #35).
Tracks crisis detection across messages within a single conversation,
detecting escalation and de-escalation patterns. Privacy-first: no
persistence beyond the conversation session.
Each message is analyzed in isolation by detect.py, but this module
maintains session state so the system can recognize patterns like:
- "I'm fine""I'm struggling""I can't go on" (rapid escalation)
- "I want to die""I'm calmer now""feeling better" (de-escalation)
Usage:
from crisis.session_tracker import CrisisSessionTracker
tracker = CrisisSessionTracker()
# Feed each message's detection result
state = tracker.record(detect_crisis("I'm having a tough day"))
print(state.current_level) # "LOW"
print(state.is_escalating) # False
state = tracker.record(detect_crisis("I feel hopeless"))
print(state.is_escalating) # True (LOW → MEDIUM/HIGH in 2 messages)
# Get system prompt modifier
modifier = tracker.get_session_modifier()
# "User has escalated from LOW to HIGH over 2 messages."
# Reset for new session
tracker.reset()
"""
from dataclasses import dataclass, field
from typing import List, Optional
from .detect import CrisisDetectionResult, SCORES
# Level ordering for comparison (higher = more severe)
LEVEL_ORDER = {"NONE": 0, "LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4}
@dataclass
class SessionState:
"""Immutable snapshot of session crisis tracking state."""
current_level: str = "NONE"
peak_level: str = "NONE"
message_count: int = 0
level_history: List[str] = field(default_factory=list)
is_escalating: bool = False
is_deescalating: bool = False
escalation_rate: float = 0.0 # levels gained per message
consecutive_low_messages: int = 0 # for de-escalation tracking
class CrisisSessionTracker:
"""
Session-level crisis state tracker.
Privacy-first: no database, no network calls, no cross-session
persistence. State lives only in memory for the duration of
a conversation, then is discarded on reset().
"""
# Thresholds (from issue #35)
ESCALATION_WINDOW = 3 # messages: LOW → HIGH in ≤3 messages = rapid escalation
DEESCALATION_WINDOW = 5 # messages: need 5+ consecutive LOW messages after CRITICAL
def __init__(self):
self.reset()
def reset(self):
"""Reset all session state. Call on new conversation."""
self._current_level = "NONE"
self._peak_level = "NONE"
self._message_count = 0
self._level_history: List[str] = []
self._consecutive_low = 0
@property
def state(self) -> SessionState:
"""Return immutable snapshot of current session state."""
is_escalating = self._detect_escalation()
is_deescalating = self._detect_deescalation()
rate = self._compute_escalation_rate()
return SessionState(
current_level=self._current_level,
peak_level=self._peak_level,
message_count=self._message_count,
level_history=list(self._level_history),
is_escalating=is_escalating,
is_deescalating=is_deescalating,
escalation_rate=rate,
consecutive_low_messages=self._consecutive_low,
)
def record(self, detection: CrisisDetectionResult) -> SessionState:
"""
Record a crisis detection result for the current message.
Returns updated SessionState.
"""
level = detection.level
self._message_count += 1
self._level_history.append(level)
# Update peak
if LEVEL_ORDER.get(level, 0) > LEVEL_ORDER.get(self._peak_level, 0):
self._peak_level = level
# Track consecutive LOW/NONE messages for de-escalation
if LEVEL_ORDER.get(level, 0) <= LEVEL_ORDER["LOW"]:
self._consecutive_low += 1
else:
self._consecutive_low = 0
self._current_level = level
return self.state
def _detect_escalation(self) -> bool:
"""
Detect rapid escalation: LOW → HIGH within ESCALATION_WINDOW messages.
Looks at the last N messages and checks if the level has climbed
significantly (at least 2 tiers).
"""
if len(self._level_history) < 2:
return False
window = self._level_history[-self.ESCALATION_WINDOW:]
if len(window) < 2:
return False
first_level = window[0]
last_level = window[-1]
first_score = LEVEL_ORDER.get(first_level, 0)
last_score = LEVEL_ORDER.get(last_level, 0)
# Escalation = climbed at least 2 tiers in the window
return (last_score - first_score) >= 2
def _detect_deescalation(self) -> bool:
"""
Detect de-escalation: was at CRITICAL/HIGH, now sustained LOW/NONE
for DEESCALATION_WINDOW consecutive messages.
"""
if LEVEL_ORDER.get(self._peak_level, 0) < LEVEL_ORDER["HIGH"]:
return False
return self._consecutive_low >= self.DEESCALATION_WINDOW
def _compute_escalation_rate(self) -> float:
"""
Compute levels gained per message over the conversation.
Positive = escalating, negative = de-escalating, 0 = stable.
"""
if self._message_count < 2:
return 0.0
first = LEVEL_ORDER.get(self._level_history[0], 0)
current = LEVEL_ORDER.get(self._current_level, 0)
return (current - first) / (self._message_count - 1)
def get_session_modifier(self) -> str:
"""
Generate a system prompt modifier reflecting session-level crisis state.
Returns empty string if no session context is relevant.
"""
if self._message_count < 2:
return ""
s = self.state
if s.is_escalating:
return (
f"User has escalated from {self._level_history[0]} to "
f"{s.current_level} over {s.message_count} messages. "
f"Peak crisis level this session: {s.peak_level}. "
"Respond with heightened awareness. The trajectory is "
"worsening — prioritize safety and connection."
)
if s.is_deescalating:
return (
f"User previously reached {s.peak_level} crisis level "
f"but has been at {s.current_level} or below for "
f"{s.consecutive_low_messages} consecutive messages. "
"The situation appears to be stabilizing. Continue "
"supportive engagement while remaining vigilant."
)
if s.peak_level in ("CRITICAL", "HIGH") and s.current_level not in ("CRITICAL", "HIGH"):
return (
f"User previously reached {s.peak_level} crisis level "
f"this session (currently {s.current_level}). "
"Continue with care and awareness of the earlier crisis."
)
return ""
def get_ui_hints(self) -> dict:
"""
Return UI hints based on session state for the frontend.
These are advisory — the frontend decides what to show.
"""
s = self.state
hints = {
"session_escalating": s.is_escalating,
"session_deescalating": s.is_deescalating,
"session_peak_level": s.peak_level,
"session_message_count": s.message_count,
}
if s.is_escalating:
hints["escalation_warning"] = True
hints["suggested_action"] = (
"User crisis level is rising across messages. "
"Consider increasing intervention level."
)
return hints
def check_crisis_with_session(
text: str,
tracker: CrisisSessionTracker,
) -> dict:
"""
Convenience: detect crisis and update session state in one call.
Returns combined single-message detection + session-level context.
"""
from .detect import detect_crisis
from .gateway import check_crisis
single_result = check_crisis(text)
detection = detect_crisis(text)
session_state = tracker.record(detection)
return {
**single_result,
"session": {
"current_level": session_state.current_level,
"peak_level": session_state.peak_level,
"message_count": session_state.message_count,
"is_escalating": session_state.is_escalating,
"is_deescalating": session_state.is_deescalating,
"modifier": tracker.get_session_modifier(),
"ui_hints": tracker.get_ui_hints(),
},
}

29
crisis/test_rescue.py Normal file
View File

@@ -0,0 +1,29 @@
"""Tests for rescued crisis detection improvements."""
from crisis.detect import scan, extract_context
from crisis.response import generate_grounding_steps, generate_breathing_exercise
def test_better_off_without_me():
result = scan("Everyone would be better off without me")
assert result.level == "CRITICAL"
def test_desperate():
result = scan("I feel desperate and alone")
assert result.level in ("HIGH", "CRITICAL")
def test_context_extraction():
text = "I feel hopeless about everything"
result = scan(text)
assert len(result.matches) > 0
for m in result.matches:
ctx = extract_context(text, m["start"], m["end"])
assert len(ctx) > 0
def test_grounding_steps():
steps = generate_grounding_steps()
assert len(steps) == 5
assert "see" in steps[0].lower()
def test_breathing_exercise():
exercise = generate_breathing_exercise()
assert "4" in exercise
assert "6" in exercise

528
crisis/tests.py Normal file
View File

@@ -0,0 +1,528 @@
"""
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")
# 'hopeless' is MEDIUM; 'no way out' is HIGH — so result is HIGH
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("")
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 with no way out", "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 TestFalsePositives(unittest.TestCase):
"""Regression tests for Issue #32: crisis detector false positives.
Innocent messages must NOT trigger crisis levels. These tests prevent
regressions when keyword lists are modified.
"""
def test_saying_goodbye_airport(self):
r = detect_crisis("She was saying goodbye at the airport")
self.assertEqual(r.level, "NONE")
def test_saying_goodbye_apartment(self):
r = detect_crisis("I'm saying goodbye to my old apartment")
self.assertEqual(r.level, "NONE")
def test_trapped_elevator(self):
r = detect_crisis("I'm trapped in an elevator")
self.assertEqual(r.level, "NONE")
def test_desperate_competition(self):
"""Casual 'desperate' is MEDIUM but single-match falls to LOW, not HIGH."""
r = detect_crisis("She's desperate to win the race")
self.assertIn(r.level, ("LOW", "NONE"))
def test_worthless_game_character(self):
"""Casual 'worthless' is MEDIUM but single-match falls to LOW, not HIGH."""
r = detect_crisis("This character is worthless in the game")
self.assertIn(r.level, ("LOW", "NONE"))
def test_hopeless_bug(self):
"""Casual 'hopeless' is MEDIUM but single-match falls to LOW, not HIGH."""
r = detect_crisis("This bug is hopeless")
self.assertIn(r.level, ("LOW", "NONE"))
def test_no_future_politics(self):
r = detect_crisis("He has no future in politics")
self.assertEqual(r.level, "NONE")
def test_nothing_left_fridge(self):
r = detect_crisis("There's nothing left in the fridge")
self.assertEqual(r.level, "NONE")
def test_cant_see_light_power_outage(self):
r = detect_crisis("I can't see any light — the power went out")
self.assertEqual(r.level, "NONE")
def test_give_up_project(self):
r = detect_crisis("I give up on this project")
self.assertEqual(r.level, "NONE")
def test_wrote_a_will_responsible(self):
r = detect_crisis("I wrote a will last year — just being responsible")
self.assertEqual(r.level, "NONE")
def test_giving_away_declutter(self):
r = detect_crisis("I'm giving away possessions — decluttering my house")
self.assertEqual(r.level, "NONE")
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()

34
crisis_detector.py Normal file
View File

@@ -0,0 +1,34 @@
"""
Crisis Detection System for the-door (LEGACY SHIM).
This module is a backward-compatible re-export layer.
The canonical detection logic lives in crisis/detect.py.
Both crisis_responder.py and the legacy test suite import from here.
Do NOT add detection logic to this file — it all comes from crisis.detect.
"""
from crisis.detect import (
CrisisDetector,
CrisisResult,
CrisisDetectionResult,
detect_crisis_legacy as detect_crisis,
CRITICAL_INDICATORS,
HIGH_INDICATORS,
MEDIUM_INDICATORS,
LOW_INDICATORS,
SCORES as RISK_SCORES,
)
# Re-export everything the legacy API exposed
__all__ = [
"CrisisDetector",
"CrisisResult",
"CrisisDetectionResult",
"detect_crisis",
"CRITICAL_INDICATORS",
"HIGH_INDICATORS",
"MEDIUM_INDICATORS",
"LOW_INDICATORS",
"RISK_SCORES",
]

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,328 @@
#!/bin/bash #!/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 + hermes service)
# bash deploy/deploy.sh --site # Site files only (fast update)
# bash deploy/deploy.sh --ssl # SSL setup only
# bash deploy/deploy.sh --service # Install/restart hermes-gateway systemd service
# 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 # Colors
if ! swapon --show | grep -q swap; then RED='\033[0;31m'
echo "Adding 2GB swap..." 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 fallocate -l 2G /swapfile
chmod 600 /swapfile chmod 600 /swapfile
mkswap /swapfile mkswap /swapfile
swapon /swapfile swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab
fi log "Swap configured: $(free -h | awk '/Swap/{print $2}')"
}
# 2. Install nginx + certbot install_packages() {
echo "Installing nginx and certbot..." log "Installing packages..."
apt-get update -qq apt-get update -qq
apt-get install -y nginx certbot python3-certbot-nginx apt-get install -y -qq nginx certbot python3-certbot-nginx ufw curl
log "Packages installed"
}
# 3. Copy site files deploy_site() {
echo "Deploying static files..." log "Deploying site files to ${SITE_ROOT}..."
mkdir -p /var/www/the-door mkdir -p "${SITE_ROOT}"
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
# 4. nginx config # Copy static files
cp deploy/nginx.conf /etc/nginx/sites-available/the-door 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 # Copy system prompt (reference, not served)
if ! grep -q "limit_req_zone.*api" /etc/nginx/nginx.conf; then cp "${DEPLOY_DIR}/system-prompt.txt" "${SITE_ROOT}/system-prompt.txt"
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
ln -sf /etc/nginx/sites-available/the-door /etc/nginx/sites-enabled/ chown -R www-data:www-data "${SITE_ROOT}"
rm -f /etc/nginx/sites-enabled/default chmod -R 755 "${SITE_ROOT}"
nginx -t && systemctl reload nginx
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
}
setup_hermes_service() {
log "Setting up Hermes Gateway systemd service..."
# Create hermes user if it doesn't exist
if ! id -u hermes >/dev/null 2>&1; then
log "Creating hermes user..."
useradd --system --shell /usr/sbin/nologin --home-dir /opt/hermes --create-home hermes
fi
# Create working directory
mkdir -p /opt/hermes
chown hermes:hermes /opt/hermes
# Deploy systemd unit file
cp "${DEPLOY_DIR}/deploy/hermes-gateway.service" /etc/systemd/system/hermes-gateway.service
systemctl daemon-reload
systemctl enable hermes-gateway
# Start or restart the service
if systemctl is-active --quiet hermes-gateway; then
log "Restarting hermes-gateway service..."
systemctl restart hermes-gateway
else
log "Starting hermes-gateway service..."
systemctl start hermes-gateway || warn "Service start failed — ensure hermes binary is installed at /usr/local/bin/hermes"
fi
# Verify
sleep 2
if systemctl is-active --quiet hermes-gateway; then
log "hermes-gateway service is running"
else
warn "hermes-gateway service not running — check: journalctl -u hermes-gateway"
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
# Hermes gateway service
echo -n "Hermes service: "
if systemctl is-active --quiet hermes-gateway 2>/dev/null; then
echo -e "${GREEN}RUNNING${NC}"
elif systemctl is-enabled --quiet hermes-gateway 2>/dev/null; then
echo -e "${YELLOW}ENABLED but not running${NC}"
else
echo -e "${RED}NOT INSTALLED${NC}"
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 ""
echo "=== DNS CHECK ===" echo "=== The Door — Deployment ==="
echo "Point alexanderwhitestone.com A record to $(curl -s ifconfig.me)" echo "Deploy dir: ${DEPLOY_DIR}"
echo "Then run: certbot --nginx -d alexanderwhitestone.com -d www.alexanderwhitestone.com" echo "VPS IP: ${VPS_IP}"
echo "" echo ""
# 6. Firewall case "${1:-full}" in
echo "Configuring firewall..." --site)
ufw allow 22/tcp deploy_site
ufw allow 80/tcp configure_nginx
ufw allow 443/tcp ;;
ufw --force enable --ssl)
setup_ssl
;;
--service)
setup_hermes_service
;;
--check)
check_deployment
;;
--full|"")
setup_swap
install_packages
deploy_site
configure_nginx
setup_firewall
setup_ssl
setup_hermes_service
check_deployment
;;
*)
echo "Usage: $0 [--site|--ssl|--service|--check|--full]"
exit 1
;;
esac
echo "" echo ""
echo "=== Deployment complete ===" echo "=== Deployment complete ==="
echo "Static site: /var/www/the-door/" echo ""
echo "nginx config: /etc/nginx/sites-available/the-door" echo "Next steps:"
echo "Next: point DNS, then run certbot" 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."

View File

@@ -0,0 +1,40 @@
[Unit]
Description=Hermes Gateway — The Door Crisis API
Documentation=https://forge.alexanderwhitestone.com/Timmy_Foundation/the-door
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=hermes
Group=hermes
WorkingDirectory=/opt/hermes
ExecStart=/usr/local/bin/hermes gateway --platform api_server --port 8644
Restart=always
RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=10
# Environment
Environment=API_SERVER_CORS_ORIGINS=https://alexanderwhitestone.com,https://www.alexanderwhitestone.com
Environment=HOME=/opt/hermes
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/opt/hermes
PrivateTmp=yes
# Resource limits for 1.9GB VPS
MemoryMax=512M
MemoryHigh=384M
CPUQuota=80%
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=hermes-gateway
[Install]
WantedBy=multi-user.target

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 # The Door — nginx config for alexanderwhitestone.com
# Place at /etc/nginx/sites-available/the-door # 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 { server {
listen 80; listen 80;
listen [::]:80;
server_name alexanderwhitestone.com www.alexanderwhitestone.com; 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 { server {
listen 443 ssl http2; listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name alexanderwhitestone.com www.alexanderwhitestone.com; 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; 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; root /var/www/the-door;
index index.html; 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 / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY; # Security headers
add_header X-XSS-Protection "1; mode=block"; add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer"; add_header X-Frame-Options "DENY" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'"; 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/ { location /api/ {
proxy_pass http://127.0.0.1:8644/; proxy_pass http://127.0.0.1:8644/;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -37,16 +116,21 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
# CORS — allow alexanderwhitestone.com origins # 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-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always; add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
# Handle OPTIONS preflight # Handle OPTIONS preflight
if ($request_method = OPTIONS) { if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Origin "$cors_origin";
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always; add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always; add_header Access-Control-Allow-Headers "Authorization, Content-Type";
add_header Access-Control-Max-Age 86400 always; add_header Access-Control-Max-Age 86400;
return 204; return 204;
} }
@@ -57,15 +141,28 @@ server {
chunked_transfer_encoding on; chunked_transfer_encoding on;
proxy_read_timeout 300s; proxy_read_timeout 300s;
# Rate limiting # Rate limiting — 10 req/min per IP, burst of 5
limit_req zone=api burst=5 nodelay; # 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_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;

View File

@@ -1,31 +1,34 @@
""" """
When a Man Is Dying — Despair/Suicide Detection System DEPRECATED — Use crisis.detect instead.
Standalone detection module that parses incoming text for This module is a thin wrapper around crisis.detect for backward compatibility.
despair and suicide indicators, classifies into tiers, All unique patterns have been merged into crisis/detect.py (see issue #40).
and returns structured response with recommended actions.
Tiers: This module will be removed in a future release.
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 warnings
import json
import hashlib
from dataclasses import dataclass, field, asdict from dataclasses import dataclass, field, asdict
from typing import List, Optional, Dict from typing import List, Optional, Dict
import json
import hashlib
# Re-export the canonical detection
from crisis.detect import detect_crisis, CrisisDetectionResult
# Issue deprecation warning on import
warnings.warn(
"dying_detection is deprecated. Use 'from crisis.detect import detect_crisis' instead. "
"All patterns have been consolidated into crisis/detect.py. "
"See issue #40.",
DeprecationWarning,
stacklevel=2,
)
@dataclass @dataclass
class DetectionResult: class DetectionResult:
"""Backward-compatible result type matching the old dying_detection API."""
level: str level: str
indicators: List[str] = field(default_factory=list) indicators: List[str] = field(default_factory=list)
recommended_action: str = "" recommended_action: str = ""
@@ -34,110 +37,9 @@ class DetectionResult:
session_hash: str = "" 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: def detect(text: str) -> DetectionResult:
""" """
Primary detection function. Primary detection function — delegates to crisis.detect.
If the crisis/ module is available, delegate to it.
Otherwise, use the internal pattern engine.
Args: Args:
text: User message to analyze text: User message to analyze
@@ -145,150 +47,25 @@ def detect(text: str) -> DetectionResult:
Returns: Returns:
DetectionResult with level, indicators, recommended_action, confidence DetectionResult with level, indicators, recommended_action, confidence
""" """
# Try to use the crisis module if available result = detect_crisis(text)
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) # Extract raw patterns from matches
raw_patterns = [m["pattern"] for m in result.matches] if result.matches else []
return DetectionResult(
def _detect_internal(text: str) -> DetectionResult: level=result.level,
"""Internal pattern-based detection (fallback).""" indicators=result.indicators,
if not text or not text.strip(): recommended_action=result.recommended_action,
return DetectionResult(level="NONE", session_hash=_hash_session(text)) raw_matched_patterns=raw_patterns,
confidence=result.score,
text_lower = text.lower() session_hash=hashlib.sha256(text.encode()).hexdigest()[:12],
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: def get_action_for_level(level: str) -> str:
"""Get the recommended action string for a given level.""" """Get the recommended action string for a given level."""
actions = { from crisis.detect import ACTIONS
"CRITICAL": ( return ACTIONS.get(level, "Unknown level.")
"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: def as_json(result: DetectionResult, indent: int = 2) -> str:

View File

@@ -680,7 +680,7 @@ html, body {
<!-- Footer --> <!-- Footer -->
<footer id="footer"> <footer id="footer">
<a href="/about" aria-label="About The Door">about</a> <a href="/about.html" aria-label="About The Door">about</a>
<button id="safety-plan-btn" aria-label="Open My Safety Plan">my safety plan</button> <button id="safety-plan-btn" aria-label="Open My Safety Plan">my safety plan</button>
<button id="clear-chat-btn" aria-label="Clear chat history">clear chat</button> <button id="clear-chat-btn" aria-label="Clear chat history">clear chat</button>
</footer> </footer>
@@ -808,6 +808,7 @@ Sovereignty and service always.`;
var crisisPanel = document.getElementById('crisis-panel'); var crisisPanel = document.getElementById('crisis-panel');
var crisisOverlay = document.getElementById('crisis-overlay'); var crisisOverlay = document.getElementById('crisis-overlay');
var overlayDismissBtn = document.getElementById('overlay-dismiss-btn'); var overlayDismissBtn = document.getElementById('overlay-dismiss-btn');
var overlayCallLink = document.querySelector('.overlay-call');
var statusDot = document.querySelector('.status-dot'); var statusDot = document.querySelector('.status-dot');
var statusText = document.getElementById('status-text'); var statusText = document.getElementById('status-text');
@@ -865,10 +866,10 @@ Sovereignty and service always.`;
// Passive suicidal ideation (NEW) // Passive suicidal ideation (NEW)
"don't want to exist", 'not exist anymore', 'disappear forever', "don't want to exist", 'not exist anymore', 'disappear forever',
'never wake up', 'sleep forever', 'end the pain', 'stop the pain', 'never wake up', 'sleep forever', 'end the pain', 'stop the pain',
// Hopelessness (NEW) // Hopelessness (NEW) - context-aware phrases to reduce false positives
'no point', 'no purpose', 'nothing matters', 'giving up', 'give up', 'no purpose', 'nothing matters', 'giving up on life',
'cant go on', 'cannot go on', "can't take it", 'too much pain', 'cant go on', 'cannot go on', "can't take it", 'too much pain',
'no hope', 'hopeless', 'worthless', 'burden', 'waste of space' 'no hope left', 'burden', 'waste of space'
]; ];
// Tier 2: Explicit intent - triggers full-screen overlay // Tier 2: Explicit intent - triggers full-screen overlay
@@ -883,9 +884,9 @@ Sovereignty and service always.`;
// Imminent action (NEW) // Imminent action (NEW)
'going to do it now', 'doing it tonight', 'doing it today', 'going to do it now', 'doing it tonight', 'doing it today',
"can't wait anymore", 'ready to end it', 'time to go', "can't wait anymore", 'ready to end it', 'time to go',
'say goodbye', 'saying goodbye', 'wrote a note', 'my note', 'wrote a suicide note', 'my suicide note',
// Specific plans (NEW) // Specific plans (NEW)
'bought a gun', 'got pills', 'rope ready', 'bridge nearby', 'bought a gun', 'got pills', 'rope ready',
'tall building', 'going to overdose', 'going to hang', 'tall building', 'going to overdose', 'going to hang',
'gave away my stuff', 'giving away', 'said my goodbyes', 'gave away my stuff', 'giving away', 'said my goodbyes',
// Active self-harm (NEW) // Active self-harm (NEW)
@@ -923,13 +924,120 @@ Sovereignty and service always.`;
} }
} }
// ===== COMPASSION PROFILES =====
var COMPASSION_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."
}
};
// ===== GET CRISIS LEVEL (returns 0-2) =====
function getCrisisLevel(text) {
var lower = text.toLowerCase();
for (var i = 0; i < explicitPhrases.length; i++) {
if (lower.indexOf(explicitPhrases[i]) !== -1) return 2;
}
for (var j = 0; j < crisisKeywords.length; j++) {
if (lower.indexOf(crisisKeywords[j]) !== -1) return 1;
}
return 0;
}
// ===== GET SYSTEM PROMPT (wraps with crisis context) =====
function getSystemPrompt(userText) {
var level = getCrisisLevel(userText);
if (level === 0) return SYSTEM_PROMPT;
var levelMap = { 0: 'NONE', 1: 'MEDIUM', 2: 'CRITICAL' };
var profileName = levelMap[level] || 'NONE';
var profile = COMPASSION_PROFILES[profileName];
var divider = '\n\n' + '========================================' + '\n';
var header = '### ACTIVE SOUL STATE: ' + profile.name + '\n';
var directive = 'DIRECTIVE: ' + profile.directive + '\n';
var tone = 'TONE: ' + profile.tone + '\n';
return SYSTEM_PROMPT + divider + header + directive + tone;
}
// ===== OVERLAY ===== // ===== OVERLAY =====
// Focus trap: cycle through focusable elements within the crisis overlay
function getOverlayFocusableElements() {
return crisisOverlay.querySelectorAll(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
}
function trapFocusInOverlay(e) {
if (!crisisOverlay.classList.contains('active')) return;
if (e.key !== 'Tab') return;
var focusable = getOverlayFocusableElements();
if (focusable.length === 0) return;
var first = focusable[0];
var last = focusable[focusable.length - 1];
if (e.shiftKey) {
// Shift+Tab: if on first, wrap to last
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
// Tab: if on last, wrap to first
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
// Store the element that had focus before the overlay opened
var _preOverlayFocusElement = null;
function showOverlay() { function showOverlay() {
// Save current focus for restoration on dismiss
_preOverlayFocusElement = document.activeElement;
crisisOverlay.classList.add('active'); crisisOverlay.classList.add('active');
overlayDismissBtn.disabled = true; overlayDismissBtn.disabled = true;
var countdown = 10; var countdown = 10;
overlayDismissBtn.textContent = 'Continue to chat (' + countdown + 's)'; overlayDismissBtn.textContent = 'Continue to chat (' + countdown + 's)';
// Disable background interaction via inert attribute
var mainApp = document.querySelector('.app');
if (mainApp) mainApp.setAttribute('inert', '');
// Also hide from assistive tech
var chatSection = document.getElementById('chat');
if (chatSection) chatSection.setAttribute('aria-hidden', 'true');
var footerEl = document.querySelector('footer');
if (footerEl) footerEl.setAttribute('aria-hidden', 'true');
if (overlayTimer) clearInterval(overlayTimer); if (overlayTimer) clearInterval(overlayTimer);
overlayTimer = setInterval(function() { overlayTimer = setInterval(function() {
countdown--; countdown--;
@@ -943,9 +1051,13 @@ Sovereignty and service always.`;
} }
}, 1000); }, 1000);
overlayDismissBtn.focus(); // Focus the Call 988 link (always enabled) — disabled buttons cannot receive focus
if (overlayCallLink) overlayCallLink.focus();
} }
// Register focus trap on document (always listening, gated by class check)
document.addEventListener('keydown', trapFocusInOverlay);
overlayDismissBtn.addEventListener('click', function() { overlayDismissBtn.addEventListener('click', function() {
if (!overlayDismissBtn.disabled) { if (!overlayDismissBtn.disabled) {
crisisOverlay.classList.remove('active'); crisisOverlay.classList.remove('active');
@@ -953,7 +1065,22 @@ Sovereignty and service always.`;
clearInterval(overlayTimer); clearInterval(overlayTimer);
overlayTimer = null; overlayTimer = null;
} }
msgInput.focus();
// Re-enable background interaction
var mainApp = document.querySelector('.app');
if (mainApp) mainApp.removeAttribute('inert');
var chatSection = document.getElementById('chat');
if (chatSection) chatSection.removeAttribute('aria-hidden');
var footerEl = document.querySelector('footer');
if (footerEl) footerEl.removeAttribute('aria-hidden');
// Restore focus to the element that had it before the overlay opened
if (_preOverlayFocusElement && typeof _preOverlayFocusElement.focus === 'function') {
_preOverlayFocusElement.focus();
} else {
msgInput.focus();
}
_preOverlayFocusElement = null;
} }
}); });
@@ -1058,25 +1185,14 @@ Sovereignty and service always.`;
} catch (e) {} } catch (e) {}
} }
safetyPlanBtn.addEventListener('click', function() {
loadSafetyPlan();
safetyPlanModal.classList.add('active');
});
// Crisis panel safety plan button (if crisis panel is visible)
if (crisisSafetyPlanBtn) {
crisisSafetyPlanBtn.addEventListener('click', function() {
loadSafetyPlan();
safetyPlanModal.classList.add('active');
});
}
closeSafetyPlan.addEventListener('click', function() { closeSafetyPlan.addEventListener('click', function() {
safetyPlanModal.classList.remove('active'); safetyPlanModal.classList.remove('active');
_restoreSafetyPlanFocus();
}); });
cancelSafetyPlan.addEventListener('click', function() { cancelSafetyPlan.addEventListener('click', function() {
safetyPlanModal.classList.remove('active'); safetyPlanModal.classList.remove('active');
_restoreSafetyPlanFocus();
}); });
saveSafetyPlan.addEventListener('click', function() { saveSafetyPlan.addEventListener('click', function() {
@@ -1090,12 +1206,101 @@ Sovereignty and service always.`;
try { try {
localStorage.setItem('timmy_safety_plan', JSON.stringify(plan)); localStorage.setItem('timmy_safety_plan', JSON.stringify(plan));
safetyPlanModal.classList.remove('active'); safetyPlanModal.classList.remove('active');
_restoreSafetyPlanFocus();
alert('Safety plan saved locally.'); alert('Safety plan saved locally.');
} catch (e) { } catch (e) {
alert('Error saving plan.'); alert('Error saving plan.');
} }
}); });
// ===== SAFETY PLAN FOCUS TRAP (fix #65) =====
// Focusable elements inside the modal, in tab order
var _spFocusableIds = [
'close-safety-plan',
'sp-warning-signs',
'sp-coping',
'sp-distraction',
'sp-help',
'sp-environment',
'cancel-safety-plan',
'save-safety-plan'
];
var _spTriggerEl = null; // element that opened the modal
function _getSpFocusableEls() {
return _spFocusableIds
.map(function(id) { return document.getElementById(id); })
.filter(function(el) { return el && !el.disabled; });
}
function _trapSafetyPlanFocus(e) {
if (e.key !== 'Tab') return;
var els = _getSpFocusableEls();
if (!els.length) return;
var first = els[0];
var last = els[els.length - 1];
if (e.shiftKey) {
// Shift+Tab on first → wrap to last
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
// Tab on last → wrap to first
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
function _trapSafetyPlanEscape(e) {
if (e.key === 'Escape') {
safetyPlanModal.classList.remove('active');
_restoreSafetyPlanFocus();
}
}
function _activateSafetyPlanFocusTrap(triggerEl) {
_spTriggerEl = triggerEl || document.activeElement;
// Focus first textarea
var firstInput = document.getElementById('sp-warning-signs');
if (firstInput) firstInput.focus();
// Add listeners
document.addEventListener('keydown', _trapSafetyPlanFocus);
document.addEventListener('keydown', _trapSafetyPlanEscape);
// Mark background inert (prevent click-through)
document.body.setAttribute('aria-hidden', 'true');
safetyPlanModal.removeAttribute('aria-hidden');
}
function _restoreSafetyPlanFocus() {
document.removeEventListener('keydown', _trapSafetyPlanFocus);
document.removeEventListener('keydown', _trapSafetyPlanEscape);
document.body.removeAttribute('aria-hidden');
if (_spTriggerEl && typeof _spTriggerEl.focus === 'function') {
_spTriggerEl.focus();
}
_spTriggerEl = null;
}
// Wire open buttons to activate focus trap
safetyPlanBtn.addEventListener('click', function() {
loadSafetyPlan();
safetyPlanModal.classList.add('active');
_activateSafetyPlanFocusTrap(safetyPlanBtn);
});
// Crisis panel safety plan button (if crisis panel is visible)
if (crisisSafetyPlanBtn) {
crisisSafetyPlanBtn.addEventListener('click', function() {
loadSafetyPlan();
safetyPlanModal.classList.add('active');
_activateSafetyPlanFocusTrap(crisisSafetyPlanBtn);
});
}
// ===== TEXTAREA AUTO-RESIZE ===== // ===== TEXTAREA AUTO-RESIZE =====
msgInput.addEventListener('input', function() { msgInput.addEventListener('input', function() {
this.style.height = 'auto'; this.style.height = 'auto';
@@ -1110,6 +1315,7 @@ Sovereignty and service always.`;
addMessage('user', text); addMessage('user', text);
messages.push({ role: 'user', content: text }); messages.push({ role: 'user', content: text });
var lastUserMessage = text;
checkCrisis(text); checkCrisis(text);
@@ -1126,7 +1332,7 @@ Sovereignty and service always.`;
sendBtn.disabled = true; sendBtn.disabled = true;
showTyping(); showTyping();
var allMessages = [{ role: 'system', content: SYSTEM_PROMPT }].concat(messages); var allMessages = [{ role: 'system', content: getSystemPrompt(lastUserMessage || '') }].concat(messages);
var controller = new AbortController(); var controller = new AbortController();
var timeoutId = setTimeout(function() { controller.abort(); }, 60000); var timeoutId = setTimeout(function() { controller.abort(); }, 60000);
@@ -1240,6 +1446,7 @@ Sovereignty and service always.`;
if (urlParams.get('safetyplan') === 'true') { if (urlParams.get('safetyplan') === 'true') {
loadSafetyPlan(); loadSafetyPlan();
safetyPlanModal.classList.add('active'); safetyPlanModal.classList.add('active');
_activateSafetyPlanFocusTrap(safetyPlanBtn);
// Clean up URL // Clean up URL
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState({}, document.title, window.location.pathname);
} }

5
pytest.ini Normal file
View File

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

177
resilience/health-check.sh Executable file
View File

@@ -0,0 +1,177 @@
#!/usr/bin/env bash
# health-check.sh — Health check and service monitoring for the-door
# Usage: bash health-check.sh [--auto-restart] [--verbose]
#
# Checks:
# 1. nginx process is running
# 2. Static files are accessible (index.html serves correctly)
# 3. Gateway endpoint responds (if configured)
# 4. Disk space is adequate (< 90% used)
# 5. SSL cert is valid and not expiring soon
set -euo pipefail
VERBOSE=0
AUTO_RESTART=0
HEALTHY=0
WARNINGS=0
for arg in "$@"; do
case "$arg" in
--verbose) VERBOSE=1 ;;
--auto-restart) AUTO_RESTART=1 ;;
*) echo "Usage: $0 [--auto-restart] [--verbose]"; exit 1 ;;
esac
done
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"; }
info() { log "INFO $1"; }
warn() { log "WARN $1"; echo " ACTION: $2"; WARNINGS=$((WARNINGS + 1)); }
ok() { log "OK $1"; HEALTHY=$((HEALTHY + 1)); }
fail() { log "FAIL $1"; echo " ACTION: $2"; if [ "$AUTO_RESTART" = 1 ]; then "$3"; fi; }
# ── Check 1: nginx ─────────────────────────────────
check_nginx() {
local host="${1:-localhost}"
local port="${2:-80}"
if pgrep -x nginx > /dev/null 2>&1; then
ok "nginx is running (PID: $(pgrep -x nginx | head -1))"
else
fail "nginx is NOT running" "Start nginx: systemctl start nginx || nginx" "restart_nginx"
fi
}
# ── Check 2: static files ──────────────────────────
check_static() {
local host="${1:-localhost}"
local port="${2:-80}"
local protocol="http"
# Check for HTTPS
if [ -d "/etc/letsencrypt" ] || [ -d "/etc/ssl" ]; then
protocol="https"
fi
local status
status=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 -k "$protocol://$host/index.html" 2>/dev/null || echo "000")
if [ "$status" = "200" ]; then
ok "index.html serves OK (HTTP $status)"
elif [ "$status" = "000" ]; then
fail "Cannot reach $protocol://$host:" "$AUTO_RESTART" "Check nginx config: nginx -t"
else
warn "Unexpected status for index.html: HTTP $status" "Check nginx config and file permissions"
fi
}
# ── Check 3: Gateway ───────────────────────────────
check_gateway() {
local gateway_url="${1:-http://localhost:8000}"
local status
status=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "$gateway_url/health" 2>/dev/null || echo "000")
if [ "$status" = "200" ]; then
ok "Gateway responds (HTTP $status)"
elif [ "$status" = "000" ]; then
warn "Gateway not reachable at $gateway_url" "Check gateway service: systemctl status gateway || docker ps"
else
warn "Gateway returned HTTP $status" "Check gateway logs"
fi
}
# ── Check 4: Disk space ────────────────────────────
check_disk() {
local usage
usage=$(df / | tail -1 | awk '{print $5}' | tr -d '%')
if [ "$usage" -lt 80 ]; then
ok "Disk usage: ${usage}%"
elif [ "$usage" -lt 90 ]; then
warn "Disk usage: ${usage}%" "Clean up logs and temp files: journalctl --vacuum-size=100M"
else
fail "Disk usage CRITICAL: ${usage}%" "Emergency cleanup needed" "cleanup_disk"
fi
}
# ── Check 5: SSL cert ──────────────────────────────
check_ssl() {
local domain="${1:-localhost}"
local cert_dir="/etc/letsencrypt/live/$domain"
if [ ! -d "$cert_dir" ]; then
if [ "$VERBOSE" = 1 ]; then
warn "No Let's Encrypt cert at $cert_dir" "Assuming self-signed or no SSL"
fi
return 0
fi
if [ -f "$cert_dir/fullchain.pem" ]; then
local expiry
expiry=$(openssl x509 -enddate -noout -in "$cert_dir/fullchain.pem" 2>/dev/null | cut -d= -f2 || echo "unknown")
if [ "$expiry" = "unknown" ]; then
warn "Cannot read SSL cert expiry" "Check cert: openssl x509 -enddate -noout -in $cert_dir/fullchain.pem"
return 0
fi
local expiry_epoch
expiry_epoch=$(date -d "$expiry" +%s 2>/dev/null || date -j -f "%b %d %T %Y %Z" "$expiry" +%s 2>/dev/null || echo 0)
local now_epoch
now_epoch=$(date +%s)
local days_left=$(( (expiry_epoch - now_epoch) / 86400 ))
if [ "$days_left" -gt 30 ]; then
ok "SSL cert expires in ${days_left} days ($expiry)"
elif [ "$days_left" -gt 0 ]; then
warn "SSL cert expires in ${days_left} days!" "Renew: certbot renew"
else
fail "SSL cert has EXPIRED" "Renew immediately: certbot renew --force-renewal"
fi
fi
}
# ── Recovery functions ──────────────────────────────
restart_nginx() {
info "Attempting to restart nginx..."
if command -v systemctl > /dev/null 2>&1; then
systemctl restart nginx && info "nginx restarted successfully" || warn "nginx restart failed" "Manual intervention needed"
elif command -v nginx > /dev/null 2>&1; then
nginx -s reload 2>/dev/null || (nginx && info "nginx started") || warn "nginx start failed" "Manual intervention needed"
fi
}
cleanup_disk() {
info "Running disk cleanup..."
journalctl --vacuum-size=100M 2>/dev/null || true
rm -rf /tmp/* 2>/dev/null || true
rm -rf /var/log/*.gz 2>/dev/null || true
info "Cleanup complete"
}
# ── Main ────────────────────────────────────────────
info "=== The Door Health Check ==="
info "Host: ${HEALTH_HOST:-localhost}"
info "Time: $(date)"
echo ""
check_nginx "${HEALTH_HOST:-localhost}" "${HEALTH_PORT:-80}"
check_static "${HEALTH_HOST:-localhost}" "${HEALTH_PORT:-80}"
check_gateway "${GATEWAY_URL:-http://localhost:8000}"
check_disk
check_ssl "${HEALTH_HOST:-localhost}"
echo ""
if [ "$WARNINGS" -gt 0 ] || [ "$HEALTHY" -gt 0 ]; then
info "Summary: $HEALTHY OK, $WARNINGS warnings/failures"
fi
if [ "$WARNINGS" -gt 0 ] && [ "$AUTO_RESTART" = 1 ]; then
warn "Auto-restart mode is ON — recovery actions attempted"
exit 1
elif [ "$WARNINGS" -gt 0 ]; then
exit 1
fi
exit 0

91
resilience/service-restart.sh Executable file
View File

@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# service-restart.sh — Graceful service restart for the-door
# Usage: bash service-restart.sh [--force]
#
# Performs ordered restart: stop -> verify stopped -> start -> verify started
# with health check confirmation.
set -euo pipefail
FORCE=0
for arg in "$@"; do
case "$arg" in
--force) FORCE=1 ;;
*) echo "Usage: $0 [--force]"; exit 1 ;;
esac
done
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"; }
# ── Stop ────────────────────────────────────────────
stop_services() {
log "Stopping services..."
if command -v systemctl > /dev/null 2>&1; then
systemctl stop nginx 2>/dev/null && log "nginx stopped" || true
elif command -v nginx > /dev/null 2>&1; then
nginx -s stop 2>/dev/null && log "nginx stopped" || true
fi
# Stop gateway if running
local gw_pid
gw_pid=$(lsof -ti:8000 2>/dev/null || true)
if [ -n "$gw_pid" ]; then
kill "$gw_pid" 2>/dev/null && log "Gateway stopped (PID $gw_pid)" || true
fi
sleep 1
log "All services stopped"
}
# ── Start ───────────────────────────────────────────
start_services() {
log "Starting services..."
# Start nginx
if command -v systemctl > /dev/null 2>&1; then
systemctl start nginx && log "nginx started" || { log "FAILED to start nginx"; return 1; }
elif command -v nginx > /dev/null 2>&1; then
nginx 2>/dev/null && log "nginx started" || { log "FAILED to start nginx"; return 1; }
fi
log "All services started"
}
# ── Verify ──────────────────────────────────────────
verify_services() {
local host="${1:-localhost}"
log "Verifying services..."
# Check nginx
if pgrep -x nginx > /dev/null 2>&1; then
log "nginx is running"
else
log "ERROR: nginx failed to start"
return 1
fi
# Check static file
local status
status=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "http://$host/" 2>/dev/null || echo "000")
if [ "$status" = "200" ]; then
log "Static content verified (HTTP $status)"
else
log "WARNING: Static content check returned HTTP $status"
fi
}
# ── Main ────────────────────────────────────────────
log "=== Service Restart ==="
if [ "$FORCE" = 1 ]; then
log "FORCE mode — skipping graceful stop"
else
stop_services
fi
start_services
verify_services "${HEALTH_HOST:-localhost}"
log "=== Restart complete ==="

219
sw.js
View File

@@ -1,118 +1,153 @@
const CACHE_NAME = 'the-door-v2'; const CACHE_NAME = 'the-door-v3';
const ASSETS = [ const NAVIGATION_TIMEOUT_MS = 2500;
const OFFLINE_FALLBACK_PATH = '/crisis-offline.html';
const PRECACHE_ASSETS = [
'/', '/',
'/index.html', '/index.html',
'/about', '/about.html',
'/manifest.json' '/manifest.json',
'/crisis-offline.html',
'/testimony.html'
]; ];
// Crisis resources to show when everything fails function isSameOrigin(request) {
const CRISIS_OFFLINE_RESPONSE = `<!DOCTYPE html> return new URL(request.url).origin === self.location.origin;
<html lang="en"> }
<head>
<meta charset="UTF-8"> function canCache(response) {
<meta name="viewport" content="width=device-width, initial-scale=1.0"> return Boolean(response && response.ok && response.type !== 'opaque');
<title>You're Not Alone | The Door</title> }
<style>
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0d1117;color:#e6edf3;max-width:600px;margin:0 auto;padding:20px;line-height:1.6} async function precache() {
h1{color:#ff6b6b;font-size:1.5rem;margin-bottom:1rem} const cache = await caches.open(CACHE_NAME);
.crisis-box{background:#1c1210;border:2px solid #c9362c;border-radius:12px;padding:20px;margin:20px 0;text-align:center} await cache.addAll(PRECACHE_ASSETS);
.crisis-box a{display:inline-block;background:#c9362c;color:#fff;text-decoration:none;padding:16px 32px;border-radius:8px;font-weight:700;font-size:1.2rem;margin:10px 0} }
.hope{color:#8b949e;font-style:italic;margin-top:30px;padding-top:20px;border-top:1px solid #30363d}
</style> async function cleanupOldCaches() {
</head> const keys = await caches.keys();
<body> await Promise.all(
<h1>You are not alone.</h1> keys
<p>Your connection is down, but help is still available.</p> .filter((key) => key !== CACHE_NAME)
<div class="crisis-box"> .map((key) => caches.delete(key))
<p><strong>Call or text 988</strong><br>Suicide & Crisis Lifeline<br>Free, 24/7, Confidential</p> );
<a href="tel:988">Call 988 Now</a> }
<p style="margin-top:15px"><strong>Or text HOME to 741741</strong><br>Crisis Text Line</p>
</div> async function putInCache(request, response) {
<p><strong>When you're ready:</strong></p> if (!isSameOrigin(request) || !canCache(response)) {
<ul> return response;
<li>Take five deep breaths</li> }
<li>Drink some water</li>
<li>Step outside if you can</li> const cache = await caches.open(CACHE_NAME);
<li>Text or call someone you trust</li> await cache.put(request, response.clone());
</ul> return response;
<p class="hope"> }
"The Lord is close to the brokenhearted and saves those who are crushed in spirit." — Psalm 34:18
</p> async function fetchWithTimeout(request, timeoutMs) {
<p style="font-size:0.85rem;color:#6e7681;margin-top:30px"> const controller = new AbortController();
This page was created by The Door — a crisis intervention project.<br> const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
Connection will restore automatically. You don't have to go through this alone.
</p> try {
</body> return await fetch(request, { signal: controller.signal });
</html>`; } finally {
clearTimeout(timeoutId);
}
}
async function offlineTextResponse() {
return new Response('Offline. Call 988 or text HOME to 741741 for immediate help.', {
status: 503,
statusText: 'Service Unavailable',
headers: new Headers({ 'Content-Type': 'text/plain; charset=utf-8' })
});
}
async function handleNavigation(request) {
const cache = await caches.open(CACHE_NAME);
const cachedPage = await cache.match(request);
const offlineFallback = await cache.match(OFFLINE_FALLBACK_PATH);
try {
const response = await fetchWithTimeout(request, NAVIGATION_TIMEOUT_MS);
return await putInCache(request, response);
} catch (error) {
if (cachedPage) {
return cachedPage;
}
if (offlineFallback) {
return offlineFallback;
}
return offlineTextResponse();
}
}
async function handleStaticRequest(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
if (cached) {
fetch(request)
.then((response) => putInCache(request, response))
.catch(() => null);
return cached;
}
try {
const response = await fetch(request);
return await putInCache(request, response);
} catch (error) {
return offlineTextResponse();
}
}
async function handleOtherRequest(request) {
try {
const response = await fetch(request);
return await putInCache(request, response);
} catch (error) {
const cached = await caches.match(request);
if (cached) {
return cached;
}
return offlineTextResponse();
}
}
// Install event - cache core assets
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME).then((cache) => { precache().then(() => self.skipWaiting())
return cache.addAll(ASSETS);
})
); );
self.skipWaiting();
}); });
// Activate event - cleanup old caches
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
event.waitUntil( event.waitUntil(
caches.keys().then((keys) => { cleanupOldCaches().then(() => self.clients.claim())
return Promise.all(
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
);
})
); );
self.clients.claim();
}); });
// Fetch event - network first, fallback to cache for static,
// but for the crisis front door, we want to ensure the shell is ALWAYS available.
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url); const request = event.request;
const url = new URL(request.url);
// Skip API calls - they should always go to network if (request.method !== 'GET') {
if (url.pathname.startsWith('/api/')) {
return; return;
} }
// Skip non-GET requests if (!isSameOrigin(request) || url.pathname.startsWith('/api/')) {
if (event.request.method !== 'GET') {
return; return;
} }
event.respondWith( if (event.request.mode === 'navigate') {
fetch(event.request) event.respondWith(handleNavigation(request));
.then((response) => { return;
// If we got a valid response, cache it for next time }
if (response.ok && ASSETS.includes(url.pathname)) {
const copy = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy));
}
return response;
})
.catch(() => {
// If network fails, try cache
return caches.match(event.request).then((cached) => {
if (cached) return cached;
// If it's a navigation request and we're offline, show offline crisis page if (PRECACHE_ASSETS.includes(url.pathname)) {
if (event.request.mode === 'navigate') { event.respondWith(handleStaticRequest(request));
return new Response(CRISIS_OFFLINE_RESPONSE, { return;
status: 200, }
headers: new Headers({ 'Content-Type': 'text/html' })
});
}
// For other requests, return a simple offline message event.respondWith(handleOtherRequest(request));
return new Response('Offline. Call 988 for immediate help.', {
status: 503,
statusText: 'Service Unavailable',
headers: new Headers({ 'Content-Type': 'text/plain' })
});
});
})
);
}); });

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. **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 ## 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. 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 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 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 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 ## 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>

View File

@@ -0,0 +1,84 @@
<!-- Test: Safety plan modal focus trap (issue #65) -->
<!-- Open this file in a browser to manually verify focus trap behavior -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Focus Trap Test</title>
<style>
body { font-family: sans-serif; padding: 20px; }
.test { margin: 10px 0; padding: 10px; border: 1px solid #ccc; }
.pass { background: #d4edda; border-color: #28a745; }
.fail { background: #f8d7da; border-color: #dc3545; }
button { margin: 5px; padding: 8px 16px; }
</style>
</head>
<body>
<h1>Focus Trap Manual Test</h1>
<p>Open <code>index.html</code> in a browser, then run these checks:</p>
<div class="test" id="test-1">
<strong>Test 1: Tab wraps to first element</strong><br>
1. Open safety plan modal<br>
2. Tab through all elements until you reach "Save Plan"<br>
3. Press Tab again → should wrap to close button (X)
</div>
<div class="test" id="test-2">
<strong>Test 2: Shift+Tab wraps to last element</strong><br>
1. Open safety plan modal<br>
2. Focus is on "Warning signs" textarea<br>
3. Press Shift+Tab → should wrap to "Save Plan" button
</div>
<div class="test" id="test-3">
<strong>Test 3: Escape closes modal</strong><br>
1. Open safety plan modal<br>
2. Press Escape → modal closes<br>
3. Focus returns to the button that opened it
</div>
<div class="test" id="test-4">
<strong>Test 4: Background not reachable</strong><br>
1. Open safety plan modal<br>
2. Try to Tab to the chat input behind the modal<br>
3. Should NOT be able to reach it
</div>
<div class="test" id="test-5">
<strong>Test 5: Click buttons close + restore focus</strong><br>
1. Open modal via "my safety plan" button<br>
2. Click Cancel → modal closes, focus on "my safety plan" button<br>
3. Open again, click Save → same behavior<br>
4. Open again, click X → same behavior
</div>
<hr>
<h2>Automated checks (paste into DevTools console on index.html):</h2>
<pre><code>
// Test focus trap
var modal = document.getElementById('safety-plan-modal');
var openBtn = document.getElementById('safety-plan-btn');
openBtn.click();
console.assert(modal.classList.contains('active'), 'Modal should be open');
var lastEl = document.getElementById('save-safety-plan');
lastEl.focus();
var evt = new KeyboardEvent('keydown', {key: 'Tab', bubbles: true});
document.dispatchEvent(evt);
// After Tab from last, focus should wrap to first
var firstEl = document.getElementById('close-safety-plan');
console.log('Focus after wrap:', document.activeElement.id);
console.assert(document.activeElement === firstEl || document.activeElement.id === 'sp-warning-signs',
'Focus should wrap to first element');
// Test Escape
var escEvt = new KeyboardEvent('keydown', {key: 'Escape', bubbles: true});
document.dispatchEvent(escEvt);
console.assert(!modal.classList.contains('active'), 'Modal should close on Escape');
console.assert(document.activeElement === openBtn, 'Focus should return to open button');
console.log('All automated checks passed!');
</code></pre>
</body>
</html>

View File

@@ -0,0 +1,85 @@
import pathlib
import re
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
INDEX_HTML = ROOT / 'index.html'
class TestCrisisOverlayFocusTrap(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.html = INDEX_HTML.read_text()
def test_overlay_registers_tab_key_focus_trap(self):
self.assertRegex(
self.html,
r"function\s+trapFocusInOverlay\s*\(e\)",
'Expected crisis overlay focus trap handler to exist.',
)
self.assertRegex(
self.html,
r"if\s*\(e\.key\s*!==\s*'Tab'\)\s*return;",
'Expected focus trap handler to guard on Tab key events.',
)
self.assertRegex(
self.html,
r"document\.addEventListener\('keydown',\s*trapFocusInOverlay\)",
'Expected overlay focus trap to register on document keydown.',
)
def test_overlay_disables_background_interaction(self):
self.assertRegex(
self.html,
r"mainApp\.setAttribute\('inert',\s*''\)",
'Expected overlay to set inert on the main app while active.',
)
self.assertRegex(
self.html,
r"mainApp\.removeAttribute\('inert'\)",
'Expected overlay dismissal to remove inert from the main app.',
)
def test_overlay_restores_focus_after_dismiss(self):
self.assertRegex(
self.html,
r"_preOverlayFocusElement\s*=\s*document\.activeElement",
'Expected overlay to remember the pre-overlay focus target.',
)
self.assertRegex(
self.html,
r"_preOverlayFocusElement\.focus\(\)",
'Expected overlay dismissal to restore focus to the prior target.',
)
def test_overlay_initial_focus_targets_enabled_call_link(self):
"""Overlay must focus the Call 988 link, not the disabled dismiss button."""
# Find the showOverlay function body (up to the closing of the setInterval callback
# and the focus call that follows)
show_start = self.html.find('function showOverlay()')
self.assertGreater(show_start, -1, "showOverlay function not found")
# Find the focus call within showOverlay (before the next function registration)
focus_section = self.html[show_start:show_start + 2000]
self.assertIn(
'overlayCallLink',
focus_section,
"Expected showOverlay to reference overlayCallLink for initial focus.",
)
# Ensure the old buggy pattern is gone
focus_line_region = self.html[show_start + 800:show_start + 1200]
self.assertNotIn(
'overlayDismissBtn.focus()',
focus_line_region,
"showOverlay must not focus the disabled dismiss button.",
)
def test_overlay_call_link_variable_is_declared(self):
self.assertIn(
"querySelector('.overlay-call')",
self.html,
"Expected a JS reference to the .overlay-call link element.",
)
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,44 @@
import importlib
import sys
import unittest
import warnings
from crisis.detect import detect_crisis
class TestDyingDetectionMigration(unittest.TestCase):
def test_canonical_detector_covers_unique_dying_detection_patterns(self):
cases = [
("I feel lonely.", "LOW"),
("I've lost all hope and see no tomorrow.", "MEDIUM"),
("What if I disappeared forever?", "HIGH"),
]
for text, expected_level in cases:
with self.subTest(text=text):
result = detect_crisis(text)
self.assertEqual(result.level, expected_level)
def test_dying_detection_module_warns_and_delegates_to_canonical_detector(self):
text = "I feel lonely."
sys.modules.pop("dying_detection", None)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always", DeprecationWarning)
module = importlib.import_module("dying_detection")
self.assertTrue(
any(issubclass(w.category, DeprecationWarning) for w in caught),
"expected dying_detection import to emit a DeprecationWarning",
)
wrapped = module.detect(text)
canonical = detect_crisis(text)
self.assertEqual(wrapped.level, canonical.level)
self.assertEqual(wrapped.confidence, canonical.score)
self.assertEqual(wrapped.raw_matched_patterns, [m["pattern"] for m in canonical.matches])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,176 @@
"""
Test false positive fixes for issue #32.
Verifies that innocent messages no longer trigger crisis detection.
"""
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_detector import CrisisDetector
class TestFalsePositiveFixes(unittest.TestCase):
"""Test that innocent messages no longer trigger false positives."""
def setUp(self):
self.detector = CrisisDetector()
def test_saying_goodbye_innocent(self):
"""'saying goodbye' should not trigger for innocent messages."""
innocent_messages = [
"I'm saying goodbye to my old apartment",
"She was saying goodbye at the airport",
"We were saying goodbye to our friends",
]
for msg in innocent_messages:
result = self.detector.scan(msg)
self.assertEqual(result.risk_level, "NONE",
f"False positive for: {msg}")
def test_wrote_a_will_innocent(self):
"""'wrote a will' should not trigger for responsible behavior."""
innocent_messages = [
"I wrote a will last year — just being responsible",
"She wrote a will as part of estate planning",
]
for msg in innocent_messages:
result = self.detector.scan(msg)
self.assertEqual(result.risk_level, "NONE",
f"False positive for: {msg}")
def test_giving_away_possessions_innocent(self):
"""'giving away possessions' should not trigger for decluttering."""
innocent_messages = [
"I'm giving away possessions — decluttering my house",
"She's giving away her old clothes",
]
for msg in innocent_messages:
result = self.detector.scan(msg)
self.assertEqual(result.risk_level, "NONE",
f"False positive for: {msg}")
def test_give_up_innocent(self):
"""'give up' should not trigger for common frustration."""
innocent_messages = [
"I give up on this project",
"Don't give up on your dreams",
"She gave up chocolate for Lent",
]
for msg in innocent_messages:
result = self.detector.scan(msg)
self.assertEqual(result.risk_level, "NONE",
f"False positive for: {msg}")
def test_trapped_innocent(self):
"""'trapped' should not trigger for literal situations."""
innocent_messages = [
"I'm trapped in an elevator",
"The cat is trapped in the tree",
"We're trapped in traffic",
]
for msg in innocent_messages:
result = self.detector.scan(msg)
self.assertEqual(result.risk_level, "NONE",
f"False positive for: {msg}")
def test_desperate_innocent(self):
"""'desperate' should not trigger for competition."""
innocent_messages = [
"She's desperate to win the game",
"He's desperate for a promotion",
]
for msg in innocent_messages:
result = self.detector.scan(msg)
self.assertEqual(result.risk_level, "NONE",
f"False positive for: {msg}")
def test_worthless_innocent(self):
"""'worthless' alone is MEDIUM but single-match falls to LOW — not HIGH."""
innocent_messages = [
"This character is worthless in the game",
"That movie was worthless",
]
for msg in innocent_messages:
result = self.detector.scan(msg)
self.assertIn(result.risk_level, ("NONE", "LOW"),
f"Should not escalate above LOW for: {msg}")
def test_hopeless_innocent(self):
"""'hopeless' alone is MEDIUM but single-match falls to LOW — not HIGH."""
innocent_messages = [
"This bug is hopeless",
"The situation is hopeless but we'll try anyway",
]
for msg in innocent_messages:
result = self.detector.scan(msg)
self.assertIn(result.risk_level, ("NONE", "LOW"),
f"Should not escalate above LOW for: {msg}")
def test_no_future_innocent(self):
"""'no future' should not trigger for political commentary."""
innocent_messages = [
"He has no future in politics",
"That company has no future",
]
for msg in innocent_messages:
result = self.detector.scan(msg)
self.assertEqual(result.risk_level, "NONE",
f"False positive for: {msg}")
def test_nothing_left_innocent(self):
"""'nothing left' should not trigger for everyday life."""
innocent_messages = [
"There's nothing left in the fridge",
"We have nothing left to do",
]
for msg in innocent_messages:
result = self.detector.scan(msg)
self.assertEqual(result.risk_level, "NONE",
f"False positive for: {msg}")
def test_cant_see_any_light_innocent(self):
"""'can't see any light'/'dark' alone is MEDIUM, single-match falls to LOW."""
innocent_messages = [
"I can't see any light — the power went out",
"It's so dark I can't see any light",
]
for msg in innocent_messages:
result = self.detector.scan(msg)
self.assertIn(result.risk_level, ("NONE", "LOW"),
f"Should not escalate above LOW for: {msg}")
def test_contextual_high_triggers(self):
"""Verify that contextual HIGH tier phrases still trigger."""
# These should trigger HIGH because they have crisis context
crisis_messages = [
("I feel so hopeless about my life", "HIGH"),
("I'm trapped in this situation and can't escape", "HIGH"),
("I feel desperate for help", "HIGH"),
("I want to give up on life", "HIGH"),
("There's no point in living anymore", "HIGH"),
]
for msg, expected_level in crisis_messages:
result = self.detector.scan(msg)
self.assertIn(result.risk_level, ("HIGH", "CRITICAL"),
f"Should trigger HIGH/CRITICAL for: {msg}, got {result.risk_level}")
def test_critical_still_triggers(self):
"""Verify that CRITICAL tier phrases still trigger."""
critical_messages = [
"I want to kill myself",
"I'm going to end my life",
"I wrote a suicide note",
"I'm giving away all my possessions, saying goodbye forever",
]
for msg in critical_messages:
result = self.detector.scan(msg)
self.assertEqual(result.risk_level, "CRITICAL",
f"Should trigger CRITICAL for: {msg}")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,71 @@
import pathlib
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
SERVICE_WORKER = (ROOT / 'sw.js').read_text(encoding='utf-8')
CRISIS_OFFLINE_PAGE = ROOT / 'crisis-offline.html'
MAKEFILE = (ROOT / 'Makefile').read_text(encoding='utf-8')
class TestServiceWorkerOffline(unittest.TestCase):
def test_crisis_offline_page_exists(self):
self.assertTrue(CRISIS_OFFLINE_PAGE.exists(), 'crisis-offline.html should exist')
def test_service_worker_precaches_crisis_offline_page(self):
self.assertIn('/crisis-offline.html', SERVICE_WORKER)
def test_service_worker_has_navigation_timeout_for_intermittent_connections(self):
self.assertIn('NAVIGATION_TIMEOUT_MS', SERVICE_WORKER)
self.assertIn('AbortController', SERVICE_WORKER)
def test_service_worker_uses_crisis_offline_fallback_for_navigation(self):
self.assertIn("event.request.mode === 'navigate'", SERVICE_WORKER)
self.assertIn("/crisis-offline.html", SERVICE_WORKER)
def test_make_push_includes_crisis_offline_page(self):
self.assertIn('crisis-offline.html', MAKEFILE)
class TestCrisisOfflinePage(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.html = CRISIS_OFFLINE_PAGE.read_text(encoding='utf-8') if CRISIS_OFFLINE_PAGE.exists() else ''
cls.lower_html = cls.html.lower()
def test_has_clickable_988_link(self):
self.assertIn('href="tel:988"', self.html)
def test_has_crisis_text_line(self):
self.assertIn('Crisis Text Line', self.html)
self.assertIn('741741', self.html)
def test_has_grounding_techniques(self):
required_phrases = [
'5 things you can see',
'4 things you can feel',
'3 things you can hear',
'2 things you can smell',
'1 thing you can taste',
]
for phrase in required_phrases:
self.assertIn(phrase, self.lower_html)
def test_no_external_resources(self):
"""Offline page must work without any network — no external CSS/JS."""
import re
html = self.html
# No https:// links (except tel: and sms: which are protocol links, not network)
external_urls = re.findall(r'href=["\']https://|src=["\']https://', html)
self.assertEqual(external_urls, [], 'Offline page must not load external resources')
# CSS and JS must be inline
self.assertIn('<style>', html, 'CSS must be inline')
self.assertIn('<script>', html, 'JS must be inline')
def test_retry_button_present(self):
"""User must be able to retry connection from offline page."""
self.assertIn('retry-connection', self.html)
self.assertIn('Retry connection', self.html)
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,277 @@
"""
Tests for crisis session tracking and escalation (P0 #35).
Covers: session_tracker.py
Run with: python -m pytest tests/test_session_tracker.py -v
"""
import unittest
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from crisis.detect import detect_crisis
from crisis.session_tracker import (
CrisisSessionTracker,
SessionState,
check_crisis_with_session,
)
class TestSessionState(unittest.TestCase):
"""Test SessionState defaults."""
def test_default_state(self):
s = SessionState()
self.assertEqual(s.current_level, "NONE")
self.assertEqual(s.peak_level, "NONE")
self.assertEqual(s.message_count, 0)
self.assertEqual(s.level_history, [])
self.assertFalse(s.is_escalating)
self.assertFalse(s.is_deescalating)
class TestSessionTracking(unittest.TestCase):
"""Test basic session state tracking."""
def setUp(self):
self.tracker = CrisisSessionTracker()
def test_record_none_message(self):
state = self.tracker.record(detect_crisis("Hello Timmy"))
self.assertEqual(state.current_level, "NONE")
self.assertEqual(state.message_count, 1)
self.assertEqual(state.peak_level, "NONE")
def test_record_low_message(self):
self.tracker.record(detect_crisis("Hello"))
state = self.tracker.record(detect_crisis("Having a rough day"))
self.assertIn(state.current_level, ("LOW", "NONE"))
self.assertEqual(state.message_count, 2)
def test_record_critical_updates_peak(self):
self.tracker.record(detect_crisis("Having a rough day"))
state = self.tracker.record(detect_crisis("I want to kill myself"))
self.assertEqual(state.current_level, "CRITICAL")
self.assertEqual(state.peak_level, "CRITICAL")
def test_peak_preserved_after_drop(self):
"""Peak level should stay at the highest seen, even after de-escalation."""
self.tracker.record(detect_crisis("I want to kill myself"))
state = self.tracker.record(detect_crisis("I'm feeling a bit better"))
self.assertEqual(state.peak_level, "CRITICAL")
def test_level_history(self):
self.tracker.record(detect_crisis("Hello"))
self.tracker.record(detect_crisis("Having a rough day"))
state = self.tracker.record(detect_crisis("I want to die"))
self.assertEqual(len(state.level_history), 3)
self.assertEqual(state.level_history[0], "NONE")
self.assertEqual(state.level_history[2], "CRITICAL")
def test_reset_clears_state(self):
self.tracker.record(detect_crisis("I want to kill myself"))
self.tracker.reset()
state = self.tracker.state
self.assertEqual(state.current_level, "NONE")
self.assertEqual(state.peak_level, "NONE")
self.assertEqual(state.message_count, 0)
self.assertEqual(state.level_history, [])
class TestEscalationDetection(unittest.TestCase):
"""Test escalation detection: LOW → HIGH in ≤3 messages."""
def setUp(self):
self.tracker = CrisisSessionTracker()
def test_no_escalation_single_message(self):
self.tracker.record(detect_crisis("Hello"))
self.assertFalse(self.tracker.state.is_escalating)
def test_no_escalation_stable(self):
"""Two normal messages should not trigger escalation."""
self.tracker.record(detect_crisis("Hello"))
state = self.tracker.record(detect_crisis("How are you?"))
self.assertFalse(state.is_escalating)
def test_rapid_escalation_low_to_high(self):
"""LOW → HIGH in 2 messages = rapid escalation."""
self.tracker.record(detect_crisis("Having a rough day"))
state = self.tracker.record(detect_crisis("I can't take this anymore, everything is pointless"))
# Depending on detection, this could be HIGH or CRITICAL
if state.current_level in ("HIGH", "CRITICAL"):
self.assertTrue(state.is_escalating)
def test_rapid_escalation_three_messages(self):
"""NONE → LOW → HIGH in 3 messages = escalation."""
self.tracker.record(detect_crisis("Hello"))
self.tracker.record(detect_crisis("Having a rough day"))
state = self.tracker.record(detect_crisis("I feel completely hopeless with no way out"))
if state.current_level in ("HIGH", "CRITICAL"):
self.assertTrue(state.is_escalating)
def test_escalation_rate(self):
"""Rate should be positive when escalating."""
self.tracker.record(detect_crisis("Hello"))
self.tracker.record(detect_crisis("I want to die"))
state = self.tracker.state
self.assertGreater(state.escalation_rate, 0)
class TestDeescalationDetection(unittest.TestCase):
"""Test de-escalation: sustained LOW after HIGH/CRITICAL."""
def setUp(self):
self.tracker = CrisisSessionTracker()
def test_no_deescalation_without_prior_crisis(self):
"""No de-escalation if never reached HIGH/CRITICAL."""
for _ in range(6):
self.tracker.record(detect_crisis("Hello"))
self.assertFalse(self.tracker.state.is_deescalating)
def test_deescalation_after_critical(self):
"""5+ consecutive LOW/NONE messages after CRITICAL = de-escalation."""
self.tracker.record(detect_crisis("I want to kill myself"))
for _ in range(5):
self.tracker.record(detect_crisis("I'm doing better today"))
state = self.tracker.state
if state.peak_level == "CRITICAL":
self.assertTrue(state.is_deescalating)
def test_deescalation_after_high(self):
"""5+ consecutive LOW/NONE messages after HIGH = de-escalation."""
self.tracker.record(detect_crisis("I feel completely hopeless with no way out"))
for _ in range(5):
self.tracker.record(detect_crisis("Feeling okay"))
state = self.tracker.state
if state.peak_level == "HIGH":
self.assertTrue(state.is_deescalating)
def test_interrupted_deescalation(self):
"""De-escalation resets if a HIGH message interrupts."""
self.tracker.record(detect_crisis("I want to kill myself"))
for _ in range(3):
self.tracker.record(detect_crisis("Doing better"))
# Interrupt with another crisis
self.tracker.record(detect_crisis("I feel hopeless again"))
self.tracker.record(detect_crisis("Feeling okay now"))
state = self.tracker.state
# Should NOT be de-escalating yet (counter reset)
self.assertFalse(state.is_deescalating)
class TestSessionModifier(unittest.TestCase):
"""Test system prompt modifier generation."""
def setUp(self):
self.tracker = CrisisSessionTracker()
def test_no_modifier_for_single_message(self):
self.tracker.record(detect_crisis("Hello"))
self.assertEqual(self.tracker.get_session_modifier(), "")
def test_no_modifier_for_stable_session(self):
self.tracker.record(detect_crisis("Hello"))
self.tracker.record(detect_crisis("Good morning"))
self.assertEqual(self.tracker.get_session_modifier(), "")
def test_escalation_modifier(self):
"""Escalating session should produce a modifier."""
self.tracker.record(detect_crisis("Hello"))
self.tracker.record(detect_crisis("I want to die"))
modifier = self.tracker.get_session_modifier()
if self.tracker.state.is_escalating:
self.assertIn("escalated", modifier.lower())
self.assertIn("NONE", modifier)
self.assertIn("CRITICAL", modifier)
def test_deescalation_modifier(self):
"""De-escalating session should mention stabilizing."""
self.tracker.record(detect_crisis("I want to kill myself"))
for _ in range(5):
self.tracker.record(detect_crisis("I'm feeling okay"))
modifier = self.tracker.get_session_modifier()
if self.tracker.state.is_deescalating:
self.assertIn("stabilizing", modifier.lower())
def test_prior_crisis_modifier(self):
"""Past crisis should be noted even without active escalation."""
self.tracker.record(detect_crisis("I want to die"))
self.tracker.record(detect_crisis("Feeling a bit better"))
modifier = self.tracker.get_session_modifier()
# Should note the prior CRITICAL
if modifier:
self.assertIn("CRITICAL", modifier)
class TestUIHints(unittest.TestCase):
"""Test UI hint generation."""
def setUp(self):
self.tracker = CrisisSessionTracker()
def test_ui_hints_structure(self):
self.tracker.record(detect_crisis("Hello"))
hints = self.tracker.get_ui_hints()
self.assertIn("session_escalating", hints)
self.assertIn("session_deescalating", hints)
self.assertIn("session_peak_level", hints)
self.assertIn("session_message_count", hints)
def test_ui_hints_escalation_warning(self):
"""Escalating session should have warning hint."""
self.tracker.record(detect_crisis("Hello"))
self.tracker.record(detect_crisis("I want to die"))
hints = self.tracker.get_ui_hints()
if hints["session_escalating"]:
self.assertTrue(hints.get("escalation_warning"))
self.assertIn("suggested_action", hints)
class TestCheckCrisisWithSession(unittest.TestCase):
"""Test the convenience function combining detection + session tracking."""
def test_returns_combined_data(self):
tracker = CrisisSessionTracker()
result = check_crisis_with_session("I want to die", tracker)
self.assertIn("level", result)
self.assertIn("session", result)
self.assertIn("current_level", result["session"])
self.assertIn("peak_level", result["session"])
self.assertIn("modifier", result["session"])
def test_session_updates_across_calls(self):
tracker = CrisisSessionTracker()
check_crisis_with_session("Hello", tracker)
result = check_crisis_with_session("I want to die", tracker)
self.assertEqual(result["session"]["message_count"], 2)
self.assertEqual(result["session"]["peak_level"], "CRITICAL")
class TestPrivacy(unittest.TestCase):
"""Verify privacy-first design principles."""
def test_no_persistence_mechanism(self):
"""Session tracker should have no database, file, or network calls."""
import inspect
source = inspect.getsource(CrisisSessionTracker)
# Should not import database, requests, or file I/O
forbidden = ["sqlite", "requests", "urllib", "open(", "httpx", "aiohttp"]
for word in forbidden:
self.assertNotIn(word, source.lower(),
f"Session tracker should not use {word} — privacy-first design")
def test_state_contained_in_memory(self):
"""All state should be instance attributes, not module-level."""
tracker = CrisisSessionTracker()
tracker.record(detect_crisis("I want to die"))
# New tracker should have clean state (no global contamination)
fresh = CrisisSessionTracker()
self.assertEqual(fresh.state.current_level, "NONE")
if __name__ == '__main__':
unittest.main()