From 35fec1cf6690febc79bf671635aae2de6a85ef33 Mon Sep 17 00:00:00 2001 From: Zhongwei Li Date: Sun, 30 Nov 2025 08:28:23 +0800 Subject: [PATCH] Initial commit --- .claude-plugin/plugin.json | 12 ++ README.md | 3 + hooks/hooks.json | 26 ++++ hooks/scripts/session-start.sh | 19 +++ hooks/scripts/stop-handler.py | 252 +++++++++++++++++++++++++++++++++ plugin.lock.json | 53 +++++++ 6 files changed, 365 insertions(+) create mode 100644 .claude-plugin/plugin.json create mode 100644 README.md create mode 100644 hooks/hooks.json create mode 100755 hooks/scripts/session-start.sh create mode 100755 hooks/scripts/stop-handler.py create mode 100644 plugin.lock.json diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..9f2d078 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -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" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..705a1f0 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# tts-output + +Text-to-speech output for Claude responses using macOS 'say' command diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..1074626 --- /dev/null +++ b/hooks/hooks.json @@ -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" + } + ] + } + ] + } +} diff --git a/hooks/scripts/session-start.sh b/hooks/scripts/session-start.sh new file mode 100755 index 0000000..7b0f266 --- /dev/null +++ b/hooks/scripts/session-start.sh @@ -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: + + + +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: + +EOF diff --git a/hooks/scripts/stop-handler.py b/hooks/scripts/stop-handler.py new file mode 100755 index 0000000..cd7c4ee --- /dev/null +++ b/hooks/scripts/stop-handler.py @@ -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 + pattern = r'' + 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() diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..9c0ff58 --- /dev/null +++ b/plugin.lock.json @@ -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": [] + } +} \ No newline at end of file