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 ADDED
@@ -0,0 +1,6 @@
1
+ """Samvada: a voice and the habits of a language tutor for AI agents, offline first.
2
+
3
+ The agent decides what to say; Samvada decides whether, where and how it is heard.
4
+ """
5
+
6
+ __version__ = "20261111.1"
samvada/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+ import sys
3
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """The agent layer: the rule, the skill, the hooks and `samvada setup` (DESIGN 7)."""
@@ -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.
@@ -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.