diff --git a/src/ai/date_info.rs b/src/ai/date_info.rs new file mode 100644 index 0000000..e6f79e0 --- /dev/null +++ b/src/ai/date_info.rs @@ -0,0 +1,350 @@ +//! The `date_info` agent tool: date arithmetic the model shouldn't do in +//! its head. +//! +//! Small local models are reliably shaky at weekday lookups, "how long ago +//! was …" across year boundaries and day counts between two dates — and +//! they state the wrong answer confidently. Persona chats anchored on old +//! SMS threads ask exactly these questions, so the tool answers them +//! exactly. It also reports "now", replacing the old `get_current_datetime`. +//! +//! Everything is computed in the owner's display zone, so the dates match +//! the timestamps the model reads in thread transcripts and photo metadata. + +use chrono::{DateTime, Datelike, Months, NaiveDate, NaiveDateTime, Utc}; +use chrono_tz::Tz; + +/// A date argument as the model gave it. A bare date stays a date (no +/// invented time of day); a date with a time keeps it. +#[derive(Debug, Clone, Copy, PartialEq)] +enum Moment { + Day(NaiveDate), + At(NaiveDateTime), +} + +impl Moment { + fn date(self) -> NaiveDate { + match self { + Moment::Day(d) => d, + Moment::At(dt) => dt.date(), + } + } + + fn label(self) -> String { + match self { + Moment::Day(d) => format!("{} ({})", d.format("%Y-%m-%d"), d.format("%A")), + Moment::At(dt) => format!("{} ({})", dt.format("%Y-%m-%d %H:%M"), dt.format("%A")), + } + } +} + +/// Parse `YYYY-MM-DD`, `YYYY-MM-DD HH:MM` (or with `T` / seconds), or unix +/// seconds (read in `zone`). +fn parse_moment(value: &serde_json::Value, zone: Tz) -> Option { + if let Some(ts) = value.as_i64() { + let utc = DateTime::::from_timestamp(ts, 0)?; + return Some(Moment::At(utc.with_timezone(&zone).naive_local())); + } + let raw = value.as_str()?.trim(); + for fmt in [ + "%Y-%m-%d %H:%M", + "%Y-%m-%dT%H:%M", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%dT%H:%M:%S", + ] { + if let Ok(dt) = NaiveDateTime::parse_from_str(raw, fmt) { + return Some(Moment::At(dt)); + } + } + if let Ok(ts) = raw.parse::() { + return parse_moment(&serde_json::json!(ts), zone); + } + NaiveDate::parse_from_str(raw, "%Y-%m-%d") + .ok() + .map(Moment::Day) +} + +/// Whole years, months and days from `a` to `b` (`a <= b`), counted the +/// calendar way: Jan 31 + 1 month is Feb 28/29, as people count ages. +fn calendar_gap(a: NaiveDate, b: NaiveDate) -> (u32, u32, u32) { + let mut months = (b.year() - a.year()) * 12 + b.month() as i32 - a.month() as i32; + let mut anchor = a.checked_add_months(Months::new(months.max(0) as u32)); + if anchor.is_none_or(|d| d > b) { + months -= 1; + anchor = a.checked_add_months(Months::new(months.max(0) as u32)); + } + let months = months.max(0) as u32; + let days = anchor.map(|d| (b - d).num_days()).unwrap_or(0).max(0) as u32; + (months / 12, months % 12, days) +} + +fn plural(n: impl Into, unit: &str) -> String { + let n = n.into(); + if n == 1 { + format!("1 {unit}") + } else { + format!("{n} {unit}s") + } +} + +/// "7 years, 3 months and 15 days" — zero parts dropped, "0 days" when the +/// dates are the same. +fn describe_gap(a: NaiveDate, b: NaiveDate) -> String { + let (y, m, d) = calendar_gap(a, b); + let mut parts = Vec::new(); + if y > 0 { + parts.push(plural(y, "year")); + } + if m > 0 { + parts.push(plural(m, "month")); + } + if d > 0 || parts.is_empty() { + parts.push(plural(d, "day")); + } + match parts.len() { + 1 => parts.remove(0), + _ => { + let last = parts.pop().unwrap_or_default(); + format!("{} and {}", parts.join(", "), last) + } + } +} + +/// The span between two dates: a headline phrase, plus a detail (exact +/// day count, weeks) the caller places after any "ago" / "in". +fn span(a: NaiveDate, b: NaiveDate) -> (String, String) { + let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; + let days = (hi - lo).num_days(); + let weeks = if days >= 14 { + format!("about {}", plural(days / 7, "week")) + } else { + String::new() + }; + if days < 31 { + (plural(days, "day"), weeks) + } else { + let detail = if weeks.is_empty() { + plural(days, "day") + } else { + format!("{}, {weeks}", plural(days, "day")) + }; + (describe_gap(lo, hi), detail) + } +} + +/// `headline` with `(detail)` appended when there is one. +fn with_detail(headline: String, detail: &str) -> String { + if detail.is_empty() { + headline + } else { + format!("{headline} ({detail})") + } +} + +/// Run the tool. `now` is injected so tests are deterministic. +pub fn date_info(args: &serde_json::Value, now: DateTime, zone: Tz) -> String { + let now_local = now.with_timezone(&zone); + let today = now_local.date_naive(); + let mut out = format!( + "Now: {} ({}), {}", + now_local.format("%Y-%m-%d %H:%M"), + now_local.format("%A"), + zone.name() + ); + + let first = args.get("date").filter(|v| !v.is_null()); + let second = args.get("other_date").filter(|v| !v.is_null()); + let add_days = args.get("add_days").and_then(|v| v.as_i64()); + let Some(first) = first else { + return out; + }; + let Some(a) = parse_moment(first, zone) else { + return format!( + "Error: couldn't read date {first} — use YYYY-MM-DD, YYYY-MM-DD HH:MM or unix seconds" + ); + }; + + // Offset: "what date is N days after/before X". + if let Some(n) = add_days { + let Some(result) = a.date().checked_add_signed(chrono::Duration::days(n)) else { + return "Error: add_days is out of range".to_string(); + }; + let direction = if n >= 0 { "after" } else { "before" }; + out.push_str(&format!( + "\n{} {direction} {} is {}.", + plural(n.abs(), "day"), + a.label(), + Moment::Day(result).label() + )); + return out; + } + + match second { + None => { + let d = a.date(); + let relation = match d.cmp(&today) { + std::cmp::Ordering::Equal => "today".to_string(), + std::cmp::Ordering::Less => { + let (head, detail) = span(d, today); + with_detail(format!("{head} ago"), &detail) + } + std::cmp::Ordering::Greater => { + let (head, detail) = span(today, d); + with_detail(format!("in {head}"), &detail) + } + }; + out.push_str(&format!("\n{} — {relation}.", a.label())); + } + Some(second) => { + let Some(b) = parse_moment(second, zone) else { + return format!( + "Error: couldn't read other_date {second} — use YYYY-MM-DD, YYYY-MM-DD HH:MM or unix seconds" + ); + }; + let (from, to) = if a.date() <= b.date() { (a, b) } else { (b, a) }; + let mut gap = if from.date() == to.date() { + "the same day".to_string() + } else { + let (head, detail) = span(from.date(), to.date()); + with_detail(head, &detail) + }; + // Both with times and close together: the hours are what matter. + if let (Moment::At(x), Moment::At(y)) = (from, to) { + let minutes = (y - x).num_minutes().abs(); + if minutes < 48 * 60 { + gap.push_str(&format!(" ({}h {}m apart)", minutes / 60, minutes % 60)); + } + } + out.push_str(&format!( + "\nFrom {} to {}: {gap}.", + from.label(), + to.label() + )); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + use serde_json::json; + + const NY: Tz = chrono_tz::America::New_York; + + fn at(zone: Tz, y: i32, m: u32, d: u32, h: u32, min: u32) -> DateTime { + zone.with_ymd_and_hms(y, m, d, h, min, 0) + .single() + .expect("unambiguous local time") + .with_timezone(&Utc) + } + + fn now() -> DateTime { + // Sunday 2026-10-18 20:15 in New York. + at(NY, 2026, 10, 18, 20, 15) + } + + #[test] + fn no_date_reports_now_in_the_display_zone() { + assert_eq!( + date_info(&json!({}), now(), NY), + "Now: 2026-10-18 20:15 (Sunday), America/New_York" + ); + } + + #[test] + fn a_past_date_gets_its_weekday_and_how_long_ago() { + let out = date_info(&json!({"date": "2019-07-03"}), now(), NY); + assert!( + out.ends_with( + "2019-07-03 (Wednesday) — 7 years, 3 months and 15 days ago (2664 days, about 380 weeks)." + ), + "{out}" + ); + } + + #[test] + fn near_dates_read_as_days() { + let out = date_info(&json!({"date": "2026-10-15"}), now(), NY); + assert!( + out.ends_with("2026-10-15 (Thursday) — 3 days ago."), + "{out}" + ); + let out = date_info(&json!({"date": "2026-10-18"}), now(), NY); + assert!(out.ends_with("— today."), "{out}"); + let out = date_info(&json!({"date": "2026-11-01"}), now(), NY); + assert!(out.ends_with("— in 14 days (about 2 weeks)."), "{out}"); + } + + #[test] + fn today_follows_the_display_zone_not_utc() { + // 02:30 UTC on the 19th is still the evening of the 18th in New York. + let late = at(NY, 2026, 10, 18, 22, 30); + let out = date_info(&json!({"date": "2026-10-18"}), late, NY); + assert!(out.ends_with("— today."), "{out}"); + } + + #[test] + fn unix_seconds_read_in_the_display_zone() { + // 2019-07-04 02:47 UTC = 2019-07-03 22:47 in New York. + let out = date_info(&json!({"date": 1562208420}), now(), NY); + assert!(out.contains("2019-07-03 22:47 (Wednesday)"), "{out}"); + } + + #[test] + fn two_dates_give_the_gap_in_either_order() { + let a = date_info( + &json!({"date": "2019-07-05", "other_date": "2019-07-03"}), + now(), + NY, + ); + assert!( + a.ends_with("From 2019-07-03 (Wednesday) to 2019-07-05 (Friday): 2 days."), + "{a}" + ); + } + + #[test] + fn two_close_times_include_the_hours() { + let out = date_info( + &json!({"date": "2019-07-03 22:47", "other_date": "2019-07-04 08:40"}), + now(), + NY, + ); + assert!( + out.ends_with("2019-07-04 08:40 (Thursday): 1 day (9h 53m apart)."), + "{out}" + ); + } + + #[test] + fn add_days_moves_forward_and_back() { + let out = date_info(&json!({"date": "2019-07-03", "add_days": 90}), now(), NY); + assert!( + out.ends_with("90 days after 2019-07-03 (Wednesday) is 2019-10-01 (Tuesday)."), + "{out}" + ); + let out = date_info(&json!({"date": "2024-03-01", "add_days": -1}), now(), NY); + assert!(out.ends_with("is 2024-02-29 (Thursday)."), "{out}"); + } + + #[test] + fn calendar_gap_counts_like_people_do() { + let d = |y, m, dd| NaiveDate::from_ymd_opt(y, m, dd).unwrap(); + // Month-end clamps: Jan 31 + 1 month = Feb 29 in a leap year. + assert_eq!(calendar_gap(d(2020, 1, 31), d(2020, 2, 29)), (0, 1, 0)); + assert_eq!(calendar_gap(d(2020, 1, 31), d(2020, 3, 1)), (0, 1, 1)); + assert_eq!(calendar_gap(d(2016, 2, 29), d(2026, 2, 28)), (10, 0, 0)); + assert_eq!(calendar_gap(d(2019, 7, 3), d(2026, 10, 18)), (7, 3, 15)); + assert_eq!(describe_gap(d(2025, 10, 18), d(2026, 10, 18)), "1 year"); + } + + #[test] + fn bad_input_says_what_format_to_use() { + let out = date_info(&json!({"date": "last tuesday"}), now(), NY); + assert!( + out.starts_with("Error:") && out.contains("YYYY-MM-DD"), + "{out}" + ); + } +} diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index dc22f53..11e9644 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -1937,7 +1937,9 @@ Return ONLY the summary, nothing else."#, self.tool_supersede_fact(arguments, user_id, persona_id, model, backend_label) .await } - "get_current_datetime" => Self::tool_get_current_datetime(), + "date_info" => { + crate::ai::date_info::date_info(arguments, Utc::now(), self.display_zone()) + } "get_thread_messages" => self.tool_get_thread_messages(arguments).await, "look_at_message_image" => { self.tool_look_at_message_image(arguments, backend.local()) @@ -3703,21 +3705,11 @@ Return ONLY the summary, nothing else."#, } } - /// Tool: get_current_datetime — returns the current local date and time - fn tool_get_current_datetime() -> String { - let now = Local::now(); - format!( - "Current date/time: {} ({})", - now.format("%Y-%m-%d %H:%M:%S %Z"), - now.format("%A") - ) - } - // ── Agentic insight generation ────────────────────────────────────── /// Build the list of tool definitions for the agentic loop, gated by /// `opts`. Always-on tools: `search_messages`, `get_sms_messages`, - /// `get_file_tags`, `reverse_geocode`, `get_current_datetime`, the + /// `get_file_tags`, `reverse_geocode`, `date_info`, the /// five knowledge-memory tools. Conditional: `describe_photo` (vision /// model), `get_personal_place_at` (Apollo configured), `search_rag` /// (daily_summaries populated), `get_calendar_events` (calendar @@ -4043,11 +4035,23 @@ Return ONLY the summary, nothing else."#, } tools.push(Tool::function( - "get_current_datetime", - "Get the current date and time. Useful when reasoning about how long ago a photo was taken.", + "date_info", + "Exact date arithmetic — use it instead of working dates out yourself. With no arguments: \ + the current date and time. With `date`: that day's weekday and how long ago (or until) it \ + is. With `date` and `other_date`: the gap between them in days, weeks and years/months/days \ + (and hours, when both have times). With `date` and `add_days`: the date that many days \ + after (or, negative, before). Dates are YYYY-MM-DD, YYYY-MM-DD HH:MM, or unix seconds, in \ + the user's local time. \ + Examples: `{}` — now. `{date: \"2019-07-03\"}` — weekday and how long ago. \ + `{date: \"2019-07-03\", other_date: \"2019-08-14\"}` — time between. \ + `{date: \"2019-07-03\", add_days: 90}` — the date 90 days later.", serde_json::json!({ "type": "object", - "properties": {} + "properties": { + "date": { "type": "string", "description": "YYYY-MM-DD, YYYY-MM-DD HH:MM, or unix seconds." }, + "other_date": { "type": "string", "description": "Second date, to measure the gap between the two." }, + "add_days": { "type": "integer", "description": "Days to add to `date` (negative to go back)." } + } }), )); @@ -4305,7 +4309,7 @@ Return ONLY the summary, nothing else."#, .find_map(|tok| tok.strip_prefix("ID:")) .map(|id| format!("stored id={}", id.trim_end_matches(','))) .unwrap_or_else(|| "stored".to_string()), - "get_current_datetime" => "time noted".to_string(), + "date_info" => "date worked out".to_string(), "look_at_message_image" | "look_at_photo" => { let short: String = raw.chars().take(80).collect(); format!("looked: \"{}...\"", short) @@ -5238,7 +5242,7 @@ mod tests { assert!(names.contains(&"get_sms_messages")); assert!(names.contains(&"get_file_tags")); assert!(names.contains(&"reverse_geocode")); - assert!(names.contains(&"get_current_datetime")); + assert!(names.contains(&"date_info")); assert!(names.contains(&"recall_entities")); assert!(names.contains(&"recall_facts_for_photo")); assert!(names.contains(&"recall_facts_for_entity")); @@ -5786,13 +5790,13 @@ mod tests { } #[test] - fn summarize_current_datetime() { + fn summarize_date_info() { assert_eq!( InsightGenerator::summarize_tool_result( - "get_current_datetime", - "Current date/time: 2024-01-15 12:00:00 PST (Monday)" + "date_info", + "Now: 2024-01-15 12:00 (Monday), America/New_York" ), - "time noted" + "date worked out" ); } diff --git a/src/ai/mod.rs b/src/ai/mod.rs index 1a35364..f491517 100644 --- a/src/ai/mod.rs +++ b/src/ai/mod.rs @@ -2,6 +2,7 @@ pub mod apollo_client; pub mod backend; pub mod clip_client; pub mod daily_summary_job; +pub mod date_info; pub mod face_client; pub mod gpu; pub mod handlers; diff --git a/src/reels/script.rs b/src/reels/script.rs index 38ef9cc..8fff21a 100644 --- a/src/reels/script.rs +++ b/src/reels/script.rs @@ -55,7 +55,7 @@ can be read aloud in a few seconds. Avoid generic filler like \"what a \ wonderful day\" — if you have little to go on, simply describe the moment \ plainly.\n\nYou may call read-only tools (search_rag, search_messages, \ get_sms_messages, get_calendar_events, get_location_history, reverse_geocode, \ -get_personal_place_at, recall_entities, get_current_datetime) to ground each \ +get_personal_place_at, recall_entities, date_info) to ground each \ line in real context — e.g. reverse_geocode a moment's GPS to name the place, \ or check the calendar/messages around its date. Never invent details. Return \ ONLY the JSON object, no prose or code fences."; @@ -335,7 +335,7 @@ pub async fn generate_script_agentic( "reverse_geocode", "get_personal_place_at", "recall_entities", - "get_current_datetime", + "date_info", ] .into_iter() .collect();