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

+306
View File
@@ -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<i64>,
page: usize,
page_size: usize,
) -> Result<ThreadPage> {
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<ThreadWindow> {
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<ThreadMessage> = first.messages[..idx].to_vec();
let anchor = first.messages[idx].clone();
let mut older: Vec<ThreadMessage> = 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<ThreadMessage> = 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<Vec<u8>> {
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<ThreadAttachment>,
}
#[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<ThreadMessage>,
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<ThreadMessage>,
/// Index of the anchor message within `messages`.
pub anchor_index: usize,
}
#[derive(Deserialize)]
struct RawThreadPage {
contact: ThreadContact,
messages: Vec<RawThreadMessage>,
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<RawThreadPart>,
}
#[derive(Deserialize)]
struct RawThreadPart {
content_type: String,
#[serde(default)]
filename: String,
#[serde(default)]
text: String,
}
impl From<RawThreadPage> 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<RawThreadMessage> 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<SmsApiMessage>,
@@ -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<_>>(),
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);