symphony-code 0.0.1__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 (169) hide show
  1. coding_agent/__init__.py +20 -0
  2. coding_agent/_lazy.py +17 -0
  3. coding_agent/addons/__init__.py +35 -0
  4. coding_agent/addons/approvals.py +224 -0
  5. coding_agent/addons/compaction/__init__.py +36 -0
  6. coding_agent/addons/compaction/addon.py +88 -0
  7. coding_agent/addons/compaction/compactor.py +138 -0
  8. coding_agent/addons/compaction/prompts.py +20 -0
  9. coding_agent/addons/compaction/transcript.py +118 -0
  10. coding_agent/addons/evaluation/__init__.py +68 -0
  11. coding_agent/addons/evaluation/addon.py +199 -0
  12. coding_agent/addons/evaluation/evaluators.py +245 -0
  13. coding_agent/addons/evaluation/handlers.py +66 -0
  14. coding_agent/addons/evaluation/policy.py +277 -0
  15. coding_agent/addons/evaluation/protocol.py +192 -0
  16. coding_agent/addons/evaluation/state.py +284 -0
  17. coding_agent/addons/langfuse/__init__.py +24 -0
  18. coding_agent/addons/langfuse/addon.py +465 -0
  19. coding_agent/addons/langfuse/install.py +79 -0
  20. coding_agent/addons/langfuse/serialize.py +158 -0
  21. coding_agent/addons/learning/__init__.py +33 -0
  22. coding_agent/addons/learning/addon.py +181 -0
  23. coding_agent/addons/learning/loop.py +247 -0
  24. coding_agent/addons/learning/prompts.py +26 -0
  25. coding_agent/addons/learning/sanitize.py +64 -0
  26. coding_agent/addons/learning/store.py +522 -0
  27. coding_agent/addons/manager/__init__.py +18 -0
  28. coding_agent/addons/manager/bridge.py +74 -0
  29. coding_agent/addons/manager/discovery.py +34 -0
  30. coding_agent/addons/manager/report.py +33 -0
  31. coding_agent/addons/manager/runtime.py +76 -0
  32. coding_agent/addons/manager/tool_changes.py +16 -0
  33. coding_agent/addons/persistence/__init__.py +41 -0
  34. coding_agent/addons/persistence/active.py +132 -0
  35. coding_agent/addons/persistence/artifacts.py +110 -0
  36. coding_agent/addons/persistence/collection.py +166 -0
  37. coding_agent/addons/persistence/compatibility.py +30 -0
  38. coding_agent/addons/persistence/images.py +133 -0
  39. coding_agent/addons/persistence/jsonl.py +1195 -0
  40. coding_agent/addons/persistence/presentation.py +104 -0
  41. coding_agent/addons/plugins/__init__.py +19 -0
  42. coding_agent/addons/plugins/install.py +37 -0
  43. coding_agent/addons/plugins/manager.py +140 -0
  44. coding_agent/addons/plugins/models.py +50 -0
  45. coding_agent/addons/skills/__init__.py +28 -0
  46. coding_agent/addons/skills/addon.py +26 -0
  47. coding_agent/addons/skills/args.py +78 -0
  48. coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
  49. coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
  50. coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
  51. coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
  52. coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
  53. coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
  54. coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
  55. coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
  56. coding_agent/addons/skills/frontmatter.py +57 -0
  57. coding_agent/addons/skills/models.py +57 -0
  58. coding_agent/addons/skills/registry.py +109 -0
  59. coding_agent/addons/skills/tool.py +54 -0
  60. coding_agent/addons/voice/__init__.py +5 -0
  61. coding_agent/addons/voice/addon.py +106 -0
  62. coding_agent/addons/voice/credentials.py +42 -0
  63. coding_agent/addons/voice/playback.py +42 -0
  64. coding_agent/addons/voice/speak.py +287 -0
  65. coding_agent/addons/voice/stream.py +651 -0
  66. coding_agent/addons/voice/summary.py +126 -0
  67. coding_agent/agent.py +523 -0
  68. coding_agent/bench/__init__.py +1 -0
  69. coding_agent/bench/cli.py +200 -0
  70. coding_agent/bench/patch.py +78 -0
  71. coding_agent/commands/__init__.py +5 -0
  72. coding_agent/commands/catalog.py +179 -0
  73. coding_agent/commands/jev_argument.py +40 -0
  74. coding_agent/config/__init__.py +51 -0
  75. coding_agent/config/config.py +376 -0
  76. coding_agent/config/credentials.py +153 -0
  77. coding_agent/config/personalities.json +34 -0
  78. coding_agent/config/personalities.py +170 -0
  79. coding_agent/config/prompts.py +67 -0
  80. coding_agent/protocols/__init__.py +5 -0
  81. coding_agent/protocols/acp/README.md +36 -0
  82. coding_agent/protocols/acp/__init__.py +27 -0
  83. coding_agent/protocols/acp/mapping.py +164 -0
  84. coding_agent/protocols/acp/server.py +261 -0
  85. coding_agent/protocols/acp/transport.py +72 -0
  86. coding_agent/protocols/stdio/README.md +36 -0
  87. coding_agent/protocols/stdio/__init__.py +37 -0
  88. coding_agent/protocols/stdio/catalog.py +49 -0
  89. coding_agent/protocols/stdio/commands.py +218 -0
  90. coding_agent/protocols/stdio/server.py +181 -0
  91. coding_agent/protocols/stdio/transport.py +71 -0
  92. coding_agent/py.typed +0 -0
  93. coding_agent/resources/__init__.py +5 -0
  94. coding_agent/resources/usage.py +264 -0
  95. coding_agent/run/__init__.py +1 -0
  96. coding_agent/run/cli.py +99 -0
  97. coding_agent/run/detach.py +37 -0
  98. coding_agent/run/interrupted.py +67 -0
  99. coding_agent/run/log_sink.py +46 -0
  100. coding_agent/tools/__init__.py +73 -0
  101. coding_agent/tools/ask_user.py +61 -0
  102. coding_agent/tools/base.py +136 -0
  103. coding_agent/tools/bash.py +288 -0
  104. coding_agent/tools/bash_jobs.py +116 -0
  105. coding_agent/tools/generate_image.py +123 -0
  106. coding_agent/tools/memory.py +100 -0
  107. coding_agent/tools/patch.py +218 -0
  108. coding_agent/tools/read_file.py +167 -0
  109. coding_agent/tools/search.py +433 -0
  110. coding_agent/tools/web_search.py +151 -0
  111. coding_agent/tools/write_file.py +40 -0
  112. coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
  113. coding_agent/tui/__init__.py +22 -0
  114. coding_agent/tui/__main__.py +122 -0
  115. coding_agent/tui/app.py +456 -0
  116. coding_agent/tui/chrome/__init__.py +33 -0
  117. coding_agent/tui/chrome/footer.py +274 -0
  118. coding_agent/tui/chrome/topbar.py +259 -0
  119. coding_agent/tui/commands/__init__.py +45 -0
  120. coding_agent/tui/commands/catalog.py +35 -0
  121. coding_agent/tui/commands/manager.py +557 -0
  122. coding_agent/tui/composer/__init__.py +18 -0
  123. coding_agent/tui/composer/input.py +219 -0
  124. coding_agent/tui/composer/slash_menu.py +341 -0
  125. coding_agent/tui/composer/surface.py +330 -0
  126. coding_agent/tui/composer/voice.py +267 -0
  127. coding_agent/tui/composer/voice_mode.py +491 -0
  128. coding_agent/tui/runtime/__init__.py +23 -0
  129. coding_agent/tui/runtime/events.py +728 -0
  130. coding_agent/tui/runtime/sink.py +115 -0
  131. coding_agent/tui/runtime/state.py +145 -0
  132. coding_agent/tui/runtime/subagent.py +531 -0
  133. coding_agent/tui/runtime/turn.py +206 -0
  134. coding_agent/tui/screens/__init__.py +54 -0
  135. coding_agent/tui/screens/ask.py +69 -0
  136. coding_agent/tui/screens/context.py +195 -0
  137. coding_agent/tui/screens/dashboard.py +91 -0
  138. coding_agent/tui/screens/diff.py +302 -0
  139. coding_agent/tui/screens/extensions.py +68 -0
  140. coding_agent/tui/screens/file_selector.py +167 -0
  141. coding_agent/tui/screens/history.py +458 -0
  142. coding_agent/tui/screens/langfuse.py +142 -0
  143. coding_agent/tui/screens/learning.py +113 -0
  144. coding_agent/tui/screens/modal.py +131 -0
  145. coding_agent/tui/screens/onboard.py +455 -0
  146. coding_agent/tui/screens/resume.py +163 -0
  147. coding_agent/tui/theme/__init__.py +49 -0
  148. coding_agent/tui/theme/load.py +325 -0
  149. coding_agent/tui/theme/style.py +95 -0
  150. coding_agent/tui/theme/theme.toml +1687 -0
  151. coding_agent/tui/tools/__init__.py +53 -0
  152. coding_agent/tui/tools/activity.py +184 -0
  153. coding_agent/tui/tools/calls.py +560 -0
  154. coding_agent/tui/tools/code_view.py +52 -0
  155. coding_agent/tui/tools/images.py +250 -0
  156. coding_agent/tui/tools/labels.py +135 -0
  157. coding_agent/tui/tools/modal.py +137 -0
  158. coding_agent/tui/tools/partial_args.py +19 -0
  159. coding_agent/tui/tools/snapshots.py +382 -0
  160. coding_agent/tui/transcript/__init__.py +36 -0
  161. coding_agent/tui/transcript/messages.py +408 -0
  162. coding_agent/tui/transcript/process.py +504 -0
  163. coding_agent/tui/transcript/surface.py +345 -0
  164. coding_agent/tui/transcript/thinking_markdown.py +314 -0
  165. symphony_code-0.0.1.dist-info/METADATA +66 -0
  166. symphony_code-0.0.1.dist-info/RECORD +169 -0
  167. symphony_code-0.0.1.dist-info/WHEEL +4 -0
  168. symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
  169. symphony_code-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: verify-before-done
