diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 29898a6..3c165f4 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -37,6 +37,7 @@ "./skills/mc-graphics", "./skills/mc-assets", "./skills/mc-audio", + "./skills/mc-prompter", "./skills/mc-package", "./skills/mc-stream-pack", "./skills/mc-retro" diff --git a/skills/mc-prompter/SKILL.md b/skills/mc-prompter/SKILL.md new file mode 100644 index 0000000..e90e7cc --- /dev/null +++ b/skills/mc-prompter/SKILL.md @@ -0,0 +1,33 @@ +--- +name: mc-prompter +description: Browser teleprompter for the record stage and standalone shows. A service skill like mc-audio, no stage, no gate, no project.json state. Launch a local prompter server, feed it the project script.md or any text, and the creator records at their own pace with a phone remote over LAN. Classic teleprompter only in this version; voice-follow and producer mode are planned tiers. +--- + +# mc-prompter + +The record stage is creator-owned; this skill hands the creator a teleprompter for it. It is a service skill: it owns no stage, stops at no gate, and writes no project state. It launches a local web server that serves a fullscreen prompter display, a phone remote, a home page for loading and editing the script, and an OBS overlay placeholder. Everything runs offline on the creator's machine; no models, no downloads, no external requests. + +## Steps + +1. Load this skill's own surface (`uv run {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root}`; run `{workflow.activation_steps_prepend}` now, `{workflow.activation_steps_append}` after this step, and hold `{workflow.persistent_facts}` as standing context). Take the default port from `[prompter] port`. If a studio config exists, also load it (`uv run {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root} --key modules.manticore`) and take `[prompter] port` and `[owner] wpm` when present; a missing studio config is fine here, unlike the stage skills, because standalone shows need no studio. +2. Locate the script. Inside a pipeline project ("record with the teleprompter"), it is the project's `script.md` under the project folder. Standalone, it is any file path the creator names, markdown or plain text. No file at all is also valid: launch without `--script` and the creator pastes text on the home page. +3. Launch: `uv run {skill-root}/scripts/run_prompter.py --script ` with `--port ` when the config or the creator sets one and `--owner-wpm ` when `[owner] wpm` is known. Add `--lan` only when the creator wants the phone remote or a tablet display; it binds the LAN and on Windows triggers a firewall consent dialog. The launcher probes the port, prints the local URL, the remote URL with its session token, and the session file path, then keeps the server running until Ctrl-C. +4. Give the creator the URLs from the launcher output: the prompt page for the recording display, and the remote URL (token included) to open on a phone when `--lan` is on. Briefly explain the pages: `/` is home (load a file, paste text, edit in place, copy the remote URL), `/prompt` is the fullscreen scroller with keyboard controls and a settings drawer (press `?` there for all shortcuts), `/remote` is the phone controller, `/overlay` is an OBS browser-source placeholder for now. +5. Explain what the display does with pipeline markers: paragraphs carrying a `[TAKE ...]` marker render dimmed with a "have it already" badge because that line was already spoken well in the interview footage, and a toggle hides them entirely; sentences flagged `[INVENTED]` get a subtle badge, toggleable off; other bracketed text renders as a dimmed note and is never counted in timing. +6. If the creator edits the script from the home page and saves, the server first copies the current file to a timestamped backup under the temp session directory, then writes the edit back to the source file, so the prompted text and the pipeline artifact never silently diverge. "Session only" applies the edit without touching the file. +7. When the creator asks for voice-follow scrolling or producer mode, say plainly that those are planned tiers that have not landed yet and the classic prompter is what works today. Never pretend they work and never improvise a substitute. + +## Rules + +- The prompter never advances pipeline state; when a pipeline project is being recorded, the record stage remains the creator's, and mc-pipeline stays the source of truth. +- The server binds localhost by default; `--lan` is opt-in and the remote URL carries a per-session token so a random LAN device cannot drive the prompter mid-show. +- Loading files by path and saving edits work only from the server machine, never from a LAN device. +- Stop and relay the launcher's guidance when it exits nonzero: a missing or unreadable script path, an explicit port already held by another session, or a server that failed to start are all creator-facing problems, not things to retry silently. + +## Checklist + +- The port and wpm came from config when a studio config exists; defaults otherwise. +- The creator got both URLs (prompt page, remote with token) and knows the pages. +- Take and invented markers were explained if the script contains them. +- Any in-place save was backed up first (the server does this; confirm the backup path in its response). +- No planned tier was presented as working. diff --git a/skills/mc-prompter/customize.toml b/skills/mc-prompter/customize.toml new file mode 100644 index 0000000..45e4e9d --- /dev/null +++ b/skills/mc-prompter/customize.toml @@ -0,0 +1,26 @@ +# DO NOT EDIT -- overwritten on every update. +# +# Customization surface for mc-prompter. +# Override files (not edited here): +# {project-root}/_bmad/custom/mc-prompter.toml (team) +# {project-root}/_bmad/custom/mc-prompter.user.toml (personal) +# +# Studio-wide config (owner, paths, prompter defaults) lives in +# [modules.manticore] in {project-root}/_bmad/custom/config.toml, +# maintained by mc-setup. This file holds only this skill's own surface. + +[workflow] + +# Steps to run before / after the standard activation. +activation_steps_prepend = [] +activation_steps_append = [] + +# Persistent facts held for the whole run: literal sentences, or +# "file:{project-root}/..." paths whose contents are loaded as facts. +persistent_facts = [] + +[prompter] + +# Default port for the prompter server; the studio config's +# [prompter] port overrides this when set. +port = 8770 diff --git a/skills/mc-prompter/scripts/run_prompter.py b/skills/mc-prompter/scripts/run_prompter.py new file mode 100644 index 0000000..424ca2c --- /dev/null +++ b/skills/mc-prompter/scripts/run_prompter.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Launch the mc-prompter teleprompter server (Phase A). + +Pure stdlib launcher. Probes the port, generates the session token, spawns +the aiohttp server as a child process, confirms it is up, writes the session +file, prints the URLs, and waits on the child (Ctrl-C terminates it). + +Usage: + uv run {skill-root}/scripts/run_prompter.py --script + [--port 8770] [--lan] [--owner-wpm 150] [--no-open] + +Behavior: + port default 8770. If busy, /health on 127.0.0.1 is queried (1 s + timeout); when it answers app == "mc-prompter" the running + session info is printed. Without an explicit --port the + launcher auto-increments to the next free port; with an + explicit --port it exits 5 with guidance. + spawn `uv run --with aiohttp==3.12.15 python -m server.main ` + with cwd set to this scripts directory so the server package + resolves. Server args are all explicit: --port --host --script + --owner-wpm --session-file --token. + session token = secrets.token_urlsafe(16). After /health confirms the + server is up, /mc-prompter/session-.json is + written: {"port": N, "pid": ..., "token": "...", + "script": "...", "started": ""}. The file is removed + when the launcher exits. + --lan binds 0.0.0.0 and prints the LAN URL (best-effort local IP); + expect a Windows Firewall consent dialog on Windows. Without + it the server binds 127.0.0.1 and only localhost URLs print. + --no-open skip opening the browser at the home page. + +Exit codes: 0 ok, 1 server failed to start, 2 usage, 4 script path missing +or unreadable, 5 port conflict with an explicit --port. +""" + +import argparse +import contextlib +import datetime +import json +import os +import secrets +import signal +import socket +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +import webbrowser +from pathlib import Path + +SCRIPTS_DIR = Path(__file__).resolve().parent +DEFAULT_PORT = 8770 +AIOHTTP_PIN = "aiohttp==3.12.15" +HEALTH_TIMEOUT = 1.0 +# Generous cap: a cold uv cache must resolve, download, and build aiohttp +# before the server can even start, which can take minutes on a slow +# network. wait_for_health fails immediately if the child exits. +STARTUP_TIMEOUT = 300.0 +PORT_SCAN_LIMIT = 20 + + +class PortConflict(Exception): + """Requested port is busy and --port was explicit (exit 5).""" + + def __init__(self, port, info=None): + self.port = port + self.info = info + super().__init__(f"port {port} is busy") + + +def port_is_free(port, host="127.0.0.1"): + """True when the port can be bound on the given host.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind((host, port)) + return True + except OSError: + return False + + +def health_identity(port, timeout=HEALTH_TIMEOUT): + """GET /health on loopback; return its JSON when it is an mc-prompter.""" + url = f"http://127.0.0.1:{port}/health" + try: + with urllib.request.urlopen(url, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, OSError, ValueError): + return None + if isinstance(data, dict) and data.get("app") == "mc-prompter": + return data + return None + + +def describe_session(info): + return ( + f"session {info.get('session')} serving " + f"{info.get('script') or '(no script)'} since {info.get('started')}" + ) + + +def pick_port(requested, explicit, probe=port_is_free, + identify=health_identity, limit=PORT_SCAN_LIMIT): + """Resolve the port to use, auto-incrementing unless explicit. + + Raises PortConflict when the explicitly requested port is busy, and + RuntimeError when no free port is found within the scan limit. + """ + port = requested + for _ in range(limit): + if probe(port): + return port + info = identify(port) + if explicit: + raise PortConflict(port, info) + if info: + print( + f"port {port} runs an mc-prompter " + f"({describe_session(info)}); trying {port + 1}" + ) + else: + print(f"port {port} is busy; trying {port + 1}") + port += 1 + raise RuntimeError( + f"no free port found in {requested}..{requested + limit - 1}" + ) + + +def session_file_path(port): + return Path(tempfile.gettempdir()) / "mc-prompter" / f"session-{port}.json" + + +def write_session_file(path, port, pid, token, script): + """Write the session JSON the skill reads back; returns the path.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "port": port, + "pid": pid, + "token": token, + "script": str(script), + "started": datetime.datetime.now().isoformat(timespec="seconds"), + } + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + return path + + +def build_server_cmd(port, host, script, owner_wpm, session_file, token): + """The child process command; runs with cwd = the scripts directory.""" + # Phase B seam: when the prompter-lab workspace exists, swap the + # `uv run --with` prefix for `/.venv python -m server.main` + # (POSIX .venv/bin/python, Windows .venv\Scripts\python.exe) with the + # same cwd and the same explicit args. TODO(phase-b): implement the + # workspace-present branch here; do not add config discovery. + return [ + "uv", "run", "--with", AIOHTTP_PIN, "python", "-m", "server.main", + "--port", str(port), + "--host", host, + "--script", str(script) if script else "", + "--owner-wpm", str(owner_wpm or 0), + "--session-file", str(session_file), + "--token", token, + ] + + +def spawn_server(cmd): + """Spawn the server command in its own process group. + + The child is a `uv run` wrapper whose grandchild is the actual python + server; isolating the tree in its own group lets terminate_server + signal all of it, so no orphan keeps the port after shutdown. + """ + if sys.platform == "win32": + return subprocess.Popen( + cmd, cwd=SCRIPTS_DIR, + creationflags=subprocess.CREATE_NEW_PROCESS_GROUP, + ) + return subprocess.Popen(cmd, cwd=SCRIPTS_DIR, start_new_session=True) + + +def terminate_server(child, timeout=10.0): + """Terminate the whole server process tree and reap the child. + + POSIX: SIGTERM the process group, escalate to SIGKILL after timeout. + Windows: terminate()/kill() the child (spawned with its own process + group; uv forwards termination on Windows). + """ + if child.poll() is not None: + return + if sys.platform == "win32": + child.terminate() + try: + child.wait(timeout=timeout) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + return + try: + pgid = os.getpgid(child.pid) + except ProcessLookupError: + child.wait() + return + with contextlib.suppress(ProcessLookupError): + os.killpg(pgid, signal.SIGTERM) + try: + child.wait(timeout=timeout) + except subprocess.TimeoutExpired: + with contextlib.suppress(ProcessLookupError): + os.killpg(pgid, signal.SIGKILL) + child.wait() + + +def local_ip(): + """Best-effort LAN IP via the UDP connect trick (no packets sent).""" + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.connect(("192.0.2.1", 80)) + return sock.getsockname()[0] + except OSError: + return "127.0.0.1" + + +def wait_for_health(port, child, timeout=STARTUP_TIMEOUT): + """Poll /health until the child answers as mc-prompter or dies.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if child.poll() is not None: + return None + info = health_identity(port) + if info: + return info + time.sleep(0.25) + return None + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--script", required=True, + help="path to the script file to prompt") + parser.add_argument("--port", type=int, default=None, + help=f"port (default {DEFAULT_PORT}, " + "auto-increments when busy)") + parser.add_argument("--lan", action="store_true", + help="bind 0.0.0.0 and print the LAN remote URL") + parser.add_argument("--owner-wpm", type=int, default=0, + help="creator wpm for read-time estimates, 0 = unset") + parser.add_argument("--no-open", action="store_true", + help="do not open the browser at the home page") + args = parser.parse_args(argv) + + script = Path(args.script).expanduser().resolve() + if not script.is_file(): + print(f"error: script not found: {script}", file=sys.stderr) + return 4 + try: + script.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + print(f"error: cannot read {script}: {exc}", file=sys.stderr) + return 4 + + explicit = args.port is not None + requested = args.port if explicit else DEFAULT_PORT + try: + port = pick_port(requested, explicit) + except PortConflict as exc: + print(f"error: port {exc.port} is already in use.", file=sys.stderr) + if exc.info: + print( + f" an mc-prompter is running there: " + f"{describe_session(exc.info)}", + file=sys.stderr, + ) + print( + " pick another --port, or omit --port to auto-select one.", + file=sys.stderr, + ) + return 5 + except RuntimeError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + host = "0.0.0.0" if args.lan else "127.0.0.1" + token = secrets.token_urlsafe(16) + session_file = session_file_path(port) + cmd = build_server_cmd(port, host, script, args.owner_wpm, + session_file, token) + + child = spawn_server(cmd) + try: + info = wait_for_health(port, child) + if info is None: + print("error: server failed to start", file=sys.stderr) + terminate_server(child) + return 1 + + write_session_file(session_file, port, child.pid, token, script) + + base_url = f"http://127.0.0.1:{port}" + local_url = f"{base_url}/?token={token}" + print(f"mc-prompter is up (session {token[:8]})") + print(f" home: {local_url}") + print(f" prompt: {base_url}/prompt") + print(f" remote: http://127.0.0.1:{port}/remote?token={token}") + if args.lan: + ip = local_ip() + print(f" LAN remote: http://{ip}:{port}/remote?token={token}") + print(" note: on Windows the first --lan launch triggers a " + "Windows Firewall consent dialog; allow it for the LAN " + "remote to reach the server.") + print(f" session file: {session_file}", flush=True) + + if not args.no_open: + webbrowser.open(local_url) + + def _on_terminate(signum, frame): + raise KeyboardInterrupt + + signal.signal(signal.SIGTERM, _on_terminate) + try: + child.wait() + except KeyboardInterrupt: + terminate_server(child) + return 0 + return 0 if child.returncode == 0 else 1 + finally: + # Never leave a stale session file advertising a dead pid/token. + with contextlib.suppress(OSError): + session_file.unlink(missing_ok=True) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/mc-prompter/scripts/server/__init__.py b/skills/mc-prompter/scripts/server/__init__.py new file mode 100644 index 0000000..e4045c6 --- /dev/null +++ b/skills/mc-prompter/scripts/server/__init__.py @@ -0,0 +1,7 @@ +"""mc-prompter server package (Phase A). + +Launched as `python -m server.main` with cwd set to the scripts directory, +so the package resolves identically under both launch paths (uv run with +aiohttp as a PEP 723 dependency now; the prompter-lab workspace venv in +Phase B). +""" diff --git a/skills/mc-prompter/scripts/server/main.py b/skills/mc-prompter/scripts/server/main.py new file mode 100644 index 0000000..5878f72 --- /dev/null +++ b/skills/mc-prompter/scripts/server/main.py @@ -0,0 +1,542 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = ["aiohttp==3.12.15"] +# /// +"""mc-prompter aiohttp server (Phase A: classic teleprompter). + +Launched by run_prompter.py as `python -m server.main` with cwd set to the +scripts directory (the PEP 723 header above also allows a direct +`uv run main.py` for debugging). All arguments are explicit; the server does +no config discovery. + +Usage: + python -m server.main --port 8770 --host 127.0.0.1|0.0.0.0 + --script --owner-wpm + --session-file --token + +HTTP routes: + GET / home page (static/home.html) + GET /prompt prompter display (static/prompt.html) + GET /remote phone remote (static/remote.html) + GET /overlay OBS overlay (static/overlay.html) + GET /static/* static assets + GET /health identity JSON, no auth: + {"app": "mc-prompter", "version": "0.1.0", + "session": "", + "script": "", "port": N, + "started": ""} + GET /api/state {"snapshot": , "doc-version": n, + "script": {"path", "title", "word-count"} or null, + "config": {"owner-wpm": N or null}} + GET /api/source {"path": , "raw": "", + "doc": + + + + diff --git a/skills/mc-prompter/scripts/server/static/js/home.js b/skills/mc-prompter/scripts/server/static/js/home.js new file mode 100644 index 0000000..97c4c3e --- /dev/null +++ b/skills/mc-prompter/scripts/server/static/js/home.js @@ -0,0 +1,221 @@ +/* mc-prompter home page glue. + * + * Source picker (path load or paste), edit-in-place with save (writes the + * file after a timestamped backup) and session-only apply, script info, + * links to /prompt and /overlay, and the remote URL with token plus copy. + * + * Token discovery: the launcher opens the home page with ?token=... in the + * URL. We keep it in sessionStorage so in-app navigation survives. If no + * token reaches this page, the remote URL is shown without one (read-only on + * other devices) and a warning explains where to find the full URL. + * SEAM(token): if the server later exposes the token to loopback callers in + * GET /api/state config, read it in fetchState() below. + */ +(function () { + 'use strict'; + var MC = window.MC; + + var els = { + session: document.getElementById('session'), + conn: document.getElementById('conn'), + connLabel: document.getElementById('conn-label'), + pathInput: document.getElementById('path-input'), + btnLoad: document.getElementById('btn-load'), + infoPath: document.getElementById('info-path'), + infoTitle: document.getElementById('info-title'), + infoWords: document.getElementById('info-words'), + infoEst: document.getElementById('info-est'), + remoteUrl: document.getElementById('remote-url'), + linkPrompt: document.getElementById('link-prompt'), + linkOverlay: document.getElementById('link-overlay'), + btnCopy: document.getElementById('btn-copy'), + tokenWarning: document.getElementById('token-warning'), + editor: document.getElementById('editor'), + btnApply: document.getElementById('btn-apply'), + btnSave: document.getElementById('btn-save'), + btnRevert: document.getElementById('btn-revert'), + dirtyChip: document.getElementById('dirty-chip'), + msg: document.getElementById('msg') + }; + + var TOKEN_STORE = 'mc-prompter-token'; + var token = new URLSearchParams(location.search).get('token') || null; + if (token) { + try { sessionStorage.setItem(TOKEN_STORE, token); } catch (e) { /* fine */ } + } else { + try { token = sessionStorage.getItem(TOKEN_STORE); } catch (e) { /* fine */ } + } + + var docVersion = null; + var loadedRaw = ''; + var dirty = false; + var currentWpm = 150; + var wordCount = null; + + var ws = MC.createWS({ role: 'home', token: token }); + + // ---------- messages ---------- + + function say(text, kind) { + els.msg.textContent = text || ''; + els.msg.className = kind || ''; + } + + function setDirty(d) { + dirty = d; + els.dirtyChip.classList.toggle('hidden', !d); + } + + // ---------- remote URL ---------- + + function renderRemoteUrl() { + var url = location.origin + '/remote' + (token ? '?token=' + encodeURIComponent(token) : ''); + els.remoteUrl.textContent = url; + els.tokenWarning.classList.toggle('hidden', !!token); + // The prompter display and overlay links need the token too, or they are + // rejected when the home page is opened from another device on the LAN. + els.linkPrompt.href = MC.model.withToken('/prompt', token); + els.linkOverlay.href = MC.model.withToken('/overlay', token); + } + + els.btnCopy.addEventListener('click', function () { + var text = els.remoteUrl.textContent; + function fallbackCopy() { + var range = document.createRange(); + range.selectNodeContents(els.remoteUrl); + var sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + try { document.execCommand('copy'); say('copied', 'ok'); } + catch (e) { say('copy failed, select it manually', 'bad'); } + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(function () { say('copied', 'ok'); }, fallbackCopy); + } else { + fallbackCopy(); + } + }); + + // ---------- script info ---------- + + function renderInfo(path, doc) { + els.infoPath.textContent = path || 'none loaded'; + els.infoTitle.textContent = (doc && doc.title) || '-'; + wordCount = doc ? doc['word-count'] : null; + els.infoWords.textContent = wordCount !== null && wordCount !== undefined ? String(wordCount) : '-'; + renderEst(); + } + + function renderEst() { + var mins = MC.model.estimateMinutes(wordCount, currentWpm); + els.infoEst.textContent = mins === null ? '-' : + MC.model.fmtClock(mins * 60) + ' at ' + currentWpm + ' wpm'; + } + + // ---------- source loading ---------- + + function refreshSource(overwriteEditor) { + return MC.model.fetchSource(token).then(function (src) { + docVersion = src['doc-version']; + loadedRaw = src.raw || ''; + renderInfo(src.path, src.doc); + if (overwriteEditor || !dirty) { + els.editor.value = loadedRaw; + setDirty(false); + } + }).catch(function (err) { + say('could not fetch script: ' + err.message, 'bad'); + }); + } + + els.btnLoad.addEventListener('click', function () { + var path = els.pathInput.value.trim(); + if (!path) { say('enter an absolute path first', 'bad'); return; } + say('loading...'); + MC.model.postJSON('/api/source/load', { path: path }, token).then(function () { + say('loaded', 'ok'); + return refreshSource(true); + }).catch(function (err) { + say('load failed: ' + err.message, 'bad'); + }); + }); + + els.pathInput.addEventListener('keydown', function (e) { + if (e.key === 'Enter') els.btnLoad.click(); + }); + + // ---------- editor ---------- + + els.editor.addEventListener('input', function () { + setDirty(els.editor.value !== loadedRaw); + }); + + function pushSource(save) { + say(save ? 'saving...' : 'applying...'); + MC.model.postJSON('/api/source', { raw: els.editor.value, save: save }, token) + .then(function (res) { + docVersion = res ? res['doc-version'] : docVersion; + loadedRaw = els.editor.value; + setDirty(false); + if (save && res && res.backup) { + say('saved (backup: ' + res.backup + ')', 'ok'); + } else if (save) { + say('saved', 'ok'); + } else { + say('applied for this session', 'ok'); + } + return refreshSource(false); + }) + .catch(function (err) { + say((save ? 'save' : 'apply') + ' failed: ' + err.message, 'bad'); + }); + } + + els.btnApply.addEventListener('click', function () { pushSource(false); }); + els.btnSave.addEventListener('click', function () { pushSource(true); }); + els.btnRevert.addEventListener('click', function () { + els.editor.value = loadedRaw; + setDirty(false); + say('reverted to the last loaded text'); + }); + + // ---------- WS wiring ---------- + + ws.onStatus(function (s) { + els.conn.classList.toggle('on', s.connected); + els.connLabel.textContent = s.connected ? 'connected' : (s.rejected ? 'token rejected' : 'reconnecting'); + els.session.textContent = s.session || ''; + }); + + ws.on('doc-updated', function (msg) { + if (msg['doc-version'] !== docVersion) { + refreshSource(false); + if (dirty) say('script changed elsewhere; your unsaved edits are kept in the editor', 'bad'); + } + }); + + ws.on('state', function (msg) { + if (typeof msg.wpm === 'number' && msg.wpm > 0) { + currentWpm = msg.wpm; + renderEst(); + } + }); + + // ---------- boot ---------- + + renderRemoteUrl(); + MC.model.fetchState(token).then(function (state) { + if (state && state.config && state.config['owner-wpm']) { + currentWpm = state.config['owner-wpm']; + } + if (state && state.snapshot && typeof state.snapshot.wpm === 'number') { + currentWpm = state.snapshot.wpm; + } + if (state && state.script && state.script.path) { + els.pathInput.value = state.script.path; + } + renderEst(); + }).catch(function () { /* defaults are fine */ }).then(function () { + return refreshSource(true); + }); +})(); diff --git a/skills/mc-prompter/scripts/server/static/js/model.js b/skills/mc-prompter/scripts/server/static/js/model.js new file mode 100644 index 0000000..61040f1 --- /dev/null +++ b/skills/mc-prompter/scripts/server/static/js/model.js @@ -0,0 +1,203 @@ +/* mc-prompter script model client (classic script, attaches to window.MC). + * + * Fetches the ingested script model from the server and renders it to DOM. + * + * Model shape (produced by script_ingest.py, see the Phase A contract): + * { title, "word-count", sections: [ { id, heading, level, blocks: [ + * {type:"para", runs:[{text, flags}]}, + * {type:"note", text}, + * {type:"take", source, start, end, runs:[...]} ] } ] } + * + * Rendering contract (Phase B alignment and click-to-anchor build on this): + * - every speakable word is wrapped in + * - take paragraphs get class "take" (dimmed, "have it already" badge, + * hidden entirely when body has class hide-takes) + * - invented runs get class "invented" (subtle badge, muted when body has + * class hide-invented) + * - notes render as class "note" (dimmed, italic, never counted) + */ +(function () { + 'use strict'; + window.MC = window.MC || {}; + + function withToken(url, token) { + if (!token) return url; + return url + (url.indexOf('?') >= 0 ? '&' : '?') + 'token=' + encodeURIComponent(token); + } + + function fetchJSON(url, token) { + return fetch(withToken(url, token), { cache: 'no-store' }).then(function (r) { + if (!r.ok) { + return r.text().then(function (body) { + var err = new Error('HTTP ' + r.status + ' for ' + url + (body ? ': ' + body : '')); + err.status = r.status; + throw err; + }); + } + return r.json(); + }); + } + + function postJSON(url, body, token) { + return fetch(withToken(url, token), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }).then(function (r) { + return r.text().then(function (text) { + var data = null; + try { data = text ? JSON.parse(text) : null; } catch (e) { /* non-JSON error body */ } + if (!r.ok) { + var msg = (data && data.message) ? data.message : ('HTTP ' + r.status); + var err = new Error(msg); + err.status = r.status; + throw err; + } + return data; + }); + }); + } + + // GET /api/source -> {path, raw, doc, "doc-version"} + function fetchSource(token) { return fetchJSON('/api/source', token); } + + // GET /api/state -> {snapshot, "doc-version", script, config} + function fetchState(token) { return fetchJSON('/api/state', token); } + + /* Render the script model into `container` (emptied first). + * Returns an index used by the scroll engine and jump logic: + * { wordCount, takeWordCount, words: [span], + * paragraphs: [{el, wordStart, sectionId}], + * sections: [{id, heading, level, el, wordStart}] } + * takeWordCount is the share of wordCount inside TAKE paragraphs, so the + * page can pace against the visible count when hide-takes is on. + */ + function renderDoc(doc, container) { + while (container.firstChild) container.removeChild(container.firstChild); + + var index = { wordCount: 0, takeWordCount: 0, words: [], paragraphs: [], sections: [] }; + if (!doc || !doc.sections) return index; + + var wordIndex = 0; + + function appendWords(text, parent) { + // Split preserving whitespace so spacing survives verbatim. + var chunks = String(text).split(/(\s+)/); + for (var i = 0; i < chunks.length; i++) { + var chunk = chunks[i]; + if (!chunk) continue; + if (/^\s+$/.test(chunk)) { + parent.appendChild(document.createTextNode(chunk)); + } else { + var span = document.createElement('span'); + span.className = 'w'; + span.dataset.i = String(wordIndex); + span.textContent = chunk; + parent.appendChild(span); + index.words.push(span); + wordIndex += 1; + } + } + } + + function appendRuns(runs, parent) { + for (var i = 0; i < (runs || []).length; i++) { + var run = runs[i]; + var flags = run.flags || []; + if (flags.indexOf('invented') >= 0) { + var inv = document.createElement('span'); + inv.className = 'run invented'; + appendWords(run.text, inv); + parent.appendChild(inv); + } else { + appendWords(run.text, parent); + } + } + } + + for (var s = 0; s < doc.sections.length; s++) { + var sec = doc.sections[s]; + var secEl = document.createElement('section'); + secEl.className = 'sec'; + secEl.dataset.sid = sec.id; + var secEntry = { + id: sec.id, + heading: sec.heading || null, + level: sec.level || 2, + el: secEl, + wordStart: wordIndex + }; + + if (sec.heading) { + var h = document.createElement('h2'); + h.className = 'sec-h'; + h.textContent = sec.heading; + secEl.appendChild(h); + } + + var blocks = sec.blocks || []; + for (var b = 0; b < blocks.length; b++) { + var block = blocks[b]; + if (block.type === 'note') { + var noteEl = document.createElement('p'); + noteEl.className = 'note'; + noteEl.textContent = block.text || ''; + secEl.appendChild(noteEl); + } else if (block.type === 'take') { + var takeEl = document.createElement('p'); + takeEl.className = 'para take'; + // Phase B seam: source clip identity rides on data attributes. + if (block.source !== undefined) takeEl.dataset.source = String(block.source); + if (block.start !== undefined) takeEl.dataset.start = String(block.start); + if (block.end !== undefined) takeEl.dataset.end = String(block.end); + var takeStart = wordIndex; + appendRuns(block.runs, takeEl); + index.takeWordCount += wordIndex - takeStart; + secEl.appendChild(takeEl); + index.paragraphs.push({ el: takeEl, wordStart: takeStart, sectionId: sec.id }); + } else { + // para (default) + var paraEl = document.createElement('p'); + paraEl.className = 'para'; + var paraStart = wordIndex; + appendRuns(block.runs, paraEl); + secEl.appendChild(paraEl); + index.paragraphs.push({ el: paraEl, wordStart: paraStart, sectionId: sec.id }); + } + } + + container.appendChild(secEl); + index.sections.push(secEntry); + } + + index.wordCount = wordIndex; + return index; + } + + function estimateMinutes(wordCount, wpm) { + if (!wordCount || !wpm) return null; + return wordCount / wpm; + } + + // Format seconds as m:ss or h:mm:ss. Returns "--:--" for null/NaN. + function fmtClock(seconds) { + if (seconds === null || seconds === undefined || isNaN(seconds)) return '--:--'; + var t = Math.max(0, Math.round(seconds)); + var h = Math.floor(t / 3600); + var m = Math.floor((t % 3600) / 60); + var sec = t % 60; + var mm = (h > 0 && m < 10 ? '0' : '') + m; + var ss = (sec < 10 ? '0' : '') + sec; + return h > 0 ? h + ':' + mm + ':' + ss : mm + ':' + ss; + } + + window.MC.model = { + fetchSource: fetchSource, + fetchState: fetchState, + postJSON: postJSON, + withToken: withToken, + renderDoc: renderDoc, + estimateMinutes: estimateMinutes, + fmtClock: fmtClock + }; +})(); diff --git a/skills/mc-prompter/scripts/server/static/js/overlay.js b/skills/mc-prompter/scripts/server/static/js/overlay.js new file mode 100644 index 0000000..e846f04 --- /dev/null +++ b/skills/mc-prompter/scripts/server/static/js/overlay.js @@ -0,0 +1,33 @@ +/* mc-prompter /overlay page glue (Phase A placeholder). + * + * OBS browser source: transparent background, a small "session connected" + * badge that hides itself 5 seconds after each (re)connect. Stays on the WS + * so Phase C can mount the producer rail and cue cards without changing the + * page contract. + */ +(function () { + 'use strict'; + var MC = window.MC; + + var badge = document.getElementById('badge'); + var hideTimer = null; + + var token = new URLSearchParams(location.search).get('token') || null; + var ws = MC.createWS({ role: 'overlay', token: token }); + + function showBadge(connected) { + badge.classList.remove('fade'); + badge.classList.toggle('disconnected', !connected); + clearTimeout(hideTimer); + if (connected) { + hideTimer = setTimeout(function () { badge.classList.add('fade'); }, 5000); + } + } + + ws.onStatus(function (s) { + showBadge(s.connected); + }); + + // Phase C seam: subscribe here for producer rail state. + // ws.on('state', function (msg) { ... }); +})(); diff --git a/skills/mc-prompter/scripts/server/static/js/prompt.js b/skills/mc-prompter/scripts/server/static/js/prompt.js new file mode 100644 index 0000000..179f8f4 --- /dev/null +++ b/skills/mc-prompter/scripts/server/static/js/prompt.js @@ -0,0 +1,750 @@ +/* mc-prompter /prompt page glue. + * + * Owns the scroll engine, display settings, keyboard map, section list, + * settings drawer, countdown overlay, and the WS state loop. + * + * Leadership: the first prompt connection is the leader (the server says so + * in the welcome frame and promotes on disconnect via a role frame). Only + * the leader runs the engine autonomously and sends state snapshots (~4 Hz + * while playing plus on every change). Non-leader prompt pages are + * display-only followers: they apply incoming state frames to their own + * scroll surface so a tablet pointed at /prompt mirrors the leader. + */ +(function () { + 'use strict'; + var MC = window.MC; + + // ---------- elements ---------- + + var els = { + stage: document.getElementById('stage'), + surface: document.getElementById('surface'), + script: document.getElementById('script'), + eyeline: document.getElementById('eyeline'), + hud: document.getElementById('hud'), + conn: document.getElementById('conn'), + roleBadge: document.getElementById('role-badge'), + btnToggle: document.getElementById('btn-toggle'), + btnRestart: document.getElementById('btn-restart'), + clockElapsed: document.getElementById('clock-elapsed'), + clockRemaining: document.getElementById('clock-remaining'), + driftChip: document.getElementById('drift-chip'), + wpmVal: document.getElementById('wpm-val'), + modeChip: document.getElementById('mode-chip'), + countdown: document.getElementById('countdown'), + countdownNum: document.getElementById('countdown-num'), + sectionsDrawer: document.getElementById('sections-drawer'), + sectionsList: document.getElementById('sections-list'), + estTime: document.getElementById('est-time'), + settingsDrawer: document.getElementById('settings-drawer'), + helpOverlay: document.getElementById('help-overlay'), + tokenError: document.getElementById('token-error'), + toast: document.getElementById('toast') + }; + + // ---------- state ---------- + + var settings = MC.settings.load(); + var docIndex = { wordCount: 0, takeWordCount: 0, words: [], paragraphs: [], sections: [] }; + var docVersion = null; + var eyelineHidden = false; + var lastRemoteState = null; // latest leader snapshot seen while follower + var stateDirty = false; + var lastStateSent = 0; + var toastTimer = null; + var hudTimer = null; + + var token = new URLSearchParams(location.search).get('token') || null; + + var ws = MC.createWS({ role: 'prompt', token: token }); + + // Words the reader actually sees: hidden TAKE paragraphs contribute no + // scroll height, so they must not count toward pacing or time estimates. + // The engine reads this fresh every frame, so the hide-takes toggle is + // picked up immediately. + function visibleWordCount() { + var wc = docIndex.wordCount; + if (settings['hide-takes']) wc -= (docIndex.takeWordCount || 0); + return Math.max(wc, 0); + } + + var engine = MC.createEngine({ + surface: els.surface, + getWordCount: visibleWordCount, + onFrame: onEngineFrame, + onChange: onEngineChange, + onFinish: function () { toast('end of script'); } + }); + + // ---------- helpers ---------- + + function isLeader() { return ws.state.leader; } + + // Whether this page drives its own engine: the elected leader does, and so + // does a page with no server connection at all (standalone fallback, so the + // prompter never goes dead if the WS drops mid-take). + function drives() { return ws.state.leader || !ws.state.connected; } + + function toast(msg) { + els.toast.textContent = msg; + els.toast.classList.add('show'); + clearTimeout(toastTimer); + toastTimer = setTimeout(function () { els.toast.classList.remove('show'); }, 1800); + } + + function eyelinePx() { + return els.surface.clientHeight * (Number(settings['eyeline-percent']) / 100); + } + + // Document-space y of the eyeline (what the reader is looking at). + function eyelineDocY() { + return els.surface.scrollTop + eyelinePx(); + } + + function currentSectionId() { + var y = eyelineDocY(); + var id = null; + for (var i = 0; i < docIndex.sections.length; i++) { + if (docIndex.sections[i].el.offsetTop <= y + 2) id = docIndex.sections[i].id; + else break; + } + if (id === null && docIndex.sections.length) id = docIndex.sections[0].id; + return id; + } + + function jumpToEl(el) { + engine.jumpToPx(el.offsetTop - eyelinePx()); + } + + function jumpSection(id) { + for (var i = 0; i < docIndex.sections.length; i++) { + if (docIndex.sections[i].id === id) { jumpToEl(docIndex.sections[i].el); return; } + } + } + + // delta paragraphs: negative = previous, positive = next. + function jumpParagraphs(delta) { + var paras = docIndex.paragraphs; + if (!paras.length) return; + var y = eyelineDocY(); + // Index of the paragraph the eyeline currently sits in (last with top <= y). + var cur = -1; + for (var i = 0; i < paras.length; i++) { + if (paras[i].el.offsetTop <= y + 2) cur = i; else break; + } + var target = Math.min(Math.max(cur + delta, 0), paras.length - 1); + if (target === cur && delta < 0 && cur >= 0) { + // Already at this paragraph top? Snap to its top anyway (re-read). + target = Math.max(cur - 1, 0); + } + jumpToEl(paras[target].el); + } + + // ---------- display settings ---------- + + function applyDisplay() { + var s = settings; + els.script.style.fontFamily = s['font-family']; + els.script.style.fontSize = s['font-size'] + 'px'; + els.script.style.lineHeight = String(s['line-height']); + els.script.style.color = s['text-color']; + document.body.style.background = s['background-color']; + els.script.style.paddingLeft = s['margin-percent'] + '%'; + els.script.style.paddingRight = s['margin-percent'] + '%'; + // Lead-in and run-out so the first and last words can reach the eyeline. + els.script.style.paddingTop = s['eyeline-percent'] + 'vh'; + els.script.style.paddingBottom = (100 - Number(s['eyeline-percent'])) + 'vh'; + + var t = ''; + if (s['mirror-h']) t += ' scaleX(-1)'; + if (s['mirror-v']) t += ' scaleY(-1)'; + els.stage.style.transform = t ? t.trim() : 'none'; + + els.eyeline.style.top = s['eyeline-percent'] + '%'; + els.eyeline.classList.toggle('style-line', s['eyeline-style'] !== 'arrow'); + els.eyeline.classList.toggle('style-arrow', s['eyeline-style'] === 'arrow'); + els.eyeline.classList.toggle('off', eyelineHidden); + + document.body.classList.toggle('hide-takes', !!s['hide-takes']); + document.body.classList.toggle('hide-invented', !s['show-invented']); + } + + // Apply a settings mutation keeping the read position stable, since layout + // changes rescale the scroll geometry (the engine reads the ratio fresh + // every frame; we only need to preserve the position across the reflow). + function changeSetting(key, value) { + var ratio = engine.getPositionRatio(); + settings[key] = value; + MC.settings.save(settings); + applyDisplay(); + engine.setPositionRatio(ratio); + syncSettingsControls(); + if (key === 'hide-takes') updateEstTime(); + markDirty(); + } + + // ---------- settings drawer wiring ---------- + + var C = { + wpm: document.getElementById('set-wpm'), + mode: document.getElementById('set-mode'), + minutes: document.getElementById('set-minutes'), + rowMinutes: document.getElementById('row-minutes'), + countdown: document.getElementById('set-countdown'), + fontStack: document.getElementById('set-font-stack'), + fontFamily: document.getElementById('set-font-family'), + fontSize: document.getElementById('set-font-size'), + fontSizeN: document.getElementById('set-font-size-n'), + lineHeight: document.getElementById('set-line-height'), + lineHeightV: document.getElementById('set-line-height-v'), + margin: document.getElementById('set-margin'), + marginV: document.getElementById('set-margin-v'), + textColor: document.getElementById('set-text-color'), + bgColor: document.getElementById('set-bg-color'), + mirrorH: document.getElementById('set-mirror-h'), + mirrorV: document.getElementById('set-mirror-v'), + eyeline: document.getElementById('set-eyeline'), + eyelineV: document.getElementById('set-eyeline-v'), + eyelineStyle: document.getElementById('set-eyeline-style'), + hideTakes: document.getElementById('set-hide-takes'), + showInvented: document.getElementById('set-show-invented') + }; + + function initFontStackSelect() { + var stacks = MC.settings.FONT_STACKS; + for (var i = 0; i < stacks.length; i++) { + var opt = document.createElement('option'); + opt.value = stacks[i].value; + opt.textContent = stacks[i].label; + C.fontStack.appendChild(opt); + } + var custom = document.createElement('option'); + custom.value = ''; + custom.textContent = 'custom (type below)'; + C.fontStack.appendChild(custom); + } + + function syncSettingsControls() { + var s = settings; + C.countdown.value = s['countdown-seconds']; + C.fontFamily.value = s['font-family']; + var found = false; + for (var i = 0; i < C.fontStack.options.length; i++) { + if (C.fontStack.options[i].value === s['font-family']) { + C.fontStack.selectedIndex = i; + found = true; + break; + } + } + if (!found) C.fontStack.value = ''; + C.fontSize.value = s['font-size']; + C.fontSizeN.value = s['font-size']; + C.lineHeight.value = s['line-height']; + C.lineHeightV.textContent = Number(s['line-height']).toFixed(2); + C.margin.value = s['margin-percent']; + C.marginV.textContent = s['margin-percent'] + '%'; + C.textColor.value = s['text-color']; + C.bgColor.value = s['background-color']; + C.mirrorH.checked = !!s['mirror-h']; + C.mirrorV.checked = !!s['mirror-v']; + C.eyeline.value = s['eyeline-percent']; + C.eyelineV.textContent = s['eyeline-percent'] + '%'; + C.eyelineStyle.value = s['eyeline-style']; + C.hideTakes.checked = !!s['hide-takes']; + C.showInvented.checked = !!s['show-invented']; + } + + function wireSettingsControls() { + C.wpm.addEventListener('change', function () { + engine.setWpm(Number(C.wpm.value)); + }); + C.mode.addEventListener('change', applyModeControls); + C.minutes.addEventListener('change', applyModeControls); + C.countdown.addEventListener('change', function () { + changeSetting('countdown-seconds', Math.max(0, Number(C.countdown.value) || 0)); + }); + C.fontStack.addEventListener('change', function () { + if (C.fontStack.value) changeSetting('font-family', C.fontStack.value); + }); + C.fontFamily.addEventListener('change', function () { + if (C.fontFamily.value.trim()) changeSetting('font-family', C.fontFamily.value.trim()); + }); + C.fontSize.addEventListener('input', function () { + changeSetting('font-size', Number(C.fontSize.value)); + }); + C.fontSizeN.addEventListener('change', function () { + changeSetting('font-size', Number(C.fontSizeN.value)); + }); + C.lineHeight.addEventListener('input', function () { + changeSetting('line-height', Number(C.lineHeight.value)); + }); + C.margin.addEventListener('input', function () { + changeSetting('margin-percent', Number(C.margin.value)); + }); + C.textColor.addEventListener('input', function () { + changeSetting('text-color', C.textColor.value); + }); + C.bgColor.addEventListener('input', function () { + changeSetting('background-color', C.bgColor.value); + }); + C.mirrorH.addEventListener('change', function () { + changeSetting('mirror-h', C.mirrorH.checked); + }); + C.mirrorV.addEventListener('change', function () { + changeSetting('mirror-v', C.mirrorV.checked); + }); + C.eyeline.addEventListener('input', function () { + changeSetting('eyeline-percent', Number(C.eyeline.value)); + }); + C.eyelineStyle.addEventListener('change', function () { + changeSetting('eyeline-style', C.eyelineStyle.value); + }); + C.hideTakes.addEventListener('change', function () { + changeSetting('hide-takes', C.hideTakes.checked); + }); + C.showInvented.addEventListener('change', function () { + changeSetting('show-invented', C.showInvented.checked); + }); + } + + function applyModeControls() { + var timed = C.mode.value === 'timed'; + C.rowMinutes.classList.toggle('hidden', !timed); + engine.setMode(timed ? 'timed' : 'manual', Number(C.minutes.value)); + } + + // ---------- panels ---------- + + function togglePanel(el) { + el.classList.toggle('hidden'); + } + + function closePanels() { + els.sectionsDrawer.classList.add('hidden'); + els.settingsDrawer.classList.add('hidden'); + els.helpOverlay.classList.add('hidden'); + } + + document.addEventListener('click', function (e) { + var closer = e.target.closest('[data-close]'); + if (closer) document.getElementById(closer.dataset.close).classList.add('hidden'); + if (e.target === els.helpOverlay) els.helpOverlay.classList.add('hidden'); + }); + + // ---------- section list ---------- + + function renderSectionList() { + var list = els.sectionsList; + while (list.firstChild) list.removeChild(list.firstChild); + for (var i = 0; i < docIndex.sections.length; i++) { + (function (sec, i2) { + var li = document.createElement('li'); + li.dataset.sid = sec.id; + var name = document.createElement('span'); + name.textContent = sec.heading || (i2 === 0 ? 'Preamble' : 'Untitled section'); + var words = document.createElement('span'); + words.className = 'sec-words'; + var next = docIndex.sections[i2 + 1]; + var count = (next ? next.wordStart : docIndex.wordCount) - sec.wordStart; + words.textContent = count + ' w'; + li.appendChild(name); + li.appendChild(words); + li.addEventListener('click', function () { + if (drives()) jumpSection(sec.id); + else ws.cmd('jump-section', sec.id); + els.sectionsDrawer.classList.add('hidden'); + }); + list.appendChild(li); + })(docIndex.sections[i], i); + } + updateEstTime(); + } + + function updateEstTime() { + var wpm = engine.view().wpm || 150; + var wc = visibleWordCount(); + var mins = MC.model.estimateMinutes(wc, wpm); + els.estTime.textContent = wc + ' words, est. ' + + MC.model.fmtClock(mins === null ? null : mins * 60) + ' at ' + wpm + ' wpm'; + } + + function highlightCurrentSection(id) { + var items = els.sectionsList.children; + for (var i = 0; i < items.length; i++) { + items[i].classList.toggle('current', items[i].dataset.sid === id); + } + } + + // ---------- document loading ---------- + + function loadSource() { + return MC.model.fetchSource(token).then(function (src) { + var ratio = engine.getPositionRatio(); + docIndex = MC.model.renderDoc(src.doc, els.script); + docVersion = src['doc-version']; + applyDisplay(); + engine.setPositionRatio(ratio); + renderSectionList(); + document.title = 'mc-prompter | ' + ((src.doc && src.doc.title) || 'prompt'); + markDirty(); + }).catch(function (err) { + toast('script load failed: ' + err.message); + }); + } + + // ---------- WS: state out (leader) ---------- + + function markDirty() { stateDirty = true; maybeSendState(); } + + function buildSnapshot() { + var v = engine.view(); + return { + type: 'state', + position: Math.round(v.position * 10000) / 10000, + section: currentSectionId(), + playing: v.playing || (v.countdown !== null), + wpm: v.wpm, + mode: v.mode, + elapsed: Math.round(v.elapsed * 10) / 10, + remaining: v.remaining === null ? null : Math.round(v.remaining * 10) / 10, + countdown: v.countdown === null ? null : Math.round(v.countdown * 10) / 10 + }; + } + + // ~4 Hz while playing, prompt on changes (force), never a flood: forced + // sends still respect a 100 ms floor and leave the dirty flag for the + // heartbeat to flush. + function maybeSendState(force) { + if (!isLeader()) return; + var now = Date.now(); + var minGap = force ? 100 : 250; + if (now - lastStateSent < minGap) { stateDirty = true; return; } + if (ws.send(buildSnapshot())) { + lastStateSent = now; + stateDirty = false; + } + } + + // ---------- engine callbacks ---------- + + function updateHud(v) { + els.clockElapsed.textContent = MC.model.fmtClock(v.elapsed); + els.clockRemaining.textContent = MC.model.fmtClock(v.remaining); + els.wpmVal.textContent = String(v.wpm); + els.modeChip.textContent = v.mode; + els.btnToggle.textContent = (v.playing || v.countdown !== null) ? 'pause' : 'play'; + + if (v.mode === 'timed' && v.drift !== null) { + var d = Math.round(v.drift); + els.driftChip.classList.remove('hidden'); + if (Math.abs(d) <= 3) { + els.driftChip.textContent = 'on plan'; + els.driftChip.className = 'chip good'; + } else if (d > 0) { + els.driftChip.textContent = d + 's behind'; + els.driftChip.className = 'chip warn'; + } else { + els.driftChip.textContent = (-d) + 's ahead'; + els.driftChip.className = 'chip'; + } + } else { + els.driftChip.classList.add('hidden'); + } + + if (v.countdown !== null) { + els.countdown.classList.remove('hidden'); + els.countdownNum.textContent = String(Math.ceil(v.countdown)); + } else { + els.countdown.classList.add('hidden'); + } + + highlightCurrentSection(currentSectionId()); + } + + function onEngineFrame(v) { + if (everConnected && !ws.state.connected && (v.playing || v.countdown !== null)) { + droveWhileDisconnected = true; + } + if (drives()) updateHud(v); + if (v.playing) maybeSendState(); + } + + function onEngineChange(reason, v) { + if (everConnected && !ws.state.connected && reason !== 'seed') { + droveWhileDisconnected = true; + } + if (!drives()) return; + updateHud(v); + if (reason === 'speed' || reason === 'mode') updateEstTime(); + C.wpm.value = engine.getManualWpm(); + maybeSendState(true); + } + + // ---------- playback intents ---------- + + function startPlay() { + if (engine.getPositionRatio() < 0.001 && Number(settings['countdown-seconds']) > 0) { + engine.beginCountdown(Number(settings['countdown-seconds'])); + } else { + engine.play(); + } + } + + function handleCmd(msg) { + // Commands are relayed to everyone; only the leader executes them. + if (!isLeader()) return; + var v = msg.value; + switch (msg.cmd) { + case 'play': startPlay(); break; + case 'pause': engine.pause(); break; + case 'toggle': engine.toggle(Number(settings['countdown-seconds'])); break; + case 'restart': engine.restart(); break; + case 'speed-delta': engine.deltaWpm(typeof v === 'number' ? v : 2); break; + case 'speed-set': if (typeof v === 'number') engine.setWpm(v); break; + case 'jump-section': if (v) jumpSection(String(v)); break; + case 'jump-words': jumpParagraphs((Number(v) || 0) >= 0 ? 1 : -1); break; + case 'countdown': + engine.beginCountdown(typeof v === 'number' ? v : Number(settings['countdown-seconds'])); + break; + default: break; + } + } + + // ---------- follower: apply leader state ---------- + + function applyRemoteState(msg) { + lastRemoteState = msg; + if (isLeader()) return; + var scrollableH = Math.max(els.surface.scrollHeight - els.surface.clientHeight, 1); + els.surface.scrollTop = (Number(msg.position) || 0) * scrollableH; + els.clockElapsed.textContent = MC.model.fmtClock(msg.elapsed); + els.clockRemaining.textContent = MC.model.fmtClock(msg.remaining); + els.wpmVal.textContent = String(msg.wpm); + els.modeChip.textContent = msg.mode || 'manual'; + els.btnToggle.textContent = msg.playing ? 'pause' : 'play'; + if (msg.countdown !== null && msg.countdown !== undefined) { + els.countdown.classList.remove('hidden'); + els.countdownNum.textContent = String(Math.ceil(msg.countdown)); + } else { + els.countdown.classList.add('hidden'); + } + highlightCurrentSection(msg.section || null); + } + + // ---------- WS wiring ---------- + + var wasLeader = false; + var everConnected = false; // this page has reached the server before + var droveWhileDisconnected = false; // engine ran or the user drove it during an outage + + ws.onStatus(function (s) { + els.conn.classList.toggle('on', s.connected); + els.roleBadge.classList.toggle('hidden', s.leader || !s.connected); + if (s.rejected) { + // Token rejected (close 4403): the WS layer has stopped retrying, so + // surface the failure instead of silently running standalone. + els.tokenError.classList.remove('hidden'); + wasLeader = false; + return; + } + if (s.connected && !s.leader) { + // Connected follower (fresh join or demotion after a reconnect): + // exactly one driver per session, so stop a locally running engine + // before mirroring the leader. + if (engine.isPlaying()) engine.pause(); + // Seed the display from the welcome snapshot until live state frames + // arrive (a paused leader sends none). + if (!lastRemoteState && s.snapshot) applyRemoteState(s.snapshot); + } + if (s.connected && s.leader && !wasLeader) { + // Just became leader (first welcome, reconnect, or promotion after the + // previous leader dropped): seed the engine from the freshest snapshot + // we know about, then start reporting state. Skip the seed if this + // page kept driving through the outage (standalone fallback): the + // server's cached snapshot predates the disconnect and seeding from it + // would rewind the scroll mid-read. + var snap = lastRemoteState || s.snapshot; + if (snap && !droveWhileDisconnected) engine.seed(snap); + markDirty(); + } + if (s.connected) { + everConnected = true; + droveWhileDisconnected = false; + } + wasLeader = s.connected && s.leader; + }); + + ws.on('cmd', handleCmd); + ws.on('state', applyRemoteState); + ws.on('doc-updated', function (msg) { + if (msg['doc-version'] !== docVersion) loadSource(); + }); + ws.on('error', function (msg) { toast('server: ' + msg.message); }); + + // ---------- input: keyboard, wheel, click, scroll ---------- + + document.addEventListener('keydown', function (e) { + var tag = (e.target && e.target.tagName) || ''; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + + var leaderAct = drives(); + function act(fn, cmdName, cmdValue) { + if (leaderAct) fn(); + else ws.cmd(cmdName, cmdValue); + } + + switch (e.key) { + case ' ': + e.preventDefault(); + act(function () { engine.toggle(Number(settings['countdown-seconds'])); }, 'toggle'); + break; + case 'ArrowUp': + case '+': + case '=': + e.preventDefault(); + act(function () { engine.deltaWpm(2); }, 'speed-delta', 2); + break; + case 'ArrowDown': + case '-': + e.preventDefault(); + act(function () { engine.deltaWpm(-2); }, 'speed-delta', -2); + break; + case 'ArrowLeft': + e.preventDefault(); + act(function () { jumpParagraphs(-1); }, 'jump-words', -1); + break; + case 'ArrowRight': + e.preventDefault(); + act(function () { jumpParagraphs(1); }, 'jump-words', 1); + break; + case 'Home': + e.preventDefault(); + act(function () { engine.restart(); }, 'restart'); + break; + case 'f': + case 'F': + e.preventDefault(); + if (document.fullscreenElement) document.exitFullscreen(); + else document.documentElement.requestFullscreen(); + break; + case 'm': + case 'M': { + e.preventDefault(); + // Cycle off -> h -> v -> both -> off. + var h = settings['mirror-h'], vv = settings['mirror-v']; + var next = !h && !vv ? [true, false] : h && !vv ? [false, true] : !h && vv ? [true, true] : [false, false]; + changeSetting('mirror-h', next[0]); + changeSetting('mirror-v', next[1]); + toast('mirror: ' + (next[0] && next[1] ? 'both' : next[0] ? 'horizontal' : next[1] ? 'vertical' : 'off')); + break; + } + case 'e': + case 'E': + e.preventDefault(); + eyelineHidden = !eyelineHidden; + els.eyeline.classList.toggle('off', eyelineHidden); + break; + case 'c': + case 'C': + e.preventDefault(); + act(function () { engine.beginCountdown(Number(settings['countdown-seconds'])); }, + 'countdown', Number(settings['countdown-seconds'])); + break; + case 's': + case 'S': + e.preventDefault(); + togglePanel(els.sectionsDrawer); + break; + case 'd': + case 'D': + e.preventDefault(); + togglePanel(els.settingsDrawer); + break; + case '?': + e.preventDefault(); + togglePanel(els.helpOverlay); + break; + case 'Escape': + closePanels(); + break; + default: + break; + } + }); + + els.surface.addEventListener('wheel', function (e) { + e.preventDefault(); + var d = e.deltaY < 0 ? 2 : -2; + if (drives()) engine.deltaWpm(d); + else ws.cmd('speed-delta', d); + }, { passive: false }); + + els.surface.addEventListener('click', function (e) { + var w = e.target.closest('.w'); + if (!w || !drives()) return; + // Click-to-jump: put the clicked word on the eyeline. + // Phase B seam: this same span is the click-to-anchor target. + engine.jumpToPx(w.offsetTop - eyelinePx()); + }); + + els.surface.addEventListener('scroll', function () { + engine.adoptScrollTop(); + }); + + window.addEventListener('resize', function () { + var ratio = engine.getPositionRatio(); + engine.recalc(); + engine.setPositionRatio(ratio); + }); + + // HUD auto-fade while playing. + function pokeHud() { + els.hud.classList.remove('faded'); + clearTimeout(hudTimer); + hudTimer = setTimeout(function () { + if (engine.isPlaying()) els.hud.classList.add('faded'); + }, 3000); + } + document.addEventListener('mousemove', pokeHud); + pokeHud(); + + // ---------- HUD buttons ---------- + + els.btnToggle.addEventListener('click', function () { + if (drives()) engine.toggle(Number(settings['countdown-seconds'])); + else ws.cmd('toggle'); + }); + els.btnRestart.addEventListener('click', function () { + if (drives()) engine.restart(); + else ws.cmd('restart'); + }); + document.getElementById('btn-sections').addEventListener('click', function () { + togglePanel(els.sectionsDrawer); + }); + document.getElementById('btn-settings').addEventListener('click', function () { + togglePanel(els.settingsDrawer); + }); + document.getElementById('btn-help').addEventListener('click', function () { + togglePanel(els.helpOverlay); + }); + + // Send-state heartbeat: 4 Hz while playing, on-change otherwise. + setInterval(function () { + if (isLeader() && (engine.isPlaying() || stateDirty)) maybeSendState(); + }, 250); + + // ---------- boot ---------- + + initFontStackSelect(); + applyDisplay(); + syncSettingsControls(); + wireSettingsControls(); + + MC.model.fetchState(token).then(function (state) { + var wpm = state && state.config && state.config['owner-wpm']; + if (wpm) { + engine.setWpm(wpm); + C.wpm.value = wpm; + } + }).catch(function () { /* defaults are fine */ }).then(loadSource); +})(); diff --git a/skills/mc-prompter/scripts/server/static/js/remote.js b/skills/mc-prompter/scripts/server/static/js/remote.js new file mode 100644 index 0000000..5e61a4f --- /dev/null +++ b/skills/mc-prompter/scripts/server/static/js/remote.js @@ -0,0 +1,170 @@ +/* mc-prompter /remote page glue. + * + * Phone-as-remote. Requires the session token from ?token= in the URL; a + * non-loopback WS connect without a valid token is closed with 4403 and the + * page goes read-only (error panel, no controls). Commands go out as cmd + * frames; the live display is driven by the leader's state frames. + */ +(function () { + 'use strict'; + var MC = window.MC; + + var els = { + app: document.getElementById('app'), + errorPanel: document.getElementById('error-panel'), + errorMsg: document.getElementById('error-msg'), + conn: document.getElementById('conn'), + session: document.getElementById('session'), + elapsed: document.getElementById('st-elapsed'), + remaining: document.getElementById('st-remaining'), + wpm: document.getElementById('st-wpm'), + posFill: document.getElementById('pos-fill'), + btnToggle: document.getElementById('btn-toggle'), + btnRestart: document.getElementById('btn-restart'), + btnSlower: document.getElementById('btn-slower'), + btnFaster: document.getElementById('btn-faster'), + btnPrev: document.getElementById('btn-prev'), + btnNext: document.getElementById('btn-next'), + sectionList: document.getElementById('section-list'), + secCount: document.getElementById('sec-count') + }; + + var token = new URLSearchParams(location.search).get('token') || null; + var sections = []; // [{id, heading}] + var currentSectionId = null; + var docVersion = null; + + function showError(msg) { + els.app.classList.add('hidden'); + els.errorPanel.classList.remove('hidden'); + if (msg) els.errorMsg.textContent = msg; + } + + if (!token) { + showError('This remote needs the session token. Open the exact remote URL ' + + 'shown on the prompter home page (it ends in ?token=...).'); + return; + } + + els.app.classList.remove('hidden'); + + var ws = MC.createWS({ role: 'remote', token: token }); + + // ---------- sections ---------- + + function loadSections() { + return MC.model.fetchSource(token).then(function (src) { + docVersion = src['doc-version']; + sections = []; + var docSections = (src.doc && src.doc.sections) || []; + for (var i = 0; i < docSections.length; i++) { + sections.push({ + id: docSections[i].id, + heading: docSections[i].heading || (i === 0 ? 'Preamble' : 'Untitled section') + }); + } + renderSections(); + }).catch(function (err) { + if (err.status === 401 || err.status === 403) { + showError('The session token was rejected. Grab a fresh remote URL from the home page.'); + } + }); + } + + function renderSections() { + var list = els.sectionList; + while (list.firstChild) list.removeChild(list.firstChild); + els.secCount.textContent = sections.length ? '(' + sections.length + ')' : ''; + for (var i = 0; i < sections.length; i++) { + (function (sec) { + var li = document.createElement('li'); + li.dataset.sid = sec.id; + li.textContent = sec.heading; + li.addEventListener('click', function () { + sendJumpSection(sec.id); + }); + list.appendChild(li); + })(sections[i]); + } + highlightSection(); + } + + function highlightSection() { + var items = els.sectionList.children; + for (var i = 0; i < items.length; i++) { + items[i].classList.toggle('current', items[i].dataset.sid === currentSectionId); + } + } + + function sectionIndex(id) { + for (var i = 0; i < sections.length; i++) { + if (sections[i].id === id) return i; + } + return -1; + } + + // Send a jump and update currentSectionId optimistically so rapid prev or + // next taps chain from the target instead of re-targeting the same section + // while the leader's next state frame is still in flight. The next state + // frame reconciles the real position. + function sendJumpSection(id) { + ws.cmd('jump-section', id); + currentSectionId = id; + highlightSection(); + } + + function jumpRelativeSection(delta) { + if (!sections.length) return; + var idx = sectionIndex(currentSectionId); + var target = idx < 0 ? 0 : Math.min(Math.max(idx + delta, 0), sections.length - 1); + sendJumpSection(sections[target].id); + } + + // ---------- live state ---------- + + function applyState(msg) { + currentSectionId = msg.section || null; + els.elapsed.textContent = MC.model.fmtClock(msg.elapsed); + els.remaining.textContent = MC.model.fmtClock(msg.remaining); + els.wpm.textContent = String(msg.wpm !== undefined ? msg.wpm : '--'); + els.posFill.style.width = (Math.min(Math.max(Number(msg.position) || 0, 0), 1) * 100) + '%'; + var playing = !!msg.playing; + els.btnToggle.textContent = playing ? 'pause' : 'play'; + els.btnToggle.classList.toggle('playing', playing); + if (msg.countdown !== null && msg.countdown !== undefined) { + els.btnToggle.textContent = 'in ' + Math.ceil(msg.countdown) + 's'; + } + highlightSection(); + } + + // ---------- wiring ---------- + + ws.onStatus(function (s) { + els.conn.classList.toggle('on', s.connected); + els.session.textContent = s.session || ''; + if (s.rejected) { + showError('The session token was rejected. Grab a fresh remote URL from the home page.'); + return; + } + if (s.connected && s.snapshot) applyState(s.snapshot); + }); + + ws.on('state', applyState); + ws.on('doc-updated', function (msg) { + if (msg['doc-version'] !== docVersion) loadSections(); + }); + + els.btnToggle.addEventListener('click', function () { ws.cmd('toggle'); }); + els.btnRestart.addEventListener('click', function () { ws.cmd('restart'); }); + els.btnSlower.addEventListener('click', function () { ws.cmd('speed-delta', -2); }); + els.btnFaster.addEventListener('click', function () { ws.cmd('speed-delta', 2); }); + els.btnPrev.addEventListener('click', function () { jumpRelativeSection(-1); }); + els.btnNext.addEventListener('click', function () { jumpRelativeSection(1); }); + + // ---------- boot ---------- + + loadSections(); + MC.model.fetchState(token).then(function (state) { + if (state && state.snapshot) applyState(state.snapshot); + }).catch(function () { /* WS state frames will fill in */ }); +})(); diff --git a/skills/mc-prompter/scripts/server/static/js/scroll.js b/skills/mc-prompter/scripts/server/static/js/scroll.js new file mode 100644 index 0000000..15017b1 --- /dev/null +++ b/skills/mc-prompter/scripts/server/static/js/scroll.js @@ -0,0 +1,309 @@ +/* mc-prompter scroll engine (classic script, attaches to window.MC). + * + * requestAnimationFrame based smooth scroll of a scrollable surface. + * + * Speed model (Phase A contract): + * px/s = wpm / 60 * (scrollable height / word count) + * where scrollable height = scrollHeight - clientHeight. The ratio is read + * fresh every frame, so font, size, margin, line-height changes and window + * resizes are picked up automatically without an explicit recompute call. + * + * Position is reported as scrollTop / (scrollHeight - clientHeight), 0..1. + * + * Modes: + * manual wpm is whatever the user set (live +/- adjustment) + * timed given a total duration, wpm is continuously re-derived as + * remaining words / remaining minutes (recomputed every frame, + * which covers resume and jumps); drift from plan is exposed as + * elapsed - position * totalSeconds (positive means behind plan) + * + * Countdown: beginCountdown(seconds) holds the scroll and counts down, then + * flips to playing. The page renders the big digits from view().countdown. + * + * Phase B seam: voice-follow will drive targetPos via an easing setter + * instead of the constant-rate integration in frame(); the surface, position + * math, and word spans stay identical. + */ +(function () { + 'use strict'; + window.MC = window.MC || {}; + + var WPM_MIN = 10; + var WPM_MAX = 1200; + + function createEngine(opts) { + // opts: { surface: element, getWordCount: fn -> int, + // onFrame: fn(view), onChange: fn(reason), onFinish: fn } + var surface = opts.surface; + + var st = { + playing: false, + mode: 'manual', // 'manual' | 'timed' + wpm: 150, // manual-mode wpm + totalSeconds: null, // timed-mode plan length + elapsed: 0, // seconds of play time accumulated + countdownLeft: 0, + finished: false + }; + + var pos = 0; // float scroll position (scrollTop rounds) + var rafId = null; + var lastTs = null; + var selfScroll = false; // guards the scroll listener against our own writes + + function scrollable() { + return Math.max(surface.scrollHeight - surface.clientHeight, 1); + } + + function position() { + var p = pos / scrollable(); + return Math.min(Math.max(p, 0), 1); + } + + function remainingWords() { + var wc = opts.getWordCount() || 0; + return wc * (1 - position()); + } + + function currentWpm() { + if (st.mode === 'timed' && st.totalSeconds) { + var remS = Math.max(st.totalSeconds - st.elapsed, 1); + var w = remainingWords() / (remS / 60); + return Math.min(Math.max(w, WPM_MIN), WPM_MAX); + } + return st.wpm; + } + + function pxPerSecond() { + var wc = opts.getWordCount(); + if (!wc) return 0; + return currentWpm() / 60 * (scrollable() / wc); + } + + function remainingSeconds() { + if (st.mode === 'timed' && st.totalSeconds) { + return Math.max(st.totalSeconds - st.elapsed, 0); + } + var w = currentWpm(); + if (!w) return null; + return remainingWords() / w * 60; + } + + function driftSeconds() { + if (st.mode !== 'timed' || !st.totalSeconds) return null; + return st.elapsed - position() * st.totalSeconds; + } + + function view() { + return { + playing: st.playing, + position: position(), + wpm: Math.round(currentWpm()), + mode: st.mode, + totalSeconds: st.totalSeconds, + elapsed: st.elapsed, + remaining: remainingSeconds(), + countdown: st.countdownLeft > 0 ? st.countdownLeft : null, + drift: driftSeconds(), + finished: st.finished + }; + } + + function setScrollTop(v) { + selfScroll = true; + surface.scrollTop = v; + // The scroll event fires async; clear the guard on the next frame. + requestAnimationFrame(function () { selfScroll = false; }); + } + + function changed(reason) { + if (opts.onChange) opts.onChange(reason, view()); + } + + function frame(ts) { + rafId = requestAnimationFrame(frame); + if (lastTs === null) { lastTs = ts; return; } + var dt = Math.min((ts - lastTs) / 1000, 0.25); + lastTs = ts; + + if (st.countdownLeft > 0) { + st.countdownLeft = Math.max(st.countdownLeft - dt, 0); + if (st.countdownLeft === 0) { + st.playing = true; + changed('countdown-done'); + } + } else if (st.playing) { + st.elapsed += dt; + pos += pxPerSecond() * dt; + var max = scrollable(); + if (pos >= max) { + pos = max; + setScrollTop(pos); + st.playing = false; + st.finished = true; + changed('finished'); + if (opts.onFinish) opts.onFinish(); + } else { + setScrollTop(pos); + } + } + + if (opts.onFrame) opts.onFrame(view()); + + if (!st.playing && st.countdownLeft <= 0) stopLoop(); + } + + function startLoop() { + if (rafId === null) { + lastTs = null; + rafId = requestAnimationFrame(frame); + } + } + + function stopLoop() { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + lastTs = null; + } + } + + var engine = { + view: view, + isPlaying: function () { return st.playing || st.countdownLeft > 0; }, + + play: function () { + if (st.playing) return; + st.finished = false; + st.countdownLeft = 0; + st.playing = true; + startLoop(); + changed('play'); + }, + + // Countdown, then play. seconds <= 0 plays immediately. + beginCountdown: function (seconds) { + var s = Number(seconds) || 0; + if (s <= 0) { engine.play(); return; } + st.finished = false; + st.playing = false; + st.countdownLeft = s; + startLoop(); + changed('countdown'); + }, + + pause: function () { + var was = st.playing || st.countdownLeft > 0; + st.playing = false; + st.countdownLeft = 0; + if (was) changed('pause'); + }, + + toggle: function (countdownSeconds) { + if (engine.isPlaying()) { + engine.pause(); + } else if (position() < 0.001 && countdownSeconds > 0) { + engine.beginCountdown(countdownSeconds); + } else { + engine.play(); + } + }, + + restart: function () { + pos = 0; + setScrollTop(0); + st.elapsed = 0; + st.playing = false; + st.countdownLeft = 0; + st.finished = false; + changed('restart'); + }, + + setWpm: function (n) { + st.wpm = Math.min(Math.max(Math.round(Number(n) || st.wpm), WPM_MIN), WPM_MAX); + changed('speed'); + }, + + deltaWpm: function (d) { + engine.setWpm(st.wpm + (Number(d) || 0)); + }, + + getManualWpm: function () { return st.wpm; }, + + // mode: 'manual' | 'timed'; totalMinutes required for timed. + setMode: function (mode, totalMinutes) { + if (mode === 'timed' && Number(totalMinutes) > 0) { + st.mode = 'timed'; + st.totalSeconds = Number(totalMinutes) * 60; + } else { + st.mode = 'manual'; + st.totalSeconds = null; + } + changed('mode'); + }, + + // Jump to an absolute pixel offset within the surface. + jumpToPx: function (px) { + pos = Math.min(Math.max(px, 0), scrollable()); + setScrollTop(pos); + st.finished = false; + changed('jump'); + }, + + // Jump to a 0..1 position ratio. + setPositionRatio: function (r) { + engine.jumpToPx((Number(r) || 0) * scrollable()); + }, + + getPositionRatio: position, + getScrollTop: function () { return pos; }, + + // Adopt an externally caused scrollTop (manual drag or touch while + // paused) so the next play resumes from where the user left the view. + adoptScrollTop: function () { + if (selfScroll || st.playing || st.countdownLeft > 0) return; + pos = surface.scrollTop; + st.finished = false; + changed('scroll'); + }, + + // Re-clamp after layout changes; the page keeps the position ratio + // stable across settings changes by capturing it before and restoring + // after (see prompt.js applyDisplay). + recalc: function () { + pos = Math.min(pos, scrollable()); + setScrollTop(pos); + }, + + // Seed from a state snapshot (welcome payload or leader promotion). + seed: function (snap) { + if (!snap) return; + if (typeof snap.wpm === 'number' && snap.mode !== 'timed') st.wpm = snap.wpm; + if (snap.mode === 'timed') { + var total = (Number(snap.elapsed) || 0) + (Number(snap.remaining) || 0); + st.mode = 'timed'; + st.totalSeconds = total > 0 ? total : null; + if (!st.totalSeconds) st.mode = 'manual'; + } else { + st.mode = 'manual'; + } + st.elapsed = Number(snap.elapsed) || 0; + if (typeof snap.position === 'number') { + pos = Math.min(Math.max(snap.position, 0), 1) * scrollable(); + setScrollTop(pos); + } + st.finished = false; + st.countdownLeft = 0; + st.playing = !!snap.playing; + if (st.playing) startLoop(); + changed('seed'); + }, + + destroy: stopLoop + }; + + return engine; + } + + window.MC.createEngine = createEngine; +})(); diff --git a/skills/mc-prompter/scripts/server/static/js/settings.js b/skills/mc-prompter/scripts/server/static/js/settings.js new file mode 100644 index 0000000..079b3c2 --- /dev/null +++ b/skills/mc-prompter/scripts/server/static/js/settings.js @@ -0,0 +1,100 @@ +/* mc-prompter display settings (classic script, attaches to window.MC). + * + * Persisted per device in localStorage under the key "mc-prompter-display". + * Schema (kebab-case keys, all optional in storage, defaults below): + * mirror-h bool horizontal flip of the scroll surface + * mirror-v bool vertical flip (both may combine) + * font-family string CSS font-family (system stacks or free text) + * font-size number px + * text-color string CSS color + * background-color string CSS color + * margin-percent number horizontal margin, percent of surface width + * line-height number unitless multiplier + * eyeline-percent number marker position, percent from viewport top + * eyeline-style string "line" | "arrow" + * countdown-seconds number countdown before scroll starts (0 disables) + * hide-takes bool hide TAKE paragraphs entirely + * show-invented bool show the invented badge styling + * + * Server config defaults (from GET /api/state .config) may be passed to + * load() as overrides; stored per-device values still win over them. + */ +(function () { + 'use strict'; + window.MC = window.MC || {}; + + var KEY = 'mc-prompter-display'; + + var DEFAULTS = { + 'mirror-h': false, + 'mirror-v': false, + 'font-family': 'system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + 'font-size': 64, + 'text-color': '#f2f2f2', + 'background-color': '#000000', + 'margin-percent': 12, + 'line-height': 1.5, + 'eyeline-percent': 33, + 'eyeline-style': 'line', + 'countdown-seconds': 3, + 'hide-takes': false, + 'show-invented': true + }; + + // A few known-safe offline font stacks for the settings drawer select. + var FONT_STACKS = [ + { label: 'System sans', value: DEFAULTS['font-family'] }, + { label: 'Georgia serif', value: 'Georgia, "Times New Roman", serif' }, + { label: 'Verdana wide', value: 'Verdana, Geneva, Tahoma, sans-serif' }, + { label: 'Trebuchet', value: '"Trebuchet MS", "Segoe UI", sans-serif' }, + { label: 'Monospace', value: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace' } + ]; + + function load(overrides) { + var out = {}; + var k; + for (k in DEFAULTS) { + if (Object.prototype.hasOwnProperty.call(DEFAULTS, k)) out[k] = DEFAULTS[k]; + } + if (overrides) { + for (k in overrides) { + if (Object.prototype.hasOwnProperty.call(DEFAULTS, k) && + overrides[k] !== null && overrides[k] !== undefined) { + out[k] = overrides[k]; + } + } + } + try { + var raw = localStorage.getItem(KEY); + if (raw) { + var saved = JSON.parse(raw); + for (k in saved) { + if (Object.prototype.hasOwnProperty.call(DEFAULTS, k)) out[k] = saved[k]; + } + } + } catch (e) { + // Corrupted storage: fall back to defaults silently. + } + return out; + } + + function save(settings) { + var out = {}; + for (var k in DEFAULTS) { + if (Object.prototype.hasOwnProperty.call(settings, k)) out[k] = settings[k]; + } + try { + localStorage.setItem(KEY, JSON.stringify(out)); + } catch (e) { + // Storage full or blocked: settings stay session-only. + } + } + + window.MC.settings = { + KEY: KEY, + DEFAULTS: DEFAULTS, + FONT_STACKS: FONT_STACKS, + load: load, + save: save + }; +})(); diff --git a/skills/mc-prompter/scripts/server/static/js/ws.js b/skills/mc-prompter/scripts/server/static/js/ws.js new file mode 100644 index 0000000..a14d0fb --- /dev/null +++ b/skills/mc-prompter/scripts/server/static/js/ws.js @@ -0,0 +1,145 @@ +/* mc-prompter WebSocket client (classic script, attaches to window.MC). + * + * Protocol (Phase A contract): + * -> hello {type:"hello", role:"prompt"|"remote"|"overlay"|"home", token?} + * <- welcome {type:"welcome", session, leader, "doc-version", snapshot} + * <- role {type:"role", leader:true} leader promotion + * <> cmd {type:"cmd", cmd, value?, from?} relayed by server to all + * <> state {type:"state", position, section, playing, wpm, mode, + * elapsed, remaining, countdown} leader prompt -> everyone + * <- doc-updated {type:"doc-updated", "doc-version"} clients refetch /api/source + * <- error {type:"error", message} + * + * Reconnects with exponential backoff. Close code 4403 means the token was + * rejected; we stop retrying and flag state.rejected so pages can go read-only. + */ +(function () { + 'use strict'; + window.MC = window.MC || {}; + + function createWS(opts) { + // opts: { role: string, token: string|null } + var listeners = {}; // message type -> [fn] + var statusFns = []; + var ws = null; + var closedByUser = false; + var backoff = 500; + var BACKOFF_MAX = 8000; + + var state = { + connected: false, + leader: false, + session: null, + docVersion: null, + snapshot: null, // last state snapshot delivered in welcome + rejected: false // token rejected (close 4403); no further retries + }; + + function emitStatus() { + for (var i = 0; i < statusFns.length; i++) statusFns[i](state); + } + + function dispatch(msg) { + if (msg.type === 'welcome') { + state.session = msg.session || null; + state.leader = !!msg.leader; + state.docVersion = msg['doc-version']; + state.snapshot = msg.snapshot || null; + emitStatus(); + } else if (msg.type === 'role') { + state.leader = !!msg.leader; + emitStatus(); + } + var fns = (listeners[msg.type] || []).concat(listeners['*'] || []); + for (var i = 0; i < fns.length; i++) { + try { fns[i](msg); } catch (e) { /* one bad listener never kills dispatch */ } + } + } + + function connect() { + if (closedByUser || state.rejected) return; + var proto = location.protocol === 'https:' ? 'wss://' : 'ws://'; + var url = proto + location.host + '/ws'; + if (opts.token) url += '?token=' + encodeURIComponent(opts.token); + try { + ws = new WebSocket(url); + } catch (e) { + scheduleReconnect(); + return; + } + ws.onopen = function () { + backoff = 500; + state.connected = true; + var hello = { type: 'hello', role: opts.role }; + if (opts.token) hello.token = opts.token; + ws.send(JSON.stringify(hello)); + emitStatus(); + }; + ws.onmessage = function (ev) { + if (typeof ev.data !== 'string') return; // binary frames reserved for Phase B + var msg; + try { msg = JSON.parse(ev.data); } catch (e) { return; } + if (msg && msg.type) dispatch(msg); + }; + ws.onclose = function (ev) { + state.connected = false; + state.leader = false; + if (ev && ev.code === 4403) { + state.rejected = true; + emitStatus(); + return; + } + emitStatus(); + scheduleReconnect(); + }; + ws.onerror = function () { + try { ws.close(); } catch (e) { /* already closing */ } + }; + } + + function scheduleReconnect() { + if (closedByUser || state.rejected) return; + setTimeout(connect, backoff); + backoff = Math.min(backoff * 2, BACKOFF_MAX); + } + + connect(); + + return { + state: state, + + // on(type, fn): subscribe to a message type; '*' catches everything. + on: function (type, fn) { + (listeners[type] = listeners[type] || []).push(fn); + }, + + // onStatus(fn): connection or role changes; called once immediately. + onStatus: function (fn) { + statusFns.push(fn); + fn(state); + }, + + send: function (obj) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(obj)); + return true; + } + return false; + }, + + // cmd(name, value): send a command frame per the protocol. + cmd: function (name, value) { + var m = { type: 'cmd', cmd: name }; + if (value !== undefined && value !== null) m.value = value; + return this.send(m); + }, + + close: function () { + closedByUser = true; + if (ws) { try { ws.close(); } catch (e) { /* noop */ } } + } + }; + } + + window.MC.createWS = createWS; +})(); diff --git a/skills/mc-prompter/scripts/server/static/overlay.html b/skills/mc-prompter/scripts/server/static/overlay.html new file mode 100644 index 0000000..96c2e1c --- /dev/null +++ b/skills/mc-prompter/scripts/server/static/overlay.html @@ -0,0 +1,21 @@ + + + + + + + mc-prompter | overlay + + + + + + +
mc-prompter session connected
+
+ + + + + diff --git a/skills/mc-prompter/scripts/server/static/prompt.html b/skills/mc-prompter/scripts/server/static/prompt.html new file mode 100644 index 0000000..5d7965f --- /dev/null +++ b/skills/mc-prompter/scripts/server/static/prompt.html @@ -0,0 +1,191 @@ + + + + + + + mc-prompter | prompt + + + + + +
+
+
+
+
+
+ +
+
+ + + + +
+
+ 00:00 / --:-- + +
+
+ 150 wpm + manual + + + +
+
+ + + + + + + + + + + +
+ + + + + + + + diff --git a/skills/mc-prompter/scripts/server/static/remote.html b/skills/mc-prompter/scripts/server/static/remote.html new file mode 100644 index 0000000..38c7762 --- /dev/null +++ b/skills/mc-prompter/scripts/server/static/remote.html @@ -0,0 +1,55 @@ + + + + + + + mc-prompter | remote + + + + + + + + + + + + + + diff --git a/skills/mc-prompter/scripts/tests/fixtures/sample-script.md b/skills/mc-prompter/scripts/tests/fixtures/sample-script.md new file mode 100644 index 0000000..b02fa5b --- /dev/null +++ b/skills/mc-prompter/scripts/tests/fixtures/sample-script.md @@ -0,0 +1,46 @@ +# Why Local Tools Win + +A quick note before we start. This opening line sits above the first section +heading on purpose, so ingestion has a preamble to deal with. + +## Cold open + +Hey everyone, welcome back to the workshop. Today we are digging into why +running your tools locally beats renting them by the minute. + +[smile, beat, then lean in] + +Stick around to the end, because the last demo surprised even me. + +## The cost argument + +Cloud bills compound quietly. A local rig is a one-time cost that keeps +paying you back. I ran the numbers last month and the break-even point was +under ninety days. [INVENTED] That figure assumes you already own a decent +GPU, which most of you watching probably do. + +[TAKE int1 12.0s-19.5s] +The first time I saw the invoice I genuinely thought it was a typo, and +that was the moment I started moving everything back on-prem. + +## Latency + +Round trips to a data center add up. When the model sits on your own +machine, the answer starts streaming before your finger leaves the key. +Local inference latency has dropped forty percent this year. [INVENTED] + +That speed changes how you work [pause for the b-roll insert] because you +stop batching your thoughts and start thinking out loud. + +[TAKE int1 41.25s-52.0s] +My editor asked me why the demo footage looked sped up, and I had to +explain that no, that is just what local feels like. + +## Wrap + +So that is the case: your money, your latency, and your data all point the +same direction. Every major toolchain will ship a local-first mode within a +year. [INVENTED] Try one local tool this week and tell me how it goes in +the comments. + +Catch you in the next one. diff --git a/skills/mc-prompter/scripts/tests/test-run_prompter.py b/skills/mc-prompter/scripts/tests/test-run_prompter.py new file mode 100644 index 0000000..3b717c5 --- /dev/null +++ b/skills/mc-prompter/scripts/tests/test-run_prompter.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Tests for run_prompter.py (mc-prompter Phase A launcher). + +Run directly: + uv run skills/mc-prompter/scripts/tests/test-run_prompter.py + +Pure stdlib unittest. Unit-tests the port-probe, health-identity, port +selection, session-file, and command-building helpers by importing them. +Nothing spawns the real server (spawn logic lives only in main(), which +these tests never call); the only sockets used are loopback listeners +opened inside the tests themselves. +""" + +import http.server +import json +import socket +import sys +import tempfile +import threading +import unittest +from pathlib import Path + +TESTS_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(TESTS_DIR.parent)) + +import run_prompter as rp # noqa: E402 + + +def ephemeral_listener(): + """An open loopback TCP listener on an OS-assigned port.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + sock.listen(1) + return sock, sock.getsockname()[1] + + +class HealthServer: + """Tiny loopback HTTP server answering /health with a fixed payload.""" + + def __init__(self, payload): + body = json.dumps(payload).encode("utf-8") + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path != "/health": + self.send_error(404) + return + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + self.httpd = http.server.HTTPServer(("127.0.0.1", 0), Handler) + self.port = self.httpd.server_address[1] + self.thread = threading.Thread( + target=self.httpd.serve_forever, daemon=True + ) + self.thread.start() + + def stop(self): + self.httpd.shutdown() + self.httpd.server_close() + + +class TestPortProbe(unittest.TestCase): + + def test_busy_port_is_not_free(self): + sock, port = ephemeral_listener() + try: + self.assertFalse(rp.port_is_free(port)) + finally: + sock.close() + + def test_released_port_is_free(self): + sock, port = ephemeral_listener() + sock.close() + self.assertTrue(rp.port_is_free(port)) + + +class TestHealthIdentity(unittest.TestCase): + + def test_recognizes_mc_prompter(self): + server = HealthServer( + {"app": "mc-prompter", "session": "abcd1234", + "script": "script.md", "started": "2026-07-09T10:00:00"} + ) + try: + info = rp.health_identity(server.port) + self.assertIsNotNone(info) + self.assertEqual(info["session"], "abcd1234") + finally: + server.stop() + + def test_rejects_other_app(self): + server = HealthServer({"app": "something-else"}) + try: + self.assertIsNone(rp.health_identity(server.port)) + finally: + server.stop() + + def test_none_when_unreachable(self): + sock, port = ephemeral_listener() + sock.close() + self.assertIsNone(rp.health_identity(port, timeout=0.2)) + + +class TestPickPort(unittest.TestCase): + + def test_free_requested_port_is_used(self): + port = rp.pick_port( + 8770, explicit=False, + probe=lambda p: True, identify=lambda p: None, + ) + self.assertEqual(port, 8770) + + def test_auto_increments_past_busy_ports(self): + port = rp.pick_port( + 8770, explicit=False, + probe=lambda p: p >= 8772, identify=lambda p: None, + ) + self.assertEqual(port, 8772) + + def test_explicit_busy_port_raises_conflict(self): + info = {"app": "mc-prompter", "session": "abcd1234", + "script": "x.md", "started": "2026-07-09T10:00:00"} + with self.assertRaises(rp.PortConflict) as ctx: + rp.pick_port( + 9000, explicit=True, + probe=lambda p: False, identify=lambda p: info, + ) + self.assertEqual(ctx.exception.port, 9000) + self.assertEqual(ctx.exception.info, info) + + def test_scan_limit_exhaustion_raises_runtime_error(self): + with self.assertRaises(RuntimeError): + rp.pick_port( + 8770, explicit=False, + probe=lambda p: False, identify=lambda p: None, limit=3, + ) + + +class TestSessionFile(unittest.TestCase): + + def test_path_shape(self): + path = rp.session_file_path(8770) + self.assertEqual(path.name, "session-8770.json") + self.assertEqual(path.parent.name, "mc-prompter") + self.assertTrue(str(path).startswith(tempfile.gettempdir())) + + def test_write_session_file(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "nested" / "session-8771.json" + rp.write_session_file( + path, port=8771, pid=4242, token="tok123", + script="/abs/script.md", + ) + data = json.loads(path.read_text(encoding="utf-8")) + self.assertEqual(data["port"], 8771) + self.assertEqual(data["pid"], 4242) + self.assertEqual(data["token"], "tok123") + self.assertEqual(data["script"], "/abs/script.md") + self.assertIn("T", data["started"]) + + +class TestBuildServerCmd(unittest.TestCase): + + def test_command_shape(self): + cmd = rp.build_server_cmd( + 8770, "127.0.0.1", "/abs/script.md", 150, + "/tmp/mc-prompter/session-8770.json", "tok123", + ) + self.assertEqual(cmd[:2], ["uv", "run"]) + self.assertIn("--with", cmd) + self.assertIn("aiohttp==3.12.15", cmd) + self.assertIn("-m", cmd) + self.assertIn("server.main", cmd) + self.assertEqual(cmd[cmd.index("--port") + 1], "8770") + self.assertEqual(cmd[cmd.index("--host") + 1], "127.0.0.1") + self.assertEqual(cmd[cmd.index("--script") + 1], "/abs/script.md") + self.assertEqual(cmd[cmd.index("--owner-wpm") + 1], "150") + self.assertEqual(cmd[cmd.index("--token") + 1], "tok123") + + def test_empty_script_and_zero_wpm(self): + cmd = rp.build_server_cmd(8770, "0.0.0.0", "", 0, "s.json", "t") + self.assertEqual(cmd[cmd.index("--script") + 1], "") + self.assertEqual(cmd[cmd.index("--owner-wpm") + 1], "0") + + +class TestSpawnAndTerminate(unittest.TestCase): + + def test_terminate_server_reaps_the_process_group(self): + child = rp.spawn_server( + [sys.executable, "-c", "import time; time.sleep(60)"] + ) + try: + rp.terminate_server(child, timeout=5.0) + self.assertIsNotNone(child.poll()) + finally: + if child.poll() is None: + child.kill() + child.wait() + + def test_terminate_server_on_already_dead_child(self): + child = rp.spawn_server([sys.executable, "-c", "pass"]) + child.wait() + rp.terminate_server(child, timeout=5.0) + self.assertIsNotNone(child.poll()) + + def test_startup_timeout_allows_cold_uv_cache(self): + # cold uv caches can take minutes to provision aiohttp; the health + # wait must not give up after the old 30 s deadline + self.assertGreaterEqual(rp.STARTUP_TIMEOUT, 300.0) + + +class TestLocalIp(unittest.TestCase): + + def test_returns_a_string(self): + ip = rp.local_ip() + self.assertIsInstance(ip, str) + self.assertGreaterEqual(ip.count("."), 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/mc-prompter/scripts/tests/test-script_ingest.py b/skills/mc-prompter/scripts/tests/test-script_ingest.py new file mode 100644 index 0000000..7922186 --- /dev/null +++ b/skills/mc-prompter/scripts/tests/test-script_ingest.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Tests for script_ingest.py (mc-prompter Phase A). + +Run directly: + uv run skills/mc-prompter/scripts/tests/test-script_ingest.py + +Pure stdlib unittest; no network, no models, no downloads. Uses the committed +fixture at fixtures/sample-script.md plus small inline documents. +""" + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +TESTS_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(TESTS_DIR.parent / "server")) + +from script_ingest import ingest # noqa: E402 + +FIXTURE = TESTS_DIR / "fixtures" / "sample-script.md" + + +def blocks_of(doc, section_index): + return doc["sections"][section_index]["blocks"] + + +def speakable_text(doc): + parts = [] + for section in doc["sections"]: + for block in section["blocks"]: + if block["type"] in ("para", "take"): + for run in block["runs"]: + parts.append(run["text"]) + return " ".join(parts) + + +class TestSectioning(unittest.TestCase): + + def test_title_from_first_h1(self): + doc = ingest("# My Title\n\nHello there.\n") + self.assertEqual(doc["title"], "My Title") + + def test_first_h1_is_not_a_section(self): + doc = ingest("# My Title\n\nHello there.\n\n## One\n\nBody.\n") + headings = [s["heading"] for s in doc["sections"]] + self.assertNotIn("My Title", headings) + + def test_h1_content_lands_in_null_heading_section(self): + doc = ingest("# My Title\n\nHello there.\n\n## One\n\nBody.\n") + first = doc["sections"][0] + self.assertIsNone(first["heading"]) + self.assertEqual(first["level"], 0) + self.assertEqual(first["blocks"][0]["runs"][0]["text"], + "Hello there.") + + def test_preamble_before_any_heading(self): + doc = ingest("Lead-in text.\n\n## One\n\nBody.\n") + self.assertIsNone(doc["title"]) + self.assertIsNone(doc["sections"][0]["heading"]) + self.assertEqual(doc["sections"][1]["heading"], "One") + + def test_section_ids_are_sequential(self): + doc = ingest("Pre.\n\n## A\n\nx.\n\n## B\n\ny.\n\n### C\n\nz.\n") + self.assertEqual([s["id"] for s in doc["sections"]], + ["s0", "s1", "s2", "s3"]) + self.assertEqual(doc["sections"][3]["level"], 3) + + def test_heading_levels_recorded(self): + doc = ingest("## Two\n\na.\n\n### Three\n\nb.\n") + self.assertEqual(doc["sections"][0]["level"], 2) + self.assertEqual(doc["sections"][1]["level"], 3) + + def test_second_h1_is_a_normal_section(self): + doc = ingest("# Title\n\n# Another\n\nBody.\n") + self.assertEqual(doc["title"], "Title") + self.assertEqual(doc["sections"][0]["heading"], "Another") + self.assertEqual(doc["sections"][0]["level"], 1) + + +class TestTakeParsing(unittest.TestCase): + + def test_take_paragraph_parsed(self): + doc = ingest("[TAKE int1 12.0s-19.5s]\nAlready recorded line.\n") + block = blocks_of(doc, 0)[0] + self.assertEqual(block["type"], "take") + self.assertEqual(block["source"], "int1") + self.assertEqual(block["start"], 12.0) + self.assertEqual(block["end"], 19.5) + self.assertEqual(block["runs"][0]["text"], "Already recorded line.") + + def test_take_marker_inline_mid_paragraph(self): + doc = ingest("Some text [TAKE cam2 5s-9s] more text.\n") + block = blocks_of(doc, 0)[0] + self.assertEqual(block["type"], "take") + self.assertEqual(block["source"], "cam2") + self.assertEqual(block["start"], 5.0) + self.assertEqual(block["end"], 9.0) + self.assertEqual(block["runs"][0]["text"], "Some text more text.") + + def test_malformed_take_degrades_to_note(self): + doc = ingest("[TAKE int1]\nStill speakable text.\n") + blocks = blocks_of(doc, 0) + types = [b["type"] for b in blocks] + self.assertIn("para", types) + self.assertIn("note", types) + note = next(b for b in blocks if b["type"] == "note") + self.assertEqual(note["text"], "TAKE int1") + + def test_malformed_take_missing_s_suffix(self): + doc = ingest("Text here. [TAKE int1 12.0-19.5]\n") + blocks = blocks_of(doc, 0) + self.assertEqual(blocks[0]["type"], "para") + self.assertEqual(blocks[1]["type"], "note") + + +class TestInventedFlag(unittest.TestCase): + + def test_flags_the_sentence_it_follows(self): + doc = ingest("First sentence. Second sentence. [INVENTED] Third.\n") + runs = blocks_of(doc, 0)[0]["runs"] + self.assertEqual(runs[0]["text"], "First sentence.") + self.assertEqual(runs[0]["flags"], []) + self.assertEqual(runs[1]["text"], "Second sentence.") + self.assertEqual(runs[1]["flags"], ["invented"]) + self.assertEqual(runs[2]["text"], "Third.") + self.assertEqual(runs[2]["flags"], []) + + def test_flags_from_paragraph_start(self): + doc = ingest("Only sentence with no terminator [INVENTED] tail.\n") + runs = blocks_of(doc, 0)[0]["runs"] + self.assertEqual(runs[0]["flags"], ["invented"]) + self.assertEqual(runs[0]["text"], + "Only sentence with no terminator") + self.assertEqual(runs[1]["text"], "tail.") + + def test_marker_mid_sentence_flags_partial_span(self): + doc = ingest("Solid claim. Shaky number [INVENTED] and onward.\n") + runs = blocks_of(doc, 0)[0]["runs"] + self.assertEqual(runs[1]["text"], "Shaky number") + self.assertEqual(runs[1]["flags"], ["invented"]) + + def test_marker_not_leaked_into_text(self): + doc = ingest("A claim. [INVENTED] More text.\n") + self.assertNotIn("INVENTED", speakable_text(doc)) + + +class TestNotes(unittest.TestCase): + + def test_whole_line_note_becomes_note_block(self): + doc = ingest("[pause here]\n") + block = blocks_of(doc, 0)[0] + self.assertEqual(block["type"], "note") + self.assertEqual(block["text"], "pause here") + + def test_inline_note_extracted_after_paragraph(self): + doc = ingest("Keep talking [look at camera] without stopping.\n") + blocks = blocks_of(doc, 0) + self.assertEqual(blocks[0]["type"], "para") + self.assertEqual(blocks[0]["runs"][0]["text"], + "Keep talking without stopping.") + self.assertEqual(blocks[1]["type"], "note") + self.assertEqual(blocks[1]["text"], "look at camera") + + def test_note_text_excluded_from_word_count(self): + with_note = ingest("One two three. [a very long stage direction]\n") + without = ingest("One two three.\n") + self.assertEqual(with_note["word-count"], 3) + self.assertEqual(with_note["word-count"], without["word-count"]) + + +class TestWordCount(unittest.TestCase): + + def test_counts_para_and_take_runs_only(self): + doc = ingest( + "## S\n\nFour words right here.\n\n" + "[TAKE int1 1.0s-2.0s]\nThree more words.\n\n" + "[note words never counted at all]\n" + ) + self.assertEqual(doc["word-count"], 7) + + def test_invented_runs_are_counted(self): + doc = ingest("Two words. [INVENTED] Two more.\n") + self.assertEqual(doc["word-count"], 4) + + +class TestPlainFormat(unittest.TestCase): + + def test_no_heading_parsing(self): + doc = ingest("# not a title\n\nBody text.\n", fmt="plain") + self.assertIsNone(doc["title"]) + self.assertEqual(len(doc["sections"]), 1) + self.assertIsNone(doc["sections"][0]["heading"]) + self.assertIn("# not a title", speakable_text(doc)) + + def test_marker_rules_still_apply(self): + doc = ingest("Spoken bit [aside] here.\n\n[TAKE t1 0.5s-2.5s]\nDone.\n", + fmt="plain") + blocks = blocks_of(doc, 0) + types = [b["type"] for b in blocks] + self.assertEqual(types, ["para", "note", "take"]) + + def test_unknown_fmt_raises(self): + with self.assertRaises(ValueError): + ingest("text", fmt="html") + + +class TestFixture(unittest.TestCase): + + def setUp(self): + self.doc = ingest(FIXTURE.read_text(encoding="utf-8")) + + def test_title_and_sections(self): + self.assertEqual(self.doc["title"], "Why Local Tools Win") + headings = [s["heading"] for s in self.doc["sections"]] + self.assertEqual(headings, [None, "Cold open", "The cost argument", + "Latency", "Wrap"]) + + def test_two_takes(self): + takes = [b for s in self.doc["sections"] for b in s["blocks"] + if b["type"] == "take"] + self.assertEqual(len(takes), 2) + self.assertEqual(takes[0]["source"], "int1") + self.assertEqual(takes[0]["start"], 12.0) + self.assertEqual(takes[0]["end"], 19.5) + self.assertEqual(takes[1]["start"], 41.25) + self.assertEqual(takes[1]["end"], 52.0) + + def test_three_invented_flags(self): + flagged = [r for s in self.doc["sections"] for b in s["blocks"] + if b["type"] in ("para", "take") for r in b["runs"] + if "invented" in r["flags"]] + self.assertEqual(len(flagged), 3) + + def test_invented_after_terminator_flags_previous_sentence(self): + latency = next(s for s in self.doc["sections"] + if s["heading"] == "Latency") + flagged = [r for b in latency["blocks"] if b["type"] == "para" + for r in b["runs"] if "invented" in r["flags"]] + self.assertEqual( + flagged[0]["text"], + "Local inference latency has dropped forty percent this year.") + + def test_notes_present_and_not_counted(self): + notes = [b for s in self.doc["sections"] for b in s["blocks"] + if b["type"] == "note"] + self.assertGreaterEqual(len(notes), 2) + self.assertNotIn("smile, beat", speakable_text(self.doc)) + self.assertGreater(self.doc["word-count"], 100) + + def test_no_markers_leak_into_display_text(self): + text = speakable_text(self.doc) + self.assertNotIn("[", text) + self.assertNotIn("TAKE", text) + self.assertNotIn("INVENTED", text) + + +class TestRobustness(unittest.TestCase): + + def test_bom_prefixed_title_parses(self): + doc = ingest("# My Title\n\nHello there.\n") + self.assertEqual(doc["title"], "My Title") + self.assertNotIn("#", speakable_text(doc)) + self.assertEqual(doc["word-count"], 2) + + def test_bom_file_read_via_main(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "bom.md" + path.write_bytes("# My Title\n\nHello there.\n".encode("utf-8-sig")) + proc = subprocess.run( + [sys.executable, str(TESTS_DIR.parent / "server" + / "script_ingest.py"), str(path)], + capture_output=True, text=True) + self.assertEqual(proc.returncode, 0, proc.stderr) + doc = json.loads(proc.stdout) + self.assertEqual(doc["title"], "My Title") + + def test_non_utf8_file_exits_2_with_message(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "cp1252.md" + path.write_bytes("# Title\n\nCurly ’quote’.\n" + .encode("cp1252")) + proc = subprocess.run( + [sys.executable, str(TESTS_DIR.parent / "server" + / "script_ingest.py"), str(path)], + capture_output=True, text=True) + self.assertEqual(proc.returncode, 2) + self.assertIn("error: cannot read", proc.stderr) + + def test_nested_brackets_fully_extracted(self): + doc = ingest("Talk here [note [nested] stuff] more.\n") + blocks = blocks_of(doc, 0) + notes = [b["text"] for b in blocks if b["type"] == "note"] + self.assertIn("nested", notes) + self.assertTrue(any("note" in n and "stuff" in n for n in notes)) + text = speakable_text(doc) + self.assertNotIn("[", text) + self.assertNotIn("]", text) + self.assertEqual(text, "Talk here more.") + + def test_empty_brackets_never_leak(self): + doc = ingest("Before [] after.\n\nAlso [[]] here.\n") + text = speakable_text(doc) + self.assertNotIn("[", text) + self.assertNotIn("]", text) + self.assertIn("Before after.", text) + self.assertIn("Also here.", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/mc-prompter/scripts/tests/test-server.py b/skills/mc-prompter/scripts/tests/test-server.py new file mode 100644 index 0000000..d89112d --- /dev/null +++ b/skills/mc-prompter/scripts/tests/test-server.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = ["aiohttp==3.12.15"] +# /// +"""Tests for server/main.py (mc-prompter Phase A). + +Run directly: + uv run skills/mc-prompter/scripts/tests/test-server.py + +Stdlib unittest with aiohttp declared in the PEP 723 block; the app runs +in-process via aiohttp's test utilities. No network beyond the in-process +test server, no models, no downloads. Static pages are exercised against a +temp static dir with placeholder HTML (the real UI is built separately); +the missing-UI 503 path is covered explicitly. +""" + +import asyncio +import json +import sys +import tempfile +import unittest +from pathlib import Path + +TESTS_DIR = Path(__file__).resolve().parent +SCRIPTS_DIR = TESTS_DIR.parent +sys.path.insert(0, str(SCRIPTS_DIR)) + +from aiohttp.test_utils import AioHTTPTestCase # noqa: E402 + +from server import main as server_main # noqa: E402 + +SAMPLE = ( + "# Test Script\n\n" + "Hello world, this is the opening line.\n\n" + "## Section One\n\n" + "More speakable words live here today.\n" +) +TOKEN = "test-token-0123456789abcdef" +PAGE_ROUTES = ("/", "/prompt", "/remote", "/overlay") + + +async def recv_json(ws, timeout=5.0): + msg = await asyncio.wait_for(ws.receive(), timeout=timeout) + return json.loads(msg.data) + + +class ServerTestBase(AioHTTPTestCase): + + build_static = True + + async def get_application(self): + self.tmpdir = Path(tempfile.mkdtemp(prefix="mc-prompter-test-")) + self.script_path = self.tmpdir / "script.md" + self.script_path.write_text(SAMPLE, encoding="utf-8") + static_dir = self.tmpdir / "static" + static_dir.mkdir() + if self.build_static: + for name in server_main.PAGES.values(): + (static_dir / name).write_text( + f"

