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