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.
- api/__init__.py +5 -0
- api/anthropic_compat.py +1518 -0
- api/artifact_viewer.py +366 -0
- api/caudate_middleware.py +618 -0
- api/forge_bootstrapper_routes.py +377 -0
- api/forge_routes.py +630 -0
- api/forge_system_routes.py +294 -0
- api/openai_compat.py +1993 -0
- api/server.py +667 -0
- api/storyboard_page.py +677 -0
- caudate_cli-0.1.0.dist-info/METADATA +354 -0
- caudate_cli-0.1.0.dist-info/RECORD +153 -0
- caudate_cli-0.1.0.dist-info/WHEEL +5 -0
- caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
- caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
- cognos_mcp/__init__.py +4 -0
- cognos_mcp/bridge.py +41 -0
- cognos_mcp/client.py +70 -0
- cognos_mcp/config.py +49 -0
- cognos_mcp/server.py +66 -0
- config.py +82 -0
- core/__init__.py +0 -0
- core/agent.py +468 -0
- core/agentic_loop.py +731 -0
- core/anthropic_auth.py +91 -0
- core/background.py +113 -0
- core/banner.py +134 -0
- core/bootstrap.py +292 -0
- core/citations.py +131 -0
- core/compaction.py +109 -0
- core/constitution.py +198 -0
- core/diff_viewer.py +87 -0
- core/export.py +85 -0
- core/file_refs.py +119 -0
- core/files.py +199 -0
- core/hooks.py +209 -0
- core/image.py +599 -0
- core/input.py +91 -0
- core/loop.py +238 -0
- core/memory_md.py +147 -0
- core/notifications.py +99 -0
- core/ownership.py +181 -0
- core/paste.py +81 -0
- core/permissions.py +210 -0
- core/plan_mode.py +215 -0
- core/sandbox_prompt.py +185 -0
- core/scheduler.py +195 -0
- core/schemas.py +202 -0
- core/session.py +90 -0
- core/settings.py +132 -0
- core/skills.py +398 -0
- core/slash_commands.py +977 -0
- core/statusline.py +61 -0
- core/subagent.py +300 -0
- core/thinking.py +50 -0
- core/updater.py +122 -0
- core/usage.py +109 -0
- core/worktree.py +93 -0
- execution/__init__.py +0 -0
- execution/executor.py +329 -0
- execution/plugins.py +108 -0
- execution/tools/__init__.py +0 -0
- execution/tools/agent_tool.py +107 -0
- execution/tools/agentic_tool.py +297 -0
- execution/tools/artifact_tool.py +191 -0
- execution/tools/ask_user_question_tool.py +137 -0
- execution/tools/base.py +81 -0
- execution/tools/calculator_tool.py +137 -0
- execution/tools/cognos_card_tool.py +124 -0
- execution/tools/cron_tool.py +215 -0
- execution/tools/datetime_tool.py +215 -0
- execution/tools/describe_image_tool.py +161 -0
- execution/tools/draw_tool.py +164 -0
- execution/tools/edit_image_tool.py +262 -0
- execution/tools/edit_tool.py +245 -0
- execution/tools/file_tool.py +90 -0
- execution/tools/find_anywhere_tool.py +255 -0
- execution/tools/forge_feature_tools.py +377 -0
- execution/tools/glob_tool.py +59 -0
- execution/tools/grep_tool.py +89 -0
- execution/tools/http_request_tool.py +224 -0
- execution/tools/load_skill_tool.py +104 -0
- execution/tools/longcat_avatar_tool.py +384 -0
- execution/tools/mcp_tool.py +100 -0
- execution/tools/notebook_tool.py +279 -0
- execution/tools/openapi_tool.py +440 -0
- execution/tools/plan_mode_tool.py +95 -0
- execution/tools/push_notification_tool.py +157 -0
- execution/tools/python_tool.py +61 -0
- execution/tools/respond_tool.py +40 -0
- execution/tools/sandbox_tool.py +378 -0
- execution/tools/search_tool.py +153 -0
- execution/tools/semantic_search_tool.py +106 -0
- execution/tools/shell_tool.py +283 -0
- execution/tools/speak_tool.py +134 -0
- execution/tools/storyboard_tool.py +727 -0
- execution/tools/system_info_tool.py +212 -0
- execution/tools/task_tool.py +323 -0
- execution/tools/think_tool.py +49 -0
- execution/tools/transcribe_audio_tool.py +86 -0
- execution/tools/update_memory_tool.py +92 -0
- execution/tools/web_fetch_tool.py +82 -0
- execution/tools/worktree_tool.py +174 -0
- llm/__init__.py +0 -0
- llm/fallback.py +116 -0
- llm/models.py +320 -0
- llm/provider.py +1356 -0
- llm/router.py +373 -0
- main.py +1889 -0
- memory/__init__.py +0 -0
- memory/episodic.py +99 -0
- memory/procedural.py +145 -0
- memory/semantic.py +71 -0
- memory/working.py +64 -0
- nn/__init__.py +43 -0
- nn/auto_evolve.py +245 -0
- nn/caudate.py +136 -0
- nn/config.py +141 -0
- nn/consolidator.py +81 -0
- nn/data.py +1635 -0
- nn/encoder.py +258 -0
- nn/forge_advisor.py +303 -0
- nn/format.py +235 -0
- nn/heads.py +432 -0
- nn/observer.py +994 -0
- nn/policy.py +214 -0
- nn/runtime.py +343 -0
- nn/scorer.py +175 -0
- nn/trainer.py +515 -0
- nn/vision.py +352 -0
- personality/__init__.py +23 -0
- personality/engine.py +129 -0
- personality/identity.py +144 -0
- personality/inner_voice.py +100 -0
- personality/mood.py +205 -0
- planning/__init__.py +0 -0
- planning/dev_server.py +221 -0
- planning/forge_models.py +718 -0
- planning/orchestrator.py +1363 -0
- planning/planner.py +451 -0
- planning/task_graph.py +61 -0
- reflection/__init__.py +0 -0
- reflection/meta_learner.py +156 -0
- reflection/reflector.py +127 -0
- ui/__init__.py +5 -0
- ui/display.py +88 -0
- voice/__init__.py +0 -0
- voice/conversation.py +125 -0
- voice/listener.py +111 -0
- voice/speaker.py +59 -0
- voice/stt.py +126 -0
- voice/tts.py +214 -0
reflection/reflector.py
ADDED
|
@@ -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
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')")
|