From 4ac574d4d2a3edca8ec2e29b805741f5b6201b4c Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Sat, 17 Oct 2026 18:07:49 -0400 Subject: [PATCH 1/8] feat(persona-chat): continue an SMS thread with a persona Persona conversations can now be anchored on a message in an SMS thread. Creating one snapshots the thread up to that message (via SMS-API) into the row; every turn appends it to the persona prompt with guidance to talk like a text thread rather than write an insight. Anchored turns get four tools: get_thread_messages (page the thread), look_at_message_image (local vision on an MMS photo), get_photos_near_time (library photos with tags and people) and look_at_photo (local vision on a library photo). The conversation list filters by contact_id. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 9 + .../down.sql | 6 + .../up.sql | 23 + src/ai/insight_generator.rs | 394 +++++++++++++++++- src/ai/mod.rs | 1 + src/ai/persona_chat.rs | 189 ++++++++- src/ai/sms_anchor.rs | 303 ++++++++++++++ src/ai/sms_client.rs | 306 ++++++++++++++ src/database/mod.rs | 2 +- src/database/persona_dao.rs | 191 +++++++-- src/database/schema.rs | 5 + 11 files changed, 1389 insertions(+), 40 deletions(-) create mode 100644 migrations/2026-10-19-000000_persona_chat_sms_anchor/down.sql create mode 100644 migrations/2026-10-19-000000_persona_chat_sms_anchor/up.sql create mode 100644 src/ai/sms_anchor.rs diff --git a/CLAUDE.md b/CLAUDE.md index 15b4b46..0bac406 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -577,6 +577,15 @@ POST /insights/chat/stream (SSE: text / tool_call / tool_result / trun GET /insights/chat/history?path=... (rendered transcript with tool invocations) POST /insights/chat/rewind (truncate transcript at a rendered index) +// Persona chat — "Continue with persona" from an SMS thread (src/ai/sms_anchor.rs) +POST /persona_chat/conversations (body: { persona_id, anchor?: { contact_id, message_id } }; + anchor snapshots ~40 SMS-API messages up to message_id into + the row, 502 if SMS-API can't read the thread) +GET /persona_chat/conversations?contact_id=N (only chats anchored on that thread) +// Anchored turns append the thread + chat-register guidance to the persona prompt and add +// the get_thread_messages / look_at_message_image / get_photos_near_time / look_at_photo +// tools (ToolGateOpts.sms_thread_tools). Image tools use the LOCAL vision model. + // User AI Prefs (owner defaults + passive mirror; src/prefs.rs) GET /prefs (current row; auth) PUT /prefs (partial update: omitted=unchanged, null=clear, value=set; auth) diff --git a/migrations/2026-10-19-000000_persona_chat_sms_anchor/down.sql b/migrations/2026-10-19-000000_persona_chat_sms_anchor/down.sql new file mode 100644 index 0000000..4f3d254 --- /dev/null +++ b/migrations/2026-10-19-000000_persona_chat_sms_anchor/down.sql @@ -0,0 +1,6 @@ +DROP INDEX IF EXISTS idx_persona_chat_anchor_contact; +ALTER TABLE persona_chat_conversations DROP COLUMN anchor_context; +ALTER TABLE persona_chat_conversations DROP COLUMN anchor_ts; +ALTER TABLE persona_chat_conversations DROP COLUMN anchor_message_id; +ALTER TABLE persona_chat_conversations DROP COLUMN anchor_contact_name; +ALTER TABLE persona_chat_conversations DROP COLUMN anchor_contact_id; diff --git a/migrations/2026-10-19-000000_persona_chat_sms_anchor/up.sql b/migrations/2026-10-19-000000_persona_chat_sms_anchor/up.sql new file mode 100644 index 0000000..2ccc5e9 --- /dev/null +++ b/migrations/2026-10-19-000000_persona_chat_sms_anchor/up.sql @@ -0,0 +1,23 @@ +-- Anchor a persona conversation on a point in an SMS thread. +-- +-- "Continue with persona" starts a persona chat from a long-pressed message +-- in the Messages screen. The conversation remembers which thread and which +-- message it grew out of, so the SMS screen can list the persona chats that +-- belong to a thread and the chat screen can show where it picked up. +-- +-- `anchor_context` is a snapshot of the thread leading up to the anchor, +-- rendered once at creation and spliced into the system prompt on every +-- turn. Snapshotting (rather than refetching per turn) keeps the prompt +-- prefix byte-stable for KV-cache reuse and keeps the persona's view of the +-- thread fixed even if SMS-API is re-imported underneath it. +-- +-- All nullable: an unanchored (open) persona chat leaves them NULL. + +ALTER TABLE persona_chat_conversations ADD COLUMN anchor_contact_id BIGINT; +ALTER TABLE persona_chat_conversations ADD COLUMN anchor_contact_name TEXT; +ALTER TABLE persona_chat_conversations ADD COLUMN anchor_message_id BIGINT; +ALTER TABLE persona_chat_conversations ADD COLUMN anchor_ts BIGINT; +ALTER TABLE persona_chat_conversations ADD COLUMN anchor_context TEXT; + +CREATE INDEX idx_persona_chat_anchor_contact + ON persona_chat_conversations (user_id, anchor_contact_id, updated_at DESC); diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index 005800a..dc22f53 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -286,6 +286,12 @@ pub struct ToolGateOpts { /// tool name still 404s, and the agent doesn't waste iterations /// trying corrections it isn't allowed to do. pub allow_agent_corrections: bool, + /// Persona chats anchored on an SMS thread ("Continue with persona") + /// get the thread tools: page the thread, look at an MMS photo, find + /// library photos around a moment and look at one. Off everywhere else + /// — the file chat already has its photo, and an open chat has no + /// thread to page. + pub sms_thread_tools: bool, } impl InsightGenerator { @@ -421,6 +427,12 @@ impl InsightGenerator { /// tool-definition gating (no point offering `get_personal_place_at` /// when Apollo is unreachable) — exposed publicly so `insight_chat` /// can call `build_tool_definitions` with the same flag. + /// The SMS-API client, for callers outside the tool loop (the anchored + /// persona chat snapshots a thread with it at creation). + pub fn sms_client(&self) -> &SmsApiClient { + &self.sms_client + } + pub fn apollo_enabled(&self) -> bool { self.apollo_client.is_enabled() } @@ -485,6 +497,7 @@ impl InsightGenerator { location_history_present, faces_present, allow_agent_corrections, + sms_thread_tools: false, } } @@ -1925,6 +1938,13 @@ Return ONLY the summary, nothing else."#, .await } "get_current_datetime" => Self::tool_get_current_datetime(), + "get_thread_messages" => self.tool_get_thread_messages(arguments).await, + "look_at_message_image" => { + self.tool_look_at_message_image(arguments, backend.local()) + .await + } + "get_photos_near_time" => self.tool_get_photos_near_time(arguments).await, + "look_at_photo" => self.tool_look_at_photo(arguments, backend.local()).await, unknown => format!("Unknown tool: {}", unknown), }; if result.starts_with("Error") || result.starts_with("No ") { @@ -2386,6 +2406,256 @@ Return ONLY the summary, nothing else."#, } } + /// Tool: get_thread_messages — page the anchored SMS thread from a + /// message id. Lines are rendered exactly like the anchor snapshot so + /// the model can keep paging from any id it sees. + async fn tool_get_thread_messages(&self, args: &serde_json::Value) -> String { + let Some(contact_id) = args.get("contact_id").and_then(|v| v.as_i64()) else { + return "Error: missing required parameter 'contact_id'".to_string(); + }; + let Some(message_id) = args.get("message_id").and_then(|v| v.as_i64()) else { + return "Error: missing required parameter 'message_id'".to_string(); + }; + let limit = args + .get("limit") + .and_then(|v| v.as_i64()) + .unwrap_or(30) + .clamp(1, 80) as usize; + let (before, after) = match args.get("direction").and_then(|v| v.as_str()) { + Some("after") => (0, limit), + Some("around") => (limit / 2, limit - limit / 2), + _ => (limit, 0), + }; + + match self + .sms_client + .fetch_thread_window(contact_id, message_id, before, after) + .await + { + Ok(window) if window.messages.len() > 1 => { + let rendered = crate::ai::sms_anchor::render_thread_window( + &window, + &user_display_name(), + self.display_zone(), + ); + format!( + "Found {} messages with {} (▶ marks #{}):\n{}", + window.messages.len(), + window.contact.display_name(), + message_id, + rendered + ) + } + Ok(_) => "No more messages in that direction.".to_string(), + Err(e) => { + log::warn!("tool_get_thread_messages failed: {}", e); + format!("Error fetching thread messages: {}", e) + } + } + } + + /// Tool: look_at_message_image — download an MMS photo from SMS-API and + /// describe it with the local vision model. + async fn tool_look_at_message_image( + &self, + args: &serde_json::Value, + local: &dyn LlmClient, + ) -> String { + let Some(contact_id) = args.get("contact_id").and_then(|v| v.as_i64()) else { + return "Error: missing required parameter 'contact_id'".to_string(); + }; + let Some(message_id) = args.get("message_id").and_then(|v| v.as_i64()) else { + return "Error: missing required parameter 'message_id'".to_string(); + }; + let media_index = args + .get("media_index") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as usize; + + let bytes = match self + .sms_client + .fetch_mms_media(contact_id, message_id, media_index) + .await + { + Ok(b) => b, + Err(e) => return format!("Error fetching attachment: {}", e), + }; + let image_base64 = match Self::encode_image_bytes_for_vision(&bytes) { + Ok(b64) => b64, + Err(_) => { + return "Error: that attachment isn't a viewable image (videos, audio and some \ + formats can't be looked at)." + .to_string(); + } + }; + match local.describe_image(&image_base64).await { + Ok(desc) => desc, + Err(e) => format!("Error describing attachment: {}", e), + } + } + + /// Tool: get_photos_near_time — library photos within ±hours of a + /// moment, closest first, with tags and recognised people. + async fn tool_get_photos_near_time(&self, args: &serde_json::Value) -> String { + let zone = self.display_zone(); + let server_zone = crate::camera_tz::server_zone(); + let Some(center) = Self::parse_moment(args, zone) else { + return "Error: pass 'timestamp' (unix seconds) or 'datetime' (YYYY-MM-DD HH:MM)" + .to_string(); + }; + let hours = args + .get("hours_radius") + .and_then(|v| v.as_i64()) + .unwrap_or(3) + .clamp(1, 72); + let limit = args + .get("limit") + .and_then(|v| v.as_i64()) + .unwrap_or(15) + .clamp(1, 40) as usize; + + let range = crate::date_view::InstantRange::new( + Some(center - hours * 3600), + Some(center + hours * 3600), + zone, + server_zone, + ); + let rows = { + let mut dao = self.exif_dao.lock().expect("Unable to lock ExifDao"); + match dao.query_by_exif(None, None, None, None, None, range) { + Ok(r) => r, + Err(e) => return format!("Error querying photos: {:?}", e), + } + }; + + // Closest first; the same bytes in two libraries list once. + let mut dated: Vec<(i64, crate::database::models::ImageExif)> = rows + .into_iter() + .filter_map(|r| { + crate::date_view::row_true_instant(&r, zone, server_zone).map(|t| (t, r)) + }) + .collect(); + dated.sort_by_key(|(t, _)| (t - center).abs()); + let mut seen = std::collections::HashSet::new(); + dated.retain(|(_, r)| match &r.content_hash { + Some(h) => seen.insert(h.clone()), + None => true, + }); + let total = dated.len(); + if total == 0 { + return format!("No photos within ±{} hours of that moment.", hours); + } + + let mut lines = Vec::new(); + for (_, row) in dated.into_iter().take(limit) { + let when = crate::date_view::row_display_wall(&row, zone, server_zone) + .map(|w| w.format("%Y-%m-%d %H:%M").to_string()) + .unwrap_or_else(|| "unknown time".to_string()); + let tags: Vec = { + let mut dao = self.tag_dao.lock().expect("Unable to lock TagDao"); + dao.get_tags_for_path(&row.file_path) + .map(|t| t.into_iter().map(|t| t.name).collect()) + .unwrap_or_default() + }; + let people: Vec = match &row.content_hash { + Some(hash) => { + let mut dao = self.face_dao.lock().expect("Unable to lock FaceDao"); + let mut names: Vec = dao + .list_for_content_hash(hash) + .map(|faces| faces.into_iter().filter_map(|f| f.person_name).collect()) + .unwrap_or_default(); + names.sort(); + names.dedup(); + names + } + None => Vec::new(), + }; + let mut line = format!("- {} — {}", row.file_path, when); + if !tags.is_empty() { + line.push_str(&format!(" · tags: {}", tags.join(", "))); + } + if !people.is_empty() { + line.push_str(&format!(" · people: {}", people.join(", "))); + } + if crate::file_types::is_video_file(std::path::Path::new(&row.file_path)) { + line.push_str(" · video"); + } + lines.push(line); + } + format!( + "{}\n{}", + Self::found_header(total, lines.len(), "photos"), + lines.join("\n") + ) + } + + /// Tool: look_at_photo — describe a library photo by path with the + /// local vision model. + async fn tool_look_at_photo(&self, args: &serde_json::Value, local: &dyn LlmClient) -> String { + let file_path = match args.get("file_path").and_then(|v| v.as_str()) { + Some(p) if Self::photo_path_is_safe(p.trim()) => p.trim().to_string(), + Some(_) => return "Error: file_path must be a library-relative path".to_string(), + None => return "Error: missing required parameter 'file_path'".to_string(), + }; + if crate::file_types::is_video_file(std::path::Path::new(&file_path)) { + return "Error: that's a video — only photos can be looked at.".to_string(); + } + let image_base64 = match self.load_image_as_base64(&file_path) { + Ok(b) => b, + Err(e) => return format!("Error loading photo: {}", e), + }; + match local.describe_image(&image_base64).await { + Ok(desc) => desc, + Err(e) => format!("Error describing photo: {}", e), + } + } + + /// A model-supplied path is joined onto each library root, so refuse + /// anything that could climb out of it. + fn photo_path_is_safe(path: &str) -> bool { + use std::path::{Component, Path}; + !path.is_empty() + && Path::new(path) + .components() + .all(|c| matches!(c, Component::Normal(_) | Component::CurDir)) + } + + /// A tool's centre moment: `timestamp` (unix seconds) wins, else + /// `datetime` read as a wall clock in the owner's display zone. A bare + /// date centres on midday. + fn parse_moment(args: &serde_json::Value, zone: chrono_tz::Tz) -> Option { + if let Some(ts) = args.get("timestamp").and_then(|v| v.as_i64()) { + return Some(ts); + } + let raw = args.get("datetime").and_then(|v| v.as_str())?.trim(); + let naive = chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%d %H:%M") + .or_else(|_| chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M")) + .or_else(|_| chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S")) + .ok() + .or_else(|| { + NaiveDate::parse_from_str(raw, "%Y-%m-%d") + .ok() + .and_then(|d| d.and_hms_opt(12, 0, 0)) + })?; + zone.from_local_datetime(&naive) + .earliest() + .map(|dt| dt.timestamp()) + } + + /// Decode arbitrary image bytes (an MMS attachment), shrink to the + /// vision models' 1024px working size and re-encode as base64 JPEG — + /// the same shape `load_image_as_base64` produces for library files. + fn encode_image_bytes_for_vision(bytes: &[u8]) -> Result { + use image::imageops::FilterType; + let img = image::load_from_memory(bytes)?; + // JPEG has no alpha channel; a PNG screenshot would fail to encode. + let resized = + image::DynamicImage::ImageRgb8(img.resize(1024, 1024, FilterType::Lanczos3).to_rgb8()); + let mut buffer = Vec::new(); + resized.write_to(&mut Cursor::new(&mut buffer), ImageFormat::Jpeg)?; + Ok(base64::engine::general_purpose::STANDARD.encode(&buffer)) + } + /// Tool: get_calendar_events — fetch calendar events near a date async fn tool_get_calendar_events(&self, args: &serde_json::Value) -> String { let date_str = match args.get("date").and_then(|v| v.as_str()) { @@ -3781,6 +4051,77 @@ Return ONLY the summary, nothing else."#, }), )); + if opts.sms_thread_tools { + tools.push(Tool::function( + "get_thread_messages", + "Read more of the SMS thread this chat was opened from. Pages from a message id in the \ + thread (the #numbers in the transcript) — `before` for older messages, `after` for what \ + came next, `around` for both sides. Lines carry their own #id, so call again from the \ + oldest/newest id to keep paging. \ + Example: `{contact_id: 42, message_id: 812, direction: \"before\", limit: 30}`.", + serde_json::json!({ + "type": "object", + "required": ["contact_id", "message_id"], + "properties": { + "contact_id": { "type": "integer", "description": "The thread's contact_id (given in the system prompt)." }, + "message_id": { "type": "integer", "description": "Message id to page from." }, + "direction": { "type": "string", "enum": ["before", "after", "around"], "description": "Which side of message_id to read (default before)." }, + "limit": { "type": "integer", "description": "Max messages (default 30, max 80)." } + } + }), + )); + + tools.push(Tool::function( + "look_at_message_image", + "Look at a photo that was sent in the SMS thread, using the local vision model. Returns a \ + visual description. Attachments appear in the transcript as `image #N` on a message line — \ + pass that message's id and N as media_index. Videos and audio can't be viewed. \ + Example: `{contact_id: 42, message_id: 815, media_index: 0}`.", + serde_json::json!({ + "type": "object", + "required": ["contact_id", "message_id"], + "properties": { + "contact_id": { "type": "integer", "description": "The thread's contact_id." }, + "message_id": { "type": "integer", "description": "Id of the message carrying the photo." }, + "media_index": { "type": "integer", "description": "Which attachment on that message (default 0)." } + } + }), + )); + + tools.push(Tool::function( + "get_photos_near_time", + "List photos and videos from the user's library taken around a moment, closest first, with \ + their user-applied tags and the names of people recognised in them. Use to see what was \ + happening around a message (pass its time). Follow up with `look_at_photo` to see one. \ + Pass either `timestamp` (unix seconds) or `datetime` (local, \"YYYY-MM-DD HH:MM\" or \ + \"YYYY-MM-DD\"). \ + Example: `{datetime: \"2019-06-12 18:30\", hours_radius: 4}`.", + serde_json::json!({ + "type": "object", + "properties": { + "timestamp": { "type": "integer", "description": "Centre moment, unix seconds." }, + "datetime": { "type": "string", "description": "Centre moment in local time, YYYY-MM-DD HH:MM (or YYYY-MM-DD for the whole day)." }, + "hours_radius": { "type": "integer", "description": "Hours before and after to include (default 3, max 72)." }, + "limit": { "type": "integer", "description": "Max photos (default 15, max 40)." } + } + }), + )); + + tools.push(Tool::function( + "look_at_photo", + "Look at one photo from the user's library with the local vision model and return a visual \ + description — people, place, activity, mood. Pass a file_path from `get_photos_near_time`. \ + Example: `{file_path: \"2019/06/IMG_4242.jpg\"}`.", + serde_json::json!({ + "type": "object", + "required": ["file_path"], + "properties": { + "file_path": { "type": "string", "description": "Library-relative path of the photo." } + } + }), + )); + } + if opts.has_vision { tools.push(Tool::function( "describe_photo", @@ -3915,7 +4256,8 @@ Return ONLY the summary, nothing else."#, && let Ok(n) = n_str.parse::() { let kind = match tool_name { - "search_messages" | "get_sms_messages" => "messages", + "search_messages" | "get_sms_messages" | "get_thread_messages" => "messages", + "get_photos_near_time" => "photos", "get_calendar_events" => "events", "get_location_history" => "location records", _ => "results", @@ -3964,6 +4306,10 @@ Return ONLY the summary, nothing else."#, .map(|id| format!("stored id={}", id.trim_end_matches(','))) .unwrap_or_else(|| "stored".to_string()), "get_current_datetime" => "time noted".to_string(), + "look_at_message_image" | "look_at_photo" => { + let short: String = raw.chars().take(80).collect(); + format!("looked: \"{}...\"", short) + } _ => format!("{} chars", raw.len()), } } @@ -4882,6 +5228,7 @@ mod tests { location_history_present: false, faces_present: false, allow_agent_corrections: false, + sms_thread_tools: false, }; let tools = InsightGenerator::build_tool_definitions(opts); let names: Vec<&str> = tools.iter().map(|t| t.function.name.as_str()).collect(); @@ -4908,6 +5255,11 @@ mod tests { // Agent-correction tools are absent without the gate. assert!(!names.contains(&"update_fact")); assert!(!names.contains(&"supersede_fact")); + // SMS-thread tools only exist for an anchored persona chat. + assert!(!names.contains(&"get_thread_messages")); + assert!(!names.contains(&"look_at_message_image")); + assert!(!names.contains(&"get_photos_near_time")); + assert!(!names.contains(&"look_at_photo")); } #[test] @@ -4920,6 +5272,7 @@ mod tests { location_history_present: true, faces_present: true, allow_agent_corrections: true, + sms_thread_tools: true, }; let tools = InsightGenerator::build_tool_definitions(opts); let names: Vec<&str> = tools.iter().map(|t| t.function.name.as_str()).collect(); @@ -4931,6 +5284,45 @@ mod tests { assert!(names.contains(&"get_faces_in_photo")); assert!(names.contains(&"update_fact")); assert!(names.contains(&"supersede_fact")); + assert!(names.contains(&"get_thread_messages")); + assert!(names.contains(&"look_at_message_image")); + assert!(names.contains(&"get_photos_near_time")); + assert!(names.contains(&"look_at_photo")); + } + + #[test] + fn photo_path_is_safe_rejects_escapes() { + assert!(InsightGenerator::photo_path_is_safe("2019/06/IMG_1.jpg")); + assert!(!InsightGenerator::photo_path_is_safe("../etc/passwd")); + assert!(!InsightGenerator::photo_path_is_safe("2019/../../x.jpg")); + assert!(!InsightGenerator::photo_path_is_safe("/etc/passwd")); + assert!(!InsightGenerator::photo_path_is_safe("")); + } + + #[test] + fn parse_moment_accepts_unix_and_local_wall_clock() { + let tz = chrono_tz::Tz::America__New_York; + assert_eq!( + InsightGenerator::parse_moment(&serde_json::json!({"timestamp": 1_700_000_000}), tz), + Some(1_700_000_000) + ); + // 2023-11-14 17:13 in New York (EST) = 22:13 UTC. + assert_eq!( + InsightGenerator::parse_moment( + &serde_json::json!({"datetime": "2023-11-14 17:13"}), + tz + ), + Some(1_699_999_980) + ); + // A bare date means midday, the least-wrong centre for a whole day. + assert_eq!( + InsightGenerator::parse_moment(&serde_json::json!({"datetime": "2023-11-14"}), tz), + Some(1_699_981_200) + ); + assert_eq!( + InsightGenerator::parse_moment(&serde_json::json!({}), tz), + None + ); } fn place(name: &str, description: &str) -> ApolloPlace { diff --git a/src/ai/mod.rs b/src/ai/mod.rs index d39645a..1a35364 100644 --- a/src/ai/mod.rs +++ b/src/ai/mod.rs @@ -15,6 +15,7 @@ pub mod ollama; pub mod openrouter; pub mod persona_chat; pub mod pronunciation; +pub mod sms_anchor; pub mod sms_client; pub mod tts; pub mod turn_registry; diff --git a/src/ai/persona_chat.rs b/src/ai/persona_chat.rs index 73be4dd..38bc62b 100644 --- a/src/ai/persona_chat.rs +++ b/src/ai/persona_chat.rs @@ -59,6 +59,9 @@ pub enum PersonaChatError { EmptyMessage, MessageTooLong, ConcurrentTurn, + /// "Continue with persona" couldn't read the SMS thread it was asked to + /// anchor on — SMS-API down, or the message isn't in that thread. + SmsThread(String), Db(anyhow::Error), } @@ -74,6 +77,7 @@ impl std::fmt::Display for PersonaChatError { PersonaChatError::ConcurrentTurn => { write!(f, "another turn for this persona is in flight") } + PersonaChatError::SmsThread(e) => write!(f, "could not read the SMS thread: {e}"), PersonaChatError::Db(e) => write!(f, "database error: {e}"), } } @@ -220,6 +224,36 @@ pub struct PersonaChatHistoryView { /// Empty for a conversation that has never been rewound. #[serde(skip_serializing_if = "Vec::is_empty")] pub fork_info: Vec>, + /// Where in an SMS thread this conversation picked up. Absent for an + /// open chat. + #[serde(skip_serializing_if = "Option::is_none")] + pub anchor: Option, +} + +/// Wire shape of a conversation's SMS anchor. Enough for the client to show +/// a "picked up from" banner and to jump back to the message in the thread; +/// the full snapshot stays server-side in the system prompt. +#[derive(Debug, Clone, Serialize)] +pub struct PersonaChatAnchorView { + pub contact_id: i64, + pub contact_name: String, + pub message_id: i64, + /// Unix seconds of the anchor message. + pub timestamp: i64, + /// The anchor message as "Speaker: text", recovered from the snapshot. + pub preview: String, +} + +impl From<&crate::database::SmsAnchor> for PersonaChatAnchorView { + fn from(a: &crate::database::SmsAnchor) -> Self { + PersonaChatAnchorView { + contact_id: a.contact_id, + contact_name: a.contact_name.clone(), + message_id: a.message_id, + timestamp: a.timestamp, + preview: crate::ai::sms_anchor::anchor_preview(&a.context), + } + } } /// One rendered line of the persona transcript. Mirrors the file chat's @@ -420,6 +454,9 @@ pub struct PersonaConversationSummary { /// Whether the conversation has been forked — the list marks these so a /// user can tell which transcripts have alternates parked in them. pub has_branches: bool, + /// SMS anchor for "Continue with persona" chats; absent for open chats. + #[serde(skip_serializing_if = "Option::is_none")] + pub anchor: Option, } impl PersonaChatHistoryView { @@ -435,6 +472,7 @@ impl PersonaChatHistoryView { active_leaf_id: 0, viewing_branch_id: 0, fork_info: Vec::new(), + anchor: None, } } } @@ -543,6 +581,67 @@ impl PersonaChatSession { .map_err(|e| PersonaChatError::Db(anyhow!("{e:?}"))) } + /// Start a conversation anchored on a message in an SMS thread + /// ("Continue with persona"). + /// + /// The thread leading up to the anchor is fetched once, here, and stored + /// as a rendered snapshot: every turn splices it into the system prompt, + /// so it has to be byte-stable (KV-cache prefix reuse) and has to + /// exist before the first turn. A thread that can't be read fails the + /// create outright — an anchored chat with no thread is just an open + /// chat with a misleading banner. + pub async fn create_anchored_conversation( + &self, + user_id: i32, + persona_id: &str, + contact_id: i64, + message_id: i64, + ) -> Result { + let persona_id = persona_id.trim(); + { + let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); + let persona = dao + .get_persona(user_id, persona_id) + .map_err(|e| PersonaChatError::Db(anyhow!("{e:?}")))?; + if persona.is_none() { + return Err(PersonaChatError::UnknownPersona(persona_id.to_string())); + } + } + + let window = self + .generator + .sms_client() + .fetch_thread_window( + contact_id, + message_id, + crate::ai::sms_anchor::ANCHOR_CONTEXT_BEFORE, + 0, + ) + .await + .map_err(|e| PersonaChatError::SmsThread(format!("{e}")))?; + let context = crate::ai::sms_anchor::render_thread_window( + &window, + &crate::ai::user_display_name(), + self.generator.display_zone(), + ); + let anchor = crate::database::SmsAnchor { + contact_id, + contact_name: window.contact.display_name(), + message_id, + timestamp: window.messages[window.anchor_index].timestamp, + context, + }; + + let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); + dao.create_anchored_persona_chat( + user_id, + persona_id, + Utc::now().timestamp_millis(), + &anchor, + ) + .map_err(|e| PersonaChatError::Db(anyhow!("{e:?}"))) + } + /// Delete a conversation and its transcript outright. pub fn delete_conversation(&self, user_id: i32, conversation_id: &str) -> Result<()> { let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); @@ -577,6 +676,7 @@ impl PersonaChatSession { conversation_id: row.conversation_id.clone(), persona_id: row.persona_id.clone(), title: row.title.clone(), + anchor: row.anchor.as_ref().map(PersonaChatAnchorView::from), ..PersonaChatHistoryView::empty() }; // A conversation holding only its seed system node renders as the @@ -748,6 +848,17 @@ impl PersonaChatSession { let system_prompt = resolve_persona_system_prompt(Some(&persona)) .unwrap_or_else(|| "You are a helpful assistant.".to_string()); + // An anchored chat carries the SMS thread in its system prompt, + // re-composed each turn like the persona prompt itself. + let system_prompt = match &row.anchor { + Some(anchor) => crate::ai::sms_anchor::anchored_system_prompt( + &system_prompt, + anchor, + &crate::ai::user_display_name(), + self.generator.display_zone(), + ), + None => system_prompt, + }; let stored = Some(decode_store(&row.messages_json)?); // `seeded` marks a conversation whose messages are NOT yet backed by @@ -818,10 +929,11 @@ impl PersonaChatSession { // Build a no-photo tool catalog. `current_gate_opts_for_persona` // already probes each tool's backing table for presence and // honours the persona's `allow_agent_corrections` toggle. - let gate_opts = self.generator.current_gate_opts_for_persona( + let mut gate_opts = self.generator.current_gate_opts_for_persona( backend.images_inline, Some((user_id, &persona.persona_id)), ); + gate_opts.sms_thread_tools = row.anchor.is_some(); let tools = InsightGenerator::build_tool_definitions(gate_opts); // Append the new user turn + apply the per-turn system-prompt @@ -981,7 +1093,14 @@ impl PersonaChatSession { /// 404), so listing them would only offer a dead-end tap target. /// Conversations that hold nothing but the seed are omitted too — the /// user has never actually said anything in them. - pub fn list_conversations(&self, user_id: i32) -> Result> { + /// + /// `contact_id` narrows the list to conversations anchored on that SMS + /// thread — the Messages screen's per-thread persona list. + pub fn list_conversations( + &self, + user_id: i32, + contact_id: Option, + ) -> Result> { let (rows, personas) = { let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); let rows = dao @@ -999,6 +1118,11 @@ impl PersonaChatSession { let mut out = Vec::with_capacity(rows.len()); for entry in rows { + if let Some(cid) = contact_id + && entry.anchor.as_ref().map(|a| a.contact_id) != Some(cid) + { + continue; + } let Some(persona_name) = names.get(&entry.persona_id).cloned() else { continue; }; @@ -1036,6 +1160,7 @@ impl PersonaChatSession { created_at: entry.created_at, updated_at: entry.updated_at, has_branches: store.leaves().len() > 1, + anchor: entry.anchor.as_ref().map(PersonaChatAnchorView::from), }); } Ok(out) @@ -1232,6 +1357,25 @@ pub struct PersonaChatHistoryQuery { #[derive(Debug, Deserialize)] pub struct PersonaChatCreateRequest { pub persona_id: String, + /// Anchor the conversation on a message in an SMS thread ("Continue + /// with persona"). Omitted for an open chat. + #[serde(default)] + pub anchor: Option, +} + +/// SMS-API ids of the thread and message to pick up from. +#[derive(Debug, Deserialize)] +pub struct PersonaChatAnchorRequest { + pub contact_id: i64, + pub message_id: i64, +} + +/// Query params for GET /persona_chat/conversations. +#[derive(Debug, Deserialize)] +pub struct PersonaChatListQuery { + /// Only conversations anchored on this SMS thread. + #[serde(default)] + pub contact_id: Option, } /// GET /persona_chat/history?persona_id=... — the rendered rolling @@ -1315,6 +1459,7 @@ pub async fn persona_chat_turn_handler( HttpResponse::BadRequest() } PersonaChatError::ConcurrentTurn => HttpResponse::Conflict(), + PersonaChatError::SmsThread(_) => HttpResponse::BadGateway(), PersonaChatError::Db(_) => { log::error!("persona chat dispatch failed: {e}"); HttpResponse::InternalServerError() @@ -1335,10 +1480,21 @@ pub async fn persona_chat_create_conversation_handler( app_state: web::Data, ) -> impl Responder { let user_id = claims.sub.parse::().unwrap_or(1); - match app_state - .persona_chat_session - .create_conversation(user_id, &request.persona_id) - { + let session = &app_state.persona_chat_session; + let created = match &request.anchor { + Some(anchor) => { + session + .create_anchored_conversation( + user_id, + &request.persona_id, + anchor.contact_id, + anchor.message_id, + ) + .await + } + None => session.create_conversation(user_id, &request.persona_id), + }; + match created { Ok(conversation_id) => HttpResponse::Created().json(serde_json::json!({ "conversation_id": conversation_id, })), @@ -1346,6 +1502,10 @@ pub async fn persona_chat_create_conversation_handler( PersonaChatError::UnknownPersona(_) => { HttpResponse::NotFound().json(serde_json::json!({ "error": format!("{e}") })) } + PersonaChatError::SmsThread(_) => { + log::warn!("anchored persona chat create failed: {e}"); + HttpResponse::BadGateway().json(serde_json::json!({ "error": format!("{e}") })) + } _ => { log::error!("persona chat create failed: {e}"); HttpResponse::InternalServerError() @@ -1384,10 +1544,14 @@ pub async fn persona_chat_delete_conversation_handler( #[get("/persona_chat/conversations")] pub async fn persona_chat_conversations_handler( claims: Claims, + query: web::Query, app_state: web::Data, ) -> impl Responder { let user_id = claims.sub.parse::().unwrap_or(1); - match app_state.persona_chat_session.list_conversations(user_id) { + match app_state + .persona_chat_session + .list_conversations(user_id, query.contact_id) + { Ok(conversations) => HttpResponse::Ok().json(serde_json::json!({ "conversations": conversations, })), @@ -1904,6 +2068,7 @@ mod tests { total: 2, node_id: 3, })], + anchor: None, }; let json = serde_json::to_value(&view).unwrap(); // The client's shared ChatHistoryView reads snake_case keys. @@ -2102,8 +2267,18 @@ mod tests { created_at: 1_600_000_000_000, updated_at: 1_700_000_000_000, has_branches: true, + anchor: Some(PersonaChatAnchorView { + contact_id: 42, + contact_name: "Sam".to_string(), + message_id: 812, + timestamp: 1_700_000_000, + preview: "Sam: see you at 6".to_string(), + }), }; let json = serde_json::to_value(&summary).unwrap(); + assert_eq!(json["anchor"]["contact_id"], 42); + assert_eq!(json["anchor"]["message_id"], 812); + assert_eq!(json["anchor"]["preview"], "Sam: see you at 6"); assert_eq!(json["conversation_id"], "conv-1"); assert_eq!(json["title"], "June recap"); assert_eq!(json["persona_id"], "journal"); diff --git a/src/ai/sms_anchor.rs b/src/ai/sms_anchor.rs new file mode 100644 index 0000000..d1be59a --- /dev/null +++ b/src/ai/sms_anchor.rs @@ -0,0 +1,303 @@ +//! "Continue with persona" — a persona chat anchored on an SMS thread. +//! +//! The user long-presses a message in the Messages screen and picks a +//! persona; the new persona conversation remembers the thread (contact) and +//! the message it picked up from. This module owns the pure parts of that: +//! +//! - rendering a slice of the thread as a compact transcript the model can +//! read (and that names message ids / attachment indices, so the thread +//! tools can page further back or look at a photo), +//! - the conversational guidance appended to the persona's system prompt. +//! +//! The network side (`SmsApiClient::fetch_thread_window`) and the tools +//! (`get_thread_messages`, `look_at_message_image`, `get_photos_near_time`, +//! `look_at_photo` in `insight_generator.rs`) live with their neighbours. + +use chrono::{DateTime, TimeZone}; +use chrono_tz::Tz; + +use crate::ai::sms_client::{ThreadMessage, ThreadWindow}; +use crate::database::SmsAnchor; + +/// Messages of history before the anchor captured into the system prompt. +/// Enough to pick up tone and the running topic; the model can page +/// further back with `get_thread_messages`. +pub const ANCHOR_CONTEXT_BEFORE: usize = 40; + +/// Per-message cap inside the rendered transcript. A pasted article or a +/// wall of text shouldn't crowd out the rest of the thread. +const LINE_MAX_CHARS: usize = 600; + +/// Render one thread message as a transcript line: +/// `[#812 · 2019-06-12 14:02] Sam: see you at 6 (image #0 "IMG_1.jpg")`. +/// +/// `#id` and the attachment index are exactly what the thread tools take, +/// so the model can act on any line it reads. +pub fn render_thread_line( + msg: &ThreadMessage, + contact_name: &str, + user_name: &str, + tz: Tz, +) -> String { + let ts = DateTime::from_timestamp(msg.timestamp, 0) + .map(|dt| { + tz.from_utc_datetime(&dt.naive_utc()) + .format("%Y-%m-%d %H:%M") + .to_string() + }) + .unwrap_or_else(|| "unknown time".to_string()); + let speaker = if msg.is_sent { user_name } else { contact_name }; + + let mut body = msg.body.split_whitespace().collect::>().join(" "); + if body.chars().count() > LINE_MAX_CHARS { + body = format!("{}…", body.chars().take(LINE_MAX_CHARS).collect::()); + } + let attachments: Vec = msg + .attachments + .iter() + .map(|a| { + let kind = a.content_type.split('/').next().unwrap_or("file"); + if a.filename.is_empty() { + format!("{kind} #{}", a.media_index) + } else { + format!("{kind} #{} \"{}\"", a.media_index, a.filename) + } + }) + .collect(); + + let mut line = format!("[#{} · {}] {}:", msg.id, ts, speaker); + if !body.is_empty() { + line.push(' '); + line.push_str(&body); + } + if !attachments.is_empty() { + line.push_str(&format!(" ({})", attachments.join(", "))); + } + line +} + +/// Render a thread window as a transcript, marking the anchor message with +/// `▶` so the model can tell where the user picked the thread up. +pub fn render_thread_window(window: &ThreadWindow, user_name: &str, tz: Tz) -> String { + let contact_name = window.contact.display_name(); + window + .messages + .iter() + .enumerate() + .map(|(i, m)| { + let line = render_thread_line(m, &contact_name, user_name, tz); + if i == window.anchor_index { + format!("▶ {line}") + } else { + line + } + }) + .collect::>() + .join("\n") +} + +/// The anchor message's text, recovered from the stored snapshot, for the +/// chat screen's "picked up from" banner and the list row. Reads the `▶` +/// line so no extra column (or SMS-API round-trip) is needed. +pub fn anchor_preview(context: &str) -> String { + context + .lines() + .find_map(|l| l.strip_prefix("▶ ")) + .map(|l| match l.split_once("] ") { + Some((_, rest)) => rest.to_string(), + None => l.to_string(), + }) + .unwrap_or_default() +} + +/// The persona's system prompt with the anchored-thread section appended. +/// +/// Re-composed every turn from the persona's current prompt and the stored +/// anchor snapshot, so editing a persona still reaches conversations already +/// in flight (as it does for open chats) while the thread snapshot itself +/// stays byte-stable. +pub fn anchored_system_prompt( + persona_prompt: &str, + anchor: &SmsAnchor, + user_name: &str, + tz: Tz, +) -> String { + let contact = if anchor.contact_name.trim().is_empty() { + "someone" + } else { + anchor.contact_name.as_str() + }; + let when = DateTime::from_timestamp(anchor.timestamp, 0) + .map(|dt| { + tz.from_utc_datetime(&dt.naive_utc()) + .format("%A, %B %-d, %Y at %-I:%M %p") + .to_string() + }) + .unwrap_or_else(|| "an unknown date".to_string()); + let thread = if anchor.context.trim().is_empty() { + "(The thread could not be loaded — use get_thread_messages to read it.)".to_string() + } else { + anchor.context.clone() + }; + let cid = anchor.contact_id; + let mid = anchor.message_id; + + format!( + "{persona_prompt} + +## Picking up a text thread + +{user_name} opened this chat from their text conversation with {contact}. \ +Below is that thread up to the message they picked (marked ▶, sent {when}). \ +They want to keep talking with you from that point — about what was going on, \ +what came of it, or just to carry on the conversation. + +How to talk here: +- Keep your own voice and values from above, but this is a chat, not a \ +write-up. Reply like a text message: short, warm, one or two thoughts at a \ +time. No headings, no bullet lists, no summaries unless asked. +- Match the thread's register — its slang, emoji and punctuation habits, how \ +long its messages run. If the thread is playful, be playful. +- You are not {contact}. Never write as them or put words in their mouth; you \ +are someone {user_name} trusts who has just read the thread over their shoulder. +- Lead with specifics from the thread — names, plans, inside jokes, open \ +questions. Don't recap the thread back to {user_name}. +- The thread is history: today's date is not {when}. If something was left \ +unresolved, it's natural to ask how it turned out. +- Don't invent details. When you need more than the thread below shows, look \ +it up with your tools first, then answer in your own words — don't narrate \ +the lookups. Look at one or two photos that matter, not every one you find. +- You can read and look, but you can't send photos or texts to anyone — \ +don't offer to. + +Tools for this thread (contact_id {cid}; #numbers below are message ids): +- get_thread_messages {{contact_id: {cid}, message_id: {mid}, direction: \"before\"}} \ +— earlier messages; \"after\" for what came next. +- look_at_message_image {{contact_id: {cid}, message_id, media_index}} — see a \ +photo sent in the thread (attachments are listed as `image #N`). +- get_photos_near_time {{timestamp}} — photos from {user_name}'s library taken \ +around a moment, with their tags and the people in them. +- look_at_photo {{file_path}} — see one of those library photos. + +Thread with {contact}: +{thread}" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ai::sms_client::{ThreadAttachment, ThreadContact}; + + fn msg(id: i64, body: &str, sent: bool) -> ThreadMessage { + ThreadMessage { + id, + body: body.to_string(), + // 2023-11-15 03:13 UTC = 2023-11-14 22:13 in New York. + timestamp: 1_700_018_000 - 1_000 + id, + is_sent: sent, + attachments: Vec::new(), + } + } + + #[test] + fn line_names_the_speaker_id_and_local_time() { + let line = render_thread_line( + &msg(1000, "on my way", true), + "Sam", + "Cam", + Tz::America__New_York, + ); + assert_eq!(line, "[#1000 · 2023-11-14 22:13] Cam: on my way"); + let line = render_thread_line(&msg(1000, "ok", false), "Sam", "Cam", Tz::America__New_York); + assert!(line.ends_with("] Sam: ok"), "{line}"); + } + + #[test] + fn line_lists_attachments_with_their_media_index() { + let mut m = msg(5, "", false); + m.attachments = vec![ + ThreadAttachment { + media_index: 0, + content_type: "image/jpeg".to_string(), + filename: "IMG_1.jpg".to_string(), + }, + ThreadAttachment { + media_index: 1, + content_type: "video/mp4".to_string(), + filename: String::new(), + }, + ]; + let line = render_thread_line(&m, "Sam", "Cam", Tz::UTC); + assert!( + line.ends_with("Sam: (image #0 \"IMG_1.jpg\", video #1)"), + "an empty body leaves just the attachments: {line}" + ); + } + + #[test] + fn line_flattens_and_caps_long_bodies() { + let long = format!("a\n\nb {}", "x".repeat(2000)); + let line = render_thread_line(&msg(1, &long, false), "Sam", "Cam", Tz::UTC); + assert!(!line.contains('\n')); + assert!(line.ends_with('…')); + assert!(line.chars().count() < LINE_MAX_CHARS + 60); + } + + #[test] + fn window_marks_only_the_anchor() { + let window = ThreadWindow { + contact: ThreadContact { + id: 3, + name: "Sam".to_string(), + address: String::new(), + }, + messages: vec![msg(1, "a", false), msg(2, "b", true), msg(3, "c", false)], + anchor_index: 1, + }; + let rendered = render_thread_window(&window, "Cam", Tz::UTC); + let lines: Vec<&str> = rendered.lines().collect(); + assert_eq!(lines.len(), 3); + assert!(!lines[0].starts_with('▶')); + assert!(lines[1].starts_with("▶ [#2")); + assert!(!lines[2].starts_with('▶')); + } + + #[test] + fn anchor_preview_reads_the_marked_line() { + let ctx = "[#1 · 2023-11-14 22:10] Cam: hi\n▶ [#2 · 2023-11-14 22:13] Sam: see you at 6\n"; + assert_eq!(anchor_preview(ctx), "Sam: see you at 6"); + assert_eq!(anchor_preview("no marker"), ""); + } + + #[test] + fn system_prompt_keeps_the_persona_first_and_carries_the_ids() { + let anchor = SmsAnchor { + contact_id: 42, + contact_name: "Sam".to_string(), + message_id: 812, + timestamp: 1_700_000_000, + context: "[#812 · …] Sam: see you there".to_string(), + }; + let prompt = anchored_system_prompt("You are Journal.", &anchor, "Cam", Tz::UTC); + assert!(prompt.starts_with("You are Journal.")); + assert!(prompt.contains("contact_id 42")); + assert!(prompt.contains("message_id: 812")); + assert!(prompt.contains("You are not Sam")); + assert!(prompt.ends_with("Sam: see you there")); + } + + #[test] + fn system_prompt_survives_a_missing_snapshot() { + let anchor = SmsAnchor { + contact_id: 42, + contact_name: String::new(), + message_id: 812, + timestamp: 0, + context: String::new(), + }; + let prompt = anchored_system_prompt("P", &anchor, "Cam", Tz::UTC); + assert!(prompt.contains("could not be loaded")); + assert!(prompt.contains("conversation with someone")); + } +} diff --git a/src/ai/sms_client.rs b/src/ai/sms_client.rs index d5e175f..48b66d4 100644 --- a/src/ai/sms_client.rs +++ b/src/ai/sms_client.rs @@ -320,6 +320,138 @@ impl SmsApiClient { Ok(data.results) } + /// One page of a contact's thread from SMS-API's conversation endpoint, + /// newest first. With `message_id`, SMS-API ignores `page` and returns + /// the page that contains that message. + pub async fn fetch_thread_page( + &self, + contact_id: i64, + message_id: Option, + page: usize, + page_size: usize, + ) -> Result { + let mut url = format!( + "{}/api/conversations/{}/?page={}&page_size={}", + self.base_url, contact_id, page, page_size + ); + if let Some(mid) = message_id { + url.push_str(&format!("&message_id={}", mid)); + } + let mut request = self.client.get(&url); + if let Some(token) = &self.token { + request = request.header("Authorization", format!("Bearer {}", token)); + } + let response = request.send().await?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!( + "SMS conversation request failed: {} - {}", + status, + body + )); + } + let raw: RawThreadPage = response.json().await?; + Ok(raw.into()) + } + + /// The slice of a thread around `anchor_message_id`: up to `before` + /// messages older than the anchor, the anchor itself, and up to `after` + /// newer ones — oldest first, ready to render as a transcript. + /// + /// SMS-API pages are fixed-size, so the anchor can sit at either edge of + /// the page that holds it; the neighbouring page is pulled in whenever + /// the requested side runs short. + pub async fn fetch_thread_window( + &self, + contact_id: i64, + anchor_message_id: i64, + before: usize, + after: usize, + ) -> Result { + let page_size = (before + after + 1).clamp(20, 200); + let first = self + .fetch_thread_page(contact_id, Some(anchor_message_id), 1, page_size) + .await?; + let contact = first.contact.clone(); + let Some(idx) = first + .messages + .iter() + .position(|m| m.id == anchor_message_id) + else { + return Err(anyhow::anyhow!( + "message {} not found in conversation {}", + anchor_message_id, + contact_id + )); + }; + + // Newest first: index < idx is newer, > idx is older. + let mut newer: Vec = first.messages[..idx].to_vec(); + let anchor = first.messages[idx].clone(); + let mut older: Vec = first.messages[idx + 1..].to_vec(); + + if older.len() < before && first.page < first.total_pages { + let next = self + .fetch_thread_page(contact_id, None, first.page + 1, page_size) + .await?; + older.extend(next.messages); + } + if newer.len() < after && first.page > 1 { + let prev = self + .fetch_thread_page(contact_id, None, first.page - 1, page_size) + .await?; + // The previous page is entirely newer; keep it newest-first so + // the closest messages stay at the end of `newer`. + let mut combined = prev.messages; + combined.extend(newer); + newer = combined; + } + + older.truncate(before); + older.reverse(); + let newer_start = newer.len().saturating_sub(after); + let mut newer: Vec = newer[newer_start..].to_vec(); + newer.reverse(); + + let mut messages = older; + let anchor_index = messages.len(); + messages.push(anchor); + messages.extend(newer); + Ok(ThreadWindow { + contact, + messages, + anchor_index, + }) + } + + /// Download one MMS attachment. `media_index` is the attachment's + /// position among the message's image/video/audio parts — the same + /// index SMS-API's `/media/{message}/{index}/file/` route takes. + pub async fn fetch_mms_media( + &self, + contact_id: i64, + message_id: i64, + media_index: usize, + ) -> Result> { + let url = format!( + "{}/api/conversations/{}/media/{}/{}/file/", + self.base_url, contact_id, message_id, media_index + ); + let mut request = self.client.get(&url); + if let Some(token) = &self.token { + request = request.header("Authorization", format!("Bearer {}", token)); + } + let response = request.send().await?; + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "MMS media request failed: {}", + response.status() + )); + } + Ok(response.bytes().await?.to_vec()) + } + pub async fn summarize_context( &self, messages: &[SmsMessage], @@ -372,6 +504,140 @@ pub struct SmsMessage { pub is_sent: bool, } +/// A thread participant as SMS-API's conversation endpoint describes it. +#[derive(Debug, Clone, Deserialize)] +pub struct ThreadContact { + pub id: i64, + #[serde(default)] + pub name: String, + #[serde(default)] + pub address: String, +} + +impl ThreadContact { + /// Name to show for the other side of the thread — the stored name, or + /// the phone number for a contact that was never named. + pub fn display_name(&self) -> String { + if self.name.trim().is_empty() { + self.address.clone() + } else { + self.name.clone() + } + } +} + +/// One message of a thread. `attachments` holds only the media parts +/// (image/video/audio); MMS text parts are folded into `body`. +#[derive(Debug, Clone)] +pub struct ThreadMessage { + pub id: i64, + pub body: String, + /// Unix seconds. + pub timestamp: i64, + pub is_sent: bool, + pub attachments: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ThreadAttachment { + /// Position among the message's media parts — the index + /// `fetch_mms_media` takes. + pub media_index: usize, + pub content_type: String, + pub filename: String, +} + +#[derive(Debug, Clone)] +pub struct ThreadPage { + pub contact: ThreadContact, + /// Newest first, as SMS-API returns them. + pub messages: Vec, + pub page: usize, + pub total_pages: usize, +} + +/// A slice of a thread around an anchor message, oldest first. +#[derive(Debug, Clone)] +pub struct ThreadWindow { + pub contact: ThreadContact, + pub messages: Vec, + /// Index of the anchor message within `messages`. + pub anchor_index: usize, +} + +#[derive(Deserialize)] +struct RawThreadPage { + contact: ThreadContact, + messages: Vec, + page: usize, + total_pages: usize, +} + +#[derive(Deserialize)] +struct RawThreadMessage { + id: i64, + #[serde(default)] + body: String, + date: i64, + #[serde(rename = "type")] + type_: i32, + #[serde(default)] + parts: Vec, +} + +#[derive(Deserialize)] +struct RawThreadPart { + content_type: String, + #[serde(default)] + filename: String, + #[serde(default)] + text: String, +} + +impl From for ThreadPage { + fn from(raw: RawThreadPage) -> Self { + ThreadPage { + contact: raw.contact, + messages: raw.messages.into_iter().map(ThreadMessage::from).collect(), + page: raw.page, + total_pages: raw.total_pages, + } + } +} + +impl From for ThreadMessage { + /// Media indices are counted the way SMS-API's media routes count them: + /// SMIL layout parts are skipped and only image/video/audio parts take + /// an index, in part order. + fn from(raw: RawThreadMessage) -> Self { + let mut attachments = Vec::new(); + let mut body = raw.body; + for part in raw.parts { + let ct = part.content_type.to_ascii_lowercase(); + if ct == "application/smil" { + continue; + } + if ct.starts_with("image/") || ct.starts_with("video/") || ct.starts_with("audio/") { + attachments.push(ThreadAttachment { + media_index: attachments.len(), + content_type: part.content_type, + filename: part.filename, + }); + } else if ct == "text/plain" && body.trim().is_empty() && !part.text.trim().is_empty() { + // MMS keeps its caption in a text part, leaving `body` empty. + body = part.text; + } + } + ThreadMessage { + id: raw.id, + body, + timestamp: raw.date, + is_sent: raw.type_ == 2, + attachments, + } + } +} + #[derive(Deserialize)] struct SmsApiResponse { messages: Vec, @@ -451,6 +717,46 @@ mod tests { assert_eq!(end - 7 * 86400, center); } + #[test] + fn thread_message_indexes_media_parts_like_sms_api() { + // SMIL is skipped and the text part only fills an empty body, so + // the image and video take media indices 0 and 1 — matching the + // `/media/{id}/{index}/file/` route. + let raw: RawThreadMessage = serde_json::from_value(serde_json::json!({ + "id": 7, + "body": "", + "date": 1_700_000_000, + "type": 2, + "parts": [ + { "content_type": "application/smil", "filename": "smil.xml" }, + { "content_type": "image/jpeg", "filename": "IMG_1.jpg" }, + { "content_type": "text/plain", "text": "look at this" }, + { "content_type": "video/mp4", "filename": "clip.mp4" } + ] + })) + .unwrap(); + let msg = ThreadMessage::from(raw); + assert!(msg.is_sent); + assert_eq!(msg.body, "look at this"); + assert_eq!( + msg.attachments + .iter() + .map(|a| (a.media_index, a.filename.as_str())) + .collect::>(), + vec![(0, "IMG_1.jpg"), (1, "clip.mp4")] + ); + } + + #[test] + fn thread_contact_falls_back_to_the_address() { + let c = ThreadContact { + id: 1, + name: " ".to_string(), + address: "+15551234".to_string(), + }; + assert_eq!(c.display_name(), "+15551234"); + } + #[test] fn window_for_radius_clamps_zero_to_one() { let (start, end) = SmsApiClient::window_for_radius(100_000, 0); diff --git a/src/database/mod.rs b/src/database/mod.rs index c5c87c1..d1d9ca2 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -64,7 +64,7 @@ pub use knowledge_dao::{ KnowledgeDao, PersonaFilter, RecentActivity, SqliteKnowledgeDao, }; pub use location_dao::{LocationHistoryDao, SqliteLocationHistoryDao}; -pub use persona_dao::{ImportPersona, PersonaDao, PersonaPatch, SqlitePersonaDao}; +pub use persona_dao::{ImportPersona, PersonaDao, PersonaPatch, SmsAnchor, SqlitePersonaDao}; pub use precomputed_reel_dao::{PrecomputedReelDao, SqlitePrecomputedReelDao}; pub use preview_dao::{PreviewDao, SqlitePreviewDao}; pub use search_dao::{SearchHistoryDao, SqliteSearchHistoryDao}; diff --git a/src/database/persona_dao.rs b/src/database/persona_dao.rs index 16d7976..d7c17c8 100644 --- a/src/database/persona_dao.rs +++ b/src/database/persona_dao.rs @@ -29,21 +29,92 @@ pub struct PersonaChatRow { pub turn_count: i32, pub created_at: i64, pub updated_at: i64, + /// The SMS thread position this conversation grew out of, for chats + /// started via "Continue with persona". None for an open chat. + pub anchor: Option, } -/// Column tuple → `PersonaChatRow`. Shared by the single-row and list -/// queries so their `select(...)` orders can never drift apart. +/// Where in an SMS thread an anchored persona conversation picks up. +/// +/// `contact_id` / `message_id` are SMS-API ids. `context` is the rendered +/// snapshot of the thread leading up to the anchor, captured once at +/// creation and spliced into the system prompt on every turn. +#[derive(Clone, Debug, PartialEq)] +pub struct SmsAnchor { + pub contact_id: i64, + pub contact_name: String, + pub message_id: i64, + /// Unix seconds of the anchor message. + pub timestamp: i64, + pub context: String, +} + +/// The column tuple every persona-chat read selects. Shared by the +/// single-row and list queries so their `select(...)` orders can never +/// drift apart. +type PersonaChatColumns = ( + String, + String, + String, + String, + i32, + i64, + i64, + Option, + Option, + Option, + Option, + Option, +); + +macro_rules! persona_chat_select { + () => { + ( + conversation_id, + persona_id, + title, + messages_json, + turn_count, + created_at, + updated_at, + anchor_contact_id, + anchor_contact_name, + anchor_message_id, + anchor_ts, + anchor_context, + ) + }; +} + +/// Column tuple → `PersonaChatRow`. fn persona_chat_row( - (conversation_id, persona_id, title, messages_json, turn_count, created_at, updated_at): ( - String, - String, - String, - String, - i32, - i64, - i64, - ), + ( + conversation_id, + persona_id, + title, + messages_json, + turn_count, + created_at, + updated_at, + anchor_contact_id, + anchor_contact_name, + anchor_message_id, + anchor_ts, + anchor_context, + ): PersonaChatColumns, ) -> PersonaChatRow { + // An anchor needs at least the thread and the message; the rest has a + // usable default if a row was written half-way by hand. + let anchor = match (anchor_contact_id, anchor_message_id) { + (Some(contact_id), Some(message_id)) => Some(SmsAnchor { + contact_id, + contact_name: anchor_contact_name.unwrap_or_default(), + message_id, + timestamp: anchor_ts.unwrap_or(0), + context: anchor_context.unwrap_or_default(), + }), + _ => None, + }; PersonaChatRow { conversation_id, persona_id, @@ -52,6 +123,7 @@ fn persona_chat_row( turn_count, created_at, updated_at, + anchor, } } @@ -135,6 +207,17 @@ pub trait PersonaDao: Sync + Send { created_at: i64, ) -> Result; + /// Start a new conversation anchored on a point in an SMS thread + /// ("Continue with persona"). Same as `create_persona_chat` plus the + /// anchor columns. + fn create_anchored_persona_chat( + &mut self, + user_id: i32, + persona_id: &str, + created_at: i64, + anchor: &SmsAnchor, + ) -> Result; + /// Replace a conversation's transcript. Called once per completed turn /// with the full new `messages_json`; `turn_count` is the cumulative /// assistant-turn count on the resulting active branch. Returns the @@ -382,16 +465,8 @@ impl PersonaDao for SqlitePersonaDao { // Scoped by user as well as id: the id is a bearer token for // someone's transcript, so it never grants access on its own. .filter(user_id.eq(uid)) - .select(( - conversation_id, - persona_id, - title, - messages_json, - turn_count, - created_at, - updated_at, - )) - .first::<(String, String, String, String, i32, i64, i64)>(conn.deref_mut()) + .select(persona_chat_select!()) + .first::(conn.deref_mut()) .optional() .map(|opt| opt.map(persona_chat_row)) .map_err(|e| anyhow::anyhow!("Query error: {}", e)) @@ -406,16 +481,8 @@ impl PersonaDao for SqlitePersonaDao { persona_chat_conversations .filter(user_id.eq(uid)) .order(updated_at.desc()) - .select(( - conversation_id, - persona_id, - title, - messages_json, - turn_count, - created_at, - updated_at, - )) - .load::<(String, String, String, String, i32, i64, i64)>(conn.deref_mut()) + .select(persona_chat_select!()) + .load::(conn.deref_mut()) .map(|rows| rows.into_iter().map(persona_chat_row).collect()) .map_err(|e| anyhow::anyhow!("Query error: {}", e)) }) @@ -452,6 +519,40 @@ impl PersonaDao for SqlitePersonaDao { .map_err(|e| DbError::log(DbErrorKind::InsertError, e)) } + fn create_anchored_persona_chat( + &mut self, + uid: i32, + pid: &str, + created: i64, + anchor: &SmsAnchor, + ) -> Result { + trace_db_call("insert", "create_anchored_persona_chat", |_span| { + use schema::persona_chat_conversations::dsl::*; + let mut conn = self.connection.lock().expect("PersonaDao lock"); + let new_id = uuid::Uuid::new_v4().to_string(); + diesel::insert_into(persona_chat_conversations) + .values(( + conversation_id.eq(&new_id), + user_id.eq(uid), + persona_id.eq(pid), + title.eq(""), + messages_json.eq("[]"), + turn_count.eq(0), + created_at.eq(created), + updated_at.eq(created), + anchor_contact_id.eq(Some(anchor.contact_id)), + anchor_contact_name.eq(Some(&anchor.contact_name)), + anchor_message_id.eq(Some(anchor.message_id)), + anchor_ts.eq(Some(anchor.timestamp)), + anchor_context.eq(Some(&anchor.context)), + )) + .execute(conn.deref_mut()) + .map_err(|e| anyhow::anyhow!("Insert error: {}", e))?; + Ok(new_id) + }) + .map_err(|e| DbError::log(DbErrorKind::InsertError, e)) + } + fn update_persona_chat( &mut self, uid: i32, @@ -766,6 +867,34 @@ mod tests { ); } + #[test] + fn persona_chat_anchor_round_trips_and_open_chats_have_none() { + let (mut dao, uid) = dao_with_user("p10"); + let anchor = SmsAnchor { + contact_id: 42, + contact_name: "Sam".to_string(), + message_id: 9001, + timestamp: 1_700_000_000, + context: "[2023-11-14 22:13] Sam: see you there".to_string(), + }; + let anchored = dao + .create_anchored_persona_chat(uid, "journal", 100, &anchor) + .unwrap(); + let open = dao.create_persona_chat(uid, "journal", 200).unwrap(); + + let row = dao.get_persona_chat(uid, &anchored).unwrap().unwrap(); + assert_eq!(row.anchor, Some(anchor)); + let row = dao.get_persona_chat(uid, &open).unwrap().unwrap(); + assert_eq!(row.anchor, None); + assert!( + dao.list_persona_chats(uid) + .unwrap() + .iter() + .any(|r| r.anchor.is_some()), + "the list query carries the anchor too" + ); + } + #[test] fn persona_chat_list_is_empty_for_a_user_who_has_never_chatted() { let (mut dao, uid) = dao_with_user("p8"); diff --git a/src/database/schema.rs b/src/database/schema.rs index 7924c3c..08080e1 100644 --- a/src/database/schema.rs +++ b/src/database/schema.rs @@ -197,6 +197,11 @@ diesel::table! { turn_count -> Integer, created_at -> BigInt, updated_at -> BigInt, + anchor_contact_id -> Nullable, + anchor_contact_name -> Nullable, + anchor_message_id -> Nullable, + anchor_ts -> Nullable, + anchor_context -> Nullable, } } From 3f1bf42f1c3fca88eb318e557a2105f862a091f0 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Sat, 17 Oct 2026 18:39:27 -0400 Subject: [PATCH 2/8] =?UTF-8?q?feat(persona-chat):=20practice=20mode=20?= =?UTF-8?q?=E2=80=94=20play=20the=20contact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An anchored conversation now has a mode. 'about' is the existing behaviour (a persona talks with the user about the thread). 'as_contact' has the model play the contact so the user can rehearse the conversation: its own prompt (stay in the contact's voice, don't invent facts, coach on 'out of character'), no persona prompt, and only the thread tools — no memory, library, calendar or other-thread access, and no knowledge writes. The shared persona/file-chat loop now refuses tool calls that weren't in the turn's catalog, so the gates hold even when a model names a tool it wasn't offered. Co-Authored-By: Claude Opus 5.5 --- .../down.sql | 1 + .../up.sql | 5 + src/ai/insight_chat.rs | 38 +++- src/ai/persona_chat.rs | 36 ++++ src/ai/sms_anchor.rs | 180 ++++++++++++++++-- src/database/mod.rs | 4 +- src/database/persona_dao.rs | 48 +++++ src/database/schema.rs | 1 + 8 files changed, 284 insertions(+), 29 deletions(-) diff --git a/migrations/2026-10-19-000000_persona_chat_sms_anchor/down.sql b/migrations/2026-10-19-000000_persona_chat_sms_anchor/down.sql index 4f3d254..7d3f23e 100644 --- a/migrations/2026-10-19-000000_persona_chat_sms_anchor/down.sql +++ b/migrations/2026-10-19-000000_persona_chat_sms_anchor/down.sql @@ -1,4 +1,5 @@ DROP INDEX IF EXISTS idx_persona_chat_anchor_contact; +ALTER TABLE persona_chat_conversations DROP COLUMN anchor_mode; ALTER TABLE persona_chat_conversations DROP COLUMN anchor_context; ALTER TABLE persona_chat_conversations DROP COLUMN anchor_ts; ALTER TABLE persona_chat_conversations DROP COLUMN anchor_message_id; diff --git a/migrations/2026-10-19-000000_persona_chat_sms_anchor/up.sql b/migrations/2026-10-19-000000_persona_chat_sms_anchor/up.sql index 2ccc5e9..ba8cd82 100644 --- a/migrations/2026-10-19-000000_persona_chat_sms_anchor/up.sql +++ b/migrations/2026-10-19-000000_persona_chat_sms_anchor/up.sql @@ -11,6 +11,10 @@ -- prefix byte-stable for KV-cache reuse and keeps the persona's view of the -- thread fixed even if SMS-API is re-imported underneath it. -- +-- `anchor_mode` is how the chat relates to the thread: 'about' (a persona +-- talks with the user about it) or 'as_contact' (the model plays the +-- contact so the user can practise the conversation). NULL reads as 'about'. +-- -- All nullable: an unanchored (open) persona chat leaves them NULL. ALTER TABLE persona_chat_conversations ADD COLUMN anchor_contact_id BIGINT; @@ -18,6 +22,7 @@ ALTER TABLE persona_chat_conversations ADD COLUMN anchor_contact_name TEXT; ALTER TABLE persona_chat_conversations ADD COLUMN anchor_message_id BIGINT; ALTER TABLE persona_chat_conversations ADD COLUMN anchor_ts BIGINT; ALTER TABLE persona_chat_conversations ADD COLUMN anchor_context TEXT; +ALTER TABLE persona_chat_conversations ADD COLUMN anchor_mode TEXT; CREATE INDEX idx_persona_chat_anchor_contact ON persona_chat_conversations (user_id, anchor_contact_id, updated_at DESC); diff --git a/src/ai/insight_chat.rs b/src/ai/insight_chat.rs index 41c4599..2cad73b 100644 --- a/src/ai/insight_chat.rs +++ b/src/ai/insight_chat.rs @@ -2466,6 +2466,13 @@ pub async fn run_streaming_agentic_loop_with_entry( let mut last_prompt_eval_count: Option = None; let mut last_eval_count: Option = None; let mut final_content = String::new(); + // Only tools in this turn's catalog may run. The catalog is how every + // gate (per-persona corrections, practice mode's narrowed set, absent + // backing tables) is expressed, and `execute_tool` dispatches on name + // alone — without this a model that names a tool it was never offered + // (one it saw in an earlier turn, or simply guessed) would still run it. + let offered: std::collections::HashSet = + tools.iter().map(|t| t.function.name.clone()).collect(); for iteration in 0..max_iterations { if !entry.is_running() { @@ -2536,17 +2543,28 @@ pub async fn run_streaming_agentic_loop_with_entry( arguments: tool_call.function.arguments.clone(), }) .await; - let result = generator - .execute_tool( - &tool_call.function.name, - &tool_call.function.arguments, - backend, - image_base64, - normalized, - user_id, - active_persona, + let result = if offered.contains(&tool_call.function.name) { + generator + .execute_tool( + &tool_call.function.name, + &tool_call.function.arguments, + backend, + image_base64, + normalized, + user_id, + active_persona, + ) + .await + } else { + log::warn!( + "refusing tool '{}': not offered this turn", + tool_call.function.name + ); + format!( + "Error: tool '{}' is not available here.", + tool_call.function.name ) - .await; + }; let (result_preview, result_truncated) = truncate_tool_result(&result); let _ = entry .push_event(ChatStreamEvent::ToolResult { diff --git a/src/ai/persona_chat.rs b/src/ai/persona_chat.rs index 38bc62b..f52c849 100644 --- a/src/ai/persona_chat.rs +++ b/src/ai/persona_chat.rs @@ -242,6 +242,9 @@ pub struct PersonaChatAnchorView { pub timestamp: i64, /// The anchor message as "Speaker: text", recovered from the snapshot. pub preview: String, + /// `about` (a persona discusses the thread) or `as_contact` (the model + /// plays the contact for practice). + pub mode: crate::database::AnchorMode, } impl From<&crate::database::SmsAnchor> for PersonaChatAnchorView { @@ -252,6 +255,7 @@ impl From<&crate::database::SmsAnchor> for PersonaChatAnchorView { message_id: a.message_id, timestamp: a.timestamp, preview: crate::ai::sms_anchor::anchor_preview(&a.context), + mode: a.mode, } } } @@ -596,6 +600,7 @@ impl PersonaChatSession { persona_id: &str, contact_id: i64, message_id: i64, + mode: crate::database::AnchorMode, ) -> Result { let persona_id = persona_id.trim(); { @@ -630,6 +635,7 @@ impl PersonaChatSession { message_id, timestamp: window.messages[window.anchor_index].timestamp, context, + mode, }; let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); @@ -935,6 +941,12 @@ impl PersonaChatSession { ); gate_opts.sms_thread_tools = row.anchor.is_some(); let tools = InsightGenerator::build_tool_definitions(gate_opts); + // Practice mode narrows the catalog to the thread itself — no + // memory, library or calendar access for a simulated contact. + let tools = match &row.anchor { + Some(anchor) => crate::ai::sms_anchor::tools_for_mode(tools, anchor.mode), + None => tools, + }; // Append the new user turn + apply the per-turn system-prompt // override. Per-persona overrides are NOT persisted (the persona @@ -1368,6 +1380,9 @@ pub struct PersonaChatCreateRequest { pub struct PersonaChatAnchorRequest { pub contact_id: i64, pub message_id: i64, + /// `about` (default) or `as_contact`. + #[serde(default)] + pub mode: crate::database::AnchorMode, } /// Query params for GET /persona_chat/conversations. @@ -1489,6 +1504,7 @@ pub async fn persona_chat_create_conversation_handler( &request.persona_id, anchor.contact_id, anchor.message_id, + anchor.mode, ) .await } @@ -2018,6 +2034,24 @@ mod tests { assert!(tracker.claim("conv-a", "t7").is_ok()); } + #[test] + fn create_request_anchor_mode_defaults_to_about() { + use crate::database::AnchorMode; + let req: PersonaChatCreateRequest = serde_json::from_str( + r#"{"persona_id":"journal","anchor":{"contact_id":42,"message_id":812}}"#, + ) + .unwrap(); + assert_eq!(req.anchor.unwrap().mode, AnchorMode::About); + let req: PersonaChatCreateRequest = serde_json::from_str( + r#"{"persona_id":"journal","anchor":{"contact_id":42,"message_id":812,"mode":"as_contact"}}"#, + ) + .unwrap(); + assert_eq!(req.anchor.unwrap().mode, AnchorMode::AsContact); + let req: PersonaChatCreateRequest = + serde_json::from_str(r#"{"persona_id":"journal"}"#).unwrap(); + assert!(req.anchor.is_none()); + } + #[test] fn persona_chat_turn_request_deserializes_snake_case_wire_body() { // Pins the mobile-client contract: snake_case keys, same as the @@ -2273,12 +2307,14 @@ mod tests { message_id: 812, timestamp: 1_700_000_000, preview: "Sam: see you at 6".to_string(), + mode: crate::database::AnchorMode::AsContact, }), }; let json = serde_json::to_value(&summary).unwrap(); assert_eq!(json["anchor"]["contact_id"], 42); assert_eq!(json["anchor"]["message_id"], 812); assert_eq!(json["anchor"]["preview"], "Sam: see you at 6"); + assert_eq!(json["anchor"]["mode"], "as_contact"); assert_eq!(json["conversation_id"], "conv-1"); assert_eq!(json["title"], "June recap"); assert_eq!(json["persona_id"], "journal"); diff --git a/src/ai/sms_anchor.rs b/src/ai/sms_anchor.rs index d1be59a..65018f3 100644 --- a/src/ai/sms_anchor.rs +++ b/src/ai/sms_anchor.rs @@ -7,7 +7,10 @@ //! - rendering a slice of the thread as a compact transcript the model can //! read (and that names message ids / attachment indices, so the thread //! tools can page further back or look at a photo), -//! - the conversational guidance appended to the persona's system prompt. +//! - the system prompt for each [`AnchorMode`]: a persona talking with the +//! user *about* the thread, or the model playing the contact so the user +//! can practise the conversation, +//! - which tools each mode is offered. //! //! The network side (`SmsApiClient::fetch_thread_window`) and the tools //! (`get_thread_messages`, `look_at_message_image`, `get_photos_near_time`, @@ -16,8 +19,17 @@ use chrono::{DateTime, TimeZone}; use chrono_tz::Tz; +use crate::ai::ollama::Tool; use crate::ai::sms_client::{ThreadMessage, ThreadWindow}; -use crate::database::SmsAnchor; +use crate::database::{AnchorMode, SmsAnchor}; + +/// Everything a simulated contact may call: the thread they were part of +/// and the photos sent in it. Nothing from the user's own life — library, +/// memories, calendar, location, other threads — which the real contact +/// would never see, and no knowledge writes: facts recorded while the model +/// is improvising someone's side of a conversation would be fiction filed +/// as memory. +const PRACTICE_TOOLS: &[&str] = &["get_thread_messages", "look_at_message_image"]; /// Messages of history before the anchor captured into the system prompt. /// Enough to pick up tone and the running topic; the model can page @@ -110,22 +122,32 @@ pub fn anchor_preview(context: &str) -> String { .unwrap_or_default() } -/// The persona's system prompt with the anchored-thread section appended. -/// -/// Re-composed every turn from the persona's current prompt and the stored -/// anchor snapshot, so editing a persona still reaches conversations already -/// in flight (as it does for open chats) while the thread snapshot itself -/// stays byte-stable. -pub fn anchored_system_prompt( - persona_prompt: &str, - anchor: &SmsAnchor, - user_name: &str, - tz: Tz, -) -> String { +/// The tool catalog narrowed to what `mode` may use. `About` keeps the full +/// persona catalog; `AsContact` keeps only [`PRACTICE_TOOLS`]. +pub fn tools_for_mode(tools: Vec, mode: AnchorMode) -> Vec { + match mode { + AnchorMode::About => tools, + AnchorMode::AsContact => tools + .into_iter() + .filter(|t| PRACTICE_TOOLS.contains(&t.function.name.as_str())) + .collect(), + } +} + +/// The values both prompts interpolate. +struct PromptParts { + contact: String, + when: String, + thread: String, + cid: i64, + mid: i64, +} + +fn prompt_parts(anchor: &SmsAnchor, tz: Tz) -> PromptParts { let contact = if anchor.contact_name.trim().is_empty() { - "someone" + "someone".to_string() } else { - anchor.contact_name.as_str() + anchor.contact_name.clone() }; let when = DateTime::from_timestamp(anchor.timestamp, 0) .map(|dt| { @@ -139,9 +161,44 @@ pub fn anchored_system_prompt( } else { anchor.context.clone() }; - let cid = anchor.contact_id; - let mid = anchor.message_id; + PromptParts { + contact, + when, + thread, + cid: anchor.contact_id, + mid: anchor.message_id, + } +} +/// The system prompt for an anchored conversation. +/// +/// Re-composed every turn from the persona's current prompt and the stored +/// anchor snapshot, so editing a persona still reaches conversations already +/// in flight (as it does for open chats) while the thread snapshot itself +/// stays byte-stable. Practice mode doesn't use the persona prompt at all: +/// the model is the contact, and a persona's voice would bleed into theirs. +pub fn anchored_system_prompt( + persona_prompt: &str, + anchor: &SmsAnchor, + user_name: &str, + tz: Tz, +) -> String { + let parts = prompt_parts(anchor, tz); + match anchor.mode { + AnchorMode::About => about_prompt(persona_prompt, &parts, user_name), + AnchorMode::AsContact => practice_prompt(&parts, user_name), + } +} + +/// A persona talking with the user about the thread. +fn about_prompt(persona_prompt: &str, parts: &PromptParts, user_name: &str) -> String { + let PromptParts { + contact, + when, + thread, + cid, + mid, + } = parts; format!( "{persona_prompt} @@ -184,6 +241,35 @@ Thread with {contact}: ) } +/// The model plays the contact so the user can rehearse the conversation. +fn practice_prompt(parts: &PromptParts, user_name: &str) -> String { + let PromptParts { + contact, + when, + thread, + cid, + mid, + } = parts; + format!( + "You are playing {contact} in a text conversation with {user_name}, so {user_name} can practise picking the conversation back up. This is a private rehearsal for {user_name}; the real {contact} is not part of it. + +Below is the real thread up to the message marked ▶ (sent {when}). Carry on from there as {contact}: {user_name}'s messages to you are their next texts, and every reply you write is {contact}'s next text. + +How to play it: +- Sound like {contact} does in the thread — their message length, slang, emoji, punctuation, humour, how fast they get to the point. Mirror them, not a generic texter. +- Write only the text itself. No narration, no stage directions, no 'As {contact}, I\'d say…'. Separate several short texts with a blank line. +- Stay true to what {contact} knows and feels in the thread. Don't invent big facts about their life — jobs, relationships, events — that the thread doesn't support; if asked about something they couldn't know, answer vaguely or deflect the way they plausibly would. +- React like a real person: it's fine to be busy, short, unconvinced, or to ask something back. Don't be more agreeable than {contact} actually is in the thread. +- It is shortly after the ▶ message unless {user_name} says time has passed. +- If {user_name} steps out of the rehearsal ('pause', 'out of character', 'how did that sound?'), drop the role and answer briefly as a coach: honest, specific feedback on how their texts are landing. Go back to being {contact} when they carry on. + +If you need to remember an earlier part of the thread, use get_thread_messages {{contact_id: {cid}, message_id: {mid}, direction: \"before\"}}; to see a photo sent in it (`image #N` on a line), use look_at_message_image {{contact_id: {cid}, message_id, media_index}}. Never mention either. + +Thread between {user_name} and {contact}: +{thread}" + ) +} + #[cfg(test)] mod tests { use super::*; @@ -278,6 +364,7 @@ mod tests { message_id: 812, timestamp: 1_700_000_000, context: "[#812 · …] Sam: see you there".to_string(), + mode: AnchorMode::About, }; let prompt = anchored_system_prompt("You are Journal.", &anchor, "Cam", Tz::UTC); assert!(prompt.starts_with("You are Journal.")); @@ -295,9 +382,66 @@ mod tests { message_id: 812, timestamp: 0, context: String::new(), + mode: AnchorMode::About, }; let prompt = anchored_system_prompt("P", &anchor, "Cam", Tz::UTC); assert!(prompt.contains("could not be loaded")); assert!(prompt.contains("conversation with someone")); } + + #[test] + fn practice_prompt_plays_the_contact_and_drops_the_persona() { + let anchor = SmsAnchor { + contact_id: 42, + contact_name: "Sam".to_string(), + message_id: 812, + timestamp: 1_700_000_000, + context: "▶ [#812 · …] Sam: see you there".to_string(), + mode: AnchorMode::AsContact, + }; + let prompt = anchored_system_prompt("You are Journal.", &anchor, "Cam", Tz::UTC); + assert!(prompt.starts_with("You are playing Sam")); + assert!( + !prompt.contains("You are Journal."), + "a persona voice would bleed into the contact's" + ); + assert!(prompt.contains("contact_id: 42")); + assert!(prompt.contains("out of character")); + assert!(prompt.ends_with("Sam: see you there")); + } + + fn tool(name: &str) -> Tool { + Tool::function(name, "", serde_json::json!({"type": "object"})) + } + + #[test] + fn practice_mode_keeps_only_the_thread_tools() { + let all = || { + [ + "get_thread_messages", + "look_at_message_image", + "get_photos_near_time", + "look_at_photo", + "recall_entities", + "store_fact", + "search_messages", + "get_calendar_events", + ] + .into_iter() + .map(tool) + .collect::>() + }; + let names = |tools: Vec| -> Vec { + tools.into_iter().map(|t| t.function.name).collect() + }; + assert_eq!( + names(tools_for_mode(all(), AnchorMode::AsContact)), + vec!["get_thread_messages", "look_at_message_image"] + ); + assert_eq!( + names(tools_for_mode(all(), AnchorMode::About)).len(), + 8, + "talking about the thread keeps the full catalog, memory tools included" + ); + } } diff --git a/src/database/mod.rs b/src/database/mod.rs index d1d9ca2..3fe7468 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -64,7 +64,9 @@ pub use knowledge_dao::{ KnowledgeDao, PersonaFilter, RecentActivity, SqliteKnowledgeDao, }; pub use location_dao::{LocationHistoryDao, SqliteLocationHistoryDao}; -pub use persona_dao::{ImportPersona, PersonaDao, PersonaPatch, SmsAnchor, SqlitePersonaDao}; +pub use persona_dao::{ + AnchorMode, ImportPersona, PersonaDao, PersonaPatch, SmsAnchor, SqlitePersonaDao, +}; pub use precomputed_reel_dao::{PrecomputedReelDao, SqlitePrecomputedReelDao}; pub use preview_dao::{PreviewDao, SqlitePreviewDao}; pub use search_dao::{SearchHistoryDao, SqliteSearchHistoryDao}; diff --git a/src/database/persona_dao.rs b/src/database/persona_dao.rs index d7c17c8..7138f03 100644 --- a/src/database/persona_dao.rs +++ b/src/database/persona_dao.rs @@ -47,6 +47,37 @@ pub struct SmsAnchor { /// Unix seconds of the anchor message. pub timestamp: i64, pub context: String, + pub mode: AnchorMode, +} + +/// How an anchored conversation relates to its SMS thread. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AnchorMode { + /// A persona talks with the user about the thread. + #[default] + About, + /// The model plays the contact so the user can practise the + /// conversation. + AsContact, +} + +impl AnchorMode { + pub fn as_str(self) -> &'static str { + match self { + AnchorMode::About => "about", + AnchorMode::AsContact => "as_contact", + } + } + + /// Stored value → mode. NULL and anything unrecognised read as `About`, + /// the behaviour rows had before the column existed. + pub fn from_column(value: Option<&str>) -> Self { + match value { + Some("as_contact") => AnchorMode::AsContact, + _ => AnchorMode::About, + } + } } /// The column tuple every persona-chat read selects. Shared by the @@ -65,6 +96,7 @@ type PersonaChatColumns = ( Option, Option, Option, + Option, ); macro_rules! persona_chat_select { @@ -82,6 +114,7 @@ macro_rules! persona_chat_select { anchor_message_id, anchor_ts, anchor_context, + anchor_mode, ) }; } @@ -101,6 +134,7 @@ fn persona_chat_row( anchor_message_id, anchor_ts, anchor_context, + anchor_mode, ): PersonaChatColumns, ) -> PersonaChatRow { // An anchor needs at least the thread and the message; the rest has a @@ -112,6 +146,7 @@ fn persona_chat_row( message_id, timestamp: anchor_ts.unwrap_or(0), context: anchor_context.unwrap_or_default(), + mode: AnchorMode::from_column(anchor_mode.as_deref()), }), _ => None, }; @@ -545,6 +580,7 @@ impl PersonaDao for SqlitePersonaDao { anchor_message_id.eq(Some(anchor.message_id)), anchor_ts.eq(Some(anchor.timestamp)), anchor_context.eq(Some(&anchor.context)), + anchor_mode.eq(Some(anchor.mode.as_str())), )) .execute(conn.deref_mut()) .map_err(|e| anyhow::anyhow!("Insert error: {}", e))?; @@ -876,6 +912,7 @@ mod tests { message_id: 9001, timestamp: 1_700_000_000, context: "[2023-11-14 22:13] Sam: see you there".to_string(), + mode: AnchorMode::AsContact, }; let anchored = dao .create_anchored_persona_chat(uid, "journal", 100, &anchor) @@ -895,6 +932,17 @@ mod tests { ); } + #[test] + fn anchor_mode_reads_null_and_unknown_as_about() { + assert_eq!(AnchorMode::from_column(None), AnchorMode::About); + assert_eq!(AnchorMode::from_column(Some("weird")), AnchorMode::About); + assert_eq!(AnchorMode::from_column(Some("about")), AnchorMode::About); + assert_eq!( + AnchorMode::from_column(Some(AnchorMode::AsContact.as_str())), + AnchorMode::AsContact + ); + } + #[test] fn persona_chat_list_is_empty_for_a_user_who_has_never_chatted() { let (mut dao, uid) = dao_with_user("p8"); diff --git a/src/database/schema.rs b/src/database/schema.rs index 08080e1..e2d4f7e 100644 --- a/src/database/schema.rs +++ b/src/database/schema.rs @@ -202,6 +202,7 @@ diesel::table! { anchor_message_id -> Nullable, anchor_ts -> Nullable, anchor_context -> Nullable, + anchor_mode -> Nullable, } } From 7215c1bdfeeee64991152c21a53d440586526893 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Sun, 18 Oct 2026 20:17:03 -0400 Subject: [PATCH 3/8] fix(persona-chat): don't offer describe_photo in persona chats describe_photo describes the file chat's photo. Persona chats have no photo, so a vision-capable model was offered a tool that could only answer "No image available". Persona chats now build their catalog with the vision gate off; the thread tools still cover looking at images there. Co-Authored-By: Claude Opus 5.5 --- src/ai/persona_chat.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/ai/persona_chat.rs b/src/ai/persona_chat.rs index f52c849..e8910ea 100644 --- a/src/ai/persona_chat.rs +++ b/src/ai/persona_chat.rs @@ -935,10 +935,14 @@ impl PersonaChatSession { // Build a no-photo tool catalog. `current_gate_opts_for_persona` // already probes each tool's backing table for presence and // honours the persona's `allow_agent_corrections` toggle. - let mut gate_opts = self.generator.current_gate_opts_for_persona( - backend.images_inline, - Some((user_id, &persona.persona_id)), - ); + // `has_vision` only gates `describe_photo`, which describes the file + // chat's photo. A persona chat has no photo, so the tool could only + // ever answer "No image available" and waste a round. Vision in a + // persona chat goes through the thread tools (look_at_message_image, + // look_at_photo), which use the local vision model directly. + let mut gate_opts = self + .generator + .current_gate_opts_for_persona(false, Some((user_id, &persona.persona_id))); gate_opts.sms_thread_tools = row.anchor.is_some(); let tools = InsightGenerator::build_tool_definitions(gate_opts); // Practice mode narrows the catalog to the thread itself — no From edfa48d1169c498827d6341facb6b7f3e5aac195 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Sun, 18 Oct 2026 20:33:00 -0400 Subject: [PATCH 4/8] feat(ai): date_info tool for exact date arithmetic Small local models get weekdays, "how long ago" and day counts between dates wrong, confidently. Persona chats anchored on old SMS threads ask exactly those questions. date_info answers them exactly, in the owner's display zone: - no args: now (replaces get_current_datetime) - date: its weekday and how long ago / until - date + other_date: the gap in days, weeks and years/months/days, plus hours when both carry times - date + add_days: the date N days after (or before) Checked live on gemma4-26b-A4B and gemma4-31: each date question took one date_info call and came back correct. Co-Authored-By: Claude Opus 5.5 --- src/ai/date_info.rs | 350 ++++++++++++++++++++++++++++++++++++ src/ai/insight_generator.rs | 46 ++--- src/ai/mod.rs | 1 + src/reels/script.rs | 4 +- 4 files changed, 378 insertions(+), 23 deletions(-) create mode 100644 src/ai/date_info.rs 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(); From cc1188427297a6e370bc9e480ad5d61aa9a253a4 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Sun, 18 Oct 2026 21:00:05 -0400 Subject: [PATCH 5/8] fix(openrouter): never send image data to OpenRouter Nothing in the client stopped it: a message carrying images became image_url parts and went out. Photos stayed local only because callers routed around it. messages_to_openai, which every OpenRouter request passes through, now refuses any image (IMAGES_STAY_LOCAL), and describe_image always errors; hybrid mode describes photos with the local model. A caller bug now fails the turn instead of uploading a photo. Co-Authored-By: Claude Opus 5.5 --- src/ai/openrouter.rs | 141 ++++++++++++++++++------------------------- 1 file changed, 59 insertions(+), 82 deletions(-) diff --git a/src/ai/openrouter.rs b/src/ai/openrouter.rs index 3c450ee..ae0c544 100644 --- a/src/ai/openrouter.rs +++ b/src/ai/openrouter.rs @@ -48,6 +48,10 @@ impl CachedEntry { } } +/// The error every image-carrying OpenRouter request fails with. +pub const IMAGES_STAY_LOCAL: &str = "refusing to send image data to OpenRouter: photos stay on \ + the local network (describe them with the local vision model and send the text)"; + lazy_static::lazy_static! { static ref MODEL_CAPABILITIES_CACHE: Arc>>>> = Arc::new(Mutex::new(HashMap::new())); @@ -58,7 +62,11 @@ lazy_static::lazy_static! { /// Translates canonical `ChatMessage` / `Tool` shapes to OpenAI wire format: /// - Tool-call `arguments` serialized as JSON-encoded strings (vs Ollama's /// native JSON). -/// - Image content rewritten into content-parts array with `image_url` entries. +/// - **Never sends images.** Photos stay on the local network: hybrid mode +/// describes them with the local vision model and sends only the text. +/// A message that still carries image data is refused (see +/// [`IMAGES_STAY_LOCAL`]) rather than uploaded, so a caller bug fails a +/// turn instead of leaking a photo. /// - `role=tool` messages attach a `tool_call_id` inferred from the preceding /// assistant turn's tool call. #[derive(Clone)] @@ -160,7 +168,18 @@ impl OpenRouterClient { /// /// Walks in order so it can attach `tool_call_id` to `role=tool` messages /// based on the most recent assistant turn's tool call. - fn messages_to_openai(messages: &[ChatMessage]) -> Vec { + /// + /// Errors if any message carries image data: this is the one choke point + /// every OpenRouter request passes through, so the "photos never leave + /// the network" rule is enforced here rather than trusted to callers. + fn messages_to_openai(messages: &[ChatMessage]) -> Result> { + if messages + .iter() + .any(|m| m.images.as_ref().is_some_and(|imgs| !imgs.is_empty())) + { + log::error!("{IMAGES_STAY_LOCAL}"); + bail!(IMAGES_STAY_LOCAL); + } let mut out = Vec::with_capacity(messages.len()); let mut last_tool_call_ids: Vec = Vec::new(); let mut next_tool_result_idx: usize = 0; @@ -169,26 +188,8 @@ impl OpenRouterClient { let mut obj = serde_json::Map::new(); obj.insert("role".into(), Value::String(msg.role.clone())); - // Content: string OR content-parts array (when images present). - match &msg.images { - Some(images) if !images.is_empty() => { - let mut parts: Vec = Vec::new(); - if !msg.content.is_empty() { - parts.push(json!({"type": "text", "text": msg.content})); - } - for img in images { - let url = image_to_data_url(img); - parts.push(json!({ - "type": "image_url", - "image_url": { "url": url } - })); - } - obj.insert("content".into(), Value::Array(parts)); - } - _ => { - obj.insert("content".into(), Value::String(msg.content.clone())); - } - } + // Text only — images were refused above. + obj.insert("content".into(), Value::String(msg.content.clone())); // Assistant message with tool_calls: stringify arguments, remember // the ids so the subsequent tool messages can reference them. @@ -233,7 +234,7 @@ impl OpenRouterClient { out.push(Value::Object(obj)); } - out + Ok(out) } /// Parse an OpenAI-compatible assistant message back into canonical shape. @@ -367,7 +368,7 @@ impl LlmClient for OpenRouterClient { body.insert("model".into(), Value::String(self.primary_model.clone())); body.insert( "messages".into(), - Value::Array(Self::messages_to_openai(&messages)), + Value::Array(Self::messages_to_openai(&messages)?), ); body.insert("stream".into(), Value::Bool(true)); // Ask for usage data in the final chunk (OpenAI + OpenRouter @@ -633,24 +634,12 @@ impl LlmClient for OpenRouterClient { Ok(parsed.data.into_iter().map(|i| i.embedding).collect()) } - async fn describe_image(&self, image_base64: &str) -> Result { - // Own span with operation=describe_image — the funnel into - // chat_with_tools_impl keeps the request on a single span. - let span = self.start_request_span("describe_image", &self.primary_model); - let prompt = "Briefly describe what you see in this image in 1-2 sentences. \ - Focus on the people, location, and activity."; - let mut messages: Vec = Vec::new(); - messages.push(ChatMessage::system( - "You are a scene description assistant. Be concise and factual.", - )); - let mut user = ChatMessage::user(prompt); - user.images = Some(vec![image_base64.to_string()]); - messages.push(user); - - let (reply, _, _) = self - .chat_with_tools_impl(Some(&span), messages, Vec::new()) - .await?; - Ok(reply.content) + async fn describe_image(&self, _image_base64: &str) -> Result { + // Describing an image means uploading it. Hybrid mode always + // describes on the local backend (`ResolvedBackend::local`), so this + // is never the intended path — refuse loudly if something reaches it. + log::error!("{IMAGES_STAY_LOCAL}"); + bail!(IMAGES_STAY_LOCAL) } async fn list_models(&self) -> Result> { @@ -720,7 +709,7 @@ impl OpenRouterClient { body.insert("model".into(), Value::String(self.primary_model.clone())); body.insert( "messages".into(), - Value::Array(Self::messages_to_openai(&messages)), + Value::Array(Self::messages_to_openai(&messages)?), ); body.insert("stream".into(), Value::Bool(false)); if !tools.is_empty() { @@ -871,15 +860,6 @@ fn find_double_newline(buf: &[u8]) -> Option { None } -/// Build a `data:` URL if the provided string is raw base64, otherwise pass it through. -fn image_to_data_url(img: &str) -> String { - if img.starts_with("data:") { - img.to_string() - } else { - format!("data:image/jpeg;base64,{}", img) - } -} - fn parse_model_capabilities(m: &Value) -> ModelCapabilities { let name = m .get("id") @@ -926,7 +906,7 @@ mod tests { msg.tool_calls.as_mut().unwrap()[0].function.arguments = json!({"query": "hello", "limit": 5}); - let wire = OpenRouterClient::messages_to_openai(&[msg]); + let wire = OpenRouterClient::messages_to_openai(&[msg]).unwrap(); let tcs = wire[0] .get("tool_calls") .and_then(|v| v.as_array()) @@ -986,40 +966,37 @@ mod tests { } #[test] - fn images_become_content_parts() { - let mut msg = ChatMessage::user("What is in this photo?"); - msg.images = Some(vec!["BASE64DATA".into()]); - - let wire = OpenRouterClient::messages_to_openai(&[msg]); - let content = wire[0].get("content").and_then(|v| v.as_array()).unwrap(); - assert_eq!(content.len(), 2); - assert_eq!(content[0]["type"], "text"); - assert_eq!(content[0]["text"], "What is in this photo?"); - assert_eq!(content[1]["type"], "image_url"); - assert_eq!( - content[1]["image_url"]["url"], - "data:image/jpeg;base64,BASE64DATA" - ); + fn any_image_anywhere_in_the_transcript_is_refused() { + // Not just the latest message: a replayed transcript whose first + // user turn still holds the photo must be refused too. + let mut first = ChatMessage::user("What is in this photo?"); + first.images = Some(vec!["BASE64DATA".into()]); + let later = ChatMessage::user("and then?"); + let err = OpenRouterClient::messages_to_openai(&[first, later]).unwrap_err(); + assert!(err.to_string().contains("photos stay on the local network")); } #[test] - fn data_url_images_pass_through_unchanged() { - let mut msg = ChatMessage::user(""); - msg.images = Some(vec!["data:image/png;base64,ABCDEF".into()]); - let wire = OpenRouterClient::messages_to_openai(&[msg]); - let content = wire[0].get("content").and_then(|v| v.as_array()).unwrap(); - // No text part when content is empty. - assert_eq!(content.len(), 1); - assert_eq!( - content[0]["image_url"]["url"], - "data:image/png;base64,ABCDEF" - ); + fn an_empty_image_list_is_not_an_image() { + let mut msg = ChatMessage::user("hi"); + msg.images = Some(Vec::new()); + assert!(OpenRouterClient::messages_to_openai(&[msg]).is_ok()); + } + + #[tokio::test] + async fn describe_image_refuses_without_a_network_call() { + // Points at a closed port: if the guard ever stopped refusing, this + // would fail with a connection error instead of the guard's. + let client = + OpenRouterClient::new("key".into(), Some("http://127.0.0.1:9".into()), "m".into()); + let err = client.describe_image("BASE64DATA").await.unwrap_err(); + assert!(err.to_string().contains("photos stay on the local network")); } #[test] fn text_only_message_stays_string() { let msg = ChatMessage::user("hello"); - let wire = OpenRouterClient::messages_to_openai(&[msg]); + let wire = OpenRouterClient::messages_to_openai(&[msg]).unwrap(); assert_eq!(wire[0]["content"], "hello"); assert!(wire[0]["content"].as_str().is_some()); } @@ -1040,7 +1017,7 @@ mod tests { }; let tool_result = ChatMessage::tool_result("found it"); - let wire = OpenRouterClient::messages_to_openai(&[assistant, tool_result]); + let wire = OpenRouterClient::messages_to_openai(&[assistant, tool_result]).unwrap(); assert_eq!(wire[1]["role"], "tool"); assert_eq!(wire[1]["tool_call_id"], "call_42"); } @@ -1071,7 +1048,7 @@ mod tests { let r1 = ChatMessage::tool_result("a result"); let r2 = ChatMessage::tool_result("b result"); - let wire = OpenRouterClient::messages_to_openai(&[assistant, r1, r2]); + let wire = OpenRouterClient::messages_to_openai(&[assistant, r1, r2]).unwrap(); assert_eq!(wire[1]["tool_call_id"], "call_A"); assert_eq!(wire[2]["tool_call_id"], "call_B"); } @@ -1090,7 +1067,7 @@ mod tests { }]), images: None, }; - let wire = OpenRouterClient::messages_to_openai(&[assistant]); + let wire = OpenRouterClient::messages_to_openai(&[assistant]).unwrap(); let tcs = wire[0] .get("tool_calls") .and_then(|v| v.as_array()) From ba39628a0d451a296b3d04976be7ab213b8064f9 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Sun, 18 Oct 2026 21:00:05 -0400 Subject: [PATCH 6/8] feat(insight-chat): allow switching a local photo chat to hybrid A local insight's transcript carries the photo itself, so continuing on hybrid was refused. Each hybrid turn now has the local vision model describe the photo (cached per image), sends the description in its place, and puts the photo back before saving, so the stored chat and later local turns keep it. If the local description fails, the turn fails. The stored model id is only reused on the same backend, so a llama-swap slot isn't sent to OpenRouter. Checked end to end against a recording fake OpenRouter on all three continuation endpoints: no image data left, the stored transcript kept the photo, and switching back to local saw the photo again. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 21 ++- src/ai/insight_chat.rs | 314 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 292 insertions(+), 43 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0bac406..0177473 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -900,9 +900,18 @@ endpoint. a deploy-time decision, not a request-time one). - `hybrid → hybrid` allowed. - `hybrid → local` allowed (the inlined description replays as text). -- `local → hybrid` rejected — the stored transcript has raw images in the - first user message and OpenRouter providers don't accept that shape - consistently. Regenerate the insight in hybrid mode instead. +- `local → hybrid` allowed. The stored transcript carries the raw photo in + its first user message, so each hybrid turn swaps it for a local vision + description (cached per photo) in the copy sent to OpenRouter, then puts + the photo back before saving (`InsightChatService::inline_images_as_text`). + If the local model can't describe it, the turn fails. A stored model id is + only reused on the same backend (`continuation_model`). + +**Photos never go to OpenRouter.** `OpenRouterClient` refuses any request +carrying image data (`IMAGES_STAY_LOCAL`) and its `describe_image` always +errors — the client is the choke point, so a caller bug fails the turn +instead of uploading a photo. Image work in hybrid mode goes through +`ResolvedBackend::local()`. **Insight Chat Continuation:** @@ -933,9 +942,9 @@ clients whether chat is available for a given insight. Backend routing rules (matches agentic-insight generation): - Stored `backend` on the insight row is authoritative by default. -- `request.backend` may override per-turn. `local -> hybrid` is rejected in - v1 (would require on-the-fly visual-description rewrite); `hybrid -> local` - replays verbatim since the description is already inlined as text. +- `request.backend` may override per-turn, in either direction (see the + cross-replay matrix above); `hybrid -> local` replays verbatim since the + description is already inlined as text. - `request.model` overrides the chat model (an Ollama id in local mode, an OpenRouter id in hybrid mode). diff --git a/src/ai/insight_chat.rs b/src/ai/insight_chat.rs index 2cad73b..1cc329e 100644 --- a/src/ai/insight_chat.rs +++ b/src/ai/insight_chat.rs @@ -119,8 +119,16 @@ pub struct InsightChatService { generator: Arc, insight_dao: Arc>>, chat_locks: ChatLockMap, + /// Local vision descriptions of transcript images, keyed by a hash of + /// the image data, so a local-generated chat continued on a text-only + /// (hybrid) model describes its photo once rather than every turn. + image_descriptions: Arc>>, } +/// Cap on cached image descriptions; the cache is cleared when it fills. +/// One entry per photo chatted about since the server started. +const IMAGE_DESCRIPTION_CACHE_MAX: usize = 256; + impl InsightChatService { pub fn new( generator: Arc, @@ -131,9 +139,62 @@ impl InsightChatService { generator, insight_dao, chat_locks, + image_descriptions: Arc::new(Mutex::new(HashMap::new())), } } + /// Prepare a replayed transcript for a backend that can't take images. + /// + /// A chat generated on the local backend carries the photo itself in its + /// first user message. Continuing it on hybrid would send that photo to + /// OpenRouter — which the OpenRouter client refuses — so each image is + /// described by the local vision model and the text takes its place in + /// the copy sent to the model. The returned stash puts the images back + /// before the transcript is saved, so the stored chat keeps its photo and + /// a later local turn still sees it. + /// + /// A no-op for backends that take images inline. If the local model + /// can't describe an image the turn fails: going ahead without the + /// photo would quietly change what the chat is about. + async fn inline_images_as_text( + &self, + backend: &ResolvedBackend, + messages: &mut [ChatMessage], + ) -> Result { + if backend.images_inline { + return Ok(ImageStash::default()); + } + let mut descriptions: HashMap = HashMap::new(); + for image in transcript_images(messages) { + let key = image_cache_key(&image); + let cached = self + .image_descriptions + .lock() + .ok() + .and_then(|c| c.get(&key).cloned()); + let description = match cached { + Some(d) => d, + None => { + let d = backend.local().describe_image(&image).await.map_err(|e| { + anyhow!( + "couldn't describe the photo with the local vision model, so this \ + chat can't continue on a text-only model: {e}" + ) + })?; + if let Ok(mut cache) = self.image_descriptions.lock() { + if cache.len() >= IMAGE_DESCRIPTION_CACHE_MAX { + cache.clear(); + } + cache.insert(key, d.clone()); + } + d + } + }; + descriptions.insert(image, description); + } + Ok(apply_image_descriptions(messages, &descriptions)) + } + /// Accessor for the insight DAO (used by async job completion). pub fn insight_dao(&self) -> &Arc>> { &self.insight_dao @@ -285,11 +346,12 @@ impl InsightChatService { let stored_model = insight.model_version.clone(); let overrides = SamplingOverrides { - model: req - .model - .clone() - .or_else(|| Some(stored_model.clone())) - .filter(|m| !m.is_empty()), + model: continuation_model( + req.model.clone(), + &stored_model, + &stored_backend, + kind.as_str(), + ), num_ctx: req.num_ctx, temperature: req.temperature, top_p: req.top_p, @@ -298,6 +360,9 @@ impl InsightChatService { enable_thinking: req.enable_thinking, }; let backend = self.generator.resolve_backend(kind, &overrides).await?; + // Text-only backend (hybrid) on a transcript holding the photo: + // swap it for a local description. Restored before saving. + let image_stash = self.inline_images_as_text(&backend, &mut messages).await?; let model_used = backend.model().to_string(); span.set_attribute("model", model_used.clone()); @@ -449,6 +514,8 @@ impl InsightChatService { // Drop the per-turn iteration-budget note from the system message // before we persist so it doesn't snowball on each subsequent turn. restore_system_content(&mut messages, original_system_content); + // Put the photo back so the stored transcript keeps it. + image_stash.restore(&mut messages); // Append mode: undo the per-turn system-prompt override so the // stored transcript keeps the original baked persona. Amend mode: @@ -979,11 +1046,12 @@ impl InsightChatService { let stored_model = insight.model_version.clone(); let overrides = SamplingOverrides { - model: req - .model - .clone() - .or_else(|| Some(stored_model.clone())) - .filter(|m| !m.is_empty()), + model: continuation_model( + req.model.clone(), + &stored_model, + &stored_backend, + kind.as_str(), + ), num_ctx: req.num_ctx, temperature: req.temperature, top_p: req.top_p, @@ -992,6 +1060,9 @@ impl InsightChatService { enable_thinking: req.enable_thinking, }; let backend = self.generator.resolve_backend(kind, &overrides).await?; + // Text-only backend (hybrid) on a transcript holding the photo: + // swap it for a local description. Restored before saving. + let image_stash = self.inline_images_as_text(&backend, &mut messages).await?; let model_used = backend.model().to_string(); // llm.* attributes on the turn-level span (issue #130): clients can't // know local-vs-hybrid, but the turn knows its resolved backend. @@ -1064,6 +1135,8 @@ impl InsightChatService { } restore_system_content(&mut messages, original_system_content); + // Put the photo back so the stored transcript keeps it. + image_stash.restore(&mut messages); if !req.amend { restore_system_prompt_override(&mut messages, override_stash); @@ -1455,11 +1528,12 @@ impl InsightChatService { let stored_model = insight.model_version.clone(); let overrides = SamplingOverrides { - model: req - .model - .clone() - .or_else(|| Some(stored_model.clone())) - .filter(|m| !m.is_empty()), + model: continuation_model( + req.model.clone(), + &stored_model, + &stored_backend, + kind.as_str(), + ), num_ctx: req.num_ctx, temperature: req.temperature, top_p: req.top_p, @@ -1468,6 +1542,9 @@ impl InsightChatService { enable_thinking: req.enable_thinking, }; let backend = self.generator.resolve_backend(kind, &overrides).await?; + // Text-only backend (hybrid) on a transcript holding the photo: + // swap it for a local description. Restored before saving. + let image_stash = self.inline_images_as_text(&backend, &mut messages).await?; let model_used = backend.model().to_string(); // Tool set — images_inline mode + first user turn carries an image → @@ -1533,6 +1610,8 @@ impl InsightChatService { // Drop the per-turn iteration-budget note before persisting so it // doesn't snowball on subsequent turns. restore_system_content(&mut messages, original_system_content); + // Put the photo back so the stored transcript keeps it. + image_stash.restore(&mut messages); // Append mode: undo the per-turn system-prompt override. // Amend mode: keep it — it becomes the new row's system message. @@ -2071,34 +2150,122 @@ fn resolve_date_taken_for_context( }) } -/// Validate a stored→effective backend transition for a chat continuation. -/// Continuation runs against a transcript that was generated with a specific -/// backend; the only blocked transition is `local → hybrid`, because the -/// stored transcript has images embedded in the first user message and the -/// hybrid path (OpenRouter chat with describe-then-inline) can't replay -/// raw image bytes through OpenRouter consistently across providers. -/// `hybrid → local` is allowed (the inlined description replays verbatim -/// as text). +/// Validate the backend a chat continuation is switching to. +/// +/// Every transition is allowed now. `local → hybrid` used to be refused +/// because a local transcript carries the raw photo; the turn now replaces +/// it with a local vision description before anything reaches OpenRouter +/// (`InsightChatService::inline_images_as_text`), and the OpenRouter client +/// refuses image data outright as a backstop. `hybrid → local` replays the +/// inlined description as text. `stored` is kept for the call sites' +/// symmetry and future rules. /// /// Whether "local" routes through Ollama or llama-swap is decided at /// startup by `LLM_BACKEND`; both share the same transcript shape from /// the chat-replay perspective. -fn validate_cross_replay(stored: &str, effective: &str) -> Result<()> { +fn validate_cross_replay(_stored: &str, effective: &str) -> Result<()> { if !matches!(effective, "local" | "hybrid") { bail!( "unknown backend '{}'; expected 'local' or 'hybrid'", effective ); } - if stored == "local" && effective == "hybrid" { - bail!( - "switching from local to hybrid mid-chat isn't supported; \ - regenerate the insight in hybrid mode if you want OpenRouter chat" - ); - } Ok(()) } +/// The model to continue a chat with when the request names none: the one +/// the insight was generated with, but only on the same backend. After a +/// switch, the stored id belongs to the other backend (a llama-swap slot is +/// not an OpenRouter model), so the new backend's default is used instead. +fn continuation_model( + requested: Option, + stored_model: &str, + stored_backend: &str, + effective_backend: &str, +) -> Option { + requested + .or_else(|| (stored_backend == effective_backend).then(|| stored_model.to_string())) + .filter(|m| !m.is_empty()) +} + +/// Every distinct image in a transcript, in order of first appearance. +fn transcript_images(messages: &[ChatMessage]) -> Vec { + let mut seen = std::collections::HashSet::new(); + messages + .iter() + .flat_map(|m| m.images.iter().flatten()) + .filter(|img| seen.insert((*img).clone())) + .cloned() + .collect() +} + +fn image_cache_key(image: &str) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + image.hash(&mut h); + h.finish() +} + +/// What `apply_image_descriptions` took out of a transcript, so it can be +/// put back before the transcript is saved. +#[derive(Debug, Default)] +struct ImageStash { + entries: Vec, +} + +#[derive(Debug)] +struct StashedImages { + index: usize, + original_content: String, + rewritten_content: String, + images: Option>, +} + +impl ImageStash { + /// Put the original content and images back. Each entry is matched by + /// index *and* by still carrying the rewritten text, so a turn that + /// reshaped the transcript can't get the wrong message restored. + fn restore(self, messages: &mut [ChatMessage]) { + for e in self.entries { + if let Some(m) = messages.get_mut(e.index) + && m.content == e.rewritten_content + { + m.content = e.original_content; + m.images = e.images; + } + } + } +} + +/// Replace each image in `messages` with its description, appended to the +/// message text. Returns what was taken out, for `ImageStash::restore`. +fn apply_image_descriptions( + messages: &mut [ChatMessage], + descriptions: &HashMap, +) -> ImageStash { + let mut stash = ImageStash::default(); + for (idx, m) in messages.iter_mut().enumerate() { + let Some(images) = m.images.as_ref().filter(|i| !i.is_empty()) else { + continue; + }; + let mut content = m.content.clone(); + for img in images { + if let Some(desc) = descriptions.get(img) { + content.push_str(&format!( + "\n\nVisual description of the photo (from the local vision model):\n{desc}" + )); + } + } + stash.entries.push(StashedImages { + index: idx, + original_content: std::mem::replace(&mut m.content, content.clone()), + rewritten_content: content, + images: m.images.take(), + }); + } + stash +} + /// Pick the backend label for bootstrap. Bootstrap has no stored insight /// to defer to (that's continuation's behaviour), so the default is /// `"local"`. Returns an error if the supplied label is non-empty but @@ -3672,17 +3839,90 @@ mod tests { } #[test] - fn cross_replay_rejects_local_to_hybrid() { - let err = validate_cross_replay("local", "hybrid").unwrap_err(); - assert!(format!("{}", err).contains("local to hybrid")); - } - - #[test] - fn cross_replay_allows_supported_transitions() { + fn cross_replay_allows_every_known_transition() { assert!(validate_cross_replay("local", "local").is_ok()); assert!(validate_cross_replay("hybrid", "hybrid").is_ok()); // Hybrid → local replays the inlined description as plain text. assert!(validate_cross_replay("hybrid", "local").is_ok()); + // Local → hybrid swaps the photo for a local description first. + assert!(validate_cross_replay("local", "hybrid").is_ok()); + } + + #[test] + fn continuation_model_drops_the_stored_model_across_a_switch() { + // Same backend: keep the model the insight was generated with. + assert_eq!( + continuation_model(None, "gemma4-31", "local", "local").as_deref(), + Some("gemma4-31") + ); + // Switched: a llama-swap slot name is not an OpenRouter model id. + assert_eq!( + continuation_model(None, "gemma4-31", "local", "hybrid"), + None + ); + // An explicit request always wins. + assert_eq!( + continuation_model(Some("x/y".into()), "gemma4-31", "local", "hybrid").as_deref(), + Some("x/y") + ); + assert_eq!(continuation_model(None, "", "local", "local"), None); + } + + fn with_image(text: &str, img: &str) -> ChatMessage { + let mut m = ChatMessage::user(text.to_string()); + m.images = Some(vec![img.to_string()]); + m + } + + #[test] + fn transcript_images_lists_each_image_once() { + let msgs = vec![ + ChatMessage::system("sys"), + with_image("describe", "IMG_A"), + ChatMessage::user("more"), + with_image("again", "IMG_A"), + ]; + assert_eq!(transcript_images(&msgs), vec!["IMG_A".to_string()]); + } + + #[test] + fn photo_is_swapped_for_text_then_restored_exactly() { + let original = vec![ + ChatMessage::system("sys"), + with_image("What's in this photo?", "IMG_A"), + assistant_text("A beach."), + ChatMessage::user("who's there?"), + ]; + let mut msgs = original.clone(); + let descriptions = + HashMap::from([("IMG_A".to_string(), "Two people on sand.".to_string())]); + + let stash = apply_image_descriptions(&mut msgs, &descriptions); + // Nothing the text-only model receives carries image data. + assert!(msgs.iter().all(|m| m.images.is_none())); + assert!(msgs[1].content.starts_with("What's in this photo?")); + assert!(msgs[1].content.contains("Two people on sand.")); + + // The turn appends its exchange, then the stash is restored before + // saving: the stored transcript keeps the photo, unchanged. + msgs.push(assistant_text("Two friends.")); + stash.restore(&mut msgs); + assert_eq!( + serde_json::to_string(&msgs[..original.len()]).unwrap(), + serde_json::to_string(&original).unwrap() + ); + assert_eq!(msgs[1].images, Some(vec!["IMG_A".to_string()])); + } + + #[test] + fn restore_leaves_a_message_the_turn_changed() { + let mut msgs = vec![with_image("q", "IMG_A")]; + let descriptions = HashMap::from([("IMG_A".to_string(), "desc".to_string())]); + let stash = apply_image_descriptions(&mut msgs, &descriptions); + msgs[0].content = "something else entirely".into(); + stash.restore(&mut msgs); + assert_eq!(msgs[0].content, "something else entirely"); + assert!(msgs[0].images.is_none()); } #[test] From 185a1b561a4cda42b6f98326a849722e5883965d Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Sun, 18 Oct 2026 21:00:05 -0400 Subject: [PATCH 7/8] Bump version to 1.6.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1251496..5637e7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2070,7 +2070,7 @@ dependencies = [ [[package]] name = "image-api" -version = "1.5.0" +version = "1.6.0" dependencies = [ "actix", "actix-cors", diff --git a/Cargo.toml b/Cargo.toml index c6aa1ae..01d56b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "image-api" -version = "1.5.0" +version = "1.6.0" authors = ["Cameron Cordes "] edition = "2024" From e029c81274814e62381cf6b30c5b7b2db8a137ee Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Tue, 20 Oct 2026 19:38:08 -0400 Subject: [PATCH 8/8] fix(persona-chat): review fixes before merge - Thread tools are bound server-side to the chat's own thread. The model used to pass contact_id, so practice mode could read any conversation it named; a model-supplied contact_id is now only checked, never trusted, and the prompts no longer hand it one. - Practice chats need no persona. They're stored under PRACTICE_PERSONA_ID and run with an inert stand-in, so deleting or editing personas can't hide or break them. persona_id is optional on create for as_contact. - The per-thread list filters in SQL (list_persona_chats_for_contact, served by idx_persona_chat_anchor_contact) instead of decoding every transcript. - fetch_mms_image checks the content type and a 20 MB cap before reading the body, so a video attachment isn't downloaded in full. - get_photos_near_time unions tags over every path sharing the content, per the multi-library model. - look_at_photo only opens indexed photos, so an excluded folder can't be read by guessing a path. - One date parser: get_photos_near_time uses date_info::parse_center. - Practice prompt source rewrapped. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 20 +++-- src/ai/date_info.rs | 29 ++++++- src/ai/insight_chat.rs | 5 ++ src/ai/insight_generator.rs | 160 +++++++++++++++++++++--------------- src/ai/persona_chat.rs | 99 ++++++++++++++++++---- src/ai/sms_anchor.rs | 55 ++++++++----- src/ai/sms_client.rs | 71 ++++++++++++++-- src/database/persona_dao.rs | 60 ++++++++++++++ 8 files changed, 383 insertions(+), 116 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0177473..b5d8423 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -578,13 +578,19 @@ GET /insights/chat/history?path=... (rendered transcript with tool invocations) POST /insights/chat/rewind (truncate transcript at a rendered index) // Persona chat — "Continue with persona" from an SMS thread (src/ai/sms_anchor.rs) -POST /persona_chat/conversations (body: { persona_id, anchor?: { contact_id, message_id } }; - anchor snapshots ~40 SMS-API messages up to message_id into - the row, 502 if SMS-API can't read the thread) -GET /persona_chat/conversations?contact_id=N (only chats anchored on that thread) -// Anchored turns append the thread + chat-register guidance to the persona prompt and add -// the get_thread_messages / look_at_message_image / get_photos_near_time / look_at_photo -// tools (ToolGateOpts.sms_thread_tools). Image tools use the LOCAL vision model. +POST /persona_chat/conversations (body: { persona_id, anchor?: { contact_id, message_id, + mode?: "about" | "as_contact" } }; anchor snapshots ~40 + SMS-API messages up to message_id into the row, 502 if + SMS-API can't read the thread. `as_contact` (practice) needs + no persona: stored under PRACTICE_PERSONA_ID, listed as + "Practice") +GET /persona_chat/conversations?contact_id=N (only chats anchored on that thread; DAO query) +// Anchored turns append the thread + mode guidance to the prompt and add the thread +// tools (ToolGateOpts.sms_thread_tools): get_thread_messages / look_at_message_image are +// bound server-side to the chat's own thread (a model-named contact_id is refused), and +// about-mode also gets get_photos_near_time / look_at_photo. Practice keeps only the two +// thread tools. Image tools use the LOCAL vision model; MMS downloads are image-only and +// capped (MMS_IMAGE_MAX_BYTES); look_at_photo only opens indexed photos. // User AI Prefs (owner defaults + passive mirror; src/prefs.rs) GET /prefs (current row; auth) diff --git a/src/ai/date_info.rs b/src/ai/date_info.rs index e6f79e0..289d798 100644 --- a/src/ai/date_info.rs +++ b/src/ai/date_info.rs @@ -10,7 +10,7 @@ //! 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::{DateTime, Datelike, Months, NaiveDate, NaiveDateTime, TimeZone, Utc}; use chrono_tz::Tz; /// A date argument as the model gave it. A bare date stays a date (no @@ -63,6 +63,19 @@ fn parse_moment(value: &serde_json::Value, zone: Tz) -> Option { .map(Moment::Day) } +/// A moment for tools that search *around* a time (`get_photos_near_time`), +/// as unix seconds. Same formats as `date_info`; a bare date centres on +/// local midday, the least-wrong centre for a whole day. +pub fn parse_center(value: &serde_json::Value, zone: Tz) -> Option { + let local = match parse_moment(value, zone)? { + Moment::At(dt) => dt, + Moment::Day(d) => d.and_hms_opt(12, 0, 0)?, + }; + zone.from_local_datetime(&local) + .earliest() + .map(|dt| dt.timestamp()) +} + /// 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) { @@ -227,7 +240,6 @@ pub fn date_info(args: &serde_json::Value, now: DateTime, zone: Tz) -> Stri #[cfg(test)] mod tests { use super::*; - use chrono::TimeZone; use serde_json::json; const NY: Tz = chrono_tz::America::New_York; @@ -339,6 +351,19 @@ mod tests { assert_eq!(describe_gap(d(2025, 10, 18), d(2026, 10, 18)), "1 year"); } + #[test] + fn parse_center_reads_unix_wall_clock_and_bare_dates() { + assert_eq!(parse_center(&json!(1_700_000_000), NY), Some(1_700_000_000)); + // 2023-11-14 17:13 in New York (EST) = 22:13 UTC. + assert_eq!( + parse_center(&json!("2023-11-14 17:13"), NY), + Some(1_699_999_980) + ); + // A bare date centres on local midday. + assert_eq!(parse_center(&json!("2023-11-14"), NY), Some(1_699_981_200)); + assert_eq!(parse_center(&json!("soon"), NY), None); + } + #[test] fn bad_input_says_what_format_to_use() { let out = date_info(&json!({"date": "last tuesday"}), now(), NY); diff --git a/src/ai/insight_chat.rs b/src/ai/insight_chat.rs index 1cc329e..3d384dd 100644 --- a/src/ai/insight_chat.rs +++ b/src/ai/insight_chat.rs @@ -473,6 +473,7 @@ impl InsightChatService { &normalized, req.user_id, &active_persona, + None, ) .instrument(loop_span.clone()) .await; @@ -1435,6 +1436,7 @@ impl InsightChatService { normalized, user_id, active_persona, + None, max_iterations, entry, fitter, @@ -1990,6 +1992,7 @@ impl InsightChatService { normalized, user_id, active_persona, + None, ) .await; let (result_preview, result_truncated) = truncate_tool_result(&result); @@ -2624,6 +2627,7 @@ pub async fn run_streaming_agentic_loop_with_entry( normalized: &str, user_id: i32, active_persona: &str, + thread_contact: Option, max_iterations: usize, entry: &Arc, fitter: &mut ContextFitter, @@ -2720,6 +2724,7 @@ pub async fn run_streaming_agentic_loop_with_entry( normalized, user_id, active_persona, + thread_contact, ) .await } else { diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index 11e9644..1792a03 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -1891,6 +1891,7 @@ Return ONLY the summary, nothing else."#, file_path: &str, user_id: i32, persona_id: &str, + thread_contact: Option, ) -> String { let model = backend.model(); let backend_label = backend.kind.as_str(); @@ -1940,9 +1941,12 @@ Return ONLY the summary, nothing else."#, "date_info" => { crate::ai::date_info::date_info(arguments, Utc::now(), self.display_zone()) } - "get_thread_messages" => self.tool_get_thread_messages(arguments).await, + "get_thread_messages" => { + self.tool_get_thread_messages(arguments, thread_contact) + .await + } "look_at_message_image" => { - self.tool_look_at_message_image(arguments, backend.local()) + self.tool_look_at_message_image(arguments, thread_contact, backend.local()) .await } "get_photos_near_time" => self.tool_get_photos_near_time(arguments).await, @@ -2411,9 +2415,14 @@ Return ONLY the summary, nothing else."#, /// Tool: get_thread_messages — page the anchored SMS thread from a /// message id. Lines are rendered exactly like the anchor snapshot so /// the model can keep paging from any id it sees. - async fn tool_get_thread_messages(&self, args: &serde_json::Value) -> String { - let Some(contact_id) = args.get("contact_id").and_then(|v| v.as_i64()) else { - return "Error: missing required parameter 'contact_id'".to_string(); + async fn tool_get_thread_messages( + &self, + args: &serde_json::Value, + thread_contact: Option, + ) -> String { + let contact_id = match Self::bound_thread(args, thread_contact) { + Ok(c) => c, + Err(e) => return e, }; let Some(message_id) = args.get("message_id").and_then(|v| v.as_i64()) else { return "Error: missing required parameter 'message_id'".to_string(); @@ -2461,10 +2470,12 @@ Return ONLY the summary, nothing else."#, async fn tool_look_at_message_image( &self, args: &serde_json::Value, + thread_contact: Option, local: &dyn LlmClient, ) -> String { - let Some(contact_id) = args.get("contact_id").and_then(|v| v.as_i64()) else { - return "Error: missing required parameter 'contact_id'".to_string(); + let contact_id = match Self::bound_thread(args, thread_contact) { + Ok(c) => c, + Err(e) => return e, }; let Some(message_id) = args.get("message_id").and_then(|v| v.as_i64()) else { return "Error: missing required parameter 'message_id'".to_string(); @@ -2476,11 +2487,15 @@ Return ONLY the summary, nothing else."#, let bytes = match self .sms_client - .fetch_mms_media(contact_id, message_id, media_index) + .fetch_mms_image(contact_id, message_id, media_index) .await { Ok(b) => b, - Err(e) => return format!("Error fetching attachment: {}", e), + Err(e) => { + return format!( + "Error: couldn't look at that attachment ({e}); only photos can be viewed." + ); + } }; let image_base64 = match Self::encode_image_bytes_for_vision(&bytes) { Ok(b64) => b64, @@ -2501,7 +2516,12 @@ Return ONLY the summary, nothing else."#, async fn tool_get_photos_near_time(&self, args: &serde_json::Value) -> String { let zone = self.display_zone(); let server_zone = crate::camera_tz::server_zone(); - let Some(center) = Self::parse_moment(args, zone) else { + let center_arg = args + .get("timestamp") + .filter(|v| !v.is_null()) + .or_else(|| args.get("datetime")); + let Some(center) = center_arg.and_then(|v| crate::ai::date_info::parse_center(v, zone)) + else { return "Error: pass 'timestamp' (unix seconds) or 'datetime' (YYYY-MM-DD HH:MM)" .to_string(); }; @@ -2553,11 +2573,22 @@ Return ONLY the summary, nothing else."#, let when = crate::date_view::row_display_wall(&row, zone, server_zone) .map(|w| w.format("%Y-%m-%d %H:%M").to_string()) .unwrap_or_else(|| "unknown time".to_string()); + // Tags follow the bytes: union over every path holding the same + // content, so a copy in another library shows its tags too. + let sibling_paths = { + let mut dao = self.exif_dao.lock().expect("Unable to lock ExifDao"); + dao.get_rel_paths_sharing_content(row.library_id, &row.file_path) + .unwrap_or_else(|_| vec![row.file_path.clone()]) + }; let tags: Vec = { let mut dao = self.tag_dao.lock().expect("Unable to lock TagDao"); - dao.get_tags_for_path(&row.file_path) + let mut names: Vec = dao + .get_tags_for_paths(&sibling_paths) .map(|t| t.into_iter().map(|t| t.name).collect()) - .unwrap_or_default() + .unwrap_or_default(); + names.sort(); + names.dedup(); + names }; let people: Vec = match &row.content_hash { Some(hash) => { @@ -2602,6 +2633,14 @@ Return ONLY the summary, nothing else."#, if crate::file_types::is_video_file(std::path::Path::new(&file_path)) { return "Error: that's a video — only photos can be looked at.".to_string(); } + // Only photos the library has indexed: excluded folders (EXCLUDED_DIRS, + // e.g. `private`) are never indexed, so a guessed path into one is + // refused rather than read off disk. + if self.fetch_exif(&file_path).is_none() { + return "Error: that isn't a photo in the library — use a file_path from \ + get_photos_near_time." + .to_string(); + } let image_base64 = match self.load_image_as_base64(&file_path) { Ok(b) => b, Err(e) => return format!("Error loading photo: {}", e), @@ -2612,6 +2651,26 @@ Return ONLY the summary, nothing else."#, } } + /// The thread a thread tool may read: the one the chat was started from, + /// fixed server-side. A `contact_id` the model passes is only checked + /// against it — never trusted — so a chat (practice mode especially) can + /// never be talked into reading another conversation. + fn bound_thread(args: &serde_json::Value, thread_contact: Option) -> Result { + let Some(bound) = thread_contact else { + return Err( + "Error: thread tools are only available in a chat started from a \ + text thread." + .to_string(), + ); + }; + match args.get("contact_id").and_then(|v| v.as_i64()) { + Some(asked) if asked != bound => { + Err("Error: only this chat's own text thread can be read here.".to_string()) + } + _ => Ok(bound), + } + } + /// A model-supplied path is joined onto each library root, so refuse /// anything that could climb out of it. fn photo_path_is_safe(path: &str) -> bool { @@ -2622,28 +2681,6 @@ Return ONLY the summary, nothing else."#, .all(|c| matches!(c, Component::Normal(_) | Component::CurDir)) } - /// A tool's centre moment: `timestamp` (unix seconds) wins, else - /// `datetime` read as a wall clock in the owner's display zone. A bare - /// date centres on midday. - fn parse_moment(args: &serde_json::Value, zone: chrono_tz::Tz) -> Option { - if let Some(ts) = args.get("timestamp").and_then(|v| v.as_i64()) { - return Some(ts); - } - let raw = args.get("datetime").and_then(|v| v.as_str())?.trim(); - let naive = chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%d %H:%M") - .or_else(|_| chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M")) - .or_else(|_| chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S")) - .ok() - .or_else(|| { - NaiveDate::parse_from_str(raw, "%Y-%m-%d") - .ok() - .and_then(|d| d.and_hms_opt(12, 0, 0)) - })?; - zone.from_local_datetime(&naive) - .earliest() - .map(|dt| dt.timestamp()) - } - /// Decode arbitrary image bytes (an MMS attachment), shrink to the /// vision models' 1024px working size and re-encode as base64 JPEG — /// the same shape `load_image_as_base64` produces for library files. @@ -4062,12 +4099,11 @@ Return ONLY the summary, nothing else."#, thread (the #numbers in the transcript) — `before` for older messages, `after` for what \ came next, `around` for both sides. Lines carry their own #id, so call again from the \ oldest/newest id to keep paging. \ - Example: `{contact_id: 42, message_id: 812, direction: \"before\", limit: 30}`.", + Example: `{message_id: 812, direction: \"before\", limit: 30}`.", serde_json::json!({ "type": "object", - "required": ["contact_id", "message_id"], + "required": ["message_id"], "properties": { - "contact_id": { "type": "integer", "description": "The thread's contact_id (given in the system prompt)." }, "message_id": { "type": "integer", "description": "Message id to page from." }, "direction": { "type": "string", "enum": ["before", "after", "around"], "description": "Which side of message_id to read (default before)." }, "limit": { "type": "integer", "description": "Max messages (default 30, max 80)." } @@ -4080,12 +4116,11 @@ Return ONLY the summary, nothing else."#, "Look at a photo that was sent in the SMS thread, using the local vision model. Returns a \ visual description. Attachments appear in the transcript as `image #N` on a message line — \ pass that message's id and N as media_index. Videos and audio can't be viewed. \ - Example: `{contact_id: 42, message_id: 815, media_index: 0}`.", + Example: `{message_id: 815, media_index: 0}`.", serde_json::json!({ "type": "object", - "required": ["contact_id", "message_id"], + "required": ["message_id"], "properties": { - "contact_id": { "type": "integer", "description": "The thread's contact_id." }, "message_id": { "type": "integer", "description": "Id of the message carrying the photo." }, "media_index": { "type": "integer", "description": "Which attachment on that message (default 0)." } } @@ -4843,6 +4878,7 @@ Return ONLY the summary, nothing else."#, &file_path, user_id, &persona_id, + None, ) .instrument(loop_span.clone()) .await; @@ -4895,6 +4931,7 @@ Return ONLY the summary, nothing else."#, &file_path, user_id, &persona_id, + None, ) .instrument(loop_span.clone()) .await; @@ -5083,6 +5120,7 @@ Return ONLY the summary, nothing else."#, "", 0, "", + None, ) .await; messages.push(ChatMessage::tool_result(result)); @@ -5124,6 +5162,7 @@ Return ONLY the summary, nothing else."#, "", 0, "", + None, ) .await; messages.push(ChatMessage::tool_result(result)); @@ -5294,6 +5333,21 @@ mod tests { assert!(names.contains(&"look_at_photo")); } + #[test] + fn thread_tools_only_read_the_chats_own_thread() { + use serde_json::json; + // The bound thread is used; the model needn't (and can't) choose. + assert_eq!(InsightGenerator::bound_thread(&json!({}), Some(42)), Ok(42)); + assert_eq!( + InsightGenerator::bound_thread(&json!({"contact_id": 42}), Some(42)), + Ok(42) + ); + // Another thread is refused, even if the model names it. + assert!(InsightGenerator::bound_thread(&json!({"contact_id": 7}), Some(42)).is_err()); + // No bound thread (not a thread chat): nothing to read. + assert!(InsightGenerator::bound_thread(&json!({"contact_id": 42}), None).is_err()); + } + #[test] fn photo_path_is_safe_rejects_escapes() { assert!(InsightGenerator::photo_path_is_safe("2019/06/IMG_1.jpg")); @@ -5303,32 +5357,6 @@ mod tests { assert!(!InsightGenerator::photo_path_is_safe("")); } - #[test] - fn parse_moment_accepts_unix_and_local_wall_clock() { - let tz = chrono_tz::Tz::America__New_York; - assert_eq!( - InsightGenerator::parse_moment(&serde_json::json!({"timestamp": 1_700_000_000}), tz), - Some(1_700_000_000) - ); - // 2023-11-14 17:13 in New York (EST) = 22:13 UTC. - assert_eq!( - InsightGenerator::parse_moment( - &serde_json::json!({"datetime": "2023-11-14 17:13"}), - tz - ), - Some(1_699_999_980) - ); - // A bare date means midday, the least-wrong centre for a whole day. - assert_eq!( - InsightGenerator::parse_moment(&serde_json::json!({"datetime": "2023-11-14"}), tz), - Some(1_699_981_200) - ); - assert_eq!( - InsightGenerator::parse_moment(&serde_json::json!({}), tz), - None - ); - } - fn place(name: &str, description: &str) -> ApolloPlace { ApolloPlace { id: 1, diff --git a/src/ai/persona_chat.rs b/src/ai/persona_chat.rs index e8910ea..6b3bb8c 100644 --- a/src/ai/persona_chat.rs +++ b/src/ai/persona_chat.rs @@ -122,6 +122,40 @@ pub fn seed_messages_for_persona(system_prompt: &str) -> Vec { vec![ChatMessage::system(system_prompt.to_string())] } +/// The persona id a practice conversation is stored under. Practice mode +/// plays the SMS contact, not a persona, so the conversation belongs to no +/// persona row: deleting or editing personas can't hide or break it. +pub const PRACTICE_PERSONA_ID: &str = ""; + +/// Name shown for a practice conversation where a persona name would go. +pub const PRACTICE_PERSONA_NAME: &str = "Practice"; + +/// Whether a stored conversation is a practice one (plays the contact). +fn is_practice(row: &crate::database::persona_dao::PersonaChatRow) -> bool { + row.anchor + .as_ref() + .is_some_and(|a| a.mode == crate::database::AnchorMode::AsContact) +} + +/// The stand-in persona a practice turn runs with. Its prompt is never used +/// (`anchored_system_prompt` ignores the persona in practice mode) and it +/// can't correct facts — practice is offered no knowledge tools at all. +fn practice_persona(user_id: i32) -> crate::database::models::Persona { + crate::database::models::Persona { + id: 0, + user_id, + persona_id: PRACTICE_PERSONA_ID.to_string(), + name: PRACTICE_PERSONA_NAME.to_string(), + system_prompt: String::new(), + is_built_in: false, + include_all_memories: false, + created_at: 0, + updated_at: 0, + reviewed_only_facts: false, + allow_agent_corrections: false, + } +} + /// Parse a persisted transcript into the conversation tree. /// /// Two on-disk formats are accepted, mirroring the file chat's reader: @@ -602,8 +636,13 @@ impl PersonaChatSession { message_id: i64, mode: crate::database::AnchorMode, ) -> Result { - let persona_id = persona_id.trim(); - { + // Practice plays the contact; any persona the client sent is ignored. + let persona_id = if mode == crate::database::AnchorMode::AsContact { + PRACTICE_PERSONA_ID + } else { + persona_id.trim() + }; + if mode != crate::database::AnchorMode::AsContact { let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); let persona = dao .get_persona(user_id, persona_id) @@ -740,10 +779,13 @@ impl PersonaChatSession { .get_persona_chat(user_id, &conversation_id) .map_err(|e| PersonaChatError::Db(anyhow!("{e:?}")))? .ok_or_else(|| PersonaChatError::UnknownConversation(conversation_id.clone()))?; - let persona = dao - .get_persona(user_id, &row.persona_id) - .map_err(|e| PersonaChatError::Db(anyhow!("{e:?}")))? - .ok_or_else(|| PersonaChatError::UnknownPersona(row.persona_id.clone()))?; + let persona = if is_practice(&row) { + practice_persona(user_id) + } else { + dao.get_persona(user_id, &row.persona_id) + .map_err(|e| PersonaChatError::Db(anyhow!("{e:?}")))? + .ok_or_else(|| PersonaChatError::UnknownPersona(row.persona_id.clone()))? + }; (persona, row.persona_id) }; @@ -1004,6 +1046,8 @@ impl PersonaChatSession { "", user_id, &persona.persona_id, + // Thread tools only ever read this chat's own thread. + row.anchor.as_ref().map(|a| a.contact_id), max_iterations, &entry, &mut fitter, @@ -1119,9 +1163,11 @@ impl PersonaChatSession { ) -> Result> { let (rows, personas) = { let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); - let rows = dao - .list_persona_chats(user_id) - .map_err(|e| anyhow!("failed to list persona chats: {e:?}"))?; + let rows = match contact_id { + Some(cid) => dao.list_persona_chats_for_contact(user_id, cid), + None => dao.list_persona_chats(user_id), + } + .map_err(|e| anyhow!("failed to list persona chats: {e:?}"))?; let personas = dao .list_personas(user_id) .map_err(|e| anyhow!("failed to list personas: {e:?}"))?; @@ -1134,13 +1180,13 @@ impl PersonaChatSession { let mut out = Vec::with_capacity(rows.len()); for entry in rows { - if let Some(cid) = contact_id - && entry.anchor.as_ref().map(|a| a.contact_id) != Some(cid) - { - continue; - } - let Some(persona_name) = names.get(&entry.persona_id).cloned() else { - continue; + let persona_name = if is_practice(&entry) { + PRACTICE_PERSONA_NAME.to_string() + } else { + match names.get(&entry.persona_id) { + Some(name) => name.clone(), + None => continue, + } }; // A single unreadable transcript shouldn't take the whole list // down with it — skip the row and keep going. @@ -1372,6 +1418,9 @@ pub struct PersonaChatHistoryQuery { /// Body for POST /persona_chat/conversations — start a new conversation. #[derive(Debug, Deserialize)] pub struct PersonaChatCreateRequest { + /// Required except for a practice (`as_contact`) anchor, which plays + /// the contact rather than a persona. + #[serde(default)] pub persona_id: String, /// Anchor the conversation on a message in an SMS thread ("Continue /// with persona"). Omitted for an open chat. @@ -2038,6 +2087,24 @@ mod tests { assert!(tracker.claim("conv-a", "t7").is_ok()); } + #[test] + fn practice_create_request_needs_no_persona() { + let req: PersonaChatCreateRequest = serde_json::from_str( + r#"{"anchor":{"contact_id":42,"message_id":812,"mode":"as_contact"}}"#, + ) + .unwrap(); + assert_eq!(req.persona_id, ""); + } + + #[test] + fn practice_persona_is_inert() { + let p = practice_persona(7); + assert_eq!(p.persona_id, PRACTICE_PERSONA_ID); + assert_eq!(p.user_id, 7); + assert!(p.system_prompt.is_empty()); + assert!(!p.allow_agent_corrections); + } + #[test] fn create_request_anchor_mode_defaults_to_about() { use crate::database::AnchorMode; diff --git a/src/ai/sms_anchor.rs b/src/ai/sms_anchor.rs index 65018f3..8f69032 100644 --- a/src/ai/sms_anchor.rs +++ b/src/ai/sms_anchor.rs @@ -139,7 +139,6 @@ struct PromptParts { contact: String, when: String, thread: String, - cid: i64, mid: i64, } @@ -165,7 +164,6 @@ fn prompt_parts(anchor: &SmsAnchor, tz: Tz) -> PromptParts { contact, when, thread, - cid: anchor.contact_id, mid: anchor.message_id, } } @@ -196,7 +194,6 @@ fn about_prompt(persona_prompt: &str, parts: &PromptParts, user_name: &str) -> S contact, when, thread, - cid, mid, } = parts; format!( @@ -227,11 +224,11 @@ the lookups. Look at one or two photos that matter, not every one you find. - You can read and look, but you can't send photos or texts to anyone — \ don't offer to. -Tools for this thread (contact_id {cid}; #numbers below are message ids): -- get_thread_messages {{contact_id: {cid}, message_id: {mid}, direction: \"before\"}} \ -— earlier messages; \"after\" for what came next. -- look_at_message_image {{contact_id: {cid}, message_id, media_index}} — see a \ -photo sent in the thread (attachments are listed as `image #N`). +Tools for this thread (#numbers below are message ids): +- get_thread_messages {{message_id: {mid}, direction: \"before\"}} — earlier \ +messages; \"after\" for what came next. +- look_at_message_image {{message_id, media_index}} — see a photo sent in the \ +thread (attachments are listed as `image #N`). - get_photos_near_time {{timestamp}} — photos from {user_name}'s library taken \ around a moment, with their tags and the people in them. - look_at_photo {{file_path}} — see one of those library photos. @@ -247,23 +244,40 @@ fn practice_prompt(parts: &PromptParts, user_name: &str) -> String { contact, when, thread, - cid, mid, } = parts; format!( - "You are playing {contact} in a text conversation with {user_name}, so {user_name} can practise picking the conversation back up. This is a private rehearsal for {user_name}; the real {contact} is not part of it. + "You are playing {contact} in a text conversation with {user_name}, so \ +{user_name} can practise picking the conversation back up. This is a private \ +rehearsal for {user_name}; the real {contact} is not part of it. -Below is the real thread up to the message marked ▶ (sent {when}). Carry on from there as {contact}: {user_name}'s messages to you are their next texts, and every reply you write is {contact}'s next text. +Below is the real thread up to the message marked ▶ (sent {when}). Carry on \ +from there as {contact}: {user_name}'s messages to you are their next texts, \ +and every reply you write is {contact}'s next text. How to play it: -- Sound like {contact} does in the thread — their message length, slang, emoji, punctuation, humour, how fast they get to the point. Mirror them, not a generic texter. -- Write only the text itself. No narration, no stage directions, no 'As {contact}, I\'d say…'. Separate several short texts with a blank line. -- Stay true to what {contact} knows and feels in the thread. Don't invent big facts about their life — jobs, relationships, events — that the thread doesn't support; if asked about something they couldn't know, answer vaguely or deflect the way they plausibly would. -- React like a real person: it's fine to be busy, short, unconvinced, or to ask something back. Don't be more agreeable than {contact} actually is in the thread. +- Sound like {contact} does in the thread — their message length, slang, \ +emoji, punctuation, humour, how fast they get to the point. Mirror them, not \ +a generic texter. +- Write only the text itself. No narration, no stage directions, no 'As \ +{contact}, I\'d say…'. Separate several short texts with a blank line. +- Stay true to what {contact} knows and feels in the thread. Don't invent \ +big facts about their life — jobs, relationships, events — that the thread \ +doesn't support; if asked about something they couldn't know, answer vaguely \ +or deflect the way they plausibly would. +- React like a real person: it's fine to be busy, short, unconvinced, or to \ +ask something back. Don't be more agreeable than {contact} actually is in \ +the thread. - It is shortly after the ▶ message unless {user_name} says time has passed. -- If {user_name} steps out of the rehearsal ('pause', 'out of character', 'how did that sound?'), drop the role and answer briefly as a coach: honest, specific feedback on how their texts are landing. Go back to being {contact} when they carry on. +- If {user_name} steps out of the rehearsal ('pause', 'out of character', \ +'how did that sound?'), drop the role and answer briefly as a coach: honest, \ +specific feedback on how their texts are landing. Go back to being {contact} \ +when they carry on. -If you need to remember an earlier part of the thread, use get_thread_messages {{contact_id: {cid}, message_id: {mid}, direction: \"before\"}}; to see a photo sent in it (`image #N` on a line), use look_at_message_image {{contact_id: {cid}, message_id, media_index}}. Never mention either. +If you need to remember an earlier part of the thread, use \ +get_thread_messages {{message_id: {mid}, direction: \"before\"}}; to see a \ +photo sent in it (`image #N` on a line), use look_at_message_image \ +{{message_id, media_index}}. Never mention either. Thread between {user_name} and {contact}: {thread}" @@ -368,8 +382,10 @@ mod tests { }; let prompt = anchored_system_prompt("You are Journal.", &anchor, "Cam", Tz::UTC); assert!(prompt.starts_with("You are Journal.")); - assert!(prompt.contains("contact_id 42")); assert!(prompt.contains("message_id: 812")); + // The thread is bound server-side; the prompt doesn't hand the + // model a contact id to choose. + assert!(!prompt.contains("contact_id")); assert!(prompt.contains("You are not Sam")); assert!(prompt.ends_with("Sam: see you there")); } @@ -405,7 +421,8 @@ mod tests { !prompt.contains("You are Journal."), "a persona voice would bleed into the contact's" ); - assert!(prompt.contains("contact_id: 42")); + assert!(prompt.contains("message_id: 812")); + assert!(!prompt.contains("contact_id")); assert!(prompt.contains("out of character")); assert!(prompt.ends_with("Sam: see you there")); } diff --git a/src/ai/sms_client.rs b/src/ai/sms_client.rs index 48b66d4..505ac6f 100644 --- a/src/ai/sms_client.rs +++ b/src/ai/sms_client.rs @@ -425,10 +425,14 @@ impl SmsApiClient { }) } - /// Download one MMS attachment. `media_index` is the attachment's - /// position among the message's image/video/audio parts — the same - /// index SMS-API's `/media/{message}/{index}/file/` route takes. - pub async fn fetch_mms_media( + /// Download one MMS *image*. `media_index` is the attachment's position + /// among the message's image/video/audio parts — the same index + /// SMS-API's `/media/{message}/{index}/file/` route takes. + /// + /// Checks the response before reading the body, so pointing this at a + /// video or audio attachment costs a header round-trip, not the whole + /// file in memory; images over [`MMS_IMAGE_MAX_BYTES`] are refused too. + pub async fn fetch_mms_image( &self, contact_id: i64, message_id: i64, @@ -442,14 +446,29 @@ impl SmsApiClient { if let Some(token) = &self.token { request = request.header("Authorization", format!("Bearer {}", token)); } - let response = request.send().await?; + let mut response = request.send().await?; if !response.status().is_success() { return Err(anyhow::anyhow!( "MMS media request failed: {}", response.status() )); } - Ok(response.bytes().await?.to_vec()) + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + check_mms_image_response(content_type.as_deref(), response.content_length()) + .map_err(|e| anyhow::anyhow!(e))?; + // Content-Length can be absent (chunked); cap while reading too. + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await? { + if bytes.len() + chunk.len() > MMS_IMAGE_MAX_BYTES { + return Err(anyhow::anyhow!("attachment is larger than the image limit")); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) } pub async fn summarize_context( @@ -504,6 +523,29 @@ pub struct SmsMessage { pub is_sent: bool, } +/// Largest MMS image `fetch_mms_image` will download. Phone photos sent by +/// MMS are a few MB; anything past this is not worth decoding for a +/// 1024px vision description. +pub const MMS_IMAGE_MAX_BYTES: usize = 20 * 1024 * 1024; + +/// Decide from the response headers whether an MMS attachment is an image +/// worth downloading. A missing content type is allowed through (the +/// decoder decides); a declared non-image or an oversized length is not. +fn check_mms_image_response( + content_type: Option<&str>, + content_length: Option, +) -> Result<(), String> { + if let Some(ct) = content_type + && !ct.trim().to_ascii_lowercase().starts_with("image/") + { + return Err(format!("attachment is {ct}, not an image")); + } + if content_length.is_some_and(|n| n > MMS_IMAGE_MAX_BYTES as u64) { + return Err("attachment is larger than the image limit".to_string()); + } + Ok(()) +} + /// A thread participant as SMS-API's conversation endpoint describes it. #[derive(Debug, Clone, Deserialize)] pub struct ThreadContact { @@ -747,6 +789,23 @@ mod tests { ); } + #[test] + fn mms_image_check_refuses_non_images_and_huge_files_before_download() { + assert!(check_mms_image_response(Some("image/jpeg"), Some(2_000_000)).is_ok()); + assert!(check_mms_image_response(Some("IMAGE/PNG"), None).is_ok()); + assert!(check_mms_image_response(None, None).is_ok()); + assert!( + check_mms_image_response(Some("video/mp4"), Some(1)) + .unwrap_err() + .contains("video/mp4") + ); + assert!(check_mms_image_response(Some("audio/amr"), None).is_err()); + assert!( + check_mms_image_response(Some("image/jpeg"), Some(MMS_IMAGE_MAX_BYTES as u64 + 1)) + .is_err() + ); + } + #[test] fn thread_contact_falls_back_to_the_address() { let c = ThreadContact { diff --git a/src/database/persona_dao.rs b/src/database/persona_dao.rs index 7138f03..f3e7231 100644 --- a/src/database/persona_dao.rs +++ b/src/database/persona_dao.rs @@ -232,6 +232,15 @@ pub trait PersonaDao: Sync + Send { /// derived from the tree rather than denormalized into its own column. fn list_persona_chats(&mut self, user_id: i32) -> Result, DbError>; + /// Conversations anchored on one SMS thread, newest first. Backs the + /// Messages screen's per-thread list; served by + /// `idx_persona_chat_anchor_contact`. + fn list_persona_chats_for_contact( + &mut self, + user_id: i32, + contact_id: i64, + ) -> Result, DbError>; + /// Start a new conversation with a persona, returning its id. Several /// conversations with the same persona are expected, so this never /// reuses an existing row. @@ -524,6 +533,26 @@ impl PersonaDao for SqlitePersonaDao { .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } + fn list_persona_chats_for_contact( + &mut self, + uid: i32, + cid: i64, + ) -> Result, DbError> { + trace_db_call("query", "list_persona_chats_for_contact", |_span| { + use schema::persona_chat_conversations::dsl::*; + let mut conn = self.connection.lock().expect("PersonaDao lock"); + persona_chat_conversations + .filter(user_id.eq(uid)) + .filter(anchor_contact_id.eq(cid)) + .order(updated_at.desc()) + .select(persona_chat_select!()) + .load::(conn.deref_mut()) + .map(|rows| rows.into_iter().map(persona_chat_row).collect()) + .map_err(|e| anyhow::anyhow!("Query error: {}", e)) + }) + .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) + } + fn create_persona_chat( &mut self, uid: i32, @@ -932,6 +961,37 @@ mod tests { ); } + #[test] + fn persona_chat_list_for_contact_returns_only_that_thread() { + let (mut dao, uid) = dao_with_user("p11"); + let anchor = |contact_id| SmsAnchor { + contact_id, + contact_name: "x".to_string(), + message_id: 1, + timestamp: 0, + context: String::new(), + mode: AnchorMode::About, + }; + let older = dao + .create_anchored_persona_chat(uid, "journal", 100, &anchor(42)) + .unwrap(); + let _other = dao + .create_anchored_persona_chat(uid, "journal", 150, &anchor(7)) + .unwrap(); + let _open = dao.create_persona_chat(uid, "journal", 175).unwrap(); + let newer = dao + .create_anchored_persona_chat(uid, "journal", 200, &anchor(42)) + .unwrap(); + + let ids: Vec = dao + .list_persona_chats_for_contact(uid, 42) + .unwrap() + .into_iter() + .map(|r| r.conversation_id) + .collect(); + assert_eq!(ids, vec![newer, older]); + } + #[test] fn anchor_mode_reads_null_and_unknown_as_about() { assert_eq!(AnchorMode::from_column(None), AnchorMode::About);