From 1058afb6351e1e00bf6ab3926a3ccff8f38893aa Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Mon, 23 Mar 2026 01:41:44 +0000 Subject: [PATCH] add TimeContext: 25 pre-computed time values for the model midnight-based day boundaries (today, yesterday, 2 days ago), week/month boundaries, rolling offsets (1h to 30d). injected into system prompt via {time_block} and per-message via compact time line. models no longer need to compute epoch timestamps. --- src/brain/personality.rs | 47 +++---- src/brain/responder.rs | 61 +++++---- src/time_context.rs | 278 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 338 insertions(+), 48 deletions(-) create mode 100644 src/time_context.rs diff --git a/src/brain/personality.rs b/src/brain/personality.rs index e99ec8c..f921d6f 100644 --- a/src/brain/personality.rs +++ b/src/brain/personality.rs @@ -1,4 +1,4 @@ -use chrono::Utc; +use crate::time_context::TimeContext; pub struct Personality { template: String, @@ -18,16 +18,9 @@ impl Personality { memory_notes: Option<&str>, is_dm: bool, ) -> String { - let now = Utc::now(); - let date = now.format("%Y-%m-%d").to_string(); - let epoch_ms = now.timestamp_millis().to_string(); + let tc = TimeContext::now(); let members_str = members.join(", "); - // Pre-compute reference timestamps so the model doesn't have to do math - let ts_1h_ago = (now - chrono::Duration::hours(1)).timestamp_millis().to_string(); - let ts_yesterday = (now - chrono::Duration::days(1)).timestamp_millis().to_string(); - let ts_last_week = (now - chrono::Duration::days(7)).timestamp_millis().to_string(); - let room_context_rules = if is_dm { String::new() } else { @@ -40,11 +33,9 @@ impl Personality { }; self.template - .replace("{date}", &date) - .replace("{epoch_ms}", &epoch_ms) - .replace("{ts_1h_ago}", &ts_1h_ago) - .replace("{ts_yesterday}", &ts_yesterday) - .replace("{ts_last_week}", &ts_last_week) + .replace("{date}", &tc.date) + .replace("{epoch_ms}", &tc.now.to_string()) + .replace("{time_block}", &tc.system_block()) .replace("{room_name}", room_name) .replace("{members}", &members_str) .replace("{room_context_rules}", &room_context_rules) @@ -60,7 +51,7 @@ mod tests { fn test_date_substitution() { let p = Personality::new("Today is {date}.".to_string()); let result = p.build_system_prompt("general", &[], None, false); - let today = Utc::now().format("%Y-%m-%d").to_string(); + let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); assert_eq!(result, format!("Today is {today}.")); } @@ -93,7 +84,7 @@ mod tests { let members = vec!["Sienna".to_string(), "Lonni".to_string()]; let result = p.build_system_prompt("studio", &members, None, false); - let today = Utc::now().format("%Y-%m-%d").to_string(); + let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); assert!(result.starts_with(&format!("Date: {today}"))); assert!(result.contains("Room: studio")); assert!(result.contains("People: Sienna, Lonni")); @@ -132,19 +123,23 @@ mod tests { } #[test] - fn test_timestamp_variables_substituted() { - let p = Personality::new( - "now={epoch_ms} 1h={ts_1h_ago} yesterday={ts_yesterday} week={ts_last_week}".to_string(), - ); + fn test_time_block_substituted() { + let p = Personality::new("before\n{time_block}\nafter".to_string()); + let result = p.build_system_prompt("room", &[], None, false); + assert!(!result.contains("{time_block}")); + assert!(result.contains("epoch_ms:")); + assert!(result.contains("today:")); + assert!(result.contains("yesterday")); + assert!(result.contains("this week")); + assert!(result.contains("1h_ago=")); + } + + #[test] + fn test_epoch_ms_substituted() { + let p = Personality::new("now={epoch_ms}".to_string()); let result = p.build_system_prompt("room", &[], None, false); - // Should NOT contain the literal placeholders assert!(!result.contains("{epoch_ms}")); - assert!(!result.contains("{ts_1h_ago}")); - assert!(!result.contains("{ts_yesterday}")); - assert!(!result.contains("{ts_last_week}")); - // Should contain numeric values assert!(result.starts_with("now=")); - assert!(result.contains("1h=")); } #[test] diff --git a/src/brain/responder.rs b/src/brain/responder.rs index cb2f0c3..93d0f23 100644 --- a/src/brain/responder.rs +++ b/src/brain/responder.rs @@ -21,6 +21,7 @@ use crate::config::Config; use crate::context::ResponseContext; use crate::conversations::ConversationRegistry; use crate::memory; +use crate::time_context::TimeContext; use crate::tools::ToolRegistry; /// Run a Mistral chat completion on a blocking thread. @@ -146,7 +147,7 @@ impl Responder { messages.push(ChatMessage::new_user_message(&trigger)); } - let tool_defs = ToolRegistry::tool_definitions(self.tools.has_gitea()); + let tool_defs = ToolRegistry::tool_definitions(self.tools.has_gitea(), self.tools.has_kratos()); let model = Model::new(&self.config.mistral.default_model); let max_iterations = self.config.mistral.max_tool_iterations; @@ -280,6 +281,7 @@ impl Responder { conversation_registry: &ConversationRegistry, image_data_uri: Option<&str>, context_hint: Option, + event_id: ruma::OwnedEventId, ) -> Option { // Apply response delay if !self.config.behavior.instant_responses { @@ -302,24 +304,16 @@ impl Responder { // Pre-response memory query (same as legacy path) let memory_notes = self.load_memory_notes(response_ctx, trigger_body).await; - // Build the input message with dynamic context header. + // Build the input message with dynamic context. // Agent instructions are static (set at creation), so per-message context // (timestamps, room, members, memory) is prepended to each user message. - let now = chrono::Utc::now(); - let epoch_ms = now.timestamp_millis(); - let ts_1h = (now - chrono::Duration::hours(1)).timestamp_millis(); - let ts_yesterday = (now - chrono::Duration::days(1)).timestamp_millis(); - let ts_last_week = (now - chrono::Duration::days(7)).timestamp_millis(); + let tc = TimeContext::now(); let mut context_header = format!( - "[context: date={}, epoch_ms={}, ts_1h_ago={}, ts_yesterday={}, ts_last_week={}, room={}, room_name={}]", - now.format("%Y-%m-%d"), - epoch_ms, - ts_1h, - ts_yesterday, - ts_last_week, - room_id, + "{}\n[room: {} ({})]", + tc.message_line(), room_name, + room_id, ); if let Some(ref notes) = memory_notes { @@ -352,9 +346,12 @@ impl Responder { // Check for function calls — execute locally and send results back let function_calls = response.function_calls(); if !function_calls.is_empty() { - // Agent UX: reactions + threads require the user's event ID - // which we don't have in the responder. For now, log tool calls - // and skip UX. TODO: pass event_id through ResponseContext. + // Agent UX: react with 🔍 and post tool details in a thread + let mut progress = crate::agent_ux::AgentProgress::new( + room.clone(), + event_id.clone(), + ); + progress.start().await; let max_iterations = self.config.mistral.max_tool_iterations; let mut current_response = response; @@ -376,13 +373,30 @@ impl Responder { "Executing tool call (conversations)" ); - - - let result = self - .tools - .execute(&fc.name, &fc.arguments, response_ctx) + // Post tool call to thread + progress + .post_step(&crate::agent_ux::AgentProgress::format_tool_call( + &fc.name, + &fc.arguments, + )) .await; + let result = if fc.name == "research" { + self.tools + .execute_research( + &fc.arguments, + response_ctx, + room, + &event_id, + 0, // depth 0 — orchestrator level + ) + .await + } else { + self.tools + .execute(&fc.name, &fc.arguments, response_ctx) + .await + }; + let result_str = match result { Ok(s) => { let preview: String = s.chars().take(500).collect(); @@ -427,6 +441,9 @@ impl Responder { debug!(iteration, "Tool iteration complete (conversations)"); } + // Done with tool calls + progress.done().await; + // Extract final text from the last response if let Some(text) = current_response.assistant_text() { let text = strip_sol_prefix(&text); diff --git a/src/time_context.rs b/src/time_context.rs new file mode 100644 index 0000000..e40f48c --- /dev/null +++ b/src/time_context.rs @@ -0,0 +1,278 @@ +use chrono::{Datelike, Duration, NaiveTime, TimeZone, Utc, Weekday}; + +/// Comprehensive time context for the model. +/// All epoch values are milliseconds. All day boundaries are midnight UTC. +pub struct TimeContext { + // ── Current moment ── + pub now: i64, + pub date: String, // 2026-03-22 + pub time: String, // 14:35 + pub datetime: String, // 2026-03-22T14:35:12Z + pub day_of_week: String, // Saturday + pub day_of_week_short: String, // Sat + + // ── Today ── + pub today_start: i64, // midnight today + pub today_end: i64, // 23:59:59.999 today + + // ── Yesterday ── + pub yesterday_start: i64, + pub yesterday_end: i64, + pub yesterday_name: String, // Friday + + // ── Day before yesterday ── + pub two_days_ago_start: i64, + pub two_days_ago_end: i64, + pub two_days_ago_name: String, + + // ── This week (Monday start) ── + pub this_week_start: i64, + + // ── Last week ── + pub last_week_start: i64, + pub last_week_end: i64, + + // ── This month ── + pub this_month_start: i64, + + // ── Last month ── + pub last_month_start: i64, + pub last_month_end: i64, + + // ── Rolling offsets from now ── + pub ago_1h: i64, + pub ago_6h: i64, + pub ago_12h: i64, + pub ago_24h: i64, + pub ago_48h: i64, + pub ago_7d: i64, + pub ago_14d: i64, + pub ago_30d: i64, +} + +fn weekday_name(w: Weekday) -> &'static str { + match w { + Weekday::Mon => "Monday", + Weekday::Tue => "Tuesday", + Weekday::Wed => "Wednesday", + Weekday::Thu => "Thursday", + Weekday::Fri => "Friday", + Weekday::Sat => "Saturday", + Weekday::Sun => "Sunday", + } +} + +fn weekday_short(w: Weekday) -> &'static str { + match w { + Weekday::Mon => "Mon", + Weekday::Tue => "Tue", + Weekday::Wed => "Wed", + Weekday::Thu => "Thu", + Weekday::Fri => "Fri", + Weekday::Sat => "Sat", + Weekday::Sun => "Sun", + } +} + +impl TimeContext { + pub fn now() -> Self { + let now = Utc::now(); + let today = now.date_naive(); + let yesterday = today - Duration::days(1); + let two_days_ago = today - Duration::days(2); + + let midnight = NaiveTime::from_hms_opt(0, 0, 0).unwrap(); + let end_of_day = NaiveTime::from_hms_milli_opt(23, 59, 59, 999).unwrap(); + + let today_start = Utc.from_utc_datetime(&today.and_time(midnight)).timestamp_millis(); + let today_end = Utc.from_utc_datetime(&today.and_time(end_of_day)).timestamp_millis(); + let yesterday_start = Utc.from_utc_datetime(&yesterday.and_time(midnight)).timestamp_millis(); + let yesterday_end = Utc.from_utc_datetime(&yesterday.and_time(end_of_day)).timestamp_millis(); + let two_days_ago_start = Utc.from_utc_datetime(&two_days_ago.and_time(midnight)).timestamp_millis(); + let two_days_ago_end = Utc.from_utc_datetime(&two_days_ago.and_time(end_of_day)).timestamp_millis(); + + // This week (Monday start) + let days_since_monday = today.weekday().num_days_from_monday() as i64; + let monday = today - Duration::days(days_since_monday); + let this_week_start = Utc.from_utc_datetime(&monday.and_time(midnight)).timestamp_millis(); + + // Last week + let last_monday = monday - Duration::days(7); + let last_sunday = monday - Duration::days(1); + let last_week_start = Utc.from_utc_datetime(&last_monday.and_time(midnight)).timestamp_millis(); + let last_week_end = Utc.from_utc_datetime(&last_sunday.and_time(end_of_day)).timestamp_millis(); + + // This month + let first_of_month = today.with_day(1).unwrap(); + let this_month_start = Utc.from_utc_datetime(&first_of_month.and_time(midnight)).timestamp_millis(); + + // Last month + let last_month_last_day = first_of_month - Duration::days(1); + let last_month_first = last_month_last_day.with_day(1).unwrap(); + let last_month_start = Utc.from_utc_datetime(&last_month_first.and_time(midnight)).timestamp_millis(); + let last_month_end = Utc.from_utc_datetime(&last_month_last_day.and_time(end_of_day)).timestamp_millis(); + + let now_ms = now.timestamp_millis(); + + Self { + now: now_ms, + date: now.format("%Y-%m-%d").to_string(), + time: now.format("%H:%M").to_string(), + datetime: now.format("%Y-%m-%dT%H:%M:%SZ").to_string(), + day_of_week: weekday_name(today.weekday()).to_string(), + day_of_week_short: weekday_short(today.weekday()).to_string(), + + today_start, + today_end, + + yesterday_start, + yesterday_end, + yesterday_name: weekday_name(yesterday.weekday()).to_string(), + + two_days_ago_start, + two_days_ago_end, + two_days_ago_name: weekday_name(two_days_ago.weekday()).to_string(), + + this_week_start, + last_week_start, + last_week_end, + this_month_start, + last_month_start, + last_month_end, + + ago_1h: now_ms - 3_600_000, + ago_6h: now_ms - 21_600_000, + ago_12h: now_ms - 43_200_000, + ago_24h: now_ms - 86_400_000, + ago_48h: now_ms - 172_800_000, + ago_7d: now_ms - 604_800_000, + ago_14d: now_ms - 1_209_600_000, + ago_30d: now_ms - 2_592_000_000, + } + } + + /// Full time block for system prompts (~25 values). + /// Used in the legacy path template and the conversations API per-message header. + pub fn system_block(&self) -> String { + format!( + "\ +## time\n\ +\n\ +current: {} {} UTC ({}, {})\n\ +epoch_ms: {}\n\ +\n\ +day boundaries (midnight UTC, use these for search_archive after/before):\n\ + today: {} to {}\n\ + yesterday ({}): {} to {}\n\ + {} ago: {} to {}\n\ + this week (Mon): {} to now\n\ + last week: {} to {}\n\ + this month: {} to now\n\ + last month: {} to {}\n\ +\n\ +rolling offsets:\n\ + 1h_ago={} 6h_ago={} 12h_ago={} 24h_ago={}\n\ + 48h_ago={} 7d_ago={} 14d_ago={} 30d_ago={}", + self.date, self.time, self.day_of_week, self.datetime, + self.now, + self.today_start, self.today_end, + self.yesterday_name, self.yesterday_start, self.yesterday_end, + self.two_days_ago_name, self.two_days_ago_start, self.two_days_ago_end, + self.this_week_start, + self.last_week_start, self.last_week_end, + self.this_month_start, + self.last_month_start, self.last_month_end, + self.ago_1h, self.ago_6h, self.ago_12h, self.ago_24h, + self.ago_48h, self.ago_7d, self.ago_14d, self.ago_30d, + ) + } + + /// Compact time line for per-message injection (~5 key values). + pub fn message_line(&self) -> String { + format!( + "[time: {} {} UTC | today={}-{} | yesterday={}-{} | now={}]", + self.date, self.time, + self.today_start, self.today_end, + self.yesterday_start, self.yesterday_end, + self.now, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_time_context_now() { + let tc = TimeContext::now(); + assert!(tc.now > 0); + assert!(tc.today_start <= tc.now); + assert!(tc.today_end >= tc.now); + assert!(tc.yesterday_start < tc.today_start); + assert!(tc.yesterday_end < tc.today_start); + assert!(tc.this_week_start <= tc.today_start); + assert!(tc.last_week_start < tc.this_week_start); + assert!(tc.this_month_start <= tc.today_start); + assert!(tc.last_month_start < tc.this_month_start); + } + + #[test] + fn test_day_boundaries_are_midnight() { + let tc = TimeContext::now(); + // today_start should be divisible by 86400000 (midnight) + // (not exactly, due to timezone, but should end in 00:00:00.000) + assert!(tc.today_start % 1000 == 0); // whole second + assert!(tc.yesterday_start % 1000 == 0); + // end of day should be .999 + assert!(tc.today_end % 1000 == 999); + assert!(tc.yesterday_end % 1000 == 999); + } + + #[test] + fn test_yesterday_is_24h_before_today() { + let tc = TimeContext::now(); + assert_eq!(tc.today_start - tc.yesterday_start, 86_400_000); + } + + #[test] + fn test_rolling_offsets() { + let tc = TimeContext::now(); + assert_eq!(tc.now - tc.ago_1h, 3_600_000); + assert_eq!(tc.now - tc.ago_24h, 86_400_000); + assert_eq!(tc.now - tc.ago_7d, 604_800_000); + } + + #[test] + fn test_day_names() { + let tc = TimeContext::now(); + let valid_days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; + assert!(valid_days.contains(&tc.day_of_week.as_str())); + assert!(valid_days.contains(&tc.yesterday_name.as_str())); + } + + #[test] + fn test_system_block_contains_key_values() { + let tc = TimeContext::now(); + let block = tc.system_block(); + assert!(block.contains("epoch_ms:")); + assert!(block.contains("today:")); + assert!(block.contains("yesterday")); + assert!(block.contains("this week")); + assert!(block.contains("last week")); + assert!(block.contains("this month")); + assert!(block.contains("1h_ago=")); + assert!(block.contains("30d_ago=")); + } + + #[test] + fn test_message_line_compact() { + let tc = TimeContext::now(); + let line = tc.message_line(); + assert!(line.starts_with("[time:")); + assert!(line.contains("today=")); + assert!(line.contains("yesterday=")); + assert!(line.contains("now=")); + assert!(line.ends_with(']')); + } +}