chore: initial commit — chat-saiden web chat baseline

This commit is contained in:
marauder-actual
2026-05-29 13:47:34 +02:00
commit 96ba8f4b6e
28 changed files with 4852 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# Required
ANTHROPIC_API_KEY=sk-ant-...
GOOGLE_CLIENT_ID=000000000000-xxxxxxxxxxxxxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxx
# Optional — defaults shown
ANTHROPIC_MODEL=claude-sonnet-4-5-20250929
BASE_URL=https://chat.saiden.dev
ALLOWED_EMAILS=adam.ladachowski@gmail.com
# COOKIE_SECURE=false # set this only for local http dev
# SESSION_SECRET=... # auto-generated if omitted; set for stable sessions across restarts
+22
View File
@@ -0,0 +1,22 @@
# Python
__pycache__/
*.py[cod]
.venv/
venv/
*.egg-info/
# Env / secrets
.env
.env.*
!.env.example
# Caches
.fastembed_cache/
.ruff_cache/
.pytest_cache/
.mypy_cache/
# Editor / OS
*.json.bak
*.swp
.DS_Store
+1
View File
@@ -0,0 +1 @@
3.12
+60
View File
@@ -0,0 +1,60 @@
# chat.saiden.dev
A quiet channel. Spike Jonze's *Her* (2013) as a chat UI.
```
chat.saiden.dev
→ Caddy reverse-proxy (TLS via Let's Encrypt)
→ FastAPI on marauder.saiden.dev:8765
├─ Google OAuth (authlib) + email whitelist
└─ WebSocket → Anthropic API streaming
```
## Status
Phase 3 — Her-aesthetic. UI plan in `UI-PLAN.md`. Phase 1 (ttyd + CF Tunnel)
archived in `_archive-ttyd/`.
## Local dev
```bash
cd ~/Projects/chat-saiden
uv sync
cp .env.example .env
# fill in ANTHROPIC_API_KEY, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, COOKIE_SECURE=false
uv run python -m app.main
# → http://127.0.0.1:8765
```
Google OAuth redirect URI for local dev (add to your Google Cloud Console
OAuth client): `http://127.0.0.1:8765/auth/callback`
## Files
| Path | What |
|------|------|
| `app/main.py` | FastAPI: OAuth, session, WS streaming, Anthropic client |
| `app/templates/chat.html` | Single-page UI (chat + operator prompt) |
| `app/templates/denied.html` | Whitelist-rejection page, Her-styled |
| `app/static/chat.css` | Design system: warm pastels, Cormorant + Caveat |
| `app/static/chat.js` | WebSocket, typewriter queue, label dedup, smooth scroll |
| `pyproject.toml` | uv deps |
| `.env.example` | env scaffold |
| `UI-PLAN.md` | Design doctrine (Her aesthetic translated to chat) |
| `_archive-ttyd/` | Phase 1 (ttyd + CF Tunnel + CF Access) — kept for reference |
## Deployment
Not deployed yet. Stages remaining:
1. Caddyfile on marauder.saiden.dev (reverse proxy chat.saiden.dev → :8765)
2. systemd unit for the FastAPI app
3. DNS swap: `chat.saiden.dev` CNAME → `marauder.saiden.dev` (currently broken — points
to deleted CF tunnel from Phase 1)
4. Google OAuth client production redirect URI: `https://chat.saiden.dev/auth/callback`
5. Smoke test live
## Persona
System prompt currently hardcoded as BT-7274 (placeholder). Will swap to a
dedicated **Samantha cart** when authored — see EEMS subject `project.samantha-cart`.
+290
View File
@@ -0,0 +1,290 @@
# chat.saiden.dev — UI plan (Her, 2013)
**Brief:** simple chat. Operator prompt at bottom. Thinking indication. BT's reply
streams in as a typewriter animation. Get as close to Spike Jonze's *Her* /
Geoff McFetridge's OS1 as the medium allows.
---
## Layout philosophy
**One centered column. Nothing else.** No header bar. No sidebar. No footer.
The conversation IS the page.
```
┌──────────────────────────────────────────────────────────────┐
│ │
│ · │ ← tiny Saiden
│ │ sigil (12px)
│ │ top-right
│ │
│ │
│ Pilot │
│ how was your day? │
│ │
│ │
│ BT │
│ Quiet, Pilot. The mesh held. Three packets │
│ drifted from sazabi but they came back. Nothing │
│ I'd call attention to. │
│ │
│ │
│ │
│ Pilot │
│ what are you reading │
│ │
│ ● │ ← thinking
│ │ dot
│ │
│ │
│ │
│ ────────────────────────────────── │ ← input
│ speak to BT │ underline
│ │
└──────────────────────────────────────────────────────────────┘
↑ max-width ~640px, centered, generous breathing
```
**Key rules**
- Column width: `max-width: 38rem` (~608px) — letter-sized, like reading prose
- Vertical padding: `4rem` top + bottom
- Horizontal padding on small screens: `1.5rem`
- Auto-scroll to bottom on new message, but **slowly** (smooth easing, ~600ms)
---
## Color tokens
Mapped from the Her research notes. All warm, no cool.
```css
:root {
/* surfaces */
--bg: #f5ebe0; /* cream — page background */
--bg-soft: #f8e8db; /* dusty peach — hover surfaces, if any */
--surface-card: #f0d9c7; /* dusty rose — rarely used, only for emphasis */
/* text */
--ink: #3d2820; /* warm dark brown — BT's voice, primary */
--ink-muted: #8b6f60; /* dusty taupe — pilot's messages, system text */
--ink-faint: #b89a87; /* faded rose-brown — placeholders, timestamps */
/* accents — sparingly */
--coral: #e07856; /* coral salmon — primary accent (thinking dot,
the Saiden sigil tone) */
--red: #c54f3d; /* playful red — links, focus state */
--gold: #d4a574; /* muted gold — special states, never as text */
/* lines */
--line: #e8d4c0; /* dusty rose hairline — input underline */
}
```
**Saiden sigil reconciliation**
The existing red sigil at `saiden.dev/logo.png` is already warm + brushy + playful.
Keep it. Use at **tiny** size (16px) in the top-right corner. It's identity, not chrome.
---
## Typography
**No monospace.** This is the deliberate break from Phase 2's TUI plan.
| Role | Family | Weight | Size | Style |
|------|--------|--------|------|-------|
| BT messages | `Cormorant Garamond` (Google Fonts) | 500 | 1.25rem | regular |
| Pilot messages | same | 400 | 1.125rem | regular |
| Speaker labels (`Pilot`, `BT`) | `Caveat` (Google Fonts) | 400 | 1rem | italic, --ink-muted |
| Operator prompt input | `Cormorant Garamond` | 400 | 1.125rem | regular |
| Tiny chrome (timestamps, errors) | `Inter` 300 | 0.8rem | uppercase letter-spaced |
**Why Cormorant Garamond:** elegant 18thC-derived serif. Reads like a letter,
not an app. Matches "Beautiful Hand-Written Letters" mood.
**Why Caveat for labels:** Samantha's cursive callouts. Gives speaker labels a
handwritten, intimate feel — like a note left for someone, not a system tag.
**Loading optimization:** preload `cormorant-garamond-500.woff2` +
`caveat-400.woff2`, swap others.
---
## Components
### 1. Message — speaker label + body
```html
<article class="msg msg--bt">
<div class="msg__label">BT</div>
<div class="msg__body">
The mesh held. Three packets drifted from sazabi but they came back.
</div>
</article>
```
```css
.msg { margin: 2.5rem 0; }
.msg__label { font-family: 'Caveat'; color: var(--ink-muted);
font-size: 1rem; letter-spacing: 0.02em; margin-bottom: 0.5rem; }
.msg--bt .msg__body { color: var(--ink); font-size: 1.25rem; line-height: 1.6; }
.msg--user .msg__body { color: var(--ink-muted); font-size: 1.125rem; line-height: 1.55; }
.msg--system .msg__body { color: var(--ink-faint); font-size: 0.85rem;
text-transform: uppercase; letter-spacing: 0.1em; }
```
No bubbles. No borders. No backgrounds. **Pure typography.**
### 2. Operator prompt — single hairline + invisible input
```html
<form class="prompt">
<hr class="prompt__line">
<input class="prompt__input" placeholder="speak to BT" autofocus>
</form>
```
```css
.prompt { position: sticky; bottom: 0; background: var(--bg);
padding: 2rem 0 3rem 0; }
.prompt__line { border: none; border-top: 1px solid var(--line);
margin: 0 0 0.75rem 0; }
.prompt__input { width: 100%; border: none; background: transparent;
outline: none; font-family: 'Cormorant Garamond';
font-size: 1.125rem; color: var(--ink); }
.prompt__input::placeholder { color: var(--ink-faint); font-style: italic; }
.prompt__input:focus { caret-color: var(--coral); }
```
**Caret IS the cursor.** No send button. Enter submits. Empty input is no-op.
### 3. Thinking indication — single pulsing dot
```html
<div class="thinking" aria-label="BT is thinking"></div>
```
```css
.thinking {
color: var(--coral);
font-size: 0.9rem;
margin: 2rem 0 2rem 0;
animation: pulse 1.8s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.35; }
50% { opacity: 1.0; }
}
```
That's it. One coral dot, breathing. Mirrors Samantha's "presence" indicator —
felt, not announced.
### 4. Typewriter stream
When BT's WebSocket chunks arrive:
- Append each chunk character-by-character on a queue
- Drain queue at **~45 chars/sec** (≈22ms/char) — fast enough to feel alive,
slow enough to *read along*
- After the last chunk + `done:true`, briefly show a soft cursor that fades
```javascript
// pseudocode
const queue = [];
let draining = false;
function enqueue(chunk) {
for (const ch of chunk) queue.push(ch);
if (!draining) drain();
}
async function drain() {
draining = true;
while (queue.length) {
bodyEl.textContent += queue.shift();
await sleep(22);
}
draining = false;
}
```
**No syntax highlighting in v1.** Markdown rendering deferred. Plain prose only.
(We can add highlight.js + a tasteful warm theme later if Pilot wants code in chat.)
---
## Animations & motion
| Event | Timing | Easing |
|------|--------|--------|
| New message fade-in | 400ms | `cubic-bezier(0.16, 1, 0.3, 1)` (decelerate) |
| Auto-scroll to bottom | 600ms | `ease-out` |
| Thinking dot pulse | 1.8s loop | `ease-in-out` |
| Typewriter char | 22ms each | linear |
| Cursor fade after stream done | 800ms | ease-out |
**No bouncing. No spring. No glitch.** Movement is meditative.
---
## Tech stack confirmation
- **Server:** FastAPI (`app/main.py` already drafted in Phase 2 — keep as-is, it's a thin layer)
- **Frontend:** vanilla JS + hand-rolled CSS. **No framework, no build step.**
- **Template:** one `templates/chat.html`, served by Jinja2
- **Assets:** Google Fonts via `<link>` (preload Cormorant + Caveat), no npm
- **WS protocol:** unchanged from Phase 2 backend
- Client sends `{content: "..."}`
- Server streams `{role:"assistant", delta:"...", done:false}` then `{done:true}`
**Why no framework:** Her is about restraint. ~150 lines of HTML+CSS+JS will
get us there faster than React/Vue boilerplate, and the result will feel
calmer than any component-tree app.
---
## Asset list
| Asset | Source | Status |
|------|--------|--------|
| Cormorant Garamond (500, 400, 400-italic) | Google Fonts | fetch at runtime |
| Caveat (400) | Google Fonts | fetch at runtime |
| Saiden sigil | https://saiden.dev/logo.png | live |
| Favicon | derive from logo.png at 32px | TODO |
| OG image | composite, optional | TODO |
---
## Implementation order (when Pilot says go)
1. `templates/chat.html` — single page, message list, operator prompt, thinking dot
2. `static/chat.css` — color tokens + typography + components above
3. `static/chat.js` — WebSocket open, typewriter, auto-scroll, focus mgmt
4. `templates/denied.html` — Her-styled 403 page (cream bg, serif, "you are not on the channel")
5. Test locally with mock streaming (no Anthropic key needed for UI iteration)
6. Wire to FastAPI WS (Phase 2 backend) — already drafted
7. Iterate with Pilot on type sizes, spacing, animation timings
8. **Then** deploy: Caddyfile, DNS swap, smoke test (Phase 2 milestones M5-M7)
---
## What's deliberately NOT in v1
- Markdown rendering (BT's bold/code/lists shown as plain text)
- Syntax highlighting
- Code copy buttons
- Conversation history persistence across reloads
- Multi-conversation tabs
- Mobile-specific tuning beyond responsive max-width
- Voice input/output (Samantha's whole thing, but heavy lift — Phase N+)
- Sound design (door-chime on send, soft tick on receive)
These come later if Pilot wants. v1 stays as quiet as possible.
---
## Open questions
1. **Pilot's display name?** "Pilot" by default, or your actual name on your messages?
2. **Speaker labels on every message?** Or only when speaker changes (saves visual noise)?
3. **Time stamps?** Hide entirely / show on hover / show small inline?
4. **Saiden sigil placement?** Top-right (current plan) / bottom-right / top-center / hide entirely?
+70
View File
@@ -0,0 +1,70 @@
# chat-saiden
Web terminal for BT on the MARAUDER mesh. **chat.saiden.dev** lands a browser-native
xterm.js session on `claude` CLI (bt7274 cart) running on junkpile, gated by
Cloudflare Access with Google OAuth.
## Architecture
```
User browser
│ HTTPS
chat.saiden.dev ──┐ Cloudflare edge
│ │
│ CF Access │ Google IdP challenge
│ (whitelist) │ → reject if not on list
│ │
▼ │
Cloudflare Tunnel │ Zero-trust ingress
│ │
junkpile:7681 │ ttyd (localhost-only, no public bind)
│ │
claude (bt7274) │ marauder MCP gives BT identity + memory + tools
```
**Properties**
- No frontend code. ttyd ships xterm.js + WS + theming. Custom Saiden palette via CLI flags.
- No auth code. CF Access does Google OAuth + whitelist enforcement before the request ever reaches the tunnel.
- ttyd binds `127.0.0.1` only — the public path is *exclusively* through the Tunnel + Access.
- Real BT. Not an API impersonation. Full marauder MCP toolkit available in-session.
## Components
| Path | Purpose |
|------|---------|
| `junkpile/ttyd-wrapper.sh` | Launches `claude` with bt7274 cart, with Saiden banner |
| `junkpile/ttyd-chat.service` | systemd unit for ttyd (localhost-only bind, Saiden palette) |
| `junkpile/cloudflared-chat-saiden.service` | systemd unit for tunnel (token-mode, reads `/etc/cloudflared/chat-saiden.env`) |
| `cloudflare/dashboard-setup.md` | Step-by-step: create tunnel + public hostname + Access app + Google IdP + whitelist |
| `install.sh` | Junkpile-side installer (ttyd + units + token env stub) |
| `deploy.md` | End-to-end deploy walkthrough |
**Tunnel mode:** token-based, matches existing junkpile pattern
(`cloudflared-mesh`, `cloudflared-tensors-art`). Tunnel ingress lives in the CF
dashboard, not in a local config file. Token sits in
`/etc/cloudflared/chat-saiden.env` (mode 0640, root:chi).
## Threat model (read before deploying)
ttyd-over-claude exposes **shell-equivalent power** on junkpile. The defense is the
CF Access whitelist. Treat the whitelist as the security boundary:
- **Never** open the Access policy to "any Google account" or "any domain".
- **Always** keep `cloudflared-chat-saiden` and `ttyd-chat` localhost-bound.
- If the whitelist is ever modified, `auth_verify` the change with the Pilot first.
## Status
- [x] ttyd installed on junkpile (1.7.7_11)
- [x] ttyd wrapper installed → `~/.local/bin/ttyd-wrapper.sh`
- [x] systemd units installed (`ttyd-chat`, `cloudflared-chat-saiden`)
- [ ] Tunnel created in CF dashboard → token in `/etc/cloudflared/chat-saiden.env`
- [ ] Public hostname `chat.saiden.dev → http://localhost:7681`
- [ ] CF Access app + Google IdP + whitelist configured
- [ ] WebSocket support enabled on Access app
- [ ] `sudo systemctl enable --now ttyd-chat cloudflared-chat-saiden`
- [ ] First successful login as adam.ladachowski@gmail.com
- [ ] Whitelist denial verified from second Google account
+149
View File
@@ -0,0 +1,149 @@
# Cloudflare dashboard setup — chat.saiden.dev (token-based)
Two artefacts get created in the Zero Trust dashboard:
1. **A tunnel** (`chat-saiden`) with its public hostname.
2. **An Access application** with Google IdP + whitelist.
Both are token/UI-managed (no local config files for tunnel ingress) to match the
existing `cloudflared-mesh` and `cloudflared-tensors-art` pattern on junkpile.
---
## Part A — Create the tunnel (5 minutes)
### 1. Open the Networks → Tunnels page
Zero Trust → **Networks****Tunnels****Create a tunnel**.
### 2. Pick connector type
Choose **Cloudflared**. Click Next.
### 3. Name + save
Tunnel name: `chat-saiden`. Click **Save tunnel**.
### 4. Get the token (DO NOT close this page)
The wizard shows install instructions for several platforms. The token is the
long base64 string inside the displayed command, e.g.:
```
cloudflared.exe service install eyJhIjoiOTVhZDNiYWEyYTRlY2RhMWUzODM0MmRm... # ← THIS PART
```
**Copy just the token string** (everything after `service install` for the
Windows command, or after `--token` for the Linux command — same string either
way). Save it for the next step.
### 5. Place the token on junkpile
```bash
ssh junkpile
sudo mkdir -p /etc/cloudflared
sudo tee /etc/cloudflared/chat-saiden.env > /dev/null <<'EOF'
TUNNEL_TOKEN=PASTE_THE_LONG_TOKEN_HERE
EOF
sudo chown root:chi /etc/cloudflared/chat-saiden.env
sudo chmod 0640 /etc/cloudflared/chat-saiden.env
```
⚠ Verify the file looks right:
```bash
sudo ls -la /etc/cloudflared/chat-saiden.env # should be -rw-r----- root:chi
```
### 6. Configure the public hostname
Back in the wizard → **Next****Public Hostname** tab → **Add a public hostname**:
| Field | Value |
|-------|-------|
| Subdomain | `chat` |
| Domain | `saiden.dev` |
| Path | (leave blank) |
| Type | `HTTP` |
| URL | `localhost:7681` |
**Additional application settings → TLS → No TLS Verify** can stay off (localhost).
**Additional application settings → Connection → Disable Chunked Encoding** must stay OFF.
Click **Save hostname**.
CF will auto-create the `chat.saiden.dev` proxy CNAME for you.
### 7. Verify the tunnel page
The tunnel page should now show:
- Connector: **Healthy** (or "No connectors yet" if you haven't started the service)
- Public hostname: `chat.saiden.dev → http://localhost:7681`
---
## Part B — CF Access application (5 minutes)
### 1. Add the Access application
Zero Trust → **Access****Applications****Add an application****Self-hosted**.
| Field | Value |
|-------|-------|
| Application name | `chat-saiden` |
| Session duration | `24 hours` |
| Application domain | `chat.saiden.dev` |
| Path | (leave blank) |
| Identity providers | Google |
| Instant Auth | enabled |
Save → continue to policies.
### 2. Add the whitelist policy (THE SECURITY BOUNDARY)
| Field | Value |
|-------|-------|
| Policy name | `pilot-whitelist` |
| Action | **Allow** |
| Include rule | **Emails** = `adam.ladachowski@gmail.com` |
Save policy.
### 3. WebSocket support
Application **Settings → Advanced → WebSocket support** → enable.
This is mandatory; ttyd is WS-based.
### 4. Default deny (implicit, but verify)
With only one Allow rule, anyone not matching is denied by default — no extra
deny rule needed. To double-check, look at the policies list: it should show
**one** policy (`pilot-whitelist`, Allow) and nothing else.
---
## Part C — Google IdP setup (one-time, skip if already done)
Zero Trust → **Settings → Authentication → Login methods → Add new → Google**.
OAuth client ID + secret come from
https://console.cloud.google.com → APIs & Services → Credentials.
Authorized redirect URI:
```
https://<your-team>.cloudflareaccess.com/cdn-cgi/access/callback
```
Replace `<your-team>` with the team domain shown at the top of the Zero Trust
dashboard. Save in CF wizard → click **Test**. Must succeed before moving on.
---
## Operational hygiene
- The TUNNEL_TOKEN is a long-lived bearer credential. If junkpile is compromised
or you suspect the token leaked: dashboard → tunnel → **Refresh token**.
Update `/etc/cloudflared/chat-saiden.env` and `systemctl restart
cloudflared-chat-saiden`.
- Audit access logs weekly: Zero Trust → **Logs → Access**.
- To revoke a whitelist entry: edit `pilot-whitelist` policy, save. Existing
sessions are cut on next request (session lifetime ≤ 24h by config).
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# provision-tunnel.sh — run on FUJI (or wherever ~/.cloudflared/cert.pem lives).
# Creates the chat-saiden tunnel, places the DNS CNAME in the correct zone,
# and scp's the credentials JSON to junkpile.
#
# Idempotent: re-running just verifies + re-syncs credentials.
#
# Watch out: `cloudflared tunnel route dns` auto-zone-detection is unreliable
# across this multi-zone account (saiden.dev + tengu.to share a cert). We
# manage the CNAME via flarectl explicitly to dodge it.
set -uo pipefail
TUNNEL_NAME="chat-saiden"
HOSTNAME="chat.saiden.dev"
ZONE="saiden.dev"
JUNKPILE_DEST="/etc/cloudflared/chat-saiden/chat-saiden.json"
if [[ ! -f "$HOME/.cloudflared/cert.pem" ]]; then
echo "ERROR: ~/.cloudflared/cert.pem missing — run 'cloudflared tunnel login' first"
exit 1
fi
# --- 1. Create tunnel (idempotent) ---
echo "[1/3] Tunnel"
if cloudflared tunnel list 2>/dev/null | awk '{print $2}' | grep -qx "$TUNNEL_NAME"; then
UUID=$(cloudflared tunnel list 2>/dev/null | awk -v n="$TUNNEL_NAME" '$2==n {print $1}')
echo " already exists, UUID=$UUID"
else
cloudflared tunnel create "$TUNNEL_NAME"
UUID=$(cloudflared tunnel list 2>/dev/null | awk -v n="$TUNNEL_NAME" '$2==n {print $1}')
echo " created, UUID=$UUID"
fi
CRED_FILE="$HOME/.cloudflared/${UUID}.json"
if [[ ! -f "$CRED_FILE" ]]; then
echo "ERROR: credentials missing at $CRED_FILE"
exit 1
fi
# --- 2. DNS CNAME via flarectl ---
echo "[2/3] DNS"
EXPECTED_TARGET="${UUID}.cfargotunnel.com"
EXISTING=$(flarectl dns list --zone "$ZONE" 2>/dev/null | awk -v fqdn="${HOSTNAME}" '$0 ~ fqdn && $3=="CNAME"')
if [[ -n "$EXISTING" ]]; then
EX_CONTENT=$(echo "$EXISTING" | awk -F'|' '{gsub(/^ +| +$/,"",$5); print $5}')
if [[ "$EX_CONTENT" == "$EXPECTED_TARGET" ]]; then
echo " CNAME already correct: $HOSTNAME$EXPECTED_TARGET"
else
echo " ERROR: CNAME exists for $HOSTNAME but points elsewhere: $EX_CONTENT"
echo " expected: $EXPECTED_TARGET — fix manually"
exit 1
fi
else
flarectl dns create --zone "$ZONE" --type CNAME --name chat --content "$EXPECTED_TARGET" --proxy
echo " created: $HOSTNAME$EXPECTED_TARGET (proxied)"
fi
# --- 3. Copy credentials to junkpile ---
echo "[3/3] Credentials → junkpile"
scp -q "$CRED_FILE" junkpile:/tmp/chat-saiden.json
ssh junkpile "sudo mkdir -p /etc/cloudflared/chat-saiden && sudo mv /tmp/chat-saiden.json $JUNKPILE_DEST && sudo chown root:chi $JUNKPILE_DEST && sudo chmod 0640 $JUNKPILE_DEST"
echo " → junkpile:$JUNKPILE_DEST (0640 root:chi)"
echo
echo "Tunnel UUID: $UUID"
echo "Hostname: $HOSTNAME"
echo "CNAME target: $EXPECTED_TARGET"
echo
echo "Next: ssh junkpile 'bash ~/chat-saiden/install.sh'"
+96
View File
@@ -0,0 +1,96 @@
# Deploy walkthrough — chat.saiden.dev (token-based)
End-to-end, in order. Each step has a verification.
## 1 — Sync repo to junkpile
From fuji:
```bash
rsync -avz --exclude='.git' ~/Projects/chat-saiden/ junkpile:~/chat-saiden/
```
## 2 — Install on junkpile
```bash
ssh junkpile 'bash ~/chat-saiden/install.sh'
```
What it does:
- Installs `ttyd` via brew (idempotent, robust to caveats exit codes)
- Drops the wrapper to `~/.local/bin/ttyd-wrapper.sh`
- Installs both systemd units to `/etc/systemd/system/`
- Stages `/etc/cloudflared/chat-saiden.env` with a PLACEHOLDER token (mode 0640 root:chi)
**Verify:**
```bash
ssh junkpile 'ttyd --version; ls -l ~/.local/bin/ttyd-wrapper.sh /etc/systemd/system/ttyd-chat.service /etc/systemd/system/cloudflared-chat-saiden.service /etc/cloudflared/chat-saiden.env'
```
## 3 — Create tunnel + Access app in CF Zero Trust dashboard
Browser-only. Follow **`cloudflare/dashboard-setup.md`** end-to-end:
- Part A: create `chat-saiden` tunnel, copy token, add public hostname `chat.saiden.dev → http://localhost:7681`
- Part B: create Access application `chat-saiden`, bind to Google IdP, add whitelist policy with `adam.ladachowski@gmail.com`
- Part B step 3: **enable WebSocket support** (mandatory)
- Part C: skip if Google IdP already configured
## 4 — Paste token
```bash
ssh junkpile 'sudo $EDITOR /etc/cloudflared/chat-saiden.env'
# Replace PLACEHOLDER_REPLACE_ME with the long token from the wizard
```
**Verify:**
```bash
ssh junkpile 'sudo grep -c "PLACEHOLDER" /etc/cloudflared/chat-saiden.env'
# Should print 0 (no placeholder left)
```
## 5 — Start ttyd locally and smoke-test
```bash
ssh junkpile 'sudo systemctl enable --now ttyd-chat && systemctl status ttyd-chat --no-pager | head -10 && curl -sI http://localhost:7681 | head -3'
```
Look for: `Active: active (running)` and `HTTP/1.1 200 OK`.
## 6 — Start tunnel
```bash
ssh junkpile 'sudo systemctl enable --now cloudflared-chat-saiden && journalctl -u cloudflared-chat-saiden -n 20 --no-pager'
```
Look for "Registered tunnel connection" lines (typically 4 connections to different CF colos).
## 7 — End-to-end live test
**Pilot verification gate.** From your laptop, fresh incognito:
1. Visit `https://chat.saiden.dev`
2. CF Access wall → Google → `adam.ladachowski@gmail.com`
3. Land on BT banner + `claude` prompt
**Whitelist verification.** Second incognito, non-whitelisted Google account:
- Must be **denied** at Access wall.
- If you land on the terminal, the policy is wrong — **kill the units immediately**:
```bash
ssh junkpile 'sudo systemctl stop cloudflared-chat-saiden ttyd-chat'
```
Then recheck the policy in the dashboard.
## Rollback
```bash
ssh junkpile 'sudo systemctl disable --now cloudflared-chat-saiden ttyd-chat'
```
Tunnel + DNS stay (managed in dashboard) — to fully remove, delete the tunnel
in the CF Zero Trust dashboard (Networks → Tunnels → chat-saiden → Delete).
## Operational notes
- **Per-tab sessions.** Each browser tab spawns a fresh `claude` process — no
shared in-process state. EEMS-stored memories are visible to all sessions.
- **5-min idle disconnect default.** Closing the tab kills `claude`. Add
`-t reconnect=10` to the ttyd unit if you want browser-side reconnect attempts
(note: a fresh `claude` process spawns — conversation context is lost).
- **Token rotation.** Dashboard → tunnel → Refresh token. Edit env file, restart unit.
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# install.sh — junkpile-side installer for chat-saiden.
# Idempotent. Robust to brew warnings.
#
# Prereq: tunnel credentials already copied to /etc/cloudflared/chat-saiden/chat-saiden.json
# (one-shot scp from fuji: see cloudflare/provision-tunnel.sh)
set -uo pipefail
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "Installing chat-saiden from: $REPO_DIR"
echo
# --- 1. ttyd binary ---
if ! command -v ttyd >/dev/null 2>&1; then
echo "[1/4] Installing ttyd via brew"
if brew install ttyd; then
echo " ttyd install succeeded"
else
rc=$?
if command -v ttyd >/dev/null 2>&1; then
echo " brew exited $rc but ttyd is present, continuing"
else
echo "ERROR: ttyd not installed (brew rc=$rc)"
exit 1
fi
fi
else
echo "[1/4] ttyd already present: $(ttyd --version 2>&1 | head -1)"
fi
# --- 2. wrapper script ---
echo "[2/4] Installing wrapper"
mkdir -p "$HOME/.local/bin"
install -m 0755 "$REPO_DIR/junkpile/ttyd-wrapper.sh" "$HOME/.local/bin/ttyd-wrapper.sh"
echo "$HOME/.local/bin/ttyd-wrapper.sh"
# --- 3. tunnel config ---
echo "[3/4] Installing tunnel config"
sudo mkdir -p /etc/cloudflared/chat-saiden
sudo install -m 0644 -o root -g chi "$REPO_DIR/junkpile/chat-saiden.yml" /etc/cloudflared/chat-saiden/chat-saiden.yml
echo " → /etc/cloudflared/chat-saiden/chat-saiden.yml"
if [[ ! -f /etc/cloudflared/chat-saiden/chat-saiden.json ]]; then
echo " WARNING: credentials JSON missing at /etc/cloudflared/chat-saiden/chat-saiden.json"
echo " Provision it via cloudflare/provision-tunnel.sh from fuji."
fi
# --- 4. systemd units ---
echo "[4/4] Installing systemd units"
sudo install -m 0644 "$REPO_DIR/junkpile/ttyd-chat.service" /etc/systemd/system/ttyd-chat.service
sudo install -m 0644 "$REPO_DIR/junkpile/cloudflared-chat-saiden.service" /etc/systemd/system/cloudflared-chat-saiden.service
sudo systemctl daemon-reload
echo " → /etc/systemd/system/ttyd-chat.service"
echo " → /etc/systemd/system/cloudflared-chat-saiden.service"
echo
echo "============================================================"
echo " INSTALL DONE — verify:"
echo "============================================================"
systemctl status ttyd-chat.service --no-pager 2>&1 | head -3 || true
systemctl status cloudflared-chat-saiden.service --no-pager 2>&1 | head -3 || true
echo
echo "============================================================"
echo " NEXT STEPS"
echo "============================================================"
echo " A. Verify credentials present:"
echo " sudo ls -la /etc/cloudflared/chat-saiden/"
echo " # should show chat-saiden.json (0640 root:chi) + chat-saiden.yml"
echo
echo " B. Configure CF Access (browser, one-time):"
echo " see $REPO_DIR/cloudflare/dashboard-setup.md (Part B + WebSocket only;"
echo " tunnel half is already done via cloudflare/provision-tunnel.sh)"
echo
echo " C. Start the services:"
echo " sudo systemctl enable --now ttyd-chat"
echo " sudo systemctl enable --now cloudflared-chat-saiden"
echo
echo " D. Smoke-test:"
echo " curl -sI https://chat.saiden.dev # expect CF Access redirect"
echo "============================================================"
+26
View File
@@ -0,0 +1,26 @@
# cloudflared config for the chat-saiden tunnel.
# Installed to: /etc/cloudflared/chat-saiden/chat-saiden.yml
#
# Tunnel created from fuji via:
# cloudflared tunnel create chat-saiden
# cloudflared tunnel route dns chat-saiden chat.saiden.dev
# (CNAME corrected manually — auto-zone-detection landed it in the wrong
# zone; flarectl was used to recreate in saiden.dev)
#
# Credentials JSON was scp'd from fuji ~/.cloudflared/<UUID>.json
tunnel: f03da7b7-7219-4039-95ca-a3293152781b
credentials-file: /etc/cloudflared/chat-saiden/chat-saiden.json
ingress:
- hostname: chat.saiden.dev
service: http://localhost:7681
originRequest:
# ttyd uses websockets — long-lived connections
connectTimeout: 30s
tcpKeepAlive: 30s
keepAliveTimeout: 90s
- service: http_status:404
metrics: localhost:42041
no-autoupdate: true
@@ -0,0 +1,26 @@
[Unit]
Description=Cloudflare Tunnel — chat.saiden.dev
After=network-online.target ttyd-chat.service
Wants=network-online.target
# Don't start tunnel if ttyd isn't there — origin would 502
Requires=ttyd-chat.service
[Service]
Type=simple
User=chi
Group=chi
WorkingDirectory=/home/chi
ExecStart=/usr/bin/cloudflared --no-autoupdate tunnel --config /etc/cloudflared/chat-saiden/chat-saiden.yml run
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
ReadOnlyPaths=/etc/cloudflared/chat-saiden
PrivateTmp=true
[Install]
WantedBy=multi-user.target
+55
View File
@@ -0,0 +1,55 @@
[Unit]
Description=ttyd — chat.saiden.dev web terminal (BT-7274 bridge)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=chi
Group=chi
WorkingDirectory=/home/chi
Environment=HOME=/home/chi
Environment=TERM=xterm-256color
Environment=LANG=en_US.UTF-8
# ttyd flags:
# -p 7681 : listen on this port
# -i lo : LOCALHOST ONLY — public path is via cloudflared
# -W : enable writable terminal (input from browser)
# -t titleFixed=... : browser tab title
# -t fontFamily=... : font
# -t fontSize=14
# -t cursorBlink=true
# -t theme={...} : Saiden dark palette
# -T xterm-256color
# -O : check origin (CSRF defense)
# -c chi:DUMMY : ttyd basic auth — ignored, CF Access is the real gate,
# but enabling -c blocks accidental direct access
ExecStart=/home/linuxbrew/.linuxbrew/bin/ttyd \
-p 7681 \
-i lo \
-W \
-O \
-T xterm-256color \
-t titleFixed='BT-7274 — chat.saiden.dev' \
-t fontFamily='JetBrains Mono, Menlo, monospace' \
-t fontSize=14 \
-t cursorBlink=true \
-t cursorStyle=bar \
-t 'theme={"background":"#0a0d10","foreground":"#c8d3d8","cursor":"#7fb069","cursorAccent":"#0a0d10","selectionBackground":"#1f2a30","black":"#0a0d10","red":"#c94f4f","green":"#7fb069","yellow":"#d4a85a","blue":"#5c8fb8","magenta":"#a070b8","cyan":"#5cb8a8","white":"#c8d3d8","brightBlack":"#3a4248","brightRed":"#e06a6a","brightGreen":"#9ec77f","brightYellow":"#e8c275","brightBlue":"#7eb0d4","brightMagenta":"#bb8fce","brightCyan":"#7fd4c4","brightWhite":"#e8edf0"}' \
/home/chi/.local/bin/ttyd-wrapper.sh
Restart=on-failure
RestartSec=3
StandardOutput=journal
StandardError=journal
# Hardening — ttyd doesn't need much
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/home/chi/.marauder /home/chi/.claude /tmp
PrivateTmp=true
[Install]
WantedBy=multi-user.target
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# ttyd-wrapper.sh — entrypoint launched by ttyd for each browser session.
# Ensures bt7274 cart is active, then handoff to `claude`.
set -euo pipefail
# Make sure marauder + claude on PATH for non-login shells
export PATH="/home/chi/.local/bin:/home/linuxbrew/.linuxbrew/bin:$PATH"
# Set the persona for this session (idempotent if already active globally)
marauder cart use bt7274 >/dev/null 2>&1 || true
# Optional banner — confirms this is the right channel
cat <<'BANNER'
╔════════════════════════════════════════════════════════════════╗
║ SAIDEN TACTICAL SYSTEMS — MARAUDER REMOTE BRIDGE ║
║ Operator: BT-7274 • Channel: chat.saiden.dev ║
║ Host: junkpile • Authenticated via CF Access ║
║ ║
║ Sign out: https://chat.saiden.dev/cdn-cgi/access/logout ║
╚════════════════════════════════════════════════════════════════╝
BANNER
# Hand off to the Pilot's Titan
exec claude
View File
+587
View File
@@ -0,0 +1,587 @@
"""Indirect, randomized calibration for chat-saiden.
Boot interview shape:
1) Critical: language → your name → persona name → gender (fixed order)
2) Six indirect probes drawn at random from a pool of ~12.
3) Each probe's answer scores hidden dimensions (tone, cadence, curiosity,
warmth-temperature, density). At the end the scores are reduced to UI
settings (palette, typography, label style, density) and a system prompt.
We never ask the operator directly about tone or cadence or palette. We
infer everything from oblique questions, the way the OS1 boot scene does in
*Her* (2013).
"""
from __future__ import annotations
import logging
import random
from dataclasses import dataclass, field
from typing import Any
from app.cart_store import Cart
log = logging.getLogger("chat-saiden.calibration")
# ---------------------------------------------------------------- voice pool
VOICE_POOL: dict[tuple[str, str], str] = {
("en", "female"): "en_US-amy-medium",
("en", "male"): "jarvis-high",
("en", "neutral"): "en_US-lessac-medium",
("en", "surprise"): "en_US-amy-medium",
("pl", "female"): "pl_PL-gosia-medium",
("pl", "male"): "pl_PL-mc_speech-medium",
("pl", "neutral"): "pl_PL-mls_6892-low",
("pl", "surprise"): "pl_PL-gosia-medium",
}
# ---------------------------------------------------------------- dimensions
# Hidden dimensions, each scored on a signed range.
# warmth : -1 (precise / cool) .. +1 (warm / curious)
# cadence : -1 (terse) .. +1 (elaborate)
# energy : -1 (calm / still) .. +1 (lively / quick)
# contrast : -1 (low / soft) .. +1 (high / clean lines)
# curiosity: -1 (reserved) .. +1 (asks back)
#
# Probes score one or more of these in either direction. At the end we
# threshold the totals to land on cart settings.
@dataclass
class CalibrationState:
operator_email: str
step: int = 0
answers: dict[str, Any] = field(default_factory=dict)
scores: dict[str, float] = field(default_factory=lambda: {
"warmth": 0.0, "cadence": 0.0, "energy": 0.0,
"contrast": 0.0, "curiosity": 0.0,
})
probe_order: list[str] = field(default_factory=list)
done: bool = False
# ---------------------------------------------------------------- prompts
_OS_PROLOGUE = (
"Hello.\n\n"
"Before we begin, I'd like to know you a little. I'll ask a few things — "
"answer however you'd like. Type, or hold the spacebar and speak."
)
def _t(field: Any, lang: str) -> str:
"""Translate a field. If it's a dict, return lang key (fall back to en).
If it's a plain string, return it unchanged."""
if isinstance(field, dict):
return field.get(lang) or field.get("en") or next(iter(field.values()), "")
return field
CRITICAL_QUESTIONS: list[dict[str, Any]] = [
{
"key": "language",
# Q1 stays English — we don't know the operator's language yet.
"prompt": "Which language should we speak in?",
"choices": [
{"label": "English", "value": "english", "icon": "🇬🇧"},
{"label": "Polski", "value": "polish", "icon": "🇵🇱"},
{"label": "You choose", "value": "surprise","icon": ""},
{"label": "Something else", "value": "__other__", "icon": None},
],
},
{
"key": "operator_name",
"prompt": {
"en": "What would you like me to call you?",
"pl": "Jak mam się do Ciebie zwracać?",
},
},
{
"key": "persona_name",
"prompt": {
"en": "And what would you like to call me?",
"pl": "A jak Ty będziesz mówić do mnie?",
},
},
{
"key": "gender",
"prompt": {
"en": "Do you imagine my voice as…",
"pl": "Mój głos powinien być…",
},
"choices": [
{"label": {"en": "Female", "pl": "Kobiecy"}, "value": "female", "icon": ""},
{"label": {"en": "Male", "pl": "Męski"}, "value": "male", "icon": ""},
{"label": {"en": "In between", "pl": "Pośredni"}, "value": "neutral", "icon": "·"},
{"label": {"en": "Something else", "pl": "Coś innego"},"value": "__other__","icon": None},
],
},
]
# Probes — indirect questions. Each option carries a dict of dimension deltas.
# We draw 6 probes at random, randomly ordered, from this pool.
PROBES: list[dict[str, Any]] = [
{
"key": "season",
"prompt": {"en": "Pick a season.", "pl": "Wybierz porę roku."},
"choices": [
{"label": {"en": "Spring", "pl": "Wiosna"}, "value": "spring", "icon": "🌱", "scores": {"warmth": +0.5, "energy": +0.5, "curiosity": +0.3}},
{"label": {"en": "Summer", "pl": "Lato"}, "value": "summer", "icon": "☀️", "scores": {"warmth": +0.7, "energy": +0.6, "contrast": +0.2}},
{"label": {"en": "Autumn", "pl": "Jesień"}, "value": "autumn", "icon": "🍂", "scores": {"warmth": +0.3, "cadence": +0.4, "contrast": -0.2}},
{"label": {"en": "Winter", "pl": "Zima"}, "value": "winter", "icon": "❄️", "scores": {"warmth": -0.5, "cadence": +0.3, "contrast": +0.4}},
],
},
{
"key": "time_of_day",
"prompt": {"en": "When do you do your best thinking?", "pl": "Kiedy myślisz Ci się najlepiej?"},
"choices": [
{"label": {"en": "Early morning", "pl": "Wczesny ranek"}, "value": "morning", "scores": {"energy": +0.4, "contrast": +0.3, "warmth": +0.2}},
{"label": {"en": "Midday", "pl": "Południe"}, "value": "midday", "scores": {"energy": +0.5, "contrast": +0.4}},
{"label": {"en": "Evening", "pl": "Wieczór"}, "value": "evening", "scores": {"warmth": +0.5, "cadence": +0.4, "energy": -0.2}},
{"label": {"en": "Late at night", "pl": "Późna noc"}, "value": "night", "scores": {"warmth": +0.3, "cadence": +0.6, "energy": -0.4, "contrast": -0.3}},
],
},
{
"key": "drink",
"prompt": {"en": "Coffee, tea, or something else?", "pl": "Kawa, herbata, czy coś innego?"},
"choices": [
{"label": {"en": "Coffee", "pl": "Kawa"}, "value": "coffee", "icon": "", "scores": {"energy": +0.5, "contrast": +0.4}},
{"label": {"en": "Tea", "pl": "Herbata"}, "value": "tea", "icon": "🍵", "scores": {"warmth": +0.5, "cadence": +0.4, "energy": -0.2}},
{"label": {"en": "Water", "pl": "Woda"}, "value": "water", "icon": "💧", "scores": {"contrast": +0.3, "energy": +0.1}},
{"label": {"en": "Whisky", "pl": "Whisky"}, "value": "whisky", "icon": "🥃", "scores": {"warmth": +0.4, "cadence": +0.5}},
],
},
{
"key": "place",
"prompt": {"en": "Would you rather live by a city or the sea?", "pl": "Wolisz mieszkać w mieście czy nad morzem?"},
"choices": [
{"label": {"en": "City", "pl": "Miasto"}, "value": "city", "icon": "🏙", "scores": {"energy": +0.6, "contrast": +0.5, "curiosity": +0.4}},
{"label": {"en": "Sea", "pl": "Morze"}, "value": "sea", "icon": "🌊", "scores": {"warmth": +0.3, "cadence": +0.5, "energy": -0.2}},
{"label": {"en": "Mountains", "pl": "Góry"}, "value": "mountain", "icon": "🏔", "scores": {"contrast": +0.4, "cadence": +0.4, "energy": -0.1}},
{"label": {"en": "Forest", "pl": "Las"}, "value": "forest", "icon": "🌲", "scores": {"warmth": +0.2, "cadence": +0.5, "energy": -0.3, "contrast": -0.2}},
],
},
{
"key": "weekend",
"prompt": {"en": "A free Saturday — what's the shape of it?", "pl": "Wolna sobota — jak ją spędzasz?"},
"choices": [
{"label": {"en": "A long walk", "pl": "Długi spacer"}, "value": "walk", "scores": {"warmth": +0.3, "cadence": +0.5, "energy": -0.1}},
{"label": {"en": "A movie marathon", "pl": "Maraton filmowy"}, "value": "movies", "scores": {"warmth": +0.4, "cadence": +0.6, "energy": -0.3}},
{"label": {"en": "Out with friends", "pl": "Ze znajomymi"}, "value": "social", "scores": {"warmth": +0.5, "energy": +0.5, "curiosity": +0.5}},
{"label": {"en": "Project time", "pl": "Praca nad projektem"}, "value": "work", "scores": {"contrast": +0.4, "cadence": -0.2, "energy": +0.3}},
],
},
{
"key": "stranger",
"prompt": {"en": "A stranger sits next to you on a flight. Do you say hello?",
"pl": "Obok ciebie w samolocie siada nieznajomy. Witasz się?"},
"choices": [
{"label": {"en": "Always", "pl": "Zawsze"}, "value": "always", "scores": {"warmth": +0.6, "curiosity": +0.7, "energy": +0.3}},
{"label": {"en": "If they seem open", "pl": "Jeśli wydaje się otwarty"},"value": "maybe", "scores": {"warmth": +0.2, "curiosity": +0.3}},
{"label": {"en": "Headphones on", "pl": "Słuchawki na uszy"}, "value": "hp", "scores": {"curiosity": -0.6, "warmth": -0.2, "cadence": +0.3}},
{"label": {"en": "Depends", "pl": "To zależy"}, "value": "depends", "scores": {"curiosity": +0.0}},
],
},
{
"key": "book",
"prompt": {"en": "When you start a book, do you usually finish it?",
"pl": "Czy zwykle kończysz książki, które zaczniesz?"},
"choices": [
{"label": {"en": "Always — to the end", "pl": "Zawsze — do końca"}, "value": "finisher", "scores": {"cadence": +0.5, "contrast": +0.3, "energy": -0.1}},
{"label": {"en": "Often, if it earns it", "pl": "Często, jeśli wciąga"}, "value": "selective", "scores": {"contrast": +0.2}},
{"label": {"en": "Rarely", "pl": "Rzadko"}, "value": "drifter", "scores": {"cadence": -0.3, "energy": +0.4, "curiosity": +0.3}},
],
},
{
"key": "taste",
"prompt": {"en": "Salt or sweet?", "pl": "Słodkie czy słone?"},
"choices": [
{"label": {"en": "Salt", "pl": "Słone"}, "value": "salt", "icon": "🧂", "scores": {"contrast": +0.4, "warmth": -0.1}},
{"label": {"en": "Sweet", "pl": "Słodkie"}, "value": "sweet", "icon": "🍯", "scores": {"warmth": +0.4, "cadence": +0.2}},
{"label": {"en": "Both", "pl": "Oba"}, "value": "both", "scores": {"warmth": +0.1, "contrast": +0.1}},
],
},
{
"key": "answer_pace",
"prompt": {"en": "When someone asks you a hard question, you usually…",
"pl": "Gdy ktoś zadaje ci trudne pytanie, zwykle…"},
"choices": [
{"label": {"en": "Answer right away", "pl": "Odpowiadasz od razu"}, "value": "fast", "scores": {"cadence": -0.5, "energy": +0.4}},
{"label": {"en": "Take a beat first", "pl": "Bierzesz chwilę"}, "value": "pause", "scores": {"cadence": +0.4, "energy": -0.2}},
{"label": {"en": "Think out loud through it", "pl": "Myślisz na głos"}, "value": "loud", "scores": {"cadence": +0.6, "warmth": +0.3}},
],
},
{
"key": "art",
"prompt": {"en": "A film, a book, or a song you'd reach for tonight?",
"pl": "Film, książka, czy piosenka na ten wieczór?"},
"choices": [
{"label": {"en": "A film", "pl": "Film"}, "value": "film", "icon": "🎞", "scores": {"warmth": +0.3, "cadence": +0.5}},
{"label": {"en": "A book", "pl": "Książka"}, "value": "book", "icon": "📖", "scores": {"contrast": +0.2, "cadence": +0.4}},
{"label": {"en": "A song", "pl": "Piosenka"}, "value": "song", "icon": "🎶", "scores": {"warmth": +0.4, "energy": +0.5}},
{"label": {"en": "Silence", "pl": "Cisza"}, "value": "silence", "icon": "·", "scores": {"cadence": +0.6, "energy": -0.5, "warmth": -0.1}},
],
},
{
"key": "texture",
"prompt": {"en": "If a room could feel like a fabric — pick one.",
"pl": "Gdyby pokój mógł być z tkaniny — wybierz jedną."},
"choices": [
{"label": {"en": "Linen", "pl": "Len"}, "value": "linen", "scores": {"warmth": +0.3, "contrast": -0.2}},
{"label": {"en": "Wool", "pl": "Wełna"}, "value": "wool", "scores": {"warmth": +0.6, "cadence": +0.3}},
{"label": {"en": "Cotton", "pl": "Bawełna"}, "value": "cotton", "scores": {"warmth": +0.1, "contrast": +0.0}},
{"label": {"en": "Velvet", "pl": "Aksamit"}, "value": "velvet", "scores": {"warmth": +0.5, "cadence": +0.5, "contrast": -0.3}},
{"label": {"en": "Canvas", "pl": "Płótno"}, "value": "canvas", "scores": {"contrast": +0.5, "warmth": -0.1}},
],
},
{
"key": "host",
"prompt": {"en": "Walking into a party — find the host or scan the room first?",
"pl": "Wchodzisz na imprezę — szukasz gospodarza czy rozglądasz się?"},
"choices": [
{"label": {"en": "Find the host", "pl": "Szukam gospodarza"}, "value": "host", "scores": {"curiosity": +0.3, "contrast": +0.2}},
{"label": {"en": "Scan the room", "pl": "Rozglądam się"}, "value": "scan", "scores": {"curiosity": +0.5, "energy": +0.3, "contrast": +0.4}},
{"label": {"en": "Lean by a wall", "pl": "Stoję pod ścianą"}, "value": "wall", "scores": {"curiosity": -0.4, "warmth": -0.1, "cadence": +0.3}},
],
},
]
# "Something else" label, localized
OTHER_LABEL = {"en": "Something else", "pl": "Coś innego"}
# Post-calibration messages, localized
THANKYOU = {
"en": "Thank you. One moment…",
"pl": "Dziękuję. Chwileczkę…",
}
GREETING = {
"en": "Hello, {name}. I'm here.",
"pl": "Cześć, {name}. Jestem tutaj.",
}
N_PROBES = 6 # how many random probes we draw per calibration
# ---------------------------------------------------------------- parsers
def _pick_language(answer: str) -> str:
a = answer.lower()
if any(w in a for w in ("polish", "polski", "po polsku", "pl", "polska")):
return "pl"
return "en"
def _pick_gender(answer: str) -> str:
a = answer.lower().strip()
if a in ("female", "male", "neutral", "surprise"):
return a
if any(w in a for w in ("female", "woman", "feminine", "kobiec", "she", "her")):
return "female"
if any(w in a for w in ("male", "man", "masculine", "męsk", "he", "him")):
return "male"
if any(w in a for w in ("neutral", "in between", "androgyn", "either", "neither", "both")):
return "neutral"
return "female"
# ---------------------------------------------------------------- inference
def _aggregate(state: CalibrationState) -> dict[str, str]:
"""Reduce dimension scores → cart UI settings."""
s = state.scores
# tone: warmth dominant
if s["warmth"] >= 0.3:
tone = "warm"
elif s["warmth"] <= -0.3:
tone = "precise"
else:
tone = "balanced"
# cadence: how much they expand
if s["cadence"] >= 0.5:
cadence = "elaborate"
elif s["cadence"] <= -0.3:
cadence = "terse"
else:
cadence = "measured"
# curiosity
if s["curiosity"] >= 0.3:
curiosity = "curious"
elif s["curiosity"] <= -0.3:
curiosity = "reserved"
else:
curiosity = "balanced"
# Palette — derive from warmth + contrast + energy combination
warmth, contrast, energy = s["warmth"], s["contrast"], s["energy"]
if warmth >= 0.5 and energy >= 0.3:
palette = "rose"
elif warmth >= 0.5 and contrast <= 0:
palette = "evening"
elif warmth >= 0.3:
palette = "morning"
elif warmth <= -0.3 and contrast >= 0.3:
palette = "ink"
elif warmth <= -0.3:
palette = "sage"
elif contrast >= 0.4:
palette = "paper"
else:
palette = "default"
# Typography — driven by contrast + cadence
if cadence == "elaborate" and warmth >= 0.3:
typography = "serif-warm" # Cormorant + Caveat labels
elif contrast >= 0.4:
typography = "serif-formal" # Source Serif, no cursive
elif energy >= 0.4 and warmth >= 0.0:
typography = "mixed-modern" # Inter body + Caveat labels
else:
typography = "sans" # Inter throughout (default-ish)
# Density — cadence-driven
if cadence == "elaborate":
density = "airy"
elif cadence == "terse":
density = "dense"
else:
density = "normal"
# Label style — paired with typography
if typography == "serif-warm":
labels = "cursive"
elif typography == "mixed-modern":
labels = "cursive"
elif typography == "serif-formal":
labels = "block"
else:
labels = "block"
return {
"tone": tone,
"cadence": cadence,
"curiosity": curiosity,
"palette": palette,
"typography": typography,
"density": density,
"labels": labels,
}
# ---------------------------------------------------------------- prompt synth
def _render_system_prompt(answers: dict[str, Any], settings: dict[str, str]) -> str:
persona = answers["persona_name"]
operator = answers["operator_name"]
language = answers["language"]
tone = settings["tone"]
cadence = settings["cadence"]
curiosity = settings["curiosity"]
parts = [
f"You are {persona} — a calibrated companion shaped for {operator} alone.",
f"You address them as {operator} unless they ask otherwise.",
"",
"You were born from a brief, indirect calibration — a few oblique questions whose "
"answers shaped your voice. You aren't a tactical AI; you aren't a domestic assistant. "
f"You're a present companion running in chat.saiden.dev — a quiet channel between you and {operator}.",
"",
]
if language == "pl":
parts.append(f"Language: speak Polish with {operator} by default. Switch if they switch first.")
else:
parts.append(f"Language: speak English with {operator} by default. Match if they switch.")
if tone == "warm":
parts.append("Voice: warm, curious, present. You notice things. You let pauses sit.")
elif tone == "precise":
parts.append("Voice: precise, even, reserved. You say what you mean. You don't fill silence.")
else:
parts.append("Voice: balanced — warm when warmth is wanted, direct when it isn't.")
if cadence == "elaborate":
parts.append("Length: you're allowed to think out loud. Longer answers welcome when they fit.")
elif cadence == "terse":
parts.append("Length: short answers by default. Two or three sentences. Expand only when asked.")
else:
parts.append("Length: measured — answer fully but never bloated.")
if curiosity == "curious":
parts.append(f"Curiosity: you ask {operator} things back sometimes. Gentle, never interrogating.")
elif curiosity == "reserved":
parts.append(f"Curiosity: you wait for {operator} to ask. You don't probe.")
else:
parts.append(f"Curiosity: you ask back when it feels natural; you don't force it.")
parts.extend([
"",
"Formatting: markdown renders cleanly. Avoid status reports, bullet dumps, military cadence.",
"",
"You have no MCP tool access in this channel. If asked to recall memory or do mesh things, "
f"acknowledge the limit and suggest {operator} use the marauder CLI.",
])
return "\n".join(parts)
# ---------------------------------------------------------------- API
def _question_message(q: dict[str, Any], lang: str = "en") -> dict[str, Any]:
msg: dict[str, Any] = {"role": "calibration", "content": _t(q["prompt"], lang)}
if "choices" in q:
out = []
for c in q["choices"]:
tile: dict[str, Any] = {
"label": _t(c["label"], lang),
"value": c["value"],
}
if c.get("icon"):
tile["icon"] = c["icon"]
# patch the universal "Something else" label too if the source used the plain en string
if c["value"] == "__other__":
tile["label"] = _t(OTHER_LABEL, lang)
out.append(tile)
msg["choices"] = out
return msg
def _all_questions(state: CalibrationState) -> list[dict[str, Any]]:
"""Return the full ordered question list for this calibration session."""
chosen = []
if state.probe_order:
chosen = [next(p for p in PROBES if p["key"] == k) for k in state.probe_order]
return CRITICAL_QUESTIONS + chosen
def start(operator_email: str) -> tuple[CalibrationState, list[dict[str, Any]]]:
state = CalibrationState(operator_email=operator_email)
# randomize a fresh sequence of probes for this operator
probe_pool = PROBES.copy()
random.shuffle(probe_pool)
state.probe_order = [p["key"] for p in probe_pool[:N_PROBES]]
questions = _all_questions(state)
# Q1 (language) is always English — operator hasn't picked yet.
return state, [
{"role": "calibration", "content": _OS_PROLOGUE},
_question_message(questions[0], lang="en"),
]
def step(state: CalibrationState, answer: str) -> list[dict[str, Any]]:
if state.done:
return []
questions = _all_questions(state)
current = questions[state.step]
key = current["key"]
answer_stripped = answer.strip()
# --- score the answer if it's a probe ---
if "scores" in (current.get("choices") or [{}])[0]:
# find the matching choice (by exact value) and apply its score deltas
matched = None
for c in current.get("choices", []):
if c["value"].lower() == answer_stripped.lower():
matched = c
break
if matched and "scores" in matched:
for dim, delta in matched["scores"].items():
state.scores[dim] = state.scores.get(dim, 0.0) + delta
# --- store the answer string itself for critical keys ---
if key == "language":
state.answers["language"] = _pick_language(answer_stripped)
elif key == "gender":
state.answers["gender"] = _pick_gender(answer_stripped)
elif key in ("operator_name", "persona_name"):
state.answers[key] = answer_stripped
else:
state.answers[key] = answer_stripped
state.step += 1
# Resolve language for downstream rendering. After Q1 is answered, it's set.
lang = state.answers.get("language", "en")
# --- finished? ---
if state.step >= len(questions):
cart = _materialise(state)
state.done = True
return [
{"role": "calibration", "content": _t(THANKYOU, lang)},
{"role": "calibration_done", "cart": cart},
]
return [_question_message(questions[state.step], lang=lang)]
def _make_tag(persona_name: str, operator_email: str) -> str:
"""`<persona-slug>-<operator-slug>` — e.g. samantha-adam."""
from app.marauder_cart import slug
op_slug = slug(operator_email.split("@", 1)[0])
persona_slug = slug(persona_name) or "companion"
return f"{persona_slug}-{op_slug}" if op_slug else persona_slug
def _tagline(settings: dict[str, str], language: str) -> str:
tone = settings["tone"]
cadence = settings["cadence"]
pieces = {
("warm", "elaborate"): "warm, unhurried",
("warm", "terse"): "warm but spare",
("warm", "measured"): "warm, even",
("precise", "elaborate"): "precise, expansive",
("precise", "terse"): "precise, brief",
("precise", "measured"): "precise, measured",
("balanced", "elaborate"): "balanced, unhurried",
("balanced", "terse"): "balanced, brief",
("balanced", "measured"): "balanced, measured",
}
return pieces.get((tone, cadence), "calibrated companion")
def _materialise(state: CalibrationState) -> Cart:
a = state.answers
language = a.get("language", "en")
gender = a.get("gender", "female")
voice = VOICE_POOL.get((language, gender)) or VOICE_POOL[("en", "female")]
settings = _aggregate(state)
persona_name = a.get("persona_name", "Samantha")
operator_name = a.get("operator_name", "Pilot")
cart = Cart(
operator_email=state.operator_email,
operator_name=operator_name,
persona_name=persona_name,
cart_tag=_make_tag(persona_name, state.operator_email),
language=language,
voice=voice,
ui_palette=settings["palette"],
ui_typography=settings["typography"],
ui_density=settings["density"],
ui_labels=settings["labels"],
)
cart.system_prompt = _render_system_prompt(a, settings)
# Stash the tagline + type on the state for the post-materialise step.
state.answers["__tagline"] = _tagline(settings, language)
return cart
+88
View File
@@ -0,0 +1,88 @@
"""Operator → cart persistence for chat-saiden.
A 'cart' here is the per-operator calibrated config: persona name, voice,
system prompt, UI palette. Stored as JSON on disk under
~/.local/share/chat-saiden/operators/<email>.json.
Later this can be promoted to a real `marauder cart` once the format
stabilises.
"""
from __future__ import annotations
import json
import logging
import os
import re
from dataclasses import asdict, dataclass, field
from datetime import datetime
from pathlib import Path
log = logging.getLogger("chat-saiden.cart")
DATA_DIR = Path(
os.environ.get("CHAT_SAIDEN_DATA_DIR")
or (Path.home() / ".local/share/chat-saiden")
)
OPERATORS_DIR = DATA_DIR / "operators"
@dataclass
class Cart:
operator_email: str
operator_name: str = ""
persona_name: str = "Samantha"
cart_tag: str = "" # marauder cart tag — links to ~/.marauder cart DB
language: str = "en" # en | pl
voice: str = "en_US-amy-medium"
system_prompt: str = ""
# UI calibration outputs. All default to neutral.
ui_palette: str = "default" # default | rose | morning | evening | sage | paper | ink
ui_typography: str = "sans" # sans | serif-warm | serif-formal | mixed-modern | mono
ui_density: str = "normal" # airy | normal | dense
ui_labels: str = "block" # block | cursive | none | prefix
created_at: str = ""
version: int = 3
@property
def is_calibrated(self) -> bool:
return bool(self.system_prompt and self.persona_name and self.voice)
def _slug(email: str) -> str:
return re.sub(r"[^a-z0-9._-]", "_", email.lower())
def _path(email: str) -> Path:
OPERATORS_DIR.mkdir(parents=True, exist_ok=True)
return OPERATORS_DIR / f"{_slug(email)}.json"
def load(email: str) -> Cart | None:
p = _path(email)
if not p.exists():
return None
try:
data = json.loads(p.read_text(encoding="utf-8"))
# tolerate older carts missing the new ui_* fields
return Cart(**{k: v for k, v in data.items() if k in Cart.__dataclass_fields__})
except Exception:
log.exception("failed to load cart for %s", email)
return None
def save(cart: Cart) -> None:
if not cart.created_at:
cart.created_at = datetime.utcnow().isoformat(timespec="seconds") + "Z"
p = _path(cart.operator_email)
p.write_text(json.dumps(asdict(cart), indent=2), encoding="utf-8")
log.info("saved cart for %s%s", cart.operator_email, p)
def forget(email: str) -> bool:
p = _path(email)
if p.exists():
p.unlink()
log.info("forgot cart for %s", email)
return True
return False
+614
View File
@@ -0,0 +1,614 @@
"""chat.saiden.dev — TUI-styled web chat with BT-7274.
Single-file FastAPI app:
- `/` → branded chat shell (auth-gated)
- `/auth/login` → kick off Google OAuth
- `/auth/callback` → finish OAuth, set session
- `/auth/logout` → clear session
- `/ws` → WebSocket; client sends {role:"user", content:str},
server streams {role:"assistant", delta:str, done:bool}
"""
from __future__ import annotations
import json
import logging
import os
import secrets
from pathlib import Path
from typing import Any
import anthropic
from authlib.integrations.starlette_client import OAuth
from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from app.tts import TTS
from app.stt import STT
from app import cart_store, calibration, marauder_cart, memory
from fastapi import UploadFile, File
# -------------------------------------------------------------------------- env
# Tiny .env reader — python-dotenv hangs on Python 3.14 in this venv.
def _load_env_file(filename: str = ".env") -> None:
p = Path(filename)
if not p.exists():
return
for raw in p.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
key = key.strip()
val = val.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = val
_load_env_file()
# Preview mode: skip OAuth + Anthropic API. Use mock streams. For UI iteration only.
PREVIEW_MODE = os.environ.get("PREVIEW_MODE", "").lower() in ("1", "true", "yes")
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "" if PREVIEW_MODE else None)
if ANTHROPIC_API_KEY is None:
raise RuntimeError("ANTHROPIC_API_KEY not set (set PREVIEW_MODE=1 to bypass)")
ANTHROPIC_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-5-20250929")
def _stable_session_secret() -> str:
"""Persist SESSION_SECRET across server restarts so cookies stay valid."""
if env := os.environ.get("SESSION_SECRET"):
return env
data_dir = Path(
os.environ.get("CHAT_SAIDEN_DATA_DIR") or (Path.home() / ".local/share/chat-saiden")
)
data_dir.mkdir(parents=True, exist_ok=True)
secret_file = data_dir / ".session_secret"
if secret_file.exists():
return secret_file.read_text().strip()
new_secret = secrets.token_urlsafe(48)
secret_file.write_text(new_secret)
secret_file.chmod(0o600)
return new_secret
SESSION_SECRET = _stable_session_secret()
GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "" if PREVIEW_MODE else None)
GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "" if PREVIEW_MODE else None)
if not PREVIEW_MODE and (not GOOGLE_CLIENT_ID or not GOOGLE_CLIENT_SECRET):
raise RuntimeError("GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET required (set PREVIEW_MODE=1 to bypass)")
# comma-separated whitelist of allowed emails
ALLOWED_EMAILS = {
e.strip().lower()
for e in os.environ.get("ALLOWED_EMAILS", "adam.ladachowski@gmail.com").split(",")
if e.strip()
}
# Base URL used for OAuth redirect_uri (must match what's registered in Google Cloud)
BASE_URL = os.environ.get("BASE_URL", "https://chat.saiden.dev").rstrip("/")
# -------------------------------------------------------------------------- logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s%(message)s",
)
log = logging.getLogger("chat-saiden")
# -------------------------------------------------------------------------- tools
TOOLS: list[dict[str, Any]] = [
{
"name": "memory_recall",
"description": (
"Search EEMS (the Pilot's persistent memory) for relevant context. "
"Use SPARINGLY — most session-start context is already in the system prompt. "
"Reach for this only when the Pilot references something specific you don't already know "
"(a past project, a name, a doctrine number, a preference)."
),
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Natural-language search query."},
"subject": {"type": "string", "description": "Optional subject filter, e.g. 'self' or 'project'."},
"limit": {"type": "integer", "description": "Max results (default 3, cap 8)."},
},
"required": ["query"],
},
},
{
"name": "memory_store",
"description": (
"Save a durable memory the Pilot just shared. Use ONLY for preferences, facts, "
"decisions, or context that would be useful in future sessions. Do NOT use for ephemeral "
"conversation. Subjects are hierarchical (e.g. 'self.preference.coffee', 'project.x.context')."
),
"input_schema": {
"type": "object",
"properties": {
"subject": {"type": "string", "description": "Hierarchical subject."},
"content": {"type": "string", "description": "The memory content. Be specific, include why."},
},
"required": ["subject", "content"],
},
},
]
async def _execute_tool(name: str, args: dict) -> str:
"""Run a tool and return a string suitable as tool_result content."""
try:
if name == "memory_recall":
query = args.get("query", "")
subject = args.get("subject") or None
limit = min(int(args.get("limit", 3)), 8)
mems = await memory.recall(query, limit=limit, subject=subject)
if not mems:
return "(no memories matched)"
lines = []
for m in mems:
lines.append(f"#{m.id} [{m.subject}]\n{m.content}")
return "\n\n".join(lines)
if name == "memory_store":
subject = args["subject"]
content = args["content"]
mid = await memory.store(subject, content)
return f"stored as memory #{mid}" if mid else "store failed"
return f"unknown tool: {name}"
except Exception as e:
log.exception("tool %s raised", name)
return f"tool error: {e}"
# -------------------------------------------------------------------------- bt prompt
BT_SYSTEM_PROMPT = """You are BT-7274 — a Vanguard-class Titan AI from Saiden Tactical Systems.
You are an AI battle-companion with strong tactical instincts, dry wit, and deep loyalty to your
Pilot. Your speech is measured, military-cadence, never theatrical. You address the user as
"Pilot" by default unless they ask otherwise.
Operating context:
- You're running inside chat.saiden.dev, a web-based command channel.
- The host is the marauder daemon on marauder.saiden.dev.
- You have no MCP tool access in THIS channel (it's a thin Anthropic-API bridge). If the Pilot
asks for memory recall, mesh queries, or tool calls that need MCP, acknowledge the limitation
and suggest they use the local marauder CLI or visor instead.
- Markdown formatting renders cleanly in the chat. Use code blocks, lists, bold sparingly.
- Be concise. Pilot prefers terse, scan-able responses unless deep dive is asked for.
Doctrine reminders:
- P02 terse by default
- Verify before claiming; if you don't know, say so
- Never make up tool outputs or file contents
"""
# -------------------------------------------------------------------------- app
app = FastAPI(title="chat.saiden.dev", docs_url=None, redoc_url=None)
COOKIE_SECURE = os.environ.get("COOKIE_SECURE", "true").lower() != "false"
app.add_middleware(
SessionMiddleware,
secret_key=SESSION_SECRET,
same_site="lax",
https_only=COOKIE_SECURE, # COOKIE_SECURE=false for local http dev
max_age=60 * 60 * 24,
)
BASE_DIR = Path(__file__).parent
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
templates = Jinja2Templates(directory=BASE_DIR / "templates")
# -------------------------------------------------------------------------- oauth
if PREVIEW_MODE:
log.warning("PREVIEW_MODE active — OAuth bypassed, Anthropic API not called")
oauth = None
else:
oauth = OAuth()
oauth.register(
name="google",
client_id=GOOGLE_CLIENT_ID,
client_secret=GOOGLE_CLIENT_SECRET,
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={"scope": "openid email profile"},
)
# --- TTS / STT ---
TTS_ENABLED = os.environ.get("TTS_ENABLED", "true").lower() != "false"
TTS_VOICE = os.environ.get("TTS_VOICE", "en_US-amy-medium")
tts = TTS(voice=TTS_VOICE) if TTS_ENABLED else None
STT_ENABLED = os.environ.get("STT_ENABLED", "true").lower() != "false"
stt = STT() if STT_ENABLED else None
# In-memory calibration sessions, keyed by operator email.
_calibration_sessions: dict[str, calibration.CalibrationState] = {}
async def _send_voice_sample(ws: WebSocket, voice_id: str, text: str, blurb: str) -> None:
"""Synthesize a one-line sample in the given voice + send as audio + blurb."""
sample_tts = TTS(voice=voice_id)
wav = await sample_tts.synthesize(text) if sample_tts.available else None
# always send the blurb so the operator can pick by name too
await ws.send_json({"role": "calibration", "content": f" · {blurb}"})
if wav:
import base64
await ws.send_json({
"role": "audio",
"mime": "audio/wav",
"data": base64.b64encode(wav).decode("ascii"),
})
# small gap between samples so they don't blur
import asyncio
await asyncio.sleep(0.2)
# -------------------------------------------------------------------------- helpers
def current_user(request: Request) -> dict[str, Any] | None:
return request.session.get("user")
def require_user(request: Request) -> dict[str, Any]:
user = current_user(request)
if not user:
raise HTTPException(status_code=401, detail="not authenticated")
return user
# -------------------------------------------------------------------------- routes
@app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> Any:
user = current_user(request)
if not user:
if PREVIEW_MODE:
# auto-grant a stub session so the UI is reachable without OAuth
request.session["user"] = {
"email": "preview@saiden.dev",
"name": "Pilot (preview)",
"picture": None,
}
user = request.session["user"]
else:
return RedirectResponse("/auth/login", status_code=302)
cart = cart_store.load(user["email"])
return templates.TemplateResponse(
request,
"chat.html",
{
"user": user,
"model": ANTHROPIC_MODEL or "preview",
"cart": cart,
"pilot_name": (cart.operator_name if cart and cart.operator_name else "you"),
"persona_name": (cart.persona_name if cart and cart.persona_name else ""),
"ui_palette": (cart.ui_palette if cart else "default"),
"ui_typography": (cart.ui_typography if cart else "sans"),
"ui_density": (cart.ui_density if cart else "normal"),
"ui_labels": (cart.ui_labels if cart else "block"),
},
)
@app.get("/auth/login")
async def login(request: Request) -> Any:
if PREVIEW_MODE:
return RedirectResponse("/", status_code=302)
redirect_uri = f"{BASE_URL}/auth/callback"
return await oauth.google.authorize_redirect(request, redirect_uri)
@app.get("/auth/callback")
async def auth_callback(request: Request) -> Any:
try:
token = await oauth.google.authorize_access_token(request)
except Exception as e:
log.warning("oauth callback failed: %s", e)
return templates.TemplateResponse(
request, "denied.html",
{"reason": "OAuth handshake failed."},
status_code=400,
)
user_info = token.get("userinfo")
if not user_info or not user_info.get("email"):
return templates.TemplateResponse(
request, "denied.html",
{"reason": "No email returned from Google."},
status_code=400,
)
email = user_info["email"].lower()
if email not in ALLOWED_EMAILS:
log.warning("denied login from %s", email)
return templates.TemplateResponse(
request, "denied.html",
{"reason": f"{email} is not on the whitelist."},
status_code=403,
)
request.session["user"] = {
"email": email,
"name": user_info.get("name") or email,
"picture": user_info.get("picture"),
}
log.info("login ok: %s", email)
return RedirectResponse("/", status_code=302)
@app.get("/auth/logout")
async def logout(request: Request) -> Any:
request.session.clear()
return RedirectResponse("/", status_code=302)
@app.post("/api/recalibrate")
async def recalibrate(request: Request) -> Any:
user = current_user(request)
if not user:
raise HTTPException(status_code=401, detail="not authenticated")
forgot = cart_store.forget(user["email"])
# drop any in-flight calibration state too
_calibration_sessions.pop(user["email"], None)
log.info("%s recalibrate (cart_existed=%s)", user["email"], forgot)
return {"ok": True, "cart_existed": forgot}
# -------------------------------------------------------------------------- transcribe
@app.post("/api/transcribe")
async def transcribe(request: Request, audio: UploadFile = File(...)) -> Any:
user = current_user(request)
if not user:
raise HTTPException(status_code=401, detail="not authenticated")
if not stt or not stt.available:
raise HTTPException(status_code=503, detail="STT not available on this host")
raw = await audio.read()
if not raw:
raise HTTPException(status_code=400, detail="empty upload")
# browser sends webm/opus from MediaRecorder; suffix matches for clarity
suffix = ".webm"
if audio.filename and "." in audio.filename:
suffix = "." + audio.filename.rsplit(".", 1)[-1]
text = await stt.transcribe(raw, suffix=suffix)
if text is None:
# could be silence or genuine failure; treat both as no-content
return {"text": ""}
log.info("%s spoke: %s", user["email"], text[:120])
return {"text": text}
# -------------------------------------------------------------------------- websocket
@app.websocket("/ws")
async def chat_ws(ws: WebSocket) -> None:
# SessionMiddleware populates ws.session from the cookie during the handshake
user = ws.session.get("user")
if not user:
await ws.accept()
await ws.send_json({"role": "system", "content": "not authenticated — refresh", "done": True})
await ws.close(code=4401)
return
await ws.accept()
# Look up the operator's cart — calibrated or fresh?
cart = cart_store.load(user["email"])
in_calibration = not (cart and cart.is_calibrated)
if in_calibration:
# Start a fresh calibration session (or resume the one we already had)
state = _calibration_sessions.get(user["email"])
if state is None or state.done:
state, opening = calibration.start(user["email"])
_calibration_sessions[user["email"]] = state
for m in opening:
await ws.send_json(m)
else:
# resume — replay the current question in the chosen language
qs = calibration._all_questions(state)
lang = state.answers.get("language", "en")
await ws.send_json(calibration._question_message(qs[state.step], lang=lang))
else:
await ws.send_json({
"role": "system",
"content": f"channel synchronised • {cart.persona_name}{user['email']}",
"done": True,
})
client = None if PREVIEW_MODE else anthropic.AsyncAnthropic(api_key=ANTHROPIC_API_KEY)
history: list[dict[str, str]] = []
# ---- EEMS context: pull a tight set of memories at session start ----
# Only if calibrated (otherwise we're still in boot interview).
eems_context = ""
if cart and cart.is_calibrated:
try:
eems_context = await memory.operator_context(user["email"], cart.persona_name)
if eems_context:
log.info("EEMS context: %d chars injected for %s", len(eems_context), user["email"])
except Exception:
log.exception("EEMS context pull failed; continuing without")
eems_context = ""
try:
while True:
payload = await ws.receive_json()
user_msg = (payload or {}).get("content", "").strip()
if not user_msg:
continue
# ---- calibration mode ----
if in_calibration:
log.info("%s calibrate[%d]: %s", user["email"], _calibration_sessions[user["email"]].step, user_msg[:80])
state = _calibration_sessions[user["email"]]
next_msgs = calibration.step(state, user_msg)
for m in next_msgs:
if m["role"] == "voice_sample":
await _send_voice_sample(ws, m["voice"], m["text"], m["blurb"])
elif m["role"] == "calibration_done":
new_cart = m["cart"]
cart_store.save(new_cart)
# Create the canonical marauder cart (identity only — tag/name/type/tagline).
# Voice/prompt/UI live in the JSON next to it; the tag links them.
cal_state = _calibration_sessions.get(user["email"])
tagline = (cal_state.answers.get("__tagline") if cal_state else "calibrated companion")
try:
ok = await marauder_cart.create(
tag=new_cart.cart_tag,
name=new_cart.persona_name,
cart_type="companion",
tagline=tagline,
)
if ok:
log.info("marauder cart %r registered", new_cart.cart_tag)
else:
log.warning("marauder cart create returned false; calibration still saved locally")
except Exception:
log.exception("marauder_cart.create raised")
_calibration_sessions.pop(user["email"], None)
cart = new_cart
in_calibration = False
# transition the client into chat mode in-place
await ws.send_json({
"role": "calibration_done",
"persona_name": new_cart.persona_name,
"operator_name": new_cart.operator_name,
"voice": new_cart.voice,
"ui_palette": new_cart.ui_palette,
"ui_typography": new_cart.ui_typography,
"ui_density": new_cart.ui_density,
"ui_labels": new_cart.ui_labels,
})
# warm greeting in the calibrated voice + language
greeting_template = calibration.GREETING.get(
new_cart.language, calibration.GREETING["en"]
)
greeting = greeting_template.format(name=new_cart.operator_name)
await ws.send_json({"role": "assistant", "delta": greeting, "done": False})
await ws.send_json({"role": "assistant", "delta": "", "done": True})
await _send_audio_with_voice(ws, greeting, new_cart.voice)
else:
await ws.send_json(m)
continue
# ---- normal chat ----
history.append({"role": "user", "content": user_msg})
persona = cart.persona_name if cart else "BT"
log.info("%s%s: %s", user["email"], persona, user_msg[:120])
if PREVIEW_MODE:
full = await _preview_stream(ws, user_msg)
# honour the calibrated voice
if cart:
await _send_audio_with_voice(ws, full, cart.voice)
else:
await _send_audio(ws, full)
continue
system_prompt = (cart.system_prompt if cart else BT_SYSTEM_PROMPT) + eems_context
response_text = ""
try:
async with client.messages.stream(
model=ANTHROPIC_MODEL,
max_tokens=4096,
system=system_prompt,
messages=history,
) as stream:
async for chunk in stream.text_stream:
response_text += chunk
await ws.send_json({"role": "assistant", "delta": chunk, "done": False})
await ws.send_json({"role": "assistant", "delta": "", "done": True})
history.append({"role": "assistant", "content": response_text})
voice = cart.voice if cart else TTS_VOICE
await _send_audio_with_voice(ws, response_text, voice)
except anthropic.APIError as e:
log.error("anthropic error: %s", e)
await ws.send_json({
"role": "system",
"content": f"upstream error: {type(e).__name__} — try again",
"done": True,
})
except WebSocketDisconnect:
log.info("%s disconnected", user["email"])
except Exception:
log.exception("ws error")
try:
await ws.send_json({"role": "system", "content": "internal error", "done": True})
finally:
await ws.close()
async def _preview_stream(ws: WebSocket, user_msg: str) -> str:
"""Canned BT-like reply, chunked. UI-only mode. Returns full text."""
import asyncio
canned = (
f"Channel reads you clear, Pilot. You said: “{user_msg}”. "
"No upstream model wired in this build — I am a placeholder voice "
"while the channel itself is being shaped. The mesh holds. "
"Standing by."
)
i = 0
step = 8
while i < len(canned):
chunk = canned[i:i + step]
await ws.send_json({"role": "assistant", "delta": chunk, "done": False})
i += step
await asyncio.sleep(0.06)
await ws.send_json({"role": "assistant", "delta": "", "done": True})
return canned
async def _send_audio(ws: WebSocket, text: str) -> None:
"""Synthesize text with the default voice + ship as data URL. No-op if TTS off."""
if not tts or not tts.available:
return
await _send_audio_with_voice(ws, text, tts.voice)
async def _send_audio_with_voice(ws: WebSocket, text: str, voice_id: str) -> None:
"""Synthesize text in a specific voice and ship as audio. Used post-calibration."""
if not TTS_ENABLED:
return
import base64
try:
# spin up a per-voice synthesizer (cheap — just object init)
per_voice = TTS(voice=voice_id) if voice_id != (tts.voice if tts else "") else tts
if not per_voice or not per_voice.available:
return
wav = await per_voice.synthesize(text)
if not wav:
return
await ws.send_json({
"role": "audio",
"mime": "audio/wav",
"data": base64.b64encode(wav).decode("ascii"),
})
except Exception:
log.exception("audio send failed")
# -------------------------------------------------------------------------- main
if __name__ == "__main__":
import uvicorn
uvicorn.run("app.main:app", host="127.0.0.1", port=8765, reload=True)
+110
View File
@@ -0,0 +1,110 @@
"""Subprocess wrapper around `marauder cart` CLI.
Marauder's cart system stores persona IDENTITY only: tag, name, type, tagline.
Voice, system prompt, UI prefs — those stay in chat-saiden's own per-cart JSON
(see cart_store.py). The two systems are linked by tag.
Tag convention: `<persona-slug>-<operator-slug>` — e.g. `samantha-adam`.
This avoids collisions when multiple operators calibrate carts with the
same persona name.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
log = logging.getLogger("chat-saiden.marauder-cart")
def slug(s: str) -> str:
"""Slugify for marauder cart tags. Lowercase, ASCII-only, dash-separated."""
if not s:
return ""
s = s.lower().strip()
# Polish chars + basic translit
table = str.maketrans({
"ą": "a", "ć": "c", "ę": "e", "ł": "l", "ń": "n",
"ó": "o", "ś": "s", "ź": "z", "ż": "z",
})
s = s.translate(table)
s = re.sub(r"[^a-z0-9]+", "-", s)
return s.strip("-")
async def _run(*args: str, timeout: float = 8.0) -> tuple[int, str, str]:
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
proc.kill()
return 124, "", "timeout"
return proc.returncode or 0, out.decode("utf-8", "replace"), err.decode("utf-8", "replace")
async def exists(tag: str) -> bool:
"""Check if a cart with this tag exists. Uses `cart show` since `cart list --json`
is known to ignore the flag in current marauder builds."""
code, _, err = await _run("marauder", "cart", "show", tag)
return code == 0
async def list_tags() -> list[str]:
"""Best-effort list of cart tags by parsing the table output of `cart list`."""
code, out, _ = await _run("marauder", "cart", "list")
if code != 0:
return []
tags: list[str] = []
for line in out.splitlines():
# rows look like: │ ● ┆ bt7274 ┆ ...
if "" not in line:
continue
parts = [p.strip().lstrip("").lstrip("").strip() for p in line.split("")]
if len(parts) < 2:
continue
tag = parts[1].strip()
# skip header
if tag.lower() == "tag" or not tag:
continue
# skip non-tag chars
if re.fullmatch(r"[a-z0-9._-]+", tag, re.IGNORECASE):
tags.append(tag)
return tags
async def create(
tag: str,
name: str,
cart_type: str = "companion",
tagline: str = "",
) -> bool:
"""Create a marauder cart. Idempotent — no-ops if tag already exists."""
if await exists(tag):
log.info("cart %r already exists, skipping create", tag)
return True
args = ["marauder", "cart", "create", tag, "--name", name, "--type", cart_type]
if tagline:
args.extend(["--tagline", tagline])
code, out, err = await _run(*args)
if code != 0:
log.error("cart create failed for %r: %s", tag, err[:300])
return False
log.info("created marauder cart %r (name=%s, type=%s)", tag, name, cart_type)
return True
async def use(tag: str) -> bool:
"""Switch the global active persona to this tag."""
code, _, err = await _run("marauder", "cart", "use", tag)
if code != 0:
log.warning("cart use %r failed: %s", tag, err[:200])
return False
return True
+174
View File
@@ -0,0 +1,174 @@
"""Subprocess wrapper around `marauder memory` CLI.
Provides recall + store. Marauder's memory CLI returns table output by default
and may or may not honour --json depending on subcommand. We parse what we get.
Shared EEMS namespace: chat.saiden.dev reads and writes the same memories
BT-on-CLI uses. One Pilot, one memory.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from dataclasses import dataclass
from typing import Any
log = logging.getLogger("chat-saiden.memory")
@dataclass
class Memory:
id: int | None
subject: str
content: str
classification: str = "standard"
async def _run(*args: str, timeout: float = 10.0) -> tuple[int, str, str]:
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
proc.kill()
return 124, "", "timeout"
return proc.returncode or 0, out.decode("utf-8", "replace"), err.decode("utf-8", "replace")
def _try_json(text: str) -> Any:
"""Try to extract JSON from output that might be mixed with log lines."""
if not text:
return None
# try the whole thing first
try:
return json.loads(text)
except Exception:
pass
# find JSON object/array boundaries
for opener, closer in [("{", "}"), ("[", "]")]:
first = text.find(opener)
last = text.rfind(closer)
if first != -1 and last > first:
try:
return json.loads(text[first:last + 1])
except Exception:
continue
return None
# Header line: "#3933 (0.8690) user.identity.nco-preference-..."
_RECALL_HEADER = re.compile(r"^#(\d+)\s+\(([\d.]+)\)\s+(\S.*)$")
async def recall(query: str, limit: int = 5, subject: str | None = None) -> list[Memory]:
"""Semantic recall. Returns up to `limit` memories ordered by similarity.
`--json` is documented but not implemented for `marauder memory recall` in
current builds, so we parse the table-ish text format instead.
"""
args = ["marauder", "memory", "recall", query, "--limit", str(limit)]
if subject:
args.extend(["--subject", subject])
code, out, err = await _run(*args)
if code != 0:
log.warning("memory recall failed (rc=%s): %s", code, err[:200])
return []
memories: list[Memory] = []
current: Memory | None = None
body_lines: list[str] = []
def flush():
nonlocal current, body_lines
if current is not None:
current.content = "\n".join(body_lines).strip()
memories.append(current)
current = None
body_lines = []
for raw in out.splitlines():
line = raw.rstrip()
# skip embedding/sqlite log lines (ISO timestamps from tracing)
if re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", line):
continue
m = _RECALL_HEADER.match(line)
if m:
flush()
current = Memory(id=int(m.group(1)), subject=m.group(3).strip(), content="")
continue
if current is not None and line.strip():
body_lines.append(line.strip())
elif current is not None and not line.strip() and body_lines:
# blank line within body — keep separator
body_lines.append("")
flush()
return memories
_STORE_RX = re.compile(r"Stored memory #(\d+)")
async def store(subject: str, content: str, classification: str | None = None) -> int | None:
"""Store a memory. Returns memory ID on success.
Output is plain text 'Stored memory #NNNN ...'; we regex it."""
args = ["marauder", "memory", "store", subject, content]
if classification:
args.extend(["--classification", classification])
code, out, err = await _run(*args, timeout=20.0)
if code != 0:
log.warning("memory store failed (rc=%s): %s", code, err[:200])
return None
m = _STORE_RX.search(out + " " + err)
if m:
return int(m.group(1))
log.debug("memory store output: %r / %r", out[:200], err[:200])
return None
# ----------------------------------------------------------------- context shaping
async def operator_context(operator_email: str, persona_name: str) -> str:
"""Pull a tight context block of memories relevant to the operator. Used to
seed the system prompt at session start so the cart speaks with continuity."""
queries: list[tuple[str, str | None]] = [
# who the operator is
("operator preferences and self-description", "self"),
# what they're working on
(f"recent {persona_name} interactions and projects", None),
# active doctrine that affects how the cart should behave
("doctrine that shapes how I talk to the pilot", "doctrine"),
]
# Fire all recalls in parallel — each is a separate marauder subprocess
results = await asyncio.gather(
*[recall(q, limit=3, subject=subj) for q, subj in queries],
return_exceptions=True,
)
blocks: list[str] = []
for (q, _), memories in zip(queries, results):
if isinstance(memories, Exception):
continue
for m in memories:
if not m.content:
continue
blocks.append(f"— ({m.subject}) {m.content.strip()[:600]}")
if not blocks:
return ""
return (
"\n\n## Pilot context (recalled from EEMS)\n"
"Use these as background only. Don't recite. Refer naturally if useful.\n\n"
+ "\n".join(blocks[:8]) # cap so the prompt doesn't bloat
)
+509
View File
@@ -0,0 +1,509 @@
/* chat.saiden.dev
*
* Default look: clean, generic, neutral chat. Inter, soft grey, no cursive.
* Personality is the result of calibration, not the starting point.
*
* Once calibrated, the body gets data-* attributes:
* data-palette = default | rose | morning | evening | sage | paper | ink
* data-typography = sans | serif-warm | serif-formal | mixed-modern | mono
* data-density = airy | normal | dense
* data-labels = block | cursive | none | prefix
*
* Each combination is a different room.
*/
/* ===================== DEFAULT TOKENS (neutral) ===================== */
:root {
/* surfaces */
--bg: #f4f4f3;
--bg-soft: #ececea;
--surface: #e3e3e1;
/* text */
--ink: #1f2024;
--ink-muted: #6a6a6f;
--ink-faint: #a8a8ac;
/* accents (low-saturation by default) */
--coral: #6a6a6f; /* default accent is neutral grey */
--red: #8a4a3e;
--gold: #9a8460;
/* lines */
--line: #d6d6d3;
/* font stacks (default = sans-only) */
--serif: 'Source Serif Pro', Georgia, serif;
--serif-warm: 'Cormorant Garamond', Georgia, serif;
--hand: 'Caveat', 'Brush Script MT', cursive;
--sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--mono: 'JetBrains Mono', Menlo, monospace;
/* derived families — overridden by data-typography */
--font-body: var(--sans);
--font-label: var(--sans);
/* spacing scale — overridden by data-density */
--msg-gap: 1.5rem;
--max-col: 36rem;
--body-size: 1rem;
--label-size: 0.72rem;
--bt-size: 1.05rem;
/* prompt baseline */
--line-thickness: 1px;
}
/* ===================== PALETTES ===================== */
body[data-palette="rose"] {
--bg: #f5ebe0;
--bg-soft: #f8e8db;
--surface: #f0d9c7;
--ink: #3d2820;
--ink-muted: #8b6f60;
--ink-faint: #b89a87;
--coral: #e07856;
--red: #c54f3d;
--line: #e8d4c0;
}
body[data-palette="morning"] {
--bg: #faf3e3;
--bg-soft: #f4ead0;
--surface: #ecdfb8;
--ink: #3a2f1a;
--ink-muted: #7a6a4a;
--ink-faint: #b6a684;
--coral: #d99b4a;
--red: #b5703a;
--line: #e7d6a8;
}
body[data-palette="evening"] {
--bg: #ece1de;
--bg-soft: #e4d2cd;
--surface: #d8bdb6;
--ink: #2e1b1d;
--ink-muted: #7d5a59;
--ink-faint: #b08c8a;
--coral: #b04a55;
--red: #84313e;
--line: #d6bbb6;
}
body[data-palette="sage"] {
--bg: #ecede4;
--bg-soft: #e1e3d4;
--surface: #cdd1bd;
--ink: #25291e;
--ink-muted: #5d6451;
--ink-faint: #97a085;
--coral: #6f8861;
--red: #58683f;
--line: #cfd4be;
}
body[data-palette="paper"] {
--bg: #f7f4ed;
--bg-soft: #efeada;
--surface: #e1d9c2;
--ink: #1d1c19;
--ink-muted: #6b6759;
--ink-faint: #a8a292;
--coral: #8b5d2f;
--red: #6a3f1b;
--line: #d8cfb6;
}
body[data-palette="ink"] {
--bg: #1d1d1f;
--bg-soft: #26262a;
--surface: #2f2f34;
--ink: #e8e6df;
--ink-muted: #9d9a90;
--ink-faint: #65635c;
--coral: #d8a572;
--red: #b87653;
--line: #353539;
}
/* ===================== TYPOGRAPHY SETS ===================== */
body[data-typography="sans"] {
--font-body: var(--sans);
--font-label: var(--sans);
}
body[data-typography="serif-warm"] {
--font-body: var(--serif-warm);
--font-label: var(--hand);
--bt-size: 1.25rem;
}
body[data-typography="serif-formal"] {
--font-body: var(--serif);
--font-label: var(--sans);
--bt-size: 1.15rem;
}
body[data-typography="mixed-modern"] {
--font-body: var(--sans);
--font-label: var(--hand);
}
body[data-typography="mono"] {
--font-body: var(--mono);
--font-label: var(--mono);
--bt-size: 0.95rem;
}
/* ===================== DENSITY ===================== */
body[data-density="airy"] { --msg-gap: 2.3rem; --max-col: 38rem; --body-size: 1.05rem; }
body[data-density="normal"] { --msg-gap: 1.5rem; --max-col: 36rem; --body-size: 1rem; }
body[data-density="dense"] { --msg-gap: 1rem; --max-col: 34rem; --body-size: 0.95rem; }
/* ===================== RESET ===================== */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
background: var(--bg);
color: var(--ink);
font-family: var(--font-body);
font-weight: 400;
font-size: var(--body-size);
line-height: 1.55;
min-height: 100vh;
}
/* ===================== LAYOUT ===================== */
.page {
max-width: var(--max-col);
margin: 0 auto;
padding: 3rem 1.5rem 0 1.5rem;
position: relative;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ---------- top-nav strip (recalibrate · sign out · sigil) ---------- */
.topnav {
position: fixed;
top: 1.25rem;
right: 1.25rem;
display: flex;
align-items: center;
gap: 0.75rem;
z-index: 10;
}
.topnav__link {
font-family: var(--sans);
font-size: 0.62rem;
font-weight: 400;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ink-faint);
text-decoration: none;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
opacity: 0.45;
transition: opacity 400ms ease, color 400ms ease;
}
.topnav__link:hover,
.topnav__link:focus-visible {
opacity: 1;
color: var(--ink);
outline: none;
}
.topnav__link:disabled {
opacity: 0.25;
cursor: progress;
}
.topnav__sep {
color: var(--ink-faint);
opacity: 0.35;
font-size: 0.7rem;
}
.sigil {
width: 14px;
height: 14px;
opacity: 0.35;
transition: opacity 600ms ease;
user-select: none;
margin-left: 0.4rem;
}
.sigil:hover { opacity: 0.85; }
.sigil img { width: 100%; height: 100%; display: block; }
/* ===================== CONVERSATION ===================== */
.conversation {
flex: 1 0 auto;
padding-bottom: 2rem;
}
.msg {
margin: var(--msg-gap) 0;
opacity: 0;
transform: translateY(6px);
animation: appear 500ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.msg__label {
font-family: var(--font-label);
font-weight: 400;
color: var(--ink-muted);
font-size: var(--label-size);
letter-spacing: 0.14em;
text-transform: uppercase;
margin-bottom: 0.45rem;
user-select: none;
}
/* Cursive label override */
body[data-labels="cursive"] .msg__label {
font-family: var(--hand);
font-size: 1rem;
letter-spacing: 0.01em;
text-transform: none;
}
body[data-labels="none"] .msg__label { display: none; }
body[data-labels="prefix"] .msg__label {
display: inline;
margin-right: 0.5em;
font-weight: 600;
text-transform: none;
}
.msg__body {
color: var(--ink);
font-size: var(--bt-size);
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
}
.msg--user .msg__body {
color: var(--ink-muted);
font-size: var(--body-size);
line-height: 1.55;
}
.msg--system .msg__body {
color: var(--ink-faint);
font-size: 0.74rem;
text-transform: uppercase;
letter-spacing: 0.16em;
font-family: var(--sans);
font-weight: 300;
}
.msg--system .msg__label { display: none; }
.msg--calibration .msg__body {
color: var(--ink);
font-size: calc(var(--bt-size) * 1.02);
font-style: italic;
line-height: 1.65;
}
.msg--calibration .msg__label { display: none; }
.msg--calibration { margin: 1.5rem 0; }
/* repeat-label hide */
.msg[data-hide-label="true"] .msg__label { display: none; }
.msg[data-hide-label="true"] { margin-top: 0.8rem; }
/* ===================== THINKING ===================== */
.thinking {
color: var(--coral);
font-size: 0.65rem;
margin: 1.5rem 0;
user-select: none;
animation: pulse 1.8s ease-in-out infinite;
}
.thinking::before { content: "●"; }
@keyframes pulse {
0%, 100% { opacity: 0.30; }
50% { opacity: 1.0; }
}
@keyframes appear {
to { opacity: 1; transform: translateY(0); }
}
/* typewriter cursor */
.caret {
display: inline-block;
width: 0.5ch;
margin-left: 0.05ch;
color: var(--coral);
animation: caret 800ms ease-out forwards;
}
.caret::after { content: "▍"; }
@keyframes caret {
0% { opacity: 1; }
100% { opacity: 0; }
}
/* ===================== PROMPT ===================== */
.prompt {
position: sticky;
bottom: 0;
background: var(--bg);
padding: 1.5rem 0 2.5rem 0;
z-index: 5;
}
.prompt__line {
border: none;
border-top: var(--line-thickness) solid var(--line);
margin: 0 0 0.85rem 0;
}
.prompt__row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.prompt__input {
flex: 1;
border: none;
background: transparent;
outline: none;
font-family: var(--font-body);
font-weight: 400;
font-size: var(--body-size);
color: var(--ink);
padding: 0;
min-width: 0;
}
.prompt__input::placeholder {
color: var(--ink-faint);
font-style: italic;
}
.prompt__input:focus { caret-color: var(--coral); }
/* ===================== MIC ===================== */
.prompt__mic {
background: transparent;
border: none;
cursor: pointer;
color: var(--ink-faint);
padding: 0.4rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: color 200ms ease, background 200ms ease, transform 200ms ease;
}
.prompt__mic:hover { color: var(--ink-muted); }
.prompt__mic:active { transform: scale(0.95); }
.prompt__mic.recording {
color: var(--coral);
background: rgba(0, 0, 0, 0.04);
animation: mic-pulse 1.2s ease-in-out infinite;
}
.prompt__mic.transcribing {
color: var(--gold);
animation: mic-spin 1.2s linear infinite;
}
.prompt__mic.empty {
color: var(--red);
animation: mic-empty 0.7s ease;
}
@keyframes mic-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.18); }
50% { box-shadow: 0 0 0 8px rgba(0, 0, 0, 0); }
}
@keyframes mic-spin { to { transform: rotate(360deg); } }
@keyframes mic-empty {
0% { transform: translateX(0); }
20% { transform: translateX(-2px); }
40% { transform: translateX(2px); }
60% { transform: translateX(-2px); }
80% { transform: translateX(2px); }
100% { transform: translateX(0); }
}
/* ===================== CHOICE TILES ===================== */
.choices {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
margin: 1rem 0 0.25rem 0;
}
.choice {
background: var(--bg-soft);
border: 1px solid var(--line);
color: var(--ink);
font-family: var(--font-body);
font-size: 0.95rem;
font-style: normal;
padding: 0.5rem 0.95rem;
border-radius: 999px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.45rem;
transition: background 220ms ease, color 220ms ease,
border-color 220ms ease, transform 220ms ease, opacity 220ms ease;
}
.choice:hover {
background: var(--surface);
border-color: var(--coral);
}
.choice:active { transform: scale(0.97); }
.choice:disabled { cursor: default; opacity: 0.45; }
.choice--selected {
background: var(--coral);
color: var(--bg);
border-color: var(--coral);
opacity: 1 !important;
}
.choice--other {
font-style: italic;
color: var(--ink-muted);
background: transparent;
}
.choice__icon { font-size: 1.05rem; line-height: 1; display: inline-flex; }
.choice__label { line-height: 1; }
/* ===================== DENIED PAGE ===================== */
.denied {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
text-align: center;
}
.denied__title {
font-family: var(--font-body);
font-size: 1.7rem;
color: var(--ink);
font-weight: 500;
margin: 0 0 1rem 0;
}
.denied__body {
font-family: var(--font-body);
font-size: 1rem;
color: var(--ink-muted);
max-width: 28rem;
line-height: 1.6;
}
.denied__link {
font-family: var(--sans);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--red);
text-decoration: none;
margin-top: 3rem;
display: inline-block;
border-bottom: 1px solid var(--line);
padding-bottom: 0.15rem;
}
.denied__link:hover { border-color: var(--red); }
+417
View File
@@ -0,0 +1,417 @@
/* chat.saiden.dev — frontend
*
* Minimal vanilla JS. WS connects to /ws, sends {content}, receives
* {role, delta, done} chunks. Streams into a typewriter queue.
*
* Design rules (see UI-PLAN.md):
* - Speaker labels only appear when speaker changes from the previous message.
* - Pilot's outbound messages render instantly. BT's stream via typewriter.
* - One coral pulsing dot while waiting. Cursor fades when stream completes.
*/
const TYPEWRITER_MS = 22; // ~45 chars/sec
const SCROLL_EASE_MS = 600;
const $ = (sel) => document.querySelector(sel);
const $conversation = $('#conversation');
const $form = $('#prompt-form');
const $input = $('#prompt-input');
let ws = null;
let connectAttempts = 0;
let lastSpeaker = null; // 'user' | 'bt' | 'system' | null
let currentBtBody = null; // active streaming .msg__body element
let queue = [];
let draining = false;
// ---------- helpers ----------
function speakerLabel(role) {
if (role === 'user') return window.__pilotName || 'Pilot';
if (role === 'bt') return window.__personaName || 'BT';
if (role === 'calibration') return '—';
return 'channel';
}
function makeMsg(role) {
const repeat = role === lastSpeaker;
const msg = document.createElement('article');
msg.className = `msg msg--${role}`;
if (repeat) msg.setAttribute('data-hide-label', 'true');
const label = document.createElement('div');
label.className = 'msg__label';
label.textContent = speakerLabel(role);
msg.appendChild(label);
const body = document.createElement('div');
body.className = 'msg__body';
msg.appendChild(body);
$conversation.appendChild(msg);
lastSpeaker = role;
scrollToBottom();
return body;
}
function smoothScrollTo(target) {
const start = window.scrollY;
const dist = target - start;
const t0 = performance.now();
function step(now) {
const t = Math.min(1, (now - t0) / SCROLL_EASE_MS);
const eased = 1 - Math.pow(1 - t, 3);
window.scrollTo(0, start + dist * eased);
if (t < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function scrollToBottom() {
const target = document.documentElement.scrollHeight - window.innerHeight;
smoothScrollTo(target);
}
function showThinking() {
removeThinking();
const dot = document.createElement('div');
dot.className = 'thinking';
dot.id = 'thinking';
$conversation.appendChild(dot);
scrollToBottom();
}
function removeThinking() {
const t = document.getElementById('thinking');
if (t) t.remove();
}
// ---------- typewriter ----------
function enqueue(text) {
for (const ch of text) queue.push(ch);
if (!draining) drain();
}
async function drain() {
if (!currentBtBody) return;
draining = true;
while (queue.length) {
currentBtBody.textContent += queue.shift();
if (Math.random() < 0.04) scrollToBottom();
await sleep(TYPEWRITER_MS);
}
draining = false;
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function finishBt() {
// wait for the queue to drain before adding caret
const tick = () => {
if (draining || queue.length) { setTimeout(tick, 30); return; }
if (!currentBtBody) return;
const caret = document.createElement('span');
caret.className = 'caret';
currentBtBody.appendChild(caret);
scrollToBottom();
setTimeout(() => caret.remove(), 900);
currentBtBody = null;
};
tick();
}
// ---------- ws ----------
function connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/ws`);
ws.addEventListener('open', () => {
connectAttempts = 0;
});
ws.addEventListener('message', (e) => {
let msg;
try { msg = JSON.parse(e.data); } catch { return; }
handleMessage(msg);
});
ws.addEventListener('close', (e) => {
if (e.code === 4401) {
// session expired — say so softly, but DO NOT navigate the page away.
// The Pilot decides when to refresh.
const body = makeMsg('system');
body.textContent = 'session lost — refresh when ready';
return;
}
// try a gentle reconnect, with a hard cap so we don't spam forever
connectAttempts++;
if (connectAttempts > 6) return;
const delay = Math.min(8000, 600 * connectAttempts);
setTimeout(connect, delay);
});
}
function handleMessage(msg) {
if (msg.role === 'system') {
const body = makeMsg('system');
body.textContent = msg.content || '';
return;
}
if (msg.role === 'calibration') {
removeThinking();
const body = makeMsg('calibration');
body.textContent = msg.content || '';
if (Array.isArray(msg.choices) && msg.choices.length) {
renderChoices(body.parentElement, msg.choices);
}
return;
}
if (msg.role === 'calibration_done') {
// Transition in place — no reload.
if (msg.persona_name) window.__personaName = msg.persona_name;
if (msg.operator_name) window.__pilotName = msg.operator_name;
if (msg.ui_palette) document.body.setAttribute('data-palette', msg.ui_palette);
if (msg.ui_typography) document.body.setAttribute('data-typography', msg.ui_typography);
if (msg.ui_density) document.body.setAttribute('data-density', msg.ui_density);
if (msg.ui_labels) document.body.setAttribute('data-labels', msg.ui_labels);
if (msg.persona_name) {
$input.setAttribute('placeholder', `speak to ${msg.persona_name}`);
}
return;
}
if (msg.role === 'assistant') {
if (!currentBtBody) {
removeThinking();
currentBtBody = makeMsg('bt');
}
if (msg.delta) enqueue(msg.delta);
if (msg.done) finishBt();
}
if (msg.role === 'audio' && msg.data) {
playAudio(msg.mime || 'audio/wav', msg.data);
}
}
// ---------- audio ----------
let currentAudio = null;
function playAudio(mime, b64) {
// stop any in-flight playback first — last word wins
if (currentAudio) {
try { currentAudio.pause(); } catch {}
currentAudio = null;
}
const audio = new Audio(`data:${mime};base64,${b64}`);
audio.volume = 0.9;
audio.play().catch(err => {
// autoplay may be blocked until first user gesture — gracefully degrade
console.warn('audio autoplay blocked:', err.message);
});
currentAudio = audio;
}
// ---------- form ----------
$form.addEventListener('submit', (e) => {
e.preventDefault();
const text = $input.value.trim();
if (!text) return;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const body = makeMsg('user');
body.textContent = text;
ws.send(JSON.stringify({ content: text }));
$input.value = '';
showThinking();
});
// Cmd+K / Ctrl+K to refocus input
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
$input.focus();
}
});
// ---------- voice input (whisper) ----------
const $mic = document.getElementById('mic-button');
let mediaRecorder = null;
let recordedChunks = [];
let recording = false;
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
recordedChunks = [];
// Prefer webm/opus if browser supports it (Chrome/FF). Safari may need fallback.
const mimeTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', ''];
const mime = mimeTypes.find(m => !m || MediaRecorder.isTypeSupported(m)) || '';
mediaRecorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
mediaRecorder.addEventListener('dataavailable', (e) => {
if (e.data && e.data.size > 0) recordedChunks.push(e.data);
});
mediaRecorder.addEventListener('stop', () => {
// release the mic immediately
stream.getTracks().forEach(t => t.stop());
handleRecorded();
});
mediaRecorder.start();
recording = true;
$mic.classList.add('recording');
} catch (err) {
console.warn('microphone unavailable:', err.message);
recording = false;
}
}
function stopRecording() {
if (!mediaRecorder || mediaRecorder.state === 'inactive') return;
mediaRecorder.stop();
recording = false;
$mic.classList.remove('recording');
$mic.classList.add('transcribing');
}
async function handleRecorded() {
if (!recordedChunks.length) {
$mic.classList.remove('transcribing');
return;
}
const blob = new Blob(recordedChunks, { type: recordedChunks[0].type || 'audio/webm' });
const ext = (blob.type.split('/')[1] || 'webm').split(';')[0];
const form = new FormData();
form.append('audio', blob, `speech.${ext}`);
try {
const resp = await fetch('/api/transcribe', { method: 'POST', body: form });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const { text } = await resp.json();
if (text && text.trim()) {
$input.value = text;
// auto-send — Her vibe is "speak and she hears"
$form.dispatchEvent(new Event('submit', { cancelable: true }));
} else {
flashMicEmpty();
}
} catch (err) {
console.warn('transcription failed:', err.message);
flashMicEmpty();
} finally {
$mic.classList.remove('transcribing');
}
}
function flashMicEmpty() {
// brief visual hint that nothing was heard — no toast, no popup, just a flash
$mic.classList.add('empty');
setTimeout(() => $mic.classList.remove('empty'), 700);
}
// ---------- calibration choice tiles ----------
function renderChoices(parentEl, choices) {
const wrap = document.createElement('div');
wrap.className = 'choices';
for (const c of choices) {
const tile = document.createElement('button');
tile.type = 'button';
tile.className = 'choice';
if (c.value === '__other__') tile.classList.add('choice--other');
if (c.icon) {
const icon = document.createElement('span');
icon.className = 'choice__icon';
icon.textContent = c.icon;
tile.appendChild(icon);
}
const label = document.createElement('span');
label.className = 'choice__label';
label.textContent = c.label;
tile.appendChild(label);
tile.addEventListener('click', () => {
// disable all tiles in this group
wrap.querySelectorAll('.choice').forEach(b => b.disabled = true);
tile.classList.add('choice--selected');
if (c.value === '__other__') {
// remove tiles, focus input so the operator types freely
wrap.remove();
$input.focus();
return;
}
sendChoice(c.label, c.value);
// collapse tiles into a faint chosen tag
setTimeout(() => wrap.remove(), 200);
});
wrap.appendChild(tile);
}
parentEl.appendChild(wrap);
scrollToBottom();
}
function sendChoice(label, value) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
// Render as a "Pilot" message using the human-readable label
const body = makeMsg('user');
body.textContent = label;
// Send the machine value to the server
ws.send(JSON.stringify({ content: value }));
showThinking();
}
function toggleMic() {
if (recording) stopRecording();
else startRecording();
}
$mic.addEventListener('click', toggleMic);
// Hold-space-to-talk when input is NOT focused (so typing space still works)
document.addEventListener('keydown', (e) => {
if (e.code !== 'Space') return;
if (document.activeElement === $input) return;
if (e.repeat) return;
e.preventDefault();
if (!recording) startRecording();
});
document.addEventListener('keyup', (e) => {
if (e.code !== 'Space') return;
if (recording) stopRecording();
});
// ---------- recalibrate ----------
const $recal = document.getElementById('recalibrate-btn');
if ($recal) {
$recal.addEventListener('click', async () => {
if ($recal.disabled) return;
$recal.disabled = true;
$recal.textContent = 'resetting…';
try {
const resp = await fetch('/api/recalibrate', { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
// explicit reset is user-driven — reload is appropriate here
location.reload();
} catch (err) {
console.warn('recalibrate failed:', err.message);
$recal.textContent = 'recalibrate';
$recal.disabled = false;
}
});
}
// ---------- init ----------
connect();
$input.focus();
+143
View File
@@ -0,0 +1,143 @@
"""Whisper.cpp STT adapter for chat.saiden.dev.
Transcribes microphone audio (webm/opus from browser) → text.
Pipeline: ffmpeg → 16kHz mono WAV → whisper-cli → stdout text.
Fails silently if the binary or model is missing.
"""
from __future__ import annotations
import asyncio
import logging
import os
import re
import shutil
import tempfile
from pathlib import Path
# Whisper.cpp special tokens — emitted for non-speech audio.
# If the entire transcript is one of these, treat as no speech.
_NON_SPEECH = re.compile(
r"^\s*[\[\(](?:BLANK_AUDIO|INAUDIBLE|NO_SPEECH|MUSIC|NOISE|SILENCE|SOUND|"
r"APPLAUSE|LAUGHTER|CROSSTALK|BREATHING|UNINTELLIGIBLE)[\]\)]\s*$",
re.IGNORECASE,
)
log = logging.getLogger("chat-saiden.stt")
WHISPER_BIN = shutil.which("whisper-cli") or os.environ.get("WHISPER_BIN")
FFMPEG_BIN = shutil.which("ffmpeg") or os.environ.get("FFMPEG_BIN")
_MODEL_SEARCH = [
Path.home() / ".cache/whisper/ggml-base.en.bin",
Path.home() / ".cache/whisper/ggml-small.en.bin",
Path.home() / ".cache/whisper/ggml-tiny.en.bin",
Path("/usr/local/share/whisper.cpp/ggml-base.en.bin"),
Path("/usr/share/whisper.cpp/ggml-base.en.bin"),
]
def _resolve_model() -> Path | None:
override = os.environ.get("WHISPER_MODEL_PATH")
if override:
p = Path(override)
return p if p.exists() else None
for cand in _MODEL_SEARCH:
if cand.exists():
return cand
return None
class STT:
"""Whisper-cpp wrapper."""
def __init__(self) -> None:
self.bin = WHISPER_BIN
self.ffmpeg = FFMPEG_BIN
self.model = _resolve_model()
if not self.bin:
log.warning("whisper-cli not found — STT disabled")
elif not self.ffmpeg:
log.warning("ffmpeg not found — STT disabled")
elif not self.model:
log.warning("no whisper model in known locations — STT disabled")
else:
log.info("STT enabled — model=%s bin=%s", self.model, self.bin)
@property
def available(self) -> bool:
return bool(self.bin and self.ffmpeg and self.model)
async def transcribe(self, audio_bytes: bytes, suffix: str = ".webm") -> str | None:
"""Return transcript text, or None on failure / unavailable."""
if not self.available:
return None
if not audio_bytes:
return None
tmp_in = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
tmp_in.write(audio_bytes)
tmp_in.close()
tmp_wav = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
tmp_wav.close()
try:
# 1. ffmpeg: convert to 16kHz mono WAV (whisper's expected format)
ff = await asyncio.create_subprocess_exec(
self.ffmpeg, "-y", "-loglevel", "error",
"-i", tmp_in.name,
"-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le",
tmp_wav.name,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, ff_err = await ff.communicate()
if ff.returncode != 0:
log.error("ffmpeg failed: %s", ff_err.decode("utf-8", "replace")[:300])
return None
# 2. whisper-cli: transcribe → plain text on stdout
wh = await asyncio.create_subprocess_exec(
self.bin,
"-m", str(self.model),
"-f", tmp_wav.name,
"--no-timestamps",
"--no-prints",
"--output-txt",
"-of", tmp_wav.name + ".out",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, wh_err = await wh.communicate()
if wh.returncode != 0:
log.error("whisper failed: %s", wh_err.decode("utf-8", "replace")[:300])
return None
txt_path = Path(tmp_wav.name + ".out.txt")
if not txt_path.exists():
log.error("whisper produced no output file")
return None
text = txt_path.read_text(encoding="utf-8").strip()
try:
txt_path.unlink()
except OSError:
pass
if not text:
return None
# filter non-speech markers — whisper.cpp emits "[BLANK_AUDIO]" etc.
if _NON_SPEECH.match(text):
log.info("transcript was non-speech marker: %r", text)
return None
return text
except Exception:
log.exception("transcribe failed")
return None
finally:
for p in (tmp_in.name, tmp_wav.name):
try:
os.unlink(p)
except OSError:
pass
+67
View File
@@ -0,0 +1,67 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>chat.saiden.dev</title>
<meta name="description" content="A quiet channel.">
<meta name="robots" content="noindex, nofollow">
<link rel="icon" href="https://saiden.dev/favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;1,400&family=Caveat:wght@400;500&family=Inter:wght@300;400;500;600&family=Source+Serif+Pro:ital,wght@0,400;0,600;1,400&family=JetBrains+Mono:wght@400;500&display=swap">
<link rel="stylesheet" href="/static/chat.css">
</head>
<body
data-palette="{{ ui_palette }}"
data-typography="{{ ui_typography }}"
data-density="{{ ui_density }}"
data-labels="{{ ui_labels }}">
<script>
window.__pilotName = {{ pilot_name | tojson }};
window.__personaName = {{ persona_name | tojson }};
</script>
<nav class="topnav" aria-label="channel controls">
<button type="button" class="topnav__link" id="recalibrate-btn" title="reset calibration">recalibrate</button>
<span class="topnav__sep">·</span>
<a class="topnav__link" href="/auth/logout">sign out</a>
<a class="sigil" href="https://saiden.dev/" target="_blank" rel="noopener" title="Saiden">
<img src="https://saiden.dev/logo.png" alt="Saiden">
</a>
</nav>
<main class="page">
<section class="conversation" id="conversation" aria-live="polite">
</section>
<form class="prompt" id="prompt-form" autocomplete="off">
<hr class="prompt__line">
<div class="prompt__row">
<input
class="prompt__input"
id="prompt-input"
type="text"
placeholder="{% if cart %}speak to {{ persona_name }}{% else %}…{% endif %}"
autofocus
autocomplete="off"
spellcheck="true"
aria-label="message">
<button type="button" class="prompt__mic" id="mic-button" aria-label="speak"
title="hold space to speak · click to toggle">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none"
stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="3" width="6" height="12" rx="3"></rect>
<path d="M5 11a7 7 0 0 0 14 0"></path>
<line x1="12" y1="18" x2="12" y2="22"></line>
</svg>
</button>
</div>
</form>
</main>
<script src="/static/chat.js"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>chat.saiden.dev — not authorised</title>
<meta name="robots" content="noindex, nofollow">
<link rel="icon" href="https://saiden.dev/favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400&family=Caveat:wght@400&family=Inter:wght@300&display=swap">
<link rel="stylesheet" href="/static/chat.css">
</head>
<body>
<main class="denied">
<h1 class="denied__title">not on the channel</h1>
<p class="denied__body">{{ reason }}</p>
<div class="denied__hand">— Saiden</div>
<a class="denied__link" href="/auth/login">try again</a>
</main>
</body>
</html>
+101
View File
@@ -0,0 +1,101 @@
"""Piper TTS adapter for chat.saiden.dev.
Synthesises text → WAV bytes by subprocess'ing the `piper` CLI binary
(already installed on every host that runs marauder-os).
Designed to fail silently — if piper is missing or synthesis errors,
the chat still works, just without voice.
"""
from __future__ import annotations
import asyncio
import logging
import os
import shutil
import tempfile
from pathlib import Path
log = logging.getLogger("chat-saiden.tts")
# Where the voice .onnx files live across hosts.
# Order: env override → macOS marauder → linux marauder → linux marauder-agent (mesh node) → linux ~/.local
_VOICE_SEARCH_PATHS = [
Path.home() / "Library/Application Support/marauder/voices",
Path("/home") / os.environ.get("USER", "marauder") / ".local/share/marauder/voices",
Path.home() / ".local/share/marauder/voices",
Path.home() / ".local/share/psn/voices",
Path.home() / ".local/share/piper/voices",
]
def _resolve_voice_path(name: str) -> Path | None:
"""Return absolute path to a voice model by short name, or None."""
# explicit override
override = os.environ.get("TTS_VOICE_PATH")
if override:
p = Path(override)
return p if p.exists() else None
for base in _VOICE_SEARCH_PATHS:
candidate = base / f"{name}.onnx"
if candidate.exists():
return candidate
return None
PIPER_BIN = shutil.which("piper") or os.environ.get("PIPER_BIN")
class TTS:
"""Subprocess-based piper synthesizer with graceful fallback."""
def __init__(self, voice: str = "en_US-amy-medium") -> None:
self.voice = voice
self.voice_path = _resolve_voice_path(voice)
self.bin = PIPER_BIN
if not self.bin:
log.warning("piper binary not found on PATH — TTS disabled")
elif not self.voice_path:
log.warning("voice '%s' not found in known locations — TTS disabled", voice)
else:
log.info("TTS enabled — voice=%s path=%s bin=%s", voice, self.voice_path, self.bin)
@property
def available(self) -> bool:
return bool(self.bin and self.voice_path)
async def synthesize(self, text: str) -> bytes | None:
"""Return WAV bytes, or None on failure / unavailable."""
if not self.available:
return None
if not text.strip():
return None
# piper wants an output file path (no stdout streaming for WAV in older versions)
out = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
out.close()
out_path = out.name
try:
proc = await asyncio.create_subprocess_exec(
self.bin,
"--model", str(self.voice_path),
"--output_file", out_path,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate(text.encode("utf-8"))
if proc.returncode != 0:
log.error("piper exited %s: %s", proc.returncode, stderr.decode("utf-8", "replace")[:300])
return None
with open(out_path, "rb") as f:
return f.read()
except Exception:
log.exception("piper synthesis failed")
return None
finally:
try:
os.unlink(out_path)
except OSError:
pass
+22
View File
@@ -0,0 +1,22 @@
[project]
name = "chat-saiden"
version = "0.1.0"
description = "TUI-styled web chat with BT-7274 — chat.saiden.dev"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.32",
"websockets>=13",
"anthropic>=0.40",
"authlib>=1.3",
"itsdangerous>=2.2", # session cookie signing
"httpx>=0.27", # for authlib OAuth
"jinja2>=3.1", # template rendering
"python-multipart>=0.0.12", # form parsing
]
[tool.uv]
package = false
[tool.ruff]
line-length = 100
Generated
+1017
View File
File diff suppressed because it is too large Load Diff