From 26444affa5313dd0dc865667a8d4163eedc6fdb0 Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Fri, 10 Jul 2026 01:14:10 -0500 Subject: [PATCH 1/5] Add rundown parser and deterministic producer state machine --- skills/mc-prompter/references/cueing.md | 68 +++ skills/mc-prompter/references/rundown-spec.md | 144 +++++ skills/mc-prompter/scripts/server/producer.py | 444 ++++++++++++++ skills/mc-prompter/scripts/server/rundown.py | 443 ++++++++++++++ .../scripts/tests/test-producer.py | 576 ++++++++++++++++++ .../mc-prompter/scripts/tests/test-rundown.py | 390 ++++++++++++ 6 files changed, 2065 insertions(+) create mode 100644 skills/mc-prompter/references/cueing.md create mode 100644 skills/mc-prompter/references/rundown-spec.md create mode 100644 skills/mc-prompter/scripts/server/producer.py create mode 100644 skills/mc-prompter/scripts/server/rundown.py create mode 100644 skills/mc-prompter/scripts/tests/test-producer.py create mode 100644 skills/mc-prompter/scripts/tests/test-rundown.py diff --git a/skills/mc-prompter/references/cueing.md b/skills/mc-prompter/references/cueing.md new file mode 100644 index 0000000..e5f2d6a --- /dev/null +++ b/skills/mc-prompter/references/cueing.md @@ -0,0 +1,68 @@ +# The cueing contract + +This is the binding contract for how mc-prompter's producer mode cues a live speaker. The deterministic state machine in `scripts/server/producer.py` and the cue engine wired in `scripts/server/main.py` implement it; the UI renders it. If code and this document disagree, that is a bug. The design principle: the LLM proposes, the state machine disposes. Every rule that protects the speaker (rate limits, tiering, coverage stickiness, the replan) is deterministic code, so a wrong model suggestion costs nothing. + +## The escalation ladder + +Four tiers, from least to most intrusive. A cue always enters at the lowest tier that can do the job. + +| Tier | Surface | Interrupts speech | Shipped | +| --- | --- | --- | --- | +| ambient | the rail: show clock, green/yellow/red state, current segment and its replanned time left, next point, progress dots | never (no motion, glanceable) | yes | +| card | a single quiet card near the eyeline ("NEXT: pricing demo", "STRETCH", "DROP: Point 4, or 90s each") | no: released at a VAD pause | yes | +| attention | the card flashes and enlarges for time-critical states ("WRAP", "2:00 OVER") | yes: the one visual tier allowed mid-sentence | yes | +| spoken | short formulaic synthesized phrases ("thirty seconds", "wrap") | pause-released only, emergency excepted | no: designed, shipped off behind `spoken-cues = false` | + +The spoken tier is a fast-follow. When it ships it carries a hard requirement: cue audio routes to headphones only, never to speakers. That is the mix-minus principle from IFB practice (the speaker must never hear their own voice back) and it is also what keeps browser echo cancellation unnecessary for the ASR path. Until then the config key exists and stays false. + +## The cue budget + +`cue-density` (config `[prompter]`, overridden by rundown frontmatter, most specific wins) sets how often card-tier cues may appear. The attention tier is exempt from the budget: time-critical states always surface. + +| Density | Card budget | Intent | +| --- | --- | --- | +| hands-off | no cards at all; time-critical attention cues only | the speaker wants a clock and nothing else | +| minimal | at most 1 card per 5 minutes | rare nudges | +| normal | at most 1 card per 2 minutes | the default producer presence | +| chatty | at most 1 card per 45 seconds | dense guidance for improvised shows | + +## Delivery rules + +- One active cue: at most one cue is on screen at any moment. Cards never stack; a new candidate waits for the active cue to clear. +- Release at a VAD pause: card-tier cues are held until the speaker pauses (the Phase B vad events). The attention tier may interrupt mid-sentence. When no ASR is running, cues release immediately (there is no pause signal to wait for). +- Auto-expiry: every cue clears itself after 15 seconds if not superseded. +- Dedup by key: every candidate carries a stable `key`. The engine shows a given key once; the producer re-emits candidates statelessly every tick and the key is what stops repeats. The OVER key advances once per whole minute over, so a long overrun re-alerts each minute. +- Quiet states: no cues before GO LIVE, none while the show is on hold, none after end-show. +- The wire frames are `{"type": "cue", "id": n, "tier": "card"|"attention", "text": str}` and `{"type": "cue-clear", "id": n}`. + +## Replan rules + +These mirror `producer.py` exactly. + +- The show clock counts live, non-hold time only. GO LIVE starts it; hold freezes it (VAD and the transcript keep running so context is not lost); resume unfreezes; end-show freezes it permanently. +- On every tick: `remaining = duration-s - elapsed`. The wrap reserve (`wrap-s`, budgeting the last segment when set) is subtracted first and protected: the wrap's replanned budget always equals its planned budget. The rest is distributed across not-done, non-wrap segments proportionally to their ORIGINAL budgets; each not-done segment's replanned budget is its share of that future, and its spent time is consumed against it. An over-running segment therefore goes red precisely because the replan can no longer afford it. +- Green/yellow/red is always computed against the REPLANNED budgets, never the original rundown: green under 80 percent consumed, yellow at 80 to 100, red over. The show-level state applies the same thresholds to elapsed against the total duration, which is the classic speech-timer semantic: yellow means wrap is approaching, red means over. +- Done segments leave the replan: they report their original planned budget and their timing reads spent against plan. That row is history, and history does not rewrite as the plan shifts. Spent time accrues only to a segment whose state is current; a done segment's spent is frozen. +- Pointer on done: after advancing past the last segment there is nothing pending, so the wire state's `current` keeps naming that final done segment. The show clock keeps counting (elapsed and remaining stay live) but no segment accrues spent while the host talks past the end; consumers of the rail state must not assume the `current` id names a segment in the current state. +- Feasibility floor: max(45 seconds, 25 percent of the segment's original budget). When the replan pushes any pending non-wrap segment below its floor during a live show, the producer emits a DROP suggestion naming the lowest-priority pending segment, defined as the last non-wrap pending segment in rundown order, with the even-split alternative: "DROP: , or <n>s each" where n is the distributable future divided across the pending non-wrap segments. A countdown that only turns red is a nag; the replan plus the DROP alternative is what makes this a producer. + +## Coverage semantics + +- Coverage is sticky and monotonic. Nothing ever un-covers a point. +- `propose_coverage` (the LLM tick and the deterministic keyword first-pass) may only flip an uncovered, unskipped point to covered. Proposals against skipped points, covered points, or unknown ids do nothing. +- The human is the final authority: mark-covered and skip from `/remote` or `/prompt` always win. Skipped points are excluded from the replan's attention and from next. +- Next is the first uncovered, unskipped point in rundown order. It stays well-defined when the speaker covers points out of order. + +## Broadcast vocabulary + +Deterministic candidates use the broadcast lexicon so a speaker who has worked with a floor manager already knows the words. + +| Text | Tier | Key | When | +| --- | --- | --- | --- | +| 30 seconds | card | seg-30:<id> | 30 seconds left in the current segment's replanned budget | +| WRAP | attention | wrap | remaining show time has reached the wrap reserve | +| STRETCH | card | stretch | the show is more than 20 percent ahead of plan (plan credit for finished segments, plus the current segment's spend capped at its budget, exceeds 1.2 times elapsed) | +| <m>:<ss> OVER | attention | over:<m> | past the show duration; the key advances per whole minute over | +| DROP: <title>, or <n>s each | card | drop:<id> | the feasibility floor is breached (see replan rules) | + +The LLM tick may additionally propose one short cue per tick in the same vocabulary (a next-point nudge, a stretch suggestion). LLM proposals enter the engine as card-tier candidates and obey every rule above: the budget, one-active-cue, pause release, expiry. The engine, not the model, decides what the speaker sees. diff --git a/skills/mc-prompter/references/rundown-spec.md b/skills/mc-prompter/references/rundown-spec.md new file mode 100644 index 0000000..9ef8d22 --- /dev/null +++ b/skills/mc-prompter/references/rundown-spec.md @@ -0,0 +1,144 @@ +# The rundown format + +This is the binding specification for the rundown file that drives mc-prompter's producer mode. The parser at `scripts/server/rundown.py` implements exactly this spec; if the parser and this document disagree, that is a bug. A starter template ships into the studio through mc-setup's assets, so any skill can draft a rundown as a project file without reading this skill's folder. + +## What a rundown is + +A rundown is one markdown file describing a timed show: total duration, an ordered list of segments with optional per-segment budgets, and a protected wrap. Segments carry either full scripted text (prompted like any script) or bullet points (talking points the producer tracks for coverage). For pipeline projects the file lives at `{projects-path}/<slug>/rundown.md`; standalone shows can pass any path. + +## Example + +```markdown +--- +show: "Why local models win" +duration-minutes: 30 +cue-density: normal # hands-off | minimal | normal | chatty +wrap-minutes: 3 +--- + +## Intro (3 min) + +Full scripted intro text, prompted normally. + +## Point 1: The cost argument (5 min) + +- cloud bills compound, local is capex +- the 4090 anecdote + +## Point 2: Latency (5 min) + +- round trips add up +- the demo + +## Wrap (3 min) + +Scripted wrap text. +``` + +## Frontmatter + +The file starts with a `---` delimited block of flat `key: value` lines. Values may be quoted; an unquoted value may carry a trailing `# comment`, which is stripped. Nesting is not supported. + +| Key | Required | Type | Meaning | +| --- | --- | --- | --- | +| show | no | string | Show title. Defaults to an empty string. | +| duration-minutes | yes | positive int | Total show length. The hard constraint all time math reconciles against. | +| cue-density | no | enum | One of `hands-off`, `minimal`, `normal`, `chatty`. Overrides the config value: most specific wins. Any other value is a hard error. | +| wrap-minutes | no | positive int | When present, the LAST segment is the wrap and its budget is protected (see time math). Exceeding duration-minutes is a hard error; equaling it loads, but every other segment reconciles to 0 seconds and a warning names them. | + +Unknown keys are ignored with a warning, so typos surface at load instead of silently doing nothing. Missing frontmatter, a missing or non-integer `duration-minutes`, and an unterminated block are hard errors. + +## Segments + +Segments split on level-2 `## ` headings. Deeper headings (`###` and below) are body content. Non-blank content before the first `## ` heading is a hard error with its line number: the parser never guesses which segment stray text belongs to. A rundown with no segments is a hard error. + +Segment ids are `g0`, `g1`, ... in document order. The heading text minus any time suffix is the segment title; an empty title is a hard error. + +## Time suffixes + +A heading may end with a time budget in parentheses. Exactly two forms are accepted: + +- `## Intro (3 min)` gives the segment 3 minutes +- `## Intro (3m)` is the compact equivalent + +Anything else in a trailing paren group that looks like a time is a hard, line-numbered error. Reject, do not guess: hand-written and model-drafted rundowns produce creative variants on day one, and a silently misread budget is worse than a load error. + +| Trailing group | Result | +| --- | --- | +| `(3 min)` | accepted, 3 minutes | +| `(12m)` | accepted, 12 minutes | +| `(3 minutes)` | error: unrecognized time suffix | +| `(3:00)` | error: unrecognized time suffix | +| `(90s)` | error: unrecognized time suffix | +| `(5)` | error: unrecognized time suffix | +| `(0 min)` | error: budget must be positive | +| `(demo)` | not a time; stays in the title | +| `(part 2)` | not a time; stays in the title | + +Looks like a time means: a bare number, a `digits:digits` clock form, digits glued to letters (`90s`, `5min`), or a number appearing alongside a time-unit word (`min`, `mins`, `minute`, `minutes`, `s`, `sec`, `second`, `seconds`, `h`, `hr`, `hour`, `hours` and plurals). A trailing paren group with no digits, or with digits but none of those shapes, is title text. Only the trailing paren group is examined; parentheses elsewhere in the title are never touched. + +A heading with no suffix is unbudgeted and gets a share of the remaining time (see time math). + +## Segment kinds + +- A segment whose body contains any non-bullet prose is `scripted`. Its body is what gets prompted (ingested via script_ingest by the server); its points list is empty, even if the body also contains bullets. +- A segment whose body contains only `- ` bullets, or nothing at all, is `bullets`. Each top-level bullet becomes a point the producer tracks for coverage. + +Only `- ` bullets count as list items; `*` bullets are treated as prose and make the segment scripted. + +Bullet edge rules, all deterministic: + +- Nesting: an indented `- ` sub-bullet joins its parent point. The sub-bullet text is appended to the preceding top-level point after `; `, so `- main` followed by an indented `- detail` yields one coverage point, `main; detail`. Sub-bullets never become independent points. An indented bullet with no preceding top-level bullet is prose and forces the segment scripted. +- Continuation lines: each point must sit on one line. A non-bullet line under a bullet (the shape a hard-wrapping editor produces) is prose, so the whole segment becomes `scripted` and its points are not tracked. +- Warning on the mix: whenever a segment classified `scripted` contains at least one `- ` bullet line, the parser records a warning naming the segment and stating that its points are not tracked. Loading is not blocked; the warning surfaces on the home page so a hard-wrapped bullet cannot silently kill coverage. + +## Time math + +All budgets resolve to whole seconds (`planned-s`). The rules, in order: + +1. `duration-s = duration-minutes * 60`. This number always wins. +2. When `wrap-minutes` is set, the last segment is the wrap and `planned-s = wrap-minutes * 60`. If the wrap heading also carries a suffix and it differs, a warning is recorded and wrap-minutes wins. The wrap budget is protected: reconciliation never scales it. +3. Explicit suffixes on other segments become their budgets. +4. Unbudgeted segments split the remaining time (`duration-s` minus the wrap minus the explicit budgets) evenly. Rounding uses whole seconds; spare seconds from the division go to the earliest unbudgeted segments so the totals add up exactly. If this split leaves any segment with `planned-s` 0 (the wrap reserve and explicit budgets consume the whole show, e.g. wrap-minutes equal to duration-minutes), a warning names the zeroed segments: a plan where a segment has no time never loads silently. +5. If the explicit budgets exceed the available time, duration-minutes wins: the non-wrap explicit budgets are scaled proportionally to fit (largest-remainder rounding, so the reconciled plan sums exactly) and a warning is recorded. If unbudgeted segments exist in this case they get `planned-s` 0 and a second warning names them. +6. If every segment is budgeted and time is left over, the budgets are kept and a warning notes the unallocated seconds. The show simply has slack; the live replanner will stretch into it. + +Warnings never block loading. The home page shows the reconciled plan, warnings included, before the show starts. + +## Parse result + +`parse_rundown(text)` returns: + +```json +{ + "show": "Why local models win", + "duration-s": 1800, + "cue-density": "normal", + "wrap-s": 180, + "warnings": [], + "segments": [ + {"id": "g0", "title": "Intro", "kind": "scripted", "planned-s": 180, + "body": "Full scripted intro text, prompted normally.", + "points": []}, + {"id": "g1", "title": "Point 1: The cost argument", "kind": "bullets", + "planned-s": 300, + "body": "- cloud bills compound, local is capex\n- the 4090 anecdote", + "points": [{"text": "cloud bills compound, local is capex", "covered": false}, + {"text": "the 4090 anecdote", "covered": false}]} + ] +} +``` + +`cue-density` is null when the frontmatter omits it; `wrap-s` is null when there is no wrap. `body` is the raw body text with outer blank lines stripped, for both kinds. + +## Errors + +Hard errors raise `RundownError(line, message)` with the 1-based source line. The hard error set: missing or unterminated frontmatter, malformed frontmatter lines, missing or invalid `duration-minutes`, invalid `cue-density`, invalid `wrap-minutes` or a wrap exceeding the duration, an unrecognized time suffix, a non-positive segment budget, an empty segment title, content before the first heading, and a rundown with no segments. + +## CLI + +``` +uv run {skill-root}/scripts/server/rundown.py <file> +``` + +Prints the parse result as JSON. Exit codes: 0 ok, 1 parse error (the error with its line number goes to stderr), 2 file missing or unreadable. diff --git a/skills/mc-prompter/scripts/server/producer.py b/skills/mc-prompter/scripts/server/producer.py new file mode 100644 index 0000000..20aaa7c --- /dev/null +++ b/skills/mc-prompter/scripts/server/producer.py @@ -0,0 +1,444 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Producer state machine for mc-prompter (Phase C). Pure stdlib. + +Deterministic show-time replanner and cue-candidate source. No I/O, no +threads, no LLM: server/main.py owns the wiring (tick loop, cue engine, +LLM proposals, WS broadcast). The binding behavior contract lives in +references/cueing.md; the rundown input shape in references/rundown-spec.md. + + producer = Producer(parse_rundown(text), cue_density="normal", + now_fn=time.monotonic) + producer.go_live() + state = producer.tick() # the rail state dict (shape below) + cues = producer.cue_candidates() # [{"tier","text","key"}, ...] + +Clock: + now_fn is injectable for tests (fake clock); time.monotonic is only the + default. now_fn must be non-decreasing (time.monotonic satisfies + this). The show clock counts live, non-hold time only. Before + go_live() everything is pre-show (live false, elapsed 0). hold() + freezes elapsed (VAD and the transcript keep running outside this + module); resume() unfreezes; end_show() freezes elapsed permanently. + +Authority and coverage: + Coverage is sticky and monotonic. propose_coverage() (LLM or keyword + first-pass) may only flip an uncovered, unskipped point to covered and + silently ignores unknown ids (model output is untrusted). + mark_covered() and skip_point() are human-authoritative and raise + ValueError on unknown ids (those calls come from validated UI paths). + Nothing ever un-covers a point. "Next" is the first uncovered, + unskipped point in rundown order. + +Segments: + The first segment is current from construction. advance_segment() + marks the current segment done and moves to the next pending one (a + manual or anchor-driven handoff). make_current(seg_id) jumps: when the + old pointer segment is actually current it becomes done when it sits + before the target in rundown order, or returns to pending when it sits + after (a backwards jump reopens work); a pointer resting on a done + segment stays done. The target becomes current even if it was done. + Per-segment spent-s accrues only to a segment whose state is + "current". Advancing past the last segment leaves the pointer on that + done segment: the wire state's "current" then names a done segment, + elapsed keeps counting, and no segment accrues spent (done rows are + frozen history). + +Replan (every tick): + remaining = duration-s - elapsed. The wrap reserve (wrap-s, the last + segment when set) is subtracted first and protected: the wrap's + replanned-s always equals its planned-s. What is left is distributed + across not-done, non-wrap segments proportionally to their ORIGINAL + budgets; each not-done segment's replanned-s IS its share of that + future, and its spent-s is consumed against it. Green/yellow/red is + computed against the REPLANNED budgets: green under 80 percent + consumed, yellow 80 to 100, red over. Done segments leave the replan: + they report replanned-s equal to their planned-s and their timing + reads spent against plan (history, not replan). The show state uses + elapsed against duration-s with the same thresholds. + + Feasibility floor: max(45 s, 25 percent of the original budget). When + the replan pushes any pending non-wrap segment below its floor while + live, a DROP candidate names the lowest-priority pending segment (the + last non-wrap pending segment in rundown order) with the alternative + even split: "DROP: <title>, or <n>s each" where n is the distributable + future divided across the pending non-wrap segments. + +Cue candidates (deterministic, broadcast vocabulary): + Emitted only while live and not on hold. Stateless: the same condition + yields the same candidate every tick; the cue engine dedupes on "key". + - "<m>:<ss> OVER" (attention, key "over:<m>") when past duration + - "WRAP" (attention, key "wrap") when remaining <= wrap-s + - "30 seconds" (card, key "seg-30:<id>") at 30 s left in the current + segment's replanned budget + - DROP text (card, key "drop:<id>") while the drop stands + - "STRETCH" (card, key "stretch") when the show is more than 20 + percent ahead of plan (plan credit for finished segments plus the + current segment's capped spend exceeds 1.2 x elapsed) + +The rail state dict (wire shape, broadcast as {"type":"producer", +"state":...}): + + {"live": bool, "hold": bool, "elapsed-s": int, "remaining-s": int, + "show-state": "green"|"yellow"|"red", "current": "g1", + "next-point": {"segment": "g1", "idx": 2, "text": str} | null, + "segments": [{"id", "title", "kind", "planned-s", "replanned-s", + "spent-s", "state": "done"|"current"|"pending", + "timing": "green"|"yellow"|"red", + "points": [{"text", "covered", "skipped"}]}], + "drop": {"segment": id, "text": str} | null} + +state and tick() return deep copies; callers may mutate them freely. +""" + +import copy +import time + +FLOOR_MIN_S = 45 +FLOOR_FRACTION = 0.25 +GREEN_BELOW = 0.8 +STRETCH_AHEAD = 1.2 +SEG_CUE_AT_S = 30 + + +class Producer: + """Deterministic rundown state machine. See the module docstring.""" + + def __init__(self, rundown, cue_density="normal", now_fn=time.monotonic): + self._now = now_fn + self.duration_s = rundown["duration-s"] + self.wrap_s = rundown.get("wrap-s") + # Frontmatter cue-density overrides the config value passed in + # (most specific wins); main.py normally resolves this already. + self.cue_density = rundown.get("cue-density") or cue_density + self._segments = [] + for seg in rundown["segments"]: + self._segments.append({ + "id": seg["id"], + "title": seg["title"], + "kind": seg["kind"], + "planned": int(seg["planned-s"]), + "points": [{"text": p["text"], + "covered": bool(p.get("covered", False)), + "skipped": False} + for p in seg.get("points", [])], + "spent": 0.0, + "state": "pending", + }) + if not self._segments: + raise ValueError("rundown has no segments") + self._wrap_idx = (len(self._segments) - 1 + if self.wrap_s is not None else None) + self._segments[0]["state"] = "current" + self._current = 0 + self._live = False + self._hold = False + self._ended = False + self._accum = 0.0 # committed live seconds + self._mark = 0.0 # now() at the last go_live/resume + self._synced = 0.0 # elapsed already attributed to segments + self._snapshot = None + self._recompute() + + # ----- clock ----- + + def _elapsed(self): + e = self._accum + if self._live and not self._hold: + e += self._now() - self._mark + return e + + def _sync_spent(self): + # _synced never rewinds: only a positive delta advances it, so a + # clock hiccup can never double-count a span into spent. + e = self._elapsed() + delta = e - self._synced + if delta > 0: + # Spent accrues only while the pointer segment is actually + # current. After advancing past the last segment the pointer + # rests on a done segment: elapsed keeps counting but the done + # row's spent is frozen (history does not rewrite). + if self._segments[self._current]["state"] == "current": + self._segments[self._current]["spent"] += delta + self._synced = e + return e + + def go_live(self): + """Start the show clock. No-op when already live or ended.""" + if self._live or self._ended: + return + self._live = True + self._hold = False + self._mark = self._now() + self._recompute() + + def hold(self): + """Freeze the show clock (BRB, technical trouble).""" + if not self._live or self._hold or self._ended: + return + self._accum += self._now() - self._mark + self._hold = True + self._recompute() + + def resume(self): + """Unfreeze the show clock after hold().""" + if not self._live or not self._hold or self._ended: + return + self._hold = False + self._mark = self._now() + self._recompute() + + def end_show(self): + """End the show; elapsed freezes permanently.""" + if self._ended: + return + # Sync BEFORE folding the live span into _accum: folding first + # would leave _mark stale while _live is still True, so _elapsed() + # would count the final span twice and corrupt the current + # segment's spent. + self._sync_spent() + if self._live and not self._hold: + self._accum += self._now() - self._mark + self._live = False + self._hold = False + self._ended = True + self._recompute() + + # ----- points ----- + + def _find(self, seg_id): + for i, seg in enumerate(self._segments): + if seg["id"] == seg_id: + return i + return None + + def _point(self, seg_id, point_idx, strict): + i = self._find(seg_id) + if i is None: + if strict: + raise ValueError(f"unknown segment: {seg_id}") + return None + points = self._segments[i]["points"] + if not isinstance(point_idx, int) or not 0 <= point_idx < len(points): + if strict: + raise ValueError( + f"unknown point: {seg_id}[{point_idx}]") + return None + return points[point_idx] + + def mark_covered(self, seg_id, point_idx): + """Human authority: cover a point (sticky, works on skipped too).""" + point = self._point(seg_id, point_idx, strict=True) + point["covered"] = True + self._recompute() + + def skip_point(self, seg_id, point_idx): + """Human authority: exclude a point from next and proposals.""" + point = self._point(seg_id, point_idx, strict=True) + point["skipped"] = True + self._recompute() + + def propose_coverage(self, seg_id, point_idx): + """LLM/keyword proposal: uncovered and unskipped -> covered ONLY. + + Unknown ids are ignored (untrusted input). Returns True when the + point flipped. + """ + point = self._point(seg_id, point_idx, strict=False) + if point is None or point["covered"] or point["skipped"]: + return False + point["covered"] = True + self._recompute() + return True + + # ----- segments ----- + + def make_current(self, seg_id): + """Jump the current segment (human authority, any direction).""" + target = self._find(seg_id) + if target is None: + raise ValueError(f"unknown segment: {seg_id}") + self._sync_spent() + old = self._current + if target != old and self._segments[old]["state"] == "current": + # Demote only a segment that is actually current. When the + # pointer rests on a done segment (advanced past the end) a + # backward jump must not resurrect it as pending. + if old < target: + self._segments[old]["state"] = "done" + else: + self._segments[old]["state"] = "pending" + self._segments[target]["state"] = "current" + self._current = target + self._recompute() + + def advance_segment(self): + """Manual or anchor-driven handoff to the next pending segment. + + Marks the current segment done. On the last segment there is + nothing to advance to: it is marked done and stays the pointer. + """ + self._sync_spent() + self._segments[self._current]["state"] = "done" + for i in range(self._current + 1, len(self._segments)): + if self._segments[i]["state"] == "pending": + self._segments[i]["state"] = "current" + self._current = i + break + self._recompute() + + # ----- replan ----- + + def _recompute(self): + e = self._sync_spent() + remaining = self.duration_s - e + + wrap = (self._segments[self._wrap_idx] + if self._wrap_idx is not None else None) + wrap_future = 0.0 + if wrap is not None and wrap["state"] != "done": + wrap_future = max(0.0, wrap["planned"] - wrap["spent"]) + + pool = [s for i, s in enumerate(self._segments) + if s["state"] != "done" and i != self._wrap_idx] + avail = max(0.0, remaining - wrap_future) + weights = [s["planned"] for s in pool] + wsum = sum(weights) + + replanned = {} + for s, w in zip(pool, weights): + if wsum > 0: + share = avail * w / wsum + else: + share = avail / len(pool) if pool else 0.0 + replanned[s["id"]] = int(round(share)) + if wrap is not None and wrap["state"] != "done": + replanned[wrap["id"]] = wrap["planned"] + for s in self._segments: + if s["state"] == "done": + replanned[s["id"]] = s["planned"] + self._last_replanned = replanned + + # Feasibility floor and the DROP candidate (live shows only). + drop = None + pending = [s for i, s in enumerate(self._segments) + if s["state"] == "pending" and i != self._wrap_idx] + if self._live and pending: + below = any( + replanned[s["id"]] + < max(FLOOR_MIN_S, FLOOR_FRACTION * s["planned"]) + for s in pending) + if below: + target = pending[-1] + n = int(avail // len(pending)) + drop = {"segment": target["id"], + "text": f"DROP: {target['title']}, or {n}s each"} + + elapsed_i = int(e) + segments_out = [] + for s in self._segments: + rp = replanned[s["id"]] + spent_i = int(s["spent"]) + segments_out.append({ + "id": s["id"], + "title": s["title"], + "kind": s["kind"], + "planned-s": s["planned"], + "replanned-s": rp, + "spent-s": spent_i, + "state": s["state"], + "timing": self._timing(s["spent"], rp), + "points": [dict(p) for p in s["points"]], + }) + + self._snapshot = { + "live": self._live, + "hold": self._hold, + "elapsed-s": elapsed_i, + "remaining-s": self.duration_s - elapsed_i, + "show-state": self._timing(e, self.duration_s), + "current": self._segments[self._current]["id"], + "next-point": self._next_point(), + "segments": segments_out, + "drop": drop, + } + + @staticmethod + def _timing(spent, budget): + if budget <= 0: + return "green" if spent <= 0 else "red" + ratio = spent / budget + if ratio < GREEN_BELOW: + return "green" + if ratio <= 1.0: + return "yellow" + return "red" + + def _next_point(self): + for seg in self._segments: + for idx, p in enumerate(seg["points"]): + if not p["covered"] and not p["skipped"]: + return {"segment": seg["id"], "idx": idx, + "text": p["text"]} + return None + + # ----- outputs ----- + + def tick(self): + """Recompute the replan and timing states; return the rail state.""" + self._recompute() + return copy.deepcopy(self._snapshot) + + @property + def state(self): + """The last computed rail state (deep copy).""" + return copy.deepcopy(self._snapshot) + + def cue_candidates(self): + """Deterministic time cues for the cue engine (see cueing.md). + + Stateless: repeated conditions re-emit the same key every call; + the cue engine dedupes on key. Empty pre-show, on hold, and after + end_show(). + """ + if not self._live or self._hold or self._ended: + return [] + self._recompute() + e = self._elapsed() + remaining = self.duration_s - e + cues = [] + + if e > self.duration_s: + over = int(e - self.duration_s) + m, s = divmod(over, 60) + cues.append({"tier": "attention", + "text": f"{m}:{s:02d} OVER", + "key": f"over:{m}"}) + + if self.wrap_s is not None and remaining <= self.wrap_s: + cues.append({"tier": "attention", "text": "WRAP", + "key": "wrap"}) + + cur = self._segments[self._current] + if cur["state"] == "current": + seg_left = self._last_replanned[cur["id"]] - cur["spent"] + if seg_left <= SEG_CUE_AT_S: + cues.append({"tier": "card", "text": "30 seconds", + "key": f"seg-30:{cur['id']}"}) + + drop = self._snapshot["drop"] + if drop is not None: + cues.append({"tier": "card", "text": drop["text"], + "key": f"drop:{drop['segment']}"}) + + plan_credit = sum(s["planned"] for s in self._segments + if s["state"] == "done") + if cur["state"] == "current": + plan_credit += min(cur["spent"], cur["planned"]) + if e > 0 and plan_credit > STRETCH_AHEAD * e: + cues.append({"tier": "card", "text": "STRETCH", + "key": "stretch"}) + + return cues diff --git a/skills/mc-prompter/scripts/server/rundown.py b/skills/mc-prompter/scripts/server/rundown.py new file mode 100644 index 0000000..5a5be2c --- /dev/null +++ b/skills/mc-prompter/scripts/server/rundown.py @@ -0,0 +1,443 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Rundown parser for mc-prompter (Phase C producer mode). Pure stdlib. + +Usage: + uv run {skill-root}/scripts/server/rundown.py <file> + +Implements the binding format in references/rundown-spec.md. A rundown is a +markdown file with YAML-ish frontmatter (a flat key: value block, no nesting) +followed by "## " segment headings: + + --- + show: "Why local models win" + duration-minutes: 30 + cue-density: normal # hands-off | minimal | normal | chatty + wrap-minutes: 3 + --- + + ## Intro (3 min) + + Full scripted intro text, prompted normally. + + ## Point 1: The cost argument (5 min) + + - cloud bills compound, local is capex + - the 4090 anecdote + +Contract: + parse_rundown(text) -> dict, raising RundownError(line, message) on hard + errors. Result shape: + + {"show": str, "duration-s": int, "cue-density": str|None, + "wrap-s": int|None, "warnings": [str], + "segments": [{"id": "g0", "title": str, + "kind": "scripted"|"bullets", "planned-s": int, + "body": str, + "points": [{"text": str, "covered": false}]}]} + +Frontmatter: + show optional string (default ""), may be quoted + duration-minutes required positive int + cue-density optional, one of hands-off|minimal|normal|chatty; + overrides the config value (most specific wins) + wrap-minutes optional positive int; when present the LAST segment + is the wrap and its budget is protected + Unknown keys produce a warning. Inline "# ..." comments are stripped + outside quoted values. + +Segments: + Split on level-2 "## " headings. Non-blank content before the first + heading is a line-numbered error. Heading time suffix: exactly "(N min)" + or "(Nm)" as the trailing paren group. Any other trailing paren group + that looks like a time (bare number, digits:digits, digits glued to a + unit like "90s", or a time-unit word next to a number) is a + line-numbered error: reject, never guess. Trailing parens that do not + look like a time (e.g. "(demo)", "(part 2)") stay in the title. No + suffix = unbudgeted. + + Kind: a body with any non-bullet prose is "scripted" (the body gets + prompted via script_ingest); a body with only "- " bullets (or nothing) + is "bullets" and each top-level bullet becomes a point. Indented "- " + sub-bullets join their parent point ("; " separated); an indented + bullet with no parent is prose. A continuation line under a bullet + (a hard-wrapped point) is prose too, so it forces scripted. Scripted + segments have an empty points list; when a scripted segment contains + any "- " bullet line a warning notes its points are not tracked. + +Time math (see rundown-spec.md for the full rules): + Unbudgeted segments split the remaining time evenly (largest-remainder + rounding, earliest segments take the spare seconds). If explicit budgets + exceed duration-minutes, duration-minutes wins: non-wrap explicit + budgets are scaled proportionally to fit and a warning is recorded. The + wrap budget is never scaled. Every segment ends up with planned-s; + any segment reconciled to planned-s 0 records a warning. + +Exit codes: 0 ok, 1 parse error (RundownError), 2 file missing/unreadable. +Pure stdlib; importable by server/main.py via `import rundown` from the +scripts/server directory. +""" + +import argparse +import json +import re +import sys +from pathlib import Path + +CUE_DENSITIES = ("hands-off", "minimal", "normal", "chatty") + +HEADING_RE = re.compile(r"^##(?!#)\s+(.*?)\s*$") +TRAILING_PAREN_RE = re.compile(r"^(.*?)\s*\(([^()]*)\)\s*$") +ACCEPT_MIN_RE = re.compile(r"^(\d+) min$") +ACCEPT_M_RE = re.compile(r"^(\d+)m$") +BARE_NUMBER_RE = re.compile(r"^\d+(\.\d+)?$") +CLOCK_RE = re.compile(r"^\d+:\d+$") +GLUED_UNIT_RE = re.compile(r"^\d+(\.\d+)?[a-z]+$") +BULLET_RE = re.compile(r"^\s*-\s+(.*\S)\s*$") +TOP_BULLET_RE = re.compile(r"^-\s+(.*\S)\s*$") +SUB_BULLET_RE = re.compile(r"^\s+-\s+(.*\S)\s*$") +UNIT_WORDS = frozenset(( + "m", "min", "mins", "minute", "minutes", + "s", "sec", "secs", "second", "seconds", + "h", "hr", "hrs", "hour", "hours", +)) + + +class RundownError(Exception): + """Hard parse error with a 1-based source line number.""" + + def __init__(self, line, message): + self.line = line + self.message = message + super().__init__(f"line {line}: {message}") + + +def _strip_comment(value): + """Strip an inline comment from an unquoted frontmatter value.""" + if value.startswith("#"): + return "" + return re.split(r"\s+#", value, maxsplit=1)[0].strip() + + +def _parse_frontmatter(lines): + """Parse the frontmatter block; return (fields, body_start_index). + + fields maps key -> (value_string, line_number). body_start_index is the + 0-based index of the first line after the closing delimiter. Raises + RundownError when the frontmatter is missing or unterminated. + """ + i = 0 + while i < len(lines) and not lines[i].strip(): + i += 1 + if i >= len(lines) or lines[i].strip() != "---": + raise RundownError(i + 1, "missing frontmatter (expected --- block " + "with duration-minutes)") + fields = {} + j = i + 1 + while j < len(lines): + line = lines[j] + if line.strip() == "---": + return fields, j + 1 + if line.strip() and ":" in line: + key, _, raw = line.partition(":") + key = key.strip() + value = raw.strip() + if value.startswith('"') and value.endswith('"') and len(value) >= 2: + value = value[1:-1] + elif value.startswith("'") and value.endswith("'") and len(value) >= 2: + value = value[1:-1] + else: + value = _strip_comment(value) + fields[key] = (value, j + 1) + elif line.strip(): + raise RundownError(j + 1, f"malformed frontmatter line: " + f"{line.strip()!r}") + j += 1 + raise RundownError(i + 1, "unterminated frontmatter (no closing ---)") + + +def _positive_int(value, line, key): + try: + n = int(value) + except ValueError: + raise RundownError(line, f"{key} must be an integer, got {value!r}") + if n <= 0: + raise RundownError(line, f"{key} must be positive, got {n}") + return n + + +def _looks_like_time(content): + """True when a paren group reads as a time expression (reject cases).""" + c = content.strip().casefold() + if not any(ch.isdigit() for ch in c): + return False + if BARE_NUMBER_RE.match(c) or CLOCK_RE.match(c) or GLUED_UNIT_RE.match(c): + return True + tokens = c.split() + return any(t in UNIT_WORDS for t in tokens) + + +def _parse_heading(text, line): + """Split a heading into (title, planned_minutes_or_None). + + Accepts exactly "(N min)" or "(Nm)" as the trailing paren group. Any + other trailing paren group that looks like a time is a hard error. + """ + if not text.strip(): + raise RundownError(line, "segment heading has no title") + m = TRAILING_PAREN_RE.match(text) + if not m: + return text, None + title, content = m.group(1).strip(), m.group(2).strip() + am = ACCEPT_MIN_RE.match(content) or ACCEPT_M_RE.match(content) + if am: + minutes = int(am.group(1)) + if minutes <= 0: + raise RundownError(line, f"segment budget must be positive: " + f"({content})") + if not title: + raise RundownError(line, "segment heading has no title") + return title, minutes + if _looks_like_time(content): + raise RundownError( + line, + f"unrecognized time suffix ({content}): use exactly (N min) " + f"or (Nm)") + return text, None + + +def _segment_kind(body_lines): + """Classify a body: any non-bullet prose is scripted, else bullets. + + Top-level "- " bullets become points. An indented "- " sub-bullet + joins its parent point (appended after "; "); an indented bullet with + no parent is prose. Any other non-blank line (including a + hard-wrapped bullet's continuation line) is prose and makes the + segment scripted with an empty points list. + """ + points = [] + for line in body_lines: + if not line.strip(): + continue + tm = TOP_BULLET_RE.match(line) + if tm: + points.append(tm.group(1).strip()) + continue + sm = SUB_BULLET_RE.match(line) + if sm and points: + points[-1] += "; " + sm.group(1).strip() + continue + return "scripted", [] + return "bullets", points + + +def _apportion(weights, total): + """Split integer total proportionally to weights, exactly. + + Largest-remainder method; ties break to the earliest index. Zero or + empty weight sums fall back to an even split. + """ + n = len(weights) + if n == 0: + return [] + wsum = sum(weights) + if wsum <= 0: + base, rem = divmod(total, n) + return [base + (1 if i < rem else 0) for i in range(n)] + shares = [w * total / wsum for w in weights] + floors = [int(s) for s in shares] + leftover = total - sum(floors) + order = sorted(range(n), key=lambda i: (floors[i] - shares[i], i)) + for i in order[:leftover]: + floors[i] += 1 + return floors + + +def _even_split(total, n): + base, rem = divmod(total, n) + return [base + (1 if i < rem else 0) for i in range(n)] + + +def parse_rundown(text): + """Parse rundown text; return the rundown dict (shape in the docstring). + + Raises RundownError(line, message) on hard errors; recoverable issues + (budget reconciliation, unknown keys) land in result["warnings"]. + """ + lines = text.splitlines() + fields, body_start = _parse_frontmatter(lines) + warnings = [] + + known = {"show", "duration-minutes", "cue-density", "wrap-minutes"} + for key, (_, line) in fields.items(): + if key not in known: + warnings.append(f"line {line}: unknown frontmatter key " + f"{key!r} ignored") + + show = fields.get("show", ("", 0))[0] + + if "duration-minutes" not in fields: + raise RundownError(1, "frontmatter is missing duration-minutes") + dur_value, dur_line = fields["duration-minutes"] + duration_s = _positive_int(dur_value, dur_line, "duration-minutes") * 60 + + cue_density = None + if "cue-density" in fields: + cd_value, cd_line = fields["cue-density"] + if cd_value not in CUE_DENSITIES: + raise RundownError( + cd_line, + f"cue-density must be one of {', '.join(CUE_DENSITIES)}, " + f"got {cd_value!r}") + cue_density = cd_value + + wrap_s = None + if "wrap-minutes" in fields: + w_value, w_line = fields["wrap-minutes"] + wrap_s = _positive_int(w_value, w_line, "wrap-minutes") * 60 + if wrap_s > duration_s: + raise RundownError( + w_line, "wrap-minutes exceeds duration-minutes") + + # Split the body into segments on "## " headings. + segments = [] + current = None + for idx in range(body_start, len(lines)): + line = lines[idx] + hm = HEADING_RE.match(line) + if hm: + title, minutes = _parse_heading(hm.group(1), idx + 1) + current = {"title": title, "minutes": minutes, + "line": idx + 1, "body_lines": []} + segments.append(current) + elif current is None: + if line.strip(): + raise RundownError( + idx + 1, + "content before the first ## segment heading") + else: + current["body_lines"].append(line) + + if not segments: + raise RundownError(body_start + 1, + "rundown has no ## segment headings") + + out = [] + for i, seg in enumerate(segments): + kind, points = _segment_kind(seg["body_lines"]) + if kind == "scripted" and any(BULLET_RE.match(ln) + for ln in seg["body_lines"]): + warnings.append( + f"line {seg['line']}: segment {seg['title']!r} mixes " + f"bullets with prose so it is scripted; its points are " + f"not tracked (a hard-wrapped bullet line counts as " + f"prose)") + body = "\n".join(seg["body_lines"]).strip("\n").rstrip() + out.append({ + "id": f"g{i}", + "title": seg["title"], + "kind": kind, + "planned-s": None, + "body": body, + "points": [{"text": p, "covered": False} for p in points], + "_minutes": seg["minutes"], + "_line": seg["line"], + }) + + # Time math. The wrap segment (the last one, when wrap-minutes is set) + # is budgeted from the frontmatter and protected from scaling. + wrap_seg = out[-1] if wrap_s is not None else None + if wrap_seg is not None: + if (wrap_seg["_minutes"] is not None + and wrap_seg["_minutes"] * 60 != wrap_s): + warnings.append( + f"line {wrap_seg['_line']}: wrap segment suffix " + f"({wrap_seg['_minutes']} min) differs from wrap-minutes; " + f"wrap-minutes wins") + wrap_seg["planned-s"] = wrap_s + + pool = [s for s in out if s is not wrap_seg] + explicit = [s for s in pool if s["_minutes"] is not None] + unbudgeted = [s for s in pool if s["_minutes"] is None] + for s in explicit: + s["planned-s"] = s["_minutes"] * 60 + + budget = duration_s - (wrap_s or 0) + explicit_sum = sum(s["planned-s"] for s in explicit) + + if explicit_sum > budget: + warnings.append( + f"explicit segment budgets total {explicit_sum}s but only " + f"{budget}s is available inside duration-minutes; " + f"duration-minutes wins, explicit budgets scaled " + f"proportionally") + scaled = _apportion([s["planned-s"] for s in explicit], budget) + for s, v in zip(explicit, scaled): + s["planned-s"] = v + if unbudgeted: + names = ", ".join(s["title"] for s in unbudgeted) + warnings.append( + f"no time remains for unbudgeted segments: {names} " + f"(planned 0s)") + for s in unbudgeted: + s["planned-s"] = 0 + elif unbudgeted: + split = _even_split(budget - explicit_sum, len(unbudgeted)) + for s, v in zip(unbudgeted, split): + s["planned-s"] = v + zeroed = [s["title"] for s in unbudgeted if s["planned-s"] == 0] + if zeroed: + warnings.append( + f"the wrap reserve and explicit budgets leave no time " + f"for: {', '.join(zeroed)} (planned 0s)") + elif explicit_sum < budget: + warnings.append( + f"segment budgets total {explicit_sum + (wrap_s or 0)}s, " + f"leaving {budget - explicit_sum}s of the show unallocated") + + for s in out: + del s["_minutes"] + del s["_line"] + + return { + "show": show, + "duration-s": duration_s, + "cue-density": cue_density, + "wrap-s": wrap_s, + "warnings": warnings, + "segments": out, + } + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Parse a rundown.md file and print the plan as JSON") + parser.add_argument("file", help="path to the rundown file") + args = parser.parse_args(argv) + + path = Path(args.file) + if not path.is_file(): + print(f"error: rundown not found: {path}", file=sys.stderr) + return 2 + try: + text = path.read_text(encoding="utf-8-sig") + except (OSError, UnicodeDecodeError) as exc: + print(f"error: cannot read {path}: {exc}", file=sys.stderr) + return 2 + + try: + plan = parse_rundown(text) + except RundownError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + print(json.dumps(plan, indent=2, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + code = main() + if code: + sys.exit(code) diff --git a/skills/mc-prompter/scripts/tests/test-producer.py b/skills/mc-prompter/scripts/tests/test-producer.py new file mode 100644 index 0000000..bdd9be3 --- /dev/null +++ b/skills/mc-prompter/scripts/tests/test-producer.py @@ -0,0 +1,576 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Tests for server/producer.py (mc-prompter Phase C producer mode). + +Run directly: + uv run skills/mc-prompter/scripts/tests/test-producer.py + +Pure stdlib unittest; no network, no models, no downloads. Every scenario +runs against a fake clock injected via now_fn: running long (proportional +replan + DROP candidate), running short (STRETCH), out-of-order coverage, +go-live/hold/resume/end clock math, wrap reserve protection, coverage +stickiness, and cue-candidate dedup keys. +""" + +import sys +import unittest +from pathlib import Path + +TESTS_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(TESTS_DIR.parent / "server")) + +from producer import Producer # noqa: E402 +from rundown import parse_rundown # noqa: E402 + + +class FakeClock: + + def __init__(self, start=1000.0): + self.t = start + + def __call__(self): + return self.t + + def advance(self, seconds): + self.t += seconds + + +def seg(seg_id, title, planned_s, kind="bullets", points=()): + return {"id": seg_id, "title": title, "kind": kind, + "planned-s": planned_s, "body": "", + "points": [{"text": t, "covered": False} for t in points]} + + +def show_1800(): + """30 min show, 3 min protected wrap, budgets fill exactly.""" + return { + "show": "Test show", "duration-s": 1800, "cue-density": None, + "wrap-s": 180, "warnings": [], + "segments": [ + seg("g0", "Intro", 180, kind="scripted"), + seg("g1", "P1", 480, points=("point a", "point b")), + seg("g2", "P2", 480, points=("point c", "point d")), + seg("g3", "P3", 480, points=("point e",)), + seg("g4", "Wrap", 180, kind="scripted"), + ], + } + + +def show_600(): + """10 min show, two equal segments, no wrap.""" + return { + "show": "", "duration-s": 600, "cue-density": None, + "wrap-s": None, "warnings": [], + "segments": [seg("g0", "A", 300), seg("g1", "B", 300)], + } + + +def live(rundown, clock=None, **kwargs): + clock = clock or FakeClock() + p = Producer(rundown, now_fn=clock, **kwargs) + p.go_live() + return p, clock + + +class TestClock(unittest.TestCase): + + def test_pre_show(self): + p = Producer(show_1800(), now_fn=FakeClock()) + state = p.state + self.assertFalse(state["live"]) + self.assertFalse(state["hold"]) + self.assertEqual(state["elapsed-s"], 0) + self.assertEqual(state["remaining-s"], 1800) + self.assertEqual(state["show-state"], "green") + self.assertEqual(state["current"], "g0") + self.assertIsNone(state["drop"]) + + def test_pre_show_clock_does_not_run(self): + clock = FakeClock() + p = Producer(show_1800(), now_fn=clock) + clock.advance(500) + self.assertEqual(p.tick()["elapsed-s"], 0) + + def test_go_live_hold_resume_end(self): + p, clock = live(show_1800()) + clock.advance(100) + state = p.tick() + self.assertTrue(state["live"]) + self.assertEqual(state["elapsed-s"], 100) + p.hold() + clock.advance(50) + state = p.tick() + self.assertTrue(state["hold"]) + self.assertEqual(state["elapsed-s"], 100) + p.resume() + clock.advance(25) + state = p.tick() + self.assertFalse(state["hold"]) + self.assertEqual(state["elapsed-s"], 125) + p.end_show() + clock.advance(500) + state = p.tick() + self.assertFalse(state["live"]) + self.assertEqual(state["elapsed-s"], 125) + + def test_end_show_spent_sums_to_elapsed_from_running(self): + p, clock = live(show_1800()) + clock.advance(100) + p.end_show() + state = p.state + self.assertEqual(state["elapsed-s"], 100) + self.assertEqual(sum(s["spent-s"] for s in state["segments"]), 100) + self.assertEqual(state["segments"][0]["spent-s"], 100) + + def test_end_show_spent_sums_to_elapsed_from_hold(self): + p, clock = live(show_1800()) + clock.advance(100) + p.hold() + clock.advance(50) + p.end_show() + state = p.state + self.assertEqual(state["elapsed-s"], 100) + self.assertEqual(sum(s["spent-s"] for s in state["segments"]), 100) + + def test_end_show_spent_sums_to_elapsed_after_hold_resume(self): + p, clock = live(show_1800()) + clock.advance(100) + p.hold() + clock.advance(50) + p.resume() + clock.advance(25) + p.end_show() + state = p.state + self.assertEqual(state["elapsed-s"], 125) + self.assertEqual(sum(s["spent-s"] for s in state["segments"]), 125) + + def test_non_monotonic_clock_never_double_counts(self): + # now_fn must be non-decreasing; if it ever regresses anyway, + # _synced never rewinds so the recovered span is not re-counted. + p, clock = live(show_1800()) + clock.advance(100) + p.tick() + clock.advance(-10) + p.tick() + clock.advance(10) + state = p.tick() + self.assertEqual(state["elapsed-s"], 100) + self.assertEqual(state["segments"][0]["spent-s"], 100) + + def test_double_go_live_is_noop(self): + p, clock = live(show_1800()) + clock.advance(10) + p.go_live() + clock.advance(10) + self.assertEqual(p.tick()["elapsed-s"], 20) + + def test_hold_does_not_attribute_spent(self): + p, clock = live(show_1800()) + clock.advance(10) + p.hold() + clock.advance(100) + state = p.tick() + self.assertEqual(state["segments"][0]["spent-s"], 10) + + def test_spent_attribution_follows_current(self): + p, clock = live(show_1800()) + clock.advance(60) + p.advance_segment() + clock.advance(30) + state = p.tick() + self.assertEqual(state["segments"][0]["state"], "done") + self.assertEqual(state["segments"][0]["spent-s"], 60) + self.assertEqual(state["segments"][1]["state"], "current") + self.assertEqual(state["segments"][1]["spent-s"], 30) + self.assertEqual(state["current"], "g1") + + +class TestReplan(unittest.TestCase): + + def test_pre_show_replan_equals_plan(self): + p = Producer(show_1800(), now_fn=FakeClock()) + for s in p.state["segments"]: + self.assertEqual(s["replanned-s"], s["planned-s"]) + self.assertEqual(s["timing"], "green") + + def test_running_long_replans_proportionally_with_drop(self): + # Intro done on time, then segment 1 (P1) runs 12 minutes over + # its 8 minute budget. + p, clock = live(show_1800()) + clock.advance(180) + p.advance_segment() + clock.advance(1200) + state = p.tick() + by_id = {s["id"]: s for s in state["segments"]} + # remaining 420, wrap reserve 180, avail 240 across equal + # originals (480 each) -> 80 each + self.assertEqual(by_id["g1"]["replanned-s"], 80) + self.assertEqual(by_id["g2"]["replanned-s"], 80) + self.assertEqual(by_id["g3"]["replanned-s"], 80) + self.assertEqual(by_id["g1"]["timing"], "red") + self.assertEqual(by_id["g2"]["timing"], "green") + # pending floor is max(45, 120) = 120 > 80: DROP the last + # non-wrap pending segment, even split 240 // 2 + self.assertEqual(state["drop"], + {"segment": "g3", "text": "DROP: P3, or 120s each"}) + keys = [c["key"] for c in p.cue_candidates()] + self.assertIn("drop:g3", keys) + + def test_replan_proportional_to_unequal_originals(self): + rundown = { + "show": "", "duration-s": 600, "cue-density": None, + "wrap-s": None, "warnings": [], + "segments": [seg("g0", "A", 100), seg("g1", "B", 200), + seg("g2", "C", 300)], + } + p, clock = live(rundown) + clock.advance(300) + state = p.tick() + self.assertEqual([s["replanned-s"] for s in state["segments"]], + [50, 100, 150]) + self.assertIsNone(state["drop"]) + clock.advance(160) + state = p.tick() + # avail 140: B share 47 < floor 50 -> DROP names C (last pending) + self.assertEqual(state["drop"], + {"segment": "g2", "text": "DROP: C, or 70s each"}) + + def test_wrap_reserve_protected(self): + p, clock = live(show_1800()) + clock.advance(1500) + state = p.tick() + wrap = state["segments"][-1] + self.assertEqual(wrap["replanned-s"], 180) + self.assertEqual(wrap["planned-s"], 180) + + def test_done_segments_report_plan(self): + p, clock = live(show_1800()) + clock.advance(60) + p.advance_segment() + state = p.tick() + intro = state["segments"][0] + self.assertEqual(intro["replanned-s"], 180) + self.assertEqual(intro["spent-s"], 60) + self.assertEqual(intro["timing"], "green") + + def test_segment_timing_thresholds(self): + rundown = { + "show": "", "duration-s": 600, "cue-density": None, + "wrap-s": None, "warnings": [], + "segments": [seg("g0", "A", 600)], + } + p, clock = live(rundown) + clock.advance(100) + self.assertEqual(p.tick()["segments"][0]["timing"], "green") + clock.advance(200) # spent 300 vs replanned 300 + self.assertEqual(p.tick()["segments"][0]["timing"], "yellow") + clock.advance(100) # spent 400 vs replanned 200 + self.assertEqual(p.tick()["segments"][0]["timing"], "red") + clock.advance(200) # spent 600 vs replanned 0 + self.assertEqual(p.tick()["segments"][0]["timing"], "red") + + def test_show_state_thresholds(self): + p, clock = live(show_1800()) + clock.advance(1439) + self.assertEqual(p.tick()["show-state"], "green") + clock.advance(1) # exactly 80 percent + self.assertEqual(p.tick()["show-state"], "yellow") + clock.advance(361) # 1801 of 1800 + state = p.tick() + self.assertEqual(state["show-state"], "red") + self.assertEqual(state["remaining-s"], -1) + + +class TestCoverage(unittest.TestCase): + + def test_next_is_first_uncovered_unskipped_in_order(self): + p, _ = live(show_1800()) + self.assertEqual(p.state["next-point"], + {"segment": "g1", "idx": 0, "text": "point a"}) + + def test_out_of_order_coverage_keeps_next_defined(self): + p, _ = live(show_1800()) + p.mark_covered("g2", 0) + self.assertEqual(p.state["next-point"]["segment"], "g1") + p.mark_covered("g1", 0) + self.assertEqual(p.state["next-point"], + {"segment": "g1", "idx": 1, "text": "point b"}) + p.mark_covered("g1", 1) + self.assertEqual(p.state["next-point"], + {"segment": "g2", "idx": 1, "text": "point d"}) + + def test_next_null_when_everything_covered_or_skipped(self): + p, _ = live(show_1800()) + p.mark_covered("g1", 0) + p.mark_covered("g1", 1) + p.skip_point("g2", 0) + p.mark_covered("g2", 1) + p.mark_covered("g3", 0) + self.assertIsNone(p.state["next-point"]) + + def test_skip_excluded_from_next(self): + p, _ = live(show_1800()) + p.skip_point("g1", 0) + self.assertEqual(p.state["next-point"], + {"segment": "g1", "idx": 1, "text": "point b"}) + + def test_propose_flips_uncovered_only_once(self): + p, _ = live(show_1800()) + self.assertTrue(p.propose_coverage("g1", 0)) + self.assertFalse(p.propose_coverage("g1", 0)) + point = p.state["segments"][1]["points"][0] + self.assertTrue(point["covered"]) + + def test_propose_after_human_skip_does_nothing(self): + p, _ = live(show_1800()) + p.skip_point("g1", 0) + self.assertFalse(p.propose_coverage("g1", 0)) + point = p.state["segments"][1]["points"][0] + self.assertFalse(point["covered"]) + self.assertTrue(point["skipped"]) + + def test_human_can_cover_a_skipped_point(self): + p, _ = live(show_1800()) + p.skip_point("g1", 0) + p.mark_covered("g1", 0) + self.assertTrue(p.state["segments"][1]["points"][0]["covered"]) + + def test_nothing_ever_uncovers(self): + p, _ = live(show_1800()) + p.mark_covered("g1", 0) + p.skip_point("g1", 0) + self.assertTrue(p.state["segments"][1]["points"][0]["covered"]) + + def test_propose_ignores_unknown_ids(self): + p, _ = live(show_1800()) + self.assertFalse(p.propose_coverage("g9", 0)) + self.assertFalse(p.propose_coverage("g1", 99)) + + def test_human_calls_raise_on_unknown_ids(self): + p, _ = live(show_1800()) + with self.assertRaises(ValueError): + p.mark_covered("g9", 0) + with self.assertRaises(ValueError): + p.skip_point("g1", 99) + with self.assertRaises(ValueError): + p.make_current("g9") + + +class TestSegmentControl(unittest.TestCase): + + def test_make_current_forward_marks_old_done(self): + p, _ = live(show_1800()) + p.make_current("g2") + states = {s["id"]: s["state"] for s in p.state["segments"]} + self.assertEqual(states["g0"], "done") + self.assertEqual(states["g1"], "pending") + self.assertEqual(states["g2"], "current") + self.assertEqual(p.state["current"], "g2") + + def test_make_current_backward_reopens(self): + p, _ = live(show_1800()) + p.make_current("g2") + p.make_current("g0") + states = {s["id"]: s["state"] for s in p.state["segments"]} + self.assertEqual(states["g0"], "current") + self.assertEqual(states["g2"], "pending") + + def test_advance_through_the_end(self): + p, _ = live(show_1800()) + for _ in range(5): + p.advance_segment() + state = p.state + self.assertEqual(state["current"], "g4") + self.assertTrue(all(s["state"] == "done" + for s in state["segments"])) + + def test_backward_jump_after_advance_past_end_keeps_last_done(self): + p, _ = live(show_600()) + p.advance_segment() + p.advance_segment() # past the end: pointer rests on done g1 + p.make_current("g0") + states = {s["id"]: s["state"] for s in p.state["segments"]} + self.assertEqual(states["g0"], "current") + self.assertEqual(states["g1"], "done") + self.assertEqual(p.state["current"], "g0") + + def test_done_pointer_does_not_accrue_spent(self): + p, clock = live(show_600()) + clock.advance(60) + p.advance_segment() + clock.advance(60) + p.advance_segment() # past the end at t=120 + clock.advance(300) + state = p.tick() + self.assertEqual(state["elapsed-s"], 420) + self.assertEqual(state["current"], "g1") + self.assertEqual(state["segments"][1]["state"], "done") + self.assertEqual(state["segments"][1]["spent-s"], 60) + self.assertEqual(sum(s["spent-s"] for s in state["segments"]), 120) + + +class TestCueCandidates(unittest.TestCase): + + def test_quiet_pre_show_hold_and_ended(self): + clock = FakeClock() + p = Producer(show_1800(), now_fn=clock) + self.assertEqual(p.cue_candidates(), []) + p.go_live() + clock.advance(1900) + self.assertTrue(p.cue_candidates()) + p.hold() + self.assertEqual(p.cue_candidates(), []) + p.resume() + self.assertTrue(p.cue_candidates()) + p.end_show() + self.assertEqual(p.cue_candidates(), []) + + def test_wrap_cue_at_reserve(self): + p, clock = live(show_1800()) + clock.advance(1619) + self.assertNotIn("wrap", [c["key"] for c in p.cue_candidates()]) + clock.advance(1) + cues = {c["key"]: c for c in p.cue_candidates()} + self.assertIn("wrap", cues) + self.assertEqual(cues["wrap"]["text"], "WRAP") + self.assertEqual(cues["wrap"]["tier"], "attention") + + def test_over_cue_text_and_minute_key(self): + p, clock = live(show_1800()) + clock.advance(1830) + cues = {c["key"]: c for c in p.cue_candidates()} + self.assertEqual(cues["over:0"]["text"], "0:30 OVER") + self.assertEqual(cues["over:0"]["tier"], "attention") + clock.advance(60) + cues = {c["key"]: c for c in p.cue_candidates()} + self.assertNotIn("over:0", cues) + self.assertEqual(cues["over:1"]["text"], "1:30 OVER") + + def test_segment_30s_cue(self): + p, clock = live(show_600()) + clock.advance(179) + self.assertNotIn("seg-30:g0", + [c["key"] for c in p.cue_candidates()]) + clock.advance(1) # replanned share 210, spent 180 -> 30 left + cues = {c["key"]: c for c in p.cue_candidates()} + self.assertIn("seg-30:g0", cues) + self.assertEqual(cues["seg-30:g0"]["text"], "30 seconds") + self.assertEqual(cues["seg-30:g0"]["tier"], "card") + + def test_candidates_are_stateless_with_stable_dedup_keys(self): + p, clock = live(show_600()) + clock.advance(200) + first = p.cue_candidates() + clock.advance(1) + second = p.cue_candidates() + self.assertEqual([c["key"] for c in first], + [c["key"] for c in second]) + self.assertIn("seg-30:g0", [c["key"] for c in first]) + + def test_stretch_when_ahead_of_plan(self): + p, clock = live(show_1800()) + clock.advance(60) + p.advance_segment() # 180s of plan credit in 60s + keys = [c["key"] for c in p.cue_candidates()] + self.assertIn("stretch", keys) + cue = next(c for c in p.cue_candidates() if c["key"] == "stretch") + self.assertEqual(cue["text"], "STRETCH") + self.assertEqual(cue["tier"], "card") + + def test_no_stretch_on_pace(self): + p, clock = live(show_1800()) + clock.advance(60) + self.assertNotIn("stretch", + [c["key"] for c in p.cue_candidates()]) + + def test_candidate_shape(self): + p, clock = live(show_1800()) + clock.advance(1900) + for cue in p.cue_candidates(): + self.assertEqual(set(cue), {"tier", "text", "key"}) + self.assertIn(cue["tier"], ("card", "attention")) + + +class TestRailState(unittest.TestCase): + + TOP_KEYS = {"live", "hold", "elapsed-s", "remaining-s", "show-state", + "current", "next-point", "segments", "drop"} + SEG_KEYS = {"id", "title", "kind", "planned-s", "replanned-s", + "spent-s", "state", "timing", "points"} + POINT_KEYS = {"text", "covered", "skipped"} + + def test_wire_shape_is_verbatim(self): + p, clock = live(show_1800()) + clock.advance(200) + state = p.tick() + self.assertEqual(set(state), self.TOP_KEYS) + for s in state["segments"]: + self.assertEqual(set(s), self.SEG_KEYS) + for point in s["points"]: + self.assertEqual(set(point), self.POINT_KEYS) + self.assertEqual(set(state["next-point"]), + {"segment", "idx", "text"}) + + def test_tick_matches_state_property(self): + p, clock = live(show_1800()) + clock.advance(100) + self.assertEqual(p.tick(), p.state) + + def test_returned_state_is_a_deep_copy(self): + p, _ = live(show_1800()) + state = p.state + state["segments"][1]["points"][0]["covered"] = True + state["live"] = False + fresh = p.state + self.assertFalse(fresh["segments"][1]["points"][0]["covered"]) + self.assertTrue(fresh["live"]) + + +class TestRundownIntegration(unittest.TestCase): + + TEXT = """--- +show: "Integration" +duration-minutes: 10 +cue-density: chatty +wrap-minutes: 2 +--- + +## Intro (2 min) + +Scripted intro. + +## Ideas + +- first idea +- second idea + +## Wrap (2 min) + +Scripted wrap. +""" + + def test_producer_accepts_parse_rundown_output(self): + p, clock = live(parse_rundown(self.TEXT)) + clock.advance(60) + state = p.tick() + self.assertEqual(state["elapsed-s"], 60) + self.assertEqual([s["id"] for s in state["segments"]], + ["g0", "g1", "g2"]) + self.assertEqual(state["next-point"]["text"], "first idea") + self.assertEqual(state["segments"][1]["planned-s"], 360) + + def test_frontmatter_cue_density_overrides_arg(self): + p = Producer(parse_rundown(self.TEXT), cue_density="normal", + now_fn=FakeClock()) + self.assertEqual(p.cue_density, "chatty") + + def test_arg_used_when_frontmatter_silent(self): + p = Producer(show_1800(), cue_density="minimal", + now_fn=FakeClock()) + self.assertEqual(p.cue_density, "minimal") + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/mc-prompter/scripts/tests/test-rundown.py b/skills/mc-prompter/scripts/tests/test-rundown.py new file mode 100644 index 0000000..fe56665 --- /dev/null +++ b/skills/mc-prompter/scripts/tests/test-rundown.py @@ -0,0 +1,390 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Tests for server/rundown.py (mc-prompter Phase C producer mode). + +Run directly: + uv run skills/mc-prompter/scripts/tests/test-rundown.py + +Pure stdlib unittest; no network, no models, no downloads. Covers the +binding format in references/rundown-spec.md: frontmatter, time-suffix +accept/reject with line numbers, budget reconciliation warnings, even +split, kind detection, points, and the CLI. +""" + +import contextlib +import io +import json +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 rundown import RundownError, main, parse_rundown # noqa: E402 + +FULL = """--- +show: "Why local models win" +duration-minutes: 30 +cue-density: normal # hands-off | minimal | normal | chatty +wrap-minutes: 3 +--- + +## Intro (3 min) + +Full scripted intro text, prompted normally. + +## Point 1: The cost argument (5 min) + +- cloud bills compound, local is capex +- the 4090 anecdote + +## Point 2: Latency (19 min) + +- round trips add up +- the demo + +## Wrap (3 min) + +Scripted wrap text. +""" + + +def make(front, body): + return f"---\n{front}\n---\n\n{body}" + + +class TestFrontmatter(unittest.TestCase): + + def test_full_example_fields(self): + plan = parse_rundown(FULL) + self.assertEqual(plan["show"], "Why local models win") + self.assertEqual(plan["duration-s"], 1800) + self.assertEqual(plan["cue-density"], "normal") + self.assertEqual(plan["wrap-s"], 180) + self.assertEqual(plan["warnings"], []) + + def test_show_optional_defaults_empty(self): + plan = parse_rundown(make("duration-minutes: 10", "## A\n")) + self.assertEqual(plan["show"], "") + + def test_cue_density_optional_defaults_null(self): + plan = parse_rundown(make("duration-minutes: 10", "## A\n")) + self.assertIsNone(plan["cue-density"]) + self.assertIsNone(plan["wrap-s"]) + + def test_inline_comment_stripped(self): + plan = parse_rundown(make( + "duration-minutes: 10\ncue-density: chatty # dense", "## A\n")) + self.assertEqual(plan["cue-density"], "chatty") + + def test_missing_frontmatter_is_error(self): + with self.assertRaises(RundownError) as ctx: + parse_rundown("## A (3 min)\n\nText.\n") + self.assertEqual(ctx.exception.line, 1) + + def test_unterminated_frontmatter_is_error(self): + with self.assertRaises(RundownError): + parse_rundown("---\nduration-minutes: 10\n\n## A\n") + + def test_missing_duration_is_error(self): + with self.assertRaises(RundownError) as ctx: + parse_rundown(make('show: "X"', "## A\n")) + self.assertIn("duration-minutes", ctx.exception.message) + + def test_non_integer_duration_is_error(self): + with self.assertRaises(RundownError) as ctx: + parse_rundown(make("duration-minutes: thirty", "## A\n")) + self.assertEqual(ctx.exception.line, 2) + + def test_negative_duration_is_error(self): + with self.assertRaises(RundownError): + parse_rundown(make("duration-minutes: -5", "## A\n")) + + def test_invalid_cue_density_is_error(self): + with self.assertRaises(RundownError) as ctx: + parse_rundown(make( + "duration-minutes: 10\ncue-density: loud", "## A\n")) + self.assertEqual(ctx.exception.line, 3) + self.assertIn("cue-density", ctx.exception.message) + + def test_wrap_exceeding_duration_is_error(self): + with self.assertRaises(RundownError): + parse_rundown(make( + "duration-minutes: 5\nwrap-minutes: 6", "## A\n")) + + def test_unknown_key_warns(self): + plan = parse_rundown(make( + "duration-minutes: 10\nduration_mins: 5", "## A\n")) + self.assertTrue(any("duration_mins" in w for w in plan["warnings"])) + + +class TestTimeSuffix(unittest.TestCase): + + def _one(self, heading, minutes=10): + return parse_rundown(make(f"duration-minutes: {minutes}", + f"{heading}\n")) + + def test_n_min_accepted(self): + plan = self._one("## Intro (3 min)") + self.assertEqual(plan["segments"][0]["planned-s"], 180) + self.assertEqual(plan["segments"][0]["title"], "Intro") + + def test_nm_accepted(self): + plan = self._one("## Intro (10m)") + self.assertEqual(plan["segments"][0]["planned-s"], 600) + + def test_minutes_word_rejected_with_line_number(self): + with self.assertRaises(RundownError) as ctx: + parse_rundown(make("duration-minutes: 10", + "## A (2 min)\n\n## B (3 minutes)\n")) + self.assertEqual(ctx.exception.line, 7) + self.assertIn("(3 minutes)", ctx.exception.message) + + def test_clock_form_rejected(self): + with self.assertRaises(RundownError): + self._one("## Intro (3:00)") + + def test_glued_seconds_rejected(self): + with self.assertRaises(RundownError): + self._one("## Intro (90s)") + + def test_bare_number_rejected(self): + with self.assertRaises(RundownError): + self._one("## Intro (5)") + + def test_zero_budget_rejected(self): + with self.assertRaises(RundownError): + self._one("## Intro (0 min)") + + def test_non_time_parens_stay_in_title(self): + plan = self._one("## The setup (demo)") + self.assertEqual(plan["segments"][0]["title"], "The setup (demo)") + self.assertEqual(plan["segments"][0]["planned-s"], 600) + + def test_digit_in_non_time_parens_allowed(self): + plan = self._one("## Q and A (part 2)") + self.assertEqual(plan["segments"][0]["title"], "Q and A (part 2)") + + +class TestSegments(unittest.TestCase): + + def test_ids_in_order(self): + plan = parse_rundown(FULL) + self.assertEqual([s["id"] for s in plan["segments"]], + ["g0", "g1", "g2", "g3"]) + + def test_content_before_first_heading_is_error(self): + with self.assertRaises(RundownError) as ctx: + parse_rundown(make("duration-minutes: 10", + "stray prose\n\n## A\n")) + self.assertEqual(ctx.exception.line, 5) + + def test_no_segments_is_error(self): + with self.assertRaises(RundownError): + parse_rundown("---\nduration-minutes: 10\n---\n\n") + + def test_deeper_headings_are_body(self): + plan = parse_rundown(make("duration-minutes: 10", + "## A\n\n### sub\n\nText.\n")) + self.assertEqual(len(plan["segments"]), 1) + self.assertEqual(plan["segments"][0]["kind"], "scripted") + + +class TestKindDetection(unittest.TestCase): + + def test_prose_is_scripted(self): + plan = parse_rundown(FULL) + self.assertEqual(plan["segments"][0]["kind"], "scripted") + self.assertEqual(plan["segments"][0]["points"], []) + self.assertIn("Full scripted intro", plan["segments"][0]["body"]) + + def test_bullets_only_is_bullets_with_points(self): + plan = parse_rundown(FULL) + seg = plan["segments"][1] + self.assertEqual(seg["kind"], "bullets") + self.assertEqual( + [p["text"] for p in seg["points"]], + ["cloud bills compound, local is capex", "the 4090 anecdote"]) + self.assertTrue(all(p["covered"] is False for p in seg["points"])) + + def test_empty_body_is_bullets_with_no_points(self): + plan = parse_rundown(make("duration-minutes: 10", "## A\n")) + self.assertEqual(plan["segments"][0]["kind"], "bullets") + self.assertEqual(plan["segments"][0]["points"], []) + + def test_mixed_body_is_scripted(self): + plan = parse_rundown(make( + "duration-minutes: 10", + "## A\n\nIntro sentence.\n\n- a bullet\n")) + self.assertEqual(plan["segments"][0]["kind"], "scripted") + self.assertEqual(plan["segments"][0]["points"], []) + + def test_mixed_body_warns_points_not_tracked(self): + plan = parse_rundown(make( + "duration-minutes: 10", + "## A\n\nIntro sentence.\n\n- a bullet\n")) + self.assertTrue(any("points are not tracked" in w + for w in plan["warnings"])) + self.assertTrue(any("'A'" in w for w in plan["warnings"])) + + def test_star_bullets_are_prose(self): + plan = parse_rundown(make("duration-minutes: 10", + "## A\n\n* not a point\n")) + self.assertEqual(plan["segments"][0]["kind"], "scripted") + # No "- " bullet lines, so no mixed-bullets warning either. + self.assertEqual(plan["warnings"], []) + + def test_wrapped_bullet_forces_scripted_with_warning(self): + plan = parse_rundown(make( + "duration-minutes: 10", + "## A\n\n- a long point that wraps\n onto a second line\n")) + self.assertEqual(plan["segments"][0]["kind"], "scripted") + self.assertEqual(plan["segments"][0]["points"], []) + self.assertTrue(any("points are not tracked" in w + for w in plan["warnings"])) + + def test_nested_sub_bullets_join_their_parent_point(self): + plan = parse_rundown(make( + "duration-minutes: 10", + "## A\n\n- main\n - detail\n- other\n")) + seg = plan["segments"][0] + self.assertEqual(seg["kind"], "bullets") + self.assertEqual([p["text"] for p in seg["points"]], + ["main; detail", "other"]) + self.assertEqual(plan["warnings"], []) + + def test_indented_bullet_without_parent_is_scripted(self): + plan = parse_rundown(make( + "duration-minutes: 10", + "## A\n\n - orphan sub-bullet\n")) + self.assertEqual(plan["segments"][0]["kind"], "scripted") + self.assertEqual(plan["segments"][0]["points"], []) + self.assertTrue(any("points are not tracked" in w + for w in plan["warnings"])) + + +class TestTimeMath(unittest.TestCase): + + def test_even_split_of_unbudgeted(self): + plan = parse_rundown(make( + "duration-minutes: 10\nwrap-minutes: 2", + "## A (3 min)\n\n## B\n\n## C\n\n## Wrap\n")) + planned = {s["title"]: s["planned-s"] for s in plan["segments"]} + self.assertEqual(planned["A"], 180) + self.assertEqual(planned["Wrap"], 120) + # 600 - 120 - 180 = 300 split across B and C + self.assertEqual(planned["B"], 150) + self.assertEqual(planned["C"], 150) + self.assertEqual(plan["warnings"], []) + + def test_even_split_remainder_goes_to_earliest(self): + plan = parse_rundown(make( + "duration-minutes: 1", + "## A\n\n## B\n\n## C\n")) + self.assertEqual([s["planned-s"] for s in plan["segments"]], + [20, 20, 20]) + plan = parse_rundown(make( + "duration-minutes: 1", + "## A\n\n## B\n\n## C\n\n## D\n\n## E\n\n## F\n\n## G\n")) + planned = [s["planned-s"] for s in plan["segments"]] + self.assertEqual(sum(planned), 60) + self.assertEqual(planned, [9, 9, 9, 9, 8, 8, 8]) + + def test_overflow_reconciliation_scales_and_warns(self): + plan = parse_rundown(make( + "duration-minutes: 30", + "## A (20 min)\n\n## B (20 min)\n")) + self.assertTrue(any("duration-minutes wins" in w + for w in plan["warnings"])) + planned = [s["planned-s"] for s in plan["segments"]] + self.assertEqual(sum(planned), 1800) + self.assertEqual(planned, [900, 900]) + + def test_overflow_scaling_is_proportional(self): + plan = parse_rundown(make( + "duration-minutes: 30", + "## A (30 min)\n\n## B (10 min)\n")) + planned = [s["planned-s"] for s in plan["segments"]] + self.assertEqual(sum(planned), 1800) + self.assertEqual(planned, [1350, 450]) + + def test_wrap_protected_from_scaling(self): + plan = parse_rundown(make( + "duration-minutes: 20\nwrap-minutes: 4", + "## A (20 min)\n\n## B (12 min)\n\n## Wrap\n")) + planned = {s["title"]: s["planned-s"] for s in plan["segments"]} + self.assertEqual(planned["Wrap"], 240) + self.assertEqual(planned["A"] + planned["B"], 960) + self.assertEqual(planned["A"], 600) + self.assertEqual(planned["B"], 360) + + def test_wrap_suffix_mismatch_warns_and_wrap_minutes_wins(self): + plan = parse_rundown(make( + "duration-minutes: 10\nwrap-minutes: 2", + "## A\n\n## Wrap (3 min)\n")) + self.assertEqual(plan["segments"][-1]["planned-s"], 120) + self.assertTrue(any("wrap-minutes wins" in w + for w in plan["warnings"])) + + def test_slack_warns_when_all_budgeted(self): + plan = parse_rundown(make( + "duration-minutes: 30", + "## A (10 min)\n\n## B (10 min)\n")) + self.assertTrue(any("unallocated" in w for w in plan["warnings"])) + self.assertEqual([s["planned-s"] for s in plan["segments"]], + [600, 600]) + + def test_wrap_equal_to_duration_loads_with_zero_plan_warning(self): + plan = parse_rundown(make( + "duration-minutes: 5\nwrap-minutes: 5", + "## A\n\n- a point\n\n## Wrap\n")) + planned = [s["planned-s"] for s in plan["segments"]] + self.assertEqual(planned, [0, 300]) + self.assertTrue(any("planned 0s" in w and "A" in w + for w in plan["warnings"])) + + def test_every_segment_gets_planned_s(self): + plan = parse_rundown(FULL) + for seg in plan["segments"]: + self.assertIsInstance(seg["planned-s"], int) + self.assertEqual(sum(s["planned-s"] for s in plan["segments"]), 1800) + + +class TestCli(unittest.TestCase): + + def test_prints_json_for_valid_file(self): + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "rundown.md" + path.write_text(FULL, encoding="utf-8") + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = main([str(path)]) + self.assertEqual(code, 0) + plan = json.loads(out.getvalue()) + self.assertEqual(plan["duration-s"], 1800) + self.assertEqual(len(plan["segments"]), 4) + + def test_parse_error_exits_1_with_line_number(self): + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "bad.md" + path.write_text(make("duration-minutes: 10", + "## A (5 minutes)\n"), encoding="utf-8") + err = io.StringIO() + with contextlib.redirect_stderr(err): + code = main([str(path)]) + self.assertEqual(code, 1) + self.assertIn("line 5", err.getvalue()) + + def test_missing_file_exits_2(self): + err = io.StringIO() + with contextlib.redirect_stderr(err): + code = main(["/nonexistent/rundown.md"]) + self.assertEqual(code, 2) + + +if __name__ == "__main__": + unittest.main() From 11a6b43408f80ed41270b1278547a53db35448a3 Mon Sep 17 00:00:00 2001 From: Brian Madison <bmadcode@gmail.com> Date: Fri, 10 Jul 2026 01:14:10 -0500 Subject: [PATCH 2/5] Add producer loop, cue engine, and Ollama tick with evidence-gated coverage --- skills/mc-prompter/scripts/run_prompter.py | 126 +- skills/mc-prompter/scripts/server/llm.py | 270 ++++ skills/mc-prompter/scripts/server/main.py | 1118 ++++++++++++- skills/mc-prompter/scripts/tests/test-llm.py | 319 ++++ .../scripts/tests/test-run_prompter.py | 98 ++ .../mc-prompter/scripts/tests/test-server.py | 1440 +++++++++++++++++ 6 files changed, 3336 insertions(+), 35 deletions(-) create mode 100644 skills/mc-prompter/scripts/server/llm.py create mode 100644 skills/mc-prompter/scripts/tests/test-llm.py diff --git a/skills/mc-prompter/scripts/run_prompter.py b/skills/mc-prompter/scripts/run_prompter.py index 01dd047..6c03f91 100644 --- a/skills/mc-prompter/scripts/run_prompter.py +++ b/skills/mc-prompter/scripts/run_prompter.py @@ -12,6 +12,9 @@ uv run {skill-root}/scripts/run_prompter.py --script <abs path> [--port 8770] [--lan] [--owner-wpm 150] [--no-open] [--workspace <abs path>] [--asr-provider nemotron-streaming|none] + [--rundown <abs path>] [--llm-provider none|ollama] + [--llm-endpoint <url>] [--llm-model <tag>] + [--cue-density hands-off|minimal|normal|chatty] Behavior: port default 8770. If busy, /health on 127.0.0.1 is queried (1 s @@ -43,10 +46,20 @@ 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, 3 planned asr provider -selected (zipformer-small; fails fast, nothing is spawned), 4 script path -missing or unreadable, 5 port conflict with an explicit --port. + rundown --rundown enables producer mode (Phase C): the file's + existence is checked here (exit 4) and the path is passed + through; the server parses it. --rundown is an alternative + to --script (either may be given; with both, the server + prompts the rundown's scripted segments, and a bullets-only + rundown keeps the script on the scroll). The llm and + cue-density flags are pure pass-throughs; --llm-provider + accepts only none|ollama and anything else fails fast with + exit 3 (planned lane, nothing is spawned). + +Exit codes: 0 ok, 1 server failed to start, 2 usage (including neither +--script nor --rundown), 3 planned asr or llm provider selected (fails +fast, nothing is spawned), 4 script or rundown path missing or unreadable, +5 port conflict with an explicit --port. """ import argparse @@ -81,6 +94,14 @@ # owns the message, but selecting one fails fast with exit 3 before any # spawn: nothing unvalidated pretends to run. ASR_PLANNED_PROVIDERS = ("zipformer-small",) +# LLM lane (Phase C producer mode): only ollama is implemented; any other +# non-none value is treated as a planned lane and fails fast with exit 3 +# (validated here, not by argparse, so the message owns the exit code). +LLM_PROVIDERS = ("none", "ollama") +DEFAULT_LLM_ENDPOINT = "http://localhost:11434" +DEFAULT_LLM_MODEL = "qwen3:4b" +CUE_DENSITIES = ("hands-off", "minimal", "normal", "chatty") +DEFAULT_CUE_DENSITY = "normal" # Lightweight readiness floors, mirroring ensure_workspace.py's layout # check (presence and size only; no subprocess, no imports). WORKSPACE_MODEL_MIN_SIZES = { @@ -260,13 +281,19 @@ def voice_follow_line(workspace, asr_provider): def build_server_cmd(port, host, script, owner_wpm, session_file, token, - workspace=None, asr_provider="none"): + workspace=None, asr_provider="none", rundown=None, + llm_provider="none", + llm_endpoint=DEFAULT_LLM_ENDPOINT, + llm_model=DEFAULT_LLM_MODEL, + cue_density=DEFAULT_CUE_DENSITY): """The child process command; runs with cwd = the scripts directory. With a workspace, the command is the workspace venv interpreter running `-m server.main` (same cwd, so the server package resolves identically on both paths) plus --models-dir and --asr-provider. Without one, it is - the uv-run spawn with --asr-provider none. + the uv-run spawn with --asr-provider none. The Phase C producer flags + (--rundown only when given; the llm and cue-density flags always) are + pure pass-throughs appended on both paths. """ server_args = [ "--port", str(port), @@ -276,6 +303,14 @@ def build_server_cmd(port, host, script, owner_wpm, session_file, token, "--session-file", str(session_file), "--token", token, ] + if rundown: + server_args += ["--rundown", str(rundown)] + server_args += [ + "--llm-provider", llm_provider, + "--llm-endpoint", llm_endpoint, + "--llm-model", llm_model, + "--cue-density", cue_density, + ] if workspace is not None: workspace = Path(workspace) return [ @@ -363,8 +398,22 @@ def wait_for_health(port, child, timeout=STARTUP_TIMEOUT): 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("--script", default=None, + help="path to the script file to prompt " + "(this, --rundown, or both)") + parser.add_argument("--rundown", default=None, + help="rundown file path; enables producer mode " + "(alternative to --script)") + parser.add_argument("--llm-provider", default="none", + help="none | ollama for the producer's LLM tick " + "(anything else is a planned lane, exit 3)") + parser.add_argument("--llm-endpoint", default=DEFAULT_LLM_ENDPOINT, + help="Ollama endpoint (producer mode)") + parser.add_argument("--llm-model", default=DEFAULT_LLM_MODEL, + help="Ollama model tag (producer mode)") + parser.add_argument("--cue-density", default=DEFAULT_CUE_DENSITY, + choices=CUE_DENSITIES, + help="cue budget (rundown frontmatter overrides)") parser.add_argument("--port", type=int, default=None, help=f"port (default {DEFAULT_PORT}, " "auto-increments when busy)") @@ -393,16 +442,42 @@ def main(argv=None): file=sys.stderr, ) return 3 + if args.llm_provider not in LLM_PROVIDERS: + print( + f"error: llm-provider {args.llm_provider!r} is a planned lane " + "and is not implemented yet; use ollama or none", + file=sys.stderr, + ) + return 3 - 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 + if not args.script and not args.rundown: + print( + "error: give --script, --rundown, or both", + file=sys.stderr, + ) + return 2 + script = None + if args.script: + 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 + rundown = None + if args.rundown: + rundown = Path(args.rundown).expanduser().resolve() + if not rundown.is_file(): + print(f"error: rundown not found: {rundown}", file=sys.stderr) + return 4 + try: + rundown.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + print(f"error: cannot read {rundown}: {exc}", file=sys.stderr) + return 4 explicit = args.port is not None requested = args.port if explicit else DEFAULT_PORT @@ -434,9 +509,13 @@ def main(argv=None): 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, + cmd = build_server_cmd(port, host, script or "", args.owner_wpm, session_file, token, - workspace=workspace, asr_provider=asr_provider) + workspace=workspace, asr_provider=asr_provider, + rundown=rundown, llm_provider=args.llm_provider, + llm_endpoint=args.llm_endpoint, + llm_model=args.llm_model, + cue_density=args.cue_density) child = spawn_server(cmd) try: @@ -446,12 +525,19 @@ def main(argv=None): terminate_server(child) return 1 - write_session_file(session_file, port, child.pid, token, script) + write_session_file(session_file, port, child.pid, token, + script or "") 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(voice_follow_line(workspace, asr_provider)) + if rundown is not None: + llm_note = ( + f"llm {args.llm_model}" if args.llm_provider == "ollama" + else "llm off, deterministic rail only" + ) + print(f" producer: on ({rundown.name}, {llm_note})") print(f" home: {local_url}") print(f" prompt: {base_url}/prompt") print(f" remote: http://127.0.0.1:{port}/remote?token={token}") diff --git a/skills/mc-prompter/scripts/server/llm.py b/skills/mc-prompter/scripts/server/llm.py new file mode 100644 index 0000000..40b1b54 --- /dev/null +++ b/skills/mc-prompter/scripts/server/llm.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Ollama client for the mc-prompter producer's LLM tick (Phase C). + +Pure stdlib (urllib in a worker thread behind asyncio.to_thread), so the +module works identically under both server launch paths (uv run with only +aiohttp, or the prompter-lab workspace venv). Imported lazily by +server/main.py only when --llm-provider ollama is selected; nothing here +runs in tier 1 or tier 2. + +Contract: + OllamaClient(endpoint, model, timeout_s=10.0) + await client.tick(state_block, status_block, transcript_tail) + -> dict | None + + tick() POSTs {endpoint}/api/chat with: + stream false (one complete JSON response) + think false (no reasoning tokens; the tick must stay cheap) + options {"temperature": 0, "num_predict": 220} + keep_alive "30m" (the model stays resident between ticks) + format RESULT_SCHEMA below (Ollama structured outputs) + messages stable prefix FIRST: one system message carrying the + static producer persona followed by the rundown with + per-point covered state (state_block, which changes + only when coverage flips). EVERYTHING volatile rides + in the single LAST user message: the clock/replan + numbers (status_block, changes every tick) and then + the rolling transcript tail. The split is the whole + point: one volatile byte in the system message would + invalidate Ollama's prefix cache and force a full + re-prefill of the rundown block on every tick. + + The whole request runs under a hard timeout: asyncio.wait_for bounds + the awaited tick, the socket-level urllib timeout bounds each socket + operation, and _post_chat additionally enforces an END-TO-END deadline + across chunked reads, so even a slow-drip peer (each byte resetting + the per-operation timeout) cannot pin the worker thread much past + timeout_s; shutdown therefore never waits long on an in-flight tick. + ANY failure (timeout, connection refused, HTTP error, malformed + envelope, non-JSON content, content that is not an object) returns + None: a failed tick is DROPPED, never queued or retried. The caller + schedules the next tick adaptively. + +Result shape (the schema below, enforced by Ollama's format field): + {"coverage": [{"segment": "g1", "point": 0, "confidence": 0.9}, ...], + "current-topic": "...", (optional) + "cue": {"text": "...", "reason": "..."} | null, (optional) + "pace-note": "..."} (optional) +A returned dict always has a list under "coverage" (coerced to [] when the +model omits or mistypes it); everything else is passed through untouched +for the caller to validate per field. + +Manual test against a live Ollama (never in CI): + uv run llm.py --endpoint http://localhost:11434 --model qwen3:4b +prints one tick's parsed result for a canned state block. +""" + +import asyncio +import json +import sys +import time +import urllib.error +import urllib.request + +DEFAULT_TIMEOUT_S = 10.0 +KEEP_ALIVE = "30m" +NUM_PREDICT = 220 + +# Ollama structured-output schema (the wire "format" field). Binding shape +# from the Phase C contract; coverage is the only required key. +RESULT_SCHEMA = { + "type": "object", + "properties": { + "coverage": { + "type": "array", + "items": { + "type": "object", + "properties": { + "segment": {"type": "string"}, + "point": {"type": "integer"}, + "confidence": {"type": "number"}, + "evidence": {"type": "string"}, + }, + "required": ["segment", "point", "confidence", "evidence"], + }, + }, + "current-topic": {"type": "string"}, + "cue": { + "type": ["object", "null"], + "properties": { + "text": {"type": "string"}, + "reason": {"type": "string"}, + }, + }, + "pace-note": {"type": "string"}, + }, + "required": ["coverage"], +} + +# The producer persona and rules: concise, rule-based, and STATIC, so the +# system message's prefix is byte-identical across ticks (prefix caching). +SYSTEM_PERSONA = ( + "You are a silent broadcast producer watching a live show. " + "You receive the rundown with per-point covered state; each user " + "message brings the live clock and replan numbers, then the latest " + "transcript tail. Rules: judge which UNCOVERED " + "points the transcript tail's content actually covers; report each as " + "its segment id, point index, a confidence between 0 and 1, and " + "evidence: a short VERBATIM quote from the transcript tail (the exact " + "words the speaker said that cover the point). A claim without a real " + "quote will be discarded. " + "A point counts as covered ONLY when the speaker explicitly said the " + "specific thing the point names; being near the topic, or the show " + "merely heading that way, is NOT coverage and must be reported with " + "confidence 0.3 or lower. If the point names a concrete item (an " + "anecdote, a demo, a number, a name) that the transcript never " + "mentions, it is not covered. When unsure, use a low confidence: a " + "missed point costs one reminder card, a wrong covered mark silences " + "the reminder forever. Only report points that appear in the rundown, " + "never invent points; optionally suggest at most ONE short cue in " + "broadcast vocabulary (NEXT, WRAP, STRETCH, time remaining) with a " + "one-line reason, or null when nothing is needed; keep pace-note to " + "one short sentence." +) + + +class OllamaClient: + """One Ollama /api/chat structured-output client for the producer tick.""" + + def __init__(self, endpoint, model, timeout_s=DEFAULT_TIMEOUT_S): + self.endpoint = str(endpoint).rstrip("/") + self.model = model + self.timeout_s = timeout_s + + def request_payload(self, state_block, status_block, transcript_tail): + """The exact /api/chat body for one tick (also the test surface). + + Stable prefix first: the system message opens with the static + persona and carries the rundown/coverage block (state_block, which + changes only on coverage flips). All volatile content, the + clock/replan numbers (status_block) and the rolling transcript + tail, sits together in the final user message so Ollama's prefix + cache keeps the system message hot across ticks. + """ + return { + "model": self.model, + "stream": False, + "think": False, + "keep_alive": KEEP_ALIVE, + "options": {"temperature": 0, "num_predict": NUM_PREDICT}, + "format": RESULT_SCHEMA, + "messages": [ + { + "role": "system", + "content": ( + SYSTEM_PERSONA + + "\n\nRUNDOWN AND COVERAGE:\n" + + state_block + ), + }, + { + "role": "user", + "content": ( + "SHOW CLOCK AND REPLAN:\n" + + status_block + + "\n\nTRANSCRIPT TAIL:\n" + + transcript_tail + ), + }, + ], + } + + def _post_chat(self, payload): + """Blocking POST; returns the message content string. Worker thread. + + The urllib timeout bounds each SOCKET OPERATION, not the request: + a peer dripping one byte per operation would keep resetting it + forever, and this thread is not cancellable, so it would gate + process exit. Reading in chunks with an end-to-end deadline check + between reads bounds the whole call to roughly timeout_s plus one + socket operation, whatever the peer does. + """ + deadline = time.monotonic() + self.timeout_s + request = urllib.request.Request( + self.endpoint + "/api/chat", + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=self.timeout_s) as resp: + chunks = [] + while True: + if time.monotonic() > deadline: + raise TimeoutError("llm tick deadline exceeded") + # read1, not read: read(n) buffers until n bytes arrive, + # which would let a dripping peer pin the loop on one call + chunk = resp.read1(65536) + if not chunk: + break + chunks.append(chunk) + envelope = json.loads(b"".join(chunks).decode("utf-8")) + return envelope["message"]["content"] + + async def tick(self, state_block, status_block, transcript_tail): + """One producer tick; the parsed result dict, or None on ANY failure. + + The urllib call runs in a worker thread; asyncio.wait_for enforces + the hard deadline even if the socket stalls between reads (and + _post_chat's own deadline bounds the thread itself). A tick that + fails or times out is dropped (None), never queued. + """ + payload = self.request_payload( + state_block, status_block, transcript_tail + ) + try: + content = await asyncio.wait_for( + asyncio.to_thread(self._post_chat, payload), + timeout=self.timeout_s, + ) + parsed = json.loads(content) + except asyncio.CancelledError: + raise + except Exception: + # timeout, refused connection, HTTP error, malformed envelope, + # non-JSON content: all one outcome by contract + return None + if not isinstance(parsed, dict): + return None + if not isinstance(parsed.get("coverage"), list): + parsed["coverage"] = [] + return parsed + + +def main(argv=None): + """Manual one-tick smoke test against a live Ollama (not run in CI).""" + import argparse + + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--endpoint", default="http://localhost:11434") + parser.add_argument("--model", default="qwen3:4b") + parser.add_argument("--timeout", type=float, default=30.0) + args = parser.parse_args(argv) + + state_block = ( + "SHOW: smoke test\n" + "SEGMENT g0 'Intro' kind scripted planned 60s\n" + "SEGMENT g1 'Points' kind bullets planned 240s\n" + " point 0 [uncovered]: local models cost nothing per token\n" + " point 1 [uncovered]: latency is lower on device\n" + ) + status_block = ( + "CLOCK: elapsed 60s | remaining 240s | show-state green | LIVE\n" + "SEGMENT g0: done | replanned 60s spent 60s timing yellow\n" + "SEGMENT g1: current | replanned 240s spent 0s timing green" + ) + tail = "so the thing about local models is you never pay per token" + client = OllamaClient(args.endpoint, args.model, timeout_s=args.timeout) + result = asyncio.run(client.tick(state_block, status_block, tail)) + if result is None: + print("tick failed (is Ollama serving and the model pulled?)", + file=sys.stderr) + return 1 + print(json.dumps(result, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/mc-prompter/scripts/server/main.py b/skills/mc-prompter/scripts/server/main.py index ce20568..3a9ae9c 100644 --- a/skills/mc-prompter/scripts/server/main.py +++ b/skills/mc-prompter/scripts/server/main.py @@ -3,7 +3,7 @@ # requires-python = ">=3.11" # dependencies = ["aiohttp==3.12.15"] # /// -"""mc-prompter aiohttp server (Phase A classic teleprompter + Phase B voice-follow). +"""mc-prompter aiohttp server (Phase A prompter + Phase B voice-follow + Phase C producer). 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 @@ -15,6 +15,9 @@ --script <path or empty> --owner-wpm <int or 0> --session-file <path or empty> --token <hex> [--models-dir <dir or empty>] [--asr-provider none|nemotron-streaming] + [--rundown <path or empty>] [--llm-provider none|ollama] + [--llm-endpoint <url>] [--llm-model <tag>] + [--cue-density hands-off|minimal|normal|chatty] ASR (Phase B): when --models-dir is set AND --asr-provider is an implemented provider (nemotron-streaming), server/asr.py is imported lazily and an @@ -61,6 +64,75 @@ Both POST endpoints refuse non-loopback callers outright (403), so a LAN device can never read or write arbitrary files. +Producer mode (Phase C): --rundown loads a rundown file (parsed lazily via +server/rundown.py). --rundown is an alternative to --script and wins when +both are given: producer state comes from the rundown and the promptable +document is the concatenation of the rundown's SCRIPTED segment bodies +(each under its segment heading), ingested through script_ingest; bullets +segments contribute no words. A bullets-only rundown contributes no doc +at all, so with --script the script text stays on the scroll (identical +to loading the same rundown at runtime over a loaded script). Each scripted segment maps to a global +word-index range so the UI can place the rail against the scroll: + GET /api/rundown {"rundown": <parse_rundown result or null>, + "segments": [{"id", "word-start", "word-end"}]} + (same read auth as /api/state) +/api/state gains "producer": {"active": bool, "live": bool, +"llm": {"provider": str, "model": str, "ok": bool}} where ok reports the +last LLM tick outcome (false until one succeeds). + +The producer loop (asyncio task, only when a rundown is loaded) calls +producer.tick() every PRODUCER_TICK_S (1 s) and broadcasts +{"type": "producer", "state": <rail state>} ONLY when the state changed. +The cue engine merges the producer's deterministic cue candidates with the +optional LLM-proposed cue and enforces: one active cue, the per-density +card budget (hands-off: attention only; minimal: 1 card/5 min; normal: +1/2 min; chatty: 1/45 s; attention exempt), release at a VAD pause (from +the Phase B vad events; immediate release when no ASR runs), 15 s +auto-expiry, and dedup by candidate key. Wire frames: + {"type": "cue", "id": n, "tier": "card"|"attention", "text": str} + {"type": "cue-clear", "id": n} +Keyword first-pass coverage runs deterministically on every tick WHILE THE +SHOW IS LIVE and not on hold: the last 90 s of FINAL transcript text +(bounded buffer) is compared against each uncovered point's informative +words; at >= 60 percent overlap the point is proposed covered. Off-air +speech can never cover a point: when a producer is active the transcript +buffer itself only collects finals while live and not held (pre-show mic +checks, hold banter, and post-show chat never enter it), and the buffer is +cleared whenever the producer stack is (re)built so speech captured before +a runtime rundown load cannot leak in either. The adaptive LLM tick +(next = max(15 s, 3 * last wall time), skipped while the ASR engine +reports behind, before go-live, while the show is held, and after end) +refines it: coverage proposals at confidence >= 0.7 go through +producer.propose_coverage (uncovered -> covered only, human stays +authoritative). + +Voice-follow and the producer rail cooperate on segment handoffs: while +the producer's CURRENT segment is a bullets segment the aligner is fed +NOTHING (bullets contribute no words to the doc, so any anchor motion +during them would be creep into the next scripted segment; ASR broadcasts +and the transcript buffer keep running, the anchor simply holds). When a +point make-current lands on a SCRIPTED segment, the server re-anchors the +aligner to that segment's word-start minus 1 (clamped to -1) and +broadcasts the fresh anchor frame, so creep into future segments is +impossible and recovery after a bullets segment or a backwards jump is +deterministic. make-current is the only segment-transition command on the +wire (the UI's anchor-driven handoff sends it too), so this one hook +covers every transition. + +POST /api/rundown/load {"path": ...} adopts a rundown at runtime +(loopback only). While the show is LIVE (and not ended) the load is +refused with 409 so a mid-show reload can never wipe the clock and the +coverage judgments; {"force": true} overrides. Every (re)build resets +llm-ok and clears any active cue from the previous engine. + +Phase C WS extensions (from ANY authenticated client; the remote is the +primary user): + {"type": "show", "cmd": "go-live"|"hold"|"resume"|"end"} + {"type": "point", "cmd": "covered"|"skip"|"make-current", + "segment": "<seg id>", "point": <idx, optional for make-current>} +Both answer with an error frame when producer mode is not active; the +resulting rail state is re-broadcast immediately. + Page routes answer 503 text/plain when the static HTML is not built yet, so the API surface is testable independently of the UI. @@ -115,20 +187,23 @@ {"type": "asr-status", "ready": bool, "behind": bool, "queue": int} on ready/behind changes -Exit codes: 0 ok, 2 usage, 3 planned asr provider selected, -4 script path or models dir missing or unreadable. +Exit codes: 0 ok, 2 usage, 3 planned asr or llm provider selected, +4 script path, rundown path, or models dir missing/unreadable/unparseable. """ import argparse import asyncio +import collections import contextlib import datetime import hmac import json import os +import re import shutil import sys import tempfile +import time from pathlib import Path from aiohttp import WSMsgType, web @@ -152,12 +227,97 @@ LOOPBACK_PEERS = ("127.0.0.1", "::1", "localhost") ASR_PROVIDERS = ("none", "nemotron-streaming") ASR_PLANNED_PROVIDERS = ("zipformer-small",) +LLM_PROVIDERS = ("none", "ollama") +DEFAULT_LLM_ENDPOINT = "http://localhost:11434" +DEFAULT_LLM_MODEL = "qwen3:4b" +CUE_DENSITIES = ("hands-off", "minimal", "normal", "chatty") +DEFAULT_CUE_DENSITY = "normal" +# Card-tier budget per density: minimum seconds between cards. None means +# no card is ever shown (hands-off is attention-tier only). The attention +# tier is exempt from the budget by contract. +CUE_DENSITY_BUDGET_S = { + "hands-off": None, + "minimal": 300.0, + "normal": 120.0, + "chatty": 45.0, +} +CUE_EXPIRY_S = 15.0 +PRODUCER_TICK_S = 1.0 +LLM_MIN_INTERVAL_S = 15.0 +LLM_INTERVAL_MULT = 3.0 +LLM_CONFIDENCE_FLOOR = 0.7 +# Shutdown grace for a cancelled task stuck on the uninterruptible urllib +# worker; past it the task is abandoned (see stop_producer). +LLM_STOP_GRACE_S = 2.0 +TRANSCRIPT_WINDOW_S = 90.0 +KEYWORD_OVERLAP_FLOOR = 0.6 +# Small function-word set for the keyword first-pass; anything not listed +# and at least 3 characters long counts as informative. +KEYWORD_STOPWORDS = frozenset( + "the a an and or but of to in on for with is are was were be been being " + "this that these those it its as at by from we you i he she they them " + "our your my his her their not no yes so if then than there here what " + "when where which who how why do does did done can could will would " + "should about into over under again more most some any all just very " + "have has had get got one two also because".split() +) +# Keyword-pass text normalization, mirroring align.normalize_word so point +# text and ASR transcript text tokenize the same way (house rules allow +# duplicating the small tables instead of importing align, which needs +# numpy and must never load in tier 1). The apostrophe family collapses +# possessives regardless of the editor's quote style; digit runs expand to +# their spoken words so "4090" in a point matches the ASR's "forty ninety". +KEYWORD_APOSTROPHES = "'’ʼ‘‛" +_NUM_ONES = ("zero one two three four five six seven eight nine ten eleven " + "twelve thirteen fourteen fifteen sixteen seventeen eighteen " + "nineteen").split() +_NUM_TENS = ("", "", "twenty", "thirty", "forty", "fifty", "sixty", + "seventy", "eighty", "ninety") + + +def _expand_digits(digits): + """Digit string -> spoken words (align.py's _expand_number, duplicated). + + 0-19 and tens from the tables; 100-999 as "N hundred [rest]"; 1000-9999 + read as digit pairs the way years are spoken ("2026" -> "twenty twenty + six") with the round/oh special cases; 10000-999999 as "N thousand + [rest]"; anything larger digit by digit. + """ + n = int(digits) + if n < 20: + return [_NUM_ONES[n]] + if n < 100: + tens, ones = divmod(n, 10) + return [_NUM_TENS[tens]] + ([_NUM_ONES[ones]] if ones else []) + if n < 1000: + hundreds, rest = divmod(n, 100) + return ([_NUM_ONES[hundreds], "hundred"] + + (_expand_digits(str(rest)) if rest else [])) + if n < 10000: + hi, lo = divmod(n, 100) + if lo == 0 and hi % 10 == 0: + return [_NUM_ONES[hi // 10], "thousand"] + if lo == 0: + return _expand_digits(str(hi)) + ["hundred"] + if hi % 10 == 0 and lo < 10: + return [_NUM_ONES[hi // 10], "thousand"] + _expand_digits(str(lo)) + if lo < 10: + return _expand_digits(str(hi)) + ["oh"] + _expand_digits(str(lo)) + return _expand_digits(str(hi)) + _expand_digits(str(lo)) + if n < 1_000_000: + thousands, rest = divmod(n, 1000) + return (_expand_digits(str(thousands)) + ["thousand"] + + (_expand_digits(str(rest)) if rest else [])) + return [_NUM_ONES[int(d)] for d in digits] # Injectable seams for tests (monkeypatchable module globals). When left as -# None the defaults below lazily import server.asr / server.align, so tier 1 -# and the test suite never touch sherpa-onnx or numpy. +# None the defaults below lazily import server.asr / server.align / +# server.producer / server.llm, so tier 1 and the test suite never touch +# sherpa-onnx or numpy, and producer tests never need a live Ollama. ENGINE_FACTORY = None # (models_dir: Path, provider: str, on_event) -> engine ALIGNER_FACTORY = None # (doc: dict) -> aligner +PRODUCER_FACTORY = None # (rundown: dict, cue_density: str) -> producer +LLM_FACTORY = None # (endpoint: str, model: str) -> client with async tick() def _default_engine_factory(models_dir, provider, on_event): @@ -183,6 +343,31 @@ def _default_aligner_factory(doc): ) +def _import_rundown(): + """Import server/rundown.py (pure stdlib) on the rundown-loaded path only.""" + try: + from server import rundown + except ImportError: # direct `uv run main.py` from the server directory + import rundown + return rundown + + +def _default_producer_factory(rundown, cue_density): + try: + from server import producer + except ImportError: + import producer + return producer.Producer(rundown, cue_density=cue_density) + + +def _default_llm_factory(endpoint, model): + try: + from server import llm + except ImportError: + import llm + return llm.OllamaClient(endpoint=endpoint, model=model) + + def now_iso(): return datetime.datetime.now().isoformat(timespec="seconds") @@ -223,6 +408,229 @@ def save_script(path: Path, raw: str): return backup +def build_prompt_doc(rundown): + """The promptable markdown + per-segment word ranges from a rundown. + + Scripted segments are concatenated, each under a level-2 heading with + its title, in rundown order; bullets segments contribute no words. + Returns (raw_markdown, ranges) where ranges is + [{"id": seg id, "word-start": int, "word-end": int}, ...] in the + doc's global speakable word-index space ([start, end) half-open), so + the UI can place the producer rail against the scroll. Word counts are + additive across segments because each chunk is heading-separated and + blank-line terminated (script_ingest never merges across them). + """ + parts = [] + ranges = [] + start = 0 + for seg in rundown.get("segments", []): + if seg.get("kind") != "scripted": + continue + body = (seg.get("body") or "").strip() + chunk = f"## {seg['title']}\n\n{body}\n" + count = script_ingest.ingest(chunk)["word-count"] + parts.append(chunk) + ranges.append( + {"id": seg["id"], "word-start": start, "word-end": start + count} + ) + start += count + return "\n".join(parts), ranges + + +def informative_words(text): + """The lowercase informative-word set of a text (keyword first-pass). + + Normalized the way align.py normalizes, so rundown point text and ASR + transcript text meet on the same tokens: the apostrophe family + (ASCII, curly, U+02BC) is stripped first ("market's" and "market’s" + both become "markets"), then words are alphanumeric runs, and pure + digit runs expand through the spoken-number table ("4090" -> "forty" + "ninety", matching what the ASR emits). Stopwords and words shorter + than 3 characters are dropped, on the expanded forms too. + """ + lowered = text.lower() + for ch in KEYWORD_APOSTROPHES: + lowered = lowered.replace(ch, "") + out = set() + for w in re.findall(r"[a-z0-9]+", lowered): + for token in (_expand_digits(w) if w.isdigit() else (w,)): + if len(token) >= 3 and token not in KEYWORD_STOPWORDS: + out.add(token) + return out + + +class TranscriptBuffer: + """Bounded rolling buffer of FINAL transcript text (last ~90 s). + + Fed from the Phase B asr final events; partials never enter (they + revise). Entries older than window_s, and oldest entries past the + character cap, are pruned on every add and read, so the buffer stays + small no matter how long the show runs. now_fn is injectable for + deterministic tests. + """ + + def __init__(self, window_s=TRANSCRIPT_WINDOW_S, max_chars=8000, + now_fn=time.monotonic): + self.window_s = window_s + self.max_chars = max_chars + self.now = now_fn + self._entries = collections.deque() # (monotonic stamp, text) + self._chars = 0 + + def add(self, text): + text = (text or "").strip() + if not text: + return + self._entries.append((self.now(), text)) + self._chars += len(text) + self._prune() + + def _prune(self): + cutoff = self.now() - self.window_s + while self._entries and ( + self._entries[0][0] < cutoff or self._chars > self.max_chars + ): + _, dropped = self._entries.popleft() + self._chars -= len(dropped) + + def text(self): + """The buffered finals, oldest first, joined with spaces.""" + self._prune() + return " ".join(text for _, text in self._entries) + + def clear(self): + """Drop everything (a fresh producer stack must not inherit speech).""" + self._entries.clear() + self._chars = 0 + + +class CueEngine: + """Deterministic cue delivery: the LLM proposes, this engine disposes. + + Enforces the Phase C cue contract: one active cue at a time, the + per-density card budget (attention tier exempt), dedup by candidate + key (a key is consumed only when its cue is actually shown), release + of card-tier cues at a VAD pause (immediate when no ASR runs, i.e. + gate_on_vad is False), attention-tier cues may interrupt the active + cue mid-sentence, and every shown cue auto-expires after CUE_EXPIRY_S. + Density hands-off never shows cards, only attention cues. + + offer()/poll()/on_vad() each return the list of wire frames to + broadcast ({"type": "cue", ...} / {"type": "cue-clear", ...}) so the + caller owns all I/O; the engine itself is pure and fake-clock testable + via now_fn. A card candidate rejected for budget/one-active/pending + reasons is dropped WITHOUT consuming its key, so the producer's + re-offered candidates retry on later ticks. A pending card that waits + longer than CUE_EXPIRY_S for a pause is discarded unshown (stale time + cues must not surface a minute late), also without consuming its key. + """ + + def __init__(self, density=DEFAULT_CUE_DENSITY, now_fn=time.monotonic, + gate_on_vad=False): + self.density = density + self.now = now_fn + self.gate_on_vad = gate_on_vad + self.speaking = False + self.active = None # {"id", "tier", "text", "key", "shown-at"} + self.pending = None # card candidate + "queued-at", awaiting a pause + self.shown_keys = set() + self.last_card_at = None + self._next_id = 0 + + def _show(self, candidate): + self._next_id += 1 + self.active = { + "id": self._next_id, + "tier": candidate["tier"], + "text": candidate["text"], + "key": candidate["key"], + "shown-at": self.now(), + } + self.shown_keys.add(candidate["key"]) + if candidate["tier"] == "card": + self.last_card_at = self.now() + return { + "type": "cue", + "id": self._next_id, + "tier": candidate["tier"], + "text": candidate["text"], + } + + def _clear_active(self): + frame = {"type": "cue-clear", "id": self.active["id"]} + self.active = None + return frame + + def _card_budget_open(self): + budget = CUE_DENSITY_BUDGET_S[self.density] + if budget is None: + return False + return ( + self.last_card_at is None + or self.now() - self.last_card_at >= budget + ) + + def offer(self, candidates): + """Consider cue candidates in order; returns frames to broadcast. + + Candidates are {"tier": "card"|"attention", "text": str, + "key": str} dicts (the producer's cue_candidates() shape; the LLM + cue is adapted to it by the caller). + """ + frames = self.poll() + for candidate in candidates: + if not isinstance(candidate, dict): + continue + key = candidate.get("key") + tier = candidate.get("tier") + text = candidate.get("text") + if not key or not text or tier not in ("card", "attention"): + continue + if key in self.shown_keys: + continue + if tier == "attention": + # exempt from the budget; may interrupt the active cue + if self.active is not None: + frames.append(self._clear_active()) + frames.append(self._show(candidate)) + continue + # card tier: budget, one-active-cue, single pending slot + if not self._card_budget_open(): + continue + if self.active is not None or self.pending is not None: + continue + if self.gate_on_vad and self.speaking: + self.pending = dict(candidate, **{"queued-at": self.now()}) + continue + frames.append(self._show(candidate)) + return frames + + def poll(self): + """Advance time-driven transitions: expiry and pending release.""" + frames = [] + now = self.now() + if ( + self.active is not None + and now - self.active["shown-at"] >= CUE_EXPIRY_S + ): + frames.append(self._clear_active()) + if self.pending is not None: + if now - self.pending["queued-at"] >= CUE_EXPIRY_S: + self.pending = None # stale, discard unshown + elif self.active is None and not ( + self.gate_on_vad and self.speaking + ): + candidate = self.pending + self.pending = None + frames.append(self._show(candidate)) + return frames + + def on_vad(self, speaking): + """Track the VAD state; a pause may release the pending card.""" + self.speaking = bool(speaking) + return self.poll() + + class Client: """One WebSocket connection with its declared role.""" @@ -244,7 +652,10 @@ class AppState: """ def __init__(self, token, owner_wpm=0, static_dir=None, - models_dir=None, asr_provider="none"): + models_dir=None, asr_provider="none", + cue_density=DEFAULT_CUE_DENSITY, llm_provider="none", + llm_endpoint=DEFAULT_LLM_ENDPOINT, + llm_model=DEFAULT_LLM_MODEL): self.token = token self.owner_wpm = owner_wpm or None self.script_path = None @@ -273,6 +684,52 @@ def __init__(self, token, owner_wpm=0, static_dir=None, self.asr_pump = None self.last_anchor = None self.last_status = None + # Phase C producer state. The producer state machine, the cue + # engine, and the LLM client are built on startup only when a + # rundown was loaded (start_producer hook); everything below stays + # inert in tiers 1 and 2. + self.cue_density = cue_density + self.llm_provider = llm_provider + self.llm_endpoint = llm_endpoint + self.llm_model = llm_model + self.llm_ok = False + self.rundown = None + self.rundown_path = None + self.prompt_ranges = [] + self.segment_kinds = {} # seg id -> "scripted"|"bullets" + self.producer = None + self.producer_task = None + self.cue_engine = None + self.llm = None + self.llm_task = None + self.transcript = TranscriptBuffer() + self.last_producer_blob = None + + def load_rundown(self, parsed, path=None): + """Adopt a parsed rundown: producer state + the promptable doc. + + The rundown wins over any --script content by contract: the + scripted segment bodies become the promptable document (via + build_prompt_doc + script_ingest) and the per-segment word ranges + are stored for /api/rundown. A bullets-only rundown yields no + promptable words; the doc is left untouched then. script_path is + deliberately NOT pointed at the rundown file, so POST /api/source + save can never overwrite a rundown with the derived scroll text. + A frontmatter cue-density overrides the configured one. + """ + self.rundown = parsed + self.rundown_path = Path(path) if path else None + self.segment_kinds = { + seg["id"]: seg.get("kind") + for seg in parsed.get("segments", []) + } + if parsed.get("cue-density"): + self.cue_density = parsed["cue-density"] + raw, ranges = build_prompt_doc(parsed) + self.prompt_ranges = ranges + if ranges: + self.script_path = None + self.apply_source(raw, script_ingest.ingest(raw)) def load_script(self, path): """Read and ingest a script file; may raise OSError/UnicodeDecodeError.""" @@ -433,10 +890,32 @@ async def api_state_handler(request): "provider": state.asr_provider, "ready": state.asr_ready, }, + "producer": { + "active": state.producer is not None, + "live": bool( + state.producer is not None + and state.producer.state.get("live") + ), + "llm": { + "provider": state.llm_provider, + "model": state.llm_model, + "ok": state.llm_ok, + }, + }, } ) +async def api_rundown_handler(request): + state = request.app[STATE_KEY] + denied = _require_read_auth(request, state) + if denied: + return denied + return web.json_response( + {"rundown": state.rundown, "segments": state.prompt_ranges} + ) + + async def api_source_get_handler(request): state = request.app[STATE_KEY] denied = _require_read_auth(request, state) @@ -536,6 +1015,75 @@ def _read_and_ingest(p): ) +async def api_rundown_load_handler(request): + state = request.app[STATE_KEY] + if not is_loopback(request): + return web.json_response( + {"error": "rundown loading is loopback only"}, status=403 + ) + try: + body = await request.json() + path = Path(body["path"]) + except (json.JSONDecodeError, KeyError, TypeError) as exc: + return web.json_response({"error": f"bad body: {exc}"}, status=400) + # A reload while LIVE would rebuild the producer from scratch: the show + # clock, every coverage judgment, and the replan history would be gone + # with no undo. Refuse (the home page surfaces the message) unless the + # caller explicitly forces it. Ended or pre-show producers reload fine. + if ( + state.producer is not None + and state.producer.state.get("live") + and not body.get("force") + ): + return web.json_response( + { + "error": ( + "show is live; end the show before loading a rundown, " + 'or pass "force": true to discard the running show' + ) + }, + status=409, + ) + if not path.is_file(): + return web.json_response( + {"error": f"not a readable file: {path}"}, status=400 + ) + rd = _import_rundown() + + def _read_and_parse(p): + raw = p.read_text(encoding="utf-8-sig").removeprefix("\ufeff") + return rd.parse_rundown(raw) + + try: + # read + parse off-loop; the doc/aligner/producer swap on the loop + parsed = await asyncio.to_thread(_read_and_parse, path) + except (OSError, UnicodeDecodeError) as exc: + return web.json_response( + {"error": f"cannot read {path}: {exc}"}, status=400 + ) + except rd.RundownError as exc: + return web.json_response( + {"error": f"rundown parse failed: {exc}"}, status=400 + ) + # An active cue belongs to the outgoing engine; clear it on every + # client before the swap so no stale card survives the reload. + if state.cue_engine is not None and state.cue_engine.active is not None: + await state.broadcast( + {"type": "cue-clear", "id": state.cue_engine.active["id"]} + ) + state.load_rundown(parsed, path) + _restart_producer_stack(state) + await _broadcast_doc_updated(state) + await _broadcast_producer_state(state) + return web.json_response( + { + "rundown": state.rundown, + "segments": state.prompt_ranges, + "warnings": parsed.get("warnings", []), + } + ) + + async def ws_handler(request): state = request.app[STATE_KEY] peer_loopback = is_loopback(request) @@ -671,6 +1219,10 @@ async def ws_handler(request): } state.last_anchor = (payload["i"], False) await state.broadcast(payload) + elif ftype == "show": + await _handle_show_cmd(state, ws, frame) + elif ftype == "point": + await _handle_point_cmd(state, ws, frame) elif ftype == "hello": await ws.send_json( {"type": "error", "message": "already registered"} @@ -698,7 +1250,18 @@ async def _broadcast_anchor(state, anchor, held): async def _handle_asr_event(state, event): - """Handle one engine event on the loop: align, then fan out.""" + """Handle one engine event on the loop: align, then fan out. + + With a producer active, two gates apply here. (1) The transcript + buffer collects finals ONLY while the show is live and not held, so + pre-show mic checks, hold banter, and post-show chat can never feed a + coverage judgment (coverage is sticky; there is no un-cover). (2) The + aligner is fed NOTHING while the producer's current segment is a + bullets segment: bullets contribute no words to the doc, so any anchor + motion during them would be creep into the next scripted segment. The + anchor simply holds; ASR text still broadcasts. Recovery is the + make-current re-anchor in _handle_point_cmd. + """ kind = event.get("kind") if kind in ("partial", "final"): await state.broadcast( @@ -709,19 +1272,34 @@ async def _handle_asr_event(state, event): "text": event.get("text", ""), } ) + rail = state.producer.state if state.producer is not None else None + if kind == "final" and ( + rail is None or (rail.get("live") and not rail.get("hold")) + ): + # producer coverage judgments read only committed ON-AIR text + state.transcript.add(event.get("text", "")) if state.aligner is not None: - result = state.aligner.feed( - event.get("tokens") or [], - event.get("segment", 0), - kind == "final", - ) - await _broadcast_anchor( - state, result["anchor"], bool(result.get("held")) + bullets_current = ( + rail is not None + and state.segment_kinds.get(rail.get("current")) == "bullets" ) + if not bullets_current: + result = state.aligner.feed( + event.get("tokens") or [], + event.get("segment", 0), + kind == "final", + ) + await _broadcast_anchor( + state, result["anchor"], bool(result.get("held")) + ) elif kind == "vad": await state.broadcast( {"type": "vad", "speaking": bool(event.get("speaking"))} ) + if state.cue_engine is not None: + # a pause may release the pending card-tier cue immediately + frames = state.cue_engine.on_vad(event.get("speaking")) + await _broadcast_cue_frames(state, frames) elif kind == "status": state.asr_ready = bool(event.get("ready")) payload = { @@ -784,19 +1362,450 @@ async def stop_asr(app): await asyncio.to_thread(state.engine.stop) +# --------------------------------------------------------------------------- +# Phase C: producer loop, cue delivery, keyword coverage, LLM tick. +# --------------------------------------------------------------------------- + + +async def _broadcast_cue_frames(state, frames): + for frame in frames: + await state.broadcast(frame) + + +async def _broadcast_producer_state(state, rail=None): + """Broadcast the rail state, but only when it actually changed. + + The change detector is a canonical-JSON compare, so nested coverage + flips and replans are caught without trusting the producer to version + its own dict. rail defaults to a fresh producer.tick(). + """ + if state.producer is None: + return + if rail is None: + rail = state.producer.tick() + blob = json.dumps(rail, sort_keys=True) + if blob != state.last_producer_blob: + state.last_producer_blob = blob + await state.broadcast({"type": "producer", "state": rail}) + + +async def _handle_show_cmd(state, ws, frame): + """WS {"type": "show", "cmd": go-live|hold|resume|end} from any client.""" + if state.producer is None: + await ws.send_json( + {"type": "error", "message": "producer mode is not active"} + ) + return + handlers = { + "go-live": state.producer.go_live, + "hold": state.producer.hold, + "resume": state.producer.resume, + "end": state.producer.end_show, + } + handler = handlers.get(frame.get("cmd")) + if handler is None: + await ws.send_json( + { + "type": "error", + "message": "show cmd must be go-live|hold|resume|end", + } + ) + return + handler() + await _broadcast_producer_state(state) + + +async def _handle_point_cmd(state, ws, frame): + """WS {"type": "point", "cmd": covered|skip|make-current, ...} handler. + + The human is the final authority: covered and skip are sticky + producer-side. "point" is optional for make-current (it jumps the + segment). Unknown segment/point ids answer with an error frame. + + make-current is the ONLY segment-transition command on the wire (the + UI's anchor-driven handoff sends it too), so it carries the aligner + re-anchor: when the target segment is SCRIPTED and voice-follow is + active, the anchor jumps to the segment's word-start minus 1 (clamped + to -1) and the fresh anchor frame broadcasts BEFORE the producer + state, undoing any creep from a preceding bullets segment and making + stale-anchor handoffs impossible. A bullets target re-anchors nothing + (feeds are suspended for it; the anchor holds where the last scripted + segment left it). + """ + if state.producer is None: + await ws.send_json( + {"type": "error", "message": "producer mode is not active"} + ) + return + cmd = frame.get("cmd") + seg = frame.get("segment") + idx = frame.get("point") + if cmd not in ("covered", "skip", "make-current"): + await ws.send_json( + { + "type": "error", + "message": "point cmd must be covered|skip|make-current", + } + ) + return + if not isinstance(seg, str) or not seg: + await ws.send_json( + {"type": "error", "message": "point cmd requires a segment id"} + ) + return + needs_index = cmd in ("covered", "skip") + if needs_index and (not isinstance(idx, int) or isinstance(idx, bool)): + await ws.send_json( + {"type": "error", "message": f"{cmd} requires an integer point"} + ) + return + try: + if cmd == "covered": + state.producer.mark_covered(seg, idx) + elif cmd == "skip": + state.producer.skip_point(seg, idx) + else: + state.producer.make_current(seg) + except (KeyError, IndexError, ValueError) as exc: + await ws.send_json( + {"type": "error", "message": f"point cmd failed: {exc}"} + ) + return + if cmd == "make-current" and state.aligner is not None: + rng = next( + (r for r in state.prompt_ranges if r["id"] == seg), None + ) + if rng is not None: # scripted segment: re-anchor deterministically + state.aligner.set_anchor(max(-1, rng["word-start"] - 1)) + payload = { + "type": "anchor", + "i": state.aligner.anchor, + "held": False, + } + state.last_anchor = (payload["i"], False) + await state.broadcast(payload) + await _broadcast_producer_state(state) + + +def _keyword_coverage_pass(state): + """Deterministic first-pass coverage from the final-transcript buffer. + + Runs ONLY while the show is live and not on hold: coverage is sticky + with no un-cover anywhere in the system, so a pre-show rehearsal, a + mic check, or hold banter must never mark a point covered. (The + transcript buffer is also fill-gated on the same condition and cleared + on producer restarts, so this guard is defense in depth.) Every + uncovered, unskipped point whose informative words appear in the last + TRANSCRIPT_WINDOW_S of final transcript at KEYWORD_OVERLAP_FLOOR + (>= 60 percent) or better is proposed covered. propose_coverage is + monotonic by contract (uncovered -> covered only), so a false hit can + never un-cover or flicker anything, and human skips stay authoritative. + """ + rail = state.producer.state + if not rail.get("live") or rail.get("hold"): + return + tail = informative_words(state.transcript.text()) + if not tail: + return + for seg in rail.get("segments", []): + for idx, point in enumerate(seg.get("points", [])): + if point.get("covered") or point.get("skipped"): + continue + info = informative_words(point.get("text", "")) + if not info: + continue + if len(info & tail) / len(info) >= KEYWORD_OVERLAP_FLOOR: + state.producer.propose_coverage(seg["id"], idx) + + +async def producer_tick(state): + """One producer heartbeat: coverage, replan, rail broadcast, cues. + + Called by the 1 s loop and directly by tests (deterministic, no + sleeps). Keyword coverage runs first so the tick's replan already + reflects it; the rail broadcast is change-gated; deterministic cue + candidates then pass through the cue engine. + """ + if state.producer is None: + return + _keyword_coverage_pass(state) + rail = state.producer.tick() + await _broadcast_producer_state(state, rail) + frames = state.cue_engine.offer(state.producer.cue_candidates()) + await _broadcast_cue_frames(state, frames) + + +async def _producer_loop(state): + """The 1 s producer heartbeat task (runs only when a rundown loaded).""" + while True: + await asyncio.sleep(PRODUCER_TICK_S) + try: + await producer_tick(state) + except asyncio.CancelledError: + raise + except Exception as exc: + print(f"producer tick error: {exc}", file=sys.stderr) + + +def build_rundown_block(state): + """The STABLE rundown text for the LLM tick's system message. + + Only slow-changing content lives here: the show title, each segment's + id/title/kind/planned budget, and per-point covered/skipped flags. + This text changes ONLY when coverage flips, so Ollama's prefix cache + survives across ticks (the design reason the system message exists). + Every per-tick number (clock, replan, spent, timing, NEXT) lives in + build_status_block, which rides in the user message instead. Segment + and point ids are the wire ids so the model's coverage answers map + straight onto propose_coverage. + """ + rail = state.producer.state + show = state.rundown.get("show") or "(untitled)" + lines = [f"SHOW: {show}"] + for seg in rail.get("segments", []): + lines.append( + f"SEGMENT {seg.get('id')} '{seg.get('title')}'" + f" kind {seg.get('kind')} planned {seg.get('planned-s')}s" + ) + for idx, point in enumerate(seg.get("points", [])): + if point.get("skipped"): + status = "skipped" + elif point.get("covered"): + status = "covered" + else: + status = "uncovered" + lines.append(f" point {idx} [{status}]: {point.get('text')}") + return "\n".join(lines) + + +def build_status_block(state): + """The VOLATILE clock/replan text for the LLM tick's user message. + + Everything here changes every tick (elapsed, remaining, per-segment + replanned/spent/timing, the current-segment marker, NEXT), so it must + stay OUT of the system message: one volatile byte at the top of the + prompt would invalidate Ollama's prefix cache and force a full + re-prefill of the rundown block on every tick. + """ + rail = state.producer.state + lines = [ + f"CLOCK: elapsed {rail.get('elapsed-s', 0)}s" + f" | remaining {rail.get('remaining-s', 0)}s" + f" | show-state {rail.get('show-state', 'green')}" + f" | {'LIVE' if rail.get('live') else 'PRE-SHOW'}" + f"{' (HOLD)' if rail.get('hold') else ''}" + ] + for seg in rail.get("segments", []): + lines.append( + f"SEGMENT {seg.get('id')}: {seg.get('state')}" + f" | replanned {seg.get('replanned-s')}s" + f" spent {seg.get('spent-s')}s timing {seg.get('timing')}" + ) + nxt = rail.get("next-point") + if nxt: + lines.append( + f"NEXT: segment {nxt.get('segment')} point {nxt.get('idx')}:" + f" {nxt.get('text')}" + ) + return "\n".join(lines) + + +def _evidence_supports(state, seg_id, idx, evidence): + """Deterministic gate on an LLM coverage claim's evidence quote. + + A claim is credible only when (a) the quote is real: at least 60 + percent of its informative words appear in the bounded final-transcript + buffer, and (b) the quote is about THIS point: it shares at least one + informative word with the point text. Measured on qwen3:4b, a + hallucinated claim ("the graphics card anecdote" reported covered at + 0.9 on a transcript that never mentions it) cannot satisfy both: + fabricated evidence fails (a), and a real quote lifted from elsewhere + in the transcript fails (b). The LLM proposes; this code disposes. + """ + ev = informative_words(evidence or "") + if not ev: + return False + tail = informative_words(state.transcript.text()) + if not tail or len(ev & tail) / len(ev) < KEYWORD_OVERLAP_FLOOR: + return False + for seg in state.producer.state.get("segments", []): + if seg["id"] != seg_id: + continue + points = seg.get("points", []) + if 0 <= idx < len(points): + point_info = informative_words(points[idx].get("text", "")) + return bool(point_info & ev) + return False + + +async def apply_llm_result(state, result): + """Apply one LLM tick result: coverage proposals + the optional cue. + + Coverage entries need segment (str), point (int), confidence >= + LLM_CONFIDENCE_FLOOR, and evidence that passes _evidence_supports; + everything else is ignored (the model can only ever propose + uncovered -> covered, the producer enforces the rest). The optional + cue is offered to the cue engine as an ordinary card candidate keyed + by its text, so budget/dedup/one-active all apply. + """ + proposed = False + for item in result.get("coverage", []): + if not isinstance(item, dict): + continue + seg = item.get("segment") + idx = item.get("point") + conf = item.get("confidence") + if ( + isinstance(seg, str) + and isinstance(idx, int) and not isinstance(idx, bool) + and isinstance(conf, (int, float)) + and conf >= LLM_CONFIDENCE_FLOOR + and _evidence_supports(state, seg, idx, item.get("evidence")) + ): + try: + state.producer.propose_coverage(seg, idx) + proposed = True + except (KeyError, IndexError, ValueError): + pass # the model named a point that does not exist + if proposed: + await _broadcast_producer_state(state) + cue = result.get("cue") + if isinstance(cue, dict) and cue.get("text"): + text = str(cue["text"]).strip() + if text: + frames = state.cue_engine.offer( + [{"tier": "card", "text": text, + "key": "llm:" + text.lower()}] + ) + await _broadcast_cue_frames(state, frames) + + +def _llm_should_skip(state): + """True when this LLM tick must be skipped (never queued for later). + + Skips while the ASR engine reports behind (the tick must never starve + the ASR thread), while the show is held, before go-live, and after + end_show (live reads false again then), so the model never judges + off-air speech (deliberate addition to the contract's behind/hold + list). + """ + if state.engine is not None and state.engine.stats.get("behind"): + return True + rail = state.producer.state + return bool(rail.get("hold")) or not rail.get("live") + + +async def _llm_loop(state): + """Adaptive LLM tick task: next = max(15 s, 3 * last tick wall time).""" + interval = LLM_MIN_INTERVAL_S + while True: + await asyncio.sleep(interval) + try: + if _llm_should_skip(state): + continue + started = time.monotonic() + result = await state.llm.tick( + build_rundown_block(state), + build_status_block(state), + state.transcript.text(), + ) + wall = time.monotonic() - started + interval = max(LLM_MIN_INTERVAL_S, LLM_INTERVAL_MULT * wall) + state.llm_ok = result is not None + if result is not None: + await apply_llm_result(state, result) + except asyncio.CancelledError: + raise + except Exception as exc: + print(f"llm tick error: {exc}", file=sys.stderr) + + +async def start_producer(app): + """on_startup hook: build the producer stack when a rundown is loaded. + + The producer and LLM client come from the module-level seams + (PRODUCER_FACTORY / LLM_FACTORY; tests inject stubs, the defaults + lazily import server.producer / server.llm). The cue engine gates + card release on VAD pauses only when ASR actually runs; without ASR + cards release immediately by contract. + """ + state = app[STATE_KEY] + if state.rundown is None: + return + _restart_producer_stack(state) + + +def _restart_producer_stack(state): + """(Re)build the producer stack; used at startup and on runtime loads. + + A fresh producer and cue engine are built from the current rundown; + the heartbeat and LLM tasks are created only when not already running + (both loops read state.producer each tick, so a swap is safe). The LLM + lane cannot appear at runtime: a server started without a rundown has + llm_provider none by the launcher downgrade rule, so a rundown loaded + through POST /api/rundown/load runs the deterministic rail only. + + The transcript buffer is cleared (speech captured before this stack + existed must never feed its coverage judgments) and llm_ok drops back + to False (it reports the last tick outcome of THIS stack, not a stale + success from the previous one). + """ + factory = PRODUCER_FACTORY or _default_producer_factory + state.producer = factory(state.rundown, state.cue_density) + state.cue_engine = CueEngine( + density=state.cue_density, gate_on_vad=state.asr_enabled + ) + state.last_producer_blob = None + state.transcript.clear() + state.llm_ok = False + if state.producer_task is None: + state.producer_task = asyncio.ensure_future(_producer_loop(state)) + if state.llm_provider == "ollama" and state.llm is None: + llm_factory = LLM_FACTORY or _default_llm_factory + state.llm = llm_factory(state.llm_endpoint, state.llm_model) + if state.llm is not None and state.llm_task is None: + state.llm_task = asyncio.ensure_future(_llm_loop(state)) + + +async def stop_producer(app): + """on_cleanup hook: cancel the producer and LLM tasks. + + The wait after cancel is bounded by LLM_STOP_GRACE_S: an LLM tick in + flight sits on a urllib worker thread that cancellation cannot + interrupt, and awaiting it unbounded would gate shutdown on the + remote peer (up to the tick timeout). Past the grace the task is + abandoned; llm.py bounds the worker itself with an end-to-end + deadline, so the thread finishes on its own shortly after and cannot + pin process exit for long. + """ + state = app[STATE_KEY] + for attr in ("producer_task", "llm_task"): + task = getattr(state, attr) + if task is not None: + task.cancel() + # asyncio.wait (not wait_for): on timeout it neither re-cancels + # nor blocks on the uninterruptible task, it just returns. + await asyncio.wait({task}, timeout=LLM_STOP_GRACE_S) + setattr(state, attr, None) + + def create_app(state): app = web.Application() app[STATE_KEY] = state app.on_startup.append(start_asr) + app.on_startup.append(start_producer) + app.on_cleanup.append(stop_producer) app.on_cleanup.append(stop_asr) for route, name in PAGES.items(): app.router.add_get(route, make_page_handler(name)) app.router.add_get("/static/{path:.+}", static_handler) app.router.add_get("/health", health_handler) app.router.add_get("/api/state", api_state_handler) + app.router.add_get("/api/rundown", api_rundown_handler) app.router.add_get("/api/source", api_source_get_handler) app.router.add_post("/api/source", api_source_post_handler) app.router.add_post("/api/source/load", api_source_load_handler) + app.router.add_post("/api/rundown/load", api_rundown_load_handler) app.router.add_get("/ws", ws_handler) return app @@ -818,6 +1827,19 @@ def main(argv=None): parser.add_argument("--asr-provider", default="none", help="none | nemotron-streaming " "(zipformer-small is planned and exits 3)") + parser.add_argument("--rundown", default="", + help="rundown file path; enables producer mode " + "(wins over --script for the prompted text)") + parser.add_argument("--llm-provider", default="none", + help="none | ollama (anything else is a planned " + "lane and exits 3)") + parser.add_argument("--llm-endpoint", default=DEFAULT_LLM_ENDPOINT, + help="Ollama endpoint for the producer's LLM tick") + parser.add_argument("--llm-model", default=DEFAULT_LLM_MODEL, + help="Ollama model tag for the producer's LLM tick") + parser.add_argument("--cue-density", default=DEFAULT_CUE_DENSITY, + choices=CUE_DENSITIES, + help="cue budget (rundown frontmatter overrides)") args = parser.parse_args(argv) provider = args.asr_provider @@ -829,6 +1851,22 @@ def main(argv=None): file=sys.stderr, ) return 3 + llm_provider = args.llm_provider + if llm_provider not in LLM_PROVIDERS: + print( + f"error: llm-provider {llm_provider!r} is not implemented " + f"(a planned lane); implemented providers: " + f"{', '.join(LLM_PROVIDERS)}", + file=sys.stderr, + ) + return 3 + if llm_provider != "none" and not args.rundown: + print( + "note: --llm-provider set without --rundown; the LLM tick " + "runs only in producer mode and is off", + file=sys.stderr, + ) + llm_provider = "none" models_dir = None if args.models_dir and provider != "none": models_dir = Path(args.models_dir) @@ -844,13 +1882,51 @@ def main(argv=None): ) provider = "none" + parsed_rundown = None + if args.rundown: + rundown_path = Path(args.rundown) + if not rundown_path.is_file(): + print(f"error: rundown not found: {rundown_path}", + file=sys.stderr) + return 4 + rd = _import_rundown() + try: + text = rundown_path.read_text(encoding="utf-8-sig") + parsed_rundown = rd.parse_rundown(text) + except (OSError, UnicodeDecodeError) as exc: + print(f"error: cannot read {rundown_path}: {exc}", + file=sys.stderr) + return 4 + except rd.RundownError as exc: + print(f"error: rundown parse failed: {rundown_path}: {exc}", + file=sys.stderr) + return 4 + for warning in parsed_rundown.get("warnings", []): + print(f"rundown warning: {warning}", file=sys.stderr) + if args.script: + print( + "note: --rundown wins over --script; the rundown's " + "scripted segments are what gets prompted (a bullets-only " + "rundown keeps the script on the scroll)", + file=sys.stderr, + ) + state = AppState( token=args.token, owner_wpm=args.owner_wpm, models_dir=models_dir, asr_provider=provider if models_dir else "none", + cue_density=args.cue_density, + llm_provider=llm_provider, + llm_endpoint=args.llm_endpoint, + llm_model=args.llm_model, ) state.port = args.port + # Load the script FIRST, then the rundown: load_rundown replaces the + # doc only when the rundown contributes scripted words, so a + # bullets-only rundown keeps the --script text on the scroll. This + # matches the runtime POST /api/rundown/load behavior exactly (same + # inputs, same outcome on both paths). if args.script: path = Path(args.script) if not path.is_file(): @@ -861,14 +1937,26 @@ def main(argv=None): except (OSError, UnicodeDecodeError) as exc: print(f"error: cannot read {path}: {exc}", file=sys.stderr) return 4 + if parsed_rundown is not None: + state.load_rundown(parsed_rundown, rundown_path) app = create_app(state) asr_note = ( f"asr {state.asr_provider}" if state.asr_enabled else "asr off (tier 1)" ) + if state.rundown is not None: + llm_note = ( + f"llm {state.llm_model}" if state.llm_provider == "ollama" + else "llm off (deterministic rail only)" + ) + producer_note = ( + f", producer on (cue-density {state.cue_density}, {llm_note})" + ) + else: + producer_note = "" print( f"{APP_NAME} {VERSION} serving on http://{args.host}:{args.port}/ " - f"({asr_note})", + f"({asr_note}{producer_note})", file=sys.stderr, ) web.run_app(app, host=args.host, port=args.port, print=None) diff --git a/skills/mc-prompter/scripts/tests/test-llm.py b/skills/mc-prompter/scripts/tests/test-llm.py new file mode 100644 index 0000000..0611bec --- /dev/null +++ b/skills/mc-prompter/scripts/tests/test-llm.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Tests for server/llm.py (mc-prompter Phase C Ollama client). + +Run directly: + uv run skills/mc-prompter/scripts/tests/test-llm.py + +Pure stdlib unittest. The OllamaClient is exercised against a local +http.server stub speaking the /api/chat protocol on an ephemeral loopback +port; every request body is captured and asserted against the Phase C +contract (think false, stream false, structured-output format schema, +options, keep_alive, stable-prefix message order). No Ollama, no models, +no network beyond the in-process stub. +""" + +import asyncio +import http.server +import json +import socket +import sys +import threading +import time +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 server import llm # noqa: E402 + +STATE_BLOCK = ( + "SHOW: test\n" + "SEGMENT g1 'Points' kind bullets planned 290s\n" + " point 0 [uncovered]: local models cost nothing per token\n" +) +STATUS_BLOCK = ( + "CLOCK: elapsed 10s | remaining 290s | show-state green | LIVE\n" + "SEGMENT g1: current | replanned 290s spent 10s timing green" +) +TAIL = "so you never pay per token with a local model" +GOOD_RESULT = { + "coverage": [{"segment": "g1", "point": 0, "confidence": 0.92}], + "current-topic": "cost", + "cue": None, + "pace-note": "on pace", +} + + +class ChatStub: + """Loopback /api/chat stub: captures requests, serves a canned reply. + + reply_body may be bytes (sent verbatim) or a dict (JSON-encoded). + status and delay_s shape the error/timeout scenarios. + """ + + def __init__(self, reply_body, status=200, delay_s=0.0): + if isinstance(reply_body, dict): + reply_body = json.dumps(reply_body).encode("utf-8") + self.requests = [] + stub = self + + class Handler(http.server.BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length) + stub.requests.append( + { + "path": self.path, + "content-type": self.headers.get("Content-Type"), + "body": json.loads(raw.decode("utf-8")), + } + ) + if delay_s: + time.sleep(delay_s) + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(reply_body))) + self.end_headers() + self.wfile.write(reply_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.endpoint = f"http://127.0.0.1:{self.port}" + self.thread = threading.Thread( + target=self.httpd.serve_forever, daemon=True + ) + self.thread.start() + + def stop(self): + self.httpd.shutdown() + self.httpd.server_close() + + +def envelope(result): + """A well-formed Ollama /api/chat non-streaming response envelope.""" + return { + "model": "stub-model", + "message": {"role": "assistant", "content": json.dumps(result)}, + "done": True, + } + + +def run_tick(client, state_block=STATE_BLOCK, status_block=STATUS_BLOCK, + tail=TAIL): + return asyncio.run(client.tick(state_block, status_block, tail)) + + +class TestRequestShape(unittest.TestCase): + """The captured /api/chat request carries the contract's exact knobs.""" + + @classmethod + def setUpClass(cls): + cls.stub = ChatStub(envelope(GOOD_RESULT)) + cls.client = llm.OllamaClient(cls.stub.endpoint, "qwen3:4b") + cls.result = run_tick(cls.client) + + @classmethod + def tearDownClass(cls): + cls.stub.stop() + + def request(self): + self.assertEqual(len(self.stub.requests), 1) + return self.stub.requests[0] + + def test_posts_api_chat_as_json(self): + req = self.request() + self.assertEqual(req["path"], "/api/chat") + self.assertEqual(req["content-type"], "application/json") + + def test_model_stream_think(self): + body = self.request()["body"] + self.assertEqual(body["model"], "qwen3:4b") + self.assertIs(body["stream"], False) + self.assertIs(body["think"], False) + + def test_options_and_keep_alive(self): + body = self.request()["body"] + self.assertEqual( + body["options"], {"temperature": 0, "num_predict": 220} + ) + self.assertEqual(body["keep_alive"], "30m") + + def test_format_is_the_result_schema(self): + body = self.request()["body"] + self.assertEqual(body["format"], llm.RESULT_SCHEMA) + self.assertEqual(body["format"]["required"], ["coverage"]) + coverage_item = body["format"]["properties"]["coverage"]["items"] + self.assertEqual( + coverage_item["required"], + ["segment", "point", "confidence", "evidence"], + ) + + def test_stable_prefix_message_order(self): + # system message first: static persona, then the rundown/coverage + # block (changes only on coverage flips); ALL volatile content, + # the clock/replan status block and the rolling transcript tail, + # rides in the LAST (user) message, so Ollama prefix caching + # skips the static part on every tick. + messages = self.request()["body"]["messages"] + self.assertEqual(len(messages), 2) + self.assertEqual(messages[0]["role"], "system") + self.assertTrue( + messages[0]["content"].startswith(llm.SYSTEM_PERSONA) + ) + self.assertIn(STATE_BLOCK, messages[0]["content"]) + self.assertEqual(messages[-1]["role"], "user") + self.assertIn(STATUS_BLOCK, messages[-1]["content"]) + self.assertIn(TAIL, messages[-1]["content"]) + # the status block must precede the tail inside the user message + self.assertLess( + messages[-1]["content"].index(STATUS_BLOCK), + messages[-1]["content"].index(TAIL), + ) + # nothing volatile may leak into the system message: one changed + # byte there would defeat the prefix cache + self.assertNotIn(STATUS_BLOCK, messages[0]["content"]) + self.assertNotIn("elapsed", messages[0]["content"]) + self.assertNotIn("spent", messages[0]["content"]) + self.assertNotIn(TAIL, messages[0]["content"]) + + def test_valid_response_parses(self): + self.assertEqual(self.result, GOOD_RESULT) + + +class TestResultHandling(unittest.TestCase): + + def tick_against(self, reply_body, status=200): + stub = ChatStub(reply_body, status=status) + try: + client = llm.OllamaClient(stub.endpoint, "m") + return run_tick(client) + finally: + stub.stop() + + def test_coverage_coerced_to_list_when_missing(self): + result = self.tick_against(envelope({"pace-note": "fine"})) + self.assertEqual(result["coverage"], []) + self.assertEqual(result["pace-note"], "fine") + + def test_coverage_coerced_to_list_when_mistyped(self): + result = self.tick_against(envelope({"coverage": "g1"})) + self.assertEqual(result["coverage"], []) + + def test_malformed_content_json_returns_none(self): + env = envelope(GOOD_RESULT) + env["message"]["content"] = "{not json" + self.assertIsNone(self.tick_against(env)) + + def test_non_object_content_returns_none(self): + env = envelope(GOOD_RESULT) + env["message"]["content"] = json.dumps(["a", "list"]) + self.assertIsNone(self.tick_against(env)) + + def test_malformed_envelope_returns_none(self): + self.assertIsNone(self.tick_against(b"{broken envelope")) + + def test_missing_message_key_returns_none(self): + self.assertIsNone(self.tick_against({"done": True})) + + def test_http_error_returns_none(self): + self.assertIsNone( + self.tick_against({"error": "model not found"}, status=404) + ) + + def test_connection_refused_returns_none(self): + # bind then close a socket so the port is known-dead + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + client = llm.OllamaClient(f"http://127.0.0.1:{port}", "m", + timeout_s=2.0) + self.assertIsNone(run_tick(client)) + + +class TestTimeout(unittest.TestCase): + + def test_slow_server_times_out_to_none(self): + # the stub sleeps well past the client deadline: the tick must + # return None near the deadline, never wait for the reply + stub = ChatStub(envelope(GOOD_RESULT), delay_s=1.5) + try: + client = llm.OllamaClient(stub.endpoint, "m", timeout_s=0.3) + started = time.monotonic() + result = run_tick(client) + elapsed = time.monotonic() - started + self.assertIsNone(result) + self.assertLess(elapsed, 1.2) + finally: + stub.stop() + + def test_slow_drip_response_bounded_end_to_end(self): + # a peer dripping bytes forever resets urllib's per-socket-op + # timeout on every read; _post_chat's own deadline must bound the + # WORKER THREAD itself (this is what keeps shutdown from hanging + # on an in-flight tick against a wedged Ollama) + class DripHandler(http.server.BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length", "0")) + self.rfile.read(length) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", "10000000") + self.end_headers() + try: + for _ in range(200): + self.wfile.write(b"x" * 10) + self.wfile.flush() + time.sleep(0.05) + except OSError: + pass # client hung up: expected + + def log_message(self, *args): + pass + + httpd = http.server.HTTPServer(("127.0.0.1", 0), DripHandler) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + client = llm.OllamaClient( + f"http://127.0.0.1:{httpd.server_address[1]}", "m", + timeout_s=0.3, + ) + payload = client.request_payload(STATE_BLOCK, STATUS_BLOCK, TAIL) + started = time.monotonic() + with self.assertRaises(TimeoutError): + client._post_chat(payload) + # roughly timeout_s plus one socket operation, never the full + # drip duration (~10 s here) + self.assertLess(time.monotonic() - started, 2.0) + finally: + httpd.shutdown() + httpd.server_close() + + +class TestClientBasics(unittest.TestCase): + + def test_endpoint_trailing_slash_normalized(self): + client = llm.OllamaClient("http://localhost:11434/", "m") + self.assertEqual(client.endpoint, "http://localhost:11434") + + def test_payload_is_json_serializable(self): + client = llm.OllamaClient("http://localhost:11434", "m") + payload = client.request_payload(STATE_BLOCK, STATUS_BLOCK, TAIL) + json.dumps(payload) # must not raise + + def test_default_timeout(self): + client = llm.OllamaClient("http://localhost:11434", "m") + self.assertEqual(client.timeout_s, llm.DEFAULT_TIMEOUT_S) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/mc-prompter/scripts/tests/test-run_prompter.py b/skills/mc-prompter/scripts/tests/test-run_prompter.py index 0e2e5bf..be9e102 100644 --- a/skills/mc-prompter/scripts/tests/test-run_prompter.py +++ b/skills/mc-prompter/scripts/tests/test-run_prompter.py @@ -398,5 +398,103 @@ def test_returns_a_string(self): self.assertGreaterEqual(ip.count("."), 3) +# --------------------------------------------------------------------------- +# Phase C: producer-mode pass-through flags and the rundown/llm guards. +# main() is still only ever called on paths that fail fast before any +# port probe or spawn. +# --------------------------------------------------------------------------- + + +class TestBuildServerCmdProducer(unittest.TestCase): + + def test_producer_flags_passed_through(self): + cmd = rp.build_server_cmd( + 8770, "127.0.0.1", "", 0, "s.json", "t", + rundown="/abs/rundown.md", llm_provider="ollama", + llm_endpoint="http://localhost:11500", llm_model="qwen3:1.7b", + cue_density="minimal", + ) + self.assertEqual(cmd[cmd.index("--rundown") + 1], "/abs/rundown.md") + self.assertEqual(cmd[cmd.index("--llm-provider") + 1], "ollama") + self.assertEqual( + cmd[cmd.index("--llm-endpoint") + 1], "http://localhost:11500" + ) + self.assertEqual(cmd[cmd.index("--llm-model") + 1], "qwen3:1.7b") + self.assertEqual(cmd[cmd.index("--cue-density") + 1], "minimal") + + def test_no_rundown_omits_the_flag_but_keeps_llm_defaults(self): + cmd = rp.build_server_cmd( + 8770, "127.0.0.1", "/abs/script.md", 150, "s.json", "t", + ) + self.assertNotIn("--rundown", cmd) + self.assertEqual(cmd[cmd.index("--llm-provider") + 1], "none") + self.assertEqual( + cmd[cmd.index("--llm-endpoint") + 1], rp.DEFAULT_LLM_ENDPOINT + ) + self.assertEqual( + cmd[cmd.index("--llm-model") + 1], rp.DEFAULT_LLM_MODEL + ) + self.assertEqual( + cmd[cmd.index("--cue-density") + 1], rp.DEFAULT_CUE_DENSITY + ) + + def test_workspace_spawn_also_carries_producer_flags(self): + with tempfile.TemporaryDirectory() as tmp: + ws = Path(tmp) + cmd = rp.build_server_cmd( + 8770, "127.0.0.1", "", 0, "s.json", "t", + workspace=ws, asr_provider="nemotron-streaming", + rundown="/abs/rundown.md", llm_provider="ollama", + ) + self.assertEqual(cmd[0], str(rp.venv_python_path(ws))) + self.assertEqual( + cmd[cmd.index("--rundown") + 1], "/abs/rundown.md" + ) + self.assertEqual(cmd[cmd.index("--llm-provider") + 1], "ollama") + + +class TestProducerLaunchGuards(unittest.TestCase): + + def _run_main(self, argv): + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = rp.main(argv) + return rc, err.getvalue() + + def test_unknown_llm_provider_exits_3_before_any_spawn(self): + # the nonexistent script proves the llm guard runs first + rc, err = self._run_main( + ["--script", "/definitely/not/there.md", + "--llm-provider", "openai"] + ) + self.assertEqual(rc, 3) + self.assertIn("openai", err) + self.assertIn("planned lane", err) + + def test_missing_rundown_exits_4(self): + with tempfile.TemporaryDirectory() as tmp: + script = Path(tmp) / "script.md" + script.write_text("# S\n\nWords.\n", encoding="utf-8") + rc, err = self._run_main( + ["--script", str(script), + "--rundown", str(Path(tmp) / "missing-rundown.md")] + ) + self.assertEqual(rc, 4) + self.assertIn("rundown not found", err) + + def test_neither_script_nor_rundown_exits_2(self): + rc, err = self._run_main([]) + self.assertEqual(rc, 2) + self.assertIn("--script", err) + self.assertIn("--rundown", err) + + def test_missing_script_still_exits_4(self): + rc, err = self._run_main( + ["--script", "/definitely/not/there.md"] + ) + self.assertEqual(rc, 4) + self.assertIn("script not found", err) + + 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 index 1847702..5df77fc 100644 --- a/skills/mc-prompter/scripts/tests/test-server.py +++ b/skills/mc-prompter/scripts/tests/test-server.py @@ -16,12 +16,16 @@ """ import asyncio +import copy import importlib +import io import json +import shutil import sys import tempfile import threading import time +import types import unittest from pathlib import Path @@ -934,5 +938,1441 @@ def test_missing_models_dir_exits_4(self): self.assertIn("models dir not found", err) +# --------------------------------------------------------------------------- +# Phase C: producer mode. Everything below runs against a STUB producer and +# a STUB LLM client injected through the module-level PRODUCER_FACTORY / +# LLM_FACTORY seams: server/producer.py, server/rundown.py, and a live +# Ollama are never needed, and the whole file still runs with only aiohttp. +# --------------------------------------------------------------------------- + +# A parsed rundown in the parse_rundown() result shape (two scripted +# segments around one bullets segment). +RUNDOWN = { + "show": "Test Show", + "duration-s": 600, + "cue-density": None, + "wrap-s": 60, + "warnings": [], + "segments": [ + {"id": "g0", "title": "Intro", "kind": "scripted", "planned-s": 120, + "body": "Welcome to the show everyone. Glad you are here today.", + "points": []}, + {"id": "g1", "title": "Point 1: Costs", "kind": "bullets", + "planned-s": 300, "body": "", + "points": [{"text": "cloud bills compound monthly forever"}, + {"text": "the graphics card anecdote"}]}, + {"id": "g2", "title": "Wrap", "kind": "scripted", "planned-s": 180, + "body": "Thanks for watching and goodbye everyone.", "points": []}, + ], +} + + +class FakeClock: + """A monotonic-clock stand-in: call it, advance .t by hand.""" + + def __init__(self, t=1000.0): + self.t = t + + def __call__(self): + return self.t + + +class StubProducer: + """Stands in for producer.Producer behind server_main.PRODUCER_FACTORY. + + Implements the full Phase C producer API; every method records itself + in .calls and mutates a plausible rail-state dict so the server's + change-gated broadcast has something real to compare. + """ + + def __init__(self, rundown, cue_density="normal"): + self.rundown = rundown + self.cue_density = cue_density + self.calls = [] + self.candidates = [] + self.tick_count = 0 + self._state = { + "live": False, "hold": False, "elapsed-s": 0, + "remaining-s": 600, "show-state": "green", "current": "g0", + "next-point": {"segment": "g1", "idx": 0, + "text": "cloud bills compound monthly forever"}, + "segments": [ + {"id": "g0", "title": "Intro", "kind": "scripted", + "planned-s": 120, "replanned-s": 120, "spent-s": 0, + "state": "current", "timing": "green", "points": []}, + {"id": "g1", "title": "Point 1: Costs", "kind": "bullets", + "planned-s": 300, "replanned-s": 300, "spent-s": 0, + "state": "pending", "timing": "green", + "points": [ + {"text": "cloud bills compound monthly forever", + "covered": False, "skipped": False}, + {"text": "the graphics card anecdote", + "covered": False, "skipped": False}]}, + {"id": "g2", "title": "Wrap", "kind": "scripted", + "planned-s": 180, "replanned-s": 180, "spent-s": 0, + "state": "pending", "timing": "green", "points": []}, + ], + "drop": None, + } + + def _point(self, seg_id, point_idx): + for seg in self._state["segments"]: + if seg["id"] == seg_id: + return seg["points"][point_idx] + raise KeyError(seg_id) + + def go_live(self): + self.calls.append(("go-live",)) + self._state["live"] = True + + def hold(self): + self.calls.append(("hold",)) + self._state["hold"] = True + + def resume(self): + self.calls.append(("resume",)) + self._state["hold"] = False + + def end_show(self): + self.calls.append(("end",)) + self._state["live"] = False + + def mark_covered(self, seg_id, point_idx): + self.calls.append(("covered", seg_id, point_idx)) + self._point(seg_id, point_idx)["covered"] = True + + def skip_point(self, seg_id, point_idx): + self.calls.append(("skip", seg_id, point_idx)) + self._point(seg_id, point_idx)["skipped"] = True + + def make_current(self, seg_id): + self.calls.append(("make-current", seg_id)) + self._state["current"] = seg_id + + def propose_coverage(self, seg_id, point_idx): + self.calls.append(("propose", seg_id, point_idx)) + point = self._point(seg_id, point_idx) + if not point["covered"] and not point["skipped"]: + point["covered"] = True + + def tick(self): + self.tick_count += 1 + return copy.deepcopy(self._state) + + def cue_candidates(self): + return [dict(c) for c in self.candidates] + + @property + def state(self): + return copy.deepcopy(self._state) + + +class StubLlm: + """Stands in for llm.OllamaClient behind server_main.LLM_FACTORY.""" + + def __init__(self, endpoint, model): + self.endpoint = endpoint + self.model = model + self.ticks = [] + self.result = None + + async def tick(self, state_block, status_block, transcript_tail): + self.ticks.append((state_block, status_block, transcript_tail)) + return self.result + + +class ProducerServerTestBase(ServerTestBase): + """Server with a rundown loaded and the producer/LLM seams stubbed. + + PRODUCER_TICK_S is patched huge so the background loop never ticks by + itself: tests drive server_main.producer_tick(state) directly and stay + deterministic. + """ + + llm_provider = "none" + + async def get_application(self): + self.producers = [] + self.llms = [] + + def producer_factory(rundown, cue_density): + producer = StubProducer(rundown, cue_density) + self.producers.append(producer) + return producer + + def llm_factory(endpoint, model): + client = StubLlm(endpoint, model) + self.llms.append(client) + return client + + server_main.PRODUCER_FACTORY = producer_factory + server_main.LLM_FACTORY = llm_factory + self._saved_tick_s = server_main.PRODUCER_TICK_S + server_main.PRODUCER_TICK_S = 3600.0 + self.addCleanup(self._reset_producer_seams) + app = await super().get_application() + self.state.llm_provider = self.llm_provider + self.state.load_rundown(copy.deepcopy(RUNDOWN)) + return app + + def _reset_producer_seams(self): + server_main.PRODUCER_FACTORY = None + server_main.LLM_FACTORY = None + server_main.PRODUCER_TICK_S = self._saved_tick_s + + @property + def producer(self): + return self.producers[0] + + 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 + + +class TestRundownLoadAndApi(ProducerServerTestBase): + + async def test_api_rundown_shape(self): + resp = await self.client.get("/api/rundown") + self.assertEqual(resp.status, 200) + data = await resp.json() + self.assertEqual(data["rundown"]["show"], "Test Show") + self.assertEqual(len(data["rundown"]["segments"]), 3) + ranges = data["segments"] + self.assertEqual([r["id"] for r in ranges], ["g0", "g2"]) + for entry in ranges: + self.assertEqual( + sorted(entry), ["id", "word-end", "word-start"] + ) + # scripted ranges are contiguous in the doc's word-index space + self.assertEqual(ranges[0]["word-start"], 0) + self.assertGreater(ranges[0]["word-end"], 0) + self.assertEqual(ranges[1]["word-start"], ranges[0]["word-end"]) + + async def test_prompt_doc_is_the_scripted_segments(self): + resp = await self.client.get("/api/source") + data = await resp.json() + self.assertIn("## Intro", data["raw"]) + self.assertIn("## Wrap", data["raw"]) + self.assertIn("Welcome to the show", data["raw"]) + # bullets segments contribute no words to the scroll surface + self.assertNotIn("cloud bills", data["raw"]) + resp = await self.client.get("/api/rundown") + ranges = (await resp.json())["segments"] + self.assertEqual( + data["doc"]["word-count"], ranges[-1]["word-end"] + ) + + async def test_api_state_producer_block(self): + resp = await self.client.get("/api/state") + data = await resp.json() + self.assertEqual( + data["producer"], + { + "active": True, + "live": False, + "llm": {"provider": "none", "model": "qwen3:4b", + "ok": False}, + }, + ) + + async def test_producer_built_from_the_loaded_rundown(self): + self.assertEqual(len(self.producers), 1) + self.assertEqual(self.producer.rundown["show"], "Test Show") + self.assertEqual(self.producer.cue_density, "normal") + self.assertIsNotNone(self.state.producer_task) + self.assertIsNone(self.state.llm_task) # provider none: no tick + + +class TestShowCommands(ProducerServerTestBase): + + async def test_go_live_hold_resume_end(self): + ws, _ = await self.hello("remote") + await ws.send_json({"type": "show", "cmd": "go-live"}) + frame = await recv_json(ws) + self.assertEqual(frame["type"], "producer") + self.assertTrue(frame["state"]["live"]) + + await ws.send_json({"type": "show", "cmd": "hold"}) + frame = await recv_json(ws) + self.assertTrue(frame["state"]["hold"]) + + await ws.send_json({"type": "show", "cmd": "resume"}) + frame = await recv_json(ws) + self.assertFalse(frame["state"]["hold"]) + + await ws.send_json({"type": "show", "cmd": "end"}) + frame = await recv_json(ws) + self.assertFalse(frame["state"]["live"]) + self.assertEqual( + self.producer.calls, + [("go-live",), ("hold",), ("resume",), ("end",)], + ) + await ws.close() + + async def test_show_commands_broadcast_to_all_clients(self): + ws1, _ = await self.hello("remote") + ws2, _ = await self.hello("overlay") + await ws1.send_json({"type": "show", "cmd": "go-live"}) + for ws in (ws1, ws2): + frame = await recv_json(ws) + self.assertEqual(frame["type"], "producer") + self.assertTrue(frame["state"]["live"]) + await ws1.close() + await ws2.close() + + async def test_unknown_show_cmd_errors(self): + ws, _ = await self.hello("remote") + await ws.send_json({"type": "show", "cmd": "party"}) + frame = await recv_json(ws) + self.assertEqual(frame["type"], "error") + self.assertIn("go-live", frame["message"]) + self.assertEqual(self.producer.calls, []) + await ws.close() + + +class TestPointCommands(ProducerServerTestBase): + + async def test_covered_skip_make_current(self): + ws, _ = await self.hello("remote") + await ws.send_json( + {"type": "point", "cmd": "covered", "segment": "g1", "point": 0} + ) + frame = await recv_json(ws) + self.assertEqual(frame["type"], "producer") + points = frame["state"]["segments"][1]["points"] + self.assertTrue(points[0]["covered"]) + + await ws.send_json( + {"type": "point", "cmd": "skip", "segment": "g1", "point": 1} + ) + frame = await recv_json(ws) + points = frame["state"]["segments"][1]["points"] + self.assertTrue(points[1]["skipped"]) + + # point is optional for make-current (it jumps the segment) + await ws.send_json( + {"type": "point", "cmd": "make-current", "segment": "g2"} + ) + frame = await recv_json(ws) + self.assertEqual(frame["state"]["current"], "g2") + self.assertEqual( + self.producer.calls, + [("covered", "g1", 0), ("skip", "g1", 1), + ("make-current", "g2")], + ) + await ws.close() + + async def test_point_cmd_validation(self): + ws, _ = await self.hello("remote") + cases = [ + {"type": "point", "cmd": "cover-all", "segment": "g1", + "point": 0}, + {"type": "point", "cmd": "covered", "point": 0}, + {"type": "point", "cmd": "covered", "segment": "g1", + "point": "zero"}, + {"type": "point", "cmd": "skip", "segment": "g1"}, + ] + for frame in cases: + await ws.send_json(frame) + reply = await recv_json(ws) + self.assertEqual(reply["type"], "error", frame) + self.assertEqual(self.producer.calls, []) + await ws.close() + + async def test_unknown_segment_answers_error(self): + ws, _ = await self.hello("remote") + await ws.send_json( + {"type": "point", "cmd": "covered", "segment": "g9", "point": 0} + ) + reply = await recv_json(ws) + self.assertEqual(reply["type"], "error") + self.assertIn("failed", reply["message"]) + await ws.close() + + +class TestProducerBroadcastDedup(ProducerServerTestBase): + + async def test_unchanged_state_broadcasts_once(self): + ws, _ = await self.hello("prompt") + await server_main.producer_tick(self.state) + await server_main.producer_tick(self.state) # unchanged: silent + await ws.send_json({"type": "cmd", "cmd": "play"}) # sentinel + frame = await recv_json(ws) + self.assertEqual(frame["type"], "producer") + frame = await recv_json(ws) + self.assertEqual(frame["type"], "cmd") + + self.producer._state["elapsed-s"] = 5 + await server_main.producer_tick(self.state) + frame = await recv_json(ws) + self.assertEqual(frame["type"], "producer") + self.assertEqual(frame["state"]["elapsed-s"], 5) + self.assertEqual(self.producer.tick_count, 3) + await ws.close() + + async def test_deterministic_cues_flow_through_the_engine(self): + ws, _ = await self.hello("overlay") + self.producer.candidates = [ + {"tier": "card", "text": "30 seconds", "key": "g0-30s"} + ] + await server_main.producer_tick(self.state) + frame = await recv_json(ws) + self.assertEqual(frame["type"], "producer") + frame = await recv_json(ws) + self.assertEqual(frame["type"], "cue") + self.assertEqual(frame["tier"], "card") + self.assertEqual(frame["text"], "30 seconds") + self.assertEqual(frame["id"], 1) + + # the same candidate re-offered next tick is deduped by key + await server_main.producer_tick(self.state) + await ws.send_json({"type": "cmd", "cmd": "play"}) + frame = await recv_json(ws) + self.assertEqual(frame["type"], "cmd") + await ws.close() + + +class TestKeywordCoverage(ProducerServerTestBase): + + async def test_first_pass_proposes_matching_point(self): + self.producer.go_live() + self.state.transcript.add( + "and your cloud bills compound monthly forever if you stay" + ) + await server_main.producer_tick(self.state) + self.assertIn(("propose", "g1", 0), self.producer.calls) + self.assertNotIn(("propose", "g1", 1), self.producer.calls) + + async def test_sixty_percent_overlap_is_enough(self): + # point 0 has 5 informative words; 3 of them = 60 percent exactly + self.producer.go_live() + self.state.transcript.add("the cloud bills compound") + await server_main.producer_tick(self.state) + self.assertIn(("propose", "g1", 0), self.producer.calls) + + async def test_below_floor_does_not_propose(self): + self.producer.go_live() + self.state.transcript.add("the cloud bills are big") # 2 of 5 + await server_main.producer_tick(self.state) + self.assertNotIn(("propose", "g1", 0), self.producer.calls) + + async def test_covered_and_skipped_points_left_alone(self): + self.producer.go_live() + self.producer._state["segments"][1]["points"][0]["skipped"] = True + self.state.transcript.add( + "cloud bills compound monthly forever" + ) + await server_main.producer_tick(self.state) + self.assertEqual( + [c for c in self.producer.calls if c[0] == "propose"], [] + ) + + async def test_pre_show_speech_never_covers(self): + # coverage is sticky with no un-cover: a rehearsal or mic check + # before GO LIVE must not silence a reminder for the whole show + self.state.transcript.add( + "cloud bills compound monthly forever" + ) + await server_main.producer_tick(self.state) + self.assertEqual( + [c for c in self.producer.calls if c[0] == "propose"], [] + ) + + async def test_hold_speech_never_covers(self): + self.producer.go_live() + self.producer.hold() + self.state.transcript.add( + "cloud bills compound monthly forever" + ) + await server_main.producer_tick(self.state) + self.assertEqual( + [c for c in self.producer.calls if c[0] == "propose"], [] + ) + + async def test_smart_quote_point_matches_ascii_transcript(self): + # a rundown written with editor smart quotes must still meet the + # ASR's plain-ASCII possessives on the same tokens + self.producer.go_live() + self.producer._state["segments"][1]["points"][0]["text"] = ( + "the market’s overnight reaction" + ) + self.state.transcript.add( + "and the market's overnight reaction was brutal" + ) + await server_main.producer_tick(self.state) + self.assertIn(("propose", "g1", 0), self.producer.calls) + + async def test_numeric_point_matches_spoken_numbers(self): + # "4090" in the point text; the ASR emits the spoken form + self.producer.go_live() + self.producer._state["segments"][1]["points"][0]["text"] = ( + "the 4090 pricing anecdote" + ) + self.state.transcript.add( + "so the forty ninety pricing anecdote goes like this" + ) + await server_main.producer_tick(self.state) + self.assertIn(("propose", "g1", 0), self.producer.calls) + + async def test_final_asr_text_feeds_the_buffer(self): + # transcript accumulation is wired off the asr FINAL events only + self.state.transcript.add("spoken words so far") + self.assertIn("spoken words", self.state.transcript.text()) + + +class ProducerAsrTestBase(ProducerServerTestBase): + """Producer AND stub ASR together: the vad/final wiring under test.""" + + models_dir = "/stub-models" + asr_provider = "nemotron-streaming" + + async def get_application(self): + server_main.ENGINE_FACTORY = ( + lambda models_dir, provider, on_event: + StubEngine(models_dir, provider, on_event) + ) + server_main.ALIGNER_FACTORY = lambda doc: StubAligner(doc) + self.addCleanup(self._reset_asr_seams) + return await super().get_application() + + @staticmethod + def _reset_asr_seams(): + server_main.ENGINE_FACTORY = None + server_main.ALIGNER_FACTORY = None + + +class TestAsrProducerWiring(ProducerAsrTestBase): + + async def test_final_events_fill_the_transcript_buffer_while_live(self): + self.producer.go_live() + ws, _ = await self.hello("prompt") + self.state.engine.on_event( + {"kind": "final", "segment": 0, "text": "hello buffer", + "tokens": [" hello", " buffer"]} + ) + await recv_json(ws) # asr frame + await recv_json(ws) # anchor frame + self.assertIn("hello buffer", self.state.transcript.text()) + # partials revise and never enter the coverage buffer + self.state.engine.on_event( + {"kind": "partial", "segment": 1, "text": "revising", + "tokens": [" revising"]} + ) + await recv_json(ws) # asr frame + await recv_json(ws) # anchor frame + self.assertNotIn("revising", self.state.transcript.text()) + await ws.close() + + async def test_off_air_finals_never_enter_the_buffer(self): + # BLOCKER regression: pre-show and hold speech must not reach the + # coverage buffer at all, so not even the first live tick can see + # the last 90 s of off-air talk + ws, _ = await self.hello("prompt") + + async def speak(text): + self.state.engine.on_event( + {"kind": "final", "segment": 0, "text": text, + "tokens": [" " + text]} + ) + # drain to the anchor frame (producer frames from earlier + # ticks may be queued between the asr frame and it) + while (await recv_json(ws))["type"] != "anchor": + pass + + await speak("pre show rehearsal of the cloud bills point") + self.assertEqual(self.state.transcript.text(), "") + await server_main.producer_tick(self.state) + self.assertEqual( + [c for c in self.producer.calls if c[0] == "propose"], [] + ) + + self.producer.go_live() + await server_main.producer_tick(self.state) # first live tick + self.assertEqual( + [c for c in self.producer.calls if c[0] == "propose"], [] + ) + + self.producer.hold() + await speak("hold banter cloud bills compound monthly forever") + self.assertEqual(self.state.transcript.text(), "") + self.producer.resume() + await server_main.producer_tick(self.state) + self.assertEqual( + [c for c in self.producer.calls if c[0] == "propose"], [] + ) + + # live speech flows normally + await speak("cloud bills compound monthly forever") + self.assertIn("compound", self.state.transcript.text()) + await server_main.producer_tick(self.state) + self.assertIn(("propose", "g1", 0), self.producer.calls) + await ws.close() + + async def test_aligner_feeds_suspended_while_bullets_current(self): + # a bullets segment contributes no words to the doc, so any anchor + # motion during it is creep by definition: the aligner gets NOTHING + # while the producer's current segment is bullets (asr text still + # broadcasts and the anchor holds) + ws, _ = await self.hello("prompt") + await ws.send_json( + {"type": "point", "cmd": "make-current", "segment": "g1"} + ) + frame = await recv_json(ws) + self.assertEqual(frame["type"], "producer") # no anchor for bullets + self.assertEqual(frame["state"]["current"], "g1") + self.assertEqual(self.state.aligner.set_calls, []) + + feeds_before = len(self.state.aligner.feeds) + for kind in ("partial", "final"): + self.state.engine.on_event( + {"kind": kind, "segment": 0, "text": "cloud bills", + "tokens": [" cloud", " bills"]} + ) + frame = await recv_json(ws) + self.assertEqual(frame["type"], "asr") # text still broadcasts + # a sentinel proves the server fully processed both events and no + # anchor frame followed either of them + await ws.send_json({"type": "cmd", "cmd": "play"}) + frame = await recv_json(ws) + self.assertEqual(frame["type"], "cmd") + self.assertEqual(len(self.state.aligner.feeds), feeds_before) + # the transcript buffer keeps running through bullets (while live) + self.producer.go_live() + self.state.engine.on_event( + {"kind": "final", "segment": 0, "text": "still buffered", + "tokens": [" still", " buffered"]} + ) + while (await recv_json(ws))["type"] != "asr": + pass + self.assertIn("still buffered", self.state.transcript.text()) + await ws.close() + + async def test_make_current_scripted_reanchors_and_broadcasts(self): + # entering a SCRIPTED segment re-anchors to word-start - 1 and + # broadcasts the fresh anchor frame, so creep accrued during a + # bullets segment (or a stale anchor after a backwards jump) can + # never leak into the new segment + ws, _ = await self.hello("prompt") + g2 = next(r for r in self.state.prompt_ranges if r["id"] == "g2") + await ws.send_json( + {"type": "point", "cmd": "make-current", "segment": "g2"} + ) + frame = await recv_json(ws) + self.assertEqual( + frame, + {"type": "anchor", "i": g2["word-start"] - 1, "held": False}, + ) + producer_frame = await recv_json(ws) + self.assertEqual(producer_frame["type"], "producer") + self.assertEqual(producer_frame["state"]["current"], "g2") + self.assertEqual( + self.state.aligner.set_calls, [g2["word-start"] - 1] + ) + self.assertEqual( + self.state.last_anchor, (g2["word-start"] - 1, False) + ) + await ws.close() + + async def test_make_current_first_segment_clamps_to_minus_one(self): + ws, _ = await self.hello("prompt") + # move away first so make-current g0 changes something + await ws.send_json( + {"type": "point", "cmd": "make-current", "segment": "g2"} + ) + await recv_json(ws) # anchor frame + await recv_json(ws) # producer frame + await ws.send_json( + {"type": "point", "cmd": "make-current", "segment": "g0"} + ) + frame = await recv_json(ws) + self.assertEqual(frame, {"type": "anchor", "i": -1, "held": False}) + g2 = next(r for r in self.state.prompt_ranges if r["id"] == "g2") + self.assertEqual( + self.state.aligner.set_calls, [g2["word-start"] - 1, -1] + ) + await ws.close() + + async def test_card_cue_waits_for_the_vad_pause(self): + ws, _ = await self.hello("prompt") + self.assertTrue(self.state.cue_engine.gate_on_vad) + self.state.engine.on_event({"kind": "vad", "speaking": True}) + frame = await recv_json(ws) + self.assertEqual(frame["type"], "vad") + + self.producer.candidates = [ + {"tier": "card", "text": "NEXT: anecdote", "key": "next-g1-1"} + ] + await server_main.producer_tick(self.state) + frame = await recv_json(ws) + self.assertEqual(frame["type"], "producer") # rail state only + self.assertIsNotNone(self.state.cue_engine.pending) + + # the pause releases the held card immediately + self.state.engine.on_event({"kind": "vad", "speaking": False}) + frame = await recv_json(ws) + self.assertEqual(frame["type"], "vad") + frame = await recv_json(ws) + self.assertEqual(frame["type"], "cue") + self.assertEqual(frame["text"], "NEXT: anecdote") + await ws.close() + + +class TestLlmWiring(ProducerServerTestBase): + + llm_provider = "ollama" + + async def test_llm_client_built_and_task_started(self): + self.assertEqual(len(self.llms), 1) + self.assertEqual(self.llms[0].endpoint, "http://localhost:11434") + self.assertEqual(self.llms[0].model, "qwen3:4b") + self.assertIsNotNone(self.state.llm_task) + + def _speak(self, text): + """Put final-transcript text in the buffer so evidence can verify.""" + self.state.transcript.add(text) + + async def test_confidence_floor_gates_proposals(self): + self._speak( + "cloud bills compound monthly forever and the point about " + "ninety days was made explicitly" + ) + good_evidence = "cloud bills compound monthly forever" + result = { + "coverage": [ + {"segment": "g1", "point": 0, "confidence": 0.9, + "evidence": good_evidence}, + {"segment": "g1", "point": 1, "confidence": 0.69, + "evidence": good_evidence}, + {"segment": "g9", "point": 0, "confidence": 0.99, + "evidence": good_evidence}, + {"segment": "g1", "point": True, "confidence": 0.99, + "evidence": good_evidence}, + "garbage", + ] + } + await server_main.apply_llm_result(self.state, result) + points = self.producer._state["segments"][1]["points"] + self.assertTrue(points[0]["covered"]) + self.assertFalse(points[1]["covered"]) + + async def test_evidence_gate_rejects_hallucinated_claims(self): + self._speak("a local rig is a one time cost that keeps paying back") + result = { + "coverage": [ + # fabricated quote: those words were never spoken + {"segment": "g1", "point": 0, "confidence": 0.9, + "evidence": "cloud bills compound monthly forever"}, + # real quote, but unrelated to the point it claims to cover + {"segment": "g1", "point": 1, "confidence": 0.9, + "evidence": "a local rig is a one time cost"}, + # no evidence at all + {"segment": "g1", "point": 0, "confidence": 0.9}, + {"segment": "g1", "point": 0, "confidence": 0.9, + "evidence": ""}, + ] + } + await server_main.apply_llm_result(self.state, result) + points = self.producer._state["segments"][1]["points"] + self.assertFalse(points[0]["covered"]) + self.assertFalse(points[1]["covered"]) + + async def test_evidence_gate_accepts_normalized_numeric_points(self): + # the point names "4090"; the speaker and the model's verbatim + # quote both carry the spoken form: normalization must let the + # point-overlap requirement pass + self.producer._state["segments"][1]["points"][1]["text"] = ( + "the 4090 pricing anecdote" + ) + self._speak("the forty ninety pricing anecdote was that " + "scalpers won that whole launch") + result = { + "coverage": [ + {"segment": "g1", "point": 1, "confidence": 0.9, + "evidence": "the forty ninety pricing anecdote"}, + ] + } + await server_main.apply_llm_result(self.state, result) + points = self.producer._state["segments"][1]["points"] + self.assertTrue(points[1]["covered"]) + + async def test_llm_cue_goes_through_the_engine(self): + ws, _ = await self.hello("prompt") + self._speak("cloud bills compound monthly forever she said") + result = { + "coverage": [{"segment": "g1", "point": 0, "confidence": 0.8, + "evidence": "cloud bills compound monthly"}], + "cue": {"text": "NEXT: the anecdote", "reason": "point done"}, + } + await server_main.apply_llm_result(self.state, result) + frame = await recv_json(ws) + self.assertEqual(frame["type"], "producer") # coverage changed + frame = await recv_json(ws) + self.assertEqual(frame["type"], "cue") + self.assertEqual(frame["tier"], "card") + self.assertEqual(frame["text"], "NEXT: the anecdote") + await ws.close() + + async def test_should_skip_respects_live_hold_end_and_behind(self): + self.assertTrue(server_main._llm_should_skip(self.state)) # pre-show + self.producer.go_live() + self.assertFalse(server_main._llm_should_skip(self.state)) + self.producer.hold() + self.assertTrue(server_main._llm_should_skip(self.state)) + self.producer.resume() + self.assertFalse(server_main._llm_should_skip(self.state)) + self.state.engine = types.SimpleNamespace( + stats={"queue": 40, "behind": True, "ready": True} + ) + try: + self.assertTrue(server_main._llm_should_skip(self.state)) + finally: + self.state.engine = None + # after end the loop never ticks again: post-show chat is off-air + self.producer.end_show() + self.assertTrue(server_main._llm_should_skip(self.state)) + + async def test_rundown_block_carries_coverage_and_stays_stable(self): + self.producer.mark_covered("g1", 0) + block = server_main.build_rundown_block(self.state) + self.assertIn("SHOW: Test Show", block) + self.assertIn("SEGMENT g1", block) + self.assertIn("planned", block) + self.assertIn("point 0 [covered]", block) + self.assertIn("point 1 [uncovered]", block) + # nothing volatile: the block goes into the system message, where + # one changed byte per tick would defeat Ollama prefix caching + for volatile in ("elapsed", "remaining", "spent", "replanned", + "timing", "NEXT"): + self.assertNotIn(volatile, block) + # per-tick clock changes leave the block byte-identical + self.producer._state["elapsed-s"] = 99 + self.producer._state["segments"][0]["spent-s"] = 99 + self.assertEqual(block, server_main.build_rundown_block(self.state)) + + async def test_status_block_carries_the_volatile_plan(self): + block = server_main.build_status_block(self.state) + self.assertIn("CLOCK: elapsed 0s", block) + self.assertIn("remaining 600s", block) + self.assertIn("SEGMENT g0: current", block) + self.assertIn("replanned", block) + self.assertIn("spent", block) + self.assertIn("timing", block) + self.assertIn("NEXT:", block) + # no rundown structure duplicated here: point text stays stable-side + self.assertNotIn("point 1", block) + + +class TestCueEngine(unittest.TestCase): + """Deterministic cue-engine rules under a fake clock (no server).""" + + def setUp(self): + self.clock = FakeClock() + + def engine(self, density="normal", gate_on_vad=False): + return server_main.CueEngine( + density=density, now_fn=self.clock, gate_on_vad=gate_on_vad + ) + + @staticmethod + def card(text="NEXT: pricing", key="next-1"): + return {"tier": "card", "text": text, "key": key} + + @staticmethod + def attention(text="WRAP", key="wrap"): + return {"tier": "attention", "text": text, "key": key} + + def test_card_released_immediately_without_asr(self): + engine = self.engine() + frames = engine.offer([self.card()]) + self.assertEqual( + frames, + [{"type": "cue", "id": 1, "tier": "card", + "text": "NEXT: pricing"}], + ) + + def test_density_budget_blocks_early_second_card(self): + engine = self.engine(density="normal") # 1 card / 120 s + engine.offer([self.card(key="a")]) + self.clock.t += 60 + # card a expired (cue-clear may surface) but the budget still + # blocks card b: no new cue is shown + frames = engine.offer([self.card(key="b")]) + self.assertEqual([f for f in frames if f["type"] == "cue"], []) + self.clock.t += 65 # 125 s since the first card + frames = engine.offer([self.card(text="b text", key="b")]) + self.assertEqual([f["type"] for f in frames], ["cue"]) + self.assertEqual(frames[0]["text"], "b text") + + def test_hands_off_never_shows_cards(self): + engine = self.engine(density="hands-off") + self.assertEqual(engine.offer([self.card()]), []) + frames = engine.offer([self.attention()]) + self.assertEqual([f["type"] for f in frames], ["cue"]) + self.assertEqual(frames[0]["tier"], "attention") + + def test_chatty_budget_is_45s(self): + engine = self.engine(density="chatty") + engine.offer([self.card(key="a")]) + self.clock.t += 40 + frames = engine.offer([self.card(key="b")]) + self.assertEqual([f for f in frames if f["type"] == "cue"], []) + self.clock.t += 6 + frames = engine.offer([self.card(key="b")]) + self.assertEqual([f["type"] for f in frames], ["cue"]) + + def test_one_active_cue_blocks_cards(self): + engine = self.engine(density="chatty") + engine.offer([self.attention(key="w1")]) # active, not a card + self.assertEqual(engine.offer([self.card()]), []) + + def test_attention_interrupts_active_cue(self): + engine = self.engine() + engine.offer([self.card()]) + frames = engine.offer([self.attention()]) + self.assertEqual( + [f["type"] for f in frames], ["cue-clear", "cue"] + ) + self.assertEqual(frames[0]["id"], 1) + self.assertEqual(frames[1]["tier"], "attention") + + def test_attention_exempt_from_budget(self): + engine = self.engine(density="minimal") + engine.offer([self.card(key="a")]) + self.clock.t += 16 # active card expired, budget still closed + engine.poll() + frames = engine.offer([self.attention()]) + self.assertEqual([f["type"] for f in frames], ["cue"]) + + def test_active_cue_expires_after_15s(self): + engine = self.engine() + engine.offer([self.card()]) + self.clock.t += 14 + self.assertEqual(engine.poll(), []) + self.clock.t += 1 + self.assertEqual(engine.poll(), [{"type": "cue-clear", "id": 1}]) + self.assertIsNone(engine.active) + + def test_dedup_by_key_survives_expiry(self): + engine = self.engine() + engine.offer([self.card()]) + self.clock.t += 200 # expired long ago, budget open again + engine.poll() + self.assertEqual(engine.offer([self.card()]), []) # same key + + def test_vad_gate_holds_card_until_pause(self): + engine = self.engine(gate_on_vad=True) + engine.on_vad(True) + self.assertEqual(engine.offer([self.card()]), []) + self.assertIsNotNone(engine.pending) + frames = engine.on_vad(False) + self.assertEqual([f["type"] for f in frames], ["cue"]) + self.assertIsNone(engine.pending) + + def test_stale_pending_card_is_discarded_unshown(self): + engine = self.engine(gate_on_vad=True) + engine.on_vad(True) + engine.offer([self.card()]) + self.clock.t += 16 + self.assertEqual(engine.on_vad(False), []) + self.assertIsNone(engine.pending) + # the key was never consumed: the candidate may retry later + frames = engine.offer([self.card()]) + self.assertEqual([f["type"] for f in frames], ["cue"]) + + def test_attention_shows_mid_speech(self): + engine = self.engine(gate_on_vad=True) + engine.on_vad(True) + frames = engine.offer([self.attention()]) + self.assertEqual([f["type"] for f in frames], ["cue"]) + + def test_rejected_candidates_do_not_consume_their_key(self): + engine = self.engine(density="normal") + engine.offer([self.card(key="a")]) + self.clock.t += 30 + engine.offer([self.card(key="b")]) # budget-blocked + self.clock.t += 95 # budget open again (125 s since card a) + engine.poll() + frames = engine.offer([self.card(key="b")]) + self.assertEqual([f["type"] for f in frames], ["cue"]) + + +class TestTranscriptBuffer(unittest.TestCase): + + def test_window_pruning(self): + clock = FakeClock() + buf = server_main.TranscriptBuffer(window_s=90.0, now_fn=clock) + buf.add("first words") + clock.t += 60 + buf.add("middle words") + clock.t += 60 + buf.add("latest words") + text = buf.text() + self.assertNotIn("first", text) # 120 s old, outside the window + self.assertIn("middle", text) + self.assertIn("latest", text) + + def test_char_cap_drops_oldest(self): + clock = FakeClock() + buf = server_main.TranscriptBuffer( + window_s=90.0, max_chars=30, now_fn=clock + ) + buf.add("aaaaaaaaaaaaaaaaaaaa") # 20 chars + buf.add("bbbbbbbbbbbbbbbbbbbb") # 40 total: a-entry must go + text = buf.text() + self.assertNotIn("a", text) + self.assertIn("b", text) + + def test_blank_finals_ignored(self): + buf = server_main.TranscriptBuffer(now_fn=FakeClock()) + buf.add("") + buf.add(" ") + self.assertEqual(buf.text(), "") + + +class TestBuildPromptDoc(unittest.TestCase): + + def test_scripted_ranges_are_contiguous_and_additive(self): + raw, ranges = server_main.build_prompt_doc(RUNDOWN) + self.assertEqual([r["id"] for r in ranges], ["g0", "g2"]) + self.assertEqual(ranges[0]["word-start"], 0) + self.assertEqual(ranges[1]["word-start"], ranges[0]["word-end"]) + from server import script_ingest + doc = script_ingest.ingest(raw) + self.assertEqual(doc["word-count"], ranges[-1]["word-end"]) + + def test_bullets_only_rundown_yields_nothing(self): + rundown = {"segments": [RUNDOWN["segments"][1]]} + raw, ranges = server_main.build_prompt_doc(rundown) + self.assertEqual(raw, "") + self.assertEqual(ranges, []) + + def test_informative_words_filtering(self): + words = server_main.informative_words( + "The 4090 is a graphics card, and it wins!" + ) + # digit runs expand to spoken words, matching what the ASR emits + self.assertEqual( + words, {"forty", "ninety", "graphics", "card", "wins"} + ) + + def test_informative_words_apostrophe_family_collapses(self): + # ASCII, curly, and U+02BC apostrophes tokenize identically, so a + # smart-quote rundown meets the ASR's plain possessives + for text in ("the market's move", "the market’s move", + "the marketʼs move"): + self.assertEqual( + server_main.informative_words(text), + {"markets", "move"}, + text, + ) + + def test_informative_words_digits_match_their_spoken_form(self): + self.assertEqual( + server_main.informative_words("the 2026 roadmap"), + server_main.informative_words("the twenty twenty six roadmap"), + ) + self.assertEqual( + server_main.informative_words("pay 25 dollars"), + {"pay", "twenty", "five", "dollars"}, + ) + + +class TestLoadRundown(unittest.TestCase): + + def test_frontmatter_density_overrides_config(self): + state = server_main.AppState(token=TOKEN, cue_density="minimal") + rundown = dict(copy.deepcopy(RUNDOWN), **{"cue-density": "chatty"}) + state.load_rundown(rundown) + self.assertEqual(state.cue_density, "chatty") + + def test_no_frontmatter_density_keeps_config(self): + state = server_main.AppState(token=TOKEN, cue_density="minimal") + state.load_rundown(copy.deepcopy(RUNDOWN)) + self.assertEqual(state.cue_density, "minimal") + + def test_doc_built_and_script_path_cleared(self): + state = server_main.AppState(token=TOKEN) + state.script_path = Path("/tmp/somewhere.md") + state.load_rundown(copy.deepcopy(RUNDOWN)) + self.assertIsNone(state.script_path) # save can never clobber + self.assertGreater(state.doc["word-count"], 0) + self.assertEqual(len(state.prompt_ranges), 2) + + def test_bullets_only_rundown_leaves_doc_untouched(self): + state = server_main.AppState(token=TOKEN) + state.set_raw("# Kept\n\nExisting words.\n") + rundown = {"show": "x", "segments": [RUNDOWN["segments"][1]]} + state.load_rundown(rundown) + self.assertEqual(state.doc["title"], "Kept") + self.assertEqual(state.prompt_ranges, []) + + +class TestProducerOffRegression(ServerTestBase): + """No rundown loaded: tiers 1/2 see no producer surface at all.""" + + async def test_api_rundown_is_null(self): + resp = await self.client.get("/api/rundown") + self.assertEqual(resp.status, 200) + data = await resp.json() + self.assertIsNone(data["rundown"]) + self.assertEqual(data["segments"], []) + + async def test_api_state_producer_inactive(self): + resp = await self.client.get("/api/state") + data = await resp.json() + self.assertEqual(data["producer"]["active"], False) + self.assertEqual(data["producer"]["live"], False) + self.assertEqual(data["producer"]["llm"]["provider"], "none") + self.assertEqual(data["producer"]["llm"]["ok"], False) + + async def test_show_and_point_commands_answer_error(self): + ws = await self.client.ws_connect("/ws") + await ws.send_json({"type": "hello", "role": "remote"}) + await recv_json(ws) + await ws.send_json({"type": "show", "cmd": "go-live"}) + frame = await recv_json(ws) + self.assertEqual(frame["type"], "error") + self.assertIn("producer mode is not active", frame["message"]) + await ws.send_json( + {"type": "point", "cmd": "covered", "segment": "g1", "point": 0} + ) + frame = await recv_json(ws) + self.assertEqual(frame["type"], "error") + await ws.close() + + async def test_no_producer_frames_ever_broadcast(self): + ws = await self.client.ws_connect("/ws") + await ws.send_json({"type": "hello", "role": "prompt"}) + await recv_json(ws) + await ws.send_json({"type": "cmd", "cmd": "play"}) + frame = await recv_json(ws) + self.assertEqual(frame["type"], "cmd") # nothing producer-shaped + self.assertIsNone(self.state.producer) + self.assertIsNone(self.state.producer_task) + await ws.close() + + async def test_no_producer_modules_imported(self): + self.assertNotIn("server.producer", sys.modules) + self.assertNotIn("server.llm", sys.modules) + self.assertNotIn("server.rundown", sys.modules) + + +class TestProducerMainGuards(unittest.TestCase): + """main() Phase C argument guards (no server is started).""" + + def _run_main(self, argv): + import contextlib + + err = io.StringIO() + with contextlib.redirect_stderr(err): + code = server_main.main(argv) + return code, err.getvalue() + + def test_unknown_llm_provider_exits_3(self): + code, err = self._run_main( + ["--token", "t", "--llm-provider", "openai"] + ) + self.assertEqual(code, 3) + self.assertIn("openai", err) + self.assertIn("planned lane", err) + + def test_missing_rundown_exits_4(self): + code, err = self._run_main( + ["--token", "t", "--rundown", + "/nonexistent-mc-prompter-rundown.md"] + ) + self.assertEqual(code, 4) + self.assertIn("rundown not found", err) + + def test_rundown_parse_error_exits_4(self): + class FakeRundownError(Exception): + pass + + def parse_rundown(text): + raise FakeRundownError("line 7: bad time suffix") + + fake = types.SimpleNamespace( + parse_rundown=parse_rundown, RundownError=FakeRundownError + ) + original = server_main._import_rundown + server_main._import_rundown = lambda: fake + self.addCleanup( + setattr, server_main, "_import_rundown", original + ) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "rundown.md" + path.write_text("## Broken (banana min)\n", encoding="utf-8") + code, err = self._run_main( + ["--token", "t", "--rundown", str(path)] + ) + self.assertEqual(code, 4) + self.assertIn("rundown parse failed", err) + self.assertIn("line 7", err) + + def test_bad_cue_density_is_a_usage_error(self): + with self.assertRaises(SystemExit) as ctx: + with contextlib_redirect_stderr_null(): + server_main.main( + ["--token", "t", "--cue-density", "frantic"] + ) + self.assertEqual(ctx.exception.code, 2) + + +RUNTIME_RUNDOWN_MD = """--- +show: "Runtime Load Show" +duration-minutes: 10 +wrap-minutes: 2 +--- + +## Intro (2 min) + +Scripted intro prose for the runtime load test. + +## Middle point (4 min) + +- first talking point here +- second talking point here + +## Wrap (2 min) + +Scripted wrap prose. +""" + + +class TestRuntimeRundownLoad(ServerTestBase): + """POST /api/rundown/load adopts a rundown on a running server. + + The server starts with NO rundown (script only), so this exercises the + home-page runtime load path: real parse via server/rundown.py, producer + stack built through the injected factory, /api/rundown and /api/state + reflecting the change, and a second load replacing the producer. + """ + + async def get_application(self): + self.producers = [] + + def producer_factory(rundown, cue_density): + producer = StubProducer(rundown, cue_density) + self.producers.append(producer) + return producer + + server_main.PRODUCER_FACTORY = producer_factory + self._saved_tick_s = server_main.PRODUCER_TICK_S + server_main.PRODUCER_TICK_S = 3600.0 + + def _cleanup(): + server_main.PRODUCER_FACTORY = None + server_main.PRODUCER_TICK_S = self._saved_tick_s + + self.addCleanup(_cleanup) + return await super().get_application() + + def _write_rundown(self, text=RUNTIME_RUNDOWN_MD): + tmp = tempfile.mkdtemp(prefix="mc-prompter-test-") + self.addCleanup(shutil.rmtree, tmp, True) + path = Path(tmp) / "show.md" + path.write_text(text, encoding="utf-8") + return path + + async def test_runtime_load_builds_producer_stack(self): + resp = await self.client.post( + "/api/rundown/load", json={"path": str(self._write_rundown())} + ) + self.assertEqual(resp.status, 200) + body = await resp.json() + self.assertEqual(body["rundown"]["show"], "Runtime Load Show") + self.assertIsInstance(body["warnings"], list) + self.assertEqual(len(self.producers), 1) + + api = await (await self.client.get("/api/rundown")).json() + self.assertIsNotNone(api["rundown"]) + state = await (await self.client.get("/api/state")).json() + self.assertTrue(state["producer"]["active"]) + + async def test_second_load_replaces_the_producer(self): + for _ in range(2): + resp = await self.client.post( + "/api/rundown/load", + json={"path": str(self._write_rundown())}, + ) + self.assertEqual(resp.status, 200) + self.assertEqual(len(self.producers), 2) + + async def test_load_while_live_is_409_and_leaves_the_show_alone(self): + path = str(self._write_rundown()) + resp = await self.client.post("/api/rundown/load", json={"path": path}) + self.assertEqual(resp.status, 200) + producer = self.producers[0] + producer.go_live() + + resp = await self.client.post("/api/rundown/load", json={"path": path}) + self.assertEqual(resp.status, 409) + body = await resp.json() + self.assertIn("live", body["error"]) + # the running show is untouched: same producer object, still live + self.assertEqual(len(self.producers), 1) + self.assertIs(self.state.producer, producer) + self.assertTrue(self.state.producer.state["live"]) + + async def test_force_load_replaces_a_live_show(self): + path = str(self._write_rundown()) + await self.client.post("/api/rundown/load", json={"path": path}) + self.producers[0].go_live() + resp = await self.client.post( + "/api/rundown/load", json={"path": path, "force": True} + ) + self.assertEqual(resp.status, 200) + self.assertEqual(len(self.producers), 2) + self.assertFalse(self.state.producer.state["live"]) + + async def test_reload_resets_llm_ok_and_clears_transcript(self): + path = str(self._write_rundown()) + await self.client.post("/api/rundown/load", json={"path": path}) + self.state.llm_ok = True + self.state.transcript.add("speech from the previous stack") + resp = await self.client.post("/api/rundown/load", json={"path": path}) + self.assertEqual(resp.status, 200) + self.assertFalse(self.state.llm_ok) + self.assertEqual(self.state.transcript.text(), "") + + async def test_reload_clears_the_active_cue_on_every_client(self): + path = str(self._write_rundown()) + await self.client.post("/api/rundown/load", json={"path": path}) + shown = self.state.cue_engine.offer( + [{"tier": "card", "text": "30 seconds", "key": "seg-30:g0"}] + ) + self.assertEqual([f["type"] for f in shown], ["cue"]) + ws = await self.client.ws_connect("/ws") + await ws.send_json({"type": "hello", "role": "prompt"}) + await recv_json(ws) # welcome + resp = await self.client.post("/api/rundown/load", json={"path": path}) + self.assertEqual(resp.status, 200) + frame = await recv_json(ws) + self.assertEqual(frame, {"type": "cue-clear", "id": shown[0]["id"]}) + frame = await recv_json(ws) + self.assertEqual(frame["type"], "doc-updated") + await ws.close() + + async def test_missing_file_is_400(self): + resp = await self.client.post( + "/api/rundown/load", + json={"path": "/nonexistent-mc-prompter-runtime.md"}, + ) + self.assertEqual(resp.status, 400) + self.assertEqual(len(self.producers), 0) + + async def test_parse_error_is_400_and_state_untouched(self): + bad = RUNTIME_RUNDOWN_MD.replace("(2 min)", "(2 minutes)", 1) + resp = await self.client.post( + "/api/rundown/load", json={"path": str(self._write_rundown(bad))} + ) + self.assertEqual(resp.status, 400) + body = await resp.json() + self.assertIn("rundown parse failed", body["error"]) + api = await (await self.client.get("/api/rundown")).json() + self.assertIsNone(api["rundown"]) + self.assertEqual(len(self.producers), 0) + + +class TestStopProducerGrace(unittest.TestCase): + """stop_producer must never wait longer than LLM_STOP_GRACE_S on an + LLM task pinned to the uninterruptible urllib worker thread.""" + + def test_shutdown_bounded_when_llm_task_ignores_cancel(self): + async def scenario(): + state = server_main.AppState(token=TOKEN) + app = {server_main.STATE_KEY: state} + started = asyncio.Event() + + async def stubborn(): + started.set() + try: + await asyncio.sleep(3600) + except asyncio.CancelledError: + # simulate the in-flight urllib worker: the first + # cancel cannot interrupt it (a later cancel from the + # loop teardown still ends the task) + await asyncio.sleep(3600) + + state.llm_task = asyncio.ensure_future(stubborn()) + await started.wait() + started_at = time.monotonic() + await server_main.stop_producer(app) + self.assertIsNone(state.llm_task) + return time.monotonic() - started_at + + saved = server_main.LLM_STOP_GRACE_S + server_main.LLM_STOP_GRACE_S = 0.2 + try: + elapsed = asyncio.run(scenario()) + finally: + server_main.LLM_STOP_GRACE_S = saved + self.assertLess(elapsed, 1.0) + + +class TestStartupScriptPlusBulletsRundown(unittest.TestCase): + """--script plus a bullets-only rundown keeps the script on the scroll. + + Startup must match the runtime POST /api/rundown/load behavior (same + inputs, same outcome): load_rundown leaves the doc untouched when the + rundown contributes no scripted words, so the script loaded first + stays promptable while the producer rail runs from the rundown. + web.run_app is stubbed to capture the app; no server starts. + """ + + BULLETS_ONLY = ( + "---\n" + 'show: "Bullets Only"\n' + "duration-minutes: 10\n" + "---\n\n" + "## Points (10 min)\n\n" + "- first talking point here\n" + "- second talking point here\n" + ) + + def test_script_kept_when_rundown_contributes_no_words(self): + with tempfile.TemporaryDirectory() as tmp: + script = Path(tmp) / "talk.md" + script.write_text(SAMPLE, encoding="utf-8") + rundown = Path(tmp) / "outline.md" + rundown.write_text(self.BULLETS_ONLY, encoding="utf-8") + captured = {} + original = server_main.web.run_app + server_main.web.run_app = ( + lambda app, **kwargs: captured.setdefault("app", app) + ) + try: + with contextlib_redirect_stderr_null(): + code = server_main.main( + ["--token", "t", "--script", str(script), + "--rundown", str(rundown)] + ) + finally: + server_main.web.run_app = original + self.assertEqual(code, 0) + state = captured["app"][server_main.STATE_KEY] + self.assertEqual(state.doc["title"], "Test Script") + self.assertIn("Hello world", state.raw) + self.assertEqual(state.script_path, script) + self.assertIsNotNone(state.rundown) # producer rail still on + self.assertEqual(state.prompt_ranges, []) + + +class contextlib_redirect_stderr_null: + """Tiny stderr silencer for the argparse usage-error test.""" + + def __enter__(self): + import contextlib + + self._cm = contextlib.redirect_stderr(io.StringIO()) + self._cm.__enter__() + return self + + def __exit__(self, *exc): + return self._cm.__exit__(*exc) + + if __name__ == "__main__": unittest.main() From 89890b2f1de83bb2f13bc266bbfc862c5b952d91 Mon Sep 17 00:00:00 2001 From: Brian Madison <bmadcode@gmail.com> Date: Fri, 10 Jul 2026 01:14:10 -0500 Subject: [PATCH 3/5] Add producer rail, cue cards, GO LIVE controls, and remote producer tab --- .../scripts/server/static/css/home.css | 49 +++ .../scripts/server/static/css/overlay.css | 26 +- .../scripts/server/static/css/prompt.css | 136 +++++++ .../scripts/server/static/css/remote.css | 115 ++++++ .../scripts/server/static/css/shared.css | 102 +++++ .../scripts/server/static/home.html | 31 ++ .../scripts/server/static/js/home.js | 139 ++++++- .../scripts/server/static/js/model.js | 6 + .../scripts/server/static/js/overlay.js | 38 +- .../scripts/server/static/js/prompt.js | 361 +++++++++++++++++- .../scripts/server/static/js/rail.js | 302 +++++++++++++++ .../scripts/server/static/js/remote.js | 260 ++++++++++++- .../scripts/server/static/js/settings.js | 4 +- .../scripts/server/static/overlay.html | 9 +- .../scripts/server/static/prompt.html | 40 ++ .../scripts/server/static/remote.html | 64 +++- 16 files changed, 1653 insertions(+), 29 deletions(-) create mode 100644 skills/mc-prompter/scripts/server/static/js/rail.js diff --git a/skills/mc-prompter/scripts/server/static/css/home.css b/skills/mc-prompter/scripts/server/static/css/home.css index 7c50886..4541846 100644 --- a/skills/mc-prompter/scripts/server/static/css/home.css +++ b/skills/mc-prompter/scripts/server/static/css/home.css @@ -110,3 +110,52 @@ header.top .sub { color: var(--dim); font-size: 0.9rem; } .note-small { font-size: 0.8rem; color: var(--dim); margin-top: 0.5rem; } #dirty-chip { margin-left: 0.4rem; } + +/* ---------- Phase C: rundown panel ---------- */ + +#rundown-msg { + margin-top: 0.5rem; + font-size: 0.88rem; + min-height: 1.3em; +} + +#rundown-msg.ok { color: var(--ok); } +#rundown-msg.bad { color: var(--danger); } + +.warn-list { list-style: none; margin-top: 0.6rem; } + +.warn-list li { + color: var(--accent-2); + font-size: 0.85rem; + padding: 0.15rem 0; +} + +.warn-list li::before { content: "warning: "; opacity: 0.8; } + +#rundown-segs { + width: 100%; + border-collapse: collapse; + margin-top: 0.7rem; + font-size: 0.88rem; +} + +#rundown-segs th { + text-align: left; + color: var(--dim); + font-weight: 600; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 0.25rem 0.5rem 0.25rem 0; + border-bottom: 1px solid var(--border); +} + +#rundown-segs td { + padding: 0.32rem 0.5rem 0.32rem 0; + border-bottom: 1px solid var(--border); + vertical-align: top; +} + +#rundown-segs td.rd-kind { color: var(--dim); } +#rundown-segs td.rd-time { font-family: var(--font-mono); white-space: nowrap; } +#rundown-segs .rd-points { color: var(--dim); font-size: 0.82rem; margin-top: 0.15rem; } diff --git a/skills/mc-prompter/scripts/server/static/css/overlay.css b/skills/mc-prompter/scripts/server/static/css/overlay.css index e49c677..e1ba408 100644 --- a/skills/mc-prompter/scripts/server/static/css/overlay.css +++ b/skills/mc-prompter/scripts/server/static/css/overlay.css @@ -48,5 +48,27 @@ html, body { #voice-badge.speaking .dot { background: var(--ok); } #voice-badge.held { color: var(--accent-2); } -/* Phase C seam: the producer ambient rail and cue cards mount here. */ -#rail { position: fixed; top: 0; right: 0; } +/* Phase C: the producer ambient rail (top center strip) and the single cue + card below it. Both elements paint their own translucent dark panels; the + page itself stays fully transparent (chroma-friendly, no background). */ + +#rail { + position: fixed; + top: 18px; + left: 50%; + transform: translateX(-50%); + max-width: 94vw; + width: max-content; +} + +#rail .mc-rail { font-size: 16px; } + +#cue { + position: fixed; + top: 84px; + left: 50%; + transform: translateX(-50%); + max-width: 70vw; + display: flex; + justify-content: center; +} diff --git a/skills/mc-prompter/scripts/server/static/css/prompt.css b/skills/mc-prompter/scripts/server/static/css/prompt.css index a3d41f2..a05ab22 100644 --- a/skills/mc-prompter/scripts/server/static/css/prompt.css +++ b/skills/mc-prompter/scripts/server/static/css/prompt.css @@ -267,6 +267,142 @@ body.hide-invented #script .invented::after { content: none; } margin-top: 0.9rem; } +/* ---------- Phase C: producer chrome ---------- */ + +/* Ambient rail: docked top (below the HUD gradient) or bottom. Operator + chrome, outside #stage, so mirror flips never apply to it. */ + +#prompter-rail { + position: fixed; + left: 50%; + transform: translateX(-50%); + z-index: 18; + max-width: 96vw; + width: max-content; +} + +#prompter-rail.dock-top { top: 3rem; } +#prompter-rail.dock-bottom { bottom: 0.9rem; } + +/* Cue region: at the eyeline (prompt.js keeps top in sync with the eyeline + setting), right-aligned inside the script's right margin so the card sits + next to what is being read without covering it. prompt.js clamps + max-width to the free margin from the margin-percent setting; long cue + text wraps inside the card instead of intruding into the text column. */ + +#cue-region { + position: fixed; + right: 1vw; + top: 33%; + transform: translateY(-50%); + z-index: 30; + max-width: min(22vw, 22rem); + display: flex; + justify-content: flex-end; + pointer-events: none; +} + +/* Narrow-margin fallback (prompt.js adds the class when the free margin + cannot fit a readable card): the cue docks as a band just above the + eyeline, over already-read text, so the line being read stays clear. + Vertical position comes from prompt.js (bottom anchored to the eyeline). */ + +#cue-region.dock-band { + left: 1vw; + max-width: none; + transform: none; +} + +/* Pre-show GO LIVE control */ + +#golive-panel { + position: fixed; + bottom: 4.5rem; + left: 50%; + transform: translateX(-50%); + z-index: 45; +} + +#btn-golive { + font-size: 2rem; + font-weight: 800; + letter-spacing: 0.06em; + padding: 0.6em 1.6em; + border-radius: 16px; + background: var(--danger); + color: #0c1322; + border-color: transparent; + box-shadow: 0 0 48px rgba(255, 51, 85, 0.35); +} + +#prod-badge.live { color: var(--danger); border-color: var(--danger); } + +#btn-end.armed { + background: var(--danger); + color: #0c1322; + border-color: transparent; +} + +/* Bullets-segment large-type rail view: covers the scroll surface and the + eyeline (z-index above both) while a bullets segment is current. Colors + and font follow the display settings via inline styles from prompt.js. */ + +#bullets-stage { + position: absolute; + inset: 0; + z-index: 6; + display: flex; + flex-direction: column; + justify-content: center; + gap: 1.2rem; + padding: 6vh 8vw; + background: #000; + overflow: hidden; +} + +#bstage-seg { + font-size: 2.2vh; + text-transform: uppercase; + letter-spacing: 0.14em; +} + +#bstage-current { + font-size: 7vh; + font-weight: 700; + line-height: 1.25; +} + +#bstage-next { + font-size: 3.6vh; + line-height: 1.3; + opacity: 0.75; +} + +#bstage-next::before { + content: "NEXT "; + font-size: 0.55em; + letter-spacing: 0.14em; + color: var(--accent); + opacity: 1; +} + +#bstage-rest { + list-style: none; + font-size: 2.6vh; + line-height: 1.5; + opacity: 0.4; + overflow: hidden; +} + +#bstage-rest li.covered { text-decoration: line-through; opacity: 0.6; } +#bstage-rest li.skipped { text-decoration: line-through; font-style: italic; opacity: 0.5; } + +#bstage-done { + font-size: 4.5vh; + font-weight: 700; + color: var(--ok); +} + /* Status toast */ #toast { diff --git a/skills/mc-prompter/scripts/server/static/css/remote.css b/skills/mc-prompter/scripts/server/static/css/remote.css index 3598713..fb28d21 100644 --- a/skills/mc-prompter/scripts/server/static/css/remote.css +++ b/skills/mc-prompter/scripts/server/static/css/remote.css @@ -107,3 +107,118 @@ header.bar h1 { font-size: 1.05rem; color: var(--dim); font-weight: 600; } #error-panel { text-align: center; margin-top: 20vh; } #error-panel .icon { font-size: 3rem; } + +/* ---------- Phase C: tabs + producer view ---------- */ + +#tabs { + display: flex; + gap: 0.5rem; +} + +#tabs .tab { + flex: 1 1 0; + padding: 0.55em 0.5em; + color: var(--dim); +} + +#tabs .tab.active { + color: var(--text); + border-color: var(--accent); +} + +#view-producer { + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +#prod-controls { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.7rem; +} + +#prod-controls .span2 { grid-column: span 2; } + +#btn-golive { + font-size: 1.6rem; + padding: 0.9em; + background: var(--danger); + color: #0c1322; + border-color: transparent; + font-weight: 700; +} + +#btn-golive:disabled { background: var(--panel-2); color: var(--dim); } + +#btn-hold.holding { + background: var(--accent-2); + color: #0c1322; + border-color: transparent; +} + +#btn-end.armed { + background: var(--danger); + color: #0c1322; + border-color: transparent; +} + +#prod-rail .mc-rail { font-size: 0.85rem; } + +#prod-segments { flex: 1 1 auto; overflow-y: auto; } + +#prod-segments h2 { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--dim); + margin-bottom: 0.4rem; +} + +.p-seg { + border: 1px solid var(--border); + border-radius: var(--radius); + margin: 0.45rem 0; + padding: 0.5rem 0.6rem; + background: var(--panel); +} + +.p-seg.current { border-color: var(--accent); } +.p-seg.done { opacity: 0.55; } + +.p-seg-head { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.p-seg-title { + flex: 1 1 auto; + font-weight: 650; + min-width: 0; + overflow-wrap: anywhere; +} + +.p-seg-time { font-family: var(--font-mono); font-size: 0.9rem; } + +.p-seg-head .p-make { font-size: 0.8rem; padding: 0.3em 0.7em; white-space: nowrap; } + +.p-points { list-style: none; margin-top: 0.4rem; } + +.p-point { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.45rem 0.2rem; + border-top: 1px solid var(--border); +} + +.p-point .p-text { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; } + +.p-point.active .p-text { color: var(--accent); font-weight: 650; } +.p-point.covered .p-text { color: var(--dim); text-decoration: line-through; } +.p-point.skipped .p-text { color: var(--dim); text-decoration: line-through; font-style: italic; } + +.p-point .p-actions { display: flex; gap: 0.4rem; } +.p-point .p-actions button { font-size: 0.8rem; padding: 0.3em 0.7em; } +.p-point .p-flag { font-size: 0.72rem; color: var(--dim); text-transform: uppercase; letter-spacing: 0.06em; } diff --git a/skills/mc-prompter/scripts/server/static/css/shared.css b/skills/mc-prompter/scripts/server/static/css/shared.css index 1fafb0d..f791ef2 100644 --- a/skills/mc-prompter/scripts/server/static/css/shared.css +++ b/skills/mc-prompter/scripts/server/static/css/shared.css @@ -205,3 +205,105 @@ input[type="checkbox"] { width: 1.1em; height: 1.1em; accent-color: var(--accent .row select { min-width: 9em; } hr.sep { border: 0; border-top: 1px solid var(--border); margin: 0.9rem 0; } + +/* ---------- producer rail (Phase C, shared by /prompt, /overlay, /remote) ---------- + Ambient strip: no motion, glanceable, own translucent dark background so + it stays readable over the prompt page and stays chroma-friendly on the + transparent OBS overlay. */ + +.mc-rail { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.9rem; + padding: 0.45rem 0.95rem; + background: rgba(12, 19, 34, 0.88); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.95rem; + color: var(--text); +} + +/* Timing colors (Toastmasters green/yellow/red against the replan). The + theme's good color stands in for green; yellow and red are literal. */ + +.mc-rail .t-green, .t-green { color: var(--ok); } +.mc-rail .t-yellow, .t-yellow { color: var(--accent-2); } +.mc-rail .t-red, .t-red { color: var(--danger); } + +.mc-rail .r-clock { font-size: 1.1rem; } + +.mc-rail .r-seg { + display: inline-flex; + align-items: baseline; + gap: 0.5rem; + min-width: 0; +} + +.mc-rail .r-seg-title { + font-weight: 650; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 18rem; +} + +.mc-rail .r-next { + flex: 1 1 auto; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--dim); +} + +.mc-rail .r-dots { display: inline-flex; gap: 0.32rem; align-items: center; } + +.mc-rail .r-dot { + width: 0.62em; + height: 0.62em; + border-radius: 50%; + border: 1px solid var(--dim); + background: transparent; +} + +.mc-rail .r-dot.done { background: var(--dim); border-color: var(--dim); opacity: 0.65; } +.mc-rail .r-dot.current { background: currentColor; border-color: currentColor; } +.mc-rail .r-dot.pending { border-color: var(--dim); background: transparent; color: var(--dim); } + +/* ---------- cue card (Phase C) ---------- + One region, one active cue. Card tier appears quietly; attention tier is + the only element allowed to flash (time-critical states like WRAP). */ + +.mc-cue { + background: rgba(12, 19, 34, 0.94); + border: 2px solid var(--accent); + border-radius: 12px; + padding: 0.55em 0.95em; + font-size: 1.5rem; + font-weight: 700; + line-height: 1.25; + color: var(--text); + max-width: 100%; + overflow-wrap: anywhere; +} + +.mc-cue.tier-card { animation: mc-cue-in 0.35s ease-out; } + +.mc-cue.tier-attention { + background: var(--accent-2); + border-color: var(--accent-2); + color: #0c1322; + font-size: 1.9rem; + animation: mc-cue-pulse 0.7s ease-in-out 3; +} + +@keyframes mc-cue-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes mc-cue-pulse { + 0%, 100% { transform: scale(1); box-shadow: none; } + 50% { transform: scale(1.05); box-shadow: 0 0 42px rgba(255, 204, 0, 0.65); } +} diff --git a/skills/mc-prompter/scripts/server/static/home.html b/skills/mc-prompter/scripts/server/static/home.html index f176413..2a1732d 100644 --- a/skills/mc-prompter/scripts/server/static/home.html +++ b/skills/mc-prompter/scripts/server/static/home.html @@ -38,6 +38,36 @@ <h2>Script source</h2> </ul> </div> + <div class="panel" style="margin-bottom:1rem" id="rundown-panel"> + <h2>Rundown (producer mode) <span id="prod-active-chip" class="chip good hidden">active</span></h2> + <div class="path-row"> + <input type="text" id="rundown-path" placeholder="/absolute/path/to/rundown.md" spellcheck="false"> + <button id="btn-rundown-load">load</button> + </div> + <p class="note-small" id="rundown-hint"> + A rundown gives the show a duration, segment budgets, and talking + points; the producer rail and cues run against it. Load one here or + launch with --rundown. + </p> + <div id="rundown-msg"></div> + <div id="rundown-plan" class="hidden"> + <ul class="info-list" style="margin-top:0.8rem"> + <li><span class="k">show</span><span class="v" id="rd-show">-</span></li> + <li><span class="k">duration</span><span class="v" id="rd-duration">-</span></li> + <li><span class="k">cue density</span><span class="v" id="rd-density">-</span></li> + <li><span class="k">wrap reserve</span><span class="v" id="rd-wrap">-</span></li> + <li><span class="k">producer LLM</span><span class="v" id="rd-llm">-</span></li> + </ul> + <ul id="rundown-warnings" class="warn-list"></ul> + <table id="rundown-segs"> + <thead> + <tr><th>segment</th><th>kind</th><th>planned</th></tr> + </thead> + <tbody id="rundown-segs-body"></tbody> + </table> + </div> + </div> + <div class="panel" style="margin-bottom:1rem"> <h2>Open</h2> <div class="launch-links"> @@ -88,6 +118,7 @@ <h2>Edit in place <span id="dirty-chip" class="chip warn hidden">unsaved edits</ <script src="/static/js/ws.js"></script> <script src="/static/js/model.js"></script> + <script src="/static/js/rail.js"></script> <script src="/static/js/home.js"></script> </body> </html> diff --git a/skills/mc-prompter/scripts/server/static/js/home.js b/skills/mc-prompter/scripts/server/static/js/home.js index 97c4c3e..27f8bc8 100644 --- a/skills/mc-prompter/scripts/server/static/js/home.js +++ b/skills/mc-prompter/scripts/server/static/js/home.js @@ -10,6 +10,14 @@ * 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. + * + * Rundown panel (Phase C): path load (POST /api/rundown/load, loopback only + * like script load) and the reconciled plan preview (segments with their + * reconciled budgets, kind, points, and any parser warnings) from GET + * /api/rundown, so the creator sees exactly what the producer will run + * before the show starts. A 404 from the load endpoint means the server + * predates runtime rundown loading; the message says to relaunch with + * --rundown instead of failing silently. */ (function () { 'use strict'; @@ -35,7 +43,20 @@ btnSave: document.getElementById('btn-save'), btnRevert: document.getElementById('btn-revert'), dirtyChip: document.getElementById('dirty-chip'), - msg: document.getElementById('msg') + msg: document.getElementById('msg'), + prodActiveChip: document.getElementById('prod-active-chip'), + rundownPath: document.getElementById('rundown-path'), + btnRundownLoad: document.getElementById('btn-rundown-load'), + rundownHint: document.getElementById('rundown-hint'), + rundownMsg: document.getElementById('rundown-msg'), + rundownPlan: document.getElementById('rundown-plan'), + rdShow: document.getElementById('rd-show'), + rdDuration: document.getElementById('rd-duration'), + rdDensity: document.getElementById('rd-density'), + rdWrap: document.getElementById('rd-wrap'), + rdLlm: document.getElementById('rd-llm'), + rundownWarnings: document.getElementById('rundown-warnings'), + rundownSegsBody: document.getElementById('rundown-segs-body') }; var TOKEN_STORE = 'mc-prompter-token'; @@ -179,6 +200,120 @@ say('reverted to the last loaded text'); }); + // ---------- rundown panel (Phase C) ---------- + + function sayRundown(text, kind) { + els.rundownMsg.textContent = text || ''; + els.rundownMsg.className = kind || ''; + } + + function renderPlan(resp) { + var info = MC.rail.normalizeRundown(resp); + if (!info) { + // 200 with {"rundown": null}: no rundown loaded, producer off. + els.rundownPlan.classList.add('hidden'); + els.rundownHint.classList.remove('hidden'); + return; + } + var rd = info.rundown; + els.rundownPlan.classList.remove('hidden'); + els.rundownHint.classList.add('hidden'); + els.rdShow.textContent = rd.show || '-'; + els.rdDuration.textContent = MC.model.fmtClock(rd['duration-s']); + els.rdDensity.textContent = rd['cue-density'] || 'from config'; + els.rdWrap.textContent = rd['wrap-s'] ? MC.model.fmtClock(rd['wrap-s']) : 'none'; + + var warnings = rd.warnings || []; + while (els.rundownWarnings.firstChild) els.rundownWarnings.removeChild(els.rundownWarnings.firstChild); + for (var w = 0; w < warnings.length; w++) { + var li = document.createElement('li'); + li.textContent = warnings[w]; + els.rundownWarnings.appendChild(li); + } + + var body = els.rundownSegsBody; + while (body.firstChild) body.removeChild(body.firstChild); + var segs = rd.segments || []; + for (var i = 0; i < segs.length; i++) { + var seg = segs[i]; + var tr = document.createElement('tr'); + var tdTitle = document.createElement('td'); + tdTitle.textContent = seg.title || seg.id; + var pts = seg.points || []; + if (pts.length) { + var pl = document.createElement('div'); + pl.className = 'rd-points'; + var texts = []; + for (var p = 0; p < pts.length; p++) texts.push(pts[p].text); + pl.textContent = texts.join(' / '); + tdTitle.appendChild(pl); + } + var tdKind = document.createElement('td'); + tdKind.className = 'rd-kind'; + tdKind.textContent = seg.kind || '-'; + var tdTime = document.createElement('td'); + tdTime.className = 'rd-time'; + tdTime.textContent = MC.model.fmtClock(seg['planned-s']); + tr.appendChild(tdTitle); + tr.appendChild(tdKind); + tr.appendChild(tdTime); + body.appendChild(tr); + } + } + + // Producer chip + LLM row from a /api/state payload. Called at boot and + // again after a runtime rundown load, which restarts the producer stack. + function renderProducerInfo(state) { + if (!(state && state.producer && state.producer.active)) return; + els.prodActiveChip.classList.remove('hidden'); + var llm = state.producer.llm || {}; + els.rdLlm.textContent = llm.provider && llm.provider !== 'none' + ? llm.provider + ' ' + (llm.model || '') + (llm.ok ? ' (ok)' : ' (unreachable: deterministic cues only)') + : 'off (deterministic rail and time cues only)'; + } + + function refreshProducerInfo() { + return MC.model.fetchState(token).then(renderProducerInfo) + .catch(function () { /* the chip alone still marks the load */ }); + } + + function refreshRundown() { + return MC.model.fetchRundown(token).then(function (resp) { + renderPlan(resp); + }).catch(function (err) { + if (err.status === 404) { + els.rundownPlan.classList.add('hidden'); + els.rundownHint.classList.remove('hidden'); + } else { + sayRundown('could not fetch the rundown: ' + err.message, 'bad'); + } + }); + } + + els.btnRundownLoad.addEventListener('click', function () { + var path = els.rundownPath.value.trim(); + if (!path) { sayRundown('enter an absolute path first', 'bad'); return; } + sayRundown('loading...'); + MC.model.postJSON('/api/rundown/load', { path: path }, token).then(function () { + sayRundown('rundown loaded', 'ok'); + els.prodActiveChip.classList.remove('hidden'); + // The load restarted the producer stack: re-read /api/state so the + // producer LLM row reflects this session instead of the boot value. + refreshProducerInfo(); + return refreshRundown(); + }).catch(function (err) { + if (err.status === 404) { + sayRundown('this server cannot load a rundown at runtime; relaunch with --rundown ' + path, 'bad'); + } else { + sayRundown('load failed: ' + err.message, 'bad'); + } + }); + }); + + els.rundownPath.addEventListener('keydown', function (e) { + if (e.key === 'Enter') els.btnRundownLoad.click(); + }); + // ---------- WS wiring ---------- ws.onStatus(function (s) { @@ -214,8 +349,10 @@ if (state && state.script && state.script.path) { els.pathInput.value = state.script.path; } + renderProducerInfo(state); renderEst(); }).catch(function () { /* defaults are fine */ }).then(function () { + refreshRundown(); 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 index 61040f1..cf4779d 100644 --- a/skills/mc-prompter/scripts/server/static/js/model.js +++ b/skills/mc-prompter/scripts/server/static/js/model.js @@ -62,8 +62,13 @@ function fetchSource(token) { return fetchJSON('/api/source', token); } // GET /api/state -> {snapshot, "doc-version", script, config} + // (Phase C adds .producer {active, live, llm {provider, model, ok}}.) function fetchState(token) { return fetchJSON('/api/state', token); } + // GET /api/rundown -> the parsed rundown + segment word ranges (Phase C). + // 404 when no rundown is loaded; callers treat that as producer-off. + function fetchRundown(token) { return fetchJSON('/api/rundown', token); } + /* Render the script model into `container` (emptied first). * Returns an index used by the scroll engine and jump logic: * { wordCount, takeWordCount, words: [span], @@ -194,6 +199,7 @@ window.MC.model = { fetchSource: fetchSource, fetchState: fetchState, + fetchRundown: fetchRundown, postJSON: postJSON, withToken: withToken, renderDoc: renderDoc, diff --git a/skills/mc-prompter/scripts/server/static/js/overlay.js b/skills/mc-prompter/scripts/server/static/js/overlay.js index 7af5211..22dbae0 100644 --- a/skills/mc-prompter/scripts/server/static/js/overlay.js +++ b/skills/mc-prompter/scripts/server/static/js/overlay.js @@ -1,13 +1,18 @@ /* mc-prompter /overlay page glue. * * 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. + * badge that hides itself 5 seconds after each (re)connect. * * Phase B: voice-follow anchor/vad frames light a small voice badge * (speaking dot, HOLD tint, committed word index) so a live operator can * see tracking state without the prompter display in view. + * + * Phase C: the real producer surface. Producer frames drive the ambient + * rail (show clock, G/Y/R, current segment, NEXT point, segment dots) and + * cue / cue-clear frames drive the single cue card. Before the first + * producer frame (the server broadcasts only on change), the rail is + * seeded from GET /api/rundown when /api/state reports producer.active. + * Everything stays hidden in tier 1/2 sessions (no rundown, no frames). */ (function () { 'use strict'; @@ -60,6 +65,29 @@ renderVoice(); }); - // Phase C seam: subscribe here for producer rail state. - // ws.on('state', function (msg) { ... }); + // ----- Phase C: producer rail + cue card ----- + + var rail = MC.rail.createRail(document.getElementById('rail')); + var cue = MC.rail.createCueCard(document.getElementById('cue')); + var gotProducerFrame = false; + + ws.on('producer', function (msg) { + if (!msg.state) return; + gotProducerFrame = true; + rail.update(msg.state); + }); + + ws.on('cue', cue.onCue); + ws.on('cue-clear', cue.onClear); + + // Pre-show seed: a live frame always wins over the synthesized state. + MC.model.fetchState(token).then(function (state) { + if (!(state && state.producer && state.producer.active)) return null; + return MC.model.fetchRundown(token).then(function (resp) { + var info = MC.rail.normalizeRundown(resp); + if (info && !gotProducerFrame) { + rail.update(MC.rail.preShowState(info.rundown)); + } + }); + }).catch(function () { /* tier 1/2 session: the rail stays hidden */ }); })(); diff --git a/skills/mc-prompter/scripts/server/static/js/prompt.js b/skills/mc-prompter/scripts/server/static/js/prompt.js index 3a2325f..1ee21c3 100644 --- a/skills/mc-prompter/scripts/server/static/js/prompt.js +++ b/skills/mc-prompter/scripts/server/static/js/prompt.js @@ -32,6 +32,19 @@ * Dev seam: ?sim-audio=1 adds "sim wav" buttons that fetch and decode a WAV * (path via prompt) and stream it through the exact same sendAudioFrame path * as the mic, so the end-to-end can be tested without a human speaking. + * + * Producer mode (Phase C): active when producer frames arrive (or /api/state + * reports producer.active). The ambient rail docks top or bottom (rail-dock + * setting); cue / cue-clear frames drive the single cue card near the + * eyeline. Scripted rundown segments use the normal scroll surface; bullets + * segments switch the stage to a large-type rail view (current point huge, + * next below, the rest dimmed). Segment handoff is manual (n / p keys, the + * remote's make-current) and, while voice follow drives the leader, anchor + * crossing a scripted segment's word-end sends make-current for the next + * segment. GO LIVE (big button pre-show, g key), hold / resume (h), and a + * two-tap end-show live in the HUD. All show / point commands go out as + * {"type":"show","cmd":...} / {"type":"point","cmd":...,"segment","point"}; + * nothing here changes tier 1/2 behavior when no rundown is loaded. */ (function () { 'use strict'; @@ -214,6 +227,32 @@ document.body.classList.toggle('hide-takes', !!s['hide-takes']); document.body.classList.toggle('hide-invented', !s['show-invented']); + + // Producer chrome (Phase C): the cue card rides the eyeline, the rail + // follows the dock setting, and the bullets stage follows the reading + // colors so a beam-splitter rig keeps one look across segment kinds. + // The cue card is clamped to the free right margin so it never covers + // the words at the eyeline; when that margin is too narrow for a + // readable card, it docks as a band whose bottom edge sits just above + // the eyeline (over already-read text), keeping the read line clear. + var cueSideVw = Number(s['margin-percent']) - 2; // minus the 1vw offsets + if (cueSideVw >= 8) { + P.cueRegion.classList.remove('dock-band'); + P.cueRegion.style.top = s['eyeline-percent'] + '%'; + P.cueRegion.style.bottom = ''; + P.cueRegion.style.maxWidth = 'min(' + cueSideVw + 'vw, 22rem)'; + } else { + P.cueRegion.classList.add('dock-band'); + P.cueRegion.style.top = 'auto'; + P.cueRegion.style.bottom = + 'calc(' + (100 - Number(s['eyeline-percent'])) + '% + 1rem)'; + P.cueRegion.style.maxWidth = ''; + } + P.rail.classList.toggle('dock-top', s['rail-dock'] !== 'bottom'); + P.rail.classList.toggle('dock-bottom', s['rail-dock'] === 'bottom'); + P.bstage.style.background = s['background-color']; + P.bstage.style.color = s['text-color']; + P.bstage.style.fontFamily = s['font-family']; } // Apply a settings mutation keeping the read position stable, since layout @@ -254,7 +293,8 @@ eyelineV: document.getElementById('set-eyeline-v'), eyelineStyle: document.getElementById('set-eyeline-style'), hideTakes: document.getElementById('set-hide-takes'), - showInvented: document.getElementById('set-show-invented') + showInvented: document.getElementById('set-show-invented'), + railDock: document.getElementById('set-rail-dock') }; function initFontStackSelect() { @@ -299,6 +339,7 @@ C.eyelineStyle.value = s['eyeline-style']; C.hideTakes.checked = !!s['hide-takes']; C.showInvented.checked = !!s['show-invented']; + C.railDock.value = s['rail-dock'] === 'bottom' ? 'bottom' : 'top'; } function wireSettingsControls() { @@ -352,6 +393,9 @@ C.showInvented.addEventListener('change', function () { changeSetting('show-invented', C.showInvented.checked); }); + C.railDock.addEventListener('change', function () { + changeSetting('rail-dock', C.railDock.value); + }); } function applyModeControls() { @@ -732,6 +776,9 @@ break; case 'ArrowRight': e.preventDefault(); + // Bullets rail view (producer mode): the clicker's forward key marks + // the current point covered instead of a meaningless paragraph jump. + if (bulletsViewActive()) { coverCurrentPoint(); break; } act(function () { jumpParagraphs(1); }, 'jump-words', 1); break; case 'Home': @@ -784,6 +831,30 @@ e.preventDefault(); setFollowEnabled(!followEnabled); break; + case 'g': + case 'G': + if (!producerActive || !producerState || producerState.live) break; + e.preventDefault(); + sendShow('go-live'); + break; + case 'h': + case 'H': + if (!producerActive || !producerState || !producerState.live) break; + e.preventDefault(); + sendShow(producerState.hold ? 'resume' : 'hold'); + break; + case 'n': + case 'N': + if (!producerActive) break; + e.preventDefault(); + producerAdvance(1); + break; + case 'p': + case 'P': + if (!producerActive) break; + e.preventDefault(); + producerAdvance(-1); + break; case '?': e.preventDefault(); togglePanel(els.helpOverlay); @@ -1267,6 +1338,293 @@ V.btnSim.addEventListener('click', runSim); V.pfSim.addEventListener('click', runSim); + // ---------- producer mode (Phase C) ---------- + + var P = { + rail: document.getElementById('prompter-rail'), + cueRegion: document.getElementById('cue-region'), + goLivePanel: document.getElementById('golive-panel'), + btnGoLive: document.getElementById('btn-golive'), + prodBadge: document.getElementById('prod-badge'), + btnHold: document.getElementById('btn-hold'), + btnEnd: document.getElementById('btn-end'), + bstage: document.getElementById('bullets-stage'), + bSeg: document.getElementById('bstage-seg'), + bCurrent: document.getElementById('bstage-current'), + bNext: document.getElementById('bstage-next'), + bRest: document.getElementById('bstage-rest'), + bDone: document.getElementById('bstage-done'), + prodSep: document.getElementById('prod-sep'), + prodH: document.getElementById('prod-h'), + rowRailDock: document.getElementById('row-rail-dock') + }; + + var producerActive = false; + var producerState = null; // latest producer frame or pre-show synth + var rundownInfo = null; // {rundown, ranges} from GET /api/rundown + var prevSegmentId = null; // for segment-handoff detection + var bulletsKey = ''; // rebuild the bullets DOM only on change + var handoffSentFor = null; // scripted segment already advanced past + var handoffArmed = false; // an anchor below the current segment's end + // was seen since it became current + var coverPendingKey = null; // covered sent, waiting for the next frame + var prodEndArmed = false; + var prodEndTimer = null; + + var prodRail = MC.rail.createRail(P.rail); + var cueCard = MC.rail.createCueCard(P.cueRegion); + + function sendShow(cmd) { ws.send({ type: 'show', cmd: cmd }); } + + function sendPoint(cmd, segId, idx) { + var m = { type: 'point', cmd: cmd, segment: segId }; + if (idx !== undefined && idx !== null) m.point = idx; + ws.send(m); + } + + function currentProdSegment() { + return producerState ? MC.rail.segById(producerState, producerState.current) : null; + } + + function bulletsViewActive() { + return producerActive && !P.bstage.classList.contains('hidden'); + } + + function activateProducer() { + if (producerActive) return; + producerActive = true; + P.prodSep.classList.remove('hidden'); + P.prodH.classList.remove('hidden'); + P.rowRailDock.classList.remove('hidden'); + P.prodBadge.classList.remove('hidden'); + if (!rundownInfo) fetchRundownInfo(); + } + + function fetchRundownInfo() { + MC.model.fetchRundown(token).then(function (resp) { + rundownInfo = MC.rail.normalizeRundown(resp); + // Pre-show seed: the server broadcasts producer frames only on + // change, so render the reconciled plan until the first one lands. + if (rundownInfo && !producerState) { + applyProducerState(MC.rail.preShowState(rundownInfo.rundown)); + } + }).catch(function () { /* producer frames still drive the rail */ }); + } + + function disarmProdEnd() { + prodEndArmed = false; + clearTimeout(prodEndTimer); + P.btnEnd.classList.remove('armed'); + P.btnEnd.textContent = 'end'; + } + + function renderProducerControls(state) { + var live = !!state.live; + var preShow = !live && (state['elapsed-s'] || 0) === 0; + P.goLivePanel.classList.toggle('hidden', + !(producerActive && preShow && ws.state.connected)); + P.prodBadge.textContent = live ? (state.hold ? 'HOLD' : 'LIVE') + : preShow ? 'PRE-SHOW' : 'ENDED'; + P.prodBadge.className = 'chip ' + (live ? (state.hold ? 'warn' : 'live') : ''); + P.btnHold.classList.toggle('hidden', !live); + P.btnHold.textContent = state.hold ? 'resume' : 'hold'; + P.btnEnd.classList.toggle('hidden', !live); + if (!live) disarmProdEnd(); + } + + // Manual segment handoff: n / p keys (and the remote's make-current). + function producerAdvance(delta) { + if (!producerState) return; + var segs = producerState.segments || []; + var idx = -1; + for (var i = 0; i < segs.length; i++) { + if (segs[i].id === producerState.current) { idx = i; break; } + } + var t = idx + delta; + if (t < 0 || t >= segs.length) { + toast('no ' + (delta > 0 ? 'next' : 'previous') + ' segment'); + return; + } + sendPoint('make-current', segs[t].id); + toast('segment: ' + (segs[t].title || segs[t].id)); + } + + // Bullets rail view: the clicker forward key covers the current point; + // once the segment is fully covered it advances to the next segment. + function coverCurrentPoint() { + var seg = currentProdSegment(); + if (!seg) return; + var idx = MC.rail.activePointIndex(seg); + if (idx < 0) { producerAdvance(1); return; } + var key = seg.id + ':' + idx; + if (coverPendingKey === key) return; // debounce until the next frame + coverPendingKey = key; + sendPoint('covered', seg.id, idx); + } + + // Scripted <-> bullets stage switching and the make-current scroll jump. + function onSegmentChange(seg) { + if (!seg) return; + if (seg.kind === 'bullets') { + // The scroll surface is covered by the bullets stage; stop the WPM + // integrator so elapsed reading time is not silently consumed. + if (drives() && engine.isPlaying()) engine.pause(); + return; + } + // Scripted: put the segment's first word on the eyeline. In voice + // follow the anchor is normally already there (the jump is a no-op); + // a make-current jump from the remote lands here too. + var range = rundownInfo && rundownInfo.ranges[seg.id]; + if (drives() && range && docIndex.words[range.start]) { + var span = docIndex.words[range.start]; + if (span.offsetParent !== null) { + engine.jumpToPx(span.offsetTop - eyelinePx()); + } + } + } + + function pointFlags(pt) { + return pt.covered ? 'c' : pt.skipped ? 's' : '.'; + } + + function updateBulletsStage(seg) { + var show = producerActive && !!seg && seg.kind === 'bullets'; + P.bstage.classList.toggle('hidden', !show); + if (!show) { bulletsKey = ''; return; } + + // Per-second refresh: segment title + replanned time left, colored. + var left = (seg['replanned-s'] || 0) - (seg['spent-s'] || 0); + P.bSeg.textContent = (seg.title || seg.id) + ' ' + + MC.rail.fmtClock(left) + ' left'; + P.bSeg.className = MC.rail.timingClass(seg.timing); + + var pts = seg.points || []; + var actIdx = MC.rail.activePointIndex(seg); + var nextIdx = -1; + if (actIdx >= 0) { + for (var i = actIdx + 1; i < pts.length; i++) { + if (!pts[i].covered && !pts[i].skipped) { nextIdx = i; break; } + } + } + var key = seg.id + ':' + actIdx + ':' + nextIdx + ':' + + pts.map(pointFlags).join(''); + if (key === bulletsKey) return; + bulletsKey = key; + + P.bCurrent.textContent = actIdx >= 0 ? pts[actIdx].text : ''; + P.bCurrent.classList.toggle('hidden', actIdx < 0); + P.bDone.classList.toggle('hidden', actIdx >= 0 || !pts.length); + if (nextIdx >= 0) { + P.bNext.textContent = pts[nextIdx].text; + P.bNext.classList.remove('hidden'); + } else { + P.bNext.classList.add('hidden'); + } + while (P.bRest.firstChild) P.bRest.removeChild(P.bRest.firstChild); + for (var j = 0; j < pts.length; j++) { + if (j === actIdx || j === nextIdx) continue; + var li = document.createElement('li'); + li.textContent = pts[j].text || ''; + if (pts[j].covered) li.className = 'covered'; + else if (pts[j].skipped) li.className = 'skipped'; + P.bRest.appendChild(li); + } + } + + function applyProducerState(state) { + if (!state) return; + producerState = state; + coverPendingKey = null; + prodRail.update(state); + P.rail.classList.remove('hidden'); + renderProducerControls(state); + var seg = currentProdSegment(); + if (state.current !== prevSegmentId) { + prevSegmentId = state.current; + handoffSentFor = null; + handoffArmed = false; + onSegmentChange(seg); + } + updateBulletsStage(seg); + } + + // Anchor-driven handoff. Fires only when (a) voice follow drives the + // leader, (b) the rail's current segment is scripted, (c) the committed + // anchor CROSSED that segment's word-end while it was current (an anchor + // below the end must be seen first, so a stale anchor held over from a + // previous position, e.g. right after a backward make-current, can never + // fire it), and (d) at most once per segment per time it becomes current + // (both flags reset in applyProducerState when the current id changes). + // The server re-anchors the aligner whenever a scripted segment becomes + // current, so the arming frame arrives within a beat of any manual jump. + function maybeAnchorHandoff() { + if (!producerActive || !producerState || !followEnabled || !isLeader()) return; + if (!producerState.live) return; + var seg = currentProdSegment(); + if (!seg || seg.kind !== 'scripted' || handoffSentFor === seg.id) return; + var range = rundownInfo && rundownInfo.ranges[seg.id]; + if (!range || typeof range.end !== 'number') return; + // word-end is half-open ([start, end) in global word indices), so the + // segment's last word is end - 1: fire when the anchor commits it. + if (lastAnchor < Math.max(range.end - 1, range.start)) { + // Behind the end while this segment is current: any later crossing + // is genuine reading progress, so the handoff is now armed. + handoffArmed = true; + return; + } + if (!handoffArmed) return; // stale anchor from before this segment was current + var segs = producerState.segments || []; + for (var i = 0; i < segs.length; i++) { + if (segs[i].id === seg.id) { + if (i + 1 < segs.length) { + handoffSentFor = seg.id; + handoffArmed = false; + sendPoint('make-current', segs[i + 1].id); + toast('segment done: ' + (segs[i + 1].title || segs[i + 1].id)); + } + return; + } + } + } + + P.btnGoLive.addEventListener('click', function () { sendShow('go-live'); }); + + P.btnHold.addEventListener('click', function () { + if (producerState) sendShow(producerState.hold ? 'resume' : 'hold'); + }); + + P.btnEnd.addEventListener('click', function () { + if (!prodEndArmed) { + prodEndArmed = true; + P.btnEnd.classList.add('armed'); + P.btnEnd.textContent = 'really end?'; + clearTimeout(prodEndTimer); + prodEndTimer = setTimeout(disarmProdEnd, 3000); + return; + } + disarmProdEnd(); + sendShow('end'); + }); + + ws.on('producer', function (msg) { + if (!msg.state) return; + activateProducer(); + applyProducerState(msg.state); + }); + + ws.on('cue', cueCard.onCue); + ws.on('cue-clear', cueCard.onClear); + + // Runs after the Phase B anchor listener (registration order), so + // lastAnchor is already updated when the handoff check reads it. + ws.on('anchor', function () { maybeAnchorHandoff(); }); + + ws.onStatus(function () { + // Keep the GO LIVE button honest across reconnects (it needs a live + // socket to mean anything). + if (producerState) renderProducerControls(producerState); + }); + // ---------- boot ---------- initFontStackSelect(); @@ -1282,6 +1640,7 @@ C.wpm.value = wpm; } asrAvailable = !!(state && state.asr && state.asr.available); + if (state && state.producer && state.producer.active) activateProducer(); syncVoiceControls(); }).catch(function () { /* defaults are fine */ }).then(loadSource); })(); diff --git a/skills/mc-prompter/scripts/server/static/js/rail.js b/skills/mc-prompter/scripts/server/static/js/rail.js new file mode 100644 index 0000000..bdaf462 --- /dev/null +++ b/skills/mc-prompter/scripts/server/static/js/rail.js @@ -0,0 +1,302 @@ +/* mc-prompter producer rail + cue card (classic script, attaches to + * window.MC.rail). Shared by /prompt, /overlay, and /remote. + * + * Wire shapes (Phase C contract): + * <- producer {type:"producer", state:{ + * live, hold, "elapsed-s", "remaining-s", + * "show-state": "green"|"yellow"|"red", + * current: "g1", + * "next-point": {segment, idx, text} | null, + * segments: [{id, title, kind, "planned-s", "replanned-s", "spent-s", + * state: "done"|"current"|"pending", + * timing: "green"|"yellow"|"red", + * points: [{text, covered, skipped}]}], + * drop: {segment, text} | null}} + * <- cue {type:"cue", id, tier:"card"|"attention", text} + * <- cue-clear {type:"cue-clear", id} + * + * createRail(container): the ambient strip. Show clock + green/yellow/red + * show state, LIVE/HOLD/PRE badge, current segment title + its replanned + * time left (colored by the segment's timing), NEXT point text, and one + * progress dot per segment. No motion, glanceable, dark-theme. The DOM is + * built once; update(state) only mutates text and classes. + * + * createCueCard(container): the single cue region. One active cue at a + * time (a new cue replaces the old one; the server enforces the budget and + * the one-active-cue rule). Card tier appears quietly; attention tier gets + * the high-contrast flash pulse. Cleared on the matching cue-clear frame, + * with a defensive local expiry in case a clear frame is lost. + * + * Helpers for the pages: + * normalizeRundown(resp): GET /api/rundown response -> {rundown, ranges} + * where ranges maps segment id -> {start, end} global word indices + * (scripted segments only carry meaningful ranges; bullets segments + * contribute no words). Accepts both a nested {rundown, segments: + * [word ranges]} response and a flat parse_rundown dict with + * word-start/word-end merged into each segment. + * preShowState(rundown): synthesize a pre-show producer state from the + * parsed rundown so the rail and point lists render before the first + * producer frame arrives (the server broadcasts only on change). + * activePointIndex(segment): first uncovered, unskipped point index, -1 + * when the segment is fully covered or has no points. + */ +(function () { + 'use strict'; + window.MC = window.MC || {}; + + // Standalone clock formatter so /overlay does not need model.js just for + // this. Negative seconds render as +m:ss (time over). + function fmtClock(seconds) { + if (seconds === null || seconds === undefined || isNaN(seconds)) return '--:--'; + var neg = seconds < 0; + var t = Math.round(Math.abs(seconds)); + var h = Math.floor(t / 3600); + var m = Math.floor((t % 3600) / 60); + var s = t % 60; + var mm = (h > 0 && m < 10 ? '0' : '') + m; + var ss = (s < 10 ? '0' : '') + s; + var out = h > 0 ? h + ':' + mm + ':' + ss : mm + ':' + ss; + return neg ? '+' + out : out; + } + + function el(tag, className, text) { + var e = document.createElement(tag); + if (className) e.className = className; + if (text !== undefined) e.textContent = text; + return e; + } + + var TIMING_CLASSES = ['t-green', 't-yellow', 't-red']; + + function timingClass(t) { + return t === 'red' ? 't-red' : t === 'yellow' ? 't-yellow' : 't-green'; + } + + function setTiming(node, t) { + for (var i = 0; i < TIMING_CLASSES.length; i++) node.classList.remove(TIMING_CLASSES[i]); + node.classList.add(timingClass(t)); + } + + function segById(state, id) { + var segs = (state && state.segments) || []; + for (var i = 0; i < segs.length; i++) { + if (segs[i].id === id) return segs[i]; + } + return null; + } + + // First uncovered, unskipped point in the segment, -1 when none remain. + function activePointIndex(seg) { + var pts = (seg && seg.points) || []; + for (var i = 0; i < pts.length; i++) { + if (!pts[i].covered && !pts[i].skipped) return i; + } + return -1; + } + + // ---------- the ambient rail ---------- + + function createRail(container) { + var root = el('div', 'mc-rail hidden'); + var liveBadge = el('span', 'r-live chip', 'PRE'); + var clockEl = el('span', 'r-clock mono t-green', '00:00 / --:--'); + var segEl = el('span', 'r-seg'); + var segTitle = el('span', 'r-seg-title', ''); + var segTime = el('span', 'r-seg-time mono t-green', ''); + segEl.appendChild(segTitle); + segEl.appendChild(segTime); + var nextEl = el('span', 'r-next', ''); + var dotsEl = el('span', 'r-dots'); + root.appendChild(liveBadge); + root.appendChild(clockEl); + root.appendChild(segEl); + root.appendChild(nextEl); + root.appendChild(dotsEl); + container.appendChild(root); + + var dotCount = -1; + + function ensureDots(n) { + if (n === dotCount) return; + while (dotsEl.firstChild) dotsEl.removeChild(dotsEl.firstChild); + for (var i = 0; i < n; i++) dotsEl.appendChild(el('span', 'r-dot')); + dotCount = n; + } + + function update(state) { + if (!state) { clear(); return; } + root.classList.remove('hidden'); + + // LIVE / HOLD / PRE / ENDED badge. After end-show the clock stops with + // elapsed still on it, which is how ENDED is told apart from PRE. + var badgeText, badgeClass; + if (state.live && state.hold) { badgeText = 'HOLD'; badgeClass = 'chip warn'; } + else if (state.live) { badgeText = 'LIVE'; badgeClass = 'chip bad'; } + else if ((state['elapsed-s'] || 0) > 0) { badgeText = 'ENDED'; badgeClass = 'chip'; } + else { badgeText = 'PRE'; badgeClass = 'chip'; } + liveBadge.textContent = badgeText; + liveBadge.className = 'r-live ' + badgeClass; + + clockEl.textContent = + fmtClock(state['elapsed-s']) + ' / ' + fmtClock(state['remaining-s']); + setTiming(clockEl, state['show-state']); + + var cur = segById(state, state.current); + if (cur) { + segTitle.textContent = cur.title || cur.id; + var left = (cur['replanned-s'] || 0) - (cur['spent-s'] || 0); + segTime.textContent = fmtClock(left >= 0 ? left : left); + setTiming(segTime, cur.timing); + } else { + segTitle.textContent = ''; + segTime.textContent = ''; + } + + var np = state['next-point']; + nextEl.textContent = np && np.text ? 'NEXT: ' + np.text : ''; + + var segs = state.segments || []; + ensureDots(segs.length); + var dots = dotsEl.children; + for (var i = 0; i < segs.length; i++) { + var d = dots[i]; + d.className = 'r-dot ' + (segs[i].state || 'pending') + ' ' + timingClass(segs[i].timing); + d.title = (segs[i].title || segs[i].id) + ' (' + (segs[i].state || 'pending') + ')'; + } + } + + function clear() { + root.classList.add('hidden'); + } + + return { update: update, clear: clear, el: root }; + } + + // ---------- the cue card ---------- + + // The server auto-expires cues at 15 s and broadcasts cue-clear; the + // local expiry is a fallback for a lost clear frame, not the contract. + var CUE_FALLBACK_EXPIRY_MS = 30000; + + function createCueCard(container) { + var root = el('div', 'mc-cue hidden'); + var textEl = el('div', 'mc-cue-text', ''); + root.appendChild(textEl); + container.appendChild(root); + + var currentId = null; + var expireTimer = null; + + function hide() { + root.classList.add('hidden'); + currentId = null; + clearTimeout(expireTimer); + } + + function onCue(msg) { + if (!msg || !msg.text) return; + currentId = msg.id !== undefined ? msg.id : null; + textEl.textContent = msg.text; + root.classList.remove('tier-card', 'tier-attention', 'hidden'); + // Force a reflow so the attention pulse animation restarts when a new + // attention cue replaces a previous one. + void root.offsetWidth; + root.classList.add(msg.tier === 'attention' ? 'tier-attention' : 'tier-card'); + clearTimeout(expireTimer); + expireTimer = setTimeout(hide, CUE_FALLBACK_EXPIRY_MS); + } + + function onClear(msg) { + // Clear only the active cue: a stale clear for a cue already replaced + // must not kill its successor. + if (msg && msg.id !== undefined && currentId !== null && msg.id !== currentId) return; + hide(); + } + + return { onCue: onCue, onClear: onClear, clear: hide, el: root }; + } + + // ---------- rundown helpers ---------- + + function normalizeRundown(resp) { + if (!resp) return null; + // A response carrying an explicit rundown key is the nested server form; + // {"rundown": null} means no rundown is loaded (producer off). + var rundown = Object.prototype.hasOwnProperty.call(resp, 'rundown') + ? resp.rundown + : resp; + if (!rundown || !rundown.segments) return null; + var ranges = {}; + var i, e; + // Nested form: {rundown: {...}, segments: [{id, word-start, word-end}]}. + var rangeList = resp.rundown ? resp.segments : null; + rangeList = rangeList || resp['word-ranges'] || null; + if (rangeList) { + for (i = 0; i < rangeList.length; i++) { + e = rangeList[i]; + if (e && e.id !== undefined && e['word-start'] !== undefined) { + ranges[e.id] = { start: e['word-start'], end: e['word-end'] }; + } + } + } + // Flat form: word-start/word-end merged into the rundown segments. + for (i = 0; i < rundown.segments.length; i++) { + e = rundown.segments[i]; + if (e && e['word-start'] !== undefined && !ranges[e.id]) { + ranges[e.id] = { start: e['word-start'], end: e['word-end'] }; + } + } + return { rundown: rundown, ranges: ranges }; + } + + // Synthesize the wire-shape producer state for the pre-show rail: the + // server broadcasts producer frames only on change, so a page that joins + // before go-live renders this until the first frame lands. + function preShowState(rundown) { + if (!rundown || !rundown.segments) return null; + var segments = []; + var nextPoint = null; + for (var i = 0; i < rundown.segments.length; i++) { + var s = rundown.segments[i]; + var points = []; + var srcPts = s.points || []; + for (var j = 0; j < srcPts.length; j++) { + points.push({ text: srcPts[j].text, covered: false, skipped: false }); + if (!nextPoint) nextPoint = { segment: s.id, idx: j, text: srcPts[j].text }; + } + segments.push({ + id: s.id, + title: s.title, + kind: s.kind, + 'planned-s': s['planned-s'], + 'replanned-s': s['planned-s'], + 'spent-s': 0, + state: i === 0 ? 'current' : 'pending', + timing: 'green', + points: points + }); + } + return { + live: false, + hold: false, + 'elapsed-s': 0, + 'remaining-s': rundown['duration-s'] || 0, + 'show-state': 'green', + current: segments.length ? segments[0].id : null, + 'next-point': nextPoint, + segments: segments, + drop: null + }; + } + + window.MC.rail = { + createRail: createRail, + createCueCard: createCueCard, + normalizeRundown: normalizeRundown, + preShowState: preShowState, + activePointIndex: activePointIndex, + segById: segById, + timingClass: timingClass, + fmtClock: fmtClock + }; +})(); diff --git a/skills/mc-prompter/scripts/server/static/js/remote.js b/skills/mc-prompter/scripts/server/static/js/remote.js index a4356a6..e08be8e 100644 --- a/skills/mc-prompter/scripts/server/static/js/remote.js +++ b/skills/mc-prompter/scripts/server/static/js/remote.js @@ -4,6 +4,14 @@ * 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. + * + * Producer tab (Phase C): appears when a rundown session is running + * (producer frames arrive, or /api/state reports producer.active). Carries + * GO LIVE / hold / end show controls ({"type":"show","cmd":...}), a live + * rail summary, and the per-segment point list with covered / skip / + * make-current taps ({"type":"point","cmd":...,"segment","point"}). The + * human is the final authority: one tap rescues any model misjudgment. + * End show is two-tap armed so a pocket brush cannot kill a live show. */ (function () { 'use strict'; @@ -26,7 +34,17 @@ btnPrev: document.getElementById('btn-prev'), btnNext: document.getElementById('btn-next'), sectionList: document.getElementById('section-list'), - secCount: document.getElementById('sec-count') + secCount: document.getElementById('sec-count'), + tabs: document.getElementById('tabs'), + tabTransport: document.getElementById('tab-transport'), + tabProducer: document.getElementById('tab-producer'), + viewTransport: document.getElementById('view-transport'), + viewProducer: document.getElementById('view-producer'), + btnGoLive: document.getElementById('btn-golive'), + btnHold: document.getElementById('btn-hold'), + btnEnd: document.getElementById('btn-end'), + prodRail: document.getElementById('prod-rail'), + prodSegList: document.getElementById('prod-seg-list') }; var token = new URLSearchParams(location.search).get('token') || null; @@ -169,10 +187,250 @@ els.btnPrev.addEventListener('click', function () { jumpRelativeSection(-1); }); els.btnNext.addEventListener('click', function () { jumpRelativeSection(1); }); + // ---------- producer tab (Phase C) ---------- + + var producerActive = false; + var producerState = null; // latest producer frame or pre-show synth + var prodBuildKey = ''; // seg ids + point counts: the DOM is built + // once per rundown and updated in place + var segEls = {}; // segment id -> node refs for updates + var endArmed = false; + var endArmTimer = null; + + var prodRail = MC.rail.createRail(els.prodRail); + + function setTab(name) { + var producer = name === 'producer'; + els.tabProducer.classList.toggle('active', producer); + els.tabTransport.classList.toggle('active', !producer); + els.viewProducer.classList.toggle('hidden', !producer); + els.viewTransport.classList.toggle('hidden', producer); + } + + els.tabTransport.addEventListener('click', function () { setTab('transport'); }); + els.tabProducer.addEventListener('click', function () { setTab('producer'); }); + + function activateProducer() { + if (producerActive) return; + producerActive = true; + els.tabs.classList.remove('hidden'); + } + + function sendShow(cmd) { ws.send({ type: 'show', cmd: cmd }); } + + function sendPoint(cmd, segId, idx) { + var m = { type: 'point', cmd: cmd, segment: segId }; + if (idx !== undefined && idx !== null) m.point = idx; + ws.send(m); + } + + function disarmEnd() { + endArmed = false; + clearTimeout(endArmTimer); + els.btnEnd.classList.remove('armed'); + els.btnEnd.textContent = 'end show'; + } + + els.btnGoLive.addEventListener('click', function () { sendShow('go-live'); }); + + els.btnHold.addEventListener('click', function () { + if (!producerState) return; + sendShow(producerState.hold ? 'resume' : 'hold'); + }); + + els.btnEnd.addEventListener('click', function () { + if (!endArmed) { + endArmed = true; + els.btnEnd.classList.add('armed'); + els.btnEnd.textContent = 'really end?'; + clearTimeout(endArmTimer); + endArmTimer = setTimeout(disarmEnd, 3000); + return; + } + disarmEnd(); + sendShow('end'); + }); + + function renderShowControls(state) { + var live = !!state.live; + // Ended is final: producer.go_live is a no-op once the show has ended, + // so the button must say so honestly instead of promising a restart. + var ended = !live && (state['elapsed-s'] || 0) > 0; + els.btnGoLive.disabled = live || ended; + els.btnGoLive.textContent = live + ? (state.hold ? 'ON HOLD' : 'LIVE') + : ended ? 'SHOW ENDED' : 'GO LIVE'; + els.btnHold.disabled = !live; + els.btnHold.textContent = state.hold ? 'resume' : 'hold'; + els.btnHold.classList.toggle('holding', !!state.hold); + els.btnEnd.disabled = !live; + if (!live) disarmEnd(); + } + + // The point list keeps one stable DOM node per segment and per point, + // keyed by segment id and point index (point counts never change during + // a show). Every frame updates classes, labels, and button visibility in + // place, so an auto-coverage flip landing mid-tap can never shift the + // rows and land the tap on a different point's button. + function buildKey(state) { + var parts = []; + var segs = state.segments || []; + for (var i = 0; i < segs.length; i++) { + parts.push(segs[i].id + ':' + (segs[i].points || []).length); + } + return parts.join('|'); + } + + function segTimeText(seg) { + var left = (seg['replanned-s'] || 0) - (seg['spent-s'] || 0); + return MC.rail.fmtClock(left) + ' left'; + } + + // Taps are delegated to the list root and resolved from data attributes + // on the button AT TAP TIME, so even if a producer frame rebuilds the + // list mid-tap the command goes to whatever the finger is actually on. + els.prodSegList.addEventListener('click', function (e) { + var btn = e.target && e.target.closest ? e.target.closest('button[data-act]') : null; + if (!btn) return; + var act = btn.dataset.act; + if (act === 'make-current') sendPoint('make-current', btn.dataset.seg); + else sendPoint(act, btn.dataset.seg, Number(btn.dataset.point)); + }); + + function buildSegList(state) { + var root = els.prodSegList; + while (root.firstChild) root.removeChild(root.firstChild); + segEls = {}; + var segs = state.segments || []; + + for (var i = 0; i < segs.length; i++) { + var seg = segs[i]; + var refs = { points: [] }; + + var box = document.createElement('div'); + box.className = 'p-seg'; + + var head = document.createElement('div'); + head.className = 'p-seg-head'; + var title = document.createElement('span'); + title.className = 'p-seg-title'; + title.textContent = seg.title || seg.id; + var time = document.createElement('span'); + time.className = 'p-seg-time'; + var make = document.createElement('button'); + make.className = 'p-make'; + make.textContent = 'make current'; + make.dataset.act = 'make-current'; + make.dataset.seg = seg.id; + head.appendChild(title); + head.appendChild(time); + head.appendChild(make); + box.appendChild(head); + refs.box = box; + refs.time = time; + refs.make = make; + + var pts = seg.points || []; + if (pts.length) { + var ul = document.createElement('ul'); + ul.className = 'p-points'; + for (var j = 0; j < pts.length; j++) { + var li = document.createElement('li'); + li.className = 'p-point'; + var text = document.createElement('span'); + text.className = 'p-text'; + text.textContent = pts[j].text || ''; + li.appendChild(text); + var flag = document.createElement('span'); + flag.className = 'p-flag hidden'; + li.appendChild(flag); + var actions = document.createElement('span'); + actions.className = 'p-actions'; + var done = document.createElement('button'); + done.textContent = 'done'; + done.dataset.act = 'covered'; + done.dataset.seg = seg.id; + done.dataset.point = String(j); + var skip = document.createElement('button'); + skip.textContent = 'skip'; + skip.dataset.act = 'skip'; + skip.dataset.seg = seg.id; + skip.dataset.point = String(j); + actions.appendChild(done); + actions.appendChild(skip); + li.appendChild(actions); + ul.appendChild(li); + refs.points.push({ li: li, flag: flag, actions: actions }); + } + box.appendChild(ul); + } + + root.appendChild(box); + segEls[seg.id] = refs; + } + } + + function updateSegList(state) { + var segs = state.segments || []; + var np = state['next-point']; + for (var i = 0; i < segs.length; i++) { + var seg = segs[i]; + var refs = segEls[seg.id]; + if (!refs) continue; + refs.box.className = 'p-seg ' + (seg.state || 'pending'); + refs.time.className = 'p-seg-time ' + MC.rail.timingClass(seg.timing); + refs.time.textContent = segTimeText(seg); + refs.make.classList.toggle('hidden', seg.state === 'current'); + var pts = seg.points || []; + for (var j = 0; j < refs.points.length && j < pts.length; j++) { + var pt = pts[j]; + var pr = refs.points[j]; + var cls = 'p-point'; + if (pt.covered) cls += ' covered'; + else if (pt.skipped) cls += ' skipped'; + else if (np && np.segment === seg.id && np.idx === j) cls += ' active'; + pr.li.className = cls; + var settled = !!(pt.covered || pt.skipped); + pr.flag.classList.toggle('hidden', !settled); + pr.flag.textContent = settled ? (pt.covered ? 'covered' : 'skipped') : ''; + pr.actions.classList.toggle('hidden', settled); + } + } + } + + function renderProducer(state) { + if (!state) return; + producerState = state; + prodRail.update(state); + renderShowControls(state); + var key = buildKey(state); + if (key !== prodBuildKey) { + prodBuildKey = key; + buildSegList(state); + } + updateSegList(state); + } + + ws.on('producer', function (msg) { + if (!msg.state) return; + activateProducer(); + renderProducer(msg.state); + }); + // ---------- boot ---------- loadSections(); MC.model.fetchState(token).then(function (state) { if (state && state.snapshot) applyState(state.snapshot); + if (state && state.producer && state.producer.active) { + activateProducer(); + // Pre-show seed until the first producer frame arrives. + return MC.model.fetchRundown(token).then(function (resp) { + var info = MC.rail.normalizeRundown(resp); + if (info && !producerState) { + renderProducer(MC.rail.preShowState(info.rundown)); + } + }); + } }).catch(function () { /* WS state frames will fill in */ }); })(); diff --git a/skills/mc-prompter/scripts/server/static/js/settings.js b/skills/mc-prompter/scripts/server/static/js/settings.js index 079b3c2..57763b8 100644 --- a/skills/mc-prompter/scripts/server/static/js/settings.js +++ b/skills/mc-prompter/scripts/server/static/js/settings.js @@ -15,6 +15,7 @@ * countdown-seconds number countdown before scroll starts (0 disables) * hide-takes bool hide TAKE paragraphs entirely * show-invented bool show the invented badge styling + * rail-dock string "top" | "bottom", producer rail position * * Server config defaults (from GET /api/state .config) may be passed to * load() as overrides; stored per-device values still win over them. @@ -38,7 +39,8 @@ 'eyeline-style': 'line', 'countdown-seconds': 3, 'hide-takes': false, - 'show-invented': true + 'show-invented': true, + 'rail-dock': 'top' }; // A few known-safe offline font stacks for the settings drawer select. diff --git a/skills/mc-prompter/scripts/server/static/overlay.html b/skills/mc-prompter/scripts/server/static/overlay.html index d0ae400..ee1808c 100644 --- a/skills/mc-prompter/scripts/server/static/overlay.html +++ b/skills/mc-prompter/scripts/server/static/overlay.html @@ -10,8 +10,7 @@ </head> <body> - <!-- Phase A placeholder: a connect badge that hides itself. The producer - ambient rail and cue cards (Phase C) mount into #rail. --> + <!-- Connect badge (Phase A): hides itself after 5 s. --> <div id="badge"><span class="dot"></span> mc-prompter session connected</div> <!-- Phase B: voice-follow indicator, hidden until anchor/vad frames flow. @@ -19,9 +18,15 @@ <div id="voice-badge" class="hidden"><span class="dot" id="voice-dot"></span> <span id="voice-text">voice</span></div> + <!-- Phase C: the producer ambient rail and the cue card. Both stay empty + and invisible until a rundown session is running; the page background + is fully transparent for OBS. --> <div id="rail"></div> + <div id="cue"></div> <script src="/static/js/ws.js"></script> + <script src="/static/js/model.js"></script> + <script src="/static/js/rail.js"></script> <script src="/static/js/overlay.js"></script> </body> </html> diff --git a/skills/mc-prompter/scripts/server/static/prompt.html b/skills/mc-prompter/scripts/server/static/prompt.html index 969a526..0db3173 100644 --- a/skills/mc-prompter/scripts/server/static/prompt.html +++ b/skills/mc-prompter/scripts/server/static/prompt.html @@ -15,6 +15,29 @@ <div id="script"></div> </div> <div id="eyeline" class="style-line"></div> + + <!-- Phase C: large-type rail view for bullets segments. Lives inside + the stage so mirror flips apply to it like the scroll surface. --> + <div id="bullets-stage" class="hidden"> + <div id="bstage-seg" class="dim"></div> + <div id="bstage-current"></div> + <div id="bstage-next" class="hidden"></div> + <ul id="bstage-rest"></ul> + <div id="bstage-done" class="hidden">all points covered, move on</div> + </div> + </div> + + <!-- Phase C: producer ambient rail, docked top (below the HUD) or bottom + per the rail-dock setting. --> + <div id="prompter-rail" class="dock-top hidden"></div> + + <!-- Phase C: the single cue-card region, near the eyeline in the right + margin, never over the text column at default margins. --> + <div id="cue-region"></div> + + <!-- Phase C: pre-show GO LIVE control (producer sessions only). --> + <div id="golive-panel" class="hidden"> + <button id="btn-golive">GO LIVE</button> </div> <header id="hud"> @@ -23,6 +46,9 @@ <span id="role-badge" class="chip warn hidden">display only</span> <button id="btn-toggle">play</button> <button id="btn-restart">restart</button> + <span id="prod-badge" class="chip hidden">PRE</span> + <button id="btn-hold" class="hidden">hold</button> + <button id="btn-end" class="hidden">end</button> </div> <div class="hud-group"> <span class="clock"><span id="clock-elapsed">00:00</span> <span class="dim">/</span> <span id="clock-remaining">--:--</span></span> @@ -140,6 +166,16 @@ <h2 id="voice-h">Voice follow</h2> <input type="checkbox" id="set-follow"> </div> + <hr class="sep hidden" id="prod-sep"> + <h2 id="prod-h" class="hidden">Producer</h2> + <div class="row hidden" id="row-rail-dock"> + <label for="set-rail-dock">Rail position</label> + <select id="set-rail-dock"> + <option value="top">top</option> + <option value="bottom">bottom</option> + </select> + </div> + <hr class="sep"> <h2>Script markers</h2> <div class="row"> @@ -169,6 +205,9 @@ <h2>Keyboard shortcuts</h2> <tr><td><span class="kbd">s</span></td><td>section list</td></tr> <tr><td><span class="kbd">d</span></td><td>settings drawer</td></tr> <tr><td><span class="kbd">v</span></td><td>toggle voice follow (this machine only)</td></tr> + <tr><td><span class="kbd">g</span></td><td>GO LIVE (producer mode, pre-show)</td></tr> + <tr><td><span class="kbd">h</span></td><td>hold / resume the show clock (producer mode)</td></tr> + <tr><td><span class="kbd">n</span> / <span class="kbd">p</span></td><td>next / previous rundown segment (producer mode)</td></tr> <tr><td><span class="kbd">mouse wheel</span></td><td>speed up / down 2 wpm</td></tr> <tr><td><span class="kbd">click a word</span></td><td>jump the eyeline to that word (re-anchors in voice follow)</td></tr> <tr><td><span class="kbd">?</span></td><td>this help</td></tr> @@ -227,6 +266,7 @@ <h2>Session token rejected</h2> <script src="/static/js/model.js"></script> <script src="/static/js/audio.js"></script> <script src="/static/js/scroll.js"></script> + <script src="/static/js/rail.js"></script> <script src="/static/js/prompt.js"></script> </body> </html> diff --git a/skills/mc-prompter/scripts/server/static/remote.html b/skills/mc-prompter/scripts/server/static/remote.html index 38c7762..1e9838d 100644 --- a/skills/mc-prompter/scripts/server/static/remote.html +++ b/skills/mc-prompter/scripts/server/static/remote.html @@ -25,31 +25,63 @@ <h1>mc-prompter remote</h1> <span><span id="conn" class="dot"></span> <span id="session" class="dim mono"></span></span> </header> - <div id="status"> - <div class="stat"><span class="v" id="st-elapsed">00:00</span><span class="k">elapsed</span></div> - <div class="stat"><span class="v" id="st-remaining">--:--</span><span class="k">remaining</span></div> - <div class="stat"><span class="v" id="st-wpm">--</span><span class="k">wpm</span></div> - </div> + <!-- Tab bar: the producer tab appears only when a rundown session is + running (Phase C). --> + <nav id="tabs" class="hidden"> + <button id="tab-transport" class="tab active">transport</button> + <button id="tab-producer" class="tab">producer</button> + </nav> + + <div id="view-transport"> + <div id="status"> + <div class="stat"><span class="v" id="st-elapsed">00:00</span><span class="k">elapsed</span></div> + <div class="stat"><span class="v" id="st-remaining">--:--</span><span class="k">remaining</span></div> + <div class="stat"><span class="v" id="st-wpm">--</span><span class="k">wpm</span></div> + </div> + + <div id="posbar"><div class="fill" id="pos-fill"></div></div> - <div id="posbar"><div class="fill" id="pos-fill"></div></div> + <div id="controls"> + <button id="btn-toggle" class="big span2">play</button> + <button id="btn-slower" class="big">−2 wpm</button> + <button id="btn-faster" class="big">+2 wpm</button> + <button id="btn-prev" class="big">← section</button> + <button id="btn-next" class="big">section →</button> + <button id="btn-restart" class="span2">restart from top</button> + </div> - <div id="controls"> - <button id="btn-toggle" class="big span2">play</button> - <button id="btn-slower" class="big">−2 wpm</button> - <button id="btn-faster" class="big">+2 wpm</button> - <button id="btn-prev" class="big">← section</button> - <button id="btn-next" class="big">section →</button> - <button id="btn-restart" class="span2">restart from top</button> + <div id="section-panel" class="panel"> + <h2>Sections <span id="sec-count" class="dim"></span></h2> + <ul id="section-list"></ul> + </div> </div> - <div id="section-panel" class="panel"> - <h2>Sections <span id="sec-count" class="dim"></span></h2> - <ul id="section-list"></ul> + <!-- Phase C: producer controls. GO LIVE / hold / end, the live rail + summary, and the per-segment point list with covered / skip / + make-current taps (the human overrides the model with one tap). --> + <div id="view-producer" class="hidden"> + <div id="prod-controls"> + <button id="btn-golive" class="big span2">GO LIVE</button> + <button id="btn-hold" class="big" disabled>hold</button> + <button id="btn-end" class="big" disabled>end show</button> + </div> + + <div id="prod-rail"></div> + + <div id="prod-segments" class="panel"> + <h2>Rundown</h2> + <div id="prod-seg-list"></div> + <p class="note-small"> + Tap done when a point was covered, skip to drop it from the plan, + or make current to jump the show to a segment. + </p> + </div> </div> </div> <script src="/static/js/ws.js"></script> <script src="/static/js/model.js"></script> + <script src="/static/js/rail.js"></script> <script src="/static/js/remote.js"></script> </body> </html> From 5345563a44459113aa2643e4f9fd9a4c28925c10 Mon Sep 17 00:00:00 2001 From: Brian Madison <bmadcode@gmail.com> Date: Fri, 10 Jul 2026 01:14:10 -0500 Subject: [PATCH 4/5] Integrate mc-prompter across the module and add producer-mode docs --- README.md | 4 +- TODO.md | 8 ++++ docs/user-guide.md | 42 ++++++++++++++++++- skills/mc-agent/customize.toml | 5 +++ skills/mc-pipeline/PIPELINE.md | 4 +- skills/mc-prompter/SKILL.md | 13 +++--- skills/mc-prompter/customize.toml | 23 ++++++++++ skills/mc-script/SKILL.md | 2 +- skills/mc-setup/SKILL.md | 11 ++++- skills/mc-setup/assets/rundown-template.md | 39 +++++++++++++++++ skills/mc-setup/customize.toml | 40 ++++++++++++++++++ skills/mc-setup/scripts/check_deps.py | 1 + .../mc-setup/scripts/tests/test-check_deps.py | 10 +++++ skills/module-help.csv | 2 +- 14 files changed, 192 insertions(+), 12 deletions(-) create mode 100644 skills/mc-setup/assets/rundown-template.md diff --git a/README.md b/README.md index 7bfab67..fb35e72 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Seven ship by default: talking-head, screen-tutorial (real UI only, generated b- ## The skills -16 skills, each self-contained: a skill ships its own defaults (`customize.toml`), scripts, and knowledge, and reads only its own folder, the installed BMad core scripts, and your project files. +17 skills, each self-contained: a skill ships its own defaults (`customize.toml`), scripts, and knowledge, and reads only its own folder, the installed BMad core scripts, and your project files. | Skill | What it does | |---|---| @@ -130,6 +130,7 @@ Seven ship by default: talking-head, screen-tutorial (real UI only, generated b- | mc-ograf | Editable broadcast graphics (DaVinci Resolve 21+ and OBS/SPX-GC) | | mc-assets | Farm b-roll stills/clips via your registered CLI tools (metered APIs opt-in), under generative-editing safety rules | | mc-audio | Farm sound, local-first: TTS narration and two-host dialogue (Kokoro-82M), instrumental beds (MusicGen-small), SFX (AudioLDM2); paid lanes opt-in | +| mc-prompter | Browser teleprompter for the record stage and standalone shows: voice-follow scrolling (local streaming ASR) and producer mode (rundown-driven live shows with a timing rail, broadcast cues, and an OBS overlay; local Ollama opt-in) | | mc-package | Titles, thumbnails (verified at 120px), description, chapters, series A/B pairs, live-event mode | | mc-stream-pack | A complete branded OBS livestream asset pack | | mc-retro | Your post-publish notes edit the pipeline's own files, plus the post-publish wrap lane | @@ -144,6 +145,7 @@ Taste lives in files (your voice bible, Production Bible, format profiles, brand - Proven in production: the full cut lane (parakeet-mlx word-level transcription validated on real footage, cut candidate detection, edl.json, FCPXML export, preview render with boundary-frame verification), Manny as the front door, setup and dependency checking, config resolution, project scaffolding, the OBS stream pack, and the retro loop. - New in 1.0, implemented and unit-tested, with the least real-project mileage: the render lane (composited preview and the offered final render), the expanded setup interview (render consent, video style, creator-emulation takeaways, headshots, guided voice bible), the Production Bible, creativity mandates and the CTA system, footage-first ingest and the livestream-vod format, series support, graphics render verification, the graphics toolkit (HTML render, snug framing, design-prompting lane), CLI-registry asset farming, and the mc-audio local sound lanes (validated end to end on Apple Silicon 2026-07-07). +- Newest, implemented and unit-tested since that date: mc-prompter, the browser teleprompter service skill (classic prompter, voice-follow via local streaming ASR, and rundown-driven producer mode with an opt-in local Ollama lane). - The writing lane (braindump, outline, script) is the core promise and is wired end to end with live blacklist linting; it has had the least real-video exercise of the core stages, so treat your first run through it as a shakedown and feed mc-retro afterward. - Planned: Premiere (xmeml) and CMX3600 EDL export lanes, per-episode stream packs with the Ecamm target (the named 1.0.x fast-follow), multitrack recording support, local-first TTS/SFX/music lanes, and a research/show-prep skill. See [TODO.md](TODO.md) for the full roadmap. diff --git a/TODO.md b/TODO.md index 0bdff0f..0d16c2e 100644 --- a/TODO.md +++ b/TODO.md @@ -9,6 +9,14 @@ State as of 2026-07-07, the 1.0.0 release. Read AGENTS.md first (module conventi - resolve_import.py: push the exported timeline into a running DaVinci Resolve. Requires Resolve Studio (the scripting API is not in the free edition); the mc-cut offer stays gated on the script's implemented status. Native scripting remains the documented path; no MCP dependency. - HyperFrames engine workspace initialization at a pinned version on the first real graphics run (upstream is pre-1.0 and moves fast; v0.7.26 as of 2026-07-03). +## mc-prompter fast-follows + +- Kokoro spoken cue tier: short formulaic phrases ("thirty seconds", "wrap") synthesized by a persistent kokoro instance, released only at pauses, headphones-only output (what keeps browser AEC unnecessary). Designed in mc-prompter's references/cueing.md; ships behind the `[prompter]` spoken-cues flag, which stays false until this lands. +- zipformer-small ASR validation: model exports are coupled to the sherpa-onnx runtime generation (the 2023 zipformer export silently produces garbage under the pinned 1.13.4; it decodes correctly only under 1.10.x). The lane stays planned and exits with a pointer until a current-generation small export is validated end to end. +- TLS for remote mic capture: getUserMedia needs a secure context, so tablet-as-mic over LAN requires shipping TLS; today the microphone is captured only on the server machine. +- Take-log consumption by mc-cut: mc-prompter's session take log (script positions and timestamps per take) pre-anchors cut plans. +- sherpa-onnx offline parakeet export: the candidate for the cross-platform transcription lane (see below); the prompter's sherpa-onnx dependency makes it cheaper to validate. + ## 1.x roadmap ### Multitrack and multicam support diff --git a/docs/user-guide.md b/docs/user-guide.md index 8c0606b..6bf6656 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -145,6 +145,46 @@ Footage-first, when the video already exists: 1. Hand Manny the file ("cut this VOD", "make a video from this recording"). mc-new's ingest mode registers the source and writes a post-production stage list that starts at cut. 2. The same gates apply from the cut stage onward: cut plan, beats with CTAs mined from the transcript, graphics, packaging with dual-timeline chapters, the final render offer. -## 10. Formats +## 10. The teleprompter + +mc-prompter is a service skill, not a stage: say "prompt me" or "record with the teleprompter" and it launches a local browser prompter for the recording you were going to do anyway. It comes in three tiers, and each one is optional on top of the one below. + +The classic prompter needs nothing extra: no models, no downloads, no workspace. It serves a fullscreen scrolling display with the standard feature set (mirror flips for beam-splitter rigs, adjustable speed and fonts, countdown, timed mode, section jumps), a home page for loading or pasting text, and a phone remote over LAN whose URL carries a per-session token. Inside a pipeline project it prompts `script.md` directly and understands its markers: `[TAKE ...]` lines render dimmed because they were already spoken well on the interview footage, and `[INVENTED]` flags show as subtle badges. Editing from the home page backs up the file before writing, so the prompted text and the pipeline artifact never diverge. + +Voice-follow makes the scroll track your voice through the script using local streaming ASR. It needs the prompter-lab workspace (default `manticore/engines/prompter-lab`): a one-time download of about 465 MB of model files plus a small venv, and nothing downloads without your explicit go-ahead. Declining always leaves the classic prompter working. The first enable runs a preflight: pick your microphone, watch the level meter, and read a few words until the tracking check passes. After that, silence or ad-libs hold the scroll and it resumes when you return to the script; clicking any word re-anchors instantly. The microphone is captured on the machine running the server, so a tablet pointed at the page is display-only. + +Producer mode is for shows that run on talking points instead of a word-for-word script. You write a rundown, a small markdown file (a starter template lands in `{brand-path}/templates/rundown-template.md` during setup): + +```markdown +--- +show: "Why local models win" +duration-minutes: 30 +cue-density: normal +wrap-minutes: 3 +--- + +## Intro (3 min) + +Full scripted intro text, prompted normally. + +## Point 1: The cost argument (5 min) + +- cloud bills compound, local is capex +- the anecdote that proves it + +## Wrap (3 min) + +Scripted wrap text. +``` + +Segments with prose prompt like a script; segments with only bullets become tracked talking points. Time budgets are optional, `wrap-minutes` protects your closing segment, and the home page shows the reconciled plan (with any warnings) before you go live. + +Running a show: hit GO LIVE on the prompt page or the phone remote to start the show clock. A rail shows elapsed time, the current segment with its remaining time in green, yellow, or red, and your next uncovered point; when you run long, the remaining time is replanned across what is left rather than just turning red. Cues speak broadcast in two tiers: quiet cards ("30 seconds", "STRETCH", "DROP: point 4, or 90s each") appear at your configured density, while "WRAP" and the overtime clock ("2:30 OVER") flash as high-contrast attention cues that ignore the density budget. Hold freezes the clock during technical trouble. The remote is your override authority: tap any point to mark it covered or skip it, jump between segments with make current, and the producer never un-marks anything you decided. + +For OBS, add `/overlay` as a browser source: it is transparent and renders only the rail, the cue cards, and small connection and voice-tracking badges, so your live audience sees a clean frame while you see the producer. + +What requires Ollama: only the coverage judgments, where a small local model (default `qwen3:4b`) reads the rolling transcript and proposes which points you have covered. Everything else in producer mode, the rail, the replanning, and every time cue, is deterministic code and works with no LLM at all; without Ollama you mark points covered from the remote yourself. Nothing metered, nothing cloud: the `[llm]` lane is local-first like every other lane. + +## 11. Formats Your `manticore/formats/` copies are yours to edit; each profile decides which stages run, carries structured density and beat-type frontmatter, and holds a Learnings section that retro appends to. Seven ship by default: talking-head, screen-tutorial (bans generated b-roll: real UI only), voiceover-explainer (narration is creator-recorded until the TTS lane lands), short (9:16 re-edit of a parent project), livestream-pack (an OBS asset pack, not a video), livestream-vod (footage-first post-production of a stream recording), course-lesson. A new format is a new markdown file. diff --git a/skills/mc-agent/customize.toml b/skills/mc-agent/customize.toml index d720eb0..9be39f1 100644 --- a/skills/mc-agent/customize.toml +++ b/skills/mc-agent/customize.toml @@ -76,6 +76,11 @@ code = "HP" description = "What can I do here? Everything installed, Manticore and beyond" prompt = "Read {project-root}/_bmad/_config/bmad-help.csv (the merged catalog of every installed skill across all modules) and present what is actually available, grouped by module, surfacing only what is relevant to where the creator is. For anything Manticore-side needing more depth, load references/skills-map.md. If the catalog is missing, the studio is not built yet: route to onboarding." +[[agent.menu]] +code = "PR" +description = "Teleprompter: prompt me, run my show, producer mode with a rundown" +skill = "mc-prompter" + [[agent.menu]] code = "GS" description = "Grow the studio: add a new skill or capability to Manticore" diff --git a/skills/mc-pipeline/PIPELINE.md b/skills/mc-pipeline/PIPELINE.md index 2807f3c..22fddec 100644 --- a/skills/mc-pipeline/PIPELINE.md +++ b/skills/mc-pipeline/PIPELINE.md @@ -4,7 +4,7 @@ The master spec for the Manticore pipeline, owned by mc-pipeline (the router). I Conventions used below: -- The studio config is the `[modules.manticore]` table in `{project-root}/_bmad/custom/config.toml` (personal overrides in `config.user.toml`), created by mc-setup and resolved with `uv run {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root} --key modules.manticore`. Table names like `[owner]`, `[paths]`, `[video]`, `[render]`, `[style]`, `[cta]`, `[live]`, `[editor]`, `[transcription]`, `[assets]`, `[mcp]` refer to its sub-tables. (`[defaults.*]` names appear only inside mc-setup's `customize.toml`, the seed that mc-setup copies from; a resolved studio config has no `[defaults]` table.) +- The studio config is the `[modules.manticore]` table in `{project-root}/_bmad/custom/config.toml` (personal overrides in `config.user.toml`), created by mc-setup and resolved with `uv run {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root} --key modules.manticore`. Table names like `[owner]`, `[paths]`, `[video]`, `[render]`, `[style]`, `[cta]`, `[live]`, `[editor]`, `[transcription]`, `[assets]`, `[prompter]`, `[llm]`, `[mcp]` refer to its sub-tables. (`[defaults.*]` names appear only inside mc-setup's `customize.toml`, the seed that mc-setup copies from; a resolved studio config has no `[defaults]` table.) - `{projects-path}`, `{brand-path}`, `{formats-path}`, `{engines-path}` are the `[paths]` values resolved against `{project-root}`. If `[modules.manticore]` is empty, run mc-setup first; no stage skill proceeds without it. - Per-skill defaults and overrides live in each skill's `customize.toml`, resolved with `uv run {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root}`. Skills read only their own folder and project files, never another skill's folder. - "the creator" is the human owner configured in `[owner]`; skills address them by their configured name. @@ -19,7 +19,7 @@ Format profiles select a subset of these stages (see the `stages:` frontmatter o | 2 | braindump | mc-braindump | | `braindump.md` (verbatim) | | 3 | outline | mc-outline | gate 1: outline | `outline.md` (hooks + outline + packaging promise) | | 4 | script | mc-script | | `script.md` (lint passed, craft QA passed) | -| 5 | record | the creator | | `raw/*` recordings, constant frame rate | +| 5 | record | the creator | | `raw/*` recordings, constant frame rate. The mc-prompter service skill offers an optional teleprompter for this creator-owned stage. | | 6 | cut | mc-cut | gate 2: cutplan | `transcript/words.json` (suffixed `<source-id>.words.json` when a project has multiple sources), `cut/candidates.json`, `cut/cutplan.md`, `cut/edl.json`, `cut/rough.fcpxml` (per `[editor] timeline-format`; `none` skips), `renders/preview.mp4` (fast low-res preview, re-rendered each iteration; once stage 8 has rendered overlays, the router sends the project back through mc-cut to re-render it with graphics composited) | | 7 | beats | mc-beats | gate 3: beats | `beats/beats.md` (the beat table), `beats/STORYBOARD.md` | | 8 | graphics | mc-graphics | | `graphics/` alpha MOVs + `graphics/HANDOFF.md`; on completion the router routes through mc-cut to re-render `renders/preview.mp4` with the overlays composited | diff --git a/skills/mc-prompter/SKILL.md b/skills/mc-prompter/SKILL.md index d423ea4..9c37da8 100644 --- a/skills/mc-prompter/SKILL.md +++ b/skills/mc-prompter/SKILL.md @@ -1,23 +1,25 @@ --- 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. Voice-follow scrolling (local streaming ASR, consent-gated model download) tracks the speaker through the script; producer mode is a planned tier. +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. Voice-follow scrolling (local streaming ASR, consent-gated model download) tracks the speaker through the script. Producer mode runs a live show from a rundown file, with a timing rail, replanned segment budgets, broadcast-style cues, an OBS overlay, and optional coverage judgments via a local Ollama model. --- # 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. The classic prompter runs offline with no models and no downloads. Voice-follow is an optional tier on top: local streaming ASR follows the speaker through the script and scrolls to match; it needs the prompter-lab workspace, whose one-time model download is consent-gated below. +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. The classic prompter runs offline with no models and no downloads. Voice-follow is an optional tier on top: local streaming ASR follows the speaker through the script and scrolls to match; it needs the prompter-lab workspace, whose one-time model download is consent-gated below. Producer mode is the third tier: a rundown file drives a live show clock, per-segment budgets that replan as the show runs, and rate-limited visual cues; a local Ollama model is optional on top for coverage judgments. ## 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 defaults from `[prompter]`: `port`, `asr-provider`, and `workspace`. 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]` values, `[owner] wpm`, and `engines-path` when present; a missing studio config is fine here, unlike the stage skills, because standalone shows need no studio. +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 defaults from `[prompter]` (`port`, `asr-provider`, `workspace`, `cue-density`) and `[llm]` (`provider`, `model`, `endpoint`). 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]` and `[llm]` values, `[owner] wpm`, and `engines-path` 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. Workspace check, only when the creator wants voice-follow or asks about it (the classic prompter needs none of this; when `asr-provider` is `none`, skip to launch). Resolve the workspace as `{engines-path}/{prompter.workspace}` when a studio config exists; otherwise ask the creator for a location or default to `~/mc-prompter-lab`. Run `uv run {skill-root}/scripts/ensure_workspace.py --workspace <resolved> --check`. Ready (exit 0): proceed. Not ready (exit 4): tell the creator plainly that a bootstrap downloads about 465 MB of ASR model files plus a small Python venv, ask for their explicit go-ahead, and only then run the same command without `--check`. Declining is fine: launch the classic prompter without `--workspace`. The bootstrap is idempotent; an existing validated workspace is reused, never rebuilt, and downloads run long, so report progress rather than going silent. 4. Launch: `uv run {skill-root}/scripts/run_prompter.py --script <path>` with `--port <N>` when the config or the creator sets one and `--owner-wpm <N>` when `[owner] wpm` is known. Add `--workspace <resolved>` when the workspace is ready and `--asr-provider <value>` when the config sets one; the launcher verifies readiness itself and falls back to the classic prompter with a printed notice when the workspace is missing or incomplete. 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, whether voice-follow is available, and the session file path, then keeps the server running until Ctrl-C. -5. 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. 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 a transparent OBS browser source that renders the producer rail and cue cards when a show is running. 6. When voice-follow is available, explain how it works on `/prompt`: it is off by default; the toggle lives in the settings drawer and as a HUD chip, and it only appears in a browser on the server machine itself, because the microphone is captured there (a tablet or phone pointed at the page is display-only). The first enable opens a preflight panel: pick the microphone, watch the live level meter, confirm the applied audio settings, and read a few words until the tracking check passes. After that the scroll follows the voice; silence or off-script ad-libs hold the scroll (HOLD chip) and it resumes when the speaker returns to the script; clicking any word re-anchors instantly; a BEHIND chip means ASR is lagging real time on this machine. Manual controls keep working throughout, and toggling voice-follow off returns to normal wpm scrolling at the current position. 7. 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 or matched by voice-follow. 8. 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. -9. When the creator asks for producer mode (rundown, timing cues), say plainly that it is a planned tier that has not landed yet. The same honesty applies to `asr-provider` values: `nemotron-streaming` and `none` work today; `zipformer-small` is a planned lane and the server exits with a pointer if it is selected. Never pretend a planned lane works and never improvise a substitute. +9. Producer mode, when the creator wants to run a show from a rundown (a timed show with talking points rather than a word-for-word script). The rundown is a markdown file per `{skill-root}/references/rundown-spec.md`: inside a pipeline project it is the project's `rundown.md`; standalone it is any path. No rundown yet: draft one with the creator from the studio template at `{brand-path}/templates/rundown-template.md` (installed by mc-setup) and save it where it belongs. Launch with `--rundown <path>` added to the step 4 command; `--script` may ride along, and scripted rundown segments are prompted like any script. Pass `--cue-density <value>` when the config sets one (the rundown's own frontmatter still wins) and, when the creator wants coverage judgments, `--llm-provider ollama` plus `--llm-model` and `--llm-endpoint` from `[llm]`. The launcher exits 4 if the rundown path is missing or unreadable. +10. Explain the show flow before the first run: the home page shows the reconciled plan (segments, budgets, warnings) so timing surprises surface before going live; the big GO LIVE button on `/prompt` (or `/remote`) starts the show clock; hold/resume freezes the clock during technical trouble without losing the transcript; end-show closes it out. During the show the rail tracks elapsed time, the current segment with its replanned time left in green/yellow/red, and the next uncovered point; cues arrive as quiet cards (attention-tier cues flash for time-critical states like WRAP or running over) at the configured density. The phone remote is the override authority: its producer tab marks points covered, skips them, or jumps segments with one tap, and the producer never un-covers a point on its own. +11. Be honest about what needs Ollama: nothing in producer mode requires it. Without Ollama (or when `--llm-provider` is omitted or `none`) the deterministic rail, the replanned budgets, and all time cues still work; what goes away is automatic coverage judgment of the transcript, so the creator marks points covered from the remote instead. When the creator wants the LLM tick, Ollama must already be running with the configured model pulled; the server reports LLM status honestly and a failed tick is dropped, never queued. The same honesty applies to `asr-provider` values: `nemotron-streaming` and `none` work today; `zipformer-small` is a planned lane and the server exits with a pointer if it is selected. Never pretend a planned lane works and never improvise a substitute. ## Rules @@ -34,5 +36,6 @@ The record stage is creator-owned; this skill hands the creator a teleprompter f - The creator got both URLs (prompt page, remote with token) and knows the pages. - If voice-follow was requested, the creator knows it is toggled on `/prompt`, that preflight must pass before a take, and how HOLD, BEHIND, and click-to-anchor behave. - Take and invented markers were explained if the script contains them. +- If producer mode was requested, the creator saw the reconciled plan before going live, knows the GO LIVE / hold / end-show flow and the remote override controls, and knows exactly what works without Ollama. - Any in-place save was backed up first (the server does this; confirm the backup path in its response). - No planned tier or provider lane was presented as working. diff --git a/skills/mc-prompter/customize.toml b/skills/mc-prompter/customize.toml index 92c6ae4..bb2b00d 100644 --- a/skills/mc-prompter/customize.toml +++ b/skills/mc-prompter/customize.toml @@ -38,3 +38,26 @@ asr-provider = "nemotron-streaming" # venv and model files; the first bootstrap downloads ~465 MB and is # consent-gated by the skill. The classic prompter needs no workspace. workspace = "prompter-lab" + +# Producer-mode cue frequency: "hands-off" (time-critical attention cues +# only), "minimal", "normal", or "chatty". A rundown's own frontmatter +# cue-density overrides this; the studio config's [prompter] cue-density +# overrides this default when set. +cue-density = "normal" + +[llm] + +# Local LLM lane for producer mode's coverage judgments and cue +# suggestions, mirroring the studio config's [llm] table (which overrides +# these defaults when set). "ollama" is the only implemented provider; +# anything else makes the server exit with a pointer, it never pretends +# to run. Producer mode degrades honestly without Ollama: the +# deterministic rail and time cues still work, coverage judgments do not. +provider = "ollama" +# Any Ollama tag; smaller tags (for example qwen3:1.7b) suit CPU-only +# machines. +model = "qwen3:4b" +endpoint = "http://localhost:11434" +# Env var for a paid LLM lane's key. Stays empty for local lanes, +# pattern-consistent with the other lanes. +api-key-env = "" diff --git a/skills/mc-script/SKILL.md b/skills/mc-script/SKILL.md index 054da55..76a9273 100644 --- a/skills/mc-script/SKILL.md +++ b/skills/mc-script/SKILL.md @@ -19,7 +19,7 @@ The anti-LLM-slop stage. The script is woven, not written. 3. Lint: `uv run {skill-root}/scripts/lint_script.py {projects-path}/<slug>/script.md --blacklist {brand-path}/blacklist.md`. Fix every violation before presenting. 4. Craft QA: run the checklist at `{workflow.craft_checklist}` (relative paths resolve against `{skill-root}`; default is the packaged 16-rule list), plus the manual QA list at the bottom of the creator's blacklist. Fix, do not annotate around, failures. 5. Compute runtime from the real word count at the creator's measured wpm (`[owner] wpm` in the config) and state it. Flag if it misses the format's target length. -6. Write `script.md` (with the `[INVENTED]` flags still visible), update project.json (append `script` to `stages_done`, set `stage` to the next entry in its `stages` array), and present. Tell the creator the ball is theirs: record, drop takes in `raw/` at constant frame rate. If `[TAKE ...]` markers exist, list the delta explicitly: which lines are already captured on the interview recording and which still need recording. +6. Write `script.md` (with the `[INVENTED]` flags still visible), update project.json (append `script` to `stages_done`, set `stage` to the next entry in its `stages` array), and present. Tell the creator the ball is theirs: record, drop takes in `raw/` at constant frame rate. Offer the mc-prompter teleprompter for the recording; it prompts `script.md` directly and understands its markers. If `[TAKE ...]` markers exist, list the delta explicitly: which lines are already captured on the interview recording and which still need recording. ## Rules diff --git a/skills/mc-setup/SKILL.md b/skills/mc-setup/SKILL.md index 503f23a..3945fa0 100644 --- a/skills/mc-setup/SKILL.md +++ b/skills/mc-setup/SKILL.md @@ -41,6 +41,10 @@ An existing `[modules.manticore]` that is missing any of the 1.0 tables (`[rende Finish with step 8 as usual so the migrated config is verified and the pending gaps are reported. +#### 1b. Teleprompter backfill + +Separate from the 0.x rule above: a config that has the 1.0 tables but is missing `[prompter]` or `[llm]` is a current studio that predates the teleprompter, not a 0.x studio. Backfill both tables surgically from this skill's `[defaults]` (prompter, llm), leave everything else untouched, and offer the optional step 3e prompter interview without forcing it. + ### 2. Dependencies Bootstrap first: check `uv --version`. If uv is missing, offer to install it; otherwise the official installer from docs.astral.sh/uv, and wait for the creator's confirmation; every pipeline script runs through uv, so nothing works without it. @@ -96,6 +100,10 @@ Present `[audio]` and confirm the local-first defaults (the full ladder and its - Disk and download honesty before any bootstrap: the engine workspace at `{engines-path}/audio-lab` needs a venv of several GB, ~340 MB of Kokoro models, and ~5 GB of Hugging Face cache on the first music/sfx run. Offer to build it now (`uv run` mc-audio's `ensure_workspace.py`) or defer; mc-audio asks again at first farming. An existing workspace (a lab the creator already built) is detected and reused, never rebuilt. - Paid audio lanes (Gemini TTS, ElevenLabs) exist behind the same keys as explicit opt-in choices; if, and only if, the creator picks one, set the provider and `api-key-env` now and handle key sourcing in step 7. +### 3e. Teleprompter (optional) + +Offer the teleprompter briefly: the mc-prompter service skill gives the record stage a browser teleprompter, and the `[prompter]` defaults (workspace, ASR provider, cue density, port) are sane as shipped, so most creators just accept them. Only if the creator wants producer mode (a rundown-driven show with timing cues) mention that its coverage judgments use a local LLM via Ollama (`[llm]`, default model qwen3:4b) and never require it: without Ollama the deterministic rail and time cues still work. No downloads happen here; the voice-follow model download is consent-gated inside mc-prompter itself. + ### 4. Brand build Create the four path folders if missing. The exit state is filled, never placeholders: a placeholder survives only when the creator genuinely has nothing to give, and every survivor goes on the step 8 pending list, loudly. @@ -110,6 +118,7 @@ Into `{brand-path}`: - `voice-bible.md`: built in step 4b. - `headshots/`: collect 3 to 6 approved photos of the creator with varied expressions (neutral, surprised, thinking, excited). Auto-classify each expression, rename to expression-slug filenames, and write an `index.md` expression catalog (one line per photo: file, expression). Explain how they get used: when a thumbnail or asset needs the creator in it, the original photo goes to the configured image model with a "use the person in this image to ..." prompt, and any revision re-sends the same original photo, never a prior generation. State the rule inline: approved photos only; mc-package never uses arbitrary frames from footage. If no headshots exist yet, flag it loudly: thumbnails are blocked until they do. - `exemplars/` folder (filled in step 4b). +- `templates/rundown-template.md` from `{skill-root}/assets/rundown-template.md` (never overwrite an existing copy): the starter rundown for mc-prompter's producer mode, kept in the studio so Manny and any skill can draft show rundowns from it without reading mc-prompter's folder. Into `{formats-path}`: copy every profile from `{skill-root}/assets/formats/` that does not already exist there (never overwrite; the creator's copies accumulate learnings). @@ -154,7 +163,7 @@ Then scaffold `{project-root}/.env.example`: ### 8. Write and confirm -Write the interview results as the `[modules.manticore]` table (with its sub-tables: owner, paths, video, render, style, cta, live, editor, transcription, assets, audio, mcp, and `[[modules.manticore.tools]]` entries) into `{project-root}/_bmad/custom/config.toml`. Edit surgically: create the file if needed, preserve everything else in it (other modules configure themselves there too), and preserve any sections the creator skipped. Mention `config.user.toml` for personal overrides in shared repos. Verify by running `uv run {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root} --key modules.manticore` and showing the resolved summary. +Write the interview results as the `[modules.manticore]` table (with its sub-tables: owner, paths, video, render, style, cta, live, editor, transcription, assets, audio, prompter, llm, mcp, and `[[modules.manticore.tools]]` entries) into `{project-root}/_bmad/custom/config.toml`. Edit surgically: create the file if needed, preserve everything else in it (other modules configure themselves there too), and preserve any sections the creator skipped. Mention `config.user.toml` for personal overrides in shared repos. Verify by running `uv run {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root} --key modules.manticore` and showing the resolved summary. Close with the honest runnability report: diff --git a/skills/mc-setup/assets/rundown-template.md b/skills/mc-setup/assets/rundown-template.md new file mode 100644 index 0000000..22cc97d --- /dev/null +++ b/skills/mc-setup/assets/rundown-template.md @@ -0,0 +1,39 @@ +--- +show: "My show title" +duration-minutes: 30 +cue-density: normal # hands-off | minimal | normal | chatty +wrap-minutes: 3 +--- + +## Intro (3 min) + +Write your scripted intro here as full prose. A segment whose body is +prose like this is scripted: it is prompted on the scroll word for word. +Replace every segment in this file with your own and save it as +rundown.md in your project folder (or anywhere; the prompter takes any +path). The full format spec lives in mc-prompter's +references/rundown-spec.md. + +## Point 1: First talking point (5 min) + +- the core claim of this point +- the example or anecdote that proves it +- the takeaway line + +## Point 2: Second talking point (5 min) + +- the setup +- the demo or story +- what it means for the viewer + +## Point 3: Third talking point + +- bullets only, no time suffix: this segment splits the remaining time +- a time budget is an optional heading suffix, exactly (N min) or (Nm) +- add or remove points freely; the producer tracks each one + +## Wrap (3 min) + +Write your scripted wrap here. With wrap-minutes set in the frontmatter, +this closing segment's time is protected as a hard reserve no matter how +long the middle runs. diff --git a/skills/mc-setup/customize.toml b/skills/mc-setup/customize.toml index 3043e5f..25e7acd 100644 --- a/skills/mc-setup/customize.toml +++ b/skills/mc-setup/customize.toml @@ -184,6 +184,46 @@ api-key-env = "" # builds it with consent; an existing lab is reused, never duplicated. workspace = "audio-lab" +[defaults.prompter] + +# Teleprompter defaults for the mc-prompter service skill (browser prompter +# for the record stage and standalone shows; no stage, no gate). +# Engine workspace folder for voice-follow, resolved as +# {engines-path}/{workspace}. Holds the ASR venv and models (~465 MB, +# consent-gated download); the classic prompter needs no workspace. +workspace = "prompter-lab" +# Voice-follow ASR provider: "nemotron-streaming" (the default, streaming +# English ASR via sherpa-onnx) or "none" (classic prompter, no ASR). +# "zipformer-small" is a planned lane; the server exits with a pointer if +# it is selected, it never pretends to run. +asr-provider = "nemotron-streaming" +# Producer-mode cue frequency: "hands-off" (time-critical cues only), +# "minimal", "normal", or "chatty". A rundown's frontmatter overrides this. +cue-density = "normal" +# Spoken cue tier (short synthesized phrases to headphones). Designed but +# not shipped yet; ships false and stays false until the lane lands. +spoken-cues = false +# Default port for the prompter server (auto-increments when busy). +port = 8770 + +[defaults.llm] + +# Local LLM lane, used by mc-prompter's producer mode for coverage +# judgments and cue suggestions. Local-first like every other lane: +# "ollama" is the only implemented provider; other rungs are planned, +# opt-in only, and the producer exits with a pointer for anything else. +# Producer mode degrades honestly without Ollama: the deterministic rail +# and time cues still work, coverage judgments do not. +provider = "ollama" +# Any Ollama tag. On CPU-only machines the producer recommends and falls +# back to a smaller tag (for example qwen3:1.7b). +model = "qwen3:4b" +endpoint = "http://localhost:11434" +# Env var for a paid LLM lane's key. Stays empty for local lanes; set only +# inside the explicit opt-in branch of the interview, pattern-consistent +# with the other lanes. +api-key-env = "" + [defaults.mcp] # MCP servers mc-setup has verified in this project. Skills treat false/absent diff --git a/skills/mc-setup/scripts/check_deps.py b/skills/mc-setup/scripts/check_deps.py index b8da23c..ec26607 100644 --- a/skills/mc-setup/scripts/check_deps.py +++ b/skills/mc-setup/scripts/check_deps.py @@ -30,6 +30,7 @@ ("npx", True, "hyperframes CLI and registry blocks"), ("git", True, "project history"), ("yt-dlp", False, "pulling your published transcripts for the voice bible"), + ("ollama", False, "local LLM for the mc-prompter producer mode (opt-in)"), ] diff --git a/skills/mc-setup/scripts/tests/test-check_deps.py b/skills/mc-setup/scripts/tests/test-check_deps.py index 2b936c6..e64b032 100644 --- a/skills/mc-setup/scripts/tests/test-check_deps.py +++ b/skills/mc-setup/scripts/tests/test-check_deps.py @@ -41,6 +41,16 @@ def test_table_output_runs(self): self.assertIn("uv", proc.stdout) self.assertIn(proc.returncode, (0, 1)) + def test_ollama_row_optional(self): + proc = run(["--json"]) + data = json.loads(proc.stdout) + rows = [r for r in data["results"] if r["dep"] == "ollama"] + self.assertEqual(len(rows), 1) + row = rows[0] + self.assertFalse(row["required"]) # producer mode is opt-in; never fails the check + if not row["found"]: + self.assertIn("mc-prompter", row["detail"]) + def test_platform_gate_row(self): proc = run(["--json"]) data = json.loads(proc.stdout) diff --git a/skills/module-help.csv b/skills/module-help.csv index 3194779..6986678 100644 --- a/skills/module-help.csv +++ b/skills/module-help.csv @@ -13,7 +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. Optional voice-follow scrolling (local streaming ASR, consent-gated model download) tracks the speaker; producer mode is still a planned tier.",,,anytime,,,false,session URLs printed by the launcher, +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. Optional voice-follow scrolling (local streaming ASR, consent-gated model download) tracks the speaker. Producer mode runs a live show from a rundown: timing rail, replanned budgets, broadcast cues, OBS overlay, remote overrides; coverage judgments via local Ollama are opt-in.",,,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 From 38b25bb41650a6c5bdc7ffb1a5641ee52a4980b0 Mon Sep 17 00:00:00 2001 From: Brian Madison <bmadcode@gmail.com> Date: Fri, 10 Jul 2026 23:16:30 -0500 Subject: [PATCH 5/5] Tell the driving agent to launch the prompter in the background and reuse the session file --- skills/mc-prompter/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/mc-prompter/SKILL.md b/skills/mc-prompter/SKILL.md index 9c37da8..1678d64 100644 --- a/skills/mc-prompter/SKILL.md +++ b/skills/mc-prompter/SKILL.md @@ -12,7 +12,7 @@ The record stage is creator-owned; this skill hands the creator a teleprompter f 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 defaults from `[prompter]` (`port`, `asr-provider`, `workspace`, `cue-density`) and `[llm]` (`provider`, `model`, `endpoint`). 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]` and `[llm]` values, `[owner] wpm`, and `engines-path` 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. Workspace check, only when the creator wants voice-follow or asks about it (the classic prompter needs none of this; when `asr-provider` is `none`, skip to launch). Resolve the workspace as `{engines-path}/{prompter.workspace}` when a studio config exists; otherwise ask the creator for a location or default to `~/mc-prompter-lab`. Run `uv run {skill-root}/scripts/ensure_workspace.py --workspace <resolved> --check`. Ready (exit 0): proceed. Not ready (exit 4): tell the creator plainly that a bootstrap downloads about 465 MB of ASR model files plus a small Python venv, ask for their explicit go-ahead, and only then run the same command without `--check`. Declining is fine: launch the classic prompter without `--workspace`. The bootstrap is idempotent; an existing validated workspace is reused, never rebuilt, and downloads run long, so report progress rather than going silent. -4. Launch: `uv run {skill-root}/scripts/run_prompter.py --script <path>` with `--port <N>` when the config or the creator sets one and `--owner-wpm <N>` when `[owner] wpm` is known. Add `--workspace <resolved>` when the workspace is ready and `--asr-provider <value>` when the config sets one; the launcher verifies readiness itself and falls back to the classic prompter with a printed notice when the workspace is missing or incomplete. 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, whether voice-follow is available, and the session file path, then keeps the server running until Ctrl-C. +4. Launch: `uv run {skill-root}/scripts/run_prompter.py --script <path>` with `--port <N>` when the config or the creator sets one and `--owner-wpm <N>` when `[owner] wpm` is known. Add `--workspace <resolved>` when the workspace is ready and `--asr-provider <value>` when the config sets one; the launcher verifies readiness itself and falls back to the classic prompter with a printed notice when the workspace is missing or incomplete. 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, whether voice-follow is available, and the session file path, then keeps the server running until Ctrl-C. Run it as a background task so the conversation continues while the server serves; never sit blocked on it. The printed session file (`<tempdir>/mc-prompter/session-<port>.json`, holding port, pid, token, and script) is how a later step or a later session finds a running server; confirm it is alive with GET `/health` on that port before reusing it, and stop it by terminating the launcher process, which tears down the whole server process group. 5. 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 a transparent OBS browser source that renders the producer rail and cue cards when a show is running. 6. When voice-follow is available, explain how it works on `/prompt`: it is off by default; the toggle lives in the settings drawer and as a HUD chip, and it only appears in a browser on the server machine itself, because the microphone is captured there (a tablet or phone pointed at the page is display-only). The first enable opens a preflight panel: pick the microphone, watch the live level meter, confirm the applied audio settings, and read a few words until the tracking check passes. After that the scroll follows the voice; silence or off-script ad-libs hold the scroll (HOLD chip) and it resumes when the speaker returns to the script; clicking any word re-anchors instantly; a BEHIND chip means ASR is lagging real time on this machine. Manual controls keep working throughout, and toggling voice-follow off returns to normal wpm scrolling at the current position. 7. 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 or matched by voice-follow.