From 24eb20f985fc884c4e18d41085b445f76323918e Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 20 Mar 2026 11:13:52 +0000 Subject: [PATCH] story-kit: merge 351_story_bot_reset_command_to_clear_conversation_context --- server/src/matrix/bot.rs | 26 +++++ server/src/matrix/commands/mod.rs | 15 +++ server/src/matrix/mod.rs | 1 + server/src/matrix/reset.rs | 170 ++++++++++++++++++++++++++++++ 4 files changed, 212 insertions(+) create mode 100644 server/src/matrix/reset.rs diff --git a/server/src/matrix/bot.rs b/server/src/matrix/bot.rs index 786de48..e71a61c 100644 --- a/server/src/matrix/bot.rs +++ b/server/src/matrix/bot.rs @@ -928,6 +928,32 @@ async fn on_room_message( return; } + // Check for the reset command, which requires async access to the shared + // conversation history and cannot be handled by the sync command registry. + if super::reset::extract_reset_command( + &user_message, + &ctx.bot_name, + ctx.bot_user_id.as_str(), + ) + .is_some() + { + slog!("[matrix-bot] Handling reset command from {sender}"); + let response = super::reset::handle_reset( + &ctx.bot_name, + &incoming_room_id, + &ctx.history, + &ctx.project_root, + ) + .await; + let html = markdown_to_html(&response); + if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await + && let Ok(event_id) = msg_id.parse() + { + ctx.bot_sent_event_ids.lock().await.insert(event_id); + } + return; + } + // Spawn a separate task so the Matrix sync loop is not blocked while we // wait for the LLM response (which can take several seconds). tokio::spawn(async move { diff --git a/server/src/matrix/commands/mod.rs b/server/src/matrix/commands/mod.rs index e114aeb..3d67d18 100644 --- a/server/src/matrix/commands/mod.rs +++ b/server/src/matrix/commands/mod.rs @@ -136,6 +136,11 @@ pub fn commands() -> &'static [BotCommand] { description: "Remove a work item from the pipeline: `delete `", handler: handle_delete_fallback, }, + BotCommand { + name: "reset", + description: "Clear the current Claude Code session and start fresh", + handler: handle_reset_fallback, + }, ] } @@ -252,6 +257,16 @@ fn handle_delete_fallback(_ctx: &CommandContext) -> Option { None } +/// Fallback handler for the `reset` command when it is not intercepted by +/// the async handler in `on_room_message`. In practice this is never called — +/// reset is detected and handled before `try_handle_command` is invoked. +/// The entry exists in the registry only so `help` lists it. +/// +/// Returns `None` to prevent the LLM from receiving "reset" as a prompt. +fn handle_reset_fallback(_ctx: &CommandContext) -> Option { + None +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/server/src/matrix/mod.rs b/server/src/matrix/mod.rs index 433adfa..bc06de3 100644 --- a/server/src/matrix/mod.rs +++ b/server/src/matrix/mod.rs @@ -20,6 +20,7 @@ pub mod commands; mod config; pub mod delete; pub mod htop; +pub mod reset; pub mod start; pub mod notifications; pub mod transport_impl; diff --git a/server/src/matrix/reset.rs b/server/src/matrix/reset.rs new file mode 100644 index 0000000..558c140 --- /dev/null +++ b/server/src/matrix/reset.rs @@ -0,0 +1,170 @@ +//! Reset command: clear the current Claude Code session for a room. +//! +//! `{bot_name} reset` drops the stored session ID and conversation history for +//! the current room so the next message starts a brand-new Claude Code session +//! with clean context. File-system memories (auto-memory directory) are not +//! affected — only the in-memory/persisted conversation state is cleared. + +use crate::matrix::bot::{ConversationHistory, RoomConversation}; +use matrix_sdk::ruma::OwnedRoomId; +use std::path::Path; + +/// A parsed reset command. +#[derive(Debug, PartialEq)] +pub struct ResetCommand; + +/// Parse a reset command from a raw message body. +/// +/// Strips the bot mention prefix and checks whether the command word is +/// `reset`. Returns `None` when the message is not a reset command at all. +pub fn extract_reset_command( + message: &str, + bot_name: &str, + bot_user_id: &str, +) -> Option { + let stripped = strip_mention(message, bot_name, bot_user_id); + let trimmed = stripped + .trim() + .trim_start_matches(|c: char| !c.is_alphanumeric()); + + let cmd = match trimmed.split_once(char::is_whitespace) { + Some((c, _)) => c, + None => trimmed, + }; + + if cmd.eq_ignore_ascii_case("reset") { + Some(ResetCommand) + } else { + None + } +} + +/// Handle a reset command: clear the session ID and conversation entries for +/// the given room, persist the updated history, and return a confirmation. +pub async fn handle_reset( + bot_name: &str, + room_id: &OwnedRoomId, + history: &ConversationHistory, + project_root: &Path, +) -> String { + { + let mut guard = history.lock().await; + let conv = guard.entry(room_id.clone()).or_insert_with(RoomConversation::default); + conv.session_id = None; + conv.entries.clear(); + crate::matrix::bot::save_history(project_root, &guard); + } + crate::slog!("[matrix-bot] reset command: cleared session for room {room_id} (bot={bot_name})"); + "Session reset. Starting fresh — previous context has been cleared.".to_string() +} + +/// Strip the bot mention prefix from a raw Matrix message body. +fn strip_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str { + let trimmed = message.trim(); + if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) { + return rest; + } + if let Some(localpart) = bot_user_id.split(':').next() + && let Some(rest) = strip_prefix_ci(trimmed, localpart) + { + return rest; + } + if let Some(rest) = strip_prefix_ci(trimmed, bot_name) { + return rest; + } + trimmed +} + +fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> { + if text.len() < prefix.len() { + return None; + } + if !text[..prefix.len()].eq_ignore_ascii_case(prefix) { + return None; + } + let rest = &text[prefix.len()..]; + match rest.chars().next() { + None => Some(rest), + Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None, + _ => Some(rest), + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_with_display_name() { + let cmd = extract_reset_command("Timmy reset", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, Some(ResetCommand)); + } + + #[test] + fn extract_with_full_user_id() { + let cmd = + extract_reset_command("@timmy:home.local reset", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, Some(ResetCommand)); + } + + #[test] + fn extract_with_localpart() { + let cmd = extract_reset_command("@timmy reset", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, Some(ResetCommand)); + } + + #[test] + fn extract_case_insensitive() { + let cmd = extract_reset_command("Timmy RESET", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, Some(ResetCommand)); + } + + #[test] + fn extract_non_reset_returns_none() { + let cmd = extract_reset_command("Timmy help", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, None); + } + + #[test] + fn extract_ignores_extra_args() { + // "reset" with trailing text is still a reset command + let cmd = extract_reset_command("Timmy reset everything", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, Some(ResetCommand)); + } + + #[tokio::test] + async fn handle_reset_clears_session_and_entries() { + use crate::matrix::bot::{ConversationEntry, ConversationRole}; + use std::collections::HashMap; + use std::sync::Arc; + use tokio::sync::Mutex as TokioMutex; + + let room_id: OwnedRoomId = "!test:example.com".parse().unwrap(); + let history: ConversationHistory = Arc::new(TokioMutex::new({ + let mut m = HashMap::new(); + m.insert(room_id.clone(), RoomConversation { + session_id: Some("old-session-id".to_string()), + entries: vec![ConversationEntry { + role: ConversationRole::User, + sender: "@alice:example.com".to_string(), + content: "previous message".to_string(), + }], + }); + m + })); + + let tmp = tempfile::tempdir().unwrap(); + let response = handle_reset("Timmy", &room_id, &history, tmp.path()).await; + + assert!(response.contains("reset"), "response should mention reset: {response}"); + + let guard = history.lock().await; + let conv = guard.get(&room_id).unwrap(); + assert!(conv.session_id.is_none(), "session_id should be cleared"); + assert!(conv.entries.is_empty(), "entries should be cleared"); + } +}