Persona chats from SMS threads (talk about / practice), date_info, photos never sent to OpenRouter #186

Merged
cameron merged 8 commits from feat/sms-anchored-persona-chat into master 2026-10-21 00:11:23 +00:00
4 changed files with 378 additions and 23 deletions
Showing only changes of commit edfa48d116 - Show all commits

No files matched your search

+350
View File
@@ -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
View File
@@ -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"
);
}
+1
View File
@@ -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
View File
@@ -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();