3
+ description: Do not claim a task is done until the check that would catch a regression has run.
4
+ args:
5
+ - name: check
6
+ type: string
7
+ description: Command or observation that proves the change.
8
+ ---
9
+
10
+ Use `check` when it is set. Otherwise pick the smallest command that fails if the change is wrong.
11
+ Run it and quote the result. If you cannot run it, say what is unverified.
@@ -0,0 +1,57 @@
1
+ """Split and validate the YAML front matter at the top of a SKILL.md.
2
+
3
+ Why: skill files come from user directories and are pasted into the prompt,
4
+ so the YAML is untrusted. It is size-capped, loaded without aliases, and
5
+ reduced to the three keys the catalog reads: name, description, and args.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ import yaml
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+ from coding_agent.addons.skills.args import Description, ExtensionArgs
16
+
17
+ SKILL_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
18
+ _FRONT_MATTER = re.compile(r"\A---\n(.*?)\n---[ \t]*(?:\n|\Z)", re.S)
19
+ _MAX_FRONT_MATTER_BYTES = 4096
20
+
21
+
22
+ class _NoAliasLoader(yaml.SafeLoader):
23
+ """SafeLoader that rejects YAML aliases (``*name``)."""
24
+
25
+ def compose_node(self, parent, index):
26
+ # WHY: aliases expand exponentially ("billion laughs"); 353 bytes of
27
+ # front matter took 50s at startup. Skills never need them.
28
+ if self.check_event(yaml.AliasEvent):
29
+ raise ValueError("YAML aliases are not allowed in front matter")
30
+ return super().compose_node(parent, index)
31
+
32
+
33
+ class _SkillFrontMatter(BaseModel):
34
+ """The keys the catalog reads; other standard keys such as allowed-tools are ignored."""
35
+
36
+ model_config = ConfigDict(extra="ignore")
37
+
38
+ name: str = Field(pattern=SKILL_NAME.pattern, max_length=1000)
39
+ description: Description
40
+ args: ExtensionArgs = ()
41
+
42
+
43
+ def read_front_matter(text: str) -> _SkillFrontMatter:
44
+ """Validate the leading ``---`` block of a SKILL.md."""
45
+ match = _FRONT_MATTER.match(text)
46
+ if match is None:
47
+ raise ValueError("SKILL.md must start with a closed YAML front matter block")
48
+ if len(match.group(1).encode()) > _MAX_FRONT_MATTER_BYTES:
49
+ raise ValueError(f"front matter exceeds the {_MAX_FRONT_MATTER_BYTES} byte limit")
50
+ try:
51
+ data = yaml.load(match.group(1), Loader=_NoAliasLoader)
52
+ except (yaml.YAMLError, RecursionError) as exc:
53
+ raise ValueError(f"invalid YAML front matter: {exc}") from exc
54
+ return _SkillFrontMatter.model_validate(data)
55
+
56
+
57
+ __all__ = ["SKILL_NAME", "read_front_matter"]
@@ -0,0 +1,57 @@
1
+ """Validated metadata for local skills."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from coding_agent.addons.skills.args import ExtensionArg
9
+
10
+ CATALOG_DESCRIPTION_CHARS = 160
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Skill:
15
+ """A discovered skill and its validated front matter."""
16
+
17
+ skill_id: str
18
+ name: str
19
+ description: str
20
+ root: Path
21
+ origin: str
22
+ args: tuple[ExtensionArg, ...] = ()
23
+
24
+ def catalog_line(self) -> str:
25
+ """One short prompt line: id and description. The body loads via the skill tool."""
26
+ description = self.description
27
+ if len(description) > CATALOG_DESCRIPTION_CHARS:
28
+ description = description[: CATALOG_DESCRIPTION_CHARS - 1].rstrip() + "…"
29
+ return f"- {self.skill_id}: {description}"
30
+
31
+ @property
32
+ def resources(self) -> tuple[str, ...]:
33
+ paths: list[str] = []
34
+ for path in sorted(self.root.rglob("*")):
35
+ if not path.is_file() or path.is_symlink():
36
+ continue
37
+ try:
38
+ relative = path.relative_to(self.root)
39
+ path.resolve().relative_to(self.root.resolve())
40
+ except ValueError:
41
+ continue
42
+ if relative.name == "SKILL.md":
43
+ continue
44
+ if relative.parts and relative.parts[0] in {"references", "scripts", "assets"}:
45
+ paths.append(relative.as_posix())
46
+ return tuple(paths)
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class SkillDiagnostic:
51
+ """A bounded, user-facing discovery diagnostic."""
52
+
53
+ source: str
54
+ message: str
55
+
56
+
57
+ __all__ = ["Skill", "SkillDiagnostic"]
@@ -0,0 +1,109 @@
1
+ """Safe discovery and bounded access to local Markdown skills."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Iterable
8
+
9
+ from coding_agent.addons.skills.frontmatter import SKILL_NAME, read_front_matter
10
+ from coding_agent.addons.skills.models import Skill, SkillDiagnostic
11
+
12
+ logger = logging.getLogger(__name__)
13
+ MAX_SKILL_FILE_BYTES = 128_000
14
+
15
+
16
+ def bundled_skills_root() -> Path:
17
+ """Skills shipped in the package, so every install starts with the same habits without copying files."""
18
+ return Path(__file__).resolve().parent / "bundled_skills"
19
+
20
+
21
+ class SkillRegistry:
22
+ """Discover skills and expose only paths within their skill roots."""
23
+
24
+ def __init__(self, skills: Iterable[Skill] = ()) -> None:
25
+ self._skills = {skill.skill_id: skill for skill in skills}
26
+
27
+ @property
28
+ def skills(self) -> tuple[Skill, ...]:
29
+ return tuple(sorted(self._skills.values(), key=lambda skill: skill.skill_id))
30
+
31
+ def get(self, skill_id: str) -> Skill:
32
+ try:
33
+ return self._skills[skill_id]
34
+ except KeyError as exc:
35
+ raise ValueError(f"Unknown skill id: {skill_id}") from exc
36
+
37
+ def resource_path(self, skill_id: str, relative_path: str) -> Path:
38
+ skill = self.get(skill_id)
39
+ candidate = Path(relative_path)
40
+ if candidate.is_absolute() or not relative_path or "" in candidate.parts:
41
+ raise ValueError("resource path must be relative")
42
+ original = skill.root / candidate
43
+ resolved = original.resolve()
44
+ if not resolved.is_relative_to(skill.root.resolve()):
45
+ raise ValueError("resource path escapes the skill")
46
+ if any(part.is_symlink() for part in _parents_from(skill.root, original)):
47
+ raise ValueError("symlink skill resources are not supported")
48
+ if not resolved.is_file():
49
+ raise ValueError(f"Skill resource not found: {relative_path}")
50
+ return resolved
51
+
52
+ @classmethod
53
+ def discover(
54
+ cls,
55
+ roots: Iterable[tuple[str, Path]],
56
+ *,
57
+ max_skills: int = 100,
58
+ ) -> tuple["SkillRegistry", tuple[SkillDiagnostic, ...]]:
59
+ """Load every SKILL.md under ``roots``; a broken skill becomes a diagnostic, never a crash."""
60
+ found: list[Skill] = []
61
+ diagnostics: list[SkillDiagnostic] = []
62
+ seen: set[str] = set()
63
+ for origin, root in roots:
64
+ root = root.expanduser().resolve()
65
+ if not root.is_dir():
66
+ continue
67
+ candidates = [root] if (root / "SKILL.md").is_file() else sorted(
68
+ path for path in root.iterdir() if path.is_dir()
69
+ )
70
+ for skill_root in candidates:
71
+ document = skill_root / "SKILL.md"
72
+ if not document.is_file():
73
+ continue
74
+ try:
75
+ skill = _parse_skill(origin, skill_root, document)
76
+ if skill.skill_id in seen:
77
+ raise ValueError(f"duplicate skill id: {skill.skill_id}")
78
+ seen.add(skill.skill_id)
79
+ if len(found) >= max_skills:
80
+ diagnostics.append(SkillDiagnostic(str(document), "skill limit reached"))
81
+ continue
82
+ found.append(skill)
83
+ except (OSError, UnicodeError, ValueError) as exc:
84
+ logger.warning("Skipping skill %s: %s", document, exc)
85
+ diagnostics.append(SkillDiagnostic(str(document), str(exc)))
86
+ return cls(found), tuple(diagnostics)
87
+
88
+
89
+ def _parents_from(root: Path, path: Path) -> list[Path]:
90
+ current = root
91
+ parents: list[Path] = []
92
+ for part in path.relative_to(root).parts:
93
+ current = current / part
94
+ parents.append(current)
95
+ return parents
96
+
97
+
98
+ def _parse_skill(origin: str, root: Path, document: Path) -> Skill:
99
+ if not SKILL_NAME.fullmatch(root.name):
100
+ raise ValueError("skill directory name must be one identifier segment")
101
+ if document.stat().st_size > MAX_SKILL_FILE_BYTES:
102
+ raise ValueError("SKILL.md exceeds the 128000 byte limit")
103
+ text = document.read_text(encoding="utf-8").lstrip("\ufeff").replace("\r\n", "\n")
104
+ meta = read_front_matter(text)
105
+ return Skill(skill_id=f"{origin}/{meta.name}", name=meta.name, description=meta.description,
106
+ root=root, origin=origin, args=meta.args)
107
+
108
+
109
+ __all__ = ["MAX_SKILL_FILE_BYTES", "SkillRegistry", "bundled_skills_root"]
@@ -0,0 +1,54 @@
1
+ """Load a skill's instructions on demand instead of carrying them in every prompt."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from pydantic import Field
8
+
9
+ from coding_agent.addons.skills.args import render_args
10
+ from coding_agent.addons.skills.registry import MAX_SKILL_FILE_BYTES, SkillRegistry
11
+ from coding_agent.tools.base import ToolArgsModel, WorkspaceTool
12
+
13
+
14
+ class SkillArgs(ToolArgsModel):
15
+ skill_id: str = Field(..., min_length=1, description="Skill id from the catalog, e.g. 'bundled/orient-first'.")
16
+ resource: str = Field(
17
+ default="",
18
+ description="Optional file inside the skill (a path listed under resources). Empty loads SKILL.md.",
19
+ )
20
+
21
+
22
+ class SkillTool(WorkspaceTool):
23
+ name = "skill"
24
+ description = (
25
+ "Load a skill when it applies to the task. The system prompt lists only skill ids and "
26
+ "one-line descriptions; this returns the full SKILL.md instructions plus the skill's "
27
+ "resource files. Pass resource to read one of those files."
28
+ )
29
+ args_model = SkillArgs
30
+
31
+ def __init__(self, workspace: str | Path, *, registry: SkillRegistry) -> None:
32
+ self.registry = registry
33
+ super().__init__(workspace, parallel=True)
34
+
35
+ def run(self, skill_id: str, resource: str = "") -> str:
36
+ try:
37
+ skill = self.registry.get(skill_id)
38
+ path = self.registry.resource_path(skill_id, resource or "SKILL.md")
39
+ if path.stat().st_size > MAX_SKILL_FILE_BYTES:
40
+ return f"error: {resource or 'SKILL.md'} exceeds the {MAX_SKILL_FILE_BYTES} byte limit"
41
+ text = path.read_text(encoding="utf-8").lstrip("\ufeff")
42
+ except (OSError, UnicodeError, ValueError) as exc:
43
+ return f"error: {exc}"
44
+ if resource:
45
+ return text
46
+ header = [f"# skill {skill.skill_id}"]
47
+ if skill.args:
48
+ header.append(f"args: {render_args(skill.args)}")
49
+ if skill.resources:
50
+ header.append("resources: " + ", ".join(skill.resources))
51
+ return "\n".join(header) + "\n\n" + text
52
+
53
+
54
+ __all__ = ["SkillArgs", "SkillTool"]
@@ -0,0 +1,5 @@
1
+ """Realtime Grok voice as a harness add-on."""
2
+
3
+ from coding_agent.addons.voice.addon import VoiceAddon
4
+
5
+ __all__ = ["VoiceAddon"]
@@ -0,0 +1,106 @@
1
+ """Harness hook that speaks the summary through TTS.
2
+
3
+ ``feed`` keeps nothing: streamed assistant text is not dictated. The realtime
4
+ socket only listens and speaks the short acknowledgement, then closes.
5
+ ``after_run`` speaks the spoken-summary section on a TTS socket, which stays
6
+ open until that audio finishes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from core_harness.addons.addon import Addon
14
+
15
+ from coding_agent.addons.voice.speak import StreamingSpeaker
16
+ from coding_agent.addons.voice.stream import VoiceStream
17
+ from coding_agent.addons.voice.summary import spoken_summary
18
+
19
+
20
+ class VoiceAddon(Addon):
21
+ """Speak around a run without joining the chat model call."""
22
+
23
+ name = "voice"
24
+
25
+ def __init__(
26
+ self,
27
+ stream: VoiceStream | None = None,
28
+ *,
29
+ on_speaking: Any = None,
30
+ on_transcript: Any = None,
31
+ on_commit: Any = None,
32
+ ) -> None:
33
+ self.stream = stream or VoiceStream(
34
+ on_speaking=on_speaking, on_transcript=on_transcript, on_commit=on_commit,
35
+ )
36
+ self._open = False
37
+ self._armed = False
38
+ self._speaker: StreamingSpeaker | None = None
39
+
40
+ def arm(self) -> None:
41
+ """Opt in exactly one microphone-originated run; typed runs stay silent."""
42
+ self._armed = True
43
+
44
+ def fork_for_child(self, parent_harness: Any) -> None:
45
+ return None
46
+
47
+ def attach(self, harness: Any) -> None:
48
+ del harness
49
+ return None
50
+
51
+ def open(self) -> str:
52
+ """Start the background stream. Returns an error string, or ``""``.
53
+
54
+ A finished utterance closes its own socket, so the next listen starts
55
+ a new one instead of talking to a dead connection.
56
+ """
57
+ if self._open and not getattr(self.stream, "alive", True):
58
+ self._open = False
59
+ if not self._open:
60
+ self.stream.start()
61
+ self._open = True
62
+ return self.stream.error
63
+
64
+ def close(self, *, timeout: float = 0.0) -> None:
65
+ """Stop speech and the realtime socket. Waits up to ``timeout``."""
66
+ self._armed = False
67
+ speaker = self._speaker
68
+ self._speaker = None
69
+ if speaker is not None:
70
+ speaker.close(timeout=timeout)
71
+ if self._open:
72
+ self.stream.close(timeout=timeout)
73
+ self._open = False
74
+
75
+ def feed(self, text: str) -> None:
76
+ """Keep the latest assistant text. Speech waits for the final answer."""
77
+ del text
78
+ return None
79
+
80
+ async def before_run(self, **payload: Any) -> None:
81
+ """Keep the mic quiet while the run goes. Do not speak a fixed line."""
82
+ del payload
83
+ if self._armed:
84
+ self.stream.hold()
85
+
86
+ async def after_run(self, **payload: Any) -> None:
87
+ if not self._armed:
88
+ return
89
+ self._armed = False
90
+ result = payload.get("result")
91
+ # Only a question that arrived by voice is dictated. The flag lives on
92
+ # the harness for the run and is copied onto its result.
93
+ if not bool(getattr(result, "reply_aloud", False)):
94
+ # A typed turn never speaks. The listen socket, if one is up,
95
+ # hangs up with the acknowledgement and is not held open here.
96
+ return
97
+ text = spoken_summary(str(getattr(result, "output_text", "") or ""))
98
+ if not text:
99
+ return
100
+ # TTS, not another realtime socket. The realtime model speaks an
101
+ # acknowledgement and then its response.done tears the connection
102
+ # down, which was cutting this line off before it could be heard.
103
+ # The TTS socket stays up until audio.done and plays the MP3 itself.
104
+ speaker = StreamingSpeaker(on_speaking=getattr(self.stream, "_on_speaking", None))
105
+ speaker.finish(text)
106
+ self._speaker = speaker
@@ -0,0 +1,42 @@
1
+ """xAI audio endpoint, TTS voice, and the bearer every voice socket uses.
2
+
3
+ The TUI's microphone path and the voice add-on both authenticate here, so this
4
+ lives with the add-on and the TUI imports it, never the other way round.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+
11
+ # Built-in TTS voice. eve is the API default.
12
+ GROK_TTS_VOICE = "eve"
13
+
14
+
15
+ class UnavailableVoice(RuntimeError):
16
+ """Raised when the microphone or the Grok voice model cannot be reached."""
17
+
18
+
19
+ # The Grok CLI chat proxy only serves chat models. ``/chat/completions`` there
20
+ # is a 404, so voice audio goes to the xAI audio endpoint. The bearer is still
21
+ # the same device-login token a ``grok:`` model call uses.
22
+ XAI_AUDIO_BASE_URL = "https://api.x.ai/v1"
23
+
24
+
25
+ def grok_voice_credentials() -> tuple[str, dict[str, str]]:
26
+ """Bearer and extra headers for one voice transcription.
27
+
28
+ An ``XAI_API_KEY`` wins, same as model calls. Otherwise the stored xAI
29
+ device login is reused. The chat-proxy base URL is not used: it has no
30
+ audio route.
31
+ """
32
+ api_key = os.getenv("XAI_API_KEY", "").strip()
33
+ if api_key:
34
+ return api_key, {}
35
+ from core_ai.oauth.runtime import oauth_runtime_for
36
+
37
+ runtime = oauth_runtime_for("grok")
38
+ if runtime is None or not runtime.api_key:
39
+ raise UnavailableVoice(
40
+ "Voice mode needs a Grok sign-in. Run /provider and choose xAI, or set XAI_API_KEY."
41
+ )
42
+ return runtime.api_key, dict(runtime.extra_headers)
@@ -0,0 +1,42 @@
1
+ """Play MP3 bytes with whichever command-line player the machine has."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from coding_agent.addons.voice.credentials import UnavailableVoice
8
+
9
+
10
+ def play_mp3(audio: bytes) -> None:
11
+ """Play MP3 bytes on the default output. No extra audio package required."""
12
+ if not audio:
13
+ return
14
+ import subprocess
15
+ import tempfile
16
+
17
+ with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as handle:
18
+ handle.write(audio)
19
+ path = handle.name
20
+ try:
21
+ player = _mp3_player(path)
22
+ if player is None:
23
+ raise UnavailableVoice("No audio player found to speak the reply.")
24
+ subprocess.run(player, check=False, capture_output=True, timeout=120)
25
+ finally:
26
+ try:
27
+ os.remove(path)
28
+ except OSError:
29
+ pass
30
+
31
+
32
+ def _mp3_player(path: str) -> list[str] | None:
33
+ import shutil
34
+ import sys
35
+
36
+ if sys.platform == "darwin" and shutil.which("afplay"):
37
+ return ["afplay", path]
38
+ if shutil.which("ffplay"):
39
+ return ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", path]
40
+ if shutil.which("mpv"):
41
+ return ["mpv", "--no-video", "--really-quiet", path]
42
+ return None