File size: 26,786 Bytes
51a9974
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
"""TurboSkillSlug Gradio application."""

from __future__ import annotations

import base64 as b64lib
import html
import json
import os
import shutil
import tempfile
import time
import wave
from pathlib import Path
from typing import Any

import gradio as gr
import httpx

from extract import extract_session
from receipt import generate_receipt_svg
from shell import generate_shell_svg
from shell_animate import wrap_in_iframe, animate_shell_svg
from shell_unroll import build_unroll_doc, N_STAGES
from gallery_client import save_shell, list_shells, get_shell
from battle_trace import render_battle_trace
from shell3d_lens import render_shell_3d
from shell_animate import wrap_in_iframe as _wrap_iframe
from transcribe import transcribe_audio
from trace_parser import parse_trace_to_transcript, detect_trace_format
from model_guard import APP_RUNTIME

# REQ-01 guard: mark this process as the live app. From here on, any attempt to
# call an over-32B model (via assert_small_model) will raise instead of shipping
# a compliance violation. Offline eval/research scripts never enable this flag.
APP_RUNTIME.enable()


# Growth stages for the live shell reveal. The slug grows the shell as it
# watches: spiral lengthens, knots and jewels form in order, aperture opens last.
GROWTH_STAGES = [0.18, 0.38, 0.58, 0.78, 1.0]
GROWTH_PACING_SECONDS = 0.55  # brief pause between stages so the eye sees growth

TTS_URL = os.environ.get(
    "MODAL_TTS_URL",
    "https://anubhavbharadwaaj--slugvoice-tts-slugtts-api.modal.run",
)
SAMPLE_WAV_NAME = "sample_session.wav"
SAMPLE_WAV_PATH = Path(__file__).parent / SAMPLE_WAV_NAME


def _resolve_audio_path(audio: str | None) -> str | None:
    """Return a readable audio path for the pipeline.

    Gradio example uploads can point at evictable temp files on HF Spaces. If the
    one-click sample's temp copy is gone, recover from the committed repo copy
    by copying it to a fresh temp file owned by this request.
    """
    if audio and os.path.exists(audio):
        return audio

    looks_like_sample = audio and SAMPLE_WAV_NAME in os.path.basename(audio)
    if (looks_like_sample or audio is None) and SAMPLE_WAV_PATH.exists():
        dst = Path(tempfile.mkdtemp(prefix="slug_sample_")) / SAMPLE_WAV_NAME
        shutil.copy(SAMPLE_WAV_PATH, dst)
        print(f"[SAMPLE] recovered evicted sample from committed repo copy: {dst}")
        return str(dst)

    return audio


def _base_url_from_request(request) -> str:
    """Reconstruct the app's public base URL (with trailing /) from a gr.Request.

    On HF Spaces the app sits behind a reverse proxy, so the real public host is
    in x-forwarded-host (not request.url's internal host). We prefer the
    forwarded headers and fall back to request.headers['host'] / request.url.
    """
    try:
        headers = {k.lower(): v for k, v in dict(request.headers).items()}
    except Exception:
        headers = {}
    host = headers.get("x-forwarded-host") or headers.get("host")
    proto = headers.get("x-forwarded-proto") or "https"
    if host:
        return f"{proto}://{host}/"
    # last-resort fallback to the request URL's origin
    try:
        u = str(request.url)
        # strip any path/query
        from urllib.parse import urlparse
        p = urlparse(u)
        if p.scheme and p.netloc:
            return f"{p.scheme}://{p.netloc}/"
    except Exception:
        pass
    return "/"


def _speak_recap(slug_lines: list[str]) -> str | None:
    """Convert slug recap to speech via Chatterbox on Modal."""
    try:
        full_text = ". ".join(slug_lines) + ". I was here."
        resp = httpx.post(TTS_URL, json={"text": full_text}, timeout=180)
        resp.raise_for_status()
        audio_b64 = resp.json().get("audio", "")
        if audio_b64:
            audio_bytes = b64lib.b64decode(audio_b64)
            tmp = Path(tempfile.mkdtemp()) / "slug_speaks.wav"
            tmp.write_bytes(audio_bytes)
            return str(tmp)
    except Exception as e:
        print(f"TTS failed: {e}")
    return None


