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 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-10-20 19:38:08 -04:00
1 parent 185a1b561a
commit e029c81274
8 files changed
+383 -116

No files matched your search

+65 -6
View File
@@ -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<u64>,
) -> 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 {