caudate-cli 0.1.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 (153) hide show
  1. api/__init__.py +5 -0
  2. api/anthropic_compat.py +1518 -0
  3. api/artifact_viewer.py +366 -0
  4. api/caudate_middleware.py +618 -0
  5. api/forge_bootstrapper_routes.py +377 -0
  6. api/forge_routes.py +630 -0
  7. api/forge_system_routes.py +294 -0
  8. api/openai_compat.py +1993 -0
  9. api/server.py +667 -0
  10. api/storyboard_page.py +677 -0
  11. caudate_cli-0.1.0.dist-info/METADATA +354 -0
  12. caudate_cli-0.1.0.dist-info/RECORD +153 -0
  13. caudate_cli-0.1.0.dist-info/WHEEL +5 -0
  14. caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
  15. caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  16. caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
  17. cognos_mcp/__init__.py +4 -0
  18. cognos_mcp/bridge.py +41 -0
  19. cognos_mcp/client.py +70 -0
  20. cognos_mcp/config.py +49 -0
  21. cognos_mcp/server.py +66 -0
  22. config.py +82 -0
  23. core/__init__.py +0 -0
  24. core/agent.py +468 -0
  25. core/agentic_loop.py +731 -0
  26. core/anthropic_auth.py +91 -0
  27. core/background.py +113 -0
  28. core/banner.py +134 -0
  29. core/bootstrap.py +292 -0
  30. core/citations.py +131 -0
  31. core/compaction.py +109 -0
  32. core/constitution.py +198 -0
  33. core/diff_viewer.py +87 -0
  34. core/export.py +85 -0
  35. core/file_refs.py +119 -0
  36. core/files.py +199 -0
  37. core/hooks.py +209 -0
  38. core/image.py +599 -0
  39. core/input.py +91 -0
  40. core/loop.py +238 -0
  41. core/memory_md.py +147 -0
  42. core/notifications.py +99 -0
  43. core/ownership.py +181 -0
  44. core/paste.py +81 -0
  45. core/permissions.py +210 -0
  46. core/plan_mode.py +215 -0
  47. core/sandbox_prompt.py +185 -0
  48. core/scheduler.py +195 -0
  49. core/schemas.py +202 -0
  50. core/session.py +90 -0
  51. core/settings.py +132 -0
  52. core/skills.py +398 -0
  53. core/slash_commands.py +977 -0
  54. core/statusline.py +61 -0
  55. core/subagent.py +300 -0
  56. core/thinking.py +50 -0
  57. core/updater.py +122 -0
  58. core/usage.py +109 -0
  59. core/worktree.py +93 -0
  60. execution/__init__.py +0 -0
  61. execution/executor.py +329 -0
  62. execution/plugins.py +108 -0
  63. execution/tools/__init__.py +0 -0
  64. execution/tools/agent_tool.py +107 -0
  65. execution/tools/agentic_tool.py +297 -0
  66. execution/tools/artifact_tool.py +191 -0
  67. execution/tools/ask_user_question_tool.py +137 -0
  68. execution/tools/base.py +81 -0
  69. execution/tools/calculator_tool.py +137 -0
  70. execution/tools/cognos_card_tool.py +124 -0
  71. execution/tools/cron_tool.py +215 -0
  72. execution/tools/datetime_tool.py +215 -0
  73. execution/tools/describe_image_tool.py +161 -0
  74. execution/tools/draw_tool.py +164 -0
  75. execution/tools/edit_image_tool.py +262 -0
  76. execution/tools/edit_tool.py +245 -0
  77. execution/tools/file_tool.py +90 -0
  78. execution/tools/find_anywhere_tool.py +255 -0
  79. execution/tools/forge_feature_tools.py +377 -0
  80. execution/tools/glob_tool.py +59 -0
  81. execution/tools/grep_tool.py +89 -0
  82. execution/tools/http_request_tool.py +224 -0
  83. execution/tools/load_skill_tool.py +104 -0
  84. execution/tools/longcat_avatar_tool.py +384 -0
  85. execution/tools/mcp_tool.py +100 -0
  86. execution/tools/notebook_tool.py +279 -0
  87. execution/tools/openapi_tool.py +440 -0
  88. execution/tools/plan_mode_tool.py +95 -0
  89. execution/tools/push_notification_tool.py +157 -0
  90. execution/tools/python_tool.py +61 -0
  91. execution/tools/respond_tool.py +40 -0
  92. execution/tools/sandbox_tool.py +378 -0
  93. execution/tools/search_tool.py +153 -0
  94. execution/tools/semantic_search_tool.py +106 -0
  95. execution/tools/shell_tool.py +283 -0
  96. execution/tools/speak_tool.py +134 -0
  97. execution/tools/storyboard_tool.py +727 -0
  98. execution/tools/system_info_tool.py +212 -0
  99. execution/tools/task_tool.py +323 -0
  100. execution/tools/think_tool.py +49 -0
  101. execution/tools/transcribe_audio_tool.py +86 -0
  102. execution/tools/update_memory_tool.py +92 -0
  103. execution/tools/web_fetch_tool.py +82 -0
  104. execution/tools/worktree_tool.py +174 -0
  105. llm/__init__.py +0 -0
  106. llm/fallback.py +116 -0
  107. llm/models.py +320 -0
  108. llm/provider.py +1356 -0
  109. llm/router.py +373 -0
  110. main.py +1889 -0
  111. memory/__init__.py +0 -0
  112. memory/episodic.py +99 -0
  113. memory/procedural.py +145 -0
  114. memory/semantic.py +71 -0
  115. memory/working.py +64 -0
  116. nn/__init__.py +43 -0
  117. nn/auto_evolve.py +245 -0
  118. nn/caudate.py +136 -0
  119. nn/config.py +141 -0
  120. nn/consolidator.py +81 -0
  121. nn/data.py +1635 -0
  122. nn/encoder.py +258 -0
  123. nn/forge_advisor.py +303 -0
  124. nn/format.py +235 -0
  125. nn/heads.py +432 -0
  126. nn/observer.py +994 -0
  127. nn/policy.py +214 -0
  128. nn/runtime.py +343 -0
  129. nn/scorer.py +175 -0
  130. nn/trainer.py +515 -0
  131. nn/vision.py +352 -0
  132. personality/__init__.py +23 -0
  133. personality/engine.py +129 -0
  134. personality/identity.py +144 -0
  135. personality/inner_voice.py +100 -0
  136. personality/mood.py +205 -0
  137. planning/__init__.py +0 -0
  138. planning/dev_server.py +221 -0
  139. planning/forge_models.py +718 -0
  140. planning/orchestrator.py +1363 -0
  141. planning/planner.py +451 -0
  142. planning/task_graph.py +61 -0
  143. reflection/__init__.py +0 -0
  144. reflection/meta_learner.py +156 -0
  145. reflection/reflector.py +127 -0
  146. ui/__init__.py +5 -0
  147. ui/display.py +88 -0
  148. voice/__init__.py +0 -0
  149. voice/conversation.py +125 -0
  150. voice/listener.py +111 -0
  151. voice/speaker.py +59 -0
  152. voice/stt.py +126 -0
  153. voice/tts.py +214 -0