def _get_audio_duration_minutes(file_path: str) -> float:
    """Get actual audio duration in minutes from the file itself."""
    try:
        with wave.open(file_path, "r") as f:
            return (f.getnframes() / f.getframerate()) / 60
    except Exception:
        try:
            size = os.path.getsize(file_path)
            return round(size / (32000 * 60), 1)
        except Exception:
            return 1.0


def _format_slug_recap(extraction: dict[str, Any]) -> str:
    """Format the slug's witness recap for display."""
    slug_voice = extraction.get("slug_voice", [])
    utterances = "\n\n".join(f"*{u}*" for u in slug_voice)

    # The slug's closing signature
    utterances += "\n\n*I was here.*"

    themes = ", ".join(extraction.get("themes", []))
    duration = extraction.get("duration_minutes", 0)

    return (
        "## what the slug witnessed\n\n"
        f"{utterances}\n\n"
        f"**Duration:** {duration} minutes\n\n"
        f"**Themes:** {themes}\n"
    )


def _format_shell_legend(extraction: dict[str, Any]) -> str:
    """Return a compact genre-aware legend for the shell display."""
    genre = str(extraction.get("genre") or "session")
    legend = extraction.get("shell_legend") or {}
    if not isinstance(legend, dict) or not legend:
        return ""

    knot = html.escape(str(legend.get("knot", "dead ends")))
    jewel = html.escape(str(legend.get("jewel", "gotchas")))
    aperture = html.escape(str(legend.get("aperture", "breakthrough")))
    genre_label = html.escape(genre.replace("_", " "))
    return (
        '<div style="font: 13px system-ui, sans-serif; '
        'color: #4d4637 !important; background: #fff8e8 !important; '
        'border: 1px solid #c8a24c; border-radius: 8px; '
        'padding: 10px 12px; margin: 8px 0 12px;">'
        f'<strong>Shell legend ({genre_label})</strong>: knots = {knot}; '
        f'jewels = {jewel}; aperture = {aperture}.'
        "</div>"
    )


def on_audio_change(audio: str | None):
    """Fired the moment a file is selected/recorded/cleared.

    Disables the button and shows a 'preparing' state during the window
    after upload but before the user can meaningfully click submit, so a
    premature click is impossible and there is always visible feedback.
    """
    if audio is None:
        return (
            gr.update(value="๐ŸŒ give it to the slug", interactive=False),
            gr.update(value=""),
        )
    return (
        gr.update(value="๐ŸŒ give it to the slug", interactive=True),
        gr.update(value="*The slug is ready. Hand it your session.*"),
    )


def _empty_outputs(message: str):
    """Twelve-tuple matching the output components, for early/error yields."""
    return (message, "", "", "", "", None, None, None, None, None, None, None)


def _finalize_outputs(transcript_display: str, extraction: dict, slug_audio_path):
    """Build the full ten-tuple of outputs once the shell is fully grown.

    Shared by the audio and trace paths so both render identically.
    """
    raw_json = json.dumps(extraction, indent=2)
    # The full static shell is what we save for download (clean, portable).
    shell_svg = generate_shell_svg(extraction, growth=1.0)
    # For the live display, build the scroll-unroll: a flipbook of growth stages
    # (each truncates the spiral ALONG its arm) led by a 3D paper curl riding the
    # tip. This lays the parchment down along the spiral path, not radially.
    try:
        stages = [
            generate_shell_svg(extraction, growth=(i + 1) / N_STAGES)
            for i in range(N_STAGES)
        ]
        unroll_doc = build_unroll_doc(stages)
        shell_display = wrap_in_iframe(unroll_doc, height=660)
    except Exception:
        # Never let the animation break the result: fall back to the static shell.
        shell_display = wrap_in_iframe(shell_svg, height=660)
    shell_display = _format_shell_legend(extraction) + shell_display
    receipt_svg = generate_receipt_svg(extraction)

    tmp_dir = Path(tempfile.mkdtemp(prefix="slug_"))
    svg_path = tmp_dir / "shell.svg"
    svg_path.write_text(shell_svg)
    receipt_path = tmp_dir / "receipt.svg"
    receipt_path.write_text(receipt_svg)
    skill_path = tmp_dir / "skill.md"
    skill_path.write_text(extraction.get("skill_md", ""))
    recap_lines = list(extraction.get("slug_voice", [])) + ["I was here."]
    recap_path = tmp_dir / "slug_recap.txt"
    recap_path.write_text("\n\n".join(recap_lines))

    return (
        transcript_display,
        _format_slug_recap(extraction),
        shell_display,
        receipt_svg,
        raw_json,
        slug_audio_path,
        str(svg_path),
        str(receipt_path),
        str(skill_path),
        str(recap_path),
        shell_svg,        # static SVG, for the "keep this shell" gallery save
        extraction,       # metadata, for the gallery entry
    )


