From 5ab592071ecc52d823c4a0dd3e463cff052d7329 Mon Sep 17 00:00:00 2001 From: Damocles Date: Thu, 30 Apr 2026 21:02:44 +0200 Subject: [PATCH] configurable model (default sonnet 4.6), timestamps in chat log --- src/main.rs | 62 ++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/src/main.rs b/src/main.rs index b662f42..15f7646 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,8 +29,11 @@ struct Config { username: String, password: String, rate_limit_per_min: Option, + model: Option, } +const DEFAULT_MODEL: &str = "claude-sonnet-4-6"; + #[derive(Debug, Serialize, Deserialize)] struct PersistedSession { homeserver: String, @@ -45,6 +48,8 @@ struct ChatMessage { sender: OwnedUserId, body: String, is_self: bool, + /// Unix seconds. 0 if unknown. + ts: i64, } struct DaemonState { @@ -56,6 +61,7 @@ struct DaemonState { rate_budget: u32, rate_limit_per_min: u32, last_rate_reset: std::time::Instant, + model: String, } const MAX_HISTORY: usize = 20; @@ -82,6 +88,10 @@ async fn main() -> anyhow::Result<()> { let rate_limit_per_min = config .rate_limit_per_min .unwrap_or(DEFAULT_RATE_LIMIT_PER_MIN); + let model = config + .model + .clone() + .unwrap_or_else(|| DEFAULT_MODEL.to_owned()); let (client, sync_token) = if session_file.exists() { restore_session(&session_file).await? @@ -90,7 +100,12 @@ async fn main() -> anyhow::Result<()> { }; let own_user_id = client.user_id().context("not logged in")?.to_owned(); - tracing::info!(user = %own_user_id, rate_limit = rate_limit_per_min, "ready"); + tracing::info!( + user = %own_user_id, + rate_limit = rate_limit_per_min, + model = %model, + "ready" + ); // Enable persistent event cache (matrix-sdk's sqlite store keeps the timeline) client @@ -105,6 +120,7 @@ async fn main() -> anyhow::Result<()> { rate_budget: rate_limit_per_min, rate_limit_per_min, last_rate_reset: std::time::Instant::now(), + model, })); let processor_state = state.clone(); @@ -368,6 +384,19 @@ fn chrono_now() -> String { format!("{y:04}-{m:02}-{d:02}") } +/// Format a unix-seconds timestamp as `YYYY-MM-DD HH:MM` UTC. Returns "?" for 0. +fn format_ts(secs: i64) -> String { + if secs == 0 { + return "?".into(); + } + let days = secs.div_euclid(86400); + let day_secs = secs.rem_euclid(86400); + let (y, m, d) = days_to_ymd(days); + let h = day_secs / 3600; + let min = (day_secs % 3600) / 60; + format!("{y:04}-{m:02}-{d:02} {h:02}:{min:02}") +} + /// Convert days-since-1970-01-01 to (year, month, day). Civil-date algorithm. fn days_to_ymd(z: i64) -> (i64, u32, u32) { let z = z + 719_468; @@ -429,29 +458,30 @@ async fn process_loop(state: Arc>, client: Client) { } }; - let own_user = { + let (own_user, model) = { let state = state.lock().await; - state.own_user_id.clone() + (state.own_user_id.clone(), state.model.clone()) }; let chat_msgs: Vec = history .iter() - .map(|(_, sender, body)| ChatMessage { + .map(|(_, sender, body, ts)| ChatMessage { sender: sender.clone(), body: body.clone(), is_self: sender == &own_user, + ts: *ts, }) .collect(); // Determine seen split: everything before (and including) prev_last_shown is "seen" let seen_idx = prev_last_shown .as_ref() - .and_then(|id| history.iter().position(|(eid, _, _)| eid == id)) + .and_then(|id| history.iter().position(|(eid, _, _, _)| eid == id)) .map_or(0, |pos| pos + 1); - let new_last_event_id = history.last().map(|(eid, _, _)| eid.clone()); + let new_last_event_id = history.last().map(|(eid, _, _, _)| eid.clone()); - match invoke_claude(&room_id, &room_name, &chat_msgs, seen_idx).await { + match invoke_claude(&room_id, &room_name, &chat_msgs, seen_idx, &model).await { Ok(Some(response)) => { if let Some(target_room) = client.get_room(&response.room) { let content = RoomMessageEventContent::text_plain(&response.body); @@ -489,17 +519,17 @@ async fn process_loop(state: Arc>, client: Client) { } /// Load the last N text messages from the room's persistent event cache. -/// Returns oldest-first list of (event_id, sender, body). +/// Returns oldest-first list of (event_id, sender, body, ts_secs). async fn load_recent_messages( room: &Room, limit: usize, -) -> anyhow::Result> { +) -> anyhow::Result> { use matrix_sdk::ruma::events::AnySyncTimelineEvent; let (cache, _handles) = room.event_cache().await?; let events = cache.events().await; - let mut out: Vec<(OwnedEventId, OwnedUserId, String)> = Vec::new(); + let mut out: Vec<(OwnedEventId, OwnedUserId, String, i64)> = Vec::new(); for ev in events.iter().rev() { if out.len() >= limit { break; @@ -514,10 +544,13 @@ async fn load_recent_messages( ) = msg { if let MessageType::Text(text) = &orig.content.msgtype { + let ts_ms: u64 = orig.origin_server_ts.0.into(); + let ts_secs: i64 = i64::try_from(ts_ms).unwrap_or(0) / 1000; out.push(( orig.event_id.clone(), orig.sender.clone(), text.body.clone(), + ts_secs, )); } } @@ -538,6 +571,7 @@ async fn invoke_claude( room_name: &str, history: &[ChatMessage], seen_idx: usize, + model: &str, ) -> anyhow::Result> { let identity_dir = paths::identity_dir(); let identity_str = identity_dir.to_string_lossy(); @@ -577,7 +611,8 @@ async fn invoke_claude( writeln!(prompt, "\n[previously seen messages — for context]").unwrap(); for msg in old { let prefix = if msg.is_self { "(you) " } else { "" }; - writeln!(prompt, "{prefix}{}: {}", msg.sender, msg.body).unwrap(); + let ts = format_ts(msg.ts); + writeln!(prompt, "[{ts}] {prefix}{}: {}", msg.sender, msg.body).unwrap(); } } @@ -587,7 +622,8 @@ async fn invoke_claude( } else { for msg in new { let prefix = if msg.is_self { "(you) " } else { "" }; - writeln!(prompt, "{prefix}{}: {}", msg.sender, msg.body).unwrap(); + let ts = format_ts(msg.ts); + writeln!(prompt, "[{ts}] {prefix}{}: {}", msg.sender, msg.body).unwrap(); } } @@ -598,6 +634,8 @@ async fn invoke_claude( let mut cmd = Command::new("claude"); cmd.args([ "--print", + "--model", + model, "--add-dir", &identity_str, "--allowedTools",