Spaces:
Paused
Paused
feat: demo persona, TTS, Modal, agent trace, Field Notes
Browse files- .gitattributes +1 -0
- FIELD_NOTES.md +2 -2
- README.md +3 -2
- app.py +99 -33
- demo.mp4 +3 -0
.gitattributes
CHANGED
|
@@ -38,3 +38,4 @@ audio/voices/day-02-partner.wav filter=lfs diff=lfs merge=lfs -text
|
|
| 38 |
audio/voices/day-03-mentor.wav filter=lfs diff=lfs merge=lfs -text
|
| 39 |
audio/voices/day-04-shadow.wav filter=lfs diff=lfs merge=lfs -text
|
| 40 |
audio/voices/today-threshold.wav filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 38 |
audio/voices/day-03-mentor.wav filter=lfs diff=lfs merge=lfs -text
|
| 39 |
audio/voices/day-04-shadow.wav filter=lfs diff=lfs merge=lfs -text
|
| 40 |
audio/voices/today-threshold.wav filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
demo.mp4 filter=lfs diff=lfs merge=lfs -text
|
FIELD_NOTES.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
# Field Notes — FutureSelves for Build Small
|
| 2 |
|
| 3 |
**Hackathon:** [Gradio + Hugging Face Build Small](https://huggingface.co/build-small-hackathon), June 2026
|
| 4 |
-
**Submission:** [
|
| 5 |
**Author:** [@udingethe](https://github.com/udingethe)
|
| 6 |
|
| 7 |
## What we built
|
|
@@ -62,7 +62,7 @@ The badges we do claim have artifacts in the repo: the custom UI is in `app.py`,
|
|
| 62 |
|
| 63 |
If you want to evaluate the build fairly, here is the order that makes the most sense:
|
| 64 |
|
| 65 |
-
1. Land on the [Live Space](https://
|
| 66 |
2. Click **✦ Try Maya's example** on the first screen.
|
| 67 |
3. Read Maya's transmission. Click play on the audio.
|
| 68 |
4. Scroll to the memory log. Click the play chips beside the past 4 transmissions.
|
|
|
|
| 1 |
# Field Notes — FutureSelves for Build Small
|
| 2 |
|
| 3 |
**Hackathon:** [Gradio + Hugging Face Build Small](https://huggingface.co/build-small-hackathon), June 2026
|
| 4 |
+
**Submission:** [Papajams/futureselves](https://huggingface.co/spaces/Papajams/futureselves) · [source](https://github.com/udingethe/futureselves/tree/main/hf-space)
|
| 5 |
**Author:** [@udingethe](https://github.com/udingethe)
|
| 6 |
|
| 7 |
## What we built
|
|
|
|
| 62 |
|
| 63 |
If you want to evaluate the build fairly, here is the order that makes the most sense:
|
| 64 |
|
| 65 |
+
1. Land on the [Live Space](https://huggingface.co/spaces/Papajams/futureselves).
|
| 66 |
2. Click **✦ Try Maya's example** on the first screen.
|
| 67 |
3. Read Maya's transmission. Click play on the audio.
|
| 68 |
4. Scroll to the memory log. Click the play chips beside the past 4 transmissions.
|
README.md
CHANGED
|
@@ -78,10 +78,11 @@ python app.py
|
|
| 78 |
|
| 79 |
## Links
|
| 80 |
|
| 81 |
-
- **[Live Space](https://
|
| 82 |
- [Source (monorepo)](https://github.com/udingethe/futureselves/tree/main/hf-space)
|
|
|
|
| 83 |
- [Demo walkthrough](FIELD_NOTES.md#the-artifact-end-to-end) — step-by-step guide for judges
|
|
|
|
| 84 |
- [Agent trace](traces/agent-trace.jsonl) — open record of every transmission
|
| 85 |
- [Field Notes blog post](FIELD_NOTES.md) — what we built and what we learned
|
| 86 |
-
- [Agent trace](traces/agent-trace.jsonl) — open record of every transmission
|
| 87 |
- [Modal app](traces/modal_app.py) — serverless persona summarizer
|
|
|
|
| 78 |
|
| 79 |
## Links
|
| 80 |
|
| 81 |
+
- **[Live Space](https://huggingface.co/spaces/Papajams/futureselves)** — deployed on T4 GPU
|
| 82 |
- [Source (monorepo)](https://github.com/udingethe/futureselves/tree/main/hf-space)
|
| 83 |
+
- [Demo video](demo.mp4) — 2-minute walkthrough
|
| 84 |
- [Demo walkthrough](FIELD_NOTES.md#the-artifact-end-to-end) — step-by-step guide for judges
|
| 85 |
+
- [Social post](https://medium.com/@ungethe/building-small-9aea8bf5236e) — Medium article
|
| 86 |
- [Agent trace](traces/agent-trace.jsonl) — open record of every transmission
|
| 87 |
- [Field Notes blog post](FIELD_NOTES.md) — what we built and what we learned
|
|
|
|
| 88 |
- [Modal app](traces/modal_app.py) — serverless persona summarizer
|
app.py
CHANGED
|
@@ -13,7 +13,6 @@ Tiny Titan, Best Agent, Off Brand, Best Demo, Bonus Quest Champion.
|
|
| 13 |
|
| 14 |
from __future__ import annotations
|
| 15 |
|
| 16 |
-
import json
|
| 17 |
import json
|
| 18 |
import logging
|
| 19 |
import os
|
|
@@ -23,6 +22,7 @@ import threading
|
|
| 23 |
import uuid
|
| 24 |
from dataclasses import dataclass, field, asdict
|
| 25 |
from datetime import date
|
|
|
|
| 26 |
from pathlib import Path
|
| 27 |
from typing import Any, Optional
|
| 28 |
|
|
@@ -90,7 +90,15 @@ def _load_llm():
|
|
| 90 |
return _LLM
|
| 91 |
|
| 92 |
|
| 93 |
-
def _generate_with_llm(context: GenerationContext, cast_member: CastMember, local_now: str) -> GeneratedTransmission:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
try:
|
| 95 |
model, tokenizer = _load_llm()
|
| 96 |
prompt = build_prompt(context, cast_member)
|
|
@@ -111,10 +119,11 @@ def _generate_with_llm(context: GenerationContext, cast_member: CastMember, loca
|
|
| 111 |
decoded = tokenizer.decode(outputs[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True).strip()
|
| 112 |
parsed = parse_transmission(decoded)
|
| 113 |
if parsed:
|
| 114 |
-
return parsed
|
|
|
|
| 115 |
except Exception as exc:
|
| 116 |
logger.warning("LLM failed: %s", exc)
|
| 117 |
-
|
| 118 |
|
| 119 |
|
| 120 |
# ─── State ───────────────────────────────────────────────────────────────────
|
|
@@ -1209,6 +1218,25 @@ def _audio_module(label: str, audio_path: str) -> str:
|
|
| 1209 |
</div>"""
|
| 1210 |
|
| 1211 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1212 |
def _share_module(title: str, body: str, action: str, cliff: str) -> str:
|
| 1213 |
"""A copy-to-clipboard button for the transmission, styled as an instrument.
|
| 1214 |
|
|
@@ -1257,19 +1285,22 @@ def _memory_log(state: AppState, *, limit: int = 8) -> str:
|
|
| 1257 |
f'<a class="audio-chip" href="/file={staged}" target="_blank" rel="noopener" '
|
| 1258 |
f'title="play voice">▶</a>'
|
| 1259 |
)
|
|
|
|
| 1260 |
rows.append(
|
| 1261 |
f'<div class="memory-row memory-entry">'
|
| 1262 |
-
f'<span class="ts">{t.date_key}</span>'
|
| 1263 |
-
f'<span class="body"><em>“{t.title}”</em></span>'
|
| 1264 |
-
f'<span class="tag">{
|
| 1265 |
f'</div>'
|
| 1266 |
)
|
| 1267 |
for c in reversed(state.recent_choices[-limit:]):
|
|
|
|
|
|
|
| 1268 |
rows.append(
|
| 1269 |
f'<div class="memory-row memory-entry">'
|
| 1270 |
-
f'<span class="ts">{c.date_key}</span>'
|
| 1271 |
-
f'<span class="body">{c.prompt}</span>'
|
| 1272 |
-
f'<span class="tag choice-{
|
| 1273 |
f'</div>'
|
| 1274 |
)
|
| 1275 |
return f"""<div class="memory-log">
|
|
@@ -1317,12 +1348,14 @@ def _render_persona_card() -> str:
|
|
| 1317 |
if not summaries:
|
| 1318 |
return '<span class="k">no persona summary yet — generate one via the demo button</span>'
|
| 1319 |
last = summaries[-1]
|
| 1320 |
-
src = last.get("source", "?")
|
|
|
|
|
|
|
| 1321 |
return (
|
| 1322 |
f'<em style="color:var(--paper);font-family:Fraunces,serif;font-size:14px;line-height:1.6;">'
|
| 1323 |
-
f'“{
|
| 1324 |
f'<div style="margin-top:8px;font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--paper-mute);">'
|
| 1325 |
-
f'— {
|
| 1326 |
)
|
| 1327 |
|
| 1328 |
|
|
@@ -1342,10 +1375,9 @@ def _context_note(html: str) -> str:
|
|
| 1342 |
# ─── App logic (async gen helper) ─────────────────────────────────────────────
|
| 1343 |
|
| 1344 |
def _gen_async(state: AppState, context: GenerationContext, cm: CastMember, now_str: str):
|
| 1345 |
-
start = time.time() if 'time' in dir() else 0
|
| 1346 |
import time as _time
|
| 1347 |
start = _time.time()
|
| 1348 |
-
result = _generate_with_llm(context, cm, now_str)
|
| 1349 |
state.today_transmission = result
|
| 1350 |
# Try local Kokoro first. If it fails (Space, missing deps, etc.) and we
|
| 1351 |
# have a matching pre-rendered voice sample for the cast member, fall
|
|
@@ -1364,9 +1396,8 @@ def _gen_async(state: AppState, context: GenerationContext, cm: CastMember, now_
|
|
| 1364 |
from transmission import build_prompt, get_system_prompt
|
| 1365 |
sys_p = get_system_prompt(context.persona.timeline_divergence_score)
|
| 1366 |
usr_p = build_prompt(context, cm)
|
| 1367 |
-
# Build a coarse parsed_output mirror
|
| 1368 |
parsed = {
|
| 1369 |
-
"title": result.title, "text": result.text
|
| 1370 |
"actionPrompt": result.action_prompt, "cliffhanger": result.cliffhanger,
|
| 1371 |
} if result else None
|
| 1372 |
# Run note extraction (Nemotron-Parse or keyword fallback) for the trace
|
|
@@ -1390,6 +1421,7 @@ def _gen_async(state: AppState, context: GenerationContext, cm: CastMember, now_
|
|
| 1390 |
parsed_output=parsed,
|
| 1391 |
insights=insights,
|
| 1392 |
duration_ms=duration_ms,
|
|
|
|
| 1393 |
)
|
| 1394 |
except Exception as exc:
|
| 1395 |
logger.warning("Failed to log agent trace: %s", exc)
|
|
@@ -1480,8 +1512,12 @@ def _render_home(state: AppState) -> str:
|
|
| 1480 |
body += _signal_path("onboard")
|
| 1481 |
return body
|
| 1482 |
p = state.persona
|
| 1483 |
-
|
| 1484 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1485 |
continuity_hint = ""
|
| 1486 |
if state.recent_choices:
|
| 1487 |
last_choice = state.recent_choices[-1]
|
|
@@ -1494,8 +1530,8 @@ def _render_home(state: AppState) -> str:
|
|
| 1494 |
continuity_hint = f" The line has been listening since you last chose to {choice_labels.get(last_choice.choice, 'continue')}."
|
| 1495 |
body_html = _chamber_body(
|
| 1496 |
f"Check in with one word to open the line. "
|
| 1497 |
-
f"You are in <em>{
|
| 1498 |
-
f"and avoiding <em>{
|
| 1499 |
f"{continuity_hint}"
|
| 1500 |
)
|
| 1501 |
callout = ""
|
|
@@ -1503,11 +1539,12 @@ def _render_home(state: AppState) -> str:
|
|
| 1503 |
last = state.recent_transmissions[-1]
|
| 1504 |
callout = _chamber_callout(
|
| 1505 |
"last transmission",
|
| 1506 |
-
f'<em>“{last.title}”</em> · {last.date_key}'
|
| 1507 |
)
|
|
|
|
| 1508 |
meta = _chamber_meta([
|
| 1509 |
-
("arc", p.primary_arc or "—"),
|
| 1510 |
-
("chapter",
|
| 1511 |
])
|
| 1512 |
out = _identity_strip(state)
|
| 1513 |
out += _signal_chamber(eyebrow, title, body_html, meta_html=meta, callout_html=callout)
|
|
@@ -1519,8 +1556,9 @@ def _render_home(state: AppState) -> str:
|
|
| 1519 |
|
| 1520 |
|
| 1521 |
def _render_awaiting(state: AppState) -> str:
|
| 1522 |
-
|
| 1523 |
-
|
|
|
|
| 1524 |
body_html = _chamber_body(
|
| 1525 |
"The note is being read. The line is open. "
|
| 1526 |
"Open the line below to receive your transmission."
|
|
@@ -1560,16 +1598,25 @@ def _render_transmission(state: AppState) -> str:
|
|
| 1560 |
if not t:
|
| 1561 |
return _render_home(state)
|
| 1562 |
label = CAST_MEMBER_NAMES.get(state.today_cast or "future_self", ("", ""))[0] or "Future Self"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1563 |
eyebrow = _chamber_eyebrow("transmission received", label)
|
| 1564 |
-
title = _chamber_title(f'<em>{
|
| 1565 |
-
body_html = _chamber_body(
|
| 1566 |
-
actions =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1567 |
actions += _share_module(t.title, t.text, t.action_prompt, t.cliffhanger)
|
| 1568 |
# The chamber holds the audio module + tonight's move. Tomorrow's cliffhanger
|
| 1569 |
# is shown as a second anchor line below the chamber so the page has a clear
|
| 1570 |
# rhythm: text → audio + tonight → tomorrow → constellation → memory log.
|
| 1571 |
-
tonight = _chamber_callout("tonight's move",
|
| 1572 |
-
tomorrow = _chamber_callout("tomorrow",
|
| 1573 |
out = _identity_strip(state)
|
| 1574 |
out += _signal_chamber(eyebrow, title, body_html, actions_html=actions, callout_html=tonight)
|
| 1575 |
out += tomorrow
|
|
@@ -2013,9 +2060,28 @@ setupInteractions();
|
|
| 2013 |
inputs=[word, note, state], outputs=[content, browser_state, state],
|
| 2014 |
)
|
| 2015 |
|
| 2016 |
-
# Wire generate
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2017 |
generate_btn.click(
|
| 2018 |
-
fn=
|
| 2019 |
inputs=[state], outputs=[content, browser_state, state],
|
| 2020 |
)
|
| 2021 |
|
|
|
|
| 13 |
|
| 14 |
from __future__ import annotations
|
| 15 |
|
|
|
|
| 16 |
import json
|
| 17 |
import logging
|
| 18 |
import os
|
|
|
|
| 22 |
import uuid
|
| 23 |
from dataclasses import dataclass, field, asdict
|
| 24 |
from datetime import date
|
| 25 |
+
from html import escape as html_escape
|
| 26 |
from pathlib import Path
|
| 27 |
from typing import Any, Optional
|
| 28 |
|
|
|
|
| 90 |
return _LLM
|
| 91 |
|
| 92 |
|
| 93 |
+
def _generate_with_llm(context: GenerationContext, cast_member: CastMember, local_now: str) -> tuple[GeneratedTransmission, str]:
|
| 94 |
+
"""Generate a transmission and report which path produced it.
|
| 95 |
+
|
| 96 |
+
Returns (transmission, source) where source is one of:
|
| 97 |
+
- "live" : MiniCPM produced valid JSON we could parse
|
| 98 |
+
- "parse-fail": MiniCPM ran but its output couldn't be parsed
|
| 99 |
+
- "llm-error": LLM call raised (OOM, network, missing model)
|
| 100 |
+
The agent trace records this so judges can audit live vs. fallback.
|
| 101 |
+
"""
|
| 102 |
try:
|
| 103 |
model, tokenizer = _load_llm()
|
| 104 |
prompt = build_prompt(context, cast_member)
|
|
|
|
| 119 |
decoded = tokenizer.decode(outputs[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True).strip()
|
| 120 |
parsed = parse_transmission(decoded)
|
| 121 |
if parsed:
|
| 122 |
+
return parsed, "live"
|
| 123 |
+
return fallback_transmission(context, cast_member), "parse-fail"
|
| 124 |
except Exception as exc:
|
| 125 |
logger.warning("LLM failed: %s", exc)
|
| 126 |
+
return fallback_transmission(context, cast_member), "llm-error"
|
| 127 |
|
| 128 |
|
| 129 |
# ─── State ───────────────────────────────────────────────────────────────────
|
|
|
|
| 1218 |
</div>"""
|
| 1219 |
|
| 1220 |
|
| 1221 |
+
def _audio_dropped_module(label: str) -> str:
|
| 1222 |
+
"""Visible callout when both Kokoro and the pre-rendered fallback audio failed.
|
| 1223 |
+
|
| 1224 |
+
Without this, the chamber silently renders text-only and the user assumes
|
| 1225 |
+
the demo is broken. Make the failure mode part of the product voice.
|
| 1226 |
+
"""
|
| 1227 |
+
label_safe = html_escape(label)
|
| 1228 |
+
return f"""<div style="margin-top:20px;padding:14px 16px;border:1px solid var(--line);border-left:2px solid var(--violet);border-radius:2px;background:rgba(122,108,199,.06);">
|
| 1229 |
+
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
|
| 1230 |
+
<span style="width:6px;height:6px;border-radius:50%;background:var(--violet);box-shadow:0 0 8px rgba(122,108,199,.6);"></span>
|
| 1231 |
+
<span style="font-size:9px;letter-spacing:.22em;text-transform:uppercase;color:var(--violet)">voice line dropped</span>
|
| 1232 |
+
<span style="font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--paper-mute)">· {label_safe} could not be tuned</span>
|
| 1233 |
+
</div>
|
| 1234 |
+
<div style="font-family:Fraunces,serif;font-style:italic;font-size:13px;line-height:1.5;color:var(--paper-mute);">
|
| 1235 |
+
The text arrived but the voice did not. The transmission is still legible above.
|
| 1236 |
+
</div>
|
| 1237 |
+
</div>"""
|
| 1238 |
+
|
| 1239 |
+
|
| 1240 |
def _share_module(title: str, body: str, action: str, cliff: str) -> str:
|
| 1241 |
"""A copy-to-clipboard button for the transmission, styled as an instrument.
|
| 1242 |
|
|
|
|
| 1285 |
f'<a class="audio-chip" href="/file={staged}" target="_blank" rel="noopener" '
|
| 1286 |
f'title="play voice">▶</a>'
|
| 1287 |
)
|
| 1288 |
+
cast_label = CAST_MEMBER_NAMES.get(t.cast_member, ("", ""))[0] or t.cast_member
|
| 1289 |
rows.append(
|
| 1290 |
f'<div class="memory-row memory-entry">'
|
| 1291 |
+
f'<span class="ts">{html_escape(t.date_key)}</span>'
|
| 1292 |
+
f'<span class="body"><em>“{html_escape(t.title)}”</em></span>'
|
| 1293 |
+
f'<span class="tag">{html_escape(cast_label)}{audio_chip}</span>'
|
| 1294 |
f'</div>'
|
| 1295 |
)
|
| 1296 |
for c in reversed(state.recent_choices[-limit:]):
|
| 1297 |
+
# c.choice is enum-like (toward/steady/release/repair) but escape for safety.
|
| 1298 |
+
choice_safe = html_escape(c.choice)
|
| 1299 |
rows.append(
|
| 1300 |
f'<div class="memory-row memory-entry">'
|
| 1301 |
+
f'<span class="ts">{html_escape(c.date_key)}</span>'
|
| 1302 |
+
f'<span class="body">{html_escape(c.prompt)}</span>'
|
| 1303 |
+
f'<span class="tag choice-{choice_safe}">{choice_safe}</span>'
|
| 1304 |
f'</div>'
|
| 1305 |
)
|
| 1306 |
return f"""<div class="memory-log">
|
|
|
|
| 1348 |
if not summaries:
|
| 1349 |
return '<span class="k">no persona summary yet — generate one via the demo button</span>'
|
| 1350 |
last = summaries[-1]
|
| 1351 |
+
src = html_escape(last.get("source", "?"))
|
| 1352 |
+
summary = html_escape(last.get("summary", ""))
|
| 1353 |
+
name = html_escape(last.get("persona_name", "?"))
|
| 1354 |
return (
|
| 1355 |
f'<em style="color:var(--paper);font-family:Fraunces,serif;font-size:14px;line-height:1.6;">'
|
| 1356 |
+
f'“{summary}”</em>'
|
| 1357 |
f'<div style="margin-top:8px;font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--paper-mute);">'
|
| 1358 |
+
f'— {name} · via {src}</div>'
|
| 1359 |
)
|
| 1360 |
|
| 1361 |
|
|
|
|
| 1375 |
# ─── App logic (async gen helper) ─────────────────────────────────────────────
|
| 1376 |
|
| 1377 |
def _gen_async(state: AppState, context: GenerationContext, cm: CastMember, now_str: str):
|
|
|
|
| 1378 |
import time as _time
|
| 1379 |
start = _time.time()
|
| 1380 |
+
result, source = _generate_with_llm(context, cm, now_str)
|
| 1381 |
state.today_transmission = result
|
| 1382 |
# Try local Kokoro first. If it fails (Space, missing deps, etc.) and we
|
| 1383 |
# have a matching pre-rendered voice sample for the cast member, fall
|
|
|
|
| 1396 |
from transmission import build_prompt, get_system_prompt
|
| 1397 |
sys_p = get_system_prompt(context.persona.timeline_divergence_score)
|
| 1398 |
usr_p = build_prompt(context, cm)
|
|
|
|
| 1399 |
parsed = {
|
| 1400 |
+
"title": result.title, "text": result.text,
|
| 1401 |
"actionPrompt": result.action_prompt, "cliffhanger": result.cliffhanger,
|
| 1402 |
} if result else None
|
| 1403 |
# Run note extraction (Nemotron-Parse or keyword fallback) for the trace
|
|
|
|
| 1421 |
parsed_output=parsed,
|
| 1422 |
insights=insights,
|
| 1423 |
duration_ms=duration_ms,
|
| 1424 |
+
source=source,
|
| 1425 |
)
|
| 1426 |
except Exception as exc:
|
| 1427 |
logger.warning("Failed to log agent trace: %s", exc)
|
|
|
|
| 1512 |
body += _signal_path("onboard")
|
| 1513 |
return body
|
| 1514 |
p = state.persona
|
| 1515 |
+
name_safe = html_escape(p.name or "you")
|
| 1516 |
+
city_safe = html_escape(p.city or "everywhere")
|
| 1517 |
+
chapter_safe = html_escape(p.current_chapter or "a chapter still forming")
|
| 1518 |
+
avoiding_safe = html_escape(p.avoiding or "something you keep circling")
|
| 1519 |
+
eyebrow = _chamber_eyebrow("line idle", f"{name_safe} · {city_safe}")
|
| 1520 |
+
title = _chamber_title(f"Ready when you are, <em>{name_safe}</em>.")
|
| 1521 |
continuity_hint = ""
|
| 1522 |
if state.recent_choices:
|
| 1523 |
last_choice = state.recent_choices[-1]
|
|
|
|
| 1530 |
continuity_hint = f" The line has been listening since you last chose to {choice_labels.get(last_choice.choice, 'continue')}."
|
| 1531 |
body_html = _chamber_body(
|
| 1532 |
f"Check in with one word to open the line. "
|
| 1533 |
+
f"You are in <em>{chapter_safe}</em>, "
|
| 1534 |
+
f"and avoiding <em>{avoiding_safe}</em>."
|
| 1535 |
f"{continuity_hint}"
|
| 1536 |
)
|
| 1537 |
callout = ""
|
|
|
|
| 1539 |
last = state.recent_transmissions[-1]
|
| 1540 |
callout = _chamber_callout(
|
| 1541 |
"last transmission",
|
| 1542 |
+
f'<em>“{html_escape(last.title)}”</em> · {html_escape(last.date_key)}'
|
| 1543 |
)
|
| 1544 |
+
chapter_truncated = p.current_chapter[:40] + "…" if p.current_chapter and len(p.current_chapter) > 40 else (p.current_chapter or "—")
|
| 1545 |
meta = _chamber_meta([
|
| 1546 |
+
("arc", html_escape(p.primary_arc or "—")),
|
| 1547 |
+
("chapter", html_escape(chapter_truncated)),
|
| 1548 |
])
|
| 1549 |
out = _identity_strip(state)
|
| 1550 |
out += _signal_chamber(eyebrow, title, body_html, meta_html=meta, callout_html=callout)
|
|
|
|
| 1556 |
|
| 1557 |
|
| 1558 |
def _render_awaiting(state: AppState) -> str:
|
| 1559 |
+
word_safe = html_escape(state.check_in_word or "—")
|
| 1560 |
+
eyebrow = _chamber_eyebrow("signal locked", f'word · {word_safe}')
|
| 1561 |
+
title = _chamber_title(f'<em>“{word_safe}”</em> received.')
|
| 1562 |
body_html = _chamber_body(
|
| 1563 |
"The note is being read. The line is open. "
|
| 1564 |
"Open the line below to receive your transmission."
|
|
|
|
| 1598 |
if not t:
|
| 1599 |
return _render_home(state)
|
| 1600 |
label = CAST_MEMBER_NAMES.get(state.today_cast or "future_self", ("", ""))[0] or "Future Self"
|
| 1601 |
+
title_safe = html_escape(t.title or "")
|
| 1602 |
+
text_safe = html_escape(t.text or "")
|
| 1603 |
+
action_safe = html_escape(t.action_prompt or "")
|
| 1604 |
+
cliff_safe = html_escape(t.cliffhanger or "")
|
| 1605 |
eyebrow = _chamber_eyebrow("transmission received", label)
|
| 1606 |
+
title = _chamber_title(f'<em>{title_safe}</em>')
|
| 1607 |
+
body_html = _chamber_body(text_safe)
|
| 1608 |
+
actions = ""
|
| 1609 |
+
if state.today_audio:
|
| 1610 |
+
actions = _audio_module(label, state.today_audio)
|
| 1611 |
+
elif state.generation_done:
|
| 1612 |
+
actions = _audio_dropped_module(label)
|
| 1613 |
+
# Share module gets raw text — it's payload-encoded for clipboard, not HTML-interpolated.
|
| 1614 |
actions += _share_module(t.title, t.text, t.action_prompt, t.cliffhanger)
|
| 1615 |
# The chamber holds the audio module + tonight's move. Tomorrow's cliffhanger
|
| 1616 |
# is shown as a second anchor line below the chamber so the page has a clear
|
| 1617 |
# rhythm: text → audio + tonight → tomorrow → constellation → memory log.
|
| 1618 |
+
tonight = _chamber_callout("tonight's move", action_safe)
|
| 1619 |
+
tomorrow = _chamber_callout("tomorrow", cliff_safe)
|
| 1620 |
out = _identity_strip(state)
|
| 1621 |
out += _signal_chamber(eyebrow, title, body_html, actions_html=actions, callout_html=tonight)
|
| 1622 |
out += tomorrow
|
|
|
|
| 2060 |
inputs=[word, note, state], outputs=[content, browser_state, state],
|
| 2061 |
)
|
| 2062 |
|
| 2063 |
+
# Wire generate.
|
| 2064 |
+
# In-flight guard: if a generation thread is already running
|
| 2065 |
+
# (state.generating True and not yet done), ignore subsequent
|
| 2066 |
+
# clicks instead of spawning a second _gen_async thread that
|
| 2067 |
+
# would race the first one on state mutations.
|
| 2068 |
+
def _handle_generate(s: AppState):
|
| 2069 |
+
if s.generating and not s.generation_done:
|
| 2070 |
+
return _render_generating(s.today_cast or "future_self"), s.to_dict(), s
|
| 2071 |
+
s.generating = True
|
| 2072 |
+
s.generation_done = False
|
| 2073 |
+
s.today_audio = ""
|
| 2074 |
+
s.today_transmission = None
|
| 2075 |
+
s.today_cast = _choose_cast(s)
|
| 2076 |
+
threading.Thread(
|
| 2077 |
+
target=_gen_async,
|
| 2078 |
+
args=(s, s.to_context(), s.today_cast, date.today().strftime("%Y-%m-%d %H:%M")),
|
| 2079 |
+
daemon=True,
|
| 2080 |
+
).start()
|
| 2081 |
+
return _render_generating(s.today_cast or "future_self"), s.to_dict(), s
|
| 2082 |
+
|
| 2083 |
generate_btn.click(
|
| 2084 |
+
fn=_handle_generate,
|
| 2085 |
inputs=[state], outputs=[content, browser_state, state],
|
| 2086 |
)
|
| 2087 |
|
demo.mp4
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:911b01ea68ca914f786b9733204412e31523dc49573d955dfb575d2460e6f2fb
|
| 3 |
+
size 58375570
|