voice-mode 4.4.0__py3-none-any.whl → 4.5.0__py3-none-any.whl

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.
Files changed (75) hide show
  1. voice_mode/__version__.py +1 -1
  2. voice_mode/cli.py +79 -3
  3. voice_mode/cli_commands/transcribe.py +7 -6
  4. voice_mode/config.py +1 -1
  5. voice_mode/conversation_logger.py +6 -0
  6. voice_mode/core.py +9 -2
  7. voice_mode/frontend/.next/BUILD_ID +1 -1
  8. voice_mode/frontend/.next/app-build-manifest.json +5 -5
  9. voice_mode/frontend/.next/build-manifest.json +3 -3
  10. voice_mode/frontend/.next/next-minimal-server.js.nft.json +1 -1
  11. voice_mode/frontend/.next/next-server.js.nft.json +1 -1
  12. voice_mode/frontend/.next/prerender-manifest.json +1 -1
  13. voice_mode/frontend/.next/required-server-files.json +1 -1
  14. voice_mode/frontend/.next/server/app/_not-found/page.js +1 -1
  15. voice_mode/frontend/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. voice_mode/frontend/.next/server/app/_not-found.html +1 -1
  17. voice_mode/frontend/.next/server/app/_not-found.rsc +1 -1
  18. voice_mode/frontend/.next/server/app/api/connection-details/route.js +2 -2
  19. voice_mode/frontend/.next/server/app/favicon.ico/route.js +2 -2
  20. voice_mode/frontend/.next/server/app/index.html +1 -1
  21. voice_mode/frontend/.next/server/app/index.rsc +2 -2
  22. voice_mode/frontend/.next/server/app/page.js +3 -3
  23. voice_mode/frontend/.next/server/app/page_client-reference-manifest.js +1 -1
  24. voice_mode/frontend/.next/server/chunks/994.js +1 -1
  25. voice_mode/frontend/.next/server/middleware-build-manifest.js +1 -1
  26. voice_mode/frontend/.next/server/next-font-manifest.js +1 -1
  27. voice_mode/frontend/.next/server/next-font-manifest.json +1 -1
  28. voice_mode/frontend/.next/server/pages/404.html +1 -1
  29. voice_mode/frontend/.next/server/pages/500.html +1 -1
  30. voice_mode/frontend/.next/server/server-reference-manifest.json +1 -1
  31. voice_mode/frontend/.next/standalone/.next/BUILD_ID +1 -1
  32. voice_mode/frontend/.next/standalone/.next/app-build-manifest.json +5 -5
  33. voice_mode/frontend/.next/standalone/.next/build-manifest.json +3 -3
  34. voice_mode/frontend/.next/standalone/.next/prerender-manifest.json +1 -1
  35. voice_mode/frontend/.next/standalone/.next/required-server-files.json +1 -1
  36. voice_mode/frontend/.next/standalone/.next/server/app/_not-found/page.js +1 -1
  37. voice_mode/frontend/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  38. voice_mode/frontend/.next/standalone/.next/server/app/_not-found.html +1 -1
  39. voice_mode/frontend/.next/standalone/.next/server/app/_not-found.rsc +1 -1
  40. voice_mode/frontend/.next/standalone/.next/server/app/api/connection-details/route.js +2 -2
  41. voice_mode/frontend/.next/standalone/.next/server/app/favicon.ico/route.js +2 -2
  42. voice_mode/frontend/.next/standalone/.next/server/app/index.html +1 -1
  43. voice_mode/frontend/.next/standalone/.next/server/app/index.rsc +2 -2
  44. voice_mode/frontend/.next/standalone/.next/server/app/page.js +3 -3
  45. voice_mode/frontend/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  46. voice_mode/frontend/.next/standalone/.next/server/chunks/994.js +1 -1
  47. voice_mode/frontend/.next/standalone/.next/server/middleware-build-manifest.js +1 -1
  48. voice_mode/frontend/.next/standalone/.next/server/next-font-manifest.js +1 -1
  49. voice_mode/frontend/.next/standalone/.next/server/next-font-manifest.json +1 -1
  50. voice_mode/frontend/.next/standalone/.next/server/pages/404.html +1 -1
  51. voice_mode/frontend/.next/standalone/.next/server/pages/500.html +1 -1
  52. voice_mode/frontend/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  53. voice_mode/frontend/.next/standalone/server.js +1 -1
  54. voice_mode/frontend/.next/static/chunks/app/layout-d3ec7f6f14ea7396.js +1 -0
  55. voice_mode/frontend/.next/static/chunks/app/{page-ae0d14863ed895ea.js → page-471796963fb1a4bd.js} +1 -1
  56. voice_mode/frontend/.next/static/chunks/{main-app-836e76fc70b52220.js → main-app-78da5e437b6a2a9f.js} +1 -1
  57. voice_mode/frontend/.next/trace +43 -43
  58. voice_mode/frontend/.next/types/app/api/connection-details/route.ts +1 -1
  59. voice_mode/frontend/.next/types/app/layout.ts +1 -1
  60. voice_mode/frontend/.next/types/app/page.ts +1 -1
  61. voice_mode/frontend/package-lock.json +26 -15
  62. voice_mode/provider_discovery.py +55 -79
  63. voice_mode/providers.py +61 -45
  64. voice_mode/simple_failover.py +41 -12
  65. voice_mode/tools/__init__.py +138 -30
  66. voice_mode/tools/converse.py +148 -337
  67. voice_mode/tools/diagnostics.py +2 -1
  68. voice_mode/tools/voice_registry.py +24 -28
  69. {voice_mode-4.4.0.dist-info → voice_mode-4.5.0.dist-info}/METADATA +5 -2
  70. {voice_mode-4.4.0.dist-info → voice_mode-4.5.0.dist-info}/RECORD +74 -74
  71. voice_mode/frontend/.next/static/chunks/app/layout-917e8410913fe899.js +0 -1
  72. /voice_mode/frontend/.next/static/{WhZriRkBKVNPSmCnOFRav → Ni4GIqyDdn0QehvmlLBZg}/_buildManifest.js +0 -0
  73. /voice_mode/frontend/.next/static/{WhZriRkBKVNPSmCnOFRav → Ni4GIqyDdn0QehvmlLBZg}/_ssgManifest.js +0 -0
  74. {voice_mode-4.4.0.dist-info → voice_mode-4.5.0.dist-info}/WHEEL +0 -0
  75. {voice_mode-4.4.0.dist-info → voice_mode-4.5.0.dist-info}/entry_points.txt +0 -0
