Persona chats from SMS threads (talk about / practice), date_info, photos never sent to OpenRouter #186
No files matched your search
@@ -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<Moment> {
|
||||
if let Some(ts) = value.as_i64() {
|
||||
let utc = DateTime::<Utc>::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::<i64>() {
|
||||
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<i64>, 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<Utc>, 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<Utc> {
|
||||
zone.with_ymd_and_hms(y, m, d, h, min, 0)
|
||||
.single()
|
||||
.expect("unambiguous local time")
|
||||
.with_timezone(&Utc)
|
||||
}
|
||||
|
||||
fn now() -> DateTime<Utc> {
|
||||
// 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
+25
-21
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+2
-2
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user