Add mc-prompter: local teleprompter skill (Phase A) - #4
Conversation
WalkthroughAdds the mc-prompter skill as an offline local teleprompter with script parsing, authenticated HTTP/WebSocket coordination, synchronized prompt playback, remote and overlay interfaces, editing, backups, and comprehensive tests. Changesmc-prompter skill
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant Creator
participant HomePage
participant PrompterServer
participant PromptPage
participant RemotePage
Creator->>HomePage: load or edit script
HomePage->>PrompterServer: POST source and backup request
PrompterServer-->>HomePage: updated document version
PrompterServer-->>PromptPage: broadcast doc-updated
PromptPage->>PrompterServer: send leader state
RemotePage->>PrompterServer: send playback command
PrompterServer-->>PromptPage: relay command
PromptPage-->>PrompterServer: broadcast synchronized state
PrompterServer-->>RemotePage: return state snapshot
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (2)
skills/mc-prompter/scripts/server/script_ingest.py (2)
225-227: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winPrefer
\ufeffescape over literal BOM character.The
lstrip("")uses a literal U+FEFF character that is invisible in most editors and code review tools, making it easy to accidentally delete or overlook. Using the escape sequence is clearer and more maintainable.♻️ Optional fix
- text = text.lstrip("") + text = text.lstrip("\ufeff")🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@skills/mc-prompter/scripts/server/script_ingest.py` around lines 225 - 227, Replace the literal BOM character in the `text.lstrip` call with the explicit `\ufeff` escape sequence, preserving the existing defensive stripping behavior.
159-164: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low valueFilter empty notes from whitespace-only bracket content.
When bracket content is whitespace-only (e.g.,
[ ]produced after inner extraction of[[]]),_extract_noteappendscontent.strip()which yields"", producing empty note blocks{"type": "note", "text": ""}in the output. The testtest_empty_brackets_never_leakonly asserts speakable text, so this goes uncaught.♻️ Optional fix
def _extract_note(m): content = m.group(1) if content == INVENTED[1:-1]: return m.group(0) - notes.append(content.strip()) + stripped = content.strip() + if stripped: + notes.append(stripped) return " "🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@skills/mc-prompter/scripts/server/script_ingest.py` around lines 159 - 164, Update _extract_note to strip bracket content before appending it, and only append non-empty text; for whitespace-only content, return the replacement without adding an empty note. Preserve the existing INVENTED handling and ensure test_empty_brackets_never_leak also verifies that no empty note blocks are produced.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@skills/mc-prompter/scripts/run_prompter.py`:
- Around line 294-333: Ensure the child process is always cleaned up when the
outer startup block exits, including interruptions during health checks or
output. Move or add terminate_server(child) at the beginning of the outer
finally block surrounding wait_for_health and URL printing, while retaining
session-file cleanup; use the existing terminate_server helper and avoid relying
only on the nested KeyboardInterrupt handler.
- Line 58: The AIOHTTP_PIN dependency is set to the vulnerable 3.12.15 release;
update AIOHTTP_PIN to version 3.14.1 or newer and ensure any corresponding
dependency references remain consistent.
In `@skills/mc-prompter/scripts/server/static/js/remote.js`:
- Around line 55-72: Update the catch handler in loadSections to retain the
existing 401/403 message and display a minimal non-fatal error message for all
other failures, including network errors and server responses such as 500, so
users are informed instead of seeing an empty section list.
In `@skills/mc-prompter/SKILL.md`:
- Around line 13-14: Align the documented no-file workflow with the
implementation: update the launcher CLI handling in run_prompter.py so --script
is optional, and ensure the server initializes an empty document that opens the
paste editor when omitted; otherwise remove the no-file claim from SKILL.md and
related launch instructions.
---
Nitpick comments:
In `@skills/mc-prompter/scripts/server/script_ingest.py`:
- Around line 225-227: Replace the literal BOM character in the `text.lstrip`
call with the explicit `\ufeff` escape sequence, preserving the existing
defensive stripping behavior.
- Around line 159-164: Update _extract_note to strip bracket content before
appending it, and only append non-empty text; for whitespace-only content,
return the replacement without adding an empty note. Preserve the existing
INVENTED handling and ensure test_empty_brackets_never_leak also verifies that
no empty note blocks are produced.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: aedd1ce5-30e1-4496-8669-a7a6376b4a2c
⛔ Files ignored due to path filters (1)
skills/module-help.csvis excluded by!**/*.csv
📒 Files selected for processing (28)
.claude-plugin/marketplace.jsonskills/mc-prompter/SKILL.mdskills/mc-prompter/customize.tomlskills/mc-prompter/scripts/run_prompter.pyskills/mc-prompter/scripts/server/__init__.pyskills/mc-prompter/scripts/server/main.pyskills/mc-prompter/scripts/server/script_ingest.pyskills/mc-prompter/scripts/server/static/css/home.cssskills/mc-prompter/scripts/server/static/css/overlay.cssskills/mc-prompter/scripts/server/static/css/prompt.cssskills/mc-prompter/scripts/server/static/css/remote.cssskills/mc-prompter/scripts/server/static/css/shared.cssskills/mc-prompter/scripts/server/static/home.htmlskills/mc-prompter/scripts/server/static/js/home.jsskills/mc-prompter/scripts/server/static/js/model.jsskills/mc-prompter/scripts/server/static/js/overlay.jsskills/mc-prompter/scripts/server/static/js/prompt.jsskills/mc-prompter/scripts/server/static/js/remote.jsskills/mc-prompter/scripts/server/static/js/scroll.jsskills/mc-prompter/scripts/server/static/js/settings.jsskills/mc-prompter/scripts/server/static/js/ws.jsskills/mc-prompter/scripts/server/static/overlay.htmlskills/mc-prompter/scripts/server/static/prompt.htmlskills/mc-prompter/scripts/server/static/remote.htmlskills/mc-prompter/scripts/tests/fixtures/sample-script.mdskills/mc-prompter/scripts/tests/test-run_prompter.pyskills/mc-prompter/scripts/tests/test-script_ingest.pyskills/mc-prompter/scripts/tests/test-server.py
|
|
||
| SCRIPTS_DIR = Path(__file__).resolve().parent | ||
| DEFAULT_PORT = 8770 | ||
| AIOHTTP_PIN = "aiohttp==3.12.15" |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check PyPI for the pinned aiohttp version
curl -s https://pypi.org/pypi/aiohttp/3.12.15/json | jq '.info.version' 2>/dev/null || echo "version not found"
# Check for security advisories
gh api graphql -f query='
{
securityVulnerabilities(first: 10, ecosystem: PIP, package: "aiohttp") {
nodes {
advisory { summary severity publishedAt }
vulnerableVersionRange
firstPatchedVersion { identifier }
}
}
}'Repository: bmad-code-org/bmad-manticore
Length of output: 2598
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="skills/mc-prompter/scripts/run_prompter.py"
# Show the relevant section with line numbers
sed -n '1,140p' "$FILE" | cat -nRepository: bmad-code-org/bmad-manticore
Length of output: 6058
Bump aiohttp to a patched release. aiohttp==3.12.15 is published, but it falls within current advisories for <= 3.14.0; upgrade to 3.14.1 or newer.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@skills/mc-prompter/scripts/run_prompter.py` at line 58, The AIOHTTP_PIN
dependency is set to the vulnerable 3.12.15 release; update AIOHTTP_PIN to
version 3.14.1 or newer and ensure any corresponding dependency references
remain consistent.
| try: | ||
| info = wait_for_health(port, child) | ||
| if info is None: | ||
| print("error: server failed to start", file=sys.stderr) | ||
| terminate_server(child) | ||
| return 1 | ||
|
|
||
| write_session_file(session_file, port, child.pid, token, script) | ||
|
|
||
| base_url = f"http://127.0.0.1:{port}" | ||
| local_url = f"{base_url}/?token={token}" | ||
| print(f"mc-prompter is up (session {token[:8]})") | ||
| print(f" home: {local_url}") | ||
| print(f" prompt: {base_url}/prompt") | ||
| print(f" remote: http://127.0.0.1:{port}/remote?token={token}") | ||
| if args.lan: | ||
| ip = local_ip() | ||
| print(f" LAN remote: http://{ip}:{port}/remote?token={token}") | ||
| print(" note: on Windows the first --lan launch triggers a " | ||
| "Windows Firewall consent dialog; allow it for the LAN " | ||
| "remote to reach the server.") | ||
| print(f" session file: {session_file}", flush=True) | ||
|
|
||
| if not args.no_open: | ||
| webbrowser.open(local_url) | ||
|
|
||
| def _on_terminate(signum, frame): | ||
| raise KeyboardInterrupt | ||
|
|
||
| signal.signal(signal.SIGTERM, _on_terminate) | ||
| try: | ||
| child.wait() | ||
| except KeyboardInterrupt: | ||
| terminate_server(child) | ||
| return 0 | ||
| return 0 if child.returncode == 0 else 1 | ||
| finally: | ||
| # Never leave a stale session file advertising a dead pid/token. | ||
| with contextlib.suppress(OSError): | ||
| session_file.unlink(missing_ok=True) |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Child process is orphaned if interrupted during startup.
terminate_server(child) is only called in the except KeyboardInterrupt block (line 327) and on failed startup (line 298), but not in the finally block. If the user presses Ctrl-C during wait_for_health (up to 300 s on a cold uv cache) or during URL printing, the KeyboardInterrupt propagates directly to the finally block — which only removes the session file. The child, running in its own process group, keeps the port alive with no parent to reap it.
Move terminate_server(child) into the finally block so the child is always terminated regardless of how the try exits. terminate_server already no-ops when the child has exited (child.poll() is not None check at line 193).
🔧 Proposed fix
try:
info = wait_for_health(port, child)
if info is None:
print("error: server failed to start", file=sys.stderr)
- terminate_server(child)
return 1
write_session_file(session_file, port, child.pid, token, script)
base_url = f"http://127.0.0.1:{port}"
local_url = f"{base_url}/?token={token}"
print(f"mc-prompter is up (session {token[:8]})")
print(f" home: {local_url}")
print(f" prompt: {base_url}/prompt")
print(f" remote: http://127.0.0.1:{port}/remote?token={token}")
if args.lan:
ip = local_ip()
print(f" LAN remote: http://{ip}:{port}/remote?token={token}")
print(" note: on Windows the first --lan launch triggers a "
"Windows Firewall consent dialog; allow it for the LAN "
"remote to reach the server.")
print(f" session file: {session_file}", flush=True)
if not args.no_open:
webbrowser.open(local_url)
def _on_terminate(signum, frame):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, _on_terminate)
try:
child.wait()
except KeyboardInterrupt:
- terminate_server(child)
return 0
return 0 if child.returncode == 0 else 1
finally:
+ terminate_server(child)
# Never leave a stale session file advertising a dead pid/token.
with contextlib.suppress(OSError):
session_file.unlink(missing_ok=True)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| try: | |
| info = wait_for_health(port, child) | |
| if info is None: | |
| print("error: server failed to start", file=sys.stderr) | |
| terminate_server(child) | |
| return 1 | |
| write_session_file(session_file, port, child.pid, token, script) | |
| base_url = f"http://127.0.0.1:{port}" | |
| local_url = f"{base_url}/?token={token}" | |
| print(f"mc-prompter is up (session {token[:8]})") | |
| print(f" home: {local_url}") | |
| print(f" prompt: {base_url}/prompt") | |
| print(f" remote: http://127.0.0.1:{port}/remote?token={token}") | |
| if args.lan: | |
| ip = local_ip() | |
| print(f" LAN remote: http://{ip}:{port}/remote?token={token}") | |
| print(" note: on Windows the first --lan launch triggers a " | |
| "Windows Firewall consent dialog; allow it for the LAN " | |
| "remote to reach the server.") | |
| print(f" session file: {session_file}", flush=True) | |
| if not args.no_open: | |
| webbrowser.open(local_url) | |
| def _on_terminate(signum, frame): | |
| raise KeyboardInterrupt | |
| signal.signal(signal.SIGTERM, _on_terminate) | |
| try: | |
| child.wait() | |
| except KeyboardInterrupt: | |
| terminate_server(child) | |
| return 0 | |
| return 0 if child.returncode == 0 else 1 | |
| finally: | |
| # Never leave a stale session file advertising a dead pid/token. | |
| with contextlib.suppress(OSError): | |
| session_file.unlink(missing_ok=True) | |
| try: | |
| info = wait_for_health(port, child) | |
| if info is None: | |
| print("error: server failed to start", file=sys.stderr) | |
| return 1 | |
| write_session_file(session_file, port, child.pid, token, script) | |
| base_url = f"http://127.0.0.1:{port}" | |
| local_url = f"{base_url}/?token={token}" | |
| print(f"mc-prompter is up (session {token[:8]})") | |
| print(f" home: {local_url}") | |
| print(f" prompt: {base_url}/prompt") | |
| print(f" remote: http://127.0.0.1:{port}/remote?token={token}") | |
| if args.lan: | |
| ip = local_ip() | |
| print(f" LAN remote: http://{ip}:{port}/remote?token={token}") | |
| print(" note: on Windows the first --lan launch triggers a " | |
| "Windows Firewall consent dialog; allow it for the LAN " | |
| "remote to reach the server.") | |
| print(f" session file: {session_file}", flush=True) | |
| if not args.no_open: | |
| webbrowser.open(local_url) | |
| def _on_terminate(signum, frame): | |
| raise KeyboardInterrupt | |
| signal.signal(signal.SIGTERM, _on_terminate) | |
| try: | |
| child.wait() | |
| except KeyboardInterrupt: | |
| return 0 | |
| return 0 if child.returncode == 0 else 1 | |
| finally: | |
| terminate_server(child) | |
| # Never leave a stale session file advertising a dead pid/token. | |
| with contextlib.suppress(OSError): | |
| session_file.unlink(missing_ok=True) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@skills/mc-prompter/scripts/run_prompter.py` around lines 294 - 333, Ensure
the child process is always cleaned up when the outer startup block exits,
including interruptions during health checks or output. Move or add
terminate_server(child) at the beginning of the outer finally block surrounding
wait_for_health and URL printing, while retaining session-file cleanup; use the
existing terminate_server helper and avoid relying only on the nested
KeyboardInterrupt handler.
| function loadSections() { | ||
| return MC.model.fetchSource(token).then(function (src) { | ||
| docVersion = src['doc-version']; | ||
| sections = []; | ||
| var docSections = (src.doc && src.doc.sections) || []; | ||
| for (var i = 0; i < docSections.length; i++) { | ||
| sections.push({ | ||
| id: docSections[i].id, | ||
| heading: docSections[i].heading || (i === 0 ? 'Preamble' : 'Untitled section') | ||
| }); | ||
| } | ||
| renderSections(); | ||
| }).catch(function (err) { | ||
| if (err.status === 401 || err.status === 403) { | ||
| showError('The session token was rejected. Grab a fresh remote URL from the home page.'); | ||
| } | ||
| }); | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Surface non-auth errors from loadSections to the user.
The .catch handler only shows an error for 401/403. Network failures or 500s are silently swallowed — the user sees an empty section list with no indication of what went wrong. A minimal non-fatal message would close the feedback gap.
💡 Proposed fix
}).catch(function (err) {
if (err.status === 401 || err.status === 403) {
showError('The session token was rejected. Grab a fresh remote URL from the home page.');
+ } else {
+ els.secCount.textContent = '(load failed)';
}
});📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function loadSections() { | |
| return MC.model.fetchSource(token).then(function (src) { | |
| docVersion = src['doc-version']; | |
| sections = []; | |
| var docSections = (src.doc && src.doc.sections) || []; | |
| for (var i = 0; i < docSections.length; i++) { | |
| sections.push({ | |
| id: docSections[i].id, | |
| heading: docSections[i].heading || (i === 0 ? 'Preamble' : 'Untitled section') | |
| }); | |
| } | |
| renderSections(); | |
| }).catch(function (err) { | |
| if (err.status === 401 || err.status === 403) { | |
| showError('The session token was rejected. Grab a fresh remote URL from the home page.'); | |
| } | |
| }); | |
| } | |
| function loadSections() { | |
| return MC.model.fetchSource(token).then(function (src) { | |
| docVersion = src['doc-version']; | |
| sections = []; | |
| var docSections = (src.doc && src.doc.sections) || []; | |
| for (var i = 0; i < docSections.length; i++) { | |
| sections.push({ | |
| id: docSections[i].id, | |
| heading: docSections[i].heading || (i === 0 ? 'Preamble' : 'Untitled section') | |
| }); | |
| } | |
| renderSections(); | |
| }).catch(function (err) { | |
| if (err.status === 401 || err.status === 403) { | |
| showError('The session token was rejected. Grab a fresh remote URL from the home page.'); | |
| } else { | |
| els.secCount.textContent = '(load failed)'; | |
| } | |
| }); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@skills/mc-prompter/scripts/server/static/js/remote.js` around lines 55 - 72,
Update the catch handler in loadSections to retain the existing 401/403 message
and display a minimal non-fatal error message for all other failures, including
network errors and server responses such as 500, so users are informed instead
of seeing an empty section list.
| 2. Locate the script. Inside a pipeline project ("record with the teleprompter"), it is the project's `script.md` under the project folder. Standalone, it is any file path the creator names, markdown or plain text. No file at all is also valid: launch without `--script` and the creator pastes text on the home page. | ||
| 3. Launch: `uv run {skill-root}/scripts/run_prompter.py --script <path>` with `--port <N>` when the config or the creator sets one and `--owner-wpm <N>` when `[owner] wpm` is known. Add `--lan` only when the creator wants the phone remote or a tablet display; it binds the LAN and on Windows triggers a firewall consent dialog. The launcher probes the port, prints the local URL, the remote URL with its session token, and the session file path, then keeps the server running until Ctrl-C. |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Align the no-file workflow with the launcher CLI.
The skill says launching without --script is valid, but skills/mc-prompter/scripts/run_prompter.py declares --script as required. A no-file session therefore exits with a usage error instead of opening the paste editor.
Either make --script optional in the launcher and ensure the server supports an empty document, or remove the no-file workflow from this contract.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@skills/mc-prompter/SKILL.md` around lines 13 - 14, Align the documented
no-file workflow with the implementation: update the launcher CLI handling in
run_prompter.py so --script is optional, and ensure the server initializes an
empty document that opens the paste editor when omitted; otherwise remove the
no-file claim from SKILL.md and related launch instructions.
What this is
Phase A of mc-prompter: a classic teleprompter as a new Manticore service skill (mc-audio pattern: no stage, no gate, no project.json state). It serves the creator-owned record stage for scripted videos and works standalone for any text. This is PR 1 of a planned stack of 3: PR 2 adds voice-follow (streaming local ASR tracks the speaker through the script), PR 3 adds producer mode (rundown + local LLM cues for free-talk shows).
What you get
One command starts a local server and opens the browser UI:
Everything is offline and local: no CDN, no external requests, aiohttp pinned, browser UI is plain HTML/JS/CSS with no build step. Server binds 127.0.0.1 by default; --lan opts into LAN with the token requirement for non-loopback peers.
Module integration
Verification
🤖 Generated with Claude Code
https://claude.ai/code/session_01BbneC1PckyV8vkDzhGYkpN
Summary by CodeRabbit