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

+182 -7
View File
@@ -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<Option<crate::ai::handlers::ChatForkInfo>>,
/// Where in an SMS thread this conversation picked up. Absent for an
/// open chat.
#[serde(skip_serializing_if = "Option::is_none")]
pub anchor: Option<PersonaChatAnchorView>,
}
/// 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<PersonaChatAnchorView>,
}
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<String, PersonaChatError> {
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<Vec<PersonaConversationSummary>> {
///
/// `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<i64>,
) -> Result<Vec<PersonaConversationSummary>> {
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<PersonaChatAnchorRequest>,
}
/// 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<i64>,
}
/// 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<AppState>,
) -> impl Responder {
let user_id = claims.sub.parse::<i32>().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<PersonaChatListQuery>,
app_state: web::Data<AppState>,
) -> impl Responder {
let user_id = claims.sub.parse::<i32>().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");