agentvibes 5.13.1 → 5.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/audio/ui/CREDITS.txt +16 -16
- package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
- package/.claude/commands/agent-vibes-rdp.md +24 -24
- package/.claude/config/audio-effects.cfg.sample +52 -52
- package/.claude/docs/TERMUX_SETUP.md +408 -408
- package/.claude/github-star-reminder.txt +1 -1
- package/.claude/hooks/kokoro-server.py +219 -219
- package/.claude/hooks/kokoro-tts.py +141 -141
- package/.claude/hooks/party-stage-roster.py +328 -328
- package/.claude/hooks/play-tts-kokoro.sh +7 -2
- package/.claude/hooks/play-tts-ssh-remote.sh +38 -3
- package/.claude/hooks/requirements.txt +6 -6
- package/.claude/hooks/soprano-gradio-synth.py +139 -139
- package/.claude/hooks/translator.py +237 -237
- package/.claude/hooks-windows/agentvibes-session-id.ps1 +83 -0
- package/.claude/hooks-windows/kokoro-server.py +219 -219
- package/.claude/hooks-windows/kokoro-tts.py +107 -107
- package/.claude/hooks-windows/play-tts.ps1 +112 -1
- package/.claude/hooks-windows/session-start-tts.ps1 +36 -0
- package/.claude/hooks-windows/soprano-gradio-synth.py +153 -153
- package/.claude/verbosity.txt +1 -1
- package/.clawdbot/README.md +105 -105
- package/CLAUDE.md +84 -176
- package/README.md +35 -2
- package/RELEASE_NOTES.md +139 -0
- package/WINDOWS-SETUP.md +208 -208
- package/bin/agent-vibes +39 -39
- package/bin/mcp-server.js +121 -121
- package/bin/test-bmad-pr +78 -78
- package/mcp-server/QUICK_START.md +203 -203
- package/mcp-server/README.md +345 -345
- package/mcp-server/examples/claude_desktop_config.json +11 -11
- package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
- package/mcp-server/examples/custom_instructions.md +169 -169
- package/mcp-server/install-deps.js +12 -2
- package/mcp-server/server.py +2111 -2085
- package/mcp-server/test_mcp_correctness.py +504 -504
- package/mcp-server/test_windows_script_parity.py +339 -339
- package/package.json +6 -3
- package/setup-ssh-receiver.ps1 +259 -0
- package/setup-windows.ps1 +39 -29
- package/src/cli/list-personalities.js +110 -110
- package/src/commands/bmad-voices.js +394 -394
- package/src/console/brand-colors.js +13 -13
- package/src/console/constants/personalities.js +44 -44
- package/src/console/tabs/agents-tab.js +14 -4
- package/src/console/tabs/help-tab.js +314 -314
- package/src/console/tabs/readme-tab.js +272 -272
- package/src/console/tabs/settings-tab.js +31 -6
- package/src/console/tabs/setup-tab.js +154 -32
- package/src/console/tabs/voices-tab.js +48 -6
- package/src/console/widgets/destroy-list.js +25 -25
- package/src/console/widgets/notice.js +55 -55
- package/src/installer/language-screen.js +31 -31
- package/src/installer/music-file-input.js +304 -304
- package/src/installer.js +98 -37
- package/src/services/language-service.js +47 -47
- package/src/services/llm-provider-service.js +4 -1
- package/src/services/provider-voice-catalog.js +9 -5
- package/src/utils/audio-format-validator.js +277 -277
- package/src/utils/dependency-checker.js +469 -469
- package/src/utils/file-ownership-verifier.js +358 -358
- package/src/utils/music-file-validator.js +285 -285
- package/src/utils/preview-list-prompt.js +144 -144
- package/src/utils/secure-music-storage.js +412 -412
- package/templates/agentvibes-receiver.ps1 +311 -0
- package/templates/agentvibes-receiver.sh +297 -297
- package/voice-assignments.json +8244 -8244
- package/.agentvibes/config.json +0 -17
- package/.agentvibes/install-manifest.json +0 -362
- package/.claude/config/audio-effects.cfg +0 -7
- package/.claude/config/audio-effects.cfg.bak-kokoro +0 -7
- package/.claude/config/background-music-enabled.txt +0 -1
- package/.claude/config/background-music-position.txt +0 -27
- package/.claude/config/background-music-volume.txt +0 -1
- package/.claude/config/background-music.cfg +0 -1
- package/.claude/config/background-music.txt +0 -1
- package/.claude/config/language.txt +0 -1
- package/.claude/config/personality.txt +0 -1
- package/.claude/config/reverb-level.txt +0 -1
- package/.claude/config/tts-speech-rate.txt +0 -1
- package/.claude/config/tts-verbosity.txt +0 -1
- package/.claude/hooks-windows/audio-cache-utils.ps1.user.bak +0 -119
- package/.claude/hooks-windows/soprano-gradio-synth.py.user.bak +0 -153
|
@@ -1,328 +1,328 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# /// script
|
|
3
|
-
# requires-python = ">=3.11"
|
|
4
|
-
# ///
|
|
5
|
-
"""Broadcast a BMAD party's cast to an AgentVibes receiver (best-effort doorbell).
|
|
6
|
-
|
|
7
|
-
A receiver normally learns an avatar only when it speaks (one /speak event per
|
|
8
|
-
line). This doorbell stages the WHOLE cast up front: it resolves the active
|
|
9
|
-
party roster (via BMAD's read-only ``resolve_party.py``), joins each member to
|
|
10
|
-
its saved voice/avatar, and POSTs the result to a receiver's ``/stage-roster``
|
|
11
|
-
endpoint keyed by the SESSION id. Any receiver that implements ``/stage-roster``
|
|
12
|
-
can then paint the labelled, voiced cast at once; a receiver that doesn't (or a
|
|
13
|
-
down/absent one) simply never hears the doorbell and the party runs unchanged.
|
|
14
|
-
|
|
15
|
-
Session key
|
|
16
|
-
-----------
|
|
17
|
-
The broadcast is keyed by the canonical routing SESSION id -- ``basename`` of
|
|
18
|
-
``CLAUDE_PROJECT_DIR`` (falling back to ``--project-root``), slugified the same
|
|
19
|
-
way ``.claude/hooks/session-id.sh :: av_session_id`` slugifies it -- plus the
|
|
20
|
-
``--session-suffix`` (default ``bmad-party-mode``). The suffix stages the party
|
|
21
|
-
on its OWN session, distinct from the plain-TTS session for the same project.
|
|
22
|
-
|
|
23
|
-
Open-cast vs fixed-cast (the forward-compat contract)
|
|
24
|
-
-----------------------------------------------------
|
|
25
|
-
* Fixed-cast (members present): POST
|
|
26
|
-
{"session","project","roster":[{name,agentId,voice,role?,avatarId?}]}
|
|
27
|
-
* Open-cast (resolve_party sets ``open_cast`` when a room lists no fixed
|
|
28
|
-
members, only a freeform ``scene`` theme): POST
|
|
29
|
-
{"session","project","roster":[...anchors if any...],
|
|
30
|
-
"openCast":true,"room":{"id","name","scene"}}
|
|
31
|
-
Open-cast rooms are broadcast rather than skipped, so a receiver that
|
|
32
|
-
understands the room theme has everything it needs to render it.
|
|
33
|
-
|
|
34
|
-
``project`` is sent as a back-compat alias of ``session`` (current receivers key
|
|
35
|
-
on ``project``).
|
|
36
|
-
|
|
37
|
-
Best-effort contract
|
|
38
|
-
--------------------
|
|
39
|
-
Stdlib only (urllib). Any failure -- resolver error, connection refused, 404,
|
|
40
|
-
timeout -- prints a benign note to stderr and returns 0. A down or absent
|
|
41
|
-
receiver must NEVER block or fail the party.
|
|
42
|
-
|
|
43
|
-
party-stage-roster.py --project-root P [--skill S] [--party ID]
|
|
44
|
-
[--session-suffix bmad-party-mode] [--url http://127.0.0.1:3747]
|
|
45
|
-
"""
|
|
46
|
-
|
|
47
|
-
import argparse
|
|
48
|
-
import json
|
|
49
|
-
import os
|
|
50
|
-
import re
|
|
51
|
-
import subprocess
|
|
52
|
-
import sys
|
|
53
|
-
import urllib.request
|
|
54
|
-
import urllib.error
|
|
55
|
-
from pathlib import Path
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def _resolve_roster(project_root: Path, skill_root: Path, party):
|
|
59
|
-
"""Run BMAD's resolve_party.py (READ-ONLY) and return its parsed JSON, or None."""
|
|
60
|
-
script = skill_root / "scripts" / "resolve_party.py"
|
|
61
|
-
cmd = [sys.executable, str(script), "--project-root", str(project_root), "--skill", str(skill_root)]
|
|
62
|
-
if party:
|
|
63
|
-
cmd += ["--party", party]
|
|
64
|
-
try:
|
|
65
|
-
# encoding+errors are REQUIRED on Windows: text=True defaults to cp1252,
|
|
66
|
-
# which crashes on the non-Latin icon bytes the resolver can emit.
|
|
67
|
-
# PYTHONUTF8 forces the child to write UTF-8 rather than crash.
|
|
68
|
-
child_env = {**os.environ, "PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8"}
|
|
69
|
-
out = subprocess.run(cmd, capture_output=True, text=True,
|
|
70
|
-
encoding="utf-8", errors="replace",
|
|
71
|
-
env=child_env, timeout=60)
|
|
72
|
-
except (OSError, subprocess.SubprocessError) as e:
|
|
73
|
-
sys.stderr.write(f"stage-roster: resolve_party failed to run: {e}\n")
|
|
74
|
-
return None
|
|
75
|
-
if out.returncode != 0 or not out.stdout.strip():
|
|
76
|
-
sys.stderr.write("stage-roster: resolve_party returned nothing usable\n")
|
|
77
|
-
return None
|
|
78
|
-
try:
|
|
79
|
-
return json.loads(out.stdout)
|
|
80
|
-
except json.JSONDecodeError:
|
|
81
|
-
sys.stderr.write("stage-roster: resolve_party output was not JSON\n")
|
|
82
|
-
return None
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def _home_dir() -> Path:
|
|
86
|
-
"""User home, resilient to the launch environment.
|
|
87
|
-
|
|
88
|
-
os.path.expanduser("~") returns "~" unchanged when USERPROFILE and
|
|
89
|
-
HOMEDRIVE/HOMEPATH are all unset -- which is exactly the env some launchers
|
|
90
|
-
hand these scripts. Check the vars actually present, Windows-then-POSIX.
|
|
91
|
-
"""
|
|
92
|
-
for var in ("USERPROFILE", "HOME"):
|
|
93
|
-
v = os.environ.get(var)
|
|
94
|
-
if v:
|
|
95
|
-
return Path(v)
|
|
96
|
-
hd, hp = os.environ.get("HOMEDRIVE"), os.environ.get("HOMEPATH")
|
|
97
|
-
if hd and hp:
|
|
98
|
-
return Path(hd + hp)
|
|
99
|
-
return Path(os.path.expanduser("~"))
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def _find_skill_root(project_root: Path):
|
|
103
|
-
"""Locate the installed bmad-party-mode skill dir (project-local, else global).
|
|
104
|
-
|
|
105
|
-
Returns a Path whose scripts/resolve_party.py exists, or None.
|
|
106
|
-
"""
|
|
107
|
-
candidates = [
|
|
108
|
-
project_root / ".claude" / "skills" / "bmad-party-mode",
|
|
109
|
-
_home_dir() / ".claude" / "skills" / "bmad-party-mode",
|
|
110
|
-
]
|
|
111
|
-
for c in candidates:
|
|
112
|
-
if (c / "scripts" / "resolve_party.py").exists():
|
|
113
|
-
return c
|
|
114
|
-
return None
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def _load_json_map(name: str) -> dict:
|
|
118
|
-
"""Read a ~/.agentvibes/<name> JSON map. {} if absent or unreadable."""
|
|
119
|
-
path = _home_dir() / ".agentvibes" / name
|
|
120
|
-
try:
|
|
121
|
-
with path.open(encoding="utf-8") as f:
|
|
122
|
-
data = json.load(f)
|
|
123
|
-
except (OSError, json.JSONDecodeError):
|
|
124
|
-
return {}
|
|
125
|
-
return data if isinstance(data, dict) else {}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def _voice_for(code: str, vmap: dict) -> str:
|
|
129
|
-
"""Saved voice string for an agent code, or '' if none.
|
|
130
|
-
|
|
131
|
-
Prefers the richer agents[code].voice; falls back to the flat
|
|
132
|
-
voiceMap[code] (older shape). Both key by the canonical agent code.
|
|
133
|
-
"""
|
|
134
|
-
agents = vmap.get("agents") or {}
|
|
135
|
-
entry = agents.get(code)
|
|
136
|
-
if isinstance(entry, dict) and entry.get("voice"):
|
|
137
|
-
return str(entry["voice"])
|
|
138
|
-
flat = vmap.get("voiceMap") or {}
|
|
139
|
-
if isinstance(flat.get(code), str):
|
|
140
|
-
return flat[code]
|
|
141
|
-
return ""
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def _avatar_for(code: str, vmap: dict, amap: dict) -> str:
|
|
145
|
-
"""Saved avatarId for an agent code, or '' if none.
|
|
146
|
-
|
|
147
|
-
The dedicated avatar map wins; the voice map's agents[id].avatarId is a
|
|
148
|
-
legacy fallback (honoured if present).
|
|
149
|
-
"""
|
|
150
|
-
ag = (amap.get("agents") or {}) if isinstance(amap, dict) else {}
|
|
151
|
-
entry = ag.get(code)
|
|
152
|
-
if isinstance(entry, dict) and entry.get("avatarId"):
|
|
153
|
-
return str(entry["avatarId"])
|
|
154
|
-
agents = vmap.get("agents") or {}
|
|
155
|
-
ventry = agents.get(code)
|
|
156
|
-
if isinstance(ventry, dict) and ventry.get("avatarId"):
|
|
157
|
-
return str(ventry["avatarId"])
|
|
158
|
-
return ""
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
def _session_key(project_root: Path) -> str:
|
|
162
|
-
"""Canonical routing SESSION id -- the same one the TTS forward paths send.
|
|
163
|
-
|
|
164
|
-
Mirrors .claude/hooks/session-id.sh :: av_session_id: basename of
|
|
165
|
-
CLAUDE_PROJECT_DIR (the real user project) when set, else --project-root,
|
|
166
|
-
slugified so the id is a safe JSON string and a stable receiver map key.
|
|
167
|
-
"""
|
|
168
|
-
cpd = os.environ.get("CLAUDE_PROJECT_DIR", "").strip()
|
|
169
|
-
base = Path(cpd) if cpd else project_root
|
|
170
|
-
name = base.name or "unknown"
|
|
171
|
-
# Slugify to match av_session_id: collapse anything outside [A-Za-z0-9._-]
|
|
172
|
-
# to '-', then trim leading/trailing dashes.
|
|
173
|
-
name = re.sub(r"[^A-Za-z0-9._-]", "-", name).strip("-")
|
|
174
|
-
return name or "unknown"
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
def _active_party(session_key: str, explicit):
|
|
178
|
-
"""Resolve which room to stage.
|
|
179
|
-
|
|
180
|
-
Explicit --party wins. Otherwise read the AgentVibes-side active-room state
|
|
181
|
-
file ~/.agentvibes/party-active-<sessionid>.json = {"group": "<id>"} (written
|
|
182
|
-
by party-set-room.sh). If neither is present, return None so the DEFAULT
|
|
183
|
-
party resolves (the common installed-agent room).
|
|
184
|
-
"""
|
|
185
|
-
if explicit:
|
|
186
|
-
return explicit
|
|
187
|
-
path = _home_dir() / ".agentvibes" / f"party-active-{session_key}.json"
|
|
188
|
-
try:
|
|
189
|
-
with path.open(encoding="utf-8") as f:
|
|
190
|
-
data = json.load(f)
|
|
191
|
-
except (OSError, json.JSONDecodeError):
|
|
192
|
-
return None
|
|
193
|
-
if isinstance(data, dict):
|
|
194
|
-
group = data.get("group")
|
|
195
|
-
if isinstance(group, str) and group.strip():
|
|
196
|
-
return group.strip()
|
|
197
|
-
return None
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def build_roster(resolved: dict, vmap: dict, amap: dict):
|
|
201
|
-
"""Join resolver members to voices/avatars. Returns (roster, skipped_names)."""
|
|
202
|
-
members = resolved.get("members") or []
|
|
203
|
-
roster, skipped = [], []
|
|
204
|
-
for m in members:
|
|
205
|
-
code = m.get("code") or ""
|
|
206
|
-
name = m.get("name") or code or "Agent"
|
|
207
|
-
voice = _voice_for(code, vmap)
|
|
208
|
-
if not voice:
|
|
209
|
-
# Without a voice string the member can't key-match a later /speak
|
|
210
|
-
# event, so staging it would orphan an avatar -- skip it.
|
|
211
|
-
skipped.append(name)
|
|
212
|
-
continue
|
|
213
|
-
member = {"name": name, "agentId": code, "voice": voice}
|
|
214
|
-
title = (m.get("title") or "").strip()
|
|
215
|
-
if title:
|
|
216
|
-
member["role"] = title
|
|
217
|
-
avatar = _avatar_for(code, vmap, amap)
|
|
218
|
-
if avatar:
|
|
219
|
-
member["avatarId"] = avatar
|
|
220
|
-
roster.append(member)
|
|
221
|
-
return roster, skipped
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
def post_payload(url: str, payload: dict):
|
|
225
|
-
"""POST the payload to /stage-roster. Returns (ok, message)."""
|
|
226
|
-
body = json.dumps(payload).encode("utf-8")
|
|
227
|
-
base = url.rstrip("/")
|
|
228
|
-
req = urllib.request.Request(
|
|
229
|
-
base + "/stage-roster",
|
|
230
|
-
data=body,
|
|
231
|
-
headers={"Content-Type": "application/json", "Origin": base},
|
|
232
|
-
method="POST",
|
|
233
|
-
)
|
|
234
|
-
try:
|
|
235
|
-
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
236
|
-
return True, resp.read().decode("utf-8", "replace")
|
|
237
|
-
except urllib.error.HTTPError as e:
|
|
238
|
-
return False, f"HTTP {e.code}: {e.read().decode('utf-8', 'replace')[:200]}"
|
|
239
|
-
except (urllib.error.URLError, OSError) as e:
|
|
240
|
-
# Receiver not running / unreachable -- benign; the party runs regardless.
|
|
241
|
-
return False, f"could not reach receiver ({e}); is a receiver up?"
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
def _default_url() -> str:
|
|
245
|
-
port = (os.environ.get("AGENTVIBES_TH_PORT")
|
|
246
|
-
or os.environ.get("TALKING_HEAD_PORT")
|
|
247
|
-
or "3747").strip()
|
|
248
|
-
if not port.isdigit():
|
|
249
|
-
port = "3747"
|
|
250
|
-
return f"http://127.0.0.1:{port}"
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
def main() -> int:
|
|
254
|
-
ap = argparse.ArgumentParser(description="Broadcast a BMAD party cast to an AgentVibes receiver.")
|
|
255
|
-
ap.add_argument("--project-root", required=True)
|
|
256
|
-
ap.add_argument("--skill", default=None,
|
|
257
|
-
help="Path to the bmad-party-mode skill dir (auto-located if omitted)")
|
|
258
|
-
ap.add_argument("--party", default=None, help="Explicit group id (wins over the active-room file)")
|
|
259
|
-
ap.add_argument("--session-suffix", "--coin-suffix", dest="session_suffix",
|
|
260
|
-
default="bmad-party-mode",
|
|
261
|
-
help="Append '-<suffix>' to the session key so the party stages on its "
|
|
262
|
-
"OWN session (default: bmad-party-mode).")
|
|
263
|
-
ap.add_argument("--url", default=None)
|
|
264
|
-
args = ap.parse_args()
|
|
265
|
-
|
|
266
|
-
project_root = Path(args.project_root).resolve()
|
|
267
|
-
|
|
268
|
-
skill_root = Path(args.skill).resolve() if args.skill else _find_skill_root(project_root)
|
|
269
|
-
if skill_root is None:
|
|
270
|
-
sys.stderr.write("stage-roster: bmad-party-mode skill not found -- nothing to stage.\n")
|
|
271
|
-
return 0
|
|
272
|
-
|
|
273
|
-
session = _session_key(project_root)
|
|
274
|
-
party = _active_party(session, args.party)
|
|
275
|
-
|
|
276
|
-
resolved = _resolve_roster(project_root, skill_root, party)
|
|
277
|
-
if resolved is None:
|
|
278
|
-
return 0 # resolver failure already warned; never break the party
|
|
279
|
-
|
|
280
|
-
suffix = (args.session_suffix or "").strip().strip("-")
|
|
281
|
-
session_key = f"{session}-{suffix}" if suffix else session
|
|
282
|
-
|
|
283
|
-
vmap = _load_json_map("bmad-voice-map.json")
|
|
284
|
-
amap = _load_json_map("bmad-avatar-map.json")
|
|
285
|
-
roster, skipped = build_roster(resolved, vmap, amap)
|
|
286
|
-
if skipped:
|
|
287
|
-
sys.stderr.write(f"stage-roster: skipped (no saved voice): {', '.join(skipped)}\n")
|
|
288
|
-
|
|
289
|
-
open_cast = bool(resolved.get("open_cast"))
|
|
290
|
-
|
|
291
|
-
if open_cast:
|
|
292
|
-
# Broadcast open-cast rooms rather than skipping them: the room theme is
|
|
293
|
-
# all a receiver needs to render the scene.
|
|
294
|
-
room = {"id": resolved.get("active", ""), "name": resolved.get("name", "")}
|
|
295
|
-
scene = resolved.get("scene")
|
|
296
|
-
if scene:
|
|
297
|
-
room["scene"] = scene
|
|
298
|
-
payload = {"session": session_key, "project": session_key,
|
|
299
|
-
"roster": roster, "openCast": True, "room": room}
|
|
300
|
-
else:
|
|
301
|
-
if not roster:
|
|
302
|
-
sys.stderr.write("stage-roster: no members had a saved voice -- nothing to stage.\n")
|
|
303
|
-
return 0
|
|
304
|
-
payload = {"session": session_key, "project": session_key, "roster": roster}
|
|
305
|
-
|
|
306
|
-
# Test hook: capture the exact payload to a file instead of hitting the
|
|
307
|
-
# network, so the hermetic bats suite can assert the wire shape.
|
|
308
|
-
capture = os.environ.get("AGENTVIBES_STAGE_CAPTURE", "").strip()
|
|
309
|
-
if capture:
|
|
310
|
-
try:
|
|
311
|
-
Path(capture).write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
|
312
|
-
except OSError as e:
|
|
313
|
-
sys.stderr.write(f"stage-roster: could not write capture file: {e}\n")
|
|
314
|
-
print(f"stage-roster: captured payload for '{session_key}' -> {capture}")
|
|
315
|
-
return 0
|
|
316
|
-
|
|
317
|
-
url = args.url or _default_url()
|
|
318
|
-
ok, msg = post_payload(url, payload)
|
|
319
|
-
kind = "open-cast" if open_cast else f"{len(roster)} member(s)"
|
|
320
|
-
if ok:
|
|
321
|
-
print(f"stage-roster: staged {kind} for '{session_key}' -> {msg.strip()}")
|
|
322
|
-
else:
|
|
323
|
-
print(f"stage-roster: {msg}")
|
|
324
|
-
return 0 # benign: a down receiver must never block the party
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
if __name__ == "__main__":
|
|
328
|
-
sys.exit(main())
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.11"
|
|
4
|
+
# ///
|
|
5
|
+
"""Broadcast a BMAD party's cast to an AgentVibes receiver (best-effort doorbell).
|
|
6
|
+
|
|
7
|
+
A receiver normally learns an avatar only when it speaks (one /speak event per
|
|
8
|
+
line). This doorbell stages the WHOLE cast up front: it resolves the active
|
|
9
|
+
party roster (via BMAD's read-only ``resolve_party.py``), joins each member to
|
|
10
|
+
its saved voice/avatar, and POSTs the result to a receiver's ``/stage-roster``
|
|
11
|
+
endpoint keyed by the SESSION id. Any receiver that implements ``/stage-roster``
|
|
12
|
+
can then paint the labelled, voiced cast at once; a receiver that doesn't (or a
|
|
13
|
+
down/absent one) simply never hears the doorbell and the party runs unchanged.
|
|
14
|
+
|
|
15
|
+
Session key
|
|
16
|
+
-----------
|
|
17
|
+
The broadcast is keyed by the canonical routing SESSION id -- ``basename`` of
|
|
18
|
+
``CLAUDE_PROJECT_DIR`` (falling back to ``--project-root``), slugified the same
|
|
19
|
+
way ``.claude/hooks/session-id.sh :: av_session_id`` slugifies it -- plus the
|
|
20
|
+
``--session-suffix`` (default ``bmad-party-mode``). The suffix stages the party
|
|
21
|
+
on its OWN session, distinct from the plain-TTS session for the same project.
|
|
22
|
+
|
|
23
|
+
Open-cast vs fixed-cast (the forward-compat contract)
|
|
24
|
+
-----------------------------------------------------
|
|
25
|
+
* Fixed-cast (members present): POST
|
|
26
|
+
{"session","project","roster":[{name,agentId,voice,role?,avatarId?}]}
|
|
27
|
+
* Open-cast (resolve_party sets ``open_cast`` when a room lists no fixed
|
|
28
|
+
members, only a freeform ``scene`` theme): POST
|
|
29
|
+
{"session","project","roster":[...anchors if any...],
|
|
30
|
+
"openCast":true,"room":{"id","name","scene"}}
|
|
31
|
+
Open-cast rooms are broadcast rather than skipped, so a receiver that
|
|
32
|
+
understands the room theme has everything it needs to render it.
|
|
33
|
+
|
|
34
|
+
``project`` is sent as a back-compat alias of ``session`` (current receivers key
|
|
35
|
+
on ``project``).
|
|
36
|
+
|
|
37
|
+
Best-effort contract
|
|
38
|
+
--------------------
|
|
39
|
+
Stdlib only (urllib). Any failure -- resolver error, connection refused, 404,
|
|
40
|
+
timeout -- prints a benign note to stderr and returns 0. A down or absent
|
|
41
|
+
receiver must NEVER block or fail the party.
|
|
42
|
+
|
|
43
|
+
party-stage-roster.py --project-root P [--skill S] [--party ID]
|
|
44
|
+
[--session-suffix bmad-party-mode] [--url http://127.0.0.1:3747]
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
import argparse
|
|
48
|
+
import json
|
|
49
|
+
import os
|
|
50
|
+
import re
|
|
51
|
+
import subprocess
|
|
52
|
+
import sys
|
|
53
|
+
import urllib.request
|
|
54
|
+
import urllib.error
|
|
55
|
+
from pathlib import Path
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resolve_roster(project_root: Path, skill_root: Path, party):
|
|
59
|
+
"""Run BMAD's resolve_party.py (READ-ONLY) and return its parsed JSON, or None."""
|
|
60
|
+
script = skill_root / "scripts" / "resolve_party.py"
|
|
61
|
+
cmd = [sys.executable, str(script), "--project-root", str(project_root), "--skill", str(skill_root)]
|
|
62
|
+
if party:
|
|
63
|
+
cmd += ["--party", party]
|
|
64
|
+
try:
|
|
65
|
+
# encoding+errors are REQUIRED on Windows: text=True defaults to cp1252,
|
|
66
|
+
# which crashes on the non-Latin icon bytes the resolver can emit.
|
|
67
|
+
# PYTHONUTF8 forces the child to write UTF-8 rather than crash.
|
|
68
|
+
child_env = {**os.environ, "PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8"}
|
|
69
|
+
out = subprocess.run(cmd, capture_output=True, text=True,
|
|
70
|
+
encoding="utf-8", errors="replace",
|
|
71
|
+
env=child_env, timeout=60)
|
|
72
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
73
|
+
sys.stderr.write(f"stage-roster: resolve_party failed to run: {e}\n")
|
|
74
|
+
return None
|
|
75
|
+
if out.returncode != 0 or not out.stdout.strip():
|
|
76
|
+
sys.stderr.write("stage-roster: resolve_party returned nothing usable\n")
|
|
77
|
+
return None
|
|
78
|
+
try:
|
|
79
|
+
return json.loads(out.stdout)
|
|
80
|
+
except json.JSONDecodeError:
|
|
81
|
+
sys.stderr.write("stage-roster: resolve_party output was not JSON\n")
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _home_dir() -> Path:
|
|
86
|
+
"""User home, resilient to the launch environment.
|
|
87
|
+
|
|
88
|
+
os.path.expanduser("~") returns "~" unchanged when USERPROFILE and
|
|
89
|
+
HOMEDRIVE/HOMEPATH are all unset -- which is exactly the env some launchers
|
|
90
|
+
hand these scripts. Check the vars actually present, Windows-then-POSIX.
|
|
91
|
+
"""
|
|
92
|
+
for var in ("USERPROFILE", "HOME"):
|
|
93
|
+
v = os.environ.get(var)
|
|
94
|
+
if v:
|
|
95
|
+
return Path(v)
|
|
96
|
+
hd, hp = os.environ.get("HOMEDRIVE"), os.environ.get("HOMEPATH")
|
|
97
|
+
if hd and hp:
|
|
98
|
+
return Path(hd + hp)
|
|
99
|
+
return Path(os.path.expanduser("~"))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _find_skill_root(project_root: Path):
|
|
103
|
+
"""Locate the installed bmad-party-mode skill dir (project-local, else global).
|
|
104
|
+
|
|
105
|
+
Returns a Path whose scripts/resolve_party.py exists, or None.
|
|
106
|
+
"""
|
|
107
|
+
candidates = [
|
|
108
|
+
project_root / ".claude" / "skills" / "bmad-party-mode",
|
|
109
|
+
_home_dir() / ".claude" / "skills" / "bmad-party-mode",
|
|
110
|
+
]
|
|
111
|
+
for c in candidates:
|
|
112
|
+
if (c / "scripts" / "resolve_party.py").exists():
|
|
113
|
+
return c
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _load_json_map(name: str) -> dict:
|
|
118
|
+
"""Read a ~/.agentvibes/<name> JSON map. {} if absent or unreadable."""
|
|
119
|
+
path = _home_dir() / ".agentvibes" / name
|
|
120
|
+
try:
|
|
121
|
+
with path.open(encoding="utf-8") as f:
|
|
122
|
+
data = json.load(f)
|
|
123
|
+
except (OSError, json.JSONDecodeError):
|
|
124
|
+
return {}
|
|
125
|
+
return data if isinstance(data, dict) else {}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _voice_for(code: str, vmap: dict) -> str:
|
|
129
|
+
"""Saved voice string for an agent code, or '' if none.
|
|
130
|
+
|
|
131
|
+
Prefers the richer agents[code].voice; falls back to the flat
|
|
132
|
+
voiceMap[code] (older shape). Both key by the canonical agent code.
|
|
133
|
+
"""
|
|
134
|
+
agents = vmap.get("agents") or {}
|
|
135
|
+
entry = agents.get(code)
|
|
136
|
+
if isinstance(entry, dict) and entry.get("voice"):
|
|
137
|
+
return str(entry["voice"])
|
|
138
|
+
flat = vmap.get("voiceMap") or {}
|
|
139
|
+
if isinstance(flat.get(code), str):
|
|
140
|
+
return flat[code]
|
|
141
|
+
return ""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _avatar_for(code: str, vmap: dict, amap: dict) -> str:
|
|
145
|
+
"""Saved avatarId for an agent code, or '' if none.
|
|
146
|
+
|
|
147
|
+
The dedicated avatar map wins; the voice map's agents[id].avatarId is a
|
|
148
|
+
legacy fallback (honoured if present).
|
|
149
|
+
"""
|
|
150
|
+
ag = (amap.get("agents") or {}) if isinstance(amap, dict) else {}
|
|
151
|
+
entry = ag.get(code)
|
|
152
|
+
if isinstance(entry, dict) and entry.get("avatarId"):
|
|
153
|
+
return str(entry["avatarId"])
|
|
154
|
+
agents = vmap.get("agents") or {}
|
|
155
|
+
ventry = agents.get(code)
|
|
156
|
+
if isinstance(ventry, dict) and ventry.get("avatarId"):
|
|
157
|
+
return str(ventry["avatarId"])
|
|
158
|
+
return ""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _session_key(project_root: Path) -> str:
|
|
162
|
+
"""Canonical routing SESSION id -- the same one the TTS forward paths send.
|
|
163
|
+
|
|
164
|
+
Mirrors .claude/hooks/session-id.sh :: av_session_id: basename of
|
|
165
|
+
CLAUDE_PROJECT_DIR (the real user project) when set, else --project-root,
|
|
166
|
+
slugified so the id is a safe JSON string and a stable receiver map key.
|
|
167
|
+
"""
|
|
168
|
+
cpd = os.environ.get("CLAUDE_PROJECT_DIR", "").strip()
|
|
169
|
+
base = Path(cpd) if cpd else project_root
|
|
170
|
+
name = base.name or "unknown"
|
|
171
|
+
# Slugify to match av_session_id: collapse anything outside [A-Za-z0-9._-]
|
|
172
|
+
# to '-', then trim leading/trailing dashes.
|
|
173
|
+
name = re.sub(r"[^A-Za-z0-9._-]", "-", name).strip("-")
|
|
174
|
+
return name or "unknown"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _active_party(session_key: str, explicit):
|
|
178
|
+
"""Resolve which room to stage.
|
|
179
|
+
|
|
180
|
+
Explicit --party wins. Otherwise read the AgentVibes-side active-room state
|
|
181
|
+
file ~/.agentvibes/party-active-<sessionid>.json = {"group": "<id>"} (written
|
|
182
|
+
by party-set-room.sh). If neither is present, return None so the DEFAULT
|
|
183
|
+
party resolves (the common installed-agent room).
|
|
184
|
+
"""
|
|
185
|
+
if explicit:
|
|
186
|
+
return explicit
|
|
187
|
+
path = _home_dir() / ".agentvibes" / f"party-active-{session_key}.json"
|
|
188
|
+
try:
|
|
189
|
+
with path.open(encoding="utf-8") as f:
|
|
190
|
+
data = json.load(f)
|
|
191
|
+
except (OSError, json.JSONDecodeError):
|
|
192
|
+
return None
|
|
193
|
+
if isinstance(data, dict):
|
|
194
|
+
group = data.get("group")
|
|
195
|
+
if isinstance(group, str) and group.strip():
|
|
196
|
+
return group.strip()
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def build_roster(resolved: dict, vmap: dict, amap: dict):
|
|
201
|
+
"""Join resolver members to voices/avatars. Returns (roster, skipped_names)."""
|
|
202
|
+
members = resolved.get("members") or []
|
|
203
|
+
roster, skipped = [], []
|
|
204
|
+
for m in members:
|
|
205
|
+
code = m.get("code") or ""
|
|
206
|
+
name = m.get("name") or code or "Agent"
|
|
207
|
+
voice = _voice_for(code, vmap)
|
|
208
|
+
if not voice:
|
|
209
|
+
# Without a voice string the member can't key-match a later /speak
|
|
210
|
+
# event, so staging it would orphan an avatar -- skip it.
|
|
211
|
+
skipped.append(name)
|
|
212
|
+
continue
|
|
213
|
+
member = {"name": name, "agentId": code, "voice": voice}
|
|
214
|
+
title = (m.get("title") or "").strip()
|
|
215
|
+
if title:
|
|
216
|
+
member["role"] = title
|
|
217
|
+
avatar = _avatar_for(code, vmap, amap)
|
|
218
|
+
if avatar:
|
|
219
|
+
member["avatarId"] = avatar
|
|
220
|
+
roster.append(member)
|
|
221
|
+
return roster, skipped
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def post_payload(url: str, payload: dict):
|
|
225
|
+
"""POST the payload to /stage-roster. Returns (ok, message)."""
|
|
226
|
+
body = json.dumps(payload).encode("utf-8")
|
|
227
|
+
base = url.rstrip("/")
|
|
228
|
+
req = urllib.request.Request(
|
|
229
|
+
base + "/stage-roster",
|
|
230
|
+
data=body,
|
|
231
|
+
headers={"Content-Type": "application/json", "Origin": base},
|
|
232
|
+
method="POST",
|
|
233
|
+
)
|
|
234
|
+
try:
|
|
235
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
236
|
+
return True, resp.read().decode("utf-8", "replace")
|
|
237
|
+
except urllib.error.HTTPError as e:
|
|
238
|
+
return False, f"HTTP {e.code}: {e.read().decode('utf-8', 'replace')[:200]}"
|
|
239
|
+
except (urllib.error.URLError, OSError) as e:
|
|
240
|
+
# Receiver not running / unreachable -- benign; the party runs regardless.
|
|
241
|
+
return False, f"could not reach receiver ({e}); is a receiver up?"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _default_url() -> str:
|
|
245
|
+
port = (os.environ.get("AGENTVIBES_TH_PORT")
|
|
246
|
+
or os.environ.get("TALKING_HEAD_PORT")
|
|
247
|
+
or "3747").strip()
|
|
248
|
+
if not port.isdigit():
|
|
249
|
+
port = "3747"
|
|
250
|
+
return f"http://127.0.0.1:{port}"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def main() -> int:
|
|
254
|
+
ap = argparse.ArgumentParser(description="Broadcast a BMAD party cast to an AgentVibes receiver.")
|
|
255
|
+
ap.add_argument("--project-root", required=True)
|
|
256
|
+
ap.add_argument("--skill", default=None,
|
|
257
|
+
help="Path to the bmad-party-mode skill dir (auto-located if omitted)")
|
|
258
|
+
ap.add_argument("--party", default=None, help="Explicit group id (wins over the active-room file)")
|
|
259
|
+
ap.add_argument("--session-suffix", "--coin-suffix", dest="session_suffix",
|
|
260
|
+
default="bmad-party-mode",
|
|
261
|
+
help="Append '-<suffix>' to the session key so the party stages on its "
|
|
262
|
+
"OWN session (default: bmad-party-mode).")
|
|
263
|
+
ap.add_argument("--url", default=None)
|
|
264
|
+
args = ap.parse_args()
|
|
265
|
+
|
|
266
|
+
project_root = Path(args.project_root).resolve()
|
|
267
|
+
|
|
268
|
+
skill_root = Path(args.skill).resolve() if args.skill else _find_skill_root(project_root)
|
|
269
|
+
if skill_root is None:
|
|
270
|
+
sys.stderr.write("stage-roster: bmad-party-mode skill not found -- nothing to stage.\n")
|
|
271
|
+
return 0
|
|
272
|
+
|
|
273
|
+
session = _session_key(project_root)
|
|
274
|
+
party = _active_party(session, args.party)
|
|
275
|
+
|
|
276
|
+
resolved = _resolve_roster(project_root, skill_root, party)
|
|
277
|
+
if resolved is None:
|
|
278
|
+
return 0 # resolver failure already warned; never break the party
|
|
279
|
+
|
|
280
|
+
suffix = (args.session_suffix or "").strip().strip("-")
|
|
281
|
+
session_key = f"{session}-{suffix}" if suffix else session
|
|
282
|
+
|
|
283
|
+
vmap = _load_json_map("bmad-voice-map.json")
|
|
284
|
+
amap = _load_json_map("bmad-avatar-map.json")
|
|
285
|
+
roster, skipped = build_roster(resolved, vmap, amap)
|
|
286
|
+
if skipped:
|
|
287
|
+
sys.stderr.write(f"stage-roster: skipped (no saved voice): {', '.join(skipped)}\n")
|
|
288
|
+
|
|
289
|
+
open_cast = bool(resolved.get("open_cast"))
|
|
290
|
+
|
|
291
|
+
if open_cast:
|
|
292
|
+
# Broadcast open-cast rooms rather than skipping them: the room theme is
|
|
293
|
+
# all a receiver needs to render the scene.
|
|
294
|
+
room = {"id": resolved.get("active", ""), "name": resolved.get("name", "")}
|
|
295
|
+
scene = resolved.get("scene")
|
|
296
|
+
if scene:
|
|
297
|
+
room["scene"] = scene
|
|
298
|
+
payload = {"session": session_key, "project": session_key,
|
|
299
|
+
"roster": roster, "openCast": True, "room": room}
|
|
300
|
+
else:
|
|
301
|
+
if not roster:
|
|
302
|
+
sys.stderr.write("stage-roster: no members had a saved voice -- nothing to stage.\n")
|
|
303
|
+
return 0
|
|
304
|
+
payload = {"session": session_key, "project": session_key, "roster": roster}
|
|
305
|
+
|
|
306
|
+
# Test hook: capture the exact payload to a file instead of hitting the
|
|
307
|
+
# network, so the hermetic bats suite can assert the wire shape.
|
|
308
|
+
capture = os.environ.get("AGENTVIBES_STAGE_CAPTURE", "").strip()
|
|
309
|
+
if capture:
|
|
310
|
+
try:
|
|
311
|
+
Path(capture).write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
|
312
|
+
except OSError as e:
|
|
313
|
+
sys.stderr.write(f"stage-roster: could not write capture file: {e}\n")
|
|
314
|
+
print(f"stage-roster: captured payload for '{session_key}' -> {capture}")
|
|
315
|
+
return 0
|
|
316
|
+
|
|
317
|
+
url = args.url or _default_url()
|
|
318
|
+
ok, msg = post_payload(url, payload)
|
|
319
|
+
kind = "open-cast" if open_cast else f"{len(roster)} member(s)"
|
|
320
|
+
if ok:
|
|
321
|
+
print(f"stage-roster: staged {kind} for '{session_key}' -> {msg.strip()}")
|
|
322
|
+
else:
|
|
323
|
+
print(f"stage-roster: {msg}")
|
|
324
|
+
return 0 # benign: a down receiver must never block the party
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == "__main__":
|
|
328
|
+
sys.exit(main())
|