Initial commit
This commit is contained in:
12
.claude-plugin/plugin.json
Normal file
12
.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "tts-output",
|
||||
"description": "Text-to-speech output for Claude responses using macOS 'say' command",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Joel Chan",
|
||||
"email": "joel611@live.hk"
|
||||
},
|
||||
"hooks": [
|
||||
"./hooks"
|
||||
]
|
||||
}
|
||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# tts-output
|
||||
|
||||
Text-to-speech output for Claude responses using macOS 'say' command
|
||||
26
hooks/hooks.json
Normal file
26
hooks/hooks.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/session-start.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/stop-handler.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
19
hooks/scripts/session-start.sh
Executable file
19
hooks/scripts/session-start.sh
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
|
||||
# SessionStart hook for TTS output plugin
|
||||
# This script injects a system prompt to request TTS summaries
|
||||
|
||||
cat <<'EOF'
|
||||
IMPORTANT: The TTS output plugin is active. At the end of each final response, include an HTML comment with a brief summary for text-to-speech output:
|
||||
|
||||
<!-- TTS-SUMMARY: 1-2 sentence summary of your response -->
|
||||
|
||||
The summary should:
|
||||
- Be concise (1-2 sentences maximum)
|
||||
- Capture the key point or result of your response
|
||||
- Be natural and speakable (avoid technical jargon when possible)
|
||||
- Focus on what you accomplished or the main takeaway
|
||||
|
||||
Example:
|
||||
<!-- TTS-SUMMARY: I've successfully implemented the authentication feature and all tests are passing. -->
|
||||
EOF
|
||||
252
hooks/scripts/stop-handler.py
Executable file
252
hooks/scripts/stop-handler.py
Executable file
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Stop hook handler for TTS output plugin.
|
||||
Speaks Claude's response summary using macOS 'say' command.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import re
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_config():
|
||||
"""Load TTS configuration from environment variables."""
|
||||
# Default debug log location: same directory as this script
|
||||
script_dir = Path(__file__).parent
|
||||
default_log_path = str(script_dir / "tts-debug.log")
|
||||
|
||||
return {
|
||||
"enabled": os.environ.get("TTS_ENABLED", "true").lower() in ("true", "1", "yes"),
|
||||
"voice": os.environ.get("TTS_VOICE", "Samantha"),
|
||||
"speed": int(os.environ.get("TTS_SPEED", "200")),
|
||||
"debug": os.environ.get("TTS_DEBUG", "false").lower() in ("true", "1", "yes"),
|
||||
"debug_log": os.environ.get("TTS_DEBUG_LOG", default_log_path)
|
||||
}
|
||||
|
||||
|
||||
def log_debug(message, log_file):
|
||||
"""Write debug message to log file with timestamp."""
|
||||
try:
|
||||
from datetime import datetime
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with open(log_file, 'a') as f:
|
||||
f.write(f"[{timestamp}] {message}\n")
|
||||
except Exception:
|
||||
# Fail silently - don't disrupt the hook
|
||||
pass
|
||||
|
||||
|
||||
def extract_tts_summary(text):
|
||||
"""Extract TTS summary from HTML comment in text."""
|
||||
# Look for <!-- TTS-SUMMARY: ... -->
|
||||
pattern = r'<!--\s*TTS-SUMMARY:\s*(.+?)\s*-->'
|
||||
match = re.search(pattern, text, re.IGNORECASE | re.DOTALL)
|
||||
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_fallback_summary(text, max_sentences=2):
|
||||
"""Extract first N sentences as fallback summary."""
|
||||
# Remove HTML comments
|
||||
text = re.sub(r'<!--.*?-->', '', text, flags=re.DOTALL)
|
||||
|
||||
# Remove markdown code blocks
|
||||
text = re.sub(r'```.*?```', '', text, flags=re.DOTALL)
|
||||
|
||||
# Remove inline code
|
||||
text = re.sub(r'`[^`]+`', '', text)
|
||||
|
||||
# Split into sentences (simple approach)
|
||||
sentences = re.split(r'(?<=[.!?])\s+', text.strip())
|
||||
|
||||
# Take first N non-empty sentences
|
||||
summary_sentences = [s for s in sentences[:max_sentences] if s.strip()]
|
||||
|
||||
if summary_sentences:
|
||||
return ' '.join(summary_sentences)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def read_last_assistant_message(transcript_path):
|
||||
"""
|
||||
Read the JSONL transcript file and extract the last assistant message's text content.
|
||||
|
||||
Returns:
|
||||
tuple: (text_content, message_count) where text_content is the combined text
|
||||
from all text blocks in the last assistant message, or None if not found.
|
||||
"""
|
||||
try:
|
||||
# Expand user path if needed
|
||||
transcript_path = os.path.expanduser(transcript_path)
|
||||
|
||||
if not os.path.exists(transcript_path):
|
||||
return None, 0
|
||||
|
||||
last_assistant_text = None
|
||||
message_count = 0
|
||||
|
||||
# Read JSONL file line by line
|
||||
with open(transcript_path, 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(line)
|
||||
message_count += 1
|
||||
|
||||
# Check if this is an assistant message
|
||||
if 'message' in data and data['message'].get('role') == 'assistant':
|
||||
content = data['message'].get('content', [])
|
||||
|
||||
# Extract all text blocks
|
||||
text_blocks = [
|
||||
block.get('text', '')
|
||||
for block in content
|
||||
if block.get('type') == 'text'
|
||||
]
|
||||
|
||||
# Join text blocks
|
||||
if text_blocks:
|
||||
last_assistant_text = '\n'.join(text_blocks)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# Skip malformed lines
|
||||
continue
|
||||
|
||||
return last_assistant_text, message_count
|
||||
|
||||
except Exception as e:
|
||||
print(f"TTS Error reading transcript: {e}", file=sys.stderr)
|
||||
return None, 0
|
||||
|
||||
|
||||
def speak_text(text, voice, speed):
|
||||
"""Speak text using macOS 'say' command."""
|
||||
try:
|
||||
# Check if 'say' command exists (macOS only)
|
||||
result = subprocess.run(
|
||||
['which', 'say'],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print("TTS: 'say' command not available (non-macOS system)", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Call 'say' command
|
||||
subprocess.run(
|
||||
['say', '-v', voice, '-r', str(speed), text],
|
||||
check=True
|
||||
)
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"TTS Error: Failed to speak text: {e}", file=sys.stderr)
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"TTS Error: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Main handler for Stop hook."""
|
||||
try:
|
||||
# Load configuration
|
||||
config = load_config()
|
||||
|
||||
# Check if TTS is enabled
|
||||
if not config.get("enabled", True):
|
||||
return
|
||||
|
||||
# Read JSON input from stdin
|
||||
input_data = sys.stdin.read()
|
||||
|
||||
# If no input, nothing to process
|
||||
if not input_data.strip():
|
||||
return
|
||||
|
||||
# Try to parse as JSON
|
||||
try:
|
||||
hook_data = json.loads(input_data)
|
||||
except json.JSONDecodeError:
|
||||
# Not valid JSON - skip execution
|
||||
if config.get("debug", False):
|
||||
debug_log = config.get("debug_log", "/tmp/tts-output-debug.log")
|
||||
log_debug("TTS-DEBUG: Invalid JSON input, skipping execution", debug_log)
|
||||
return
|
||||
|
||||
# Only process if this is Stop hook data with transcript_path
|
||||
if not isinstance(hook_data, dict) or 'transcript_path' not in hook_data:
|
||||
if config.get("debug", False):
|
||||
debug_log = config.get("debug_log", "/tmp/tts-output-debug.log")
|
||||
log_debug("TTS-DEBUG: No transcript_path in input, skipping execution", debug_log)
|
||||
return
|
||||
|
||||
transcript_path = hook_data['transcript_path']
|
||||
|
||||
# Debug log transcript processing
|
||||
if config.get("debug", False):
|
||||
debug_log = config.get("debug_log", "/tmp/tts-output-debug.log")
|
||||
log_debug(f"TTS-DEBUG: Reading transcript from: {transcript_path}", debug_log)
|
||||
|
||||
# Read the last assistant message from transcript
|
||||
response_text, message_count = read_last_assistant_message(transcript_path)
|
||||
|
||||
# Debug log results
|
||||
if config.get("debug", False):
|
||||
debug_log = config.get("debug_log", "/tmp/tts-output-debug.log")
|
||||
if response_text:
|
||||
log_debug(
|
||||
f"TTS-DEBUG: Found {message_count} messages in transcript, "
|
||||
f"extracted {len(response_text)} chars from last assistant message",
|
||||
debug_log
|
||||
)
|
||||
else:
|
||||
log_debug(
|
||||
f"TTS-DEBUG: No assistant message found in transcript ({message_count} total messages)",
|
||||
debug_log
|
||||
)
|
||||
|
||||
# If no text found, skip execution
|
||||
if not response_text:
|
||||
return
|
||||
|
||||
# Extract TTS summary
|
||||
summary = extract_tts_summary(response_text)
|
||||
|
||||
# Fallback to first sentences if no summary comment found
|
||||
if not summary:
|
||||
summary = extract_fallback_summary(response_text)
|
||||
|
||||
# If we have something to speak, do it
|
||||
if summary:
|
||||
voice = config.get("voice", "Samantha")
|
||||
speed = config.get("speed", 200)
|
||||
|
||||
# Debug logging if enabled
|
||||
if config.get("debug", False):
|
||||
debug_log = config.get("debug_log", "/tmp/tts-output-debug.log")
|
||||
log_debug(
|
||||
f"TTS-DEBUG: Speaking with voice='{voice}' speed={speed}: \"{summary}\"",
|
||||
debug_log
|
||||
)
|
||||
|
||||
speak_text(summary, voice, speed)
|
||||
|
||||
except Exception as e:
|
||||
# Fail silently - don't disrupt Claude Code
|
||||
print(f"TTS Plugin Error: {e}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
53
plugin.lock.json
Normal file
53
plugin.lock.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"$schema": "internal://schemas/plugin.lock.v1.json",
|
||||
"pluginId": "gh:joel611/claude-plugins:plugins/claude/tts-output",
|
||||
"normalized": {
|
||||
"repo": null,
|
||||
"ref": "refs/tags/v20251128.0",
|
||||
"commit": "9f5a4b0de94b44c3912c076166fe4471e37208a1",
|
||||
"treeHash": "97fda54ab3cba94b016727c09661943ae0553f37fbf319171ed567f6d0a83caf",
|
||||
"generatedAt": "2025-11-28T10:19:17.649128Z",
|
||||
"toolVersion": "publish_plugins.py@0.2.0"
|
||||
},
|
||||
"origin": {
|
||||
"remote": "git@github.com:zhongweili/42plugin-data.git",
|
||||
"branch": "master",
|
||||
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
|
||||
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
|
||||
},
|
||||
"manifest": {
|
||||
"name": "tts-output",
|
||||
"description": "Text-to-speech output for Claude responses using macOS 'say' command",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"content": {
|
||||
"files": [
|
||||
{
|
||||
"path": "README.md",
|
||||
"sha256": "57e67377d19cf86bb1001247dbce3d9fc992b880c554cffa109c57c537b3b779"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks.json",
|
||||
"sha256": "ff832ebff0783ba1df08b63e8718991a641ea51abd0d83af4beb1301107ee109"
|
||||
},
|
||||
{
|
||||
"path": "hooks/scripts/stop-handler.py",
|
||||
"sha256": "dbbb2e819ab79f0295a699464bd60b6720bbb8d0585fbe72ac2fbe6d35c7b27d"
|
||||
},
|
||||
{
|
||||
"path": "hooks/scripts/session-start.sh",
|
||||
"sha256": "053734cff43018be840e551dacc575ebd8dc44ee9e4699e178caaa41d0ef11c8"
|
||||
},
|
||||
{
|
||||
"path": ".claude-plugin/plugin.json",
|
||||
"sha256": "e3032f8b920871237fd584e8a47125b249136daf5d4d98194c49bb92db2644fa"
|
||||
}
|
||||
],
|
||||
"dirSha256": "97fda54ab3cba94b016727c09661943ae0553f37fbf319171ed567f6d0a83caf"
|
||||
},
|
||||
"security": {
|
||||
"scannedAt": null,
|
||||
"scannerVersion": null,
|
||||
"flags": []
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user