def _grow_shell_stages(extraction: dict, transcript_display: str):
    """Yield a single 'shaping' status while the browser-played birth animation
    is prepared. The smooth growth now happens in the browser (shards / draw /
    glass), so we no longer stream mechanical server-side frames. One gentle
    status, then the caller yields the finished animated shell.
    """
    yield (
        "*The slug is shaping your shell...*",
        "*the shell is coming into being...*",
        "", "", "", None, None, None, None, None, None, None,
    )


def process_audio(audio: str | None):
    """Transcribe, extract, then GROW the shell live, yielding each stage."""
    audio = _resolve_audio_path(audio)
    if audio is None or not os.path.exists(audio):
        yield _empty_outputs(
            "The slug couldn't find that audio file. On HF Spaces an upload can "
            "expire; please re-upload and try again."
        )
        return

    try:
        transcript = transcribe_audio(audio)
        extraction = extract_session(transcript)
    except Exception as e:
        print(f"[PIPELINE] audio path failed: {type(e).__name__}: {e}")
        yield _empty_outputs(
            "The slug couldn't read this session right now โ€” the extractor is "
            "temporarily unavailable. Please try again, or use the trace tab."
        )
        return
    slug_audio_path = _speak_recap(extraction.get("slug_voice", []))

    audio_minutes = round(_get_audio_duration_minutes(audio), 1)
    model_minutes = extraction.get("duration_minutes", 0)
    try:
        model_minutes = float(model_minutes)
    except (TypeError, ValueError):
        model_minutes = 0.0
    extraction["duration_minutes"] = round(max(audio_minutes, model_minutes), 1)

    transcript_display = f"## Transcript\n\n{transcript}"
    yield from _grow_shell_stages(extraction, transcript_display)
    yield _finalize_outputs(transcript_display, extraction, slug_audio_path)



def process_trace(trace_file: str | None):
    """Parse an agent session JSONL trace, then GROW the shell live.

    A Claude Code or Codex CLI session log becomes witness testimony the slug
    reads exactly like a spoken transcript. This is the input judges can feed
    from their own machines.
    """
    if trace_file is None:
        yield _empty_outputs("Drop a Claude Code or Codex session trace first.")
        return

    try:
        with open(trace_file, "r", encoding="utf-8", errors="replace") as f:
            jsonl_string = f.read()
    except Exception as e:
        yield _empty_outputs(f"Could not read the trace: {e}")
        return

    source = detect_trace_format(jsonl_string)
    transcript = parse_trace_to_transcript(jsonl_string)
    if not transcript:
        yield _empty_outputs("The slug could not find a session in that file.")
        return

    extraction = extract_session(transcript)
    slug_audio_path = _speak_recap(extraction.get("slug_voice", []))

    # Duration: no audio file, so trust the model's estimate.
    model_minutes = extraction.get("duration_minutes", 0)
    try:
        model_minutes = float(model_minutes)
    except (TypeError, ValueError):
        model_minutes = 0.0
    extraction["duration_minutes"] = round(model_minutes, 1) if model_minutes > 0 else 1.0

    label = {"claude": "Claude Code", "codex": "Codex CLI"}.get(source, "agent")
    transcript_display = (
        f"## Session trace ({label})\n\n"
        f"The slug read your {label} session and witnessed this:\n\n{transcript}"
    )

    yield from _grow_shell_stages(extraction, transcript_display)
    yield _finalize_outputs(transcript_display, extraction, slug_audio_path)


