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:
1 parent
fdbcc75391
commit
4ac574d4d2
11 files changed
+1389
-40
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user