bxctl: chat --speaker NAME, the speaker on every turn of the chat

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-09-28 10:57:11 -07:00
co-authored by ornith-1.5-35b-a3b
parent fae12c3fe2
commit 5199bd7149
8 changed files with 435 additions and 10 deletions
+6 -4
View File
@@ -73,12 +73,14 @@ pub fn new_session_id() -> SessionId {
SessionId::new(&candidate).unwrap_or_else(|_| SessionId::new("chat-0-0").unwrap_or_default())
}
/// Sends one turn and reads the reply. `on_event` sees every event frame as it arrives.
/// Sends one turn and reads the reply. `on_event` sees every event frame as it arrives. `speaker`
/// is `--speaker`'s value, sent as it is; `None` sends none.
pub fn run_turn(
socket: &Path,
session: &SessionId,
content: &str,
resume: bool,
speaker: Option<&str>,
on_event: &mut dyn FnMut(&TurnEvent),
) -> Result<TurnDone, ChatError> {
let mut stream = UnixStream::connect(socket).map_err(ChatError::Connect)?;
@@ -87,8 +89,7 @@ pub fn run_turn(
session: session.clone(),
content: content.to_string(),
resume,
// Set in M5c-1 task 04, from `--speaker`. Until then no turn has a speaker.
speaker: None,
speaker: speaker.map(str::to_string),
};
write_frame(
&mut stream,
@@ -324,11 +325,12 @@ pub fn stream_turn(
session: &SessionId,
text: &str,
resume: bool,
speaker: Option<&str>,
approvals: &Approvals<'_>,
io: &mut TurnIo<'_>,
) -> std::io::Result<Result<TurnDone, ChatError>> {
let mut err = None;
let outcome = run_turn(socket, session, text, resume, &mut |event| {
let outcome = run_turn(socket, session, text, resume, speaker, &mut |event| {
if err.is_some() {
return;
}
+23 -1
View File
@@ -9,8 +9,9 @@ use bxcore::SessionId;
pub const USAGE: &str = "\
usage: bxctl <command> [options]
bxctl chat [--socket PATH] [--admin-socket PATH] [--session ID] [--say TEXT] [--no-thinking] [--json]
bxctl chat [--socket PATH] [--admin-socket PATH] [--session ID] [--say TEXT] [--no-thinking] [--json] [--speaker NAME]
Talk to loopd.
With --speaker NAME (no spaces), the turns are NAME's and are synced to Honcho as that person.
bxctl approvals [--admin-socket PATH]
List the approvals waiting for a decision.
@@ -50,6 +51,9 @@ pub struct ChatOptions {
pub show_thinking: bool,
pub say: Option<String>,
pub json: bool,
/// Who is typing, from `--speaker`, sent on every turn (M5c spec, section 11). There is no
/// default: without the flag it is `None`, and a turn without a speaker is not synced.
pub speaker: Option<String>,
}
impl ChatOptions {
@@ -61,6 +65,7 @@ impl ChatOptions {
show_thinking: true,
say: None,
json: false,
speaker: None,
}
}
}
@@ -143,6 +148,14 @@ fn parse_chat(args: &[String], home: &Path) -> Result<Command, UsageError> {
"--admin-socket" => opts.admin_socket = PathBuf::from(value_at(args, i)?),
"--session" => opts.session = Some(parse_session(&value_at(args, i)?)?),
"--say" => opts.say = Some(value_at(args, i)?),
// Given twice, it is refused rather than one of them chosen: the turns would be synced
// as the wrong person.
"--speaker" => {
if opts.speaker.is_some() {
return Err(UsageError);
}
opts.speaker = Some(parse_speaker(&value_at(args, i)?)?);
}
"--no-thinking" => {
opts.show_thinking = false;
i += 1;
@@ -305,6 +318,15 @@ fn parse_session(value: &str) -> Result<SessionId, UsageError> {
}
}
// A speaker must be a name: not empty, and no whitespace or control character anywhere. The rest,
// Honcho's pattern, is `memoryd`'s to check (M5c spec, section 3); a `.` is kept as it is.
fn parse_speaker(value: &str) -> Result<String, UsageError> {
if value.is_empty() || value.chars().any(|c| c.is_whitespace() || c.is_control()) {
return Err(UsageError);
}
Ok(value.to_string())
}
// A refuse reason must be present; its content is unvalidated on the client.
fn verify_reason(reason: &str) -> Result<String, UsageError> {
if reason.is_empty() {
+19 -2
View File
@@ -201,7 +201,15 @@ fn run_turn_twice(
input,
out: &mut handle,
};
match stream_turn(&opts.socket, session, text, resume, &approvals, &mut io) {
match stream_turn(
&opts.socket,
session,
text,
resume,
opts.speaker.as_deref(),
&approvals,
&mut io,
) {
Err(e) => Err(RunError::Io(e)),
Ok(Ok(done)) => {
write_answer(opts, &done).map_err(RunError::Io)?;
@@ -214,7 +222,15 @@ fn run_turn_twice(
input,
out: &mut handle,
};
match stream_turn(&opts.socket, session, text, false, &approvals, &mut io) {
match stream_turn(
&opts.socket,
session,
text,
false,
opts.speaker.as_deref(),
&approvals,
&mut io,
) {
Err(e) => Err(RunError::Io(e)),
Ok(Ok(done)) => {
write_answer(opts, &done).map_err(RunError::Io)?;
@@ -272,6 +288,7 @@ fn run_interactive(opts: &ChatOptions, reader: &mut dyn BufRead) -> ExitCode {
this_session,
text,
resume,
opts.speaker.as_deref(),
&approvals,
&mut io,
) {