@@ -8,11 +8,12 @@ Connection refused errors are instant, so there's no performance penalty.
8
8
  import logging
9
9
  from typing import Optional, Tuple, Dict, Any
10
10
  from openai import AsyncOpenAI
11
+ from .provider_discovery import is_local_provider
11
12
 
12
13
  from .config import TTS_BASE_URLS, STT_BASE_URLS, OPENAI_API_KEY
13
14
  from .provider_discovery import detect_provider_type
14
15
 
15
- logger = logging.getLogger("voice-mode")
16
+ logger = logging.getLogger("voicemode")
16
17
 
17
18
 
18
19
  async def simple_tts_failover(
@@ -71,10 +72,13 @@ async def simple_tts_failover(
71
72
  else:
72
73
  selected_voice = voice # Use original voice for Kokoro
73
74
 
75
+ # Disable retries for local endpoints - they either work or don't
76
+ max_retries = 0 if is_local_provider(base_url) else 2
74
77
  client = AsyncOpenAI(
75
78
  api_key=api_key,
76
79
  base_url=base_url,
77
- timeout=30.0 # Reasonable timeout
80
+ timeout=30.0, # Reasonable timeout
81
+ max_retries=max_retries
78
82
  )
79
83
 
80
84
  # Create clients dict for text_to_speech
@@ -132,19 +136,31 @@ async def simple_stt_failover(
132
136
  """
133
137
  last_error = None
134
138
 
139
+ # Log STT request details
140
+ logger.info("STT: Starting speech-to-text conversion")
141
+ logger.info(f" Available endpoints: {STT_BASE_URLS}")
142
+
135
143
  # Try each STT endpoint in order
136
- for base_url in STT_BASE_URLS:
144
+ for i, base_url in enumerate(STT_BASE_URLS):
137
145
  try:
138
- logger.info(f"Trying STT endpoint: {base_url}")
139
-
140
- # Create client for this endpoint
146
+ # Detect provider type for logging
141
147
  provider_type = detect_provider_type(base_url)
148
+
149
+ if i == 0:
150
+ logger.info(f"STT: Attempting primary endpoint: {base_url} ({provider_type})")
151
+ else:
152
+ logger.warning(f"STT: Primary failed, attempting fallback #{i}: {base_url} ({provider_type})")
153
+
154
+ # Create client for this endpoint
142
155
  api_key = OPENAI_API_KEY if provider_type == "openai" else (OPENAI_API_KEY or "dummy-key-for-local")
143
156
 
157
+ # Disable retries for local endpoints - they either work or don't
158
+ max_retries = 0 if is_local_provider(base_url) else 2
144
159
  client = AsyncOpenAI(
145
160
  api_key=api_key,
146
161
  base_url=base_url,
147
- timeout=30.0
162
+ timeout=30.0,
163
+ max_retries=max_retries
148
164
  )
149
165
 
150
166
  # Try STT with this endpoint
@@ -155,17 +171,30 @@ async def simple_stt_failover(
155
171
  )
156
172
 
157
173
  text = transcription.strip() if isinstance(transcription, str) else transcription.text.strip()
158
-
174
+
159
175
  if text:
160
- logger.info(f"STT succeeded with {base_url}")
161
- return text
176
+ logger.info(f"STT succeeded with {provider_type} at {base_url}")
177
+ logger.info(f" Transcribed: {text[:100]}{'...' if len(text) > 100 else ''}")
178
+ # Return both text and provider info for display
179
+ return {"text": text, "provider": provider_type, "endpoint": base_url}
180
+ else:
181
+ logger.warning(f"STT returned empty result from {base_url} ({provider_type})")
162
182
 
163
183
  except Exception as e:
164
184
  last_error = str(e)
165
- logger.debug(f"STT failed for {base_url}: {e}")
185
+ provider_type = detect_provider_type(base_url)
186
+
187
+ # Log failure with appropriate level based on whether we have fallbacks
188
+ if i < len(STT_BASE_URLS) - 1:
189
+ logger.warning(f"STT failed for {base_url} ({provider_type}): {e}")
190
+ logger.info(" Will try next endpoint...")
191
+ else:
192
+ logger.error(f"STT failed for final endpoint {base_url} ({provider_type}): {e}")
193
+
166
194
  # Continue to next endpoint
167
195
  continue
168
196
 
169
197
  # All endpoints failed
170
- logger.error(f"All STT endpoints failed. Last error: {last_error}")
198
+ logger.error(f"All STT endpoints failed after {len(STT_BASE_URLS)} attempts")
199
+ logger.error(f" Last error: {last_error}")
171
200
  return None
@@ -9,42 +9,150 @@ logger = logging.getLogger("voice-mode")
9
9
  # Get the directory containing this file
10
10
  tools_dir = Path(__file__).parent
11
11
 
12
- # Check if we should only load specific tools
13
- # This can be set in .voicemode.env, shell environment, or .mcp.json
14
- allowed_tools = os.environ.get("VOICEMODE_TOOLS", "").strip()
15
-
16
- if allowed_tools:
17
- # Only load specified tools (comma-separated list)
18
- tool_list = [t.strip() for t in allowed_tools.split(",")]
19
-
20
- logger.info(f"Selective tool loading enabled. Loading only: {', '.join(tool_list)}")
21
-
22
- for tool_name in tool_list:
23
- tool_file = tools_dir / f"{tool_name}.py"
24
- if tool_file.exists():
25
- logger.debug(f"Loading tool: {tool_name}")
26
- importlib.import_module(f".{tool_name}", package=__name__)
27
- else:
28
- logger.warning(f"Tool module not found: {tool_name}.py")
29
- else:
30
- # Default behavior: load all tools
31
- logger.info("Loading all available tools (set VOICEMODE_TOOLS to limit)")
32
-
33
- # Import all Python files in this directory (except __init__.py)
12
+ def get_all_available_tools() -> set[str]:
13
+ """
14
+ Get all available tool names from the filesystem.
15
+
16
+ Returns:
17
+ Set of tool module names (without .py extension)
18
+ """
19
+ available_tools = set()
20
+
21
+ # Get tools from main directory
34
22
  for file in tools_dir.glob("*.py"):
35
23
  if file.name != "__init__.py" and not file.name.startswith("_"):
36
- module_name = file.stem
37
- logger.debug(f"Loading tool: {module_name}")
38
- importlib.import_module(f".{module_name}", package=__name__)
24
+ available_tools.add(file.stem)
39
25
 
40
- # Import all service tools from subdirectories
26
+ # Get tools from services subdirectories
41
27
  services_dir = tools_dir / "services"
42
28
  if services_dir.exists():
43
29
  for service_dir in services_dir.iterdir():
44
30
  if service_dir.is_dir() and not service_dir.name.startswith("_"):
45
- # Import all Python files in each service directory
46
31
  for file in service_dir.glob("*.py"):
47
32
  if file.name != "__init__.py" and not file.name.startswith("_") and file.name != "helpers.py":
48
- module_path = f".services.{service_dir.name}.{file.stem}"
49
- logger.debug(f"Loading service tool: {module_path}")
50
- importlib.import_module(module_path, package=__name__)
33
+ # Use flattened naming: service_toolname
34
+ tool_name = f"{service_dir.name}_{file.stem}"
35
+ available_tools.add(tool_name)
36
+
37
+ return available_tools
38
+
39
+ def parse_tool_list(tool_string: str) -> set[str]:
40
+ """
41
+ Parse comma-separated tool list into a set of tool names.
42
+
43
+ Args:
44
+ tool_string: Comma-separated string of tool names
45
+
46
+ Returns:
47
+ Set of trimmed tool names
48
+ """
49
+ if not tool_string:
50
+ return set()
51
+ return {t.strip() for t in tool_string.split(",") if t.strip()}
52
+
53
+ def determine_tools_to_load() -> tuple[set[str], str]:
54
+ """
55
+ Determine which tools should be loaded based on environment variables.
56
+
57
+ Returns:
58
+ Tuple of (tools_to_load, mode_description)
59
+ """
60
+ # Check for new environment variables
61
+ enabled_tools = os.environ.get("VOICEMODE_TOOLS_ENABLED", "").strip()
62
+ disabled_tools = os.environ.get("VOICEMODE_TOOLS_DISABLED", "").strip()
63
+
64
+ # Check for legacy variable
65
+ legacy_tools = os.environ.get("VOICEMODE_TOOLS", "").strip()
66
+
67
+ # Get all available tools
68
+ all_tools = get_all_available_tools()
69
+
70
+ # Determine which tools to load
71
+ if enabled_tools:
72
+ # Whitelist mode - only load specified tools
73
+ requested = parse_tool_list(enabled_tools)
74
+ tools_to_load = requested & all_tools # Only load tools that exist
75
+ invalid = requested - all_tools
76
+
77
+ if invalid:
78
+ logger.warning(f"Requested tools not found: {', '.join(sorted(invalid))}")
79
+
80
+ return tools_to_load, f"whitelist mode ({len(tools_to_load)} tools)"
81
+
82
+ elif disabled_tools:
83
+ # Blacklist mode - load all except specified
84
+ excluded = parse_tool_list(disabled_tools)
85
+ tools_to_load = all_tools - excluded
86
+
87
+ # Log if any excluded tools don't exist (informational)
88
+ nonexistent = excluded - all_tools
89
+ if nonexistent:
90
+ logger.debug(f"Excluded tools not found (ignoring): {', '.join(sorted(nonexistent))}")
91
+
92
+ return tools_to_load, f"blacklist mode (excluding {len(excluded & all_tools)} tools)"
93
+
94
+ elif legacy_tools:
95
+ # Legacy support with deprecation warning
96
+ logger.warning(
97
+ "VOICEMODE_TOOLS is deprecated and will be removed in v5.0. "
98
+ "Please use VOICEMODE_TOOLS_ENABLED or VOICEMODE_TOOLS_DISABLED instead."
99
+ )
100
+ requested = parse_tool_list(legacy_tools)
101
+ tools_to_load = requested & all_tools
102
+ invalid = requested - all_tools
103
+
104
+ if invalid:
105
+ logger.warning(f"Requested tools not found: {', '.join(sorted(invalid))}")
106
+
107
+ return tools_to_load, f"legacy mode ({len(tools_to_load)} tools)"
108
+
109
+ else:
110
+ # Default - load everything
111
+ return all_tools, "default mode (all tools)"
112
+
113
+ def load_tool(tool_name: str) -> bool:
114
+ """
115
+ Load a single tool by name.
116
+
117
+ Args:
118
+ tool_name: Name of the tool to load
119
+
120
+ Returns:
121
+ True if successfully loaded, False otherwise
122
+ """
123
+ try:
124
+ # Check if it's a service tool (contains underscore)
125
+ if "_" in tool_name:
126
+ parts = tool_name.split("_", 1)
127
+ if len(parts) == 2:
128
+ service_name, tool_file = parts
129
+ module_path = f".services.{service_name}.{tool_file}"
130
+ logger.debug(f"Loading service tool: {tool_name}")
131
+ importlib.import_module(module_path, package=__name__)
132
+ return True
133
+
134
+ # Try as a regular tool
135
+ tool_file = tools_dir / f"{tool_name}.py"
136
+ if tool_file.exists():
137
+ logger.debug(f"Loading tool: {tool_name}")
138
+ importlib.import_module(f".{tool_name}", package=__name__)
139
+ return True
140
+
141
+ logger.warning(f"Tool not found: {tool_name}")
142
+ return False
143
+
144
+ except ImportError as e:
145
+ logger.error(f"Failed to import tool {tool_name}: {e}")
146
+ return False
147
+
148
+ # Main loading logic
149
+ tools_to_load, mode = determine_tools_to_load()
150
+
151
+ if tools_to_load:
152
+ logger.info(f"Tool loading: {mode} - loading {len(tools_to_load)} tools")
153
+
154
+ # Sort for consistent loading order
155
+ for tool_name in sorted(tools_to_load):
156
+ load_tool(tool_name)
157
+ else:
158
+ logger.warning("No tools to load based on current configuration")