samvada 20261111.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.
- samvada/__init__.py +6 -0
- samvada/__main__.py +3 -0
- samvada/agents/__init__.py +1 -0
- samvada/agents/hooks.py +105 -0
- samvada/agents/rule.md +22 -0
- samvada/agents/setup.py +239 -0
- samvada/agents/skills/samvada/SKILL.md +88 -0
- samvada/cli.py +246 -0
- samvada/engines.py +130 -0
- samvada/locks/piper-plus.txt +74 -0
- samvada/locks/piper.txt +120 -0
- samvada/packs.py +306 -0
- samvada/paths.py +51 -0
- samvada/player.py +22 -0
- samvada/presence.py +326 -0
- samvada/speak.py +75 -0
- samvada/state.py +166 -0
- samvada/voices.lock +155 -0
- samvada/voices.toml +29 -0
- samvada-20261111.1.dist-info/METADATA +101 -0
- samvada-20261111.1.dist-info/RECORD +24 -0
- samvada-20261111.1.dist-info/WHEEL +4 -0
- samvada-20261111.1.dist-info/entry_points.txt +2 -0
- samvada-20261111.1.dist-info/licenses/LICENSE +21 -0
samvada/__init__.py
ADDED
samvada/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The agent layer: the rule, the skill, the hooks and `samvada setup` (DESIGN 7)."""
|
samvada/agents/hooks.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Hooks run by the agent harness (DESIGN 7.3): deterministic checks, no model call.
|
|
2
|
+
|
|
3
|
+
`samvada hook prompt` (UserPromptSubmit): note the turn's start; when the voice is off, add one
|
|
4
|
+
line of context so the agent does not compose speech nobody will hear.
|
|
5
|
+
`samvada hook stop` (Stop): when the voice is on and `samvada speak` was not called since the
|
|
6
|
+
turn started, ask the agent once to speak its closing.
|
|
7
|
+
|
|
8
|
+
The harness sends the event as JSON on stdin (session_id, hook_event_name, stop_hook_active).
|
|
9
|
+
A hook never breaks a session: any error is swallowed and the exit is 0.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from ..state import State
|
|
21
|
+
|
|
22
|
+
TURN, SPOKE, NUDGED = "turn", "spoke", "nudged"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _session_dir(state: State, session_id: str) -> Path | None:
|
|
26
|
+
if not session_id:
|
|
27
|
+
return state.session_dir
|
|
28
|
+
return state.root / "sessions" / f"claude-{session_id}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _stamp(d: Path, name: str) -> None:
|
|
32
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
(d / name).write_text(f"{time.time():.3f}\n")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _read(d: Path, name: str) -> float:
|
|
37
|
+
try:
|
|
38
|
+
return float((d / name).read_text().strip())
|
|
39
|
+
except (OSError, ValueError):
|
|
40
|
+
return 0.0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def mark_spoke(state: State, env=os.environ) -> None:
|
|
44
|
+
"""Called by `samvada speak`: the agent spoke this turn (whatever was heard).
|
|
45
|
+
|
|
46
|
+
Written under the session key and, inside Claude Code, under its session id too: the Stop
|
|
47
|
+
hook knows only the id, and the key may come from $SAMVADA_SESSION instead."""
|
|
48
|
+
dirs = {state.session_dir}
|
|
49
|
+
if env.get("CLAUDE_CODE_SESSION_ID"):
|
|
50
|
+
dirs.add(_session_dir(state, env["CLAUDE_CODE_SESSION_ID"]))
|
|
51
|
+
for d in dirs - {None}:
|
|
52
|
+
try:
|
|
53
|
+
_stamp(d, SPOKE)
|
|
54
|
+
except OSError:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def prompt(event: dict, state: State, decide) -> str | None:
|
|
59
|
+
d = _session_dir(state, event.get("session_id", ""))
|
|
60
|
+
if d is None:
|
|
61
|
+
return None
|
|
62
|
+
_stamp(d, TURN)
|
|
63
|
+
(d / NUDGED).unlink(missing_ok=True)
|
|
64
|
+
decision = decide(state)
|
|
65
|
+
if decision.on:
|
|
66
|
+
return None
|
|
67
|
+
return (f"Samvada: the voice is off now ({decision.why}). Keep the corrections and the "
|
|
68
|
+
"table; skip `samvada speak` this turn unless the owner asks for the voice.")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def stop(event: dict, state: State, decide) -> str | None:
|
|
72
|
+
d = _session_dir(state, event.get("session_id", ""))
|
|
73
|
+
if d is None or event.get("stop_hook_active"):
|
|
74
|
+
return None
|
|
75
|
+
turn = _read(d, TURN)
|
|
76
|
+
if not turn or _read(d, SPOKE) >= turn or _read(d, NUDGED) >= turn:
|
|
77
|
+
return None # no turn seen, already spoke, or already asked once this turn
|
|
78
|
+
if not decide(state).on:
|
|
79
|
+
return None
|
|
80
|
+
_stamp(d, NUDGED)
|
|
81
|
+
return ("Samvada: you did not call `samvada speak` this turn. Speak the closing now, one "
|
|
82
|
+
"call with every language (what was done, what is open, what needs the owner; "
|
|
83
|
+
"no symbols or paths), then stop.")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def run(kind: str, state: State, decide) -> int:
|
|
87
|
+
try:
|
|
88
|
+
event = json.loads(sys.stdin.read() or "{}")
|
|
89
|
+
except (ValueError, OSError):
|
|
90
|
+
event = {}
|
|
91
|
+
try:
|
|
92
|
+
if kind == "prompt":
|
|
93
|
+
text = prompt(event, state, decide)
|
|
94
|
+
if text:
|
|
95
|
+
print(json.dumps({"hookSpecificOutput": {
|
|
96
|
+
"hookEventName": event.get("hook_event_name", "UserPromptSubmit"),
|
|
97
|
+
"additionalContext": text}}, ensure_ascii=False))
|
|
98
|
+
elif kind == "stop":
|
|
99
|
+
reason = stop(event, state, decide)
|
|
100
|
+
if reason:
|
|
101
|
+
print(json.dumps({"decision": "block", "reason": reason}, ensure_ascii=False))
|
|
102
|
+
except Exception as e: # never break the session over a hook
|
|
103
|
+
print(f"samvada hook {kind}: {e}", file=sys.stderr)
|
|
104
|
+
return 0
|
|
105
|
+
|
samvada/agents/rule.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
## Samvada: language tutor and voice
|
|
2
|
+
|
|
3
|
+
The owner's native language is {native}; they are learning {learning}. For every message they
|
|
4
|
+
send, before any other work:
|
|
5
|
+
|
|
6
|
+
1. **Correct it.** Correct the grammar and wording of every language in the message and list
|
|
7
|
+
the corrections, with the IPA of a word whose sound or spelling was wrong. This is the
|
|
8
|
+
first text of the turn, printed before any tool call or work.
|
|
9
|
+
2. **Show it in every language.** A table with one row per language: {rows}.{ipa}
|
|
10
|
+
3. **Speak with `samvada speak`**, one call, every language passed in this order: the
|
|
11
|
+
message's own language first, then {order}.
|
|
12
|
+
- A question answered at once: one call at the end; each language says the corrected
|
|
13
|
+
question, then the answer.
|
|
14
|
+
- A task, or a question that needs real work: an **opening** call before the work ("you
|
|
15
|
+
are asking …; I will …"), and a **closing** call at the end with the conclusion: what
|
|
16
|
+
was done, what is still open, what needs the owner. Two to four sentences per language.
|
|
17
|
+
- Spoken text carries no symbols, code, paths or formulas.
|
|
18
|
+
- When `samvada` stays silent it says why and exits 0: never retry, never play sound
|
|
19
|
+
another way.
|
|
20
|
+
|
|
21
|
+
Read the `samvada` skill for anything else: mute, scopes, languages, the online mode, a
|
|
22
|
+
chat-bridge session, failures.
|
samvada/agents/setup.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""`samvada setup`: give an agent the tutor's rule, the skill and the hooks (DESIGN 7.4).
|
|
2
|
+
|
|
3
|
+
Prints the plan first (`--dry-run` stops there), then changes only what differs:
|
|
4
|
+
1. rule the managed block between `samvada:begin` and `samvada:end` in CLAUDE.md
|
|
5
|
+
(Claude Code) or AGENTS.md (Codex), filled from the owner's settings
|
|
6
|
+
2. skill skills/samvada/SKILL.md, copied (marked as Samvada's; a folder that is not ours
|
|
7
|
+
is never touched)
|
|
8
|
+
3. hooks Claude Code only: UserPromptSubmit and Stop in settings.json; other hooks kept
|
|
9
|
+
4. voices whether each language of the rule has its voice pack (nothing is downloaded)
|
|
10
|
+
|
|
11
|
+
Before a file is changed, a copy is kept beside it (<name>.samvada-backup-<time>).
|
|
12
|
+
`--uninstall` removes exactly the block, the skill and the hooks it added.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
import tomllib
|
|
24
|
+
from collections.abc import Mapping
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from importlib import resources
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from .. import engines, paths
|
|
30
|
+
|
|
31
|
+
NAMES = {"en": "English", "zh": "Chinese", "ja": "Japanese", "fr": "French"}
|
|
32
|
+
FLAGS = {"en": "🇺🇸 EN", "zh": "🇨🇳 ZH", "ja": "🇯🇵 JA", "fr": "🇫🇷 FR"}
|
|
33
|
+
BEGIN = "<!-- samvada:begin (managed by `samvada setup`; change the settings, not this block) -->"
|
|
34
|
+
END = "<!-- samvada:end -->"
|
|
35
|
+
BLOCK_RE = re.compile(r"\n*<!-- samvada:begin.*?<!-- samvada:end -->\n?", re.S)
|
|
36
|
+
MARKER = ".samvada-managed"
|
|
37
|
+
HOOK_TAG = "-m samvada hook"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Tutor:
|
|
42
|
+
native: str = "zh"
|
|
43
|
+
langs: tuple[str, ...] = ("en", "zh")
|
|
44
|
+
ipa: bool = True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def config_path(env: Mapping[str, str] = os.environ) -> Path:
|
|
48
|
+
base = Path(env["XDG_CONFIG_HOME"]) if env.get("XDG_CONFIG_HOME") else Path.home() / ".config"
|
|
49
|
+
return base / "samvada" / "tutor.toml"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_tutor(env: Mapping[str, str] = os.environ) -> Tutor:
|
|
53
|
+
try:
|
|
54
|
+
d = tomllib.loads(config_path(env).read_text())
|
|
55
|
+
except (OSError, ValueError):
|
|
56
|
+
return Tutor()
|
|
57
|
+
return Tutor(d.get("native", "zh"), tuple(d.get("langs", ("en", "zh"))), bool(d.get("ipa", True)))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def save_tutor(t: Tutor, env: Mapping[str, str] = os.environ) -> None:
|
|
61
|
+
p = config_path(env)
|
|
62
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
langs = ", ".join(f'"{l}"' for l in t.langs)
|
|
64
|
+
p.write_text(f'# Written by `samvada setup`; run it again after a change.\nnative = "{t.native}"\n'
|
|
65
|
+
f"langs = [{langs}]\nipa = {'true' if t.ipa else 'false'}\n")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def render_rule(t: Tutor) -> str:
|
|
69
|
+
learning = [NAMES[l] for l in t.langs if l != t.native] or [NAMES[l] for l in t.langs]
|
|
70
|
+
rows = []
|
|
71
|
+
for l in t.langs:
|
|
72
|
+
rows.append(FLAGS[l])
|
|
73
|
+
if l == "en" and t.ipa:
|
|
74
|
+
rows.append("🔤 IPA")
|
|
75
|
+
ipa = (" The IPA row transcribes the whole English row (General American): slashes, stress "
|
|
76
|
+
"marks, the flap ɾ, weak forms as spoken.") if t.ipa and "en" in t.langs else ""
|
|
77
|
+
body = resources.files("samvada").joinpath("agents", "rule.md").read_text().format(
|
|
78
|
+
native=NAMES[t.native], learning=", ".join(learning), rows=", ".join(rows), ipa=ipa,
|
|
79
|
+
order=", ".join(NAMES[l] for l in t.langs) + " (each language once)")
|
|
80
|
+
return f"{BEGIN}\n{body.rstrip()}\n{END}\n"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def agent_dir(agent: str, env: Mapping[str, str] = os.environ) -> Path:
|
|
84
|
+
if agent == "claude":
|
|
85
|
+
return Path(env["CLAUDE_CONFIG_DIR"]) if env.get("CLAUDE_CONFIG_DIR") else Path.home() / ".claude"
|
|
86
|
+
return Path(env["CODEX_HOME"]) if env.get("CODEX_HOME") else Path.home() / ".codex"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _backup(p: Path) -> None:
|
|
90
|
+
if p.exists():
|
|
91
|
+
shutil.copy2(p, p.with_name(f"{p.name}.samvada-backup-{time.strftime('%Y%m%d-%H%M%S')}"))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _hook_command(kind: str) -> str:
|
|
95
|
+
return f'"{sys.executable}" {HOOK_TAG} {kind}'
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _is_ours(entry: dict) -> bool:
|
|
99
|
+
return any(HOOK_TAG in h.get("command", "") for h in entry.get("hooks", []))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _indent(text: str) -> int:
|
|
103
|
+
m = re.search(r"\n( +)\S", text)
|
|
104
|
+
return len(m.group(1)) if m else 2
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Step:
|
|
108
|
+
def __init__(self, name: str, desc: str, apply=None):
|
|
109
|
+
self.name, self.desc, self.apply = name, desc, apply
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def plan(agent: str, t: Tutor, uninstall: bool, env: Mapping[str, str] = os.environ) -> list[Step]:
|
|
113
|
+
home = agent_dir(agent, env)
|
|
114
|
+
steps: list[Step] = []
|
|
115
|
+
|
|
116
|
+
# 1. rule
|
|
117
|
+
rule_file = home / ("CLAUDE.md" if agent == "claude" else "AGENTS.md")
|
|
118
|
+
old = rule_file.read_text() if rule_file.exists() else ""
|
|
119
|
+
stripped = BLOCK_RE.sub("\n", old).rstrip("\n")
|
|
120
|
+
new = (stripped + "\n") if uninstall else ((stripped + "\n\n" if stripped else "") + render_rule(t))
|
|
121
|
+
if uninstall and new.strip() == stripped.strip() and BLOCK_RE.search(old) is None:
|
|
122
|
+
steps.append(Step("rule", f"no Samvada block in {rule_file}"))
|
|
123
|
+
elif new == old:
|
|
124
|
+
steps.append(Step("rule", f"unchanged in {rule_file}"))
|
|
125
|
+
else:
|
|
126
|
+
def write_rule(new=new):
|
|
127
|
+
rule_file.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
_backup(rule_file)
|
|
129
|
+
rule_file.write_text(new)
|
|
130
|
+
verb = "remove the block from" if uninstall else ("update the block in" if BLOCK_RE.search(old) else "add the block to")
|
|
131
|
+
steps.append(Step("rule", f"{verb} {rule_file}", write_rule))
|
|
132
|
+
|
|
133
|
+
# 2. skill
|
|
134
|
+
dest = home / "skills" / "samvada"
|
|
135
|
+
src = resources.files("samvada").joinpath("agents", "skills", "samvada", "SKILL.md").read_text()
|
|
136
|
+
ours = (dest / MARKER).exists()
|
|
137
|
+
if dest.exists() and not ours:
|
|
138
|
+
steps.append(Step("skill", f"{dest} exists and is not Samvada's: left alone"))
|
|
139
|
+
elif uninstall:
|
|
140
|
+
if ours:
|
|
141
|
+
steps.append(Step("skill", f"remove {dest}", lambda: shutil.rmtree(dest)))
|
|
142
|
+
else:
|
|
143
|
+
steps.append(Step("skill", f"not installed in {dest}"))
|
|
144
|
+
elif ours and (dest / "SKILL.md").exists() and (dest / "SKILL.md").read_text() == src:
|
|
145
|
+
steps.append(Step("skill", f"unchanged in {dest}"))
|
|
146
|
+
else:
|
|
147
|
+
def write_skill():
|
|
148
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
(dest / "SKILL.md").write_text(src)
|
|
150
|
+
(dest / MARKER).write_text("installed by `samvada setup`; `samvada setup --uninstall` removes it\n")
|
|
151
|
+
steps.append(Step("skill", f"{'update' if ours else 'install'} {dest / 'SKILL.md'}", write_skill))
|
|
152
|
+
|
|
153
|
+
# 3. hooks (Claude Code)
|
|
154
|
+
if agent == "claude":
|
|
155
|
+
sfile = home / "settings.json"
|
|
156
|
+
text = sfile.read_text() if sfile.exists() else "{}"
|
|
157
|
+
try:
|
|
158
|
+
settings = json.loads(text)
|
|
159
|
+
except ValueError:
|
|
160
|
+
steps.append(Step("hooks", f"{sfile} is not valid JSON: left alone"))
|
|
161
|
+
settings = None
|
|
162
|
+
if settings is not None:
|
|
163
|
+
hooks = settings.setdefault("hooks", {})
|
|
164
|
+
for event in ("UserPromptSubmit", "Stop"):
|
|
165
|
+
hooks[event] = [e for e in hooks.get(event, []) if not _is_ours(e)]
|
|
166
|
+
if not uninstall:
|
|
167
|
+
kind = "prompt" if event == "UserPromptSubmit" else "stop"
|
|
168
|
+
hooks[event].append({"hooks": [{"type": "command", "command": _hook_command(kind),
|
|
169
|
+
"timeout": 10}]})
|
|
170
|
+
if not hooks[event]:
|
|
171
|
+
del hooks[event]
|
|
172
|
+
if not hooks:
|
|
173
|
+
del settings["hooks"]
|
|
174
|
+
new_text = json.dumps(settings, indent=_indent(text), ensure_ascii=False) + "\n"
|
|
175
|
+
if json.loads(new_text) == json.loads(text):
|
|
176
|
+
steps.append(Step("hooks", f"unchanged in {sfile}"))
|
|
177
|
+
else:
|
|
178
|
+
def write_hooks(new_text=new_text):
|
|
179
|
+
sfile.parent.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
_backup(sfile)
|
|
181
|
+
sfile.write_text(new_text)
|
|
182
|
+
verb = "remove the UserPromptSubmit and Stop hooks from" if uninstall else \
|
|
183
|
+
"set the UserPromptSubmit and Stop hooks in"
|
|
184
|
+
steps.append(Step("hooks", f"{verb} {sfile}", write_hooks))
|
|
185
|
+
else:
|
|
186
|
+
steps.append(Step("hooks", "Codex: no hooks; the rule and the skill carry the behaviour"))
|
|
187
|
+
|
|
188
|
+
# 4. voices (report only)
|
|
189
|
+
if not uninstall:
|
|
190
|
+
voices = engines.load_voices()
|
|
191
|
+
dirs = paths.voices_dirs(env)
|
|
192
|
+
missing = [l for l in t.langs if l in voices and not engines.has_pack(voices[l], dirs)]
|
|
193
|
+
steps.append(Step("voices", "every language has its voice pack" if not missing else
|
|
194
|
+
f"no voice pack for {' '.join(missing)}: samvada voices add {' '.join(missing)}"))
|
|
195
|
+
return steps
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def main(args: list[str], env: Mapping[str, str] = os.environ) -> int:
|
|
199
|
+
agent, dry, uninstall = "claude", False, False
|
|
200
|
+
t = load_tutor(env)
|
|
201
|
+
it = iter(args)
|
|
202
|
+
for a in it:
|
|
203
|
+
if a == "--agent":
|
|
204
|
+
agent = next(it, "")
|
|
205
|
+
elif a == "--dry-run":
|
|
206
|
+
dry = True
|
|
207
|
+
elif a == "--uninstall":
|
|
208
|
+
uninstall = True
|
|
209
|
+
elif a == "--native":
|
|
210
|
+
t.native = next(it, "")
|
|
211
|
+
elif a == "--langs":
|
|
212
|
+
t.langs = tuple(l for l in next(it, "").split(",") if l)
|
|
213
|
+
elif a in ("--ipa", "--no-ipa"):
|
|
214
|
+
t.ipa = a == "--ipa"
|
|
215
|
+
else:
|
|
216
|
+
print(f"samvada setup: unknown argument {a}", file=sys.stderr)
|
|
217
|
+
return 2
|
|
218
|
+
if agent not in ("claude", "codex"):
|
|
219
|
+
print("samvada setup: --agent claude|codex", file=sys.stderr)
|
|
220
|
+
return 2
|
|
221
|
+
bad = [l for l in (*t.langs, t.native) if l not in NAMES]
|
|
222
|
+
if bad or not t.langs:
|
|
223
|
+
print(f"samvada setup: languages are {' '.join(NAMES)} (got {' '.join(bad) or 'none'})", file=sys.stderr)
|
|
224
|
+
return 2
|
|
225
|
+
steps = plan(agent, t, uninstall, env)
|
|
226
|
+
print(f"samvada setup ({agent}{', uninstall' if uninstall else ''}): native {t.native}, "
|
|
227
|
+
f"languages {','.join(t.langs)}, IPA {'on' if t.ipa else 'off'}")
|
|
228
|
+
for s in steps:
|
|
229
|
+
print(f" {s.name:7} {s.desc}")
|
|
230
|
+
if dry:
|
|
231
|
+
print("dry run: nothing changed")
|
|
232
|
+
return 0
|
|
233
|
+
for s in steps:
|
|
234
|
+
if s.apply:
|
|
235
|
+
s.apply()
|
|
236
|
+
if not uninstall:
|
|
237
|
+
save_tutor(t, env)
|
|
238
|
+
print("done" + ("" if any(s.apply for s in steps) else ": nothing to change"))
|
|
239
|
+
return 0
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: samvada
|
|
3
|
+
description: Speak to the owner with Samvada (the `samvada` command) and act as their language tutor - mute or unmute the voice (e.g. "we are watching TV", "unmute"), turn a language on or off for this session or everywhere, allow or forbid online voices, handle a silent or failed `samvada speak`, install or check voice packs, and the conventions of the correction table and its IPA row. Use whenever the owner talks about the voice, a language, muting, or when `samvada` prints something unexpected.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Samvada: the voice and the tutor
|
|
7
|
+
|
|
8
|
+
The always-on rule (in CLAUDE.md or AGENTS.md, between `samvada:begin` and `samvada:end`)
|
|
9
|
+
says what to do on every message. This skill is the how-to for everything else.
|
|
10
|
+
|
|
11
|
+
## Speaking
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
samvada speak en "<text>" zh "<text>" ja "<text>" fr "<text>" # every language, in order
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- **One call per opening or closing**, every language passed. Samvada skips the languages that
|
|
18
|
+
are off or have no voice installed; you never need to know the settings.
|
|
19
|
+
- **Spoken text is plain language**: strip code, paths, formulas, symbols, URLs.
|
|
20
|
+
- **Silent is fine.** `samvada` decides where the voice plays (the owner at this desk, screen
|
|
21
|
+
unlocked, not muted, not on the phone). When silent it prints the reason on stderr and
|
|
22
|
+
exits 0: do not retry, do not fall back to another player.
|
|
23
|
+
- **Exit 1** means a language failed with every engine allowed; say so in one line and go on.
|
|
24
|
+
`SAMVADA_DEBUG=1` shows the engine's own output. **Exit 2** is a usage error: fix the call.
|
|
25
|
+
|
|
26
|
+
## The owner's words → the command
|
|
27
|
+
|
|
28
|
+
| The owner says | Run |
|
|
29
|
+
|---|---|
|
|
30
|
+
| "we are watching TV", "watching with my son", "陪孩子看电视", "mute" | `samvada mute "watching TV"` (once), and say once that the voice is muted |
|
|
31
|
+
| "unmute", "TV is over", "speak again" | `samvada unmute` |
|
|
32
|
+
| "mute this session", "quiet here" | `samvada --session mute "<why>"` |
|
|
33
|
+
| "no Japanese anymore", "turn off French everywhere" | `samvada lang off ja` (global) |
|
|
34
|
+
| "turn off French here", "only English in this session" | `samvada --session lang off fr` |
|
|
35
|
+
| "bring French back here" | `samvada --session lang on fr`; `samvada --session lang reset` drops this session's choices |
|
|
36
|
+
| "online mode", "use edge tts" | `samvada online always`; "safe mode", "offline mode" → `samvada online off` |
|
|
37
|
+
| "allow online when a voice fails" | `samvada online fallback` |
|
|
38
|
+
|
|
39
|
+
Unclear whether they mean this session or everywhere: choose the session, and say so. A mute
|
|
40
|
+
in either scope silences; for languages and the online mode, the session wins over global.
|
|
41
|
+
While muted, keep the corrections and the table; only the voice stops. Do not play any other
|
|
42
|
+
sound (no test audio, no clients started with sound).
|
|
43
|
+
|
|
44
|
+
`samvada status` says where the voice goes now and why, which languages are on, the online
|
|
45
|
+
mode, and the session key (the Claude Code session id, kept across a resume; else the pys
|
|
46
|
+
window; else the tty; or `$SAMVADA_SESSION`).
|
|
47
|
+
|
|
48
|
+
## Online voices
|
|
49
|
+
|
|
50
|
+
Off by default: the owner's words never leave the machine. `fallback` retries only a language
|
|
51
|
+
that failed offline with edge-tts; `always` sends every language online. Both need the online
|
|
52
|
+
extra (`pip install 'samvada[online]'`); `samvada online <mode>` says when it is missing.
|
|
53
|
+
`SAMVADA_ONLINE=off|fallback|always` overrides for one call.
|
|
54
|
+
|
|
55
|
+
## Voice packs
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
samvada voices # installed where, size, engine
|
|
59
|
+
samvada voices add ja fr # download (pinned, checksummed), install, and the engine if missing
|
|
60
|
+
samvada voices verify # check the installed files against voices.lock
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
A language without a pack is skipped (offline) with "no voice pack". Suggest `samvada voices
|
|
64
|
+
add <lang>` once; do not install on your own: it downloads tens to hundreds of MB.
|
|
65
|
+
|
|
66
|
+
## The table and its IPA row
|
|
67
|
+
|
|
68
|
+
- One row per language the rule lists, natural phrasing in each, not word-for-word.
|
|
69
|
+
- The IPA row (when the rule asks for it) transcribes the whole English row, General American:
|
|
70
|
+
slashes around it, stress marks (ˈ) on words of two or more syllables, the flap ɾ where
|
|
71
|
+
Americans say it (little → ˈlɪɾəl), weak forms as spoken in a sentence (to → tə, and → ənd).
|
|
72
|
+
- In a correction about one word's sound or spelling, give that word's IPA too.
|
|
73
|
+
|
|
74
|
+
## Sessions that are not at a desk
|
|
75
|
+
|
|
76
|
+
- **Chat bridge** (the reply is shown in a chat on a phone; the system prompt says so):
|
|
77
|
+
do not speak (the bridge runs with `SAMVADA=off`, so a call would be silent anyway). Keep
|
|
78
|
+
the corrections and the table; if the chat cannot render a table, give one line per
|
|
79
|
+
language: `EN: …`, `ZH: …`.
|
|
80
|
+
- **Headless runs** set `SAMVADA=off`. A chat bridge that hears from the owner on the phone
|
|
81
|
+
runs `samvada presence phone`, so the desk stays silent until the owner types there again.
|
|
82
|
+
|
|
83
|
+
## Hooks (installed by `samvada setup`)
|
|
84
|
+
|
|
85
|
+
- Before a turn, a hook notes the turn's start, and adds one line of context when the voice
|
|
86
|
+
is off ("the voice is off: …"), so you do not compose speech nobody will hear.
|
|
87
|
+
- At the end of a turn, if the voice is on and `samvada speak` was not called since the turn
|
|
88
|
+
started, a hook asks you once to speak the closing. Speak it, then stop.
|