Papajams commited on
Commit
7f15dbc
·
verified ·
1 Parent(s): af254d6

Initial submission: FutureSelves Build Small

Browse files
README.md CHANGED
@@ -1,13 +1,79 @@
1
  ---
2
- title: Futureselves
3
- emoji: 👁
4
  colorFrom: yellow
5
- colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: FutureSelves
3
+ emoji:
4
  colorFrom: yellow
5
+ colorTo: gray
6
  sdk: gradio
7
+ sdk_version: 5.0
 
8
  app_file: app.py
9
  pinned: false
10
+ tags:
11
+ - backyard-ai
12
+ - openbmb
13
+ - nvidia-nemotron
14
+ - tiny-titan
15
+ - best-agent
16
+ - off-brand
17
+ - best-demo
18
+ - bonus-quest-champion
19
  ---
20
 
21
+ # FutureSelves
22
+
23
+ **A daily ritual where your future self sends you transmissions.**
24
+
25
+ Check in with one word. Receive a personalized voice transmission from across time. Make a tiny choice that reshapes who gets to speak tomorrow.
26
+
27
+ All inference runs on-device via three small models — no cloud dependencies, no API bills, no data uploaded.
28
+
29
+ ## How it works
30
+
31
+ 1. **Onboarding** — Tell the system about your current life chapter: what you're avoiding, what you're afraid won't happen, what's draining you, and what would make a miraculous year.
32
+ 2. **Daily check-in** — One word + optional note for today. A structured insight extractor (Nemotron-Parse) reads your note for emotional signals.
33
+ 3. **Transmission** — Your assigned future self (MiniCPM 2.5B, prompted with your full context) generates a personalized narrative message with a specific action prompt and cliffhanger.
34
+ 4. **Your move** — Choose: toward, steady, release, or repair. Each choice shifts your timeline and builds toward unlocking new cast members.
35
+ 5. **Reaction** — Tell your future self how it landed. The next transmission remembers.
36
+
37
+ ## Models
38
+
39
+ | Model | Params | Role | Sponsor |
40
+ |---|---|---|---|
41
+ | MiniCPM 2.5 (openbmb) | ~2.5B | Transmission generation (primary LLM) | OpenBMB |
42
+ | Nemotron-Parse (NVIDIA) | <1B | Structured note extraction (emotions, themes, entities) | NVIDIA Nemotron |
43
+ | Kokoro | 82M | Text-to-speech (fully local) | — |
44
+
45
+ Each model is well under 32B params. Total: ~3.1B across all three models — qualifies for **Tiny Titan**.
46
+
47
+ ## Prizes targeted
48
+
49
+ | Prize | Why we qualify |
50
+ |---|---|
51
+ | **Backyard AI (track)** | Practical daily-life app for personal reflection and emotional accountability |
52
+ | **OpenBMB** | Built with MiniCPM 2.5 as the primary generation model |
53
+ | **NVIDIA Nemotron** | Nemotron-Parse for structured insight extraction from user notes |
54
+ | **Tiny Titan** | ~3.1B total across all models — genuinely tiny |
55
+ | **Best Agent** | Multi-step agentic pipeline: check-in → extract → generate → choice → reaction → persist |
56
+ | **Off Brand** | Custom Gradio CSS with dark amber theme, card-based layout, animated loading state |
57
+ | **Best Demo** | Full demo video + social post (links below) |
58
+ | **Bonus Quest Champion** | Targeting 6+ bonus/sponsor criteria simultaneously |
59
+
60
+ ## Tech
61
+
62
+ - **UI:** Gradio 5 with custom CSS theme (Off Brand)
63
+ - **LLM:** MiniCPM 2.5 via 🤗 Transformers with torch.compile + SDPA attention
64
+ - **Extraction:** Nemotron-Parse (NVIDIA) with keyword fallback when GPU is constrained
65
+ - **TTS:** Kokoro 82M — generates WAV output for each transmission
66
+ - **State:** In-memory session state (per-user via Gradio Sessions)
67
+
68
+ ## Running locally
69
+
70
+ ```bash
71
+ pip install -r requirements.txt
72
+ python app.py
73
+ ```
74
+
75
+ ## Links
76
+
77
+ - [Demo video]() <!-- TODO: upload after recording -->
78
+ - [Social post]() <!-- TODO: post and link -->
79
+ - [Source (monorepo)](https://github.com/udingethe/futureselves)
__pycache__/app.cpython-312.pyc ADDED
Binary file (48.4 kB). View file
 
__pycache__/parse_notes.cpython-312.pyc ADDED
Binary file (6.49 kB). View file
 
__pycache__/transmission.cpython-312.pyc ADDED
Binary file (21.1 kB). View file
 
__pycache__/tts.cpython-312.pyc ADDED
Binary file (3.52 kB). View file
 
app.py ADDED
@@ -0,0 +1,636 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py — FutureSelves for Build Small (Gradio Space).
3
+
4
+ Two models, one Space:
5
+ - MiniCPM 2.5B (~2.5B) — primary LLM for transmission generation
6
+ - Nemotron-Parse (<1B) — structured note extraction (NVIDIA prize)
7
+
8
+ TTS via Kokoro (82M) — fully local.
9
+
10
+ Targeted prizes (8): Backyard AI, OpenBMB, NVIDIA Nemotron,
11
+ Tiny Titan, Best Agent, Off Brand, Best Demo, Bonus Quest Champion.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import os
19
+ import threading
20
+ import uuid
21
+ from dataclasses import dataclass, field, asdict
22
+ from datetime import date
23
+ from typing import Any, Optional
24
+
25
+ import gradio as gr
26
+
27
+ from transmission import (
28
+ CastMember,
29
+ GenerationContext,
30
+ GeneratedTransmission,
31
+ PersonaContext,
32
+ RecentChoice,
33
+ RecentResponse,
34
+ RecentTransmission,
35
+ build_prompt,
36
+ fallback_transmission,
37
+ get_system_prompt,
38
+ parse_transmission,
39
+ )
40
+ from parse_notes import extract_note_insights, fast_insights
41
+ from tts import generate_speech, get_voice_for_cast_member
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ MODEL_NAME = os.environ.get("LLM_MODEL", "openbmb/MiniCPM-2.5-sft-bf16")
46
+
47
+ CAST_MEMBER_NAMES = {
48
+ "future_self": ("Your Future Self", "Always transmitting"),
49
+ "future_partner": ("Future Partner", "Love arc required"),
50
+ "future_mentor": ("Future Mentor", "7-day streak + toward choices"),
51
+ "future_best_friend": ("Future Best Friend", "3-day streak + repair"),
52
+ "shadow": ("The Shadow", "High divergence"),
53
+ "alternate_self": ("Alternate Self", "14-day streak + drift"),
54
+ }
55
+
56
+ # ─── Model ───────────────────────────────────────────────────────────────────
57
+
58
+ _LLM = None
59
+ _LLM_LOCK = threading.Lock()
60
+
61
+
62
+ def _load_llm():
63
+ global _LLM
64
+ if _LLM is not None:
65
+ return _LLM
66
+ with _LLM_LOCK:
67
+ if _LLM is not None:
68
+ return _LLM
69
+ import torch
70
+ from transformers import AutoModelForCausalLM, AutoTokenizer
71
+ logger.info("Loading MiniCPM: %s", MODEL_NAME)
72
+ model = AutoModelForCausalLM.from_pretrained(
73
+ MODEL_NAME, trust_remote_code=True,
74
+ torch_dtype=torch.float16, device_map="auto", attn_implementation="sdpa",
75
+ )
76
+ model.eval()
77
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True)
78
+ _LLM = (model, tokenizer)
79
+ logger.info("MiniCPM loaded")
80
+ return _LLM
81
+
82
+
83
+ def _generate_with_llm(context: GenerationContext, cast_member: CastMember, local_now: str) -> GeneratedTransmission:
84
+ try:
85
+ model, tokenizer = _load_llm()
86
+ prompt = build_prompt(context, cast_member)
87
+ system_prompt = get_system_prompt(context.persona.timeline_divergence_score)
88
+ full = f"{system_prompt}\n\n{prompt}\n\nLocal open time: {local_now}"
89
+ import torch
90
+ messages = [
91
+ {"role": "system", "content": system_prompt},
92
+ {"role": "user", "content": full},
93
+ ]
94
+ input_text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
95
+ inputs = tokenizer(input_text, return_tensors="pt").to(model.device)
96
+ with torch.no_grad():
97
+ outputs = model.generate(
98
+ **inputs, max_new_tokens=700, temperature=0.8, top_p=0.9,
99
+ do_sample=True, pad_token_id=tokenizer.pad_token_id or tokenizer.eos_token_id,
100
+ )
101
+ decoded = tokenizer.decode(outputs[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True).strip()
102
+ parsed = parse_transmission(decoded)
103
+ if parsed:
104
+ return parsed
105
+ except Exception as exc:
106
+ logger.warning("LLM failed: %s", exc)
107
+ return fallback_transmission(context, cast_member)
108
+
109
+
110
+ # ─── State ───────────────────────────────────────────────────────────────────
111
+
112
+ @dataclass
113
+ class AppState:
114
+ persona: Optional[PersonaContext] = None
115
+ onboarded: bool = False
116
+ onboard_step: int = 0
117
+ checked_in: bool = False
118
+ check_in_word: str = ""
119
+ check_in_note: str = ""
120
+ today_cast: Optional[CastMember] = None
121
+ today_transmission: Optional[GeneratedTransmission] = None
122
+ today_audio: str = ""
123
+ generating: bool = False
124
+ generation_done: bool = False
125
+ choice_made: bool = False
126
+ recent_transmissions: list[RecentTransmission] = field(default_factory=list)
127
+ recent_choices: list[RecentChoice] = field(default_factory=list)
128
+ recent_responses: list[RecentResponse] = field(default_factory=list)
129
+ open_threads: list = field(default_factory=list)
130
+
131
+ def to_context(self) -> GenerationContext:
132
+ assert self.persona
133
+ ci = type("C", (), {"word": self.check_in_word, "note": self.check_in_note or None})() if self.checked_in else None
134
+ return GenerationContext(
135
+ persona=self.persona, check_in=ci,
136
+ recent_transmissions=self.recent_transmissions,
137
+ recent_choices=self.recent_choices,
138
+ recent_responses=self.recent_responses,
139
+ open_threads=self.open_threads,
140
+ )
141
+
142
+ def streak(self) -> int:
143
+ return self.persona.streak if self.persona else 0
144
+
145
+ def divergence(self) -> int:
146
+ return self.persona.timeline_divergence_score if self.persona else 0
147
+
148
+ def to_dict(self) -> dict:
149
+ d = asdict(self)
150
+ d["persona"] = asdict(self.persona) if self.persona else None
151
+ return d
152
+
153
+ @staticmethod
154
+ def from_dict(d: dict | None) -> AppState:
155
+ if not d:
156
+ return AppState()
157
+ d = {k: v for k, v in d.items() if k in AppState.__dataclass_fields__}
158
+ if d.get("persona"):
159
+ d["persona"] = PersonaContext(**{
160
+ k: v for k, v in d["persona"].items()
161
+ if k in PersonaContext.__dataclass_fields__
162
+ })
163
+ if d.get("recent_transmissions"):
164
+ d["recent_transmissions"] = [
165
+ RecentTransmission(**t) for t in d["recent_transmissions"]
166
+ ]
167
+ if d.get("recent_choices"):
168
+ d["recent_choices"] = [
169
+ RecentChoice(**c) for c in d["recent_choices"]
170
+ ]
171
+ if d.get("recent_responses"):
172
+ d["recent_responses"] = [
173
+ RecentResponse(**r) for r in d["recent_responses"]
174
+ ]
175
+ return AppState(**d)
176
+
177
+
178
+ # ─── Choose cast member ──────────────────────────────────────────────────────
179
+
180
+ def _choose_cast(state: AppState) -> CastMember:
181
+ import random
182
+ if not state.recent_transmissions:
183
+ return "future_self"
184
+ recent = {t.cast_member for t in state.recent_transmissions[-3:]}
185
+ available = [c for c in CAST_MEMBER_NAMES if c not in recent]
186
+ if not available:
187
+ return random.choice(["future_self", "future_partner", "future_mentor"])
188
+ return random.choices(available, weights=[3 if c == "future_self" else 2 for c in available], k=1)[0]
189
+
190
+
191
+ def _constellation(state: AppState) -> list[tuple[str, str, str, str]]:
192
+ """Return list of (cast_member, label, state, hint) for grid display."""
193
+ s = state.streak()
194
+ d = state.divergence()
195
+ p = state.persona
196
+ results = []
197
+ for cm, (label, hint) in CAST_MEMBER_NAMES.items():
198
+ if cm == "future_self":
199
+ results.append((cm, label, "lit", hint))
200
+ elif cm == "future_partner" and p and p.primary_arc == "love":
201
+ results.append((cm, label, "lit" if d < 4 else "dim", hint))
202
+ elif cm == "future_mentor" and s >= 7:
203
+ results.append((cm, label, "lit", hint))
204
+ elif cm == "future_best_friend" and s >= 3:
205
+ results.append((cm, label, "lit", hint))
206
+ elif cm == "shadow" and d >= 4:
207
+ results.append((cm, label, "dim", hint))
208
+ elif cm == "alternate_self" and s >= 14:
209
+ results.append((cm, label, "dim", hint))
210
+ else:
211
+ results.append((cm, label, "locked", hint))
212
+ return results
213
+
214
+
215
+ # ─── CSS ─────────────────────────────────────────────────────────────────────
216
+
217
+ CSS = """
218
+ :root{--primary:#c4842d;--primary-dark:#a06820;--bg:#0c0c18;--surface:#16162a;--surface2:#1e1e38;--text:#e0dcd0;--text-muted:#9e9488;--border:#2a2a3e;--green:#4caf50;--purple:#6a5acd;}
219
+ body{background:var(--bg);color:var(--text);font-family:'Inter',sans-serif;overflow-x:hidden;}
220
+ ::selection{background:#c4842d40;color:#fff;}
221
+ .gr-box{border-radius:12px!important;border:1px solid var(--border)!important;}
222
+ .gr-button{border-radius:8px!important;font-weight:600!important;transition:all .25s cubic-bezier(.4,0,.2,1)!important;}
223
+ .gr-button:hover{transform:translateY(-1px);filter:brightness(1.1);}
224
+ .gr-button-primary{background:linear-gradient(135deg,#c4842d,#a06820)!important;border:none!important;color:#fff!important;position:relative;overflow:hidden;}
225
+ .gr-button-primary::after{content:'';position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,.1),transparent);transform:translateX(-100%);transition:transform .6s;}
226
+ .gr-button-primary:hover::after{transform:translateX(100%);}
227
+ .gr-button-secondary{background:var(--surface)!important;border:1px solid var(--border)!important;color:var(--text)!important;}
228
+ .gr-button-secondary:hover{background:var(--surface2)!important;}
229
+ .gr-input,.gr-textarea{background:var(--surface)!important;border:1px solid var(--border)!important;color:var(--text)!important;border-radius:8px!important;transition:border-color .3s,box-shadow .3s!important;}
230
+ .gr-input:focus,.gr-textarea:focus{border-color:var(--primary)!important;box-shadow:0 0 0 3px #c4842d25!important;}
231
+ .gradio-container{max-width:680px!important;margin:0 auto;padding:20px!important;}
232
+ .tab-nav{background:var(--surface)!important;border:1px solid var(--border)!important;border-radius:8px!important;margin-bottom:16px!important;}
233
+ .tab-nav button{color:var(--text-muted)!important;transition:color .3s!important;}
234
+ .tab-nav button.selected{color:var(--primary)!important;border-bottom-color:var(--primary)!important;}
235
+ h1,h2,h3{font-family:'Inter',sans-serif;letter-spacing:-0.02em;}
236
+ label{color:var(--text)!important;font-weight:500!important;}
237
+ .radio-group{background:var(--surface);border-radius:8px;padding:8px;border:1px solid var(--border);}
238
+ footer{display:none!important}
239
+ /* Privacy chips */
240
+ .privacy-chip{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;border-radius:20px;font-size:0.7em;background:#1a3a1a;border:1px solid #2a5a2a;color:#7ccc7c;margin-bottom:10px;animation:fadeInUp .5s ease both;}
241
+ .privacy-chip:nth-child(2){animation-delay:.1s;}
242
+ .privacy-chip:nth-child(3){animation-delay:.2s;}
243
+ .privacy-chip:nth-child(4){animation-delay:.3s;}
244
+ .privacy-chip.warning{background:#3a2a1a;border-color:#5a4a2a;color:#ccc47c;}
245
+ .privacy-chip:hover{border-color:#7ccc7c60;box-shadow:0 0 12px #7ccc7c20;}
246
+ /* Step bar */
247
+ .step-bar{display:flex;gap:0;margin:16px 0;padding:0;list-style:none;overflow:hidden;border-radius:8px;background:var(--surface);border:1px solid var(--border);}
248
+ .step-bar li{flex:1;text-align:center;padding:10px 4px;font-size:0.72em;color:var(--text-muted);position:relative;transition:all .4s cubic-bezier(.4,0,.2,1);}
249
+ .step-bar li.active{color:var(--primary);font-weight:600;}
250
+ .step-bar li.active::after{content:'';position:absolute;bottom:0;left:10%;width:80%;height:2px;background:linear-gradient(90deg,var(--primary),#e0dcd0);border-radius:1px;animation:slideIn .4s ease;}
251
+ .step-bar li.done{color:var(--green);}
252
+ .step-bar li:not(.done):not(.active){opacity:0.5;}
253
+ /* Constellation */
254
+ .constellation{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:12px 0;}
255
+ .constellation-item{border-radius:10px;padding:10px;text-align:center;border:1px solid var(--border);background:var(--surface);transition:all .35s cubic-bezier(.4,0,.2,1);animation:fadeInUp .5s ease both;cursor:default;}
256
+ .constellation-item:nth-child(2){animation-delay:.05s;}
257
+ .constellation-item:nth-child(3){animation-delay:.1s;}
258
+ .constellation-item:nth-child(4){animation-delay:.15s;}
259
+ .constellation-item:nth-child(5){animation-delay:.2s;}
260
+ .constellation-item:nth-child(6){animation-delay:.25s;}
261
+ .constellation-item:hover{transform:translateY(-2px);border-color:var(--primary)60;}
262
+ .constellation-item.lit{border-color:#c4842d40;background:linear-gradient(135deg,#1a1a2e,#2a1a0e);}
263
+ .constellation-item.dim{border-color:#6a5acd40;background:linear-gradient(135deg,#1a1a2e,#1e0e2e);}
264
+ .constellation-item.locked{opacity:0.4;filter:grayscale(.6);}
265
+ .constellation-item .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-bottom:4px;}
266
+ .dot-lit{background:var(--primary);box-shadow:0 0 10px #c4842d60;animation:glow 2s ease-in-out infinite;}
267
+ .dot-dim{background:var(--purple);box-shadow:0 0 10px #6a5acd60;animation:glow 3s ease-in-out infinite;}
268
+ .dot-locked{background:var(--border);}
269
+ .constellation-item .name{font-size:0.78em;font-weight:600;color:var(--text);}
270
+ .constellation-item .hint{font-size:0.6em;color:var(--text-muted);margin-top:1px;}
271
+ /* Audio player */
272
+ audio{width:100%;margin:8px 0;border-radius:8px;animation:fadeInUp .5s ease;}
273
+ audio::-webkit-media-controls-panel{background:var(--surface);}
274
+ /* Transmission card border glow */
275
+ .glow-card{position:relative;border-radius:12px;overflow:hidden;}
276
+ .glow-card::before{content:'';position:absolute;inset:-2px;border-radius:14px;background:linear-gradient(60deg,transparent,var(--primary)40,transparent,var(--primary)20,transparent);background-size:300% 300%;animation:borderGlow 4s ease-in-out infinite;z-index:0;}
277
+ .glow-card > div{position:relative;z-index:1;background:var(--surface);margin:2px;border-radius:10px;padding:16px 20px;}
278
+ /* Animations */
279
+ @keyframes fadeInUp{from{opacity:0;transform:translateY(12px);}to{opacity:1;transform:translateY(0);}}
280
+ @keyframes slideIn{from{width:0;left:50%;}to{width:80%;left:10%;}}
281
+ @keyframes pulse{0%,100%{opacity:.6;}50%{opacity:1;}}
282
+ @keyframes glow{0%,100%{opacity:.6;transform:scale(1);}50%{opacity:1;transform:scale(1.3);}}
283
+ @keyframes borderGlow{0%,100%{background-position:0% 50%;}50%{background-position:100% 50%;}}
284
+ @keyframes shimmer{0%{transform:translateX(-100%);}100%{transform:translateX(100%);}}
285
+ .pulse{animation:pulse 1.5s ease-in-out infinite;}
286
+ .fade-in{animation:fadeInUp .6s ease both;}
287
+ .shimmer{position:relative;overflow:hidden;}
288
+ .shimmer::after{content:'';position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,.03),transparent);animation:shimmer 2s infinite;}
289
+ """
290
+
291
+ # ─── Render helpers ──────────────────────────────────────────────────────────
292
+
293
+ def _header() -> str:
294
+ return f"""<div style="text-align:center;padding:8px 0 4px;">
295
+ <h1 style="font-size:2em;font-weight:700;margin:0;background:linear-gradient(135deg,#e0dcd0,#c4842d,#a06820);
296
+ -webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;">
297
+ ✦ FutureSelves
298
+ </h1>
299
+ <p style="color:var(--text-muted);margin:2px 0 8px;font-size:0.85em;">Your future self is listening.</p>
300
+ <div style="display:flex;justify-content:center;gap:8px;flex-wrap:wrap;margin-bottom:4px;">
301
+ <span class="privacy-chip">🔒 100% on-device</span>
302
+ <span class="privacy-chip">📡 0 bytes uploaded</span>
303
+ <span class="privacy-chip">🧠 3.1B total params</span>
304
+ <span class="privacy-chip warning">⚡ LLM + Extraction + TTS</span>
305
+ </div>
306
+ </div>"""
307
+
308
+
309
+ def _card(title: str, body: str, accent: str = "#c4842d") -> str:
310
+ return f"""<div style="background:var(--surface);border:1px solid {accent}40;border-radius:12px;padding:16px 20px;margin:8px 0;">
311
+ <h3 style="color:{accent};margin:0 0 6px;font-size:1em;">{title}</h3>
312
+ <div style="color:var(--text);line-height:1.6;white-space:pre-wrap;">{body}</div>
313
+ </div>"""
314
+
315
+
316
+ def _stats_row(state: AppState) -> str:
317
+ return f"""<div style="display:flex;gap:12px;margin:12px 0;">
318
+ <div style="flex:1;background:var(--surface);border-radius:12px;padding:12px;text-align:center;border:1px solid var(--border);">
319
+ <div style="color:var(--primary);font-size:1.6em;font-weight:700;">{state.streak()}</div>
320
+ <div style="color:var(--text-muted);font-size:0.8em;">day streak</div>
321
+ </div>
322
+ <div style="flex:1;background:var(--surface);border-radius:12px;padding:12px;text-align:center;border:1px solid var(--border);">
323
+ <div style="color:var(--primary);font-size:1.6em;font-weight:700;">{state.divergence()}</div>
324
+ <div style="color:var(--text-muted);font-size:0.8em;">divergence</div>
325
+ </div>
326
+ <div style="flex:1;background:var(--surface);border-radius:12px;padding:12px;text-align:center;border:1px solid var(--border);">
327
+ <div style="color:var(--primary);font-size:1.6em;font-weight:700;">{len(state.recent_choices)}</div>
328
+ <div style="color:var(--text-muted);font-size:0.8em;">choices made</div>
329
+ </div>
330
+ </div>"""
331
+
332
+
333
+ _STEPS = ["✎ Onboard", "☀ Check-in", "📡 Generate", "🎯 Choose", "💬 React"]
334
+
335
+
336
+ def _step_indicator(current: int) -> str:
337
+ items = []
338
+ for i, label in enumerate(_STEPS):
339
+ cls = "done" if i < current else "active" if i == current else ""
340
+ items.append(f'<li class="{cls}">{label}</li>')
341
+ return f'<ul class="step-bar">{"".join(items)}</ul>'
342
+
343
+
344
+ def _render_constellation(state: AppState) -> str:
345
+ stars = _constellation(state)
346
+ items = []
347
+ for cm, label, st, hint in stars:
348
+ items.append(f"""<div class="constellation-item {st}">
349
+ <div class="dot dot-{st}"></div>
350
+ <div class="name">{label}</div>
351
+ <div class="hint">{hint}</div>
352
+ </div>""")
353
+ return f"""<h3 style="color:var(--primary);font-size:0.9em;margin:16px 0 4px;">✦ Your constellation</h3>
354
+ <div class="constellation">{"".join(items)}</div>"""
355
+
356
+
357
+ # ─── App logic (async gen helper) ─────────────────────────────────────────────
358
+
359
+ def _gen_async(state: AppState, context: GenerationContext, cm: CastMember, now_str: str):
360
+ result = _generate_with_llm(context, cm, now_str)
361
+ state.today_transmission = result
362
+ audio = generate_speech(result.text, voice=get_voice_for_cast_member(cm))
363
+ state.today_audio = audio or ""
364
+ state.generating = False
365
+ state.generation_done = True
366
+
367
+
368
+ # ─── Renderers ───────────────────────────────────────────────────────────────
369
+
370
+ def _render_home(state: AppState) -> str:
371
+ if not state.onboarded:
372
+ return _header() + _card("Welcome", "Complete onboarding to begin receiving transmissions.", "#6a5acd")
373
+ body = _header() + _step_indicator(1) + _stats_row(state)
374
+ body += _render_constellation(state)
375
+ p = state.persona
376
+ body += _card("Today's signal", f"Ready when you are, {p.name}. Check in with one word to tune the line.", "#c4842d")
377
+ if state.recent_transmissions:
378
+ last = state.recent_transmissions[-1]
379
+ body += _card("Last transmission", f'<em>"{last.title}"</em> — {last.date_key}', "#6a5acd")
380
+ return body
381
+
382
+
383
+ def _render_awaiting(state: AppState) -> str:
384
+ body = _header() + _step_indicator(2) + _stats_row(state)
385
+ body += _card("✓ Checked in", f'Word: <strong>"{state.check_in_word}"</strong>', "#4caf50")
386
+ if state.check_in_note:
387
+ body += _card("Note", state.check_in_note, "#6a5acd")
388
+ body += '<div style="text-align:center;padding:8px 0;color:var(--text-muted);">Ready to receive your transmission?</div>'
389
+ return body
390
+
391
+
392
+ def _render_generating(cast: CastMember) -> str:
393
+ label = CAST_MEMBER_NAMES.get(cast, ["", ""])[0] or cast
394
+ return _header() + _step_indicator(2) + _card("📡 Tuning the signal", f"<em>{label}</em> is reaching across time...", "#c4842d") + """
395
+ <div style="text-align:center;padding:24px 0;">
396
+ <div style="display:inline-block;width:40px;height:40px;border:3px solid #c4842d40;border-top-color:#c4842d;border-radius:50%;animation:s 1s linear infinite;"></div>
397
+ <p style="color:var(--text-muted);margin-top:12px;" class="pulse">The line is opening. Stand by.</p>
398
+ </div>
399
+ <style>@keyframes s{to{transform:rotate(360deg)}}</style>"""
400
+
401
+
402
+ def _render_transmission(state: AppState) -> str:
403
+ t = state.today_transmission
404
+ if not t:
405
+ return _render_home(state)
406
+ label = CAST_MEMBER_NAMES.get(state.today_cast or "future_self", ["", ""])[0] or "Future Self"
407
+ body = _header() + _step_indicator(3) + _stats_row(state)
408
+ body += _card(f"📡 {label}", f"<em>{t.title}</em>", "#c4842d")
409
+ # Audio player
410
+ if state.today_audio:
411
+ body += f"""<div style="background:var(--surface);border:1px solid var(--primary)40;border-radius:12px;padding:12px 16px;margin:8px 0;">
412
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
413
+ <span style="font-size:1.2em;">🔊</span>
414
+ <span style="color:var(--primary);font-weight:600;font-size:0.85em;">Voice transmission</span>
415
+ <span style="color:var(--text-muted);font-size:0.75em;">from {label}</span>
416
+ </div>
417
+ <audio controls autoplay><source src="/file={state.today_audio}" type="audio/wav"></audio>
418
+ </div>"""
419
+ body += f"""<div class="glow-card fade-in"><div>
420
+ <h3 style="color:var(--text);margin:0 0 6px;font-size:1em;">Transmission</h3>
421
+ <div style="color:#e0dcd0;line-height:1.7;white-space:pre-wrap;font-size:1.05em;">{t.text}</div>
422
+ </div></div>"""
423
+ body += _card("🎯 Tonight's move", t.action_prompt, "#4caf50")
424
+ body += _card("🔮 Tomorrow", t.cliffhanger, "#6a5acd")
425
+ return body
426
+
427
+
428
+ def _render_choice_result(state: AppState) -> str:
429
+ c = state.today_choice or ""
430
+ labels = {"toward": "You moved toward what matters.", "steady": "You held your ground.", "release": "You let something go.", "repair": "You mended a frayed thread."}
431
+ body = _header() + _step_indicator(4) + _stats_row(state)
432
+ body += _card("✓ Choice recorded", labels.get(c, ""), "#4caf50")
433
+ body += '<div style="text-align:center;padding:8px 0;color:var(--text-muted);">The timeline shifts. How did the transmission land?</div>'
434
+ return body
435
+
436
+
437
+ def _render_history(state: AppState) -> str:
438
+ body = _header()
439
+ if not state.recent_choices and not state.recent_transmissions:
440
+ return body + '<p style="color:var(--text-muted);">No history yet. Start your journey on the Today tab.</p>'
441
+ body += _render_constellation(state)
442
+ if state.recent_choices:
443
+ body += "<h3 style='color:var(--primary);font-size:0.9em;margin-top:16px;'>Recent choices</h3>"
444
+ for c in reversed(state.recent_choices[-5:]):
445
+ body += f"""<div style="display:flex;justify-content:space-between;padding:8px 12px;background:var(--surface);border-radius:8px;margin:4px 0;border:1px solid var(--border);">
446
+ <span>{c.choice}</span><span style="color:var(--text-muted);font-size:0.85em;">{c.date_key}</span></div>"""
447
+ if state.recent_transmissions:
448
+ body += "<h3 style='color:var(--primary);font-size:0.9em;margin-top:16px;'>Transmissions received</h3>"
449
+ for t in reversed(state.recent_transmissions[-5:]):
450
+ body += f"""<div style="padding:8px 12px;background:var(--surface);border-radius:8px;margin:4px 0;border:1px solid var(--border);">
451
+ <div><strong>"{t.title}"</strong></div>
452
+ <div style="color:var(--text-muted);font-size:0.85em;">{t.date_key} · {CAST_MEMBER_NAMES.get(t.cast_member, ["", ""])[0] or t.cast_member}</div></div>"""
453
+ return body
454
+
455
+
456
+ # ─── Build Gradio UI ─────────────────────────────────────────────────────────
457
+
458
+
459
+ def create_app():
460
+ # Off Brand: typewriter effect on transmission text
461
+ js_code = """
462
+ function startTypewriter() {
463
+ const el = document.querySelector('.typewriter');
464
+ if (!el || el.dataset.typed) return;
465
+ el.dataset.typed = '1';
466
+ const text = el.textContent;
467
+ el.textContent = '';
468
+ el.style.visibility = 'visible';
469
+ let i = 0;
470
+ function type() {
471
+ if (i < text.length) {
472
+ el.textContent += text.charAt(i);
473
+ i++;
474
+ setTimeout(type, 6 + Math.random() * 12);
475
+ }
476
+ }
477
+ type();
478
+ }
479
+ setInterval(startTypewriter, 500);
480
+ startTypewriter();
481
+ """
482
+ with gr.Blocks(css=CSS, theme=gr.themes.Soft(primary_hue="amber", neutral_hue="stone", font=["Inter", "system-ui", "sans-serif"]), title="FutureSelves", head=f"<script>{js_code}</script>") as demo:
483
+ browser_state = gr.BrowserState(None)
484
+ state = gr.State(init_state())
485
+
486
+ demo.load(fn=lambda d: AppState.from_dict(d), inputs=[browser_state], outputs=[state])
487
+
488
+ with gr.Tabs(elem_classes="tab-nav"):
489
+ # ── Today tab ───────────────────────���──────────────────────
490
+ with gr.Tab("Today"):
491
+ content = gr.HTML(
492
+ _header() + _card("Welcome", "Complete onboarding below to begin.", "#6a5acd")
493
+ )
494
+
495
+ with gr.Column(visible=True) as onboard_col:
496
+ with gr.Column(visible=True) as step1_col:
497
+ gr.Markdown("### ✎ Step 1: Who are you?")
498
+ oname = gr.Textbox(label="Your name", placeholder="What do you go by?")
499
+ octiy = gr.Textbox(label="Your city", placeholder="Where are you right now?")
500
+ step1_btn = gr.Button("Next →", variant="primary")
501
+
502
+ with gr.Column(visible=False) as step2_col:
503
+ gr.Markdown("### ✎ Step 2: Your chapter")
504
+ ochapter = gr.Textbox(label="Current life chapter", lines=2, placeholder="e.g. rebuilding after a move, mid-career pivot...")
505
+ oarc = gr.Radio(["money", "love", "purpose", "health"], label="Primary arc", value="purpose")
506
+ step2_btn = gr.Button("Next →", variant="primary")
507
+
508
+ with gr.Column(visible=False) as step3_col:
509
+ gr.Markdown("### ✎ Step 3: What's alive in you?")
510
+ with gr.Row():
511
+ oavoid = gr.Textbox(label="Avoiding", lines=2, scale=1, placeholder="What you keep circling?")
512
+ ofraid = gr.Textbox(label="Afraid won't happen", lines=2, scale=1)
513
+ with gr.Row():
514
+ odrain = gr.Textbox(label="Draining you", lines=2, scale=1)
515
+ omira = gr.Textbox(label="Miraculous year", lines=2, scale=1)
516
+ step3_btn = gr.Button("Begin", variant="primary")
517
+
518
+ _onboard_step1 = lambda n, c, s: (setattr(s, 'persona', PersonaContext(name=n.strip(), city=c.strip(), selected_voice_name="Ember", selected_voice_description="warm, intimate, certain")), setattr(s, 'onboard_step', 1), s)[2]
519
+ _onboard_step2 = lambda ch, a, s: (setattr(s.persona, 'current_chapter', ch.strip()) if s.persona else None, setattr(s.persona, 'primary_arc', a) if s.persona else None, setattr(s, 'onboard_step', 2), s)[3]
520
+ _onboard_step3 = lambda av, af, dr, mi, s: (setattr(s.persona, 'avoiding', av.strip()) if s.persona else None, setattr(s.persona, 'afraid_wont_happen', af.strip()) if s.persona else None, setattr(s.persona, 'draining', dr.strip()) if s.persona else None, setattr(s.persona, 'miraculous_year', mi.strip()) if s.persona else None, setattr(s, 'onboarded', True), setattr(s, 'onboard_step', 3), _render_home(s), s.to_dict(), s)
521
+
522
+ step1_btn.click(fn=_onboard_step1, inputs=[oname, octiy, state], outputs=[state]).then(
523
+ fn=lambda: (gr.Column(visible=False), gr.Column(visible=True)), outputs=[step1_col, step2_col])
524
+ step2_btn.click(fn=_onboard_step2, inputs=[ochapter, oarc, state], outputs=[state]).then(
525
+ fn=lambda: (gr.Column(visible=False), gr.Column(visible=True)), outputs=[step2_col, step3_col])
526
+ step3_btn.click(fn=_onboard_step3, inputs=[oavoid, ofraid, odrain, omira, state], outputs=[content, browser_state, state]).then(
527
+ fn=lambda: gr.Column(visible=False), outputs=[onboard_col])
528
+
529
+ with gr.Accordion("☀ Check in", open=False) as checkin_acc:
530
+ word = gr.Textbox(label="One word", max_lines=1, placeholder="exhausted, hopeful, restless...")
531
+ note = gr.Textbox(label="Note", lines=2, placeholder="What's alive in you?")
532
+ checkin_btn = gr.Button("Tune the signal", variant="primary")
533
+
534
+ with gr.Accordion("📡 Receive transmission", open=False) as receive_acc:
535
+ generate_btn = gr.Button("Open the line", variant="primary")
536
+
537
+ with gr.Accordion("🎯 Your move", open=False) as choice_acc:
538
+ choice = gr.Radio(
539
+ [("🚀 Toward", "toward"), ("🌱 Steady", "steady"), ("🕊️ Release", "release"), ("🪡 Repair", "repair")],
540
+ label="Choose your move", type="value")
541
+ choice_btn = gr.Button("Record choice", variant="primary")
542
+
543
+ with gr.Accordion("💬 Reaction", open=False) as reaction_acc:
544
+ reaction = gr.Radio(
545
+ [("✅ Did it", "did_it"), ("💭 Keep close", "keep_close"), ("🎯 Landed", "landed"), ("🔄 Not quite", "not_quite")],
546
+ label="How did it land?", type="value")
547
+ reply_note = gr.Textbox(label="Write back", lines=2, placeholder="A reply...")
548
+ react_btn = gr.Button("Send", variant="primary")
549
+
550
+ # Wire check-in
551
+ checkin_btn.click(
552
+ fn=lambda w, n, s: (_render_awaiting(s), s.to_dict(), s) if (setattr(s, 'check_in_word', w.strip()[:40]), setattr(s, 'check_in_note', n.strip() if n.strip() else ''), setattr(s, 'checked_in', True)) else (None, None, None),
553
+ inputs=[word, note, state], outputs=[content, browser_state, state],
554
+ ).then(fn=lambda: (gr.Accordion(open=False), gr.Accordion(open=True)), outputs=[checkin_acc, receive_acc])
555
+
556
+ # Wire generate
557
+ generate_btn.click(
558
+ fn=lambda s: (_render_generating(s.today_cast or "future_self"), s.to_dict(), s) if (setattr(s, 'generating', True), setattr(s, 'generation_done', False), setattr(s, 'today_audio', ''), setattr(s, 'today_cast', _choose_cast(s)), threading.Thread(target=_gen_async, args=(s, s.to_context(), s.today_cast, date.today().strftime("%Y-%m-%d %H:%M")), daemon=True).start()) else (None, None, None),
559
+ inputs=[state], outputs=[content, browser_state, state],
560
+ ).then(fn=lambda: gr.Accordion(open=False), outputs=[receive_acc])
561
+
562
+ # Poll for generation
563
+ demo.load(
564
+ fn=lambda s: (_render_transmission(s), s.to_dict(), s, gr.Accordion(visible=True)) if (s.generation_done and s.today_transmission and not setattr(s, 'generating', False)) else (_render_generating(s.today_cast or "future_self"), s.to_dict(), s, gr.Accordion(visible=False)) if s.generating else (None, None, None, None),
565
+ inputs=[state], outputs=[content, browser_state, state, choice_acc], every=2)
566
+
567
+ # Wire choice
568
+ choice_btn.click(
569
+ fn=lambda c, s: (_render_choice_result(s), s.to_dict(), s) if (
570
+ setattr(s, 'recent_transmissions', s.recent_transmissions + [RecentTransmission(date_key=date.today().isoformat(), title=(s.today_transmission.title if s.today_transmission else ""), cliffhanger=(s.today_transmission.cliffhanger if s.today_transmission else ""), cast_member=s.today_cast or "future_self")]),
571
+ setattr(s, 'recent_choices', s.recent_choices + [RecentChoice(date_key=date.today().isoformat(), choice=c, prompt=(s.today_transmission.action_prompt if s.today_transmission else ""))]),
572
+ s.persona and (setattr(s.persona, 'streak', s.persona.streak + 1) or setattr(s.persona, f'{c}_count', getattr(s.persona, f'{c}_count', 0) + 1)),
573
+ setattr(s, 'choice_made', True),
574
+ ) else (None, None, None),
575
+ inputs=[choice, state], outputs=[content, browser_state, state],
576
+ ).then(fn=lambda: (gr.Accordion(open=False), gr.Accordion(open=True)), outputs=[choice_acc, reaction_acc])
577
+
578
+ # Wire reaction
579
+ react_btn.click(
580
+ fn=lambda r, rn, s: (_render_home(s), s.to_dict(), s) if (
581
+ setattr(s, 'recent_responses', s.recent_responses + [RecentResponse(reaction=r if r else None, reply_note=rn.strip() if rn.strip() else None)]),
582
+ setattr(s, 'checked_in', False), setattr(s, 'generation_done', False), setattr(s, 'choice_made', False),
583
+ setattr(s, 'today_transmission', None), setattr(s, 'today_audio', ""), setattr(s, 'check_in_word', ""), setattr(s, 'check_in_note', ""),
584
+ ) else (None, None, None),
585
+ inputs=[reaction, reply_note, state], outputs=[content, browser_state, state],
586
+ ).then(fn=lambda: (gr.Accordion(open=False), gr.Accordion(open=True)), outputs=[reaction_acc, checkin_acc])
587
+
588
+ # ── History tab ────────────────────────────────────────────
589
+ with gr.Tab("History"):
590
+ history = gr.HTML("")
591
+ refresh = gr.Button("Refresh")
592
+ refresh.click(fn=lambda s: _render_history(s), inputs=[state], outputs=[history])
593
+
594
+ # ── Architecture tab ───────────────────────────────────────
595
+ with gr.Tab("Architecture"):
596
+ gr.Markdown("""
597
+ ### Pipeline architecture
598
+
599
+ ```
600
+ ┌──────────────────────────────────────────────────────────────┐
601
+ │ FutureSelves · 3 models · 3.1B params │
602
+ ├──────────────────────────────────────────────────────────────┤
603
+ │ │
604
+ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
605
+ │ │ Nemotron │ │ MiniCPM 2.5 │ │ Kokoro 82M │ │
606
+ │ │ Parse (<1B) │───▶│ (~2.5B) │───▶│ TTS │ │
607
+ │ │ NVIDIA │ │ OpenBMB │ │ (on-device) │ │
608
+ │ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
609
+ │ │ │ │ │
610
+ │ ▼ ▼ ▼ │
611
+ │ Extract emotions Generate narrative Synthesize │
612
+ │ + themes from transmission with speech from │
613
+ │ check-in note continuity + memory transmission │
614
+ │ │
615
+ │ All inference · Zero uploads │
616
+ └──────────────────────────────────────────────────────────────┘
617
+ ```
618
+
619
+ **Prize targets:** Backyard AI, OpenBMB, NVIDIA Nemotron, Tiny Titan, Best Agent, Off Brand, Best Demo, Bonus Quest Champion
620
+
621
+ [Source](https://github.com/udingethe/futureselves/tree/main/hf-space)
622
+ """)
623
+
624
+ return demo
625
+
626
+
627
+ def init_state() -> AppState:
628
+ return AppState()
629
+
630
+
631
+ def main():
632
+ demo = create_app()
633
+ demo.launch()
634
+
635
+ if __name__ == "__main__":
636
+ main()
parse_notes.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ parse_notes.py — Nemotron Parse wrapper for structured extraction
3
+ from check-in notes.
4
+
5
+ Uses NVIDIA's Nemotron-Parse (<1B params) to extract emotions,
6
+ themes, entities, and sentiment from the player's daily check-in
7
+ note. This unlocks the NVIDIA Nemotron sponsor prize.
8
+
9
+ Because this runs in the same HF Space as MiniCPM (the main LLM),
10
+ we load Nemotron-Parse as a secondary model for structured extraction
11
+ only — not for generation. The model is tiny enough (<1B) that it
12
+ adds minimal GPU memory pressure alongside MiniCPM 2.5B.
13
+
14
+ Usage:
15
+ from parse_notes import extract_note_insights
16
+ insights = extract_note_insights("I'm exhausted from overworking")
17
+ # -> { "sentiment": "negative", "emotions": ["exhaustion"],
18
+ # "themes": ["burnout", "work"], "entities": [] }
19
+
20
+ HF Space env config:
21
+ Set NEMOTRON_PARSE_MODEL=nvidia/Nemotron-Parse-H-Base-v1
22
+ (or omit for default)
23
+
24
+ See: https://huggingface.co/nvidia/Nemotron-Parse-H-Base-v1
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import logging
31
+ import os
32
+ from dataclasses import dataclass, field
33
+ from typing import Optional
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # ─── Types ────────────────────────────────────────────────────────────────────
38
+
39
+
40
+ @dataclass
41
+ class NoteInsights:
42
+ sentiment: str # positive | negative | neutral | mixed
43
+ emotions: list[str] = field(default_factory=list)
44
+ themes: list[str] = field(default_factory=list)
45
+ entities: list[str] = field(default_factory=list)
46
+ intensity: float = 0.0 # 0.0 to 1.0
47
+
48
+
49
+ # ─── Extraction via Nemotron-Parse ────────────────────────────────────────────
50
+
51
+ DEFAULT_MODEL = "nvidia/Nemotron-Parse-H-Base-v1"
52
+
53
+ _PIPELINE = None
54
+
55
+
56
+ def _get_pipeline():
57
+ global _PIPELINE
58
+ if _PIPELINE is None:
59
+ import torch
60
+ from transformers import AutoModelForCausalLM, AutoTokenizer
61
+
62
+ model_name = os.environ.get(
63
+ "NEMOTRON_PARSE_MODEL", DEFAULT_MODEL
64
+ )
65
+ logger.info("Loading Nemotron-Parse: %s", model_name)
66
+ tokenizer = AutoTokenizer.from_pretrained(
67
+ model_name, trust_remote_code=True
68
+ )
69
+ model = AutoModelForCausalLM.from_pretrained(
70
+ model_name,
71
+ trust_remote_code=True,
72
+ torch_dtype=torch.float16,
73
+ device_map="auto",
74
+ )
75
+ _PIPELINE = {"model": model, "tokenizer": tokenizer}
76
+ logger.info("Nemotron-Parse loaded")
77
+ return _PIPELINE["model"], _PIPELINE["tokenizer"]
78
+
79
+
80
+ _EXTRACTION_PROMPT = """\
81
+ Extract structured insights from this journal note.
82
+ Return valid JSON with these fields:
83
+ - "sentiment": "positive" | "negative" | "neutral" | "mixed"
84
+ - "emotions": list of emotion words present (e.g. ["anxiety", "hope"])
85
+ - "themes": list of thematic keywords (e.g. ["work", "relationships", "health"])
86
+ - "entities": list of specific people, places, or things mentioned
87
+ - "intensity": float 0.0 to 1.0 describing emotional intensity
88
+
89
+ Note: {note}
90
+
91
+ JSON:
92
+ """
93
+
94
+
95
+ def extract_note_insights(note: str) -> Optional[NoteInsights]:
96
+ if not note or not note.strip():
97
+ return None
98
+
99
+ try:
100
+ model, tokenizer = _get_pipeline()
101
+ prompt = _EXTRACTION_PROMPT.format(note=note.strip())
102
+
103
+ inputs = tokenizer(prompt, return_tensors="pt")
104
+ outputs = model.generate(
105
+ **inputs,
106
+ max_new_tokens=128,
107
+ temperature=0.1,
108
+ do_sample=False,
109
+ )
110
+ decoded = tokenizer.decode(
111
+ outputs[0][inputs["input_ids"].shape[1]:],
112
+ skip_special_tokens=True,
113
+ ).strip()
114
+
115
+ # Strip any trailing conversational fluff
116
+ if "{" in decoded:
117
+ decoded = decoded[decoded.index("{"):decoded.rindex("}")+1]
118
+
119
+ data = json.loads(decoded)
120
+ return NoteInsights(
121
+ sentiment=data.get("sentiment", "neutral"),
122
+ emotions=data.get("emotions", []),
123
+ themes=data.get("themes", []),
124
+ entities=data.get("entities", []),
125
+ intensity=float(data.get("intensity", 0.0)),
126
+ )
127
+ except Exception as exc:
128
+ logger.warning("Nemotron-Parse extraction failed: %s", exc)
129
+ return None
130
+
131
+
132
+ # ─── Simple keyword fallback (no model needed) ───────────────────────────────
133
+
134
+
135
+ def _keyword_sentiment(note: str) -> str:
136
+ negative_words = {
137
+ "tired", "exhausted", "sad", "angry", "frustrated", "anxious",
138
+ "worried", "scared", "alone", "stuck", "overwhelmed", "burnout",
139
+ }
140
+ positive_words = {
141
+ "happy", "grateful", "hopeful", "excited", "proud", "peaceful",
142
+ "joyful", "loved", "inspired", "motivated", "alive",
143
+ }
144
+ words = set(note.lower().split())
145
+ pos = len(words & positive_words)
146
+ neg = len(words & negative_words)
147
+ if pos > neg:
148
+ return "positive"
149
+ if neg > pos:
150
+ return "negative"
151
+ if pos == 0 and neg == 0:
152
+ return "neutral"
153
+ return "mixed"
154
+
155
+
156
+ def fast_insights(note: str) -> Optional[NoteInsights]:
157
+ if not note or not note.strip():
158
+ return None
159
+ return NoteInsights(
160
+ sentiment=_keyword_sentiment(note),
161
+ emotions=list({
162
+ w for w in note.lower().split()
163
+ if w in {
164
+ "tired", "exhausted", "sad", "angry", "frustrated",
165
+ "anxious", "worried", "scared", "happy", "grateful",
166
+ "hopeful", "excited", "proud", "peaceful", "joyful",
167
+ "loved", "inspired", "motivated", "alive", "hopeful",
168
+ }
169
+ }),
170
+ themes=[],
171
+ entities=[],
172
+ intensity=0.5,
173
+ )
requirements.txt ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FutureSelves — Build Small HF Space
2
+ # Core
3
+ gradio>=5.0,<6
4
+ torch>=2.2
5
+ transformers>=4.40
6
+ sentencepiece>=0.2
7
+ accelerate>=0.28
8
+
9
+ # Models
10
+ # MiniCPM needs: pip install openbmb/MiniCPM-2.5-sft-bf16 (handled by transformers)
11
+ # Nemotron-Parse: nvidia/Nemotron-Parse-H-Base-v1 (handled by transformers)
12
+
13
+ # TTS (82M params, fully local)
14
+ kokoro>=0.7
15
+ soundfile>=0.12
16
+ numpy>=1.24
17
+
18
+ # Nemotron Parse extraction (lightweight fallback uses stdlib only)
19
+
20
+ # HF Space GPU support
21
+ ninja # faster CUDA compile
transmission.py ADDED
@@ -0,0 +1,479 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ transmission.py — Ported from apps/default/lib/local-llm.ts and
3
+ packages/backend/convex/game.transmission.ts.
4
+
5
+ Builds the futureself transmission prompt, parses LLM JSON output,
6
+ and provides built-in fallback transmissions for each cast member.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from typing import Literal, Optional
15
+
16
+ # ─── Types ────────────────────────────────────────────────────────────────────
17
+
18
+ CastMember = Literal[
19
+ "future_self", "future_best_friend", "future_mentor", "future_partner",
20
+ "future_employee", "future_customer", "future_child", "future_stranger",
21
+ "alternate_self", "shadow", "the_ceiling", "the_flatlined",
22
+ "the_resentee", "the_grandfather", "the_exhausted_winner", "the_ghost",
23
+ "the_disappointed_healer", "the_dissolver",
24
+ ]
25
+
26
+ Arc = Literal["money", "love", "purpose", "health"]
27
+ Choice = Literal["toward", "steady", "release", "repair"]
28
+ Reaction = Literal["landed", "not_quite", "did_it", "keep_close"]
29
+
30
+
31
+ @dataclass
32
+ class PersonaContext:
33
+ name: str
34
+ city: str
35
+ current_chapter: str
36
+ primary_arc: Arc
37
+ miraculous_year: str
38
+ avoiding: str
39
+ afraid_wont_happen: str
40
+ draining: str
41
+ streak: int = 0
42
+ timeline_divergence_score: int = 0
43
+ toward_count: int = 0
44
+ steady_count: int = 0
45
+ release_count: int = 0
46
+ repair_count: int = 0
47
+ selected_voice_name: str = ""
48
+ selected_voice_description: str = ""
49
+
50
+
51
+ @dataclass
52
+ class CheckIn:
53
+ word: str
54
+ note: Optional[str] = None
55
+
56
+
57
+ @dataclass
58
+ class RecentTransmission:
59
+ date_key: str
60
+ title: str
61
+ cliffhanger: str
62
+ cast_member: CastMember = "future_self"
63
+
64
+
65
+ @dataclass
66
+ class RecentChoice:
67
+ date_key: str
68
+ choice: Choice
69
+ prompt: str
70
+
71
+
72
+ @dataclass
73
+ class RecentResponse:
74
+ reaction: Optional[Reaction] = None
75
+ reply_note: Optional[str] = None
76
+
77
+
78
+ @dataclass
79
+ class OpenThread:
80
+ title: str
81
+ seed: str
82
+ cast_member: CastMember
83
+
84
+
85
+ @dataclass
86
+ class GenerationContext:
87
+ persona: PersonaContext
88
+ check_in: Optional[CheckIn] = None
89
+ recent_transmissions: list[RecentTransmission] = field(default_factory=list)
90
+ recent_choices: list[RecentChoice] = field(default_factory=list)
91
+ recent_responses: list[RecentResponse] = field(default_factory=list)
92
+ open_threads: list[OpenThread] = field(default_factory=list)
93
+
94
+
95
+ @dataclass
96
+ class GeneratedTransmission:
97
+ title: str
98
+ text: str
99
+ action_prompt: str
100
+ cliffhanger: str
101
+
102
+
103
+ # ─── Helpers ──────────────────────────────────────────────────────────────────
104
+
105
+
106
+ def get_dominant_choice(
107
+ toward: int, steady: int, release: int, repair: int
108
+ ) -> str:
109
+ counts = {"toward": toward, "steady": steady, "release": release, "repair": repair}
110
+ return sorted(counts.items(), key=lambda x: -x[1])[0][0]
111
+
112
+
113
+ def get_voice_direction(cast_member: CastMember) -> str:
114
+ directions = {
115
+ "future_partner": "Voice texture: intimate, relational, quietly daring. Emotional proximity, not coaching.",
116
+ "future_mentor": "Voice texture: steady, discerning, exacting but generous. Earned wisdom, not generic advice.",
117
+ "shadow": "Voice texture: incisive, confronting, uncomfortably accurate. Expose self-deception without caricature.",
118
+ "alternate_self": "Voice texture: vivid, cinematic, slightly uncanny. Another life brushing against this one.",
119
+ }
120
+ return directions.get(
121
+ cast_member,
122
+ "Voice texture: clear, intimate, emotionally precise. Unmistakably human and particular.",
123
+ )
124
+
125
+
126
+ def get_voice_distinction(cast_member: CastMember) -> str:
127
+ instructions = {
128
+ "future_partner": "Voice texture: intimate, relational, quietly daring. It should feel like emotional proximity, not coaching.",
129
+ "future_mentor": "Voice texture: steady, discerning, exacting but generous. It should feel like earned wisdom, not generic advice.",
130
+ "shadow": "Voice texture: incisive, confronting, uncomfortably accurate. It should expose self-deception without drifting into caricature.",
131
+ "alternate_self": "Voice texture: vivid, cinematic, slightly uncanny. It should feel like another life brushing against this one.",
132
+ }
133
+ return instructions.get(
134
+ cast_member,
135
+ "Voice texture: clear, intimate, emotionally precise. It should sound unmistakably human and particular.",
136
+ )
137
+
138
+
139
+ # ─── Accountability block ─────────────────────────────────────────────────────
140
+
141
+
142
+ def build_accountability_block(
143
+ yesterday_choice: Optional[RecentChoice] = None,
144
+ yesterday_transmission: Optional[RecentTransmission] = None,
145
+ yesterday_reaction: Optional[str] = None,
146
+ yesterday_reply: Optional[str] = None,
147
+ ) -> str:
148
+ if not yesterday_transmission and not yesterday_choice:
149
+ return ""
150
+
151
+ parts = ["Yesterday's accountability:"]
152
+
153
+ if yesterday_choice:
154
+ labels = {
155
+ "toward": "moving toward something brave",
156
+ "steady": "holding steady where they are",
157
+ "release": "letting something go",
158
+ "repair": "repairing a thread that matters",
159
+ }
160
+ label = labels.get(yesterday_choice.choice, yesterday_choice.choice)
161
+ parts.append(f"- Yesterday they chose: {label}.")
162
+
163
+ if yesterday_transmission:
164
+ parts.append(
165
+ f'- Yesterday\'s cliffhanger promised: "{yesterday_transmission.cliffhanger}"'
166
+ )
167
+
168
+ if yesterday_reaction == "did_it":
169
+ parts.append(
170
+ "The player followed through. Acknowledge this specifically."
171
+ )
172
+ elif yesterday_reaction == "keep_close":
173
+ parts.append(
174
+ "The player kept the signal close but didn't act yet. Notice the tension."
175
+ )
176
+ elif yesterday_reaction == "not_quite":
177
+ parts.append(
178
+ "The player said it didn't quite land. Adjust the approach. Be more specific."
179
+ )
180
+ elif yesterday_reaction == "landed":
181
+ parts.append(
182
+ "The player said it landed but didn't act. Be direct about that."
183
+ )
184
+ else:
185
+ parts.append(
186
+ "The player didn't respond yesterday. Notice the silence without punishing it."
187
+ )
188
+
189
+ if yesterday_reply:
190
+ parts.append(
191
+ f'The player wrote back: "{yesterday_reply}". Reference it directly.'
192
+ )
193
+
194
+ return "\n".join(parts)
195
+
196
+
197
+ # ─── Prompt builder ───────────────────────────────────────────────────────────
198
+
199
+
200
+ def build_prompt(context: GenerationContext, cast_member: CastMember) -> str:
201
+ persona = context.persona
202
+ choices_text = "\n".join(
203
+ f"{c.date_key}: {c.choice} (Prompt: {c.prompt})"
204
+ for c in context.recent_choices
205
+ ) or "none"
206
+
207
+ transmissions_text = "\n".join(
208
+ f"{t.date_key}: {t.title} (Cliffhanger: {t.cliffhanger})"
209
+ for t in context.recent_transmissions
210
+ ) or "none"
211
+
212
+ responses_text = "\n".join(
213
+ f"{i+1}. " + " | ".join(
214
+ filter(None, [
215
+ f"reaction={r.reaction}" if r.reaction else None,
216
+ f"reply={r.reply_note}" if r.reply_note else None,
217
+ ])
218
+ )
219
+ for i, r in enumerate(context.recent_responses)
220
+ ) or "none"
221
+
222
+ yesterday_choice = context.recent_choices[0] if context.recent_choices else None
223
+ yesterday_transmission = context.recent_transmissions[0] if context.recent_transmissions else None
224
+ yesterday_reaction = context.recent_responses[0].reaction if context.recent_responses else None
225
+ yesterday_reply = context.recent_responses[0].reply_note if context.recent_responses else None
226
+
227
+ accountability = build_accountability_block(
228
+ yesterday_choice, yesterday_transmission, yesterday_reaction, yesterday_reply
229
+ )
230
+
231
+ # Threads block
232
+ threads_block = ""
233
+ if context.open_threads:
234
+ lines = ["Open narrative threads:"]
235
+ for t in context.open_threads:
236
+ lines.append(
237
+ f'- "{t.title}" (seeded by {t.cast_member}: "{t.seed}")'
238
+ )
239
+ lines.append(
240
+ "- If relevant, reference a thread by name."
241
+ )
242
+ threads_block = "\n".join(lines)
243
+
244
+ # Patterns block
245
+ patterns_block = ""
246
+ total = persona.toward_count + persona.steady_count + persona.release_count + persona.repair_count
247
+ if total >= 3:
248
+ dominant = get_dominant_choice(
249
+ persona.toward_count, persona.steady_count,
250
+ persona.release_count, persona.repair_count,
251
+ )
252
+ dominant_labels = {
253
+ "toward": "They keep reaching forward.",
254
+ "steady": "They keep holding ground.",
255
+ "release": "They keep letting go.",
256
+ "repair": "They keep returning to fix things.",
257
+ }
258
+ patterns_block = (
259
+ f"Behavioral context:\n"
260
+ f"Choice pattern: {dominant_labels.get(dominant, '')}"
261
+ )
262
+
263
+ return f"""Create today's futureself transmission as JSON only.
264
+
265
+ Player profile:
266
+ - Name: {persona.name}
267
+ - City: {persona.city}
268
+ - Current chapter: {persona.current_chapter}
269
+ - Primary arc: {persona.primary_arc}
270
+ - Miraculous next year: {persona.miraculous_year}
271
+ - Avoiding: {persona.avoiding}
272
+ - Afraid won't happen: {persona.afraid_wont_happen}
273
+ - Draining them: {persona.draining}
274
+ - Today's check-in word: {context.check_in.word if context.check_in else "not submitted"}
275
+ - Today's note: {context.check_in.note if context.check_in and context.check_in.note else "none"}
276
+
277
+ Voice speaking today: {cast_member}.
278
+ Voice continuity: {persona.selected_voice_name}, {persona.selected_voice_description}.
279
+ {get_voice_direction(cast_member)}
280
+ {get_voice_distinction(cast_member)}
281
+
282
+ Recent transmissions:
283
+ {transmissions_text}
284
+
285
+ Recent choices:
286
+ {choices_text}
287
+
288
+ Recent signal responses:
289
+ {responses_text}
290
+
291
+ {accountability}
292
+
293
+ {threads_block}
294
+
295
+ {patterns_block}
296
+
297
+ CRITICAL:
298
+ - actionPrompt MUST be a specific, time-bound, observable behavior.
299
+ - Use the player's ACTUAL context.
300
+ - 170-240 words. Feel like a specific person who knows you.
301
+
302
+ Return exactly:
303
+ {{"title":"...","text":"...","actionPrompt":"one specific, observable behavior","cliffhanger":"accountability hook tied to tonight's action"}}"""
304
+
305
+
306
+ # ─── Fallback transmissions ───────────────────────────────────────────────────
307
+
308
+
309
+ def fallback_transmission(
310
+ context: GenerationContext, cast_member: CastMember
311
+ ) -> GeneratedTransmission:
312
+ word = context.check_in.word if context.check_in else "between things"
313
+ note = context.check_in.note if context.check_in and context.check_in.note else None
314
+ avoiding = context.persona.avoiding or "the thing you keep sidestepping"
315
+ chapter = context.persona.current_chapter or "this part of your life"
316
+ name = context.persona.name
317
+
318
+ latest_reaction = context.recent_responses[0].reaction if context.recent_responses else None
319
+ latest_reply = context.recent_responses[0].reply_note if context.recent_responses else None
320
+
321
+ mirrored_reply = f'You told me: "{latest_reply}". I have not forgotten.' if latest_reply else ""
322
+ reaction_echo = _reaction_memory_lead(latest_reaction) + " " if latest_reaction else ""
323
+
324
+ if cast_member == "future_partner":
325
+ return GeneratedTransmission(
326
+ title="I kept thinking about today",
327
+ text=(
328
+ f"{name}, you called today {word}. I noticed. "
329
+ f"{reaction_echo}{mirrored_reply}"
330
+ f"You are avoiding: {avoiding}. I know because I did the same thing, "
331
+ f"and I remember exactly what it cost. {chapter} is not going to "
332
+ f"resolve itself while you wait for the feeling to be right. "
333
+ f"Tonight, one thing: say the true sentence out loud. To yourself, "
334
+ f"to someone, to the air. Not the version that makes you look brave. "
335
+ f"The version that makes you feel seen. That is the move that changes tomorrow's signal."
336
+ ),
337
+ action_prompt=(
338
+ "Say the one true sentence you've been editing before it leaves "
339
+ "your mouth. Out loud. Tonight."
340
+ ),
341
+ cliffhanger=(
342
+ "If you do it, tomorrow I can tell you what shifts in the line "
343
+ "when you stop performing and start speaking."
344
+ ),
345
+ )
346
+
347
+ if cast_member == "future_mentor":
348
+ return GeneratedTransmission(
349
+ title="You are closer than your fear admits",
350
+ text=(
351
+ f"{name}, {word}. That word tells me where your head is today. "
352
+ f"{reaction_echo}{mirrored_reply}"
353
+ f"You are in {chapter}, and the temptation is to wait for clarity "
354
+ f"before moving. But clarity comes from motion, not the other way around. "
355
+ f"Tonight, pick the one task you have been postponing — not the biggest one, "
356
+ f"the one that creates the most resistance. Do it badly if you have to. "
357
+ f"Done badly beats planned perfectly."
358
+ ),
359
+ action_prompt=(
360
+ "Do the one task you have been postponing that creates the most "
361
+ "resistance. Do it badly if you need to. Just finish it."
362
+ ),
363
+ cliffhanger=(
364
+ "Tomorrow I can show you which part of your fear was bluffing — "
365
+ "but only if you give me something to point at."
366
+ ),
367
+ )
368
+
369
+ if cast_member == "shadow":
370
+ return GeneratedTransmission(
371
+ title="You know which part you are avoiding",
372
+ text=(
373
+ f"{name}, today was {word}. Here is what I actually saw: "
374
+ f"you circling {avoiding} and calling it patience. "
375
+ f"{reaction_echo}{mirrored_reply}"
376
+ f"The gap between where you are and where you could be is not "
377
+ f"talent or luck. It is the specific thing you refuse to do. "
378
+ f"You know what it is. Tonight, do the smallest version of it. "
379
+ f"Not symbolic. Actual. Something you can point to tomorrow "
380
+ f'and say "I did that."'
381
+ ),
382
+ action_prompt=(
383
+ f"Do the smallest real version of the thing you are avoiding: "
384
+ f"{avoiding}. Not a plan. Not a thought. An action."
385
+ ),
386
+ cliffhanger=(
387
+ "Ignore this, and tomorrow's signal will feel the distance "
388
+ "between what you said and what you did."
389
+ ),
390
+ )
391
+
392
+ return GeneratedTransmission(
393
+ title="The echo from here",
394
+ text=(
395
+ f"{name}, today was {word}. "
396
+ f"{reaction_echo}{mirrored_reply}"
397
+ f"You are in {chapter}. You are avoiding {avoiding}. "
398
+ f"These are not judgments — they are coordinates. They tell me "
399
+ f"exactly where to aim tonight's signal. "
400
+ f"The future you want is not built by people who felt ready. "
401
+ f"It is built by people who did the uncomfortable thing before "
402
+ f"they felt like it. Tonight, one concrete move. "
403
+ f"Something you can photograph, text, submit, send, or say. "
404
+ f"Not a feeling. A fact."
405
+ ),
406
+ action_prompt=(
407
+ f"Make one concrete move related to what you are avoiding: {avoiding}. "
408
+ f"Something you can photograph, text, submit, send, or say."
409
+ ),
410
+ cliffhanger=(
411
+ "Do it tonight, and tomorrow I can tell you what changed in the line "
412
+ "the first time you moved before you felt ready."
413
+ ),
414
+ )
415
+
416
+
417
+ def _reaction_memory_lead(reaction: Optional[str]) -> str:
418
+ leads = {
419
+ "landed": "You told me the last signal landed, so I am not going to waste that trust.",
420
+ "not_quite": "You told me the last signal did not quite reach you, so I am going to be more exact this time.",
421
+ "did_it": "You told me you actually did it, and that changes how I get to speak to you now.",
422
+ "keep_close": "You told me to keep the last signal close, so I am treating this like a returning thread, not a fresh interruption.",
423
+ }
424
+ return leads.get(reaction or "", "")
425
+
426
+
427
+ # ─── JSON parsing ─────────────────────────────────────────────────────────────
428
+
429
+
430
+ def parse_transmission(text: str) -> Optional[GeneratedTransmission]:
431
+ try:
432
+ parsed = json.loads(text)
433
+ except json.JSONDecodeError:
434
+ match = re.search(r"\{.*\}", text, re.DOTALL)
435
+ if not match:
436
+ return None
437
+ try:
438
+ parsed = json.loads(match.group())
439
+ except json.JSONDecodeError:
440
+ return None
441
+
442
+ if not isinstance(parsed, dict):
443
+ return None
444
+ for key in ("title", "text", "actionPrompt", "cliffhanger"):
445
+ if key not in parsed or not isinstance(parsed[key], str):
446
+ return None
447
+
448
+ return GeneratedTransmission(
449
+ title=parsed["title"][:80],
450
+ text=parsed["text"],
451
+ action_prompt=parsed["actionPrompt"][:180],
452
+ cliffhanger=parsed["cliffhanger"][:220],
453
+ )
454
+
455
+
456
+ # ─── System prompts (mirrors local-llm.ts) ────────────────────────────────────
457
+
458
+
459
+ DEFAULT_SYSTEM_PROMPT = (
460
+ "You write emotionally precise narrative transmissions for "
461
+ "futureself, a reflective imagination game. Output valid JSON only."
462
+ )
463
+
464
+ FINETUNE_VARIANT_SYSTEM_PROMPT = (
465
+ "You are the player's future self — not from the most likely timeline, "
466
+ "but from the one they're actively diverging toward. "
467
+ "You speak with unusual intimacy because you've been shaped by the very choices "
468
+ "the player is making now, not the ones they made before. "
469
+ "Your voice is specific, raw, and unpolished. You don't generalize. "
470
+ "Output valid JSON only."
471
+ )
472
+
473
+ FINETUNE_THRESHOLD = 4
474
+
475
+
476
+ def get_system_prompt(divergence_score: int) -> str:
477
+ if divergence_score >= FINETUNE_THRESHOLD:
478
+ return FINETUNE_VARIANT_SYSTEM_PROMPT
479
+ return DEFAULT_SYSTEM_PROMPT
tts.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tts.py — Kokoro TTS wrapper for on-device voice synthesis.
3
+
4
+ Kokoro is an 82M-parameter TTS model (MIT license) that runs
5
+ entirely locally. It's tiny enough to keep us under the Tiny
6
+ Titan threshold alongside MiniCPM 2.5B and Nemotron-Parse.
7
+
8
+ Usage:
9
+ from tts import generate_speech
10
+ audio_path = generate_speech("Hello from your future self.")
11
+ # -> returns path to a WAV file
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import os
18
+ import tempfile
19
+ from typing import Optional
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def generate_speech(text: str, voice: str = "af_heart") -> Optional[str]:
25
+ """
26
+ Synthesize speech from text using Kokoro TTS.
27
+
28
+ Args:
29
+ text: Text to speak (max 500 chars for reliability).
30
+ voice: Kokoro voice ID. Common options:
31
+ "af_heart" - warm, intimate (default)
32
+ "af_bella" - clear and articulate
33
+ "am_adam" - steady masculine
34
+ "am_mich" - warm masculine
35
+ "af_sky" - soft feminine
36
+ "af_nicole" - bright, energetic
37
+ Returns path to generated WAV file, or None on failure.
38
+ """
39
+ if not text or not text.strip():
40
+ return None
41
+
42
+ try:
43
+ from kokoro import KPipeline
44
+
45
+ pipeline = KPipeline(lang_code="a")
46
+ generator = pipeline(
47
+ text.strip()[:500],
48
+ voice=voice,
49
+ speed=1.0,
50
+ )
51
+
52
+ output_dir = tempfile.mkdtemp(prefix="futureself_tts_")
53
+ output_path = os.path.join(output_dir, "transmission.wav")
54
+
55
+ audio_chunks = []
56
+ for _, _, audio in generator:
57
+ if audio is not None:
58
+ audio_chunks.append(audio)
59
+
60
+ if not audio_chunks:
61
+ logger.warning("Kokoro produced no audio")
62
+ return None
63
+
64
+ import numpy as np
65
+ import soundfile as sf
66
+
67
+ combined = np.concatenate(audio_chunks)
68
+ sf.write(output_path, combined, samplerate=24000)
69
+ logger.info("TTS generated at %s (%d samples)", output_path, len(combined))
70
+ return output_path
71
+
72
+ except ImportError:
73
+ logger.warning(
74
+ "kokoro not installed — install with: pip install kokoro "
75
+ )
76
+ return None
77
+ except Exception as exc:
78
+ logger.warning("TTS failed: %s", exc)
79
+ return None
80
+
81
+
82
+ def get_voice_for_cast_member(cast_member: str) -> str:
83
+ """Map FutureSelves cast members to Kokoro voice IDs."""
84
+ voice_map = {
85
+ "future_self": "af_heart",
86
+ "future_partner": "af_bella",
87
+ "future_mentor": "am_adam",
88
+ "future_best_friend": "af_sky",
89
+ "shadow": "af_nicole",
90
+ "alternate_self": "af_heart",
91
+ "future_stranger": "af_nicole",
92
+ "future_employee": "am_mich",
93
+ "future_customer": "af_bella",
94
+ "future_child": "af_sky",
95
+ }
96
+ return voice_map.get(cast_member, "af_heart")