agentvibes 5.12.0 → 5.13.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/commands/agent-vibes/commands.json +0 -20
- package/.claude/commands/agent-vibes/unmute.md +6 -2
- package/.claude/config/audio-effects.cfg +6 -6
- package/.claude/github-star-reminder.txt +1 -1
- package/.claude/hooks/agentvibes-session-id.sh +69 -0
- package/.claude/hooks/bmad-party-speak.sh +20 -4
- package/.claude/hooks/bmad-speak.sh +60 -2
- package/.claude/hooks/bmad-tts-injector.sh +20 -1
- package/.claude/hooks/bmad-voice-manager.sh +25 -3
- package/.claude/hooks/clawdbot-receiver-SECURE.sh +21 -2
- package/.claude/hooks/clawdbot-receiver.sh +19 -1
- package/.claude/hooks/elevenlabs-voices.sh +62 -0
- package/.claude/hooks/kokoro-installer.sh +20 -10
- package/.claude/hooks/language-manager.sh +10 -3
- package/.claude/hooks/party-set-room.sh +71 -0
- package/.claude/hooks/party-stage-roster.py +328 -0
- package/.claude/hooks/personality-manager.sh +19 -2
- package/.claude/hooks/piper-voice-manager.sh +3 -2
- package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +24 -5
- package/.claude/hooks/play-tts-elevenlabs.sh +38 -118
- package/.claude/hooks/play-tts-kokoro.sh +33 -6
- package/.claude/hooks/play-tts-soprano.sh +3 -2
- package/.claude/hooks/play-tts-ssh-remote.sh +37 -29
- package/.claude/hooks/play-tts-termux-ssh.sh +5 -4
- package/.claude/hooks/play-tts.sh +66 -61
- package/.claude/hooks/provider-catalog.json +352 -0
- package/.claude/hooks/provider-catalog.sh +161 -0
- package/.claude/hooks/provider-commands.sh +2 -1
- package/.claude/hooks/provider-manager.sh +47 -9
- package/.claude/hooks/python-resolver.sh +117 -0
- package/.claude/hooks/session-id.sh +56 -0
- package/.claude/hooks/session-start-tts.sh +39 -0
- package/.claude/hooks/speed-manager.sh +1 -1
- package/.claude/hooks/translate-manager.sh +3 -2
- package/.claude/hooks/translator.py +1 -1
- package/.claude/hooks/voice-manager.sh +242 -10
- package/.claude/hooks-windows/language-manager.ps1 +7 -1
- package/.claude/hooks-windows/personality-manager.ps1 +16 -1
- package/.claude/hooks-windows/play-tts-kokoro.ps1 +20 -4
- package/.claude/hooks-windows/play-tts.ps1 +32 -3
- package/.claude/hooks-windows/provider-catalog.ps1 +140 -0
- package/.claude/hooks-windows/provider-manager.ps1 +63 -8
- package/.claude/hooks-windows/tts-watcher.ps1 +33 -12
- package/.claude/hooks-windows/voice-manager-windows.ps1 +49 -0
- package/.mcp.json +0 -7
- package/README.md +12 -3
- package/RELEASE_NOTES.md +43 -0
- package/mcp-server/server.py +146 -49
- package/mcp-server/test_mcp_correctness.py +20 -2
- package/mcp-server/test_windows_script_parity.py +0 -2
- package/package.json +1 -1
- package/src/cli/list-voices.js +218 -114
- package/src/console/bling.js +71 -0
- package/src/console/music-preview.js +79 -0
- package/src/console/tabs/music-tab.js +16 -39
- package/src/console/tabs/settings-tab.js +195 -13
- package/src/console/tabs/setup-tab.js +9 -34
- package/src/console/tabs/voices-tab.js +83 -14
- package/src/console/widgets/track-picker.js +82 -0
- package/src/installer.js +124 -10
- package/src/services/provider-catalog.js +412 -0
- package/src/services/provider-voice-catalog.js +52 -73
- package/src/services/tts-engine-service.js +29 -0
- package/src/utils/provider-validator.js +62 -12
- package/.claude/commands/agent-vibes/language.md +0 -23
- package/.claude/commands/agent-vibes/learn.md +0 -67
- package/.claude/commands/agent-vibes/replay-target.md +0 -14
- package/.claude/commands/agent-vibes/target-voice.md +0 -26
- package/.claude/commands/agent-vibes/target.md +0 -30
- package/.claude/hooks/learn-manager.sh +0 -492
- package/.claude/hooks/replay-target-audio.sh +0 -95
- package/.claude/hooks-windows/learn-manager.ps1 +0 -241
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# File: .claude/hooks/party-set-room.sh
|
|
4
|
+
#
|
|
5
|
+
# AgentVibes - record the chosen (non-default) party room for this session.
|
|
6
|
+
#
|
|
7
|
+
# party-stage-roster.py broadcasts the DEFAULT installed-agent room unless it
|
|
8
|
+
# knows a specific room was chosen. This tiny helper is the AgentVibes-side
|
|
9
|
+
# mechanism for recording that choice: it writes the active-room state file
|
|
10
|
+
#
|
|
11
|
+
# ~/.agentvibes/party-active-<sessionid>.json = {"group":"<group-id>"}
|
|
12
|
+
#
|
|
13
|
+
# keyed by the canonical routing session id (av_session_id = basename of
|
|
14
|
+
# CLAUDE_PROJECT_DIR). party-stage-roster.py reads this file and passes the
|
|
15
|
+
# group to resolve_party.py as --party, so a chosen non-default room gets
|
|
16
|
+
# broadcast instead of the default.
|
|
17
|
+
#
|
|
18
|
+
# EXTENSION POINT (we do NOT edit the BMAD skill): a party skill can call this
|
|
19
|
+
# from its user-config `activation_steps_append` when the user picks a room --
|
|
20
|
+
# that is the sanctioned way to wire this in without touching BMAD-owned files.
|
|
21
|
+
#
|
|
22
|
+
# Non-destructive: only creates ~/.agentvibes (mkdir -p) and writes/overwrites
|
|
23
|
+
# the one per-session state file it owns. Touches no user config.
|
|
24
|
+
#
|
|
25
|
+
# Usage: party-set-room.sh <group-id>
|
|
26
|
+
# party-set-room.sh --clear # remove the state file (back to default)
|
|
27
|
+
#
|
|
28
|
+
# bash-3.2 safe (macOS default bash).
|
|
29
|
+
|
|
30
|
+
set -euo pipefail
|
|
31
|
+
|
|
32
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
33
|
+
# shellcheck source=./session-id.sh
|
|
34
|
+
source "$SCRIPT_DIR/session-id.sh"
|
|
35
|
+
|
|
36
|
+
GROUP="${1:-}"
|
|
37
|
+
if [[ -z "$GROUP" ]]; then
|
|
38
|
+
echo "usage: party-set-room.sh <group-id> | --clear" >&2
|
|
39
|
+
exit 2
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
SESSION_ID="$(av_session_id "${CLAUDE_PROJECT_DIR:-$PWD}")"
|
|
43
|
+
STATE_DIR="$HOME/.agentvibes"
|
|
44
|
+
STATE_FILE="$STATE_DIR/party-active-${SESSION_ID}.json"
|
|
45
|
+
|
|
46
|
+
mkdir -p "$STATE_DIR"
|
|
47
|
+
|
|
48
|
+
if [[ "$GROUP" == "--clear" ]]; then
|
|
49
|
+
rm -f "$STATE_FILE"
|
|
50
|
+
# Also clear the stage-on-first-speak idempotency flag so the next party
|
|
51
|
+
# re-broadcasts its cast (this is the manual party-reset path).
|
|
52
|
+
rm -f "$STATE_DIR/staged-${SESSION_ID}.flag"
|
|
53
|
+
echo "party-set-room: cleared active room for session '$SESSION_ID' (back to default)."
|
|
54
|
+
exit 0
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
# Validate the group id: a group id is a slug (letters/digits/._-). Reject
|
|
58
|
+
# anything else so we never write attacker-controlled JSON.
|
|
59
|
+
if [[ ! "$GROUP" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
|
60
|
+
echo "party-set-room: invalid group id '$GROUP' (allowed: A-Za-z0-9._-)" >&2
|
|
61
|
+
exit 2
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
# Write atomically so a concurrent reader never sees a half-written file.
|
|
65
|
+
_tmp="$(mktemp "$STATE_DIR/party-active-${SESSION_ID}.XXXXXX")" || exit 1
|
|
66
|
+
trap 'rm -f "$_tmp"' EXIT
|
|
67
|
+
printf '{"group":"%s"}\n' "$GROUP" > "$_tmp"
|
|
68
|
+
mv "$_tmp" "$STATE_FILE"
|
|
69
|
+
trap - EXIT
|
|
70
|
+
|
|
71
|
+
echo "party-set-room: session '$SESSION_ID' room set to '$GROUP'."
|
|
@@ -0,0 +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())
|
|
@@ -62,6 +62,17 @@ fi
|
|
|
62
62
|
|
|
63
63
|
PERSONALITY_FILE="$CLAUDE_DIR/tts-personality.txt"
|
|
64
64
|
|
|
65
|
+
# Source the generated Provider Catalog (bash-3.2-safe SSOT accessors) when present.
|
|
66
|
+
# FAIL-SAFE: consumers probe `type catalog_* >/dev/null 2>&1` and fall back to the
|
|
67
|
+
# legacy hardcoded literal when the artifact is missing (installed-tree skew) —
|
|
68
|
+
# mirrors voice-manager.sh's identical pattern (AVI-S9.3/9.4).
|
|
69
|
+
_load_provider_catalog() {
|
|
70
|
+
if [[ -f "$SCRIPT_DIR/provider-catalog.sh" ]]; then
|
|
71
|
+
# shellcheck source=/dev/null
|
|
72
|
+
source "$SCRIPT_DIR/provider-catalog.sh" 2>/dev/null || true
|
|
73
|
+
fi
|
|
74
|
+
}
|
|
75
|
+
|
|
65
76
|
# Function to get personality data from markdown file
|
|
66
77
|
get_personality_data() {
|
|
67
78
|
local personality="$1"
|
|
@@ -200,8 +211,14 @@ case "$1" in
|
|
|
200
211
|
# Try to get Piper-specific voice first
|
|
201
212
|
ASSIGNED_VOICE=$(get_personality_data "$PERSONALITY" "piper_voice")
|
|
202
213
|
if [[ -z "$ASSIGNED_VOICE" ]]; then
|
|
203
|
-
# Fallback to default Piper voice
|
|
204
|
-
|
|
214
|
+
# Fallback to default Piper voice — sourced from the generated Provider
|
|
215
|
+
# Catalog SSOT (AVI-S9.4). FAIL-SAFE: legacy literal if artifact missing.
|
|
216
|
+
_load_provider_catalog
|
|
217
|
+
if type catalog_default_voice >/dev/null 2>&1; then
|
|
218
|
+
ASSIGNED_VOICE="$(catalog_default_voice piper)"
|
|
219
|
+
else
|
|
220
|
+
ASSIGNED_VOICE="en_US-lessac-medium"
|
|
221
|
+
fi
|
|
205
222
|
fi
|
|
206
223
|
else
|
|
207
224
|
# Use Piper voice (reads from piper_voice: field)
|
|
@@ -296,6 +296,7 @@ patch_libritts_speaker_names() {
|
|
|
296
296
|
# Find voice-assignments.json relative to this script (SCRIPT_DIR/../.. = project root)
|
|
297
297
|
local script_dir
|
|
298
298
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
299
|
+
source "$script_dir/python-resolver.sh"
|
|
299
300
|
local project_root
|
|
300
301
|
project_root="$(cd "$script_dir/../.." 2>/dev/null && pwd)"
|
|
301
302
|
local catalog="$project_root/voice-assignments.json"
|
|
@@ -315,8 +316,8 @@ patch_libritts_speaker_names() {
|
|
|
315
316
|
fi
|
|
316
317
|
|
|
317
318
|
# Check if already patched (first key doesn't start with 'p' + digits)
|
|
318
|
-
if
|
|
319
|
-
|
|
319
|
+
if [[ -n "$PYTHON_BIN" ]]; then
|
|
320
|
+
"$PYTHON_BIN" -c "
|
|
320
321
|
import json, sys
|
|
321
322
|
|
|
322
323
|
json_path = sys.argv[1]
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
set -euo pipefail
|
|
15
15
|
|
|
16
16
|
TEXT="${1:-}"
|
|
17
|
-
VOICE="${2:-
|
|
17
|
+
VOICE="${2:-}"
|
|
18
18
|
AGENT_NAME="${3:-default}"
|
|
19
19
|
|
|
20
20
|
# Validate required input
|
|
@@ -26,8 +26,27 @@ fi
|
|
|
26
26
|
|
|
27
27
|
# Get script directory
|
|
28
28
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
29
|
+
source "$SCRIPT_DIR/python-resolver.sh"
|
|
29
30
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
30
31
|
|
|
32
|
+
# Default voice: prefer the Provider Catalog's piper default (AVI-S9.6 AC3,
|
|
33
|
+
# design row 23), falling back to the legacy literal if the generated catalog
|
|
34
|
+
# artifact is missing (installed-tree skew). NOTE: any voice-format allowlist
|
|
35
|
+
# added to this receiver in the future must stay independent of the catalog —
|
|
36
|
+
# a security allowlist is deliberately narrower than the full inventory (see
|
|
37
|
+
# provider-catalog.js module header).
|
|
38
|
+
if [[ -z "$VOICE" ]]; then
|
|
39
|
+
if [[ -f "$SCRIPT_DIR/provider-catalog.sh" ]]; then
|
|
40
|
+
# shellcheck source=/dev/null
|
|
41
|
+
source "$SCRIPT_DIR/provider-catalog.sh" 2>/dev/null || true
|
|
42
|
+
fi
|
|
43
|
+
if type catalog_default_voice >/dev/null 2>&1; then
|
|
44
|
+
VOICE="$(catalog_default_voice piper)"
|
|
45
|
+
else
|
|
46
|
+
VOICE="en_US-lessac-medium" # legacy fallback
|
|
47
|
+
fi
|
|
48
|
+
fi
|
|
49
|
+
|
|
31
50
|
# ---------------------------------------------------------------------------
|
|
32
51
|
# Get SSH connection details from config
|
|
33
52
|
# Prefer ~/.agentvibes/transport-config.json, fall back to legacy host file
|
|
@@ -38,10 +57,10 @@ SSH_KEY=""
|
|
|
38
57
|
SSH_PORT=""
|
|
39
58
|
|
|
40
59
|
_TRANSPORT_CFG="$HOME/.agentvibes/transport-config.json"
|
|
41
|
-
if [[ -f "$_TRANSPORT_CFG" ]] &&
|
|
42
|
-
SSH_HOST=$(
|
|
43
|
-
SSH_KEY=$(
|
|
44
|
-
SSH_PORT=$(
|
|
60
|
+
if [[ -f "$_TRANSPORT_CFG" ]] && [[ -n "$PYTHON_BIN" ]]; then
|
|
61
|
+
SSH_HOST=$("$PYTHON_BIN" -c "import json; d=json.load(open('$_TRANSPORT_CFG')); p=d.get('agentvibes-receiver',{}); print(p.get('host',''))" 2>/dev/null || echo "")
|
|
62
|
+
SSH_KEY=$("$PYTHON_BIN" -c "import json; d=json.load(open('$_TRANSPORT_CFG')); p=d.get('agentvibes-receiver',{}); print(p.get('sshKey',''))" 2>/dev/null || echo "")
|
|
63
|
+
SSH_PORT=$("$PYTHON_BIN" -c "import json; d=json.load(open('$_TRANSPORT_CFG')); p=d.get('agentvibes-receiver',{}); print(p.get('port',''))" 2>/dev/null || echo "")
|
|
45
64
|
fi
|
|
46
65
|
|
|
47
66
|
if [[ -z "$SSH_HOST" ]]; then
|