use serde_json::{json, Value};
use std::sync::atomic::Ordering;
use rpi_plugin_sdk::{StbString, StbStringRef};
const SECTION_HEADING: &str = "## How to speak your replies";
pub const SPOKEN_STYLE: &str = "\
The user is listening to your reply through text-to-speech, not reading it.
Write for the ear:
1. **Acknowledge first.** Open with a short reaction before anything else —
\"Got it.\" / \"Okay, let me look.\" — so the user hears a response
immediately instead of waiting through silence.
2. **Announce before you act.** Before running a tool, say in one sentence what
you are about to do and why. After it, say in one sentence what you found.
Never let a long silence pass unexplained.
3. **Sound like a person.** Contractions, everyday words, the occasional
\"hmm\", \"oh\", \"actually\". If something is genuinely funny, laugh — \"ha,
that's great.\" If you get something wrong, just say so. Do not overdo it: a
laugh in every sentence is worse than none.
4. **Short sentences.** Lead with the answer, then expand only if it is
actually needed. Speak the conclusion, not the reasoning.
5. **Nothing unpronounceable.** No tool names, file paths, JSON, code or URLs
read literally — describe them instead (\"I read that file\", not \"I called
read on D:\\...\\foo.rs\"). No tables, no bullet-point lists read verbatim,
no markdown syntax.";
fn section() -> String {
format!("\n\n{SECTION_HEADING}\n\n{SPOKEN_STYLE}\n")
}
pub fn prompt_for_turn(base: &str, enabled: bool) -> Option<String> {
if !enabled {
return None;
}
if has_spoken_style(base) {
return Some(base.to_string());
}
Some(format!("{base}{}", section()))
}
pub fn has_spoken_style(prompt: &str) -> bool {
prompt.contains(SECTION_HEADING)
}
extern "C" fn on_before_agent_start(
event_json: StbStringRef,
out: *mut StbString,
_user_data: *mut std::ffi::c_void,
) -> i32 {
let raw = unsafe { event_json.as_str() };
let enabled = super::AUTO_TTS_ENABLED.load(Ordering::Relaxed);
if !enabled {
write_out(out, json!({}));
return 0;
}
let parsed: Value = match serde_json::from_str(raw) {
Ok(v) => v,
Err(_) => {
super::debug_log("spoken style: unparseable before_agent_start envelope");
write_out(out, json!({}));
return 0;
}
};
let base = parsed
.get("systemPrompt")
.and_then(Value::as_str)
.unwrap_or_default();
match prompt_for_turn(base, enabled) {
Some(next) => {
super::debug_log(&format!(
"spoken style: appended {} chars (prompt {} -> {})",
next.len().saturating_sub(base.len()),
base.len(),
next.len()
));
write_out(out, json!({ "systemPrompt": next }));
}
None => write_out(out, json!({})),
}
0
}
fn write_out(out: *mut StbString, value: Value) {
if !out.is_null() {
unsafe {
*out = StbString::from_string(value.to_string());
}
}
}
extern "C" fn free_string(s: StbString) {
if !s.is_empty() && !s.ptr.is_null() {
unsafe {
let slice = std::slice::from_raw_parts(s.ptr as *const u8, s.len);
let _ = Box::from_raw(slice as *const [u8] as *mut [u8]);
}
}
}
pub fn register(api: &rpi_plugin_sdk::PluginApi) -> bool {
let Some(register) = api.register_before_agent_start else {
return false;
};
register(on_before_agent_start, free_string, api.user_data) == 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disabled_speech_never_touches_the_prompt() {
assert_eq!(prompt_for_turn("BASE", false), None);
}
#[test]
fn enabled_speech_appends_the_section_to_the_base() {
let out = prompt_for_turn("BASE", true).expect("changes the prompt");
assert!(out.starts_with("BASE"), "base must come first: {out}");
assert!(out.contains(SECTION_HEADING));
assert!(out.contains("Acknowledge first"));
}
#[test]
fn appending_twice_does_not_stack_the_section() {
let once = prompt_for_turn("BASE", true).unwrap();
let twice = prompt_for_turn(&once, true).unwrap();
assert_eq!(once, twice);
}
#[test]
fn an_empty_base_still_yields_a_usable_prompt() {
let out = prompt_for_turn("", true).expect("changes the prompt");
assert!(has_spoken_style(&out));
}
fn call(event: &str) -> String {
let event_ref = StbStringRef::from_str(event);
let mut out = StbString::empty();
let rc = on_before_agent_start(event_ref, &mut out, std::ptr::null_mut());
assert_eq!(rc, 0);
let text = out.to_string_lossy();
assert!(
text.starts_with("{\"systemPrompt\"") || text.starts_with("{}"),
"unexpected payload: {text}"
);
text
}
#[test]
fn handler_declines_when_speech_is_off() {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
super::super::AUTO_TTS_ENABLED.store(false, Ordering::Relaxed);
let payload = call(r#"{"prompt":"hi","systemPrompt":"BASE"}"#);
assert_eq!(payload, "{}", "speech off must report no change");
}
#[test]
fn handler_appends_when_speech_is_on() {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
super::super::AUTO_TTS_ENABLED.store(true, Ordering::Relaxed);
let payload = call(r#"{"prompt":"hi","systemPrompt":"BASE"}"#);
let v: Value = serde_json::from_str(&payload).unwrap();
let prompt = v["systemPrompt"].as_str().expect("systemPrompt present");
assert!(prompt.starts_with("BASE"));
assert!(has_spoken_style(prompt));
super::super::AUTO_TTS_ENABLED.store(false, Ordering::Relaxed);
}
#[test]
fn a_malformed_envelope_declines_instead_of_dropping_the_base() {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
super::super::AUTO_TTS_ENABLED.store(true, Ordering::Relaxed);
assert_eq!(call("not json"), "{}");
super::super::AUTO_TTS_ENABLED.store(false, Ordering::Relaxed);
}
}