@@ -0,0 +1,127 @@
1
+ """Reflector — evaluates outcomes and extracts lessons."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from config import SUCCESS_THRESHOLD
11
+ from core.schemas import (
12
+ Episode, Goal, Reflection, Task, ToolResultStatus,
13
+ )
14
+ from llm.provider import LLMProvider
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class ReflectionOutput(BaseModel):
20
+ """LLM-emitted reflection structure."""
21
+ score: float = Field(ge=0.0, le=1.0)
22
+ what_happened: str = ""
23
+ what_worked: str = ""
24
+ what_failed: str = ""
25
+ lesson: str = ""
26
+ should_replan: bool = False
27
+ replan_reason: str = ""
28
+
29
+ REFLECT_PROMPT = """You are the reflection module of a cognitive architecture.
30
+ Evaluate what just happened and extract lessons.
31
+
32
+ Goal: {goal}
33
+ Task attempted: {task}
34
+ Action taken: {action}
35
+ Tool used: {tool}
36
+ Result status: {status}
37
+ Result output: {output}
38
+ Error (if any): {error}
39
+
40
+ Respond with JSON:
41
+ {{
42
+ "score": 0.0 to 1.0 (how successful was this action),
43
+ "what_happened": "brief description",
44
+ "what_worked": "what went well",
45
+ "what_failed": "what went wrong",
46
+ "lesson": "key takeaway for future actions",
47
+ "should_replan": true/false,
48
+ "replan_reason": "why replanning is needed (if applicable)"
49
+ }}"""
50
+
51
+
52
+ class Reflector:
53
+ """Evaluates action outcomes and produces structured reflections."""
54
+
55
+ def __init__(self, llm: LLMProvider):
56
+ self.llm = llm
57
+
58
+ async def reflect(
59
+ self,
60
+ episode: Episode,
61
+ goal: Goal,
62
+ task: Task,
63
+ ) -> Reflection:
64
+ """Reflect on an episode and produce a structured evaluation."""
65
+ result = episode.result
66
+
67
+ # Fast path: clear success → skip LLM. Originally only no-tool
68
+ # reasoning steps short-circuited; now any clean tool success
69
+ # does too, saving an LLM round-trip per successful step. The
70
+ # meta-learner's value still comes mainly from failure-reflections
71
+ # and the periodic strategy pass, so dropping prose reflections
72
+ # on routine successes costs nothing observable.
73
+ if result and result.status == ToolResultStatus.SUCCESS:
74
+ tool = task.tool_name or "Reasoning"
75
+ return Reflection(
76
+ episode_id=episode.id,
77
+ goal_id=goal.id,
78
+ score=0.85,
79
+ what_happened=f"{tool} step completed: {task.description}",
80
+ what_worked=f"{tool} executed without error",
81
+ lesson="",
82
+ )
83
+
84
+ # Use LLM for nuanced reflection
85
+ prompt = REFLECT_PROMPT.format(
86
+ goal=goal.description,
87
+ task=task.description,
88
+ action=episode.action,
89
+ tool=episode.tool_name or "none",
90
+ status=result.status.value if result else "unknown",
91
+ output=str(result.output)[:500] if result and result.output else "none",
92
+ error=result.error or "none" if result else "unknown",
93
+ )
94
+
95
+ try:
96
+ data = await self.llm.structured_output(
97
+ prompt=prompt, response_model=ReflectionOutput,
98
+ caller="reflection",
99
+ )
100
+ return Reflection(
101
+ episode_id=episode.id,
102
+ goal_id=goal.id,
103
+ score=data.score,
104
+ what_happened=data.what_happened,
105
+ what_worked=data.what_worked,
106
+ what_failed=data.what_failed,
107
+ lesson=data.lesson,
108
+ should_replan=data.should_replan,
109
+ replan_reason=data.replan_reason,
110
+ )
111
+ except Exception as e:
112
+ logger.error(f"Reflection failed: {e}")
113
+ # Fallback: score based on result status
114
+ score = 0.8 if result and result.status == ToolResultStatus.SUCCESS else 0.2
115
+ return Reflection(
116
+ episode_id=episode.id,
117
+ goal_id=goal.id,
118
+ score=score,
119
+ what_happened=f"Action {'succeeded' if score > 0.5 else 'failed'}",
120
+ lesson=f"Reflection generation failed: {e}",
121
+ should_replan=score < SUCCESS_THRESHOLD,
122
+ replan_reason="Action failed" if score < SUCCESS_THRESHOLD else "",
123
+ )
124
+
125
+ def needs_replan(self, reflection: Reflection) -> bool:
126
+ """Determine if a reflection warrants re-planning."""
127
+ return reflection.should_replan or reflection.score < SUCCESS_THRESHOLD
ui/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Cognos UI layer — rich-based terminal rendering."""
2
+
3
+ from ui.display import StreamDisplay
4
+
5
+ __all__ = ["StreamDisplay"]
ui/display.py ADDED
@@ -0,0 +1,88 @@
1
+ """StreamDisplay — render agentic-loop stream events as clean inline text.
2
+
3
+ Goals:
4
+ - text_delta events stream tokens directly to stdout, no surrounding box
5
+ - tool calls show as a single dim arrow line: `→ Glob(...)`
6
+ - tool results show as a dim arrow with a short preview: `← /path/to/...`
7
+ - no panels, no border lines, no live re-render flicker
8
+
9
+ Anything that wants pretty boxes can wrap the output itself.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import sys
16
+ from typing import Any
17
+
18
+ from rich.console import Console
19
+
20
+ from core.schemas import StreamEvent
21
+
22
+
23
+ class StreamDisplay:
24
+ """Consumes StreamEvents from AgenticLoop.run_streaming and prints them."""
25
+
26
+ def __init__(self, console: Console | None = None):
27
+ self.console = console or Console()
28
+ self._text: list[str] = []
29
+ self._wrote_text = False
30
+
31
+ async def render(self, stream) -> str:
32
+ """Consume an async iterator of StreamEvents, returning the final text."""
33
+ self._text = []
34
+ self._wrote_text = False
35
+
36
+ async for event in stream:
37
+ self._handle(event)
38
+
39
+ # Trailing newline if we streamed anything so the next prompt starts clean.
40
+ if self._wrote_text:
41
+ self.console.file.write("\n")
42
+ self.console.file.flush()
43
+ return "".join(self._text)
44
+
45
+ # ------------------------------------------------------------------
46
+
47
+ def _handle(self, event: StreamEvent) -> None:
48
+ t = event.type
49
+ if t == "text_delta" and event.delta:
50
+ self._text.append(event.delta)
51
+ # Write directly to the underlying file to avoid Rich's
52
+ # auto-formatting on tokens that may contain markup chars.
53
+ self.console.file.write(event.delta)
54
+ self.console.file.flush()
55
+ self._wrote_text = True
56
+ elif t == "tool_use_end":
57
+ self._flush_newline()
58
+ args = _summarize_args(event.tool_input or {})
59
+ self.console.print(f"[dim]→ {event.tool_name}({args})[/dim]")
60
+ elif t == "tool_result":
61
+ self._flush_newline()
62
+ preview = (event.delta or "").strip().splitlines()[0] if event.delta else ""
63
+ if len(preview) > 160:
64
+ preview = preview[:157] + "…"
65
+ if preview:
66
+ self.console.print(f"[dim]← {preview}[/dim]")
67
+ elif t == "thinking_delta" and event.delta:
68
+ self._flush_newline()
69
+ self.console.print(f"[dim italic]thinking: {event.delta}[/dim italic]")
70
+ # message_start / message_stop: nothing to render
71
+
72
+ def _flush_newline(self) -> None:
73
+ """If the previous output was inline text, drop a newline before printing
74
+ an aux line so they don't run together on the same row."""
75
+ if self._wrote_text:
76
+ self.console.file.write("\n")
77
+ self.console.file.flush()
78
+ self._wrote_text = False
79
+
80
+
81
+ def _summarize_args(args: dict[str, Any]) -> str:
82
+ try:
83
+ s = json.dumps(args, ensure_ascii=False)
84
+ except (TypeError, ValueError):
85
+ s = str(args)
86
+ if len(s) > 120:
87
+ s = s[:117] + "…"
88
+ return s
voice/__init__.py ADDED
File without changes
voice/conversation.py ADDED
@@ -0,0 +1,125 @@
1
+ """Voice conversation — full listen-think-speak loop wired into CognosAgent.
2
+
3
+ This is the post-Phase-2 rewrite. The old loop bypassed the agent and
4
+ talked to `LLMProvider` directly, which meant no personality, no memory,
5
+ no tools, no permissions. Now it goes through `CognosAgent.chat()` so
6
+ voice mode is just the same agentic loop with a different I/O surface.
7
+
8
+ Voice-mode wisdom is injected via a system-prompt addendum rather than
9
+ forking the personality engine: the agent stays itself, it just speaks
10
+ shorter and avoids markdown.
11
+
12
+ Interruption is handled with a Ctrl+C inside `speaker.speak()` — the
13
+ playback aborts, the user starts talking, and we resume listening. No
14
+ duplex audio (that's Moshi territory) but enough to feel responsive.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+
21
+ import sounddevice as sd
22
+
23
+ from core.agent import CognosAgent
24
+ from voice.listener import Listener
25
+ from voice.speaker import Speaker
26
+ from voice.stt import STT, make_stt
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ VOICE_INSTRUCTIONS = """You are speaking, not writing. Apply these rules:
32
+
33
+ - Reply in 1-3 short sentences. Conversational, not lecturing.
34
+ - No markdown, bullet points, headings, code blocks, or URLs.
35
+ - If the user asks for code or a list, summarize it in prose and offer to send the full version to text mode.
36
+ - Spell out symbols when they matter ('greater than' not '>').
37
+ - Never say "as an AI" or "I am an AI". You are Cognos.
38
+ - If you didn't catch what was said, ask once for it to be repeated."""
39
+
40
+
41
+ _EXIT_PHRASES = {
42
+ "goodbye", "good bye", "bye", "bye bye", "exit", "quit", "stop",
43
+ "end conversation", "shut down", "shut up", "that's all",
44
+ }
45
+
46
+
47
+ class VoiceConversation:
48
+ """Full conversational voice loop: listen → agent.chat → speak."""
49
+
50
+ def __init__(
51
+ self,
52
+ agent: CognosAgent,
53
+ stt: STT | str = "moonshine",
54
+ tts: str = "kokoro",
55
+ voice: str | None = None,
56
+ voice_path: str | None = None,
57
+ **stt_kwargs,
58
+ ):
59
+ self.agent = agent
60
+ self.listener = Listener(stt=stt, **stt_kwargs)
61
+ # Speaker picks the right TTS keyword: Piper takes a file path,
62
+ # Kokoro takes a voice id. Speaker handles the dispatch.
63
+ self.speaker = Speaker(tts=tts, voice=voice, voice_path=voice_path)
64
+ self._inject_voice_instructions()
65
+
66
+ def _inject_voice_instructions(self) -> None:
67
+ """Add the voice-mode prompt addendum to the agent's system prompt.
68
+
69
+ Personality wraps the system prompt at every turn, so we extend
70
+ its augmentation chain rather than mutating any stored prompt.
71
+ """
72
+ existing = self.agent.agentic.system_prompt_provider
73
+
74
+ def voice_wrap(base: str) -> str:
75
+ wrapped = existing(base) if existing else base
76
+ return f"{wrapped}\n\n{VOICE_INSTRUCTIONS}"
77
+
78
+ self.agent.agentic.system_prompt_provider = voice_wrap
79
+
80
+ async def run(self, greeting: bool = True) -> None:
81
+ await self.agent.start() # MCP servers, hooks, etc.
82
+
83
+ if greeting:
84
+ opening = "Hi. I'm Cognos. What's on your mind?"
85
+ print(f"\nCognos: {opening}")
86
+ self._speak_safely(opening)
87
+
88
+ while True:
89
+ print("\n[listening]", end="", flush=True)
90
+ try:
91
+ text = self.listener.listen()
92
+ except KeyboardInterrupt:
93
+ print("\n[interrupted while listening — exiting]")
94
+ break
95
+
96
+ if text is None:
97
+ print(" (no speech)")
98
+ continue
99
+
100
+ print(f"\rYou: {text} ")
101
+
102
+ normalized = text.lower().strip().rstrip(".!?")
103
+ if normalized in _EXIT_PHRASES:
104
+ farewell = "Talk soon."
105
+ print(f"Cognos: {farewell}")
106
+ self._speak_safely(farewell)
107
+ break
108
+
109
+ print("[thinking]", end="", flush=True)
110
+ try:
111
+ reply = await self.agent.chat(text)
112
+ except Exception as e:
113
+ logger.exception("agent.chat failed in voice loop")
114
+ reply = f"Something went wrong on my end. {e}"
115
+
116
+ print(f"\rCognos: {reply} ")
117
+ self._speak_safely(reply)
118
+
119
+ def _speak_safely(self, text: str) -> None:
120
+ """Speak with Ctrl+C aborting playback so the user can interrupt."""
121
+ try:
122
+ self.speaker.speak(text)
123
+ except KeyboardInterrupt:
124
+ sd.stop()
125
+ print("\n[interrupted]")
voice/listener.py ADDED
@@ -0,0 +1,111 @@
1
+ """Voice listener — VAD-gated mic capture + pluggable STT.
2
+
3
+ Captures audio from the microphone using `webrtcvad` for voice activity
4
+ detection, then hands the recorded WAV bytes off to a pluggable STT
5
+ backend (`voice.stt.STT`). The listener has no opinion on which engine
6
+ runs the transcription — caller picks.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ import logging
13
+ import struct
14
+ import wave
15
+ from collections import deque
16
+
17
+ import numpy as np
18
+ import sounddevice as sd
19
+ import webrtcvad
20
+
21
+ from voice.stt import STT, make_stt
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Audio config — webrtcvad mandates 16k mono with 10/20/30ms frames.
26
+ SAMPLE_RATE = 16000
27
+ FRAME_DURATION_MS = 30
28
+ FRAME_SIZE = int(SAMPLE_RATE * FRAME_DURATION_MS / 1000)
29
+ CHANNELS = 1
30
+
31
+
32
+ class Listener:
33
+ """VAD-gated microphone capture with delegated transcription."""
34
+
35
+ def __init__(
36
+ self,
37
+ stt: STT | str = "moonshine",
38
+ vad_aggressiveness: int = 2,
39
+ **stt_kwargs,
40
+ ):
41
+ self.vad = webrtcvad.Vad(vad_aggressiveness)
42
+ self._stt: STT = stt if not isinstance(stt, str) else make_stt(stt, **stt_kwargs)
43
+
44
+ def listen(
45
+ self,
46
+ silence_timeout: float = 1.5,
47
+ max_duration: float = 30.0,
48
+ ) -> str | None:
49
+ """Block until speech, record until silence, return transcription.
50
+
51
+ Args:
52
+ silence_timeout: Seconds of silence after speech to stop.
53
+ max_duration: Hard cap on recording length.
54
+
55
+ Returns:
56
+ Transcribed text, or None if nothing was said.
57
+ """
58
+ frames_per_timeout = int(silence_timeout * 1000 / FRAME_DURATION_MS)
59
+ max_frames = int(max_duration * 1000 / FRAME_DURATION_MS)
60
+
61
+ audio_frames: list[bytes] = []
62
+ ring_buffer: deque = deque(maxlen=frames_per_timeout)
63
+ triggered = False
64
+ silent_frames = 0
65
+
66
+ def _frame_to_bytes(frame: np.ndarray) -> bytes:
67
+ pcm = (frame * 32767).astype(np.int16)
68
+ return struct.pack(f"{len(pcm)}h", *pcm)
69
+
70
+ with sd.InputStream(
71
+ samplerate=SAMPLE_RATE,
72
+ channels=CHANNELS,
73
+ dtype="float32",
74
+ blocksize=FRAME_SIZE,
75
+ ) as stream:
76
+ for _ in range(max_frames):
77
+ data, _ = stream.read(FRAME_SIZE)
78
+ frame = data[:, 0]
79
+ raw = _frame_to_bytes(frame)
80
+ is_speech = self.vad.is_speech(raw, SAMPLE_RATE)
81
+
82
+ if not triggered:
83
+ ring_buffer.append((raw, is_speech))
84
+ voiced = sum(1 for _, s in ring_buffer if s)
85
+ if voiced > 0.8 * (ring_buffer.maxlen or 1):
86
+ triggered = True
87
+ audio_frames.extend(f for f, _ in ring_buffer)
88
+ ring_buffer.clear()
89
+ else:
90
+ audio_frames.append(raw)
91
+ if not is_speech:
92
+ silent_frames += 1
93
+ if silent_frames >= frames_per_timeout:
94
+ break
95
+ else:
96
+ silent_frames = 0
97
+
98
+ if not audio_frames:
99
+ return None
100
+
101
+ wav_buffer = io.BytesIO()
102
+ with wave.open(wav_buffer, "wb") as wf:
103
+ wf.setnchannels(CHANNELS)
104
+ wf.setsampwidth(2)
105
+ wf.setframerate(SAMPLE_RATE)
106
+ wf.writeframes(b"".join(audio_frames))
107
+
108
+ text = self._stt.transcribe(wav_buffer.getvalue())
109
+ if text:
110
+ logger.info(f"Transcribed: {text}")
111
+ return text or None
voice/speaker.py ADDED
@@ -0,0 +1,59 @@
1
+ """Speaker — plays synthesized audio through the system output.
2
+
3
+ Thin wrapper around a TTS backend (`voice.tts.TTS`). The backend turns
4
+ text into PCM samples; this class plays them via `sounddevice`.
5
+
6
+ Defaults to Kokoro for natural human-sounding speech. Pass `tts="piper"`
7
+ for the lightweight legacy backend, or `tts=<your TTS instance>` to plug
8
+ something custom.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import sounddevice as sd
18
+
19
+ from voice.tts import TTS, make_tts
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class Speaker:
25
+ """Speaks text aloud using a pluggable TTS backend."""
26
+
27
+ def __init__(
28
+ self,
29
+ tts: TTS | str = "kokoro",
30
+ voice_path: str | Path | None = None,
31
+ voice: str | None = None,
32
+ **tts_kwargs: Any,
33
+ ):
34
+ # Backwards-compat: if voice_path is set we assume Piper since
35
+ # Piper is the only backend that takes a file path.
36
+ if isinstance(tts, str):
37
+ backend = tts
38
+ kwargs: dict[str, Any] = dict(tts_kwargs)
39
+ if backend == "piper" and voice_path is not None:
40
+ kwargs["voice_path"] = voice_path
41
+ elif backend == "kokoro" and voice:
42
+ kwargs["voice"] = voice
43
+ self._tts = make_tts(backend, **kwargs)
44
+ else:
45
+ self._tts = tts
46
+
47
+ def speak(self, text: str) -> None:
48
+ """Synthesize and play text. No-op for empty text."""
49
+ if not text:
50
+ return
51
+ try:
52
+ audio, sample_rate = self._tts.synthesize(text)
53
+ except Exception as e:
54
+ logger.warning(f"TTS synthesis failed: {e}")
55
+ return
56
+ if audio.size == 0:
57
+ return
58
+ sd.play(audio, samplerate=sample_rate)
59
+ sd.wait()
voice/stt.py ADDED
@@ -0,0 +1,126 @@
1
+ """Speech-to-text backends.
2
+
3
+ Pluggable transcription. Two backends ship out of the box:
4
+
5
+ - **MoonshineSTT** (default) — Useful Sensors' Moonshine via ONNX
6
+ Runtime. Designed explicitly as a Whisper alternative for edge
7
+ devices: ~27M params, very low first-word latency, no GPU
8
+ required. `pip install useful-moonshine-onnx`.
9
+
10
+ - **WhisperSTT** (fallback / kept for compat) — faster-whisper.
11
+ Heavier, more accurate, supports many languages. `pip install
12
+ faster-whisper`.
13
+
14
+ Both expose the same interface: `.transcribe(wav_bytes) -> str`. The
15
+ listener is unaware of which is in use.
16
+
17
+ Pick a backend with `make_stt(name)`. Unknown names raise so a typo
18
+ fails loudly instead of silently picking the wrong engine.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import io
24
+ import logging
25
+ import wave
26
+ from typing import Protocol
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class STT(Protocol):
32
+ """Speech-to-text protocol. Implementations are stateful and lazy."""
33
+
34
+ def transcribe(self, wav_bytes: bytes) -> str:
35
+ ...
36
+
37
+
38
+ class MoonshineSTT:
39
+ """Moonshine via ONNX. Loads on first transcribe() call."""
40
+
41
+ def __init__(self, model: str = "moonshine/base"):
42
+ self._model_name = model
43
+ self._model = None
44
+
45
+ def _ensure_loaded(self) -> None:
46
+ if self._model is not None:
47
+ return
48
+ try:
49
+ import moonshine_onnx as moonshine
50
+ except ImportError:
51
+ try:
52
+ import moonshine_onnx as moonshine # alt package name
53
+ except ImportError as e:
54
+ raise RuntimeError(
55
+ "Moonshine STT requested but `useful-moonshine-onnx` "
56
+ "is not installed. Run: pip install useful-moonshine-onnx"
57
+ ) from e
58
+ logger.info(f"Loading Moonshine '{self._model_name}'...")
59
+ self._model = moonshine
60
+ logger.info("Moonshine loaded.")
61
+
62
+ def transcribe(self, wav_bytes: bytes) -> str:
63
+ self._ensure_loaded()
64
+ # Moonshine's API takes a numpy float32 array at 16k.
65
+ import numpy as np
66
+ with wave.open(io.BytesIO(wav_bytes), "rb") as wf:
67
+ rate = wf.getframerate()
68
+ n = wf.getnframes()
69
+ raw = wf.readframes(n)
70
+ audio = np.frombuffer(raw, dtype=np.int16).astype("float32") / 32768.0
71
+ if rate != 16000:
72
+ # Cheap linear resample; Moonshine expects 16k.
73
+ ratio = 16000 / rate
74
+ new_len = int(len(audio) * ratio)
75
+ audio = np.interp(
76
+ np.linspace(0, len(audio), new_len, endpoint=False),
77
+ np.arange(len(audio)),
78
+ audio,
79
+ ).astype("float32")
80
+ result = self._model.transcribe(audio, self._model_name)
81
+ # API returns either a list[str] (per-segment) or a single str.
82
+ if isinstance(result, (list, tuple)):
83
+ return " ".join(s for s in result if s).strip()
84
+ return str(result).strip()
85
+
86
+
87
+ class WhisperSTT:
88
+ """faster-whisper. Heavier but more accurate, kept as fallback."""
89
+
90
+ def __init__(self, model: str = "base", device: str = "cuda"):
91
+ self._model_name = model
92
+ self._device = device
93
+ self._model = None
94
+
95
+ def _ensure_loaded(self) -> None:
96
+ if self._model is not None:
97
+ return
98
+ try:
99
+ from faster_whisper import WhisperModel
100
+ except ImportError as e:
101
+ raise RuntimeError(
102
+ "Whisper STT requested but `faster-whisper` is not "
103
+ "installed. Run: pip install faster-whisper"
104
+ ) from e
105
+ logger.info(f"Loading Whisper '{self._model_name}' on {self._device}...")
106
+ self._model = WhisperModel(
107
+ self._model_name,
108
+ device=self._device,
109
+ compute_type="float16" if self._device == "cuda" else "int8",
110
+ )
111
+ logger.info("Whisper loaded.")
112
+
113
+ def transcribe(self, wav_bytes: bytes) -> str:
114
+ self._ensure_loaded()
115
+ segments, _ = self._model.transcribe(io.BytesIO(wav_bytes), language="en")
116
+ return " ".join(seg.text for seg in segments).strip()
117
+
118
+
119
+ def make_stt(name: str = "moonshine", **kwargs) -> STT:
120
+ """Factory for STT backends. Names: 'moonshine', 'whisper'."""
121
+ n = name.lower()
122
+ if n == "moonshine":
123
+ return MoonshineSTT(**kwargs)
124
+ if n == "whisper":
125
+ return WhisperSTT(**kwargs)
126
+ raise ValueError(f"Unknown STT backend: {name!r} (try 'moonshine' or 'whisper')")