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 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-10-17 18:07:49 -04:00
1 parent fdbcc75391
commit 4ac574d4d2
11 files changed
+1389 -40

No files matched your search

+160 -31
View File
@@ -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<SmsAnchor>,
}
/// 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<i64>,
Option<String>,
Option<i64>,
Option<i64>,
Option<String>,
);
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<String, DbError>;
/// 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<String, DbError>;
/// 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::<PersonaChatColumns>(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::<PersonaChatColumns>(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<String, DbError> {
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");