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.
- coding_agent/__init__.py +20 -0
- coding_agent/_lazy.py +17 -0
- coding_agent/addons/__init__.py +35 -0
- coding_agent/addons/approvals.py +224 -0
- coding_agent/addons/compaction/__init__.py +36 -0
- coding_agent/addons/compaction/addon.py +88 -0
- coding_agent/addons/compaction/compactor.py +138 -0
- coding_agent/addons/compaction/prompts.py +20 -0
- coding_agent/addons/compaction/transcript.py +118 -0
- coding_agent/addons/evaluation/__init__.py +68 -0
- coding_agent/addons/evaluation/addon.py +199 -0
- coding_agent/addons/evaluation/evaluators.py +245 -0
- coding_agent/addons/evaluation/handlers.py +66 -0
- coding_agent/addons/evaluation/policy.py +277 -0
- coding_agent/addons/evaluation/protocol.py +192 -0
- coding_agent/addons/evaluation/state.py +284 -0
- coding_agent/addons/langfuse/__init__.py +24 -0
- coding_agent/addons/langfuse/addon.py +465 -0
- coding_agent/addons/langfuse/install.py +79 -0
- coding_agent/addons/langfuse/serialize.py +158 -0
- coding_agent/addons/learning/__init__.py +33 -0
- coding_agent/addons/learning/addon.py +181 -0
- coding_agent/addons/learning/loop.py +247 -0
- coding_agent/addons/learning/prompts.py +26 -0
- coding_agent/addons/learning/sanitize.py +64 -0
- coding_agent/addons/learning/store.py +522 -0
- coding_agent/addons/manager/__init__.py +18 -0
- coding_agent/addons/manager/bridge.py +74 -0
- coding_agent/addons/manager/discovery.py +34 -0
- coding_agent/addons/manager/report.py +33 -0
- coding_agent/addons/manager/runtime.py +76 -0
- coding_agent/addons/manager/tool_changes.py +16 -0
- coding_agent/addons/persistence/__init__.py +41 -0
- coding_agent/addons/persistence/active.py +132 -0
- coding_agent/addons/persistence/artifacts.py +110 -0
- coding_agent/addons/persistence/collection.py +166 -0
- coding_agent/addons/persistence/compatibility.py +30 -0
- coding_agent/addons/persistence/images.py +133 -0
- coding_agent/addons/persistence/jsonl.py +1195 -0
- coding_agent/addons/persistence/presentation.py +104 -0
- coding_agent/addons/plugins/__init__.py +19 -0
- coding_agent/addons/plugins/install.py +37 -0
- coding_agent/addons/plugins/manager.py +140 -0
- coding_agent/addons/plugins/models.py +50 -0
- coding_agent/addons/skills/__init__.py +28 -0
- coding_agent/addons/skills/addon.py +26 -0
- coding_agent/addons/skills/args.py +78 -0
- coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
- coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
- coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
- coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
- coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
- coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
- coding_agent/addons/skills/frontmatter.py +57 -0
- coding_agent/addons/skills/models.py +57 -0
- coding_agent/addons/skills/registry.py +109 -0
- coding_agent/addons/skills/tool.py +54 -0
- coding_agent/addons/voice/__init__.py +5 -0
- coding_agent/addons/voice/addon.py +106 -0
- coding_agent/addons/voice/credentials.py +42 -0
- coding_agent/addons/voice/playback.py +42 -0
- coding_agent/addons/voice/speak.py +287 -0
- coding_agent/addons/voice/stream.py +651 -0
- coding_agent/addons/voice/summary.py +126 -0
- coding_agent/agent.py +523 -0
- coding_agent/bench/__init__.py +1 -0
- coding_agent/bench/cli.py +200 -0
- coding_agent/bench/patch.py +78 -0
- coding_agent/commands/__init__.py +5 -0
- coding_agent/commands/catalog.py +179 -0
- coding_agent/commands/jev_argument.py +40 -0
- coding_agent/config/__init__.py +51 -0
- coding_agent/config/config.py +376 -0
- coding_agent/config/credentials.py +153 -0
- coding_agent/config/personalities.json +34 -0
- coding_agent/config/personalities.py +170 -0
- coding_agent/config/prompts.py +67 -0
- coding_agent/protocols/__init__.py +5 -0
- coding_agent/protocols/acp/README.md +36 -0
- coding_agent/protocols/acp/__init__.py +27 -0
- coding_agent/protocols/acp/mapping.py +164 -0
- coding_agent/protocols/acp/server.py +261 -0
- coding_agent/protocols/acp/transport.py +72 -0
- coding_agent/protocols/stdio/README.md +36 -0
- coding_agent/protocols/stdio/__init__.py +37 -0
- coding_agent/protocols/stdio/catalog.py +49 -0
- coding_agent/protocols/stdio/commands.py +218 -0
- coding_agent/protocols/stdio/server.py +181 -0
- coding_agent/protocols/stdio/transport.py +71 -0
- coding_agent/py.typed +0 -0
- coding_agent/resources/__init__.py +5 -0
- coding_agent/resources/usage.py +264 -0
- coding_agent/run/__init__.py +1 -0
- coding_agent/run/cli.py +99 -0
- coding_agent/run/detach.py +37 -0
- coding_agent/run/interrupted.py +67 -0
- coding_agent/run/log_sink.py +46 -0
- coding_agent/tools/__init__.py +73 -0
- coding_agent/tools/ask_user.py +61 -0
- coding_agent/tools/base.py +136 -0
- coding_agent/tools/bash.py +288 -0
- coding_agent/tools/bash_jobs.py +116 -0
- coding_agent/tools/generate_image.py +123 -0
- coding_agent/tools/memory.py +100 -0
- coding_agent/tools/patch.py +218 -0
- coding_agent/tools/read_file.py +167 -0
- coding_agent/tools/search.py +433 -0
- coding_agent/tools/web_search.py +151 -0
- coding_agent/tools/write_file.py +40 -0
- coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
- coding_agent/tui/__init__.py +22 -0
- coding_agent/tui/__main__.py +122 -0
- coding_agent/tui/app.py +456 -0
- coding_agent/tui/chrome/__init__.py +33 -0
- coding_agent/tui/chrome/footer.py +274 -0
- coding_agent/tui/chrome/topbar.py +259 -0
- coding_agent/tui/commands/__init__.py +45 -0
- coding_agent/tui/commands/catalog.py +35 -0
- coding_agent/tui/commands/manager.py +557 -0
- coding_agent/tui/composer/__init__.py +18 -0
- coding_agent/tui/composer/input.py +219 -0
- coding_agent/tui/composer/slash_menu.py +341 -0
- coding_agent/tui/composer/surface.py +330 -0
- coding_agent/tui/composer/voice.py +267 -0
- coding_agent/tui/composer/voice_mode.py +491 -0
- coding_agent/tui/runtime/__init__.py +23 -0
- coding_agent/tui/runtime/events.py +728 -0
- coding_agent/tui/runtime/sink.py +115 -0
- coding_agent/tui/runtime/state.py +145 -0
- coding_agent/tui/runtime/subagent.py +531 -0
- coding_agent/tui/runtime/turn.py +206 -0
- coding_agent/tui/screens/__init__.py +54 -0
- coding_agent/tui/screens/ask.py +69 -0
- coding_agent/tui/screens/context.py +195 -0
- coding_agent/tui/screens/dashboard.py +91 -0
- coding_agent/tui/screens/diff.py +302 -0
- coding_agent/tui/screens/extensions.py +68 -0
- coding_agent/tui/screens/file_selector.py +167 -0
- coding_agent/tui/screens/history.py +458 -0
- coding_agent/tui/screens/langfuse.py +142 -0
- coding_agent/tui/screens/learning.py +113 -0
- coding_agent/tui/screens/modal.py +131 -0
- coding_agent/tui/screens/onboard.py +455 -0
- coding_agent/tui/screens/resume.py +163 -0
- coding_agent/tui/theme/__init__.py +49 -0
- coding_agent/tui/theme/load.py +325 -0
- coding_agent/tui/theme/style.py +95 -0
- coding_agent/tui/theme/theme.toml +1687 -0
- coding_agent/tui/tools/__init__.py +53 -0
- coding_agent/tui/tools/activity.py +184 -0
- coding_agent/tui/tools/calls.py +560 -0
- coding_agent/tui/tools/code_view.py +52 -0
- coding_agent/tui/tools/images.py +250 -0
- coding_agent/tui/tools/labels.py +135 -0
- coding_agent/tui/tools/modal.py +137 -0
- coding_agent/tui/tools/partial_args.py +19 -0
- coding_agent/tui/tools/snapshots.py +382 -0
- coding_agent/tui/transcript/__init__.py +36 -0
- coding_agent/tui/transcript/messages.py +408 -0
- coding_agent/tui/transcript/process.py +504 -0
- coding_agent/tui/transcript/surface.py +345 -0
- coding_agent/tui/transcript/thinking_markdown.py +314 -0
- symphony_code-0.0.1.dist-info/METADATA +66 -0
- symphony_code-0.0.1.dist-info/RECORD +169 -0
- symphony_code-0.0.1.dist-info/WHEEL +4 -0
- symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
- 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,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
|