Initial commit
This commit is contained in:
14
hooks/hooks.json
Normal file
14
hooks/hooks.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/stop.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
215
hooks/mood_extractor.py
Executable file
215
hooks/mood_extractor.py
Executable file
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Reachy Mini Mood Hook - Continuous Movement During TTS
|
||||
Extracts <!-- MOOD: mood_name --> markers and plays random emotions from that mood
|
||||
until TTS finishes speaking (detected by polling HTTP status endpoint).
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import random
|
||||
import requests
|
||||
|
||||
# Tracking panel mood endpoint (preferred - uses MovementManager queue)
|
||||
TRACKING_PANEL_URL = "http://localhost:5002"
|
||||
|
||||
# Daemon configuration (fallback - bypasses MovementManager, may cause race conditions)
|
||||
DAEMON_URL = "http://localhost:8100"
|
||||
DATASET = "pollen-robotics/reachy-mini-emotions-library"
|
||||
|
||||
# TTS server status endpoint
|
||||
TTS_STATUS_URL = "http://localhost:5001/status"
|
||||
|
||||
# Mood categories with mapped emotions
|
||||
MOOD_CATEGORIES = {
|
||||
"celebratory": [
|
||||
"success1", "success2", "proud1", "proud2", "proud3",
|
||||
"cheerful1", "electric1", "enthusiastic1", "enthusiastic2",
|
||||
"grateful1", "yes1", "laughing1", "laughing2"
|
||||
],
|
||||
|
||||
"thoughtful": [
|
||||
"thoughtful1", "thoughtful2", "curious1", "attentive1", "attentive2",
|
||||
"inquiring1", "inquiring2", "inquiring3", "understanding1", "understanding2"
|
||||
],
|
||||
|
||||
"welcoming": [
|
||||
"welcoming1", "welcoming2", "helpful1", "helpful2", "loving1",
|
||||
"come1", "grateful1", "cheerful1", "calming1"
|
||||
],
|
||||
|
||||
"confused": [
|
||||
"confused1", "uncertain1", "lost1", "inquiring1", "inquiring2",
|
||||
"incomprehensible2", "uncomfortable1", "oops1", "oops2"
|
||||
],
|
||||
|
||||
"frustrated": [
|
||||
"frustrated1", "irritated1", "irritated2", "impatient1", "impatient2",
|
||||
"exhausted1", "tired1", "displeased1", "displeased2"
|
||||
],
|
||||
|
||||
"surprised": [
|
||||
"surprised1", "surprised2", "amazed1", "oops1", "oops2",
|
||||
"incomprehensible2", "electric1"
|
||||
],
|
||||
|
||||
"calm": [
|
||||
"calming1", "serenity1", "relief1", "relief2", "shy1",
|
||||
"understanding1", "understanding2", "sleep1"
|
||||
],
|
||||
|
||||
"energetic": [
|
||||
"electric1", "enthusiastic1", "enthusiastic2", "dance1", "dance2",
|
||||
"dance3", "laughing1", "laughing2", "yes1", "come1"
|
||||
],
|
||||
|
||||
"playful": [
|
||||
"laughing1", "laughing2", "dance1", "dance2", "dance3",
|
||||
"cheerful1", "enthusiastic1", "oops1", "oops2"
|
||||
]
|
||||
}
|
||||
|
||||
def extract_mood_marker(text):
|
||||
"""
|
||||
Extract <!-- MOOD: mood_name --> marker from text.
|
||||
Returns mood name or None.
|
||||
"""
|
||||
pattern = r'<!--\s*MOOD:\s*([a-zA-Z0-9_]+)\s*-->'
|
||||
match = re.search(pattern, text)
|
||||
return match.group(1) if match else None
|
||||
|
||||
def is_tts_playing():
|
||||
"""
|
||||
Check if TTS is currently playing by polling the status endpoint.
|
||||
Returns True if audio is playing, False otherwise.
|
||||
"""
|
||||
try:
|
||||
response = requests.get(TTS_STATUS_URL, timeout=1)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return data.get('is_playing', False)
|
||||
else:
|
||||
print(f"[MOOD] TTS status check failed: HTTP {response.status_code}", file=sys.stderr)
|
||||
return False
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"[MOOD] TTS status check error: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
def trigger_move(emotion_name):
|
||||
"""
|
||||
Trigger an emotion move via the daemon API.
|
||||
"""
|
||||
url = f"{DAEMON_URL}/api/move/play/recorded-move-dataset/{DATASET}/{emotion_name}"
|
||||
|
||||
try:
|
||||
response = requests.post(url, timeout=2)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
uuid = result.get('uuid', 'unknown')
|
||||
print(f"[MOOD] Triggered: {emotion_name} (UUID: {uuid})", file=sys.stderr)
|
||||
return True
|
||||
else:
|
||||
print(f"[MOOD] Failed to trigger {emotion_name}: HTTP {response.status_code}", file=sys.stderr)
|
||||
return False
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"[MOOD] API error for {emotion_name}: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
def play_mood_loop(mood_name, max_duration=60):
|
||||
"""
|
||||
Continuously play random emotions from the mood category until TTS finishes.
|
||||
|
||||
Args:
|
||||
mood_name: Name of the mood category
|
||||
max_duration: Maximum time to loop (safety timeout in seconds)
|
||||
"""
|
||||
if mood_name not in MOOD_CATEGORIES:
|
||||
print(f"[MOOD] Warning: Unknown mood '{mood_name}', falling back to 'thoughtful'", file=sys.stderr)
|
||||
mood_name = "thoughtful"
|
||||
|
||||
emotions = MOOD_CATEGORIES[mood_name]
|
||||
print(f"[MOOD] Starting mood loop: {mood_name} ({len(emotions)} emotions available)", file=sys.stderr)
|
||||
|
||||
start_time = time.time()
|
||||
moves_played = 0
|
||||
|
||||
while True:
|
||||
# Safety timeout
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed > max_duration:
|
||||
print(f"[MOOD] Safety timeout reached ({max_duration}s), stopping", file=sys.stderr)
|
||||
break
|
||||
|
||||
# Check if TTS is still playing
|
||||
if not is_tts_playing():
|
||||
print(f"[MOOD] TTS finished (detected is_playing=false), stopping after {moves_played} moves", file=sys.stderr)
|
||||
break
|
||||
|
||||
# Pick random emotion from mood and trigger it
|
||||
emotion = random.choice(emotions)
|
||||
if trigger_move(emotion):
|
||||
moves_played += 1
|
||||
|
||||
# Wait ~1-2 seconds between moves, then check again
|
||||
wait_time = random.uniform(1.0, 2.0)
|
||||
time.sleep(wait_time)
|
||||
|
||||
print(f"[MOOD] Mood loop complete: {mood_name}, {moves_played} moves in {elapsed:.1f}s", file=sys.stderr)
|
||||
|
||||
def is_tracking_panel_available():
|
||||
"""Check if tracking panel mood endpoint is available."""
|
||||
try:
|
||||
response = requests.get(f"{TRACKING_PANEL_URL}/status", timeout=0.5)
|
||||
return response.status_code == 200
|
||||
except:
|
||||
return False
|
||||
|
||||
def trigger_tracking_panel_mood(mood_name):
|
||||
"""
|
||||
Trigger mood via tracking panel endpoint (uses MovementManager queue).
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
try:
|
||||
url = f"{TRACKING_PANEL_URL}/trigger_mood?mood={mood_name}"
|
||||
response = requests.get(url, timeout=2)
|
||||
if response.status_code == 200:
|
||||
print(f"[MOOD] Triggered via tracking panel: {mood_name}", file=sys.stderr)
|
||||
return True
|
||||
else:
|
||||
print(f"[MOOD] Tracking panel returned: HTTP {response.status_code}", file=sys.stderr)
|
||||
return False
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"[MOOD] Tracking panel unavailable: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""
|
||||
Read stdin, extract mood marker, and run continuous mood loop.
|
||||
"""
|
||||
# Read the full response from stdin
|
||||
text = sys.stdin.read()
|
||||
|
||||
# Extract mood marker
|
||||
mood = extract_mood_marker(text)
|
||||
|
||||
if not mood:
|
||||
# No mood requested - silent exit
|
||||
sys.exit(0)
|
||||
|
||||
# Try tracking panel first (preferred - uses MovementManager queue)
|
||||
if is_tracking_panel_available():
|
||||
print(f"[MOOD] Using tracking panel endpoint (coordinated with breathing)", file=sys.stderr)
|
||||
if trigger_tracking_panel_mood(mood):
|
||||
sys.exit(0)
|
||||
else:
|
||||
print(f"[MOOD] Tracking panel trigger failed, falling back to daemon", file=sys.stderr)
|
||||
|
||||
# Fallback to daemon API (may cause race condition with breathing)
|
||||
print(f"[MOOD] Using daemon API (WARNING: may conflict with breathing)", file=sys.stderr)
|
||||
play_mood_loop(mood)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
114
hooks/mood_mapping.py
Normal file
114
hooks/mood_mapping.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Mood Mapping for Reachy Mini Emotion Clusters
|
||||
|
||||
Maps high-level moods to clusters of emotion moves for ambient presence during TTS.
|
||||
"""
|
||||
|
||||
# Mood clusters - each mood contains a list of related emotions
|
||||
MOOD_CLUSTERS = {
|
||||
'thoughtful': [
|
||||
'thoughtful1', 'thoughtful2', 'curious1', 'inquiring1', 'inquiring2',
|
||||
'inquiring3', 'attentive1', 'attentive2', 'understanding1', 'understanding2'
|
||||
],
|
||||
|
||||
'energetic': [
|
||||
'cheerful1', 'enthusiastic1', 'enthusiastic2', 'electric1', 'success1',
|
||||
'success2', 'proud1', 'proud2', 'proud3', 'amazed1', 'yes1'
|
||||
],
|
||||
|
||||
'playful': [
|
||||
'laughing1', 'laughing2', 'dance1', 'dance2', 'dance3', 'come1',
|
||||
'electric1', 'oops1', 'oops2'
|
||||
],
|
||||
|
||||
'calm': [
|
||||
'calming1', 'serenity1', 'relief1', 'relief2', 'understanding1',
|
||||
'understanding2', 'welcoming1', 'welcoming2', 'grateful1'
|
||||
],
|
||||
|
||||
'confused': [
|
||||
'confused1', 'uncertain1', 'lost1', 'oops1', 'oops2',
|
||||
'incomprehensible2', 'uncomfortable1'
|
||||
],
|
||||
|
||||
'frustrated': [
|
||||
'frustrated1', 'impatient1', 'impatient2', 'irritated1', 'irritated2',
|
||||
'exhausted1', 'tired1', 'resigned1', 'displeased1', 'displeased2'
|
||||
],
|
||||
|
||||
'sad': [
|
||||
'sad1', 'sad2', 'downcast1', 'lonely1', 'no_sad1', 'yes_sad1',
|
||||
'resigned1', 'uncomfortable1'
|
||||
],
|
||||
|
||||
'surprised': [
|
||||
'surprised1', 'surprised2', 'amazed1', 'oops1', 'oops2',
|
||||
'incomprehensible2', 'fear1', 'scared1'
|
||||
],
|
||||
|
||||
'angry': [
|
||||
'furious1', 'rage1', 'frustrated1', 'irritated1', 'irritated2',
|
||||
'contempt1', 'disgusted1', 'reprimand1', 'reprimand2', 'reprimand3'
|
||||
],
|
||||
|
||||
'helpful': [
|
||||
'helpful1', 'helpful2', 'welcoming1', 'welcoming2', 'grateful1',
|
||||
'understanding1', 'understanding2', 'attentive1', 'attentive2', 'yes1'
|
||||
],
|
||||
|
||||
'shy': [
|
||||
'shy1', 'uncertain1', 'uncomfortable1', 'downcast1', 'anxiety1'
|
||||
],
|
||||
|
||||
'sleepy': [
|
||||
'sleep1', 'tired1', 'exhausted1', 'boredom1', 'boredom2', 'resigned1'
|
||||
],
|
||||
|
||||
'affectionate': [
|
||||
'loving1', 'grateful1', 'welcoming1', 'welcoming2', 'cheerful1',
|
||||
'shy1', 'come1'
|
||||
],
|
||||
|
||||
'defiant': [
|
||||
'no1', 'no_excited1', 'go_away1', 'contempt1', 'reprimand1',
|
||||
'reprimand2', 'reprimand3', 'indifferent1'
|
||||
],
|
||||
|
||||
'neutral': [
|
||||
'attentive1', 'attentive2', 'thoughtful1', 'curious1', 'yes1',
|
||||
'understanding1', 'calming1', 'serenity1'
|
||||
]
|
||||
}
|
||||
|
||||
def get_mood_emotions(mood_name):
|
||||
"""
|
||||
Get the list of emotions for a given mood.
|
||||
|
||||
Args:
|
||||
mood_name: Name of the mood (e.g., 'thoughtful', 'energetic')
|
||||
|
||||
Returns:
|
||||
List of emotion names, or None if mood not found
|
||||
"""
|
||||
return MOOD_CLUSTERS.get(mood_name.lower())
|
||||
|
||||
def get_all_moods():
|
||||
"""
|
||||
Get list of all available mood names.
|
||||
|
||||
Returns:
|
||||
List of mood names
|
||||
"""
|
||||
return list(MOOD_CLUSTERS.keys())
|
||||
|
||||
def validate_mood(mood_name):
|
||||
"""
|
||||
Check if a mood name is valid.
|
||||
|
||||
Args:
|
||||
mood_name: Name to validate
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
return mood_name.lower() in MOOD_CLUSTERS
|
||||
89
hooks/move_extractor.py
Executable file
89
hooks/move_extractor.py
Executable file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Reachy Mini Movement Hook - Emotion-Based Gestures
|
||||
Extracts <!-- MOVE: emotion_name --> markers from Claude responses and triggers emotion moves.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import requests
|
||||
|
||||
# Daemon configuration
|
||||
DAEMON_URL = "http://localhost:8100"
|
||||
DATASET = "pollen-robotics/reachy-mini-emotions-library"
|
||||
|
||||
# Full emotion library (82 emotions - complete access)
|
||||
EMOTIONS = [
|
||||
'amazed1', 'anxiety1', 'attentive1', 'attentive2', 'boredom1', 'boredom2',
|
||||
'calming1', 'cheerful1', 'come1', 'confused1', 'contempt1', 'curious1',
|
||||
'dance1', 'dance2', 'dance3', 'disgusted1', 'displeased1', 'displeased2',
|
||||
'downcast1', 'dying1', 'electric1', 'enthusiastic1', 'enthusiastic2',
|
||||
'exhausted1', 'fear1', 'frustrated1', 'furious1', 'go_away1', 'grateful1',
|
||||
'helpful1', 'helpful2', 'impatient1', 'impatient2', 'incomprehensible2',
|
||||
'indifferent1', 'inquiring1', 'inquiring2', 'inquiring3', 'irritated1',
|
||||
'irritated2', 'laughing1', 'laughing2', 'lonely1', 'lost1', 'loving1',
|
||||
'no1', 'no_excited1', 'no_sad1', 'oops1', 'oops2', 'proud1', 'proud2',
|
||||
'proud3', 'rage1', 'relief1', 'relief2', 'reprimand1', 'reprimand2',
|
||||
'reprimand3', 'resigned1', 'sad1', 'sad2', 'scared1', 'serenity1', 'shy1',
|
||||
'sleep1', 'success1', 'success2', 'surprised1', 'surprised2', 'thoughtful1',
|
||||
'thoughtful2', 'tired1', 'uncertain1', 'uncomfortable1', 'understanding1',
|
||||
'understanding2', 'welcoming1', 'welcoming2', 'yes1', 'yes_sad1'
|
||||
]
|
||||
|
||||
def extract_move_markers(text):
|
||||
"""
|
||||
Extract <!-- MOVE: emotion_name --> markers from text.
|
||||
Returns list of emotion names (max 2 for subtlety).
|
||||
"""
|
||||
pattern = r'<!--\s*MOVE:\s*([a-zA-Z0-9_]+)\s*-->'
|
||||
matches = re.findall(pattern, text)
|
||||
|
||||
# Limit to 2 moves for subtle presence
|
||||
return matches[:2]
|
||||
|
||||
def trigger_move(emotion_name):
|
||||
"""
|
||||
Trigger an emotion move via the daemon API.
|
||||
"""
|
||||
if emotion_name not in EMOTIONS:
|
||||
print(f"[MOVE] Warning: '{emotion_name}' not in emotion library, skipping", file=sys.stderr)
|
||||
return False
|
||||
|
||||
url = f"{DAEMON_URL}/api/move/play/recorded-move-dataset/{DATASET}/{emotion_name}"
|
||||
|
||||
try:
|
||||
response = requests.post(url, timeout=2)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
uuid = result.get('uuid', 'unknown')
|
||||
print(f"[MOVE] Triggered: {emotion_name} (UUID: {uuid})", file=sys.stderr)
|
||||
return True
|
||||
else:
|
||||
print(f"[MOVE] Failed to trigger {emotion_name}: HTTP {response.status_code}", file=sys.stderr)
|
||||
return False
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"[MOVE] API error for {emotion_name}: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""
|
||||
Read stdin, extract movement markers, and trigger emotion moves.
|
||||
"""
|
||||
# Read the full response from stdin
|
||||
text = sys.stdin.read()
|
||||
|
||||
# Extract move markers
|
||||
moves = extract_move_markers(text)
|
||||
|
||||
if not moves:
|
||||
# No moves requested - silent exit
|
||||
sys.exit(0)
|
||||
|
||||
# Trigger each move
|
||||
for move in moves:
|
||||
trigger_move(move)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
65
hooks/stop.sh
Executable file
65
hooks/stop.sh
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Reachy Mini Stop Hook - Automatic Movement Triggers
|
||||
# Fires after Claude finishes responding
|
||||
# Extracts and triggers movement markers from response
|
||||
#
|
||||
|
||||
# Debug logging
|
||||
DEBUG_FILE="/tmp/reachy_mini_stop_hook.log"
|
||||
echo "=== Reachy Mini Stop Hook Fired at $(date) ===" >> "$DEBUG_FILE"
|
||||
|
||||
# Get the plugin directory (parent of hooks directory)
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PLUGIN_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# Path to extractor scripts
|
||||
MOVE_SCRIPT="$PLUGIN_DIR/hooks/move_extractor.py"
|
||||
MOOD_SCRIPT="/home/user/reachy/pi_reachy_deployment/mood_extractor.py"
|
||||
|
||||
# Read the JSON input from stdin
|
||||
INPUT=$(cat)
|
||||
|
||||
# Extract the transcript path from JSON
|
||||
TRANSCRIPT_PATH=$(echo "$INPUT" | grep -o '"transcript_path":"[^"]*"' | cut -d'"' -f4)
|
||||
|
||||
# Read the last assistant message from the transcript
|
||||
if [ -f "$TRANSCRIPT_PATH" ]; then
|
||||
# Get the last line (latest message) from the transcript
|
||||
LAST_MESSAGE=$(tail -1 "$TRANSCRIPT_PATH")
|
||||
|
||||
# Extract content from the JSON - content is an array of objects
|
||||
RESPONSE=$(echo "$LAST_MESSAGE" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
msg = json.load(sys.stdin)
|
||||
content = msg.get('message', {}).get('content', [])
|
||||
if isinstance(content, list) and len(content) > 0:
|
||||
# Get the first text block
|
||||
for block in content:
|
||||
if block.get('type') == 'text':
|
||||
print(block.get('text', ''))
|
||||
break
|
||||
except:
|
||||
pass
|
||||
" 2>/dev/null || echo "")
|
||||
|
||||
# Pass response to both extractors (move and mood)
|
||||
if [ -n "$RESPONSE" ]; then
|
||||
# Run move extractor (for specific emotions)
|
||||
echo "$RESPONSE" | python3 "$MOVE_SCRIPT" 2>&1
|
||||
|
||||
# Extract mood marker from response
|
||||
MOOD=$(echo "$RESPONSE" | grep -oP '<!--\s*MOOD:\s*\K[a-zA-Z0-9_]+(?=\s*-->)' | head -1)
|
||||
|
||||
# If mood marker found, POST to conversation app to trigger mood state
|
||||
if [ -n "$MOOD" ]; then
|
||||
echo "Found mood marker: $MOOD" >> "$DEBUG_FILE"
|
||||
curl -s -X POST http://localhost:8888/mood/trigger \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"mood\":\"$MOOD\"}" >> "$DEBUG_FILE" 2>&1 &
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Reference in New Issue
Block a user