def build_interface() -> gr.Blocks:
    """Build and return the TurboSkillSlug Gradio Blocks interface."""
    with gr.Blocks(title="TurboSkillSlug") as demo:
        gr.Markdown(
            "# ๐ŸŒ TurboSkillSlug\n\n"
            "*A small, slow companion who watches you build.*\n\n"
            "Give the slug a build session: narrate it aloud, or drop an agent "
            "session trace. It watches, then grows you a shell, a recap, a "
            "SKILL.md, and a receipt."
        )

        with gr.Row():
            with gr.Column(scale=1):
                with gr.Tabs():
                    with gr.Tab("narrate aloud"):
                        audio_input = gr.Audio(
                            sources=["upload", "microphone"],
                            type="filepath",
                            label="your build session",
                        )
                        gr.Examples(
                            examples=[str(SAMPLE_WAV_PATH)],
                            inputs=audio_input,
                            label="or try a sample session",
                        )
                        submit_button = gr.Button(
                            "๐ŸŒ give it to the slug", variant="primary",
                            interactive=False,
                        )
                    with gr.Tab("drop a session trace"):
                        gr.Markdown(
                            "Drag a **Claude Code** "
                            "(`~/.claude/projects/.../*.jsonl`) or **Codex CLI** "
                            "(`~/.codex/sessions/.../*.jsonl`) session log. "
                            "The slug reads your actual work, no narration needed."
                        )
                        trace_input = gr.File(
                            label="session trace (.jsonl)",
                            file_types=[".jsonl"],
                            type="filepath",
                        )
                        gr.Examples(
                            examples=["sample_trace.jsonl"],
                            inputs=trace_input,
                            label="or try a sample trace",
                        )
                        trace_button = gr.Button(
                            "๐ŸŒ let the slug read it", variant="primary",
                        )
            with gr.Column(scale=1):
                status_line = gr.Markdown("")

        # ---- THE SHELL, front and center, right under the inputs ----
        # This is what plays during generation. Everything else stays hidden
        # until the shell has finished being born.
        shell_output = gr.HTML(label="your shell")

        # State holders for the current shell (static SVG + extraction), used by
        # the "keep this shell" gallery save and the experimental battle replay.
        cur_shell_svg = gr.State(None)
        cur_extraction = gr.State(None)

        # ---- EVERYTHING ELSE, hidden until the shell finishes ----
        with gr.Group(visible=False) as gifts_group:
            gr.Markdown("### the slug's other gifts")
            with gr.Row():
                keep_button = gr.Button("๐Ÿš keep this shell in the terrarium",
                                        variant="secondary")
                keep_status = gr.Markdown("")
            recap_output = gr.Markdown(label="slug recap")
            slug_audio = gr.Audio(label="the slug speaks", type="filepath")
            receipt_output = gr.HTML(label="your receipt")
            transcript_output = gr.Markdown(label="transcript")
            raw_json_output = gr.Code(label="Raw JSON", language="json")
            with gr.Row():
                svg_download = gr.File(label="shell.svg")
                receipt_download = gr.File(label="receipt.svg")
                skill_download = gr.File(label="skill.md")
                recap_download = gr.File(label="slug_recap.txt")

            # Experimental: the LITERAL temporal replay of the session as a war
            # between you (the Agent) and the Environment. The shell remembers
            # the campaign as a frozen folding screen; this replays it in time.
            with gr.Accordion("โš” the battle, as it happened (replay)", open=False):
                gr.Markdown(
                    "The shell is how the slug *remembers* the battle: the "
                    "aftermath, frozen like a folding screen, where the dead "
                    "ends fell and where the breakthrough struck. This is the "
                    "*replay* of that same battle as it actually happened in "
                    "time, your moves against the Environment's strikes (dead "
                    "ends), the clashes (gotchas), and the blow that finally "
                    "lands (the breakthrough). Memory above; footage here."
                )
                battle_view = gr.HTML()
                battle_button = gr.Button("โš” replay the battle the slug remembers")

                def _show_battle(extraction):
                    if not extraction:
                        return gr.update(value="*The slug has no battle to replay yet.*")
                    return gr.update(value=render_battle_trace(extraction, height=440))

                battle_button.click(
                    fn=_show_battle,
                    inputs=[cur_extraction],
                    outputs=[battle_view],
                )

            # The shell as a real 3D object you can turn in the light. Same session
            # data, a different lens: the SceneGraph drives a Three.js nautilus with
            # iridescent nacre. First of the planned multi-lens renderers.
            with gr.Accordion("๐Ÿ”ฎ turn the shell in 3D (experimental lens)", open=False):
                gr.Markdown(
                    "The same shell, rendered as a real object you can orbit. The "
                    "spiral's growth, the knots (dead ends), the glowing aperture "
                    "(breakthrough), and the colour arc all come from your session, "
                    "now with true iridescent nacre. Drag to turn it; scroll to zoom."
                )
                shell3d_view = gr.HTML()
                shell3d_button = gr.Button("๐Ÿ”ฎ see this shell in 3D")

                def _show_shell3d(extraction):
                    if not extraction:
                        return gr.update(value="*No shell to render in 3D yet.*")
                    html = render_shell_3d(extraction)
                    if not html:
                        return gr.update(value="*The 3D lens is unavailable right now.*")
                    return gr.update(value=html)

                shell3d_button.click(
                    fn=_show_shell3d,
                    inputs=[cur_extraction],
                    outputs=[shell3d_view],
                )

        # Enable the button only once audio is actually present
        audio_input.change(
            fn=on_audio_change,
            inputs=audio_input,
            outputs=[submit_button, status_line],
        )

        # The 12 pipeline outputs (10 UI + 2 state for the gallery save)
        pipeline_outputs = [
            transcript_output,
            recap_output,
            shell_output,
            receipt_output,
            raw_json_output,
            slug_audio,
            svg_download,
            receipt_download,
            skill_download,
            recap_download,
            cur_shell_svg,
            cur_extraction,
        ]

        def _hide_gifts():
            # On start: hide the gifts, lock the button, set a watching status
            return (
                gr.update(visible=False),
                gr.update(value="๐ŸŒ the slug is watchingโ€ฆ", interactive=False),
                gr.update(value="*The slug is listening, then it will grow your shell...*"),
            )

        def _reveal_gifts():
            # On finish: reveal the gifts, reset the button
            return (
                gr.update(visible=True),
                gr.update(value="๐ŸŒ give it to the slug", interactive=True),
                gr.update(value="*The slug has finished. Your gifts are below.*"),
            )

        # Audio path
        submit_button.click(
            fn=_hide_gifts,
            inputs=None,
            outputs=[gifts_group, submit_button, status_line],
        ).then(
            fn=process_audio,
            inputs=audio_input,
            outputs=pipeline_outputs,
        ).then(
            fn=_reveal_gifts,
            inputs=None,
            outputs=[gifts_group, submit_button, status_line],
        )

        # Trace path
        trace_button.click(
            fn=_hide_gifts,
            inputs=None,
            outputs=[gifts_group, submit_button, status_line],
        ).then(
            fn=process_trace,
            inputs=trace_input,
            outputs=pipeline_outputs,
        ).then(
            fn=_reveal_gifts,
            inputs=None,
            outputs=[gifts_group, submit_button, status_line],
        )

        # ---- Stage B: save the current shell to the shared terrarium ----
        def _keep_shell(svg, extraction, request: gr.Request):
            if not svg or not extraction:
                return gr.update(value="*No shell to keep yet.*")
            sid = save_shell(svg, extraction)
            if not sid:
                return gr.update(value="*The terrarium was unreachable; shell not saved.*")
            # Build a COMPLETE clickable link (not a bare ?shell= fragment, which
            # is confusing). Derive the base URL from the request's headers.
            base = _base_url_from_request(request)
            link = f"{base}?shell={sid}"
            return gr.update(
                value=(
                    f"๐Ÿš **Kept in the terrarium.** Here's the shareable link to "
                    f"this exact shell:\n\n[{link}]({link})\n\n"
                    f"_Anyone who opens it sees this shell unroll. You can also "
                    f"browse all kept shells in the terrarium below._"
                )
            )

        keep_button.click(
            fn=_keep_shell,
            inputs=[cur_shell_svg, cur_extraction],
            outputs=[keep_status],
        )

        # ---- Stage C: the shared gallery (a living terrarium of kept shells) ----
        def _render_gallery(request: gr.Request):
            shells = list_shells(limit=60)
            if not shells:
                return ("*The terrarium is empty so far. Grow a shell and keep it "
                        "to be the first.*")
            base = _base_url_from_request(request)
            # build a responsive grid of shells; each loads its SVG by id
            cards = []
            for e in shells:
                sid = e.get("id")
                title = e.get("title") or "a session"
                dur = e.get("duration")
                arc = f"{e.get('start','?')} โ†’ {e.get('end','?')}"
                sub = f"{dur}m ยท {arc}" if dur else arc
                data = get_shell(sid) if sid else None
                svg = (data or {}).get("svg", "")
                if not svg:
                    continue
                # shrink each shell into a card; static, no replay button (these
                # are kept shells, like photos in an album โ€” the replay belongs
                # to the focused permalink view below).
                frame = _wrap_iframe(svg, height=240, replay=False)
                link = f"{base}?shell={sid}"
                cards.append(
                    f'<div style="flex:0 0 250px;margin:8px;text-align:center;">'
                    f'<div style="font:600 13px Georgia,serif;color:#c8a24c;">{title}</div>'
                    f'<div style="font:11px monospace;color:#999;margin-bottom:4px;">{sub}</div>'
                    f'{frame}'
                    f'<div style="margin-top:4px;"><a href="{link}" target="_blank" '
                    f'style="font:12px Georgia,serif;color:#6ee7ff;text-decoration:none;">'
                    f'open this shell โ†’</a></div>'
                    f'</div>'
                )
            grid = (
                '<div style="display:flex;flex-wrap:wrap;justify-content:center;">'
                + "".join(cards) + "</div>"
            )
            return grid

        def _load_permalink(request: gr.Request):
            """If the URL has ?shell=<id>, load that single shell on page open."""
            try:
                sid = dict(request.query_params).get("shell")
            except Exception:
                sid = None
            if not sid:
                return gr.update()
            data = get_shell(sid)
            if not data or not data.get("svg"):
                return gr.update(value="*That shell could not be found in the terrarium.*")
            # Re-animate the saved static shell for the focused permalink view:
            # the mask-based scroll-unroll works on a single SVG (no growth
            # stages needed), so this shell unrolls and the replay button works.
            try:
                animated = animate_shell_svg(data["svg"])
                return gr.update(value=wrap_in_iframe(animated, height=520, replay=True))
            except Exception:
                return gr.update(value=_wrap_iframe(data["svg"], height=520, replay=False))

        with gr.Accordion("๐ŸŒฟ the terrarium (shared gallery)", open=False):
            gr.Markdown(
                "A shared collection of shells people have kept. Each one is the "
                "fingerprint of a real session. Open `?shell=<id>` to link a "
                "specific shell."
            )
            permalink_view = gr.HTML()
            refresh_gallery = gr.Button("โ†ป refresh the terrarium")
            gallery_grid = gr.HTML()
            refresh_gallery.click(fn=_render_gallery, inputs=None, outputs=[gallery_grid])
            # populate on load + handle ?shell= permalink
            demo.load(fn=_render_gallery, inputs=None, outputs=[gallery_grid])
            demo.load(fn=_load_permalink, inputs=None, outputs=[permalink_view])

    return demo


interface = build_interface()

if __name__ == "__main__":
    interface.launch()