{name}

", encoding="utf-8" + ) + self.state = server_main.AppState( + token=TOKEN, owner_wpm=150, static_dir=static_dir + ) + self.state.port = 0 + self.state.load_script(self.script_path) + return server_main.create_app(self.state) + + +class TestHttpSurface(ServerTestBase): + + async def test_health_shape(self): + resp = await self.client.get("/health") + self.assertEqual(resp.status, 200) + data = await resp.json() + self.assertEqual(data["app"], "mc-prompter") + self.assertEqual(data["version"], "0.1.0") + self.assertEqual(data["session"], TOKEN[:8]) + self.assertEqual(len(data["session"]), 8) + self.assertEqual(data["script"], "script.md") + self.assertIn("port", data) + self.assertIn("T", data["started"]) + + async def test_pages_return_html(self): + for route in PAGE_ROUTES: + resp = await self.client.get(route) + self.assertEqual(resp.status, 200, route) + self.assertEqual(resp.content_type, "text/html", route) + + async def test_static_asset_served(self): + resp = await self.client.get("/static/home.html") + self.assertEqual(resp.status, 200) + + async def test_static_traversal_blocked(self): + resp = await self.client.get("/static/../script.md") + self.assertIn(resp.status, (403, 404)) + + async def test_api_state_initial(self): + resp = await self.client.get("/api/state") + self.assertEqual(resp.status, 200) + data = await resp.json() + self.assertIsNone(data["snapshot"]) + self.assertEqual(data["doc-version"], 1) + self.assertEqual(data["script"]["title"], "Test Script") + self.assertGreater(data["script"]["word-count"], 0) + self.assertEqual(data["config"]["owner-wpm"], 150) + + async def test_api_source_roundtrip(self): + resp = await self.client.get("/api/source") + data = await resp.json() + self.assertEqual(data["raw"], SAMPLE) + self.assertEqual(data["path"], str(self.script_path)) + self.assertEqual(data["doc"]["title"], "Test Script") + self.assertEqual(data["doc-version"], 1) + + new_raw = "# Edited\n\nNew words to speak.\n" + resp = await self.client.post( + "/api/source", json={"raw": new_raw, "save": True} + ) + self.assertEqual(resp.status, 200) + posted = await resp.json() + self.assertEqual(posted["doc-version"], 2) + backup = Path(posted["backup"]) + self.assertTrue(backup.is_file()) + self.assertEqual(backup.read_text(encoding="utf-8"), SAMPLE) + self.assertEqual( + self.script_path.read_text(encoding="utf-8"), new_raw + ) + backup.unlink() + + resp = await self.client.get("/api/source") + data = await resp.json() + self.assertEqual(data["raw"], new_raw) + self.assertEqual(data["doc"]["title"], "Edited") + + async def test_api_source_apply_without_save(self): + new_raw = "Session only text.\n" + resp = await self.client.post( + "/api/source", json={"raw": new_raw, "save": False} + ) + posted = await resp.json() + self.assertEqual(posted["doc-version"], 2) + self.assertIsNone(posted["backup"]) + self.assertEqual( + self.script_path.read_text(encoding="utf-8"), SAMPLE + ) + + async def test_api_source_bad_body(self): + resp = await self.client.post("/api/source", json={"save": True}) + self.assertEqual(resp.status, 400) + + async def test_rapid_saves_get_distinct_backups(self): + resp1 = await self.client.post( + "/api/source", json={"raw": "# One\n\nFirst.\n", "save": True} + ) + resp2 = await self.client.post( + "/api/source", json={"raw": "# Two\n\nSecond.\n", "save": True} + ) + backup1 = Path((await resp1.json())["backup"]) + backup2 = Path((await resp2.json())["backup"]) + try: + self.assertNotEqual(backup1, backup2) + self.assertTrue(backup1.is_file()) + self.assertTrue(backup2.is_file()) + self.assertEqual(backup1.read_text(encoding="utf-8"), SAMPLE) + self.assertEqual( + backup2.read_text(encoding="utf-8"), "# One\n\nFirst.\n" + ) + finally: + backup1.unlink(missing_ok=True) + backup2.unlink(missing_ok=True) + + async def test_save_failure_returns_400_not_500(self): + self.state.script_path = self.tmpdir / "missing-dir" / "script.md" + resp = await self.client.post( + "/api/source", json={"raw": "New text.\n", "save": True} + ) + self.assertEqual(resp.status, 400) + data = await resp.json() + self.assertIn("cannot save", data["error"]) + # the failed save must not have applied the edit + resp = await self.client.get("/api/state") + self.assertEqual((await resp.json())["doc-version"], 1) + + async def test_api_source_load(self): + other = self.tmpdir / "other.md" + other.write_text("# Other\n\nDifferent text.\n", encoding="utf-8") + resp = await self.client.post( + "/api/source/load", json={"path": str(other)} + ) + self.assertEqual(resp.status, 200) + data = await resp.json() + self.assertEqual(data["doc-version"], 2) + self.assertEqual(data["script"]["title"], "Other") + + async def test_api_source_load_bad_path(self): + resp = await self.client.post( + "/api/source/load", json={"path": str(self.tmpdir / "nope.md")} + ) + self.assertEqual(resp.status, 400) + data = await resp.json() + self.assertIn("not a readable file", data["error"]) + + +class TestMissingUi(ServerTestBase): + + build_static = False + + async def test_pages_answer_503_when_ui_absent(self): + for route in PAGE_ROUTES: + resp = await self.client.get(route) + self.assertEqual(resp.status, 503, route) + self.assertEqual(resp.content_type, "text/plain", route) + self.assertIn("UI not built", await resp.text()) + + +class TestWebSocket(ServerTestBase): + + async def hello(self, role): + ws = await self.client.ws_connect("/ws") + await ws.send_json({"type": "hello", "role": role}) + welcome = await recv_json(ws) + return ws, welcome + + async def test_welcome_and_leader_election(self): + ws1, w1 = await self.hello("prompt") + self.assertEqual(w1["type"], "welcome") + self.assertTrue(w1["leader"]) + self.assertEqual(w1["session"], TOKEN[:8]) + self.assertEqual(w1["doc-version"], 1) + self.assertIsNone(w1["snapshot"]) + + ws2, w2 = await self.hello("prompt") + self.assertFalse(w2["leader"]) + + await ws1.close() + role = await recv_json(ws2) + self.assertEqual(role, {"type": "role", "leader": True}) + await ws2.close() + + async def test_cmd_relay_to_all_with_from_stamp(self): + ws1, _ = await self.hello("prompt") + ws2, _ = await self.hello("remote") + await ws2.send_json({"type": "cmd", "cmd": "play"}) + for ws in (ws1, ws2): + frame = await recv_json(ws) + self.assertEqual(frame["type"], "cmd") + self.assertEqual(frame["cmd"], "play") + self.assertEqual(frame["from"], "remote") + await ws1.close() + await ws2.close() + + async def test_state_cached_and_fanned_out(self): + ws1, _ = await self.hello("prompt") + ws2, _ = await self.hello("remote") + snapshot = { + "type": "state", "position": 0.25, "section": "s1", + "playing": True, "wpm": 150, "mode": "manual", + "elapsed": 12.0, "remaining": 36.0, "countdown": None, + } + await ws1.send_json(snapshot) + fanned = await recv_json(ws2) + self.assertEqual(fanned, snapshot) + + resp = await self.client.get("/api/state") + data = await resp.json() + self.assertEqual(data["snapshot"], snapshot) + + ws3, w3 = await self.hello("overlay") + self.assertEqual(w3["snapshot"], snapshot) + await ws1.close() + await ws2.close() + await ws3.close() + + async def test_state_from_non_leader_ignored(self): + ws1, w1 = await self.hello("prompt") + ws2, w2 = await self.hello("prompt") + self.assertTrue(w1["leader"]) + self.assertFalse(w2["leader"]) + await ws2.send_json({"type": "state", "position": 0.9}) + # a cmd on the same connection orders after the state frame, so its + # relay proves the server already processed (and ignored) the state + await ws2.send_json({"type": "cmd", "cmd": "pause"}) + frame = await recv_json(ws2) + self.assertEqual(frame["type"], "cmd") + resp = await self.client.get("/api/state") + data = await resp.json() + self.assertIsNone(data["snapshot"]) + await ws1.close() + await ws2.close() + + async def test_doc_updated_broadcast_on_source_post(self): + ws, _ = await self.hello("home") + resp = await self.client.post( + "/api/source", json={"raw": "New text.\n", "save": False} + ) + self.assertEqual(resp.status, 200) + frame = await recv_json(ws) + self.assertEqual(frame, {"type": "doc-updated", "doc-version": 2}) + await ws.close() + + async def test_malformed_frame_gets_error(self): + ws, _ = await self.hello("remote") + await ws.send_str("this is not json") + frame = await recv_json(ws) + self.assertEqual(frame["type"], "error") + self.assertIn("malformed", frame["message"]) + await ws.close() + + async def test_first_frame_must_be_hello(self): + ws = await self.client.ws_connect("/ws") + await ws.send_json({"type": "cmd", "cmd": "play"}) + frame = await recv_json(ws) + self.assertEqual(frame["type"], "error") + self.assertIn("hello", frame["message"]) + await ws.close() + + async def test_bad_role_rejected(self): + ws = await self.client.ws_connect("/ws") + await ws.send_json({"type": "hello", "role": "hacker"}) + frame = await recv_json(ws) + self.assertEqual(frame["type"], "error") + await ws.close() + + async def test_binary_frames_rejected(self): + ws, _ = await self.hello("prompt") + await ws.send_bytes(b"\x00\x01\x02") + frame = await recv_json(ws) + self.assertEqual(frame["type"], "error") + self.assertIn("Phase B", frame["message"]) + await ws.close() + + +class TestBomHandling(unittest.TestCase): + + def test_utf8_sig_file_load_parses_title(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "bom.md" + path.write_text( + "# Bom Title\n\nWords to speak.\n", encoding="utf-8-sig" + ) + state = server_main.AppState(token=TOKEN) + state.load_script(path) + self.assertEqual(state.doc["title"], "Bom Title") + self.assertFalse(state.raw.startswith("\ufeff")) + + def test_set_raw_strips_leading_bom(self): + state = server_main.AppState(token=TOKEN) + state.set_raw("\ufeff# Pasted Title\n\nText here.\n") + self.assertEqual(state.doc["title"], "Pasted Title") + self.assertFalse(state.raw.startswith("\ufeff")) + + +class TestNoScriptStartup(AioHTTPTestCase): + + async def get_application(self): + state = server_main.AppState(token=TOKEN) + state.port = 0 + return server_main.create_app(state) + + async def test_health_and_state_without_script(self): + resp = await self.client.get("/health") + data = await resp.json() + self.assertIsNone(data["script"]) + resp = await self.client.get("/api/state") + data = await resp.json() + self.assertIsNone(data["script"]) + self.assertEqual(data["doc-version"], 0) + self.assertIsNone(data["config"]["owner-wpm"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/module-help.csv b/skills/module-help.csv index 604048e..90aebbf 100644 --- a/skills/module-help.csv +++ b/skills/module-help.csv @@ -13,6 +13,7 @@ BMad Manticore,mc-graphics,Build Graphics,GX,"Execute the approved beat table in BMad Manticore,mc-assets,Farm Assets,FA,"Source and farm the stills and b-roll the beat table calls for through registered CLI tools (metered APIs opt-in), real verified imagery first.",,,3-graphics,mc-beats,mc-package,false,projects-path,*/assets/manifest.json BMad Manticore,mc-audio,Farm Sound,AU,"Service skill, no stage or gate: local-first TTS narration and two-host dialogue (Kokoro-82M), instrumental beds (MusicGen-small), SFX (AudioLDM2). Called from graphics, stream packs, and voiceover narration, or directly.",,,anytime,,,false,,*/manifest.json BMad Manticore,mc-ograf,OGraf Graphics,OG,"Service skill: editable broadcast graphics where the target supports them (DaVinci Resolve 21+ editor lane, OBS/SPX-GC live lane). Everyone else gets baked alpha.",,,anytime,,,false,, +BMad Manticore,mc-prompter,Teleprompter,TP,"Service skill, no stage or gate: browser teleprompter for the record stage and standalone shows. Fullscreen mirrored display, phone remote over LAN with a session token, script.md marker handling, edit in place with backups. Voice-follow and producer mode are planned tiers.",,,anytime,,,false,session URLs printed by the launcher, BMad Manticore,mc-package,Package,PK,"Titles, thumbnails verified at 120px, description, CTAs, dual-timeline chapters, series A/B pairs, live-event mode. May start any time after gate 1; offer it during dead time between stages.",,,4-package,mc-outline,mc-retro,false,projects-path,*/packaging/* BMad Manticore,mc-stream-pack,Stream Pack,LS,"A complete branded livestream asset pack for OBS (scenes, stinger, lower thirds) from brand tokens; the livestream-pack format lane.",,,anytime,,,false,projects-path,*/graphics/scenes/* BMad Manticore,mc-retro,Retro,RT,"After publishing: one round of notes edits the format profile, the bibles, and the brand files so the next video starts smarter, then the post-publish wrap.",,,5-wrap,mc-package,,false,brand-path,production-bible.md