Skip to content

Commit 5cd25fe

Browse files
feat: add voice inbox and return briefing (#6)
1 parent 4e5995f commit 5cd25fe

8 files changed

Lines changed: 304 additions & 8 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,6 @@ bench/
1010
.clips/
1111
.turns.json
1212
.timeline.json
13+
.voice-inbox.json
14+
.media-duck-lease.json
1315
voices/

CLAUDE.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Repo-local notes for Claude and Codex.
2626
- Prompt text is redacted and held in memory only. Restart recovery persists compact request intent, turn identity, transcript location, and the Git baseline in `.turns.json`.
2727
- Spoken claims are checked against actual local test commands and outcomes found in Claude/Codex transcripts. Diagnostics shows intent and verification metadata, never prompt or transcript bodies.
2828
- Attributable Git diffs are translated into behavioral facts. `.timeline.json` keeps only compact outcomes for session-arc narration and `./voice recap`; it stores no prompt, reply, or source text.
29+
- Optional Voice Inbox mode persists prepared replies in `.voice-inbox.json` without speaking them. `I'm back` drains them into one local return briefing.
2930
- Condensing is local-only through Ollama, with `none` as the deterministic fallback.
3031
- Replies over `max_direct_chars` (default 400) get condensed by the configured summarizer; on failure it speaks the first sentence with a short note.
3132
- Markdown-table replies send the raw full response to the summarizer first, so the voice gets a conversational table summary instead of row-by-row noise. If summarization fails, a mostly-table reply becomes a spoken invite to look.
@@ -37,13 +38,14 @@ Repo-local notes for Claude and Codex.
3738
- Quiet hours (`quiet_hours` in config) accept speech but keep silent, logged as `quiet_skip`.
3839
- Empty `last_assistant_message` falls back to parsing `transcript_path`.
3940
- Playback via `afplay` honoring `rate` and `volume` config. The daemon exits after `idle_exit_s` idle.
40-
- Optional ducking targets Spotify and Browser/YouTube independently. Browser/YouTube is controlled by the companion Chromium extension through `GET /browser/duck`.
41+
- Optional ducking targets Spotify and Browser/YouTube independently. Browser/YouTube is controlled by the companion Chromium extension through `GET /browser/duck`. Spotify writes `.media-duck-lease.json` before fading so startup and the watchdog can restore interrupted playback state.
4142
- Errors land in `.voice.log` as `event: "error"`. The log self-caps around 500 lines.
4243
- Repo earcons, intent cues, build-result sonification, adaptive brevity, pronunciation dictionaries, and privacy redaction are local defaults. A repo can override speech using `.ltbv/pronounce.json`.
4344
- When multiple projects finish together, the daemon can speak one radio bulletin naming each project. Backchannel commands are available through `/backchannel` and the CLI.
4445

4546
## Endpoints
46-
- `GET /` controller, `GET /health` (includes live Git state), `GET /doctor`, `GET /config`, `GET /voices`, `GET /engines`, `GET /log/tail?limit=N`.
47+
- `GET /` controller, `GET /health` (includes live Git state), `GET /doctor`, `GET /config`, `GET /voices`, `GET /engines`, `GET /inbox`, `GET /log/tail?limit=N`.
48+
- `POST /inbox/return` drains and speaks one briefing; `POST /inbox/clear` discards waiting entries.
4749
- `POST /speak`, `POST /stop`, `POST /turn/start`, `POST /backchannel {command}`, `POST /config`, `POST /engine {name}`, `POST /bench {text, voice?}`, `POST /audition {voice}`, `POST /recondense`.
4850
- Warm shootout on 2026-07-06, same sentence: Pocket RTF 0.160, TTFA 0.135s, synth 0.459s, duration 2.880s. Kokoro RTF 0.096, TTFA 0.420s, synth 0.420s, duration 4.375s.
4951

build-packet.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,7 @@ <h1>let-there-be-voice</h1>
618618
<div class="filter-row"><b>Config</b><span><code>GET/POST /config</code> backed by <code>config.json</code> in the repo root, hot-applied, no restart. Daemon constants become defaults.</span><span class="tag blue">Core</span></div>
619619
<div class="filter-row"><b>Voices</b><span><code>GET /voices</code> lists the catalog; <code>POST /audition</code> speaks one fixed sentence in a chosen voice. Voice states cached so switching stays cheap.</span><span class="tag blue">Core</span></div>
620620
<div class="filter-row"><b>Ducking</b><span>Optional Spotify fade via <code>osascript</code> plus Browser YouTube page-volume ducking through the companion Chromium extension. Each target saves its current level, fades to <code>duck_target_volume</code> before playback, and restores in <code>_finish()</code>. No global volume, no tab mute, no pause/resume.</span><span class="tag blue">Core</span></div>
621+
<div class="filter-row"><b>Voice inbox</b><span>Optional away mode prepares and persists agent replies without playback. <code>POST /inbox/return</code> drains the queue into one concise return briefing. Spotify ducking writes a restoration lease before fading and repairs interrupted state at startup or through the watchdog.</span><span class="tag">Shipped</span></div>
621622
<div class="filter-row"><b>Data</b><span><code>GET /log/tail</code> returns recent log entries for the histogram plus the last raw/cleaned pair for the inspector.</span><span class="tag blue">Core</span></div>
622623
<div class="filter-row"><b>Restart</b><span><code>POST /restart</code> (2026-07-10): daemon acks, logs <code>restart_requested</code>, spawns a detached shell that sleeps <span class="num">300ms</span> then execs a fresh <code>daemon.py</code>, and the handler calls <code>os._exit(0)</code>. Picks up any code or config change without a manual kill. Controller button polls <code>/health</code> and reloads once it answers.</span><span class="tag blue">Core</span></div>
623624
<div class="filter-row"><b>Rules</b><span>House skin: dark grey, radius 0, Inter + Share Tech Mono, compact. No framework, stdlib-only daemon side. The controller is a remote, not a brain. (Amended: controller v2 added an SSE live layer, see Locked Decisions.)</span><span class="tag red">Hard</span></div>

controller.html

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,12 @@ <h1>let-there-be-voice</h1>
334334
<button class="btn toggle" id="browser-duck-toggle">browser / youtube duck off</button>
335335
<span class="hint">fade media during speech, restore after</span>
336336
</div>
337+
<div class="row-actions">
338+
<button class="btn toggle" id="inbox-toggle">voice inbox off</button>
339+
<button class="btn" id="return-briefing">I'm back</button>
340+
<button class="btn" id="inbox-clear">clear</button>
341+
<span class="hint" id="inbox-state">no updates waiting</span>
342+
</div>
337343
<div class="row-actions">
338344
<button class="btn" data-backchannel="repeat">repeat</button>
339345
<button class="btn" data-backchannel="slower">slower</button>
@@ -808,6 +814,8 @@ <h1>let-there-be-voice</h1>
808814
$("duck-toggle").textContent = cfg.ducking_enabled ? "spotify duck on" : "spotify duck off";
809815
$("browser-duck-toggle").classList.toggle("on", !!cfg.browser_youtube_ducking_enabled);
810816
$("browser-duck-toggle").textContent = cfg.browser_youtube_ducking_enabled ? "browser / youtube duck on" : "browser / youtube duck off";
817+
$("inbox-toggle").classList.toggle("on", !!cfg.voice_inbox_enabled);
818+
$("inbox-toggle").textContent = cfg.voice_inbox_enabled ? "voice inbox on" : "voice inbox off";
811819
}
812820
function attachScrub(el, k) {
813821
let x0 = 0, v0 = 0, live = null, moved = false, pid = null;
@@ -1231,6 +1239,27 @@ <h1>let-there-be-voice</h1>
12311239
renderDucking();
12321240
pushConfig({ browser_youtube_ducking_enabled: cfg.browser_youtube_ducking_enabled }, "saved-desk", "err-desk");
12331241
};
1242+
async function refreshInbox() {
1243+
try {
1244+
const data = await api("/inbox");
1245+
$("inbox-state").textContent = data.count
1246+
? `${data.count} update${data.count === 1 ? "" : "s"} waiting`
1247+
: "no updates waiting";
1248+
} catch { $("inbox-state").textContent = "inbox unavailable"; }
1249+
}
1250+
$("inbox-toggle").onclick = () => {
1251+
cfg.voice_inbox_enabled = !cfg.voice_inbox_enabled;
1252+
renderDucking();
1253+
pushConfig({ voice_inbox_enabled: cfg.voice_inbox_enabled }, "saved-desk", "err-desk");
1254+
};
1255+
$("return-briefing").onclick = async () => {
1256+
await api("/inbox/return", {});
1257+
refreshInbox();
1258+
};
1259+
$("inbox-clear").onclick = async () => {
1260+
await api("/inbox/clear", {});
1261+
refreshInbox();
1262+
};
12341263
for (const button of document.querySelectorAll("[data-backchannel]")) {
12351264
button.onclick = async () => {
12361265
button.disabled = true;
@@ -1305,6 +1334,7 @@ <h1>let-there-be-voice</h1>
13051334
if (ev.event === "now") { setNow(ev.on ? ev : null); return; }
13061335
if (ev.event === "wave") { startStage(ev.peaks, ev.duration); return; }
13071336
if (ev.event === "mute") { setMuteState(!!ev.muted); return; }
1337+
if (ev.event === "inbox") { refreshInbox(); return; }
13081338
if (ev.event === "config") {
13091339
api("/config").then((c) => {
13101340
cfg = c.config;
@@ -1337,6 +1367,7 @@ <h1>let-there-be-voice</h1>
13371367
drawHisto(); renderLedger();
13381368
if (t.stages) renderInspector(t.stages);
13391369
await pollHealth();
1370+
await refreshInbox();
13401371
setInterval(pollHealth, 10000);
13411372
connectSSE();
13421373
addEventListener("resize", drawHisto);

0 commit comments

Comments
 (0)