Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:28:23 +08:00
commit 35fec1cf66
6 changed files with 365 additions and 0 deletions

View 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
View File

@@ -0,0 +1,3 @@
# tts-output
Text-to-speech output for Claude responses using macOS 'say' command

26
hooks/hooks.json Normal file
View 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
View 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
View 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
View 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": []
}
}