# Insight Chat Improvements Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make the insight + chat surface honor user-provided system prompts (no baked POV/format), gate optional tools on data presence, fix the `days_radius` no-op in `get_sms_messages`, and rewrite tool descriptions to a consistent convention. **Architecture:** Backend changes in ImageApi (Rust/Actix) split the generation system prompt into identity-vs-procedural blocks, add a `system_prompt` field to chat-turn requests with ephemeral swap-and-restore in append mode and persistence in amend mode, and gate tool registration on a per-turn `ToolGateOpts` struct. Frontend changes in FileViewer-React wire the active persona's prompt into every chat turn and add a one-shot "style note" composer affordance. **Tech Stack:** Rust (actix-web, diesel, kamadak-exif, serde), TypeScript (React Native, expo-router, AsyncStorage, react-native-sse), SQLite. Tests: `cargo test` for ImageApi. **Spec:** `specs/002-insight-chat-improvements.md` **Branch:** `feature/insight-chat-improvements` (already created in both `ImageApi/` and `FileViewer-React/`). --- ## File structure ### ImageApi (Rust) — files modified | File | Responsibility | |---|---| | `src/ai/insight_generator.rs` | Split system-prompt assembly; neutralize user message; widen `build_tool_definitions(opts: ToolGateOpts)`; add `current_gate_opts()` method to `InsightGenerator` for the chat path; description rewrites; SMS `search_messages` tool gains date+contact_id. | | `src/ai/insight_chat.rs` | Add `system_prompt: Option` to `ChatTurnRequest`; helper `apply_system_prompt_override` and its restore; pass the override through `chat_turn` + `chat_turn_stream`; persist on amend. | | `src/ai/handlers.rs` | Add `system_prompt: Option` to `ChatTurnHttpRequest`; plumb through both `chat_turn_handler` and `chat_stream_handler`. | | `src/ai/sms_client.rs` | Widen `fetch_messages_for_contact` to take `days_radius`. | | (no new files) | All changes are in-place. | ### FileViewer-React (TypeScript) — files modified | File | Responsibility | |---|---| | `hooks/usePersonas.tsx` | Update `DEFAULT_PERSONAS` system prompts to specify voice/shape explicitly. | | `hooks/useInsightChat.tsx` | `SendTurnOptions` gains `systemPromptOverride?: string \| null`; `sendTurn` reads selected persona and includes `system_prompt` in the request body. | | `components/InsightChatModal.tsx` | One-shot "Style note" input next to the composer; passes `systemPromptOverride` into `sendTurn`. | --- ## PR 1 — ImageApi: Split system-prompt assembly + neutralize user message **Files:** - Modify: `src/ai/insight_generator.rs:3291-3326` (system-prompt assembly) - Modify: `src/ai/insight_generator.rs:3356-3371` (user message construction) - Test: `src/ai/insight_generator.rs` (existing `#[cfg(test)] mod tests` at bottom) ### Task 1.1: Add a unit test for the new system-prompt builder (failing) - [ ] **Step 1: Add a failing test for the new builder** Add to the existing `#[cfg(test)] mod tests` block at the bottom of `src/ai/insight_generator.rs`: ```rust #[test] fn build_system_content_uses_custom_prompt_verbatim_for_identity() { let out = InsightGenerator::build_system_content( Some("You are a journal writer in first person, warm and reflective."), None, // no owner_id_note "", // empty fewshot block 6, // max_iterations ); // The custom prompt is the *only* identity language — no // "you are a personal photo memory assistant" anywhere. assert!( out.starts_with("You are a journal writer in first person, warm and reflective."), "custom prompt must lead the system content; got: {}", &out[..out.len().min(200)], ); assert!( !out.contains("personal photo memory assistant"), "framework identity must not leak when custom prompt is supplied" ); // Procedural block must still arrive. assert!(out.contains("Tool-use guidance")); assert!(out.contains("budget of 6")); } #[test] fn build_system_content_uses_neutral_default_when_no_custom() { let out = InsightGenerator::build_system_content(None, None, "", 6); // Neutral default — does not assert a strong identity. assert!(out.contains("reconstructing a memory from a photo")); assert!(!out.contains("personal photo memory assistant")); // Procedural block still present. assert!(out.contains("Tool-use guidance")); } #[test] fn build_system_content_includes_fewshot_and_owner_id() { let owner = "\n\nYour identity in the knowledge store: Alice (entity ID: 7)."; let fewshot = "## Examples\n\n### Example 1\n...\n\n---\n\n"; let out = InsightGenerator::build_system_content(None, Some(owner), fewshot, 6); assert!(out.contains("Alice (entity ID: 7)")); assert!(out.contains("## Examples")); } ``` - [ ] **Step 2: Run the test to verify it fails** ```bash cd /home/cameron/development/opencode/ImageApi cargo test --lib build_system_content -- --nocapture ``` Expected: compile error — `build_system_content` is not yet a function on `InsightGenerator`. ### Task 1.2: Implement `build_system_content` - [ ] **Step 1: Add the new helper as an associated function on `InsightGenerator`** In `src/ai/insight_generator.rs`, add this method inside the `impl InsightGenerator { ... }` block (above `generate_agentic_insight_for_photo`): ```rust /// Assemble the chat system prompt from two named blocks: /// /// 1. **Identity / voice / format** — `custom_system_prompt` verbatim /// when supplied, or a neutral default that doesn't fight a future /// persona. The framework never asserts an identity that could /// contradict the persona. /// 2. **Procedural scaffolding** — tool-use guidance, iteration budget, /// contact-filter rule. Identity-free; never asserts voice or shape. /// /// `owner_id_note` and `fewshot_block` are pre-rendered strings (they /// already encode their own headers / blank lines). Pass empty / None /// to skip. pub(crate) fn build_system_content( custom_system_prompt: Option<&str>, owner_id_note: Option<&str>, fewshot_block: &str, max_iterations: usize, ) -> String { let identity = match custom_system_prompt { Some(s) if !s.trim().is_empty() => s.trim().to_string(), _ => String::from( "You are reconstructing a memory from a photo. Use the gathered \ context to write a thoughtful summary; you decide voice, length, and shape." ), }; let owner = owner_id_note.unwrap_or(""); let procedural = format!( "Tool-use guidance:\n\ - You have a budget of {max_iterations} tool-calling iterations.\n\ - Call tools to gather context BEFORE writing your final answer; don't answer after one or two calls.\n\ - When calling get_sms_messages or search_rag, make at least one call WITHOUT a contact filter \ — surrounding events matter even when a contact is known.\n\ - Use recall_facts_for_photo + recall_entities to load any prior knowledge about subjects in the photo.\n\ - When you identify people / places / events / things, use store_entity + store_fact to grow the persistent memory.\n\ - A tool returning no results is informative; continue with the others.", max_iterations = max_iterations ); // Compose: identity, then owner note (if any), then fewshot block // (already self-delimited), then procedural. let mut out = identity; if !owner.is_empty() { out.push_str(owner); } out.push_str("\n\n"); if !fewshot_block.is_empty() { out.push_str(fewshot_block); } out.push_str(&procedural); out } ``` - [ ] **Step 2: Run the tests to verify they pass** ```bash cd /home/cameron/development/opencode/ImageApi cargo test --lib build_system_content -- --nocapture ``` Expected: all three tests pass. ### Task 1.3: Wire `build_system_content` into the agentic flow - [ ] **Step 1: Replace the inlined system-prompt construction** In `src/ai/insight_generator.rs`, find the block at lines 3291-3326 that begins with `// 8. Build system message` and ends with `let system_content = if let Some(ref custom) = custom_system_prompt { ... };`. Replace that whole block (everything from `let owner_id_note = match owner_entity_id { ... }` through the end of `let system_content = ...;`) with: ```rust // 8. Build system message via the two-block helper. Custom prompt // (when supplied) is the authoritative identity — the framework // never appends a competing "you are a personal photo memory // assistant" line. The procedural block stays identity-free. let owner_id_note = match owner_entity_id { Some(id) => Some(format!( "\n\nYour identity in the knowledge store: {name} (entity ID: {id}). \ When storing facts where you ({name}) are the object — for example, someone is your friend, \ sibling, or colleague — use subject_entity_id for the other person and set object_value to \ \"{name}\" (or use store_fact with the other person as subject). When storing facts about \ {name} directly, use {id} as the subject_entity_id.", name = owner_name, id = id )), None => None, }; let fewshot_block = Self::render_fewshot_examples(&fewshot_examples); let system_content = Self::build_system_content( custom_system_prompt.as_deref(), owner_id_note.as_deref(), &fewshot_block, max_iterations, ); ``` - [ ] **Step 2: Build to confirm the wire-up compiles** ```bash cd /home/cameron/development/opencode/ImageApi cargo build 2>&1 | tail -20 ``` Expected: clean build (no errors). - [ ] **Step 3: Re-run the unit tests to confirm regression-free** ```bash cd /home/cameron/development/opencode/ImageApi cargo test --lib insight_generator -- --nocapture 2>&1 | tail -30 ``` Expected: all tests in `insight_generator` pass. ### Task 1.4: Neutralize the user message - [ ] **Step 1: Replace the user-content template** In `src/ai/insight_generator.rs` around line 3356, find: ```rust let user_content = format!( "{visual_block}Please analyze this photo and gather any relevant context from the surrounding weeks.\n\n\ Photo file path: {}\n\ Date taken: {}\n\ {}\n\ {}\n\ {}\n\n\ Use the available tools to gather more context about this moment (messages, calendar events, location history, etc.), \ then write a detailed insight with a title and summary.", file_path, date_taken.format("%B %d, %Y"), contact_info, gps_info, tags_info, visual_block = visual_block, ); ``` Replace with: ```rust // Context-only payload — no output-shape prescription. The persona / // custom_system_prompt owns voice, length, and structure. The "title // and summary" claim that used to live here was unused (the title is // regenerated post-hoc from the summary by generate_photo_title). let user_content = format!( "{visual_block}Photo file path: {}\n\ Date taken: {}\n\ {}\n\ {}\n\ {}\n\n\ Gather context with the available tools, then respond.", file_path, date_taken.format("%B %d, %Y"), contact_info, gps_info, tags_info, visual_block = visual_block, ); ``` - [ ] **Step 2: Build to confirm the change compiles** ```bash cd /home/cameron/development/opencode/ImageApi cargo build 2>&1 | tail -10 ``` Expected: clean build. ### Task 1.5: Commit PR 1 - [ ] **Step 1: Run the full test suite** ```bash cd /home/cameron/development/opencode/ImageApi cargo test --lib 2>&1 | tail -20 ``` Expected: all tests pass. - [ ] **Step 2: Stage and commit** ```bash cd /home/cameron/development/opencode/ImageApi git add src/ai/insight_generator.rs git commit -m "$(cat <<'EOF' insight-chat: split generation system prompt into identity + procedural blocks The framework no longer asserts "you are a personal photo memory assistant" alongside a user-supplied custom_system_prompt — the persona is the authoritative identity. The procedural block (tool-use guidance, iteration budget) stays identity-free. The user message also stops asking for "a detailed insight with a title and summary" since the title is regenerated post-hoc anyway and the wording was constraining voice for no data-model benefit. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## PR 2 — ImageApi: `system_prompt` field on chat request + swap/restore + amend persistence **Files:** - Modify: `src/ai/insight_chat.rs:30-51` (`ChatTurnRequest` struct) - Modify: `src/ai/insight_chat.rs:208-550` (`chat_turn`) - Modify: `src/ai/insight_chat.rs:659-1016` (`run_streaming_turn`) - Modify: `src/ai/insight_chat.rs:1122-1154` (annotate/restore helpers — companion to a new helper) - Modify: `src/ai/handlers.rs:621-645` (`ChatTurnHttpRequest`) - Modify: `src/ai/handlers.rs:686-699` and `:900-913` (request mapping in both handlers) - Test: `src/ai/insight_chat.rs` (existing `#[cfg(test)] mod tests` at bottom) ### Task 2.1: Failing tests for the override behavior - [ ] **Step 1: Add unit tests for the new helpers** Add to the `#[cfg(test)] mod tests` block at the bottom of `src/ai/insight_chat.rs`: ```rust #[test] fn apply_override_replaces_existing_system_message() { let mut msgs = vec![ ChatMessage::system("original persona"), ChatMessage::user("hi"), ]; let stash = apply_system_prompt_override(&mut msgs, Some("new persona")); assert_eq!(msgs[0].content, "new persona"); // Stash carries the data we'll need to restore. match stash { Some(SystemPromptStash::Replaced { original }) => { assert_eq!(original, "original persona"); } other => panic!("expected Replaced, got {:?}", other), } } #[test] fn apply_override_prepends_synthetic_when_missing() { let mut msgs = vec![ChatMessage::user("hi")]; let stash = apply_system_prompt_override(&mut msgs, Some("new persona")); assert_eq!(msgs.len(), 2); assert_eq!(msgs[0].role, "system"); assert_eq!(msgs[0].content, "new persona"); assert!(matches!(stash, Some(SystemPromptStash::Prepended))); } #[test] fn apply_override_no_op_when_none() { let mut msgs = vec![ ChatMessage::system("sys"), ChatMessage::user("hi"), ]; let stash = apply_system_prompt_override(&mut msgs, None); assert!(stash.is_none()); assert_eq!(msgs[0].content, "sys"); } #[test] fn apply_override_no_op_for_empty_string() { let mut msgs = vec![ChatMessage::system("sys")]; let stash = apply_system_prompt_override(&mut msgs, Some("")); assert!(stash.is_none()); assert_eq!(msgs[0].content, "sys"); } #[test] fn restore_override_replaces_back() { let mut msgs = vec![ ChatMessage::system("new"), ChatMessage::user("hi"), ]; restore_system_prompt_override( &mut msgs, Some(SystemPromptStash::Replaced { original: "original".to_string() }), ); assert_eq!(msgs[0].content, "original"); assert_eq!(msgs.len(), 2); } #[test] fn restore_override_pops_synthetic() { let mut msgs = vec![ ChatMessage::system("new"), ChatMessage::user("hi"), ]; restore_system_prompt_override(&mut msgs, Some(SystemPromptStash::Prepended)); assert_eq!(msgs.len(), 1); assert_eq!(msgs[0].role, "user"); } ``` - [ ] **Step 2: Run the tests to verify they fail** ```bash cd /home/cameron/development/opencode/ImageApi cargo test --lib insight_chat::tests::apply_override -- --nocapture ``` Expected: compile errors — `apply_system_prompt_override`, `restore_system_prompt_override`, and `SystemPromptStash` are not yet defined. ### Task 2.2: Implement override apply/restore helpers - [ ] **Step 1: Add the stash type and the two helpers** In `src/ai/insight_chat.rs`, just below the `restore_system_content` function (around line 1154), add: ```rust /// Receipt produced by [`apply_system_prompt_override`] so the caller can /// undo the override before persistence. Two variants because we either /// replaced an existing system message (need its original content) or /// prepended a synthetic one (need to pop it). #[derive(Debug)] pub(crate) enum SystemPromptStash { Replaced { original: String }, Prepended, } /// Apply a per-turn `system_prompt` override to `messages` so the model /// sees the requested persona for this turn. Returns a stash the caller /// must pass to [`restore_system_prompt_override`] before persisting the /// transcript — without that step, append-mode chat would silently /// rewrite the stored persona. /// /// No-op (returns `None`) when `override_prompt` is `None` or empty. pub(crate) fn apply_system_prompt_override( messages: &mut Vec, override_prompt: Option<&str>, ) -> Option { let prompt = match override_prompt { Some(s) if !s.trim().is_empty() => s.trim().to_string(), _ => return None, }; if let Some(first) = messages.first_mut() && first.role == "system" { let original = std::mem::replace(&mut first.content, prompt); return Some(SystemPromptStash::Replaced { original }); } messages.insert(0, ChatMessage::system(prompt)); Some(SystemPromptStash::Prepended) } /// Undo an override previously applied by [`apply_system_prompt_override`]. /// No-op when `stash` is `None`. pub(crate) fn restore_system_prompt_override( messages: &mut Vec, stash: Option, ) { let Some(stash) = stash else { return }; match stash { SystemPromptStash::Replaced { original } => { if let Some(first) = messages.first_mut() && first.role == "system" { first.content = original; } } SystemPromptStash::Prepended => { if !messages.is_empty() && messages[0].role == "system" { messages.remove(0); } } } } ``` - [ ] **Step 2: Run the unit tests** ```bash cd /home/cameron/development/opencode/ImageApi cargo test --lib insight_chat::tests -- --nocapture 2>&1 | tail -30 ``` Expected: all six new tests pass; existing tests still pass. ### Task 2.3: Add `system_prompt` to `ChatTurnRequest` - [ ] **Step 1: Add the field** In `src/ai/insight_chat.rs`, find the `ChatTurnRequest` struct around line 31 and add `system_prompt` between `max_iterations` and `amend`: ```rust #[derive(Debug)] pub struct ChatTurnRequest { pub library_id: i32, pub file_path: String, pub user_message: String, /// Override the model id. Local mode: an Ollama model name. Hybrid: /// an OpenRouter id. None defers to the stored insight's `model_version`. pub model: Option, /// Override the backend used for this turn. None defers to the stored /// insight's `backend`. Switching `local -> hybrid` is rejected in v1. pub backend: Option, pub num_ctx: Option, pub temperature: Option, pub top_p: Option, pub top_k: Option, pub min_p: Option, pub max_iterations: Option, /// Per-turn system-prompt override. In append mode (default), applied /// ephemerally — original system message restored before persistence. /// In amend mode, persisted into the new insight row's system message. /// None / empty = no change. pub system_prompt: Option, /// When true, write a new insight row (regenerating title) instead of /// updating training_messages on the existing row. pub amend: bool, } ``` - [ ] **Step 2: Build to confirm** ```bash cd /home/cameron/development/opencode/ImageApi cargo build 2>&1 | tail -10 ``` Expected: clean build (the new field is unused so far; that's fine). ### Task 2.4: Apply override in `chat_turn` (non-streaming) - [ ] **Step 1: Hook the override into the loop** In `src/ai/insight_chat.rs`, in the `chat_turn` function around line 386, find the line: ```rust // 7. Append the new user turn. messages.push(ChatMessage::user(req.user_message.clone())); // Temporarily annotate the system message with this turn's iteration // budget so the model knows how many tool-calling rounds it has. We // restore the original content before persistence so the note doesn't // accumulate across turns. let original_system_content = annotate_system_with_budget(&mut messages, max_iterations); ``` Replace with: ```rust // 7. Append the new user turn. messages.push(ChatMessage::user(req.user_message.clone())); // Apply per-turn system-prompt override BEFORE the budget annotation // so the budget note attaches to the override, not the original. // The stash is consumed below before persistence (append mode) or // dropped (amend mode, where the override stays in place). let override_stash = apply_system_prompt_override(&mut messages, req.system_prompt.as_deref()); // Temporarily annotate the system message with this turn's iteration // budget so the model knows how many tool-calling rounds it has. We // restore the original content before persistence so the note doesn't // accumulate across turns. let original_system_content = annotate_system_with_budget(&mut messages, max_iterations); ``` - [ ] **Step 2: Restore appropriately before persistence** Still in `chat_turn`, find: ```rust // Drop the per-turn iteration-budget note from the system message // before we persist so it doesn't snowball on each subsequent turn. restore_system_content(&mut messages, original_system_content); // 9. Persist. Append mode rewrites the JSON blob in place; amend // mode regenerates the title and inserts a new insight row, // relying on store_insight to flip prior rows' is_current=false. let json = serde_json::to_string(&messages) .map_err(|e| anyhow!("failed to serialize chat history: {}", e))?; ``` Replace with: ```rust // Drop the per-turn iteration-budget note from the system message // before we persist so it doesn't snowball on each subsequent turn. restore_system_content(&mut messages, original_system_content); // Append mode: undo the per-turn system-prompt override so the // stored transcript keeps the original baked persona. Amend mode: // keep the override in place — it becomes the new insight row's // system message. if !req.amend { restore_system_prompt_override(&mut messages, override_stash); } // 9. Persist. Append mode rewrites the JSON blob in place; amend // mode regenerates the title and inserts a new insight row, // relying on store_insight to flip prior rows' is_current=false. let json = serde_json::to_string(&messages) .map_err(|e| anyhow!("failed to serialize chat history: {}", e))?; ``` - [ ] **Step 3: Build** ```bash cd /home/cameron/development/opencode/ImageApi cargo build 2>&1 | tail -10 ``` Expected: clean build. (Note: with `req.amend = false`, the local `override_stash` binding may produce an "unused if amend mode" warning when `amend` is true — silenced by the binding existing in the conditional path. Should compile clean.) ### Task 2.5: Apply override in `run_streaming_turn` - [ ] **Step 1: Hook into the streaming loop** In `src/ai/insight_chat.rs`, in `run_streaming_turn` around line 813, find: ```rust messages.push(ChatMessage::user(req.user_message.clone())); let original_system_content = annotate_system_with_budget(&mut messages, max_iterations); ``` Replace with: ```rust messages.push(ChatMessage::user(req.user_message.clone())); // Mirror chat_turn: per-turn override goes on first, budget note next. let override_stash = apply_system_prompt_override(&mut messages, req.system_prompt.as_deref()); let original_system_content = annotate_system_with_budget(&mut messages, max_iterations); ``` - [ ] **Step 2: Restore before streaming-mode persistence** Still in `run_streaming_turn`, find: ```rust // Drop the per-turn iteration-budget note from the system message // before we persist so it doesn't snowball on each subsequent turn. restore_system_content(&mut messages, original_system_content); // Persist. let json = serde_json::to_string(&messages) .map_err(|e| anyhow!("failed to serialize chat history: {}", e))?; ``` Replace with: ```rust // Drop the per-turn iteration-budget note from the system message // before we persist so it doesn't snowball on each subsequent turn. restore_system_content(&mut messages, original_system_content); // Append mode: undo the per-turn system-prompt override (mirrors // chat_turn). Amend mode: keep the override — it becomes the new // insight row's system message. if !req.amend { restore_system_prompt_override(&mut messages, override_stash); } // Persist. let json = serde_json::to_string(&messages) .map_err(|e| anyhow!("failed to serialize chat history: {}", e))?; ``` - [ ] **Step 3: Build** ```bash cd /home/cameron/development/opencode/ImageApi cargo build 2>&1 | tail -10 ``` Expected: clean build. ### Task 2.6: Wire `system_prompt` through HTTP handlers - [ ] **Step 1: Add to `ChatTurnHttpRequest`** In `src/ai/handlers.rs` around line 622, find the `ChatTurnHttpRequest` struct and add the field: ```rust #[derive(Debug, Deserialize)] pub struct ChatTurnHttpRequest { pub file_path: String, #[serde(default)] pub library: Option, pub user_message: String, #[serde(default)] pub model: Option, #[serde(default)] pub backend: Option, #[serde(default)] pub num_ctx: Option, #[serde(default)] pub temperature: Option, #[serde(default)] pub top_p: Option, #[serde(default)] pub top_k: Option, #[serde(default)] pub min_p: Option, #[serde(default)] pub max_iterations: Option, /// Per-turn system-prompt override. Ephemeral in append mode, /// persisted in amend mode. See ChatTurnRequest for semantics. #[serde(default)] pub system_prompt: Option, #[serde(default)] pub amend: bool, } ``` - [ ] **Step 2: Plumb into the non-streaming handler** In `src/ai/handlers.rs` around line 686, find: ```rust let chat_req = ChatTurnRequest { library_id: library.id, file_path: request.file_path.clone(), user_message: request.user_message.clone(), model: request.model.clone(), backend: request.backend.clone(), num_ctx: request.num_ctx, temperature: request.temperature, top_p: request.top_p, top_k: request.top_k, min_p: request.min_p, max_iterations: request.max_iterations, amend: request.amend, }; ``` Replace with: ```rust let chat_req = ChatTurnRequest { library_id: library.id, file_path: request.file_path.clone(), user_message: request.user_message.clone(), model: request.model.clone(), backend: request.backend.clone(), num_ctx: request.num_ctx, temperature: request.temperature, top_p: request.top_p, top_k: request.top_k, min_p: request.min_p, max_iterations: request.max_iterations, system_prompt: request.system_prompt.clone(), amend: request.amend, }; ``` - [ ] **Step 3: Plumb into the streaming handler** Same file, around line 900, find the identical block in `chat_stream_handler` and apply the same change — add `system_prompt: request.system_prompt.clone(),` between `max_iterations` and `amend`. - [ ] **Step 4: Build** ```bash cd /home/cameron/development/opencode/ImageApi cargo build 2>&1 | tail -10 ``` Expected: clean build. ### Task 2.7: Add a swap-restore round-trip test - [ ] **Step 1: Failing test for round-trip preservation** Add to the `#[cfg(test)] mod tests` block in `src/ai/insight_chat.rs`: ```rust #[test] fn override_round_trip_preserves_original_system_message() { let mut msgs = vec![ ChatMessage::system("original persona"), ChatMessage::user("first user"), assistant_text("first reply"), ]; let stash = apply_system_prompt_override(&mut msgs, Some("ephemeral persona")); assert_eq!(msgs[0].content, "ephemeral persona"); restore_system_prompt_override(&mut msgs, stash); assert_eq!(msgs[0].content, "original persona"); assert_eq!(msgs.len(), 3); assert_eq!(msgs[1].role, "user"); assert_eq!(msgs[2].role, "assistant"); } #[test] fn override_with_synthetic_round_trip_drops_extra_message() { let mut msgs = vec![ChatMessage::user("first user")]; let stash = apply_system_prompt_override(&mut msgs, Some("ephemeral")); assert_eq!(msgs.len(), 2); assert_eq!(msgs[0].role, "system"); restore_system_prompt_override(&mut msgs, stash); assert_eq!(msgs.len(), 1); assert_eq!(msgs[0].role, "user"); } ``` - [ ] **Step 2: Run them** ```bash cd /home/cameron/development/opencode/ImageApi cargo test --lib insight_chat::tests::override -- --nocapture 2>&1 | tail -20 ``` Expected: both pass. ### Task 2.8: Commit PR 2 - [ ] **Step 1: Run the full ImageApi test suite** ```bash cd /home/cameron/development/opencode/ImageApi cargo test --lib 2>&1 | tail -20 ``` Expected: all tests pass. - [ ] **Step 2: Stage and commit** ```bash cd /home/cameron/development/opencode/ImageApi git add src/ai/insight_chat.rs src/ai/handlers.rs git commit -m "$(cat <<'EOF' insight-chat: per-turn system_prompt override on chat continuation Append mode: applied ephemerally — original system message restored before persistence so re-opens see the baked persona. Amend mode: override stays in place and becomes the new insight row's system message. Pattern mirrors annotate_system_with_budget. Adds system_prompt field on both ChatTurnHttpRequest and ChatTurnRequest; plumbs through chat_turn and chat_turn_stream identically. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## PR 3 — ImageApi: `fetch_messages_for_contact` honors `days_radius` **Files:** - Modify: `src/ai/sms_client.rs:23-76` (`fetch_messages_for_contact`) - Modify: `src/ai/insight_generator.rs:1799-1869` (`tool_get_sms_messages`) - Test: `src/ai/sms_client.rs` (new tests at bottom) ### Task 3.1: Failing test for the days_radius window math - [ ] **Step 1: Add test for the helper that computes the window** To `src/ai/sms_client.rs`, add at the bottom: ```rust #[cfg(test)] mod tests { use super::*; #[test] fn window_for_radius_produces_2n_day_span() { // Helper function under test below. let center: i64 = 1_700_000_000; // 2023-11-14 22:13:20 UTC let (start, end) = SmsApiClient::window_for_radius(center, 7); assert_eq!(end - start, 14 * 86400); assert_eq!(start + 7 * 86400, center); assert_eq!(end - 7 * 86400, center); } #[test] fn window_for_radius_clamps_negative_to_one() { // Days_radius < 1 falls through to a 1-day radius (so the call // doesn't return 0 messages from a degenerate window). let (start, end) = SmsApiClient::window_for_radius(100_000, 0); assert_eq!(end - start, 2 * 86400); } } ``` - [ ] **Step 2: Run to verify failure** ```bash cd /home/cameron/development/opencode/ImageApi cargo test --lib sms_client::tests -- --nocapture ``` Expected: compile error — `window_for_radius` doesn't exist. ### Task 3.2: Implement `window_for_radius` and widen `fetch_messages_for_contact` - [ ] **Step 1: Add the helper and widen the function** In `src/ai/sms_client.rs`, replace the existing `fetch_messages_for_contact` (lines 26-76) with: ```rust /// Compute a `[start, end]` unix-second window of `2 * radius_days` /// centered on `center_ts`. `radius_days < 1` is clamped to 1 to avoid /// degenerate zero-width windows. pub(crate) fn window_for_radius(center_ts: i64, radius_days: i64) -> (i64, i64) { let r = radius_days.max(1); let span = r * 86400; (center_ts - span, center_ts + span) } /// Fetch messages for a specific contact within ±`radius_days` of the /// given timestamp. Falls back to all contacts when no messages found /// for the named contact. Sorted by proximity to the center timestamp. pub async fn fetch_messages_for_contact( &self, contact: Option<&str>, center_timestamp: i64, radius_days: i64, ) -> Result> { let (start_ts, end_ts) = Self::window_for_radius(center_timestamp, radius_days); let center_dt = chrono::DateTime::from_timestamp(center_timestamp, 0) .ok_or_else(|| anyhow::anyhow!("Invalid timestamp"))?; // If contact specified, try fetching for that contact first if let Some(contact_name) = contact { log::info!( "Fetching SMS for contact: {} (±{} days from {})", contact_name, radius_days.max(1), center_dt.format("%Y-%m-%d %H:%M:%S") ); let messages = self .fetch_messages(start_ts, end_ts, Some(contact_name), Some(center_timestamp)) .await?; if !messages.is_empty() { log::info!( "Found {} messages for contact {}", messages.len(), contact_name ); return Ok(messages); } log::info!( "No messages found for contact {}, falling back to all contacts", contact_name ); } // Fallback to all contacts log::info!( "Fetching all SMS messages (±{} days from {})", radius_days.max(1), center_dt.format("%Y-%m-%d %H:%M:%S") ); self.fetch_messages(start_ts, end_ts, None, Some(center_timestamp)) .await } ``` - [ ] **Step 2: Run the new tests** ```bash cd /home/cameron/development/opencode/ImageApi cargo test --lib sms_client::tests -- --nocapture 2>&1 | tail -10 ``` Expected: both pass. ### Task 3.3: Update callers to pass `days_radius` - [ ] **Step 1: Update `tool_get_sms_messages` to pass `days_radius`** In `src/ai/insight_generator.rs` around line 1836-1840, find: ```rust match self .sms_client .fetch_messages_for_contact(contact.as_deref(), timestamp) .await ``` Replace with: ```rust match self .sms_client .fetch_messages_for_contact(contact.as_deref(), timestamp, days_radius) .await ``` - [ ] **Step 2: Find any other callers and update them** ```bash cd /home/cameron/development/opencode/ImageApi grep -rn "fetch_messages_for_contact" src/ --include="*.rs" ``` Expected output: only the one caller in `insight_generator.rs` (now updated) and the definition in `sms_client.rs`. If any other call sites exist, update each by adding a third argument matching the call's intent — most likely `4` (the historical hardcoded value). - [ ] **Step 3: Build** ```bash cd /home/cameron/development/opencode/ImageApi cargo build 2>&1 | tail -10 ``` Expected: clean build. ### Task 3.4: Commit PR 3 - [ ] **Step 1: Run the full test suite** ```bash cd /home/cameron/development/opencode/ImageApi cargo test --lib 2>&1 | tail -20 ``` Expected: all tests pass. - [ ] **Step 2: Stage and commit** ```bash cd /home/cameron/development/opencode/ImageApi git add src/ai/sms_client.rs src/ai/insight_generator.rs git commit -m "$(cat <<'EOF' insight-chat: get_sms_messages tool now honors days_radius The agentic tool definition advertised a days_radius parameter but sms_client::fetch_messages_for_contact was hardcoded to ±4 days, silently ignoring whatever value the LLM chose. Plumb the parameter through; default 4 retained at the tool level for back-compat. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## PR 4 — ImageApi: `ToolGateOpts` + per-tool description rewrites + SMS tool param expansion **Files:** - Modify: `src/ai/insight_generator.rs:2488-2772` (`build_tool_definitions` + tool definitions) - Modify: `src/ai/insight_generator.rs:1718-1796` (`tool_search_messages` to accept new params) - Modify: `src/ai/insight_chat.rs` — chat service must compute `ToolGateOpts` per turn - Test: `src/ai/insight_generator.rs` (new tests in existing `mod tests`) ### Task 4.1: Define `ToolGateOpts` - [ ] **Step 1: Add the struct** In `src/ai/insight_generator.rs`, just above the `impl InsightGenerator { ... }` block that contains `build_tool_definitions` (search for `pub(crate) fn build_tool_definitions`), add: ```rust /// Per-call gating flags for `build_tool_definitions`. Tools whose backing /// data is empty (or whose env-var guard is unset) are dropped from the /// catalog so the LLM doesn't reach for a tool that always returns "No /// results found." — that wastes iteration budget. #[derive(Debug, Clone, Copy, Default)] pub struct ToolGateOpts { pub has_vision: bool, pub apollo_enabled: bool, pub daily_summaries_present: bool, pub calendar_present: bool, pub location_history_present: bool, } ``` - [ ] **Step 2: Build to confirm** ```bash cd /home/cameron/development/opencode/ImageApi cargo build 2>&1 | tail -10 ``` Expected: clean build. (Unused-struct warning at this stage is fine.) ### Task 4.2: Failing test for the new `build_tool_definitions(opts)` signature - [ ] **Step 1: Test that gates drop tools when flags are off** Add to the `#[cfg(test)] mod tests` block in `src/ai/insight_generator.rs` (near the existing `summarize_*` tests): ```rust #[test] fn build_tool_definitions_drops_gated_tools() { let opts = ToolGateOpts { has_vision: false, apollo_enabled: false, daily_summaries_present: false, calendar_present: false, location_history_present: false, }; let tools = InsightGenerator::build_tool_definitions(opts); let names: Vec<&str> = tools.iter().map(|t| t.function.name.as_str()).collect(); // Always-on tools survive. assert!(names.contains(&"search_messages")); assert!(names.contains(&"get_sms_messages")); assert!(names.contains(&"get_file_tags")); assert!(names.contains(&"reverse_geocode")); assert!(names.contains(&"get_current_datetime")); assert!(names.contains(&"recall_entities")); assert!(names.contains(&"recall_facts_for_photo")); assert!(names.contains(&"store_entity")); assert!(names.contains(&"store_fact")); // Gated tools are absent. assert!(!names.contains(&"describe_photo")); assert!(!names.contains(&"get_personal_place_at")); assert!(!names.contains(&"search_rag")); assert!(!names.contains(&"get_calendar_events")); assert!(!names.contains(&"get_location_history")); } #[test] fn build_tool_definitions_includes_gated_tools_when_present() { let opts = ToolGateOpts { has_vision: true, apollo_enabled: true, daily_summaries_present: true, calendar_present: true, location_history_present: true, }; let tools = InsightGenerator::build_tool_definitions(opts); let names: Vec<&str> = tools.iter().map(|t| t.function.name.as_str()).collect(); assert!(names.contains(&"describe_photo")); assert!(names.contains(&"get_personal_place_at")); assert!(names.contains(&"search_rag")); assert!(names.contains(&"get_calendar_events")); assert!(names.contains(&"get_location_history")); } ``` - [ ] **Step 2: Run to verify failure** ```bash cd /home/cameron/development/opencode/ImageApi cargo test --lib build_tool_definitions -- --nocapture ``` Expected: compile error — current signature is `(has_vision: bool, apollo_enabled: bool)`, tests pass `ToolGateOpts`. ### Task 4.3: Widen `build_tool_definitions` and add gating - [ ] **Step 1: Replace the function signature and rewrite description bodies** In `src/ai/insight_generator.rs`, replace the entire `build_tool_definitions` function (around lines 2488-2772) with: ```rust /// Build the list of tool definitions for the agentic loop, gated by /// `opts`. Always-on tools: `search_messages`, `get_sms_messages`, /// `get_file_tags`, `reverse_geocode`, `get_current_datetime`, the /// four knowledge-memory tools. Conditional: `describe_photo` (vision /// model), `get_personal_place_at` (Apollo configured), `search_rag` /// (daily_summaries populated), `get_calendar_events` (calendar /// populated), `get_location_history` (location history populated). pub(crate) fn build_tool_definitions(opts: ToolGateOpts) -> Vec { let mut tools: Vec = Vec::new(); if opts.daily_summaries_present { tools.push(Tool::function( "search_rag", "Date-anchored semantic search over the user's daily-summary corpus. \ Returns up to `limit` summaries most semantically similar to `query`, \ weighted toward summaries near `date`. For raw message text across all \ time, prefer `search_messages`. \ Examples: `{query: \"family dinner\", date: \"2018-12-24\"}` — what \ daily summaries near Christmas Eve mention family / dinner / gathering. \ `{query: \"work travel\", date: \"2019-06-15\", contact: \"Alice\"}` — \ narrowed to summaries that involve Alice.", serde_json::json!({ "type": "object", "required": ["query", "date"], "properties": { "query": { "type": "string", "description": "Free-text query, semantically matched." }, "date": { "type": "string", "description": "Anchor date, YYYY-MM-DD. Summaries near this date rank higher." }, "contact": { "type": "string", "description": "Optional contact name to bias toward conversations with that person." }, "limit": { "type": "integer", "description": "Max summaries to return (default 10, max 25)." } } }), )); } tools.push(Tool::function( "search_messages", "Search SMS/MMS message bodies. Modes: `fts5` (keyword + phrase + prefix + AND/OR/NOT + NEAR proximity), \ `semantic` (embedding similarity, requires generated embeddings), `hybrid` (RRF merge, recommended; \ degrades to fts5 when embeddings absent). Optional `start_ts` / `end_ts` (real-UTC unix seconds) and \ `contact_id` filters. For pure date / contact browsing without keywords, prefer `get_sms_messages`. \ Examples: `{query: \"trader joe's\"}` — phrase across all time. \ `{query: \"dinner\", contact_id: 42, start_ts: 1700000000, end_ts: 1700604800}` — keyword within a contact and a week. \ `{query: \"NEAR(meeting work, 5)\"}` — proximity search.", serde_json::json!({ "type": "object", "required": ["query"], "properties": { "query": { "type": "string", "description": "Search query. Min 3 chars. fts5 supports phrase (\"\"), prefix (*), AND/OR/NOT, and NEAR proximity." }, "mode": { "type": "string", "enum": ["fts5", "semantic", "hybrid"], "description": "Search strategy. Default: hybrid." }, "limit": { "type": "integer", "description": "Max results (default 20, max 50)." }, "contact_id": { "type": "integer", "description": "Optional numeric contact id to scope the search." }, "start_ts": { "type": "integer", "description": "Optional inclusive lower bound, real-UTC unix seconds." }, "end_ts": { "type": "integer", "description": "Optional inclusive upper bound, real-UTC unix seconds." } } }), )); tools.push(Tool::function( "get_sms_messages", "Fetch SMS/MMS messages near a date (and optionally from a specific contact). Use when you know the date \ or want context around a photo's timestamp. For keyword search without a date, use `search_messages`. \ Returns up to `limit` messages within `±days_radius` of `date`, sorted by proximity. \ Example: `{date: \"2018-08-12\", contact: \"Mom\", days_radius: 2}` — messages from Mom within ±2 days of Aug 12 2018.", serde_json::json!({ "type": "object", "required": ["date"], "properties": { "date": { "type": "string", "description": "Center date, YYYY-MM-DD." }, "contact": { "type": "string", "description": "Optional contact name (case-insensitive). Falls back to all contacts on no match." }, "days_radius": { "type": "integer", "description": "Days before and after to include (default 4)." }, "limit": { "type": "integer", "description": "Max messages to return (default 60, max 150)." } } }), )); if opts.calendar_present { tools.push(Tool::function( "get_calendar_events", "Fetch calendar events near a date — meetings, scheduled activities, all-day events. \ Returns events within `±days_radius` of `date`. \ Example: `{date: \"2019-03-22\", days_radius: 3}` — events within a week of March 22 2019.", serde_json::json!({ "type": "object", "required": ["date"], "properties": { "date": { "type": "string", "description": "Center date, YYYY-MM-DD." }, "days_radius": { "type": "integer", "description": "Days before and after to include (default 7)." }, "limit": { "type": "integer", "description": "Max events to return (default 20, max 50)." } } }), )); } if opts.location_history_present { tools.push(Tool::function( "get_location_history", "Fetch raw location records (lat/lon/timestamp/activity) near a date. The default 14-day radius is \ wide because location density varies; tighten to ±1 day for a single-trip query. For a coordinate's \ named place, use `reverse_geocode` (or `get_personal_place_at` when Apollo is enabled).", serde_json::json!({ "type": "object", "required": ["date"], "properties": { "date": { "type": "string", "description": "Center date, YYYY-MM-DD." }, "days_radius": { "type": "integer", "description": "Days before and after to include (default 14)." } } }), )); } tools.push(Tool::function( "get_file_tags", "Get user-applied tags for a specific photo file path. Tags are user-curated, not auto-detected.", serde_json::json!({ "type": "object", "required": ["file_path"], "properties": { "file_path": { "type": "string", "description": "File path of the photo." } } }), )); tools.push(Tool::function( "reverse_geocode", "Convert GPS lat/lon to a human-readable place name (city, state). Use for any coordinate the LLM has \ obtained from EXIF or `get_location_history`. When Apollo is configured, prefer `get_personal_place_at` \ — it returns the user's named places (Home / Work / etc.) which are more specific.", serde_json::json!({ "type": "object", "required": ["latitude", "longitude"], "properties": { "latitude": { "type": "number", "description": "Decimal degrees." }, "longitude": { "type": "number", "description": "Decimal degrees." } } }), )); if opts.apollo_enabled { tools.push(Tool::function( "get_personal_place_at", "Return any of the user's named Places (e.g. Home, Work, Cabin) whose radius contains (latitude, longitude). \ Smallest radius first — most specific match wins. More specific than `reverse_geocode`; prefer this when \ both apply. Returns place name, category, free-text description, and radius.", serde_json::json!({ "type": "object", "required": ["latitude", "longitude"], "properties": { "latitude": { "type": "number", "description": "Decimal degrees." }, "longitude": { "type": "number", "description": "Decimal degrees." } } }), )); } tools.push(Tool::function( "recall_entities", "Search the persistent knowledge memory for previously learned people, places, events, or things. \ Use BEFORE writing the insight to ground the model on what's already known.", serde_json::json!({ "type": "object", "properties": { "name": { "type": "string", "description": "Name or partial name (case-insensitive substring match)." }, "entity_type": { "type": "string", "enum": ["person", "place", "event", "thing"] }, "limit": { "type": "integer", "description": "Max results (default 20, max 50)." } } }), )); tools.push(Tool::function( "recall_facts_for_photo", "Retrieve all stored facts linked to a specific photo. Call at the start of insight generation to load \ prior knowledge about subjects in this photo without scanning the whole knowledge base.", serde_json::json!({ "type": "object", "required": ["file_path"], "properties": { "file_path": { "type": "string", "description": "File path of the photo." } } }), )); tools.push(Tool::function( "store_entity", "Upsert a person / place / event / thing into the knowledge memory. Returns the entity id (use it as \ `subject_entity_id` or `object_entity_id` in `store_fact`). Idempotent on canonical name.", serde_json::json!({ "type": "object", "required": ["name", "entity_type"], "properties": { "name": { "type": "string", "description": "Canonical name (e.g. \"John Smith\", \"Banff National Park\")." }, "entity_type": { "type": "string", "enum": ["person", "place", "event", "thing"] }, "description": { "type": "string", "description": "Brief description." } } }), )); tools.push(Tool::function( "store_fact", "Record a fact about an entity in the knowledge memory. Always linked to the current photo. \ You must provide EITHER `object_entity_id` (when the object is itself a stored entity — e.g. \ person A is_friend_of person B) OR `object_value` (free-text attribute — e.g. role=\"software engineer\"). \ `object_entity_id` takes precedence when both are present. \ Examples: \ `{subject_entity_id: 7, predicate: \"is_friend_of\", object_entity_id: 12}` — links two known entities. \ `{subject_entity_id: 7, predicate: \"lives_in\", object_value: \"Portland, Oregon\"}` — free-text attribute.", serde_json::json!({ "type": "object", "required": ["subject_entity_id", "predicate"], "properties": { "subject_entity_id": { "type": "integer", "description": "Entity id this fact is about." }, "predicate": { "type": "string", "description": "Relationship or attribute (e.g. is_friend_of, located_in, attended_event)." }, "object_entity_id": { "type": "integer", "description": "Use when the object is itself a stored entity. Takes precedence over object_value." }, "object_value": { "type": "string", "description": "Use for free-text attributes where the object is not a stored entity." }, "photo_role": { "type": "string", "description": "How this entity appears in the photo (default \"subject\")." } } }), )); tools.push(Tool::function( "get_current_datetime", "Get the current date and time. Useful when reasoning about how long ago a photo was taken.", serde_json::json!({ "type": "object", "properties": {} }), )); if opts.has_vision { tools.push(Tool::function( "describe_photo", "Generate a visual description of the current photo — people, location, objects, activity visible \ in the image. Only available with vision-capable models.", serde_json::json!({ "type": "object", "properties": {} }), )); } tools } ``` - [ ] **Step 2: Build to find call sites that need updating** ```bash cd /home/cameron/development/opencode/ImageApi cargo build 2>&1 | tail -25 ``` Expected: build errors at the call sites in `insight_chat.rs` (`build_tool_definitions(offer_describe_tool, ...)`) and in `insight_generator.rs` itself (the agentic generation site at line 3377). The struct test should now compile and pass after we update the call sites. ### Task 4.4: Add `current_gate_opts` helper on `InsightGenerator` - [ ] **Step 1: Add a method that probes data presence** In `src/ai/insight_generator.rs`, inside `impl InsightGenerator { ... }`, add (a sensible location is near `apollo_enabled()` — search for `pub fn apollo_enabled`): ```rust /// Compute the per-call tool gate options by probing each backing /// table. Cheap (`SELECT 1 FROM LIMIT 1` shape via the existing /// count methods); meant to be called once per chat turn / generation. /// `has_vision` is supplied by the caller because it depends on the /// model selected for this turn, not on persistent state. pub fn current_gate_opts(&self, has_vision: bool) -> ToolGateOpts { let cx = opentelemetry::Context::new(); let calendar_present = { let mut dao = self .calendar_dao .lock() .expect("Unable to lock CalendarEventDao"); dao.get_event_count(&cx).map(|n| n > 0).unwrap_or(false) }; let location_history_present = { let mut dao = self .location_dao .lock() .expect("Unable to lock LocationHistoryDao"); dao.get_location_count(&cx).map(|n| n > 0).unwrap_or(false) }; let daily_summaries_present = { let mut dao = self .daily_summary_dao .lock() .expect("Unable to lock DailySummaryDao"); dao.get_summary_count(&cx).map(|n| n > 0).unwrap_or(false) }; ToolGateOpts { has_vision, apollo_enabled: self.apollo_enabled(), daily_summaries_present, calendar_present, location_history_present, } } ``` - [ ] **Step 2: Build** ```bash cd /home/cameron/development/opencode/ImageApi cargo build 2>&1 | tail -10 ``` Expected: errors only at the call sites of `build_tool_definitions` (now mismatched signature). New helper compiles cleanly. ### Task 4.5: Update the agentic generation call site - [ ] **Step 1: Switch to the new signature** In `src/ai/insight_generator.rs` around line 3373-3377, find: ```rust // 10. Define tools. Hybrid mode omits `describe_photo` since the // chat model receives the visual description inline. let offer_describe_tool = has_vision && !is_hybrid; let tools = Self::build_tool_definitions(offer_describe_tool, self.apollo_client.is_enabled()); ``` Replace with: ```rust // 10. Define tools. Gate flags computed from current data presence; // hybrid mode omits describe_photo since the chat model receives // the visual description inline (so we pass `false` for has_vision // in hybrid mode regardless of the model's actual capability). let gate_opts = self.current_gate_opts(has_vision && !is_hybrid); let tools = Self::build_tool_definitions(gate_opts); ``` - [ ] **Step 2: Build** ```bash cd /home/cameron/development/opencode/ImageApi cargo build 2>&1 | tail -10 ``` Expected: this site compiles; remaining errors are in `insight_chat.rs`. ### Task 4.6: Update the chat call sites - [ ] **Step 1: Replace both `build_tool_definitions` calls in `insight_chat.rs`** In `src/ai/insight_chat.rs`, find (around line 362): ```rust let offer_describe_tool = !is_hybrid && local_first_user_has_image; let tools = InsightGenerator::build_tool_definitions( offer_describe_tool, self.generator.apollo_enabled(), ); ``` Replace with: ```rust let offer_describe_tool = !is_hybrid && local_first_user_has_image; // current_gate_opts(has_vision) sets gate_opts.has_vision = has_vision // and probes the per-table presence flags. Pass `offer_describe_tool` // directly — the `!is_hybrid && local_first_user_has_image` decision // is the chat-path's vision predicate. let gate_opts = self.generator.current_gate_opts(offer_describe_tool); let tools = InsightGenerator::build_tool_definitions(gate_opts); ``` Then around line 793 (in `run_streaming_turn`), find the identical block and replace the same way. - [ ] **Step 2: Build** ```bash cd /home/cameron/development/opencode/ImageApi cargo build 2>&1 | tail -10 ``` Expected: clean build. - [ ] **Step 3: Run the new gate tests** ```bash cd /home/cameron/development/opencode/ImageApi cargo test --lib build_tool_definitions -- --nocapture ``` Expected: both gate tests pass. ### Task 4.7: Expand `tool_search_messages` to honor the new params - [ ] **Step 1: Read existing `tool_search_messages`** The current implementation at `src/ai/insight_generator.rs:1718-1796` only forwards `query`, `mode`, and `limit` to SMS-API. Now it should also forward `start_ts`, `end_ts`, and `contact_id`. **Pattern:** SMS-API's `/api/messages/search/` accepts `contact_id` natively but not date range — we add the date filter as a client-side post-filter (mirrors Apollo's `chat_tools.py:670–680`). Bump the SMS-API `limit` to 100 when a date filter is present so in-window matches aren't lost to out-of-window FTS rank. - [ ] **Step 2: Add a new method on `SmsApiClient` for the broader search call** In `src/ai/sms_client.rs`, just below the existing `search_messages` method (around line 290), add: ```rust /// Same shape as `search_messages` but with optional `contact_id`. The /// SMS-API endpoint accepts contact_id natively; date filtering is the /// caller's responsibility (post-filter on the returned rows). pub async fn search_messages_with_contact( &self, query: &str, mode: &str, limit: usize, contact_id: Option, ) -> Result> { let mut url = format!( "{}/api/messages/search/?q={}&mode={}&limit={}", self.base_url, urlencoding::encode(query), urlencoding::encode(mode), limit ); if let Some(cid) = contact_id { url.push_str(&format!("&contact_id={}", cid)); } 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 search request failed: {} - {}", status, body )); } let data: SmsSearchResponse = response.json().await?; Ok(data.results) } ``` - [ ] **Step 3: Replace `tool_search_messages` body** In `src/ai/insight_generator.rs`, replace the `tool_search_messages` function (lines 1718-1796) with: ```rust /// Tool: search_messages — keyword / semantic / hybrid search over all /// SMS message bodies via the Django FTS5 + embeddings pipeline. Now /// supports optional `contact_id`, `start_ts`, `end_ts` filters. async fn tool_search_messages(&self, args: &serde_json::Value) -> String { let query = match args.get("query").and_then(|v| v.as_str()) { Some(q) if !q.trim().is_empty() => q.trim(), _ => { let has_date = args.get("date").is_some() || args.get("start_ts").is_some() || args.get("end_ts").is_some(); let has_contact = args.get("contact").is_some() || args.get("contact_id").is_some(); if has_date || has_contact { return "Error: search_messages needs a 'query' (keywords/phrase). \ To fetch messages around a date or from a contact without keywords, \ call get_sms_messages with { date, contact? } instead." .to_string(); } return "Error: missing required parameter 'query'".to_string(); } }; if query.len() < 3 { return "Error: query must be at least 3 characters".to_string(); } let mode = args .get("mode") .and_then(|v| v.as_str()) .map(|s| s.to_lowercase()) .unwrap_or_else(|| "hybrid".to_string()); if !matches!(mode.as_str(), "fts5" | "semantic" | "hybrid") { return format!( "Error: unknown mode '{}'; expected one of: fts5, semantic, hybrid", mode ); } let user_limit = args .get("limit") .and_then(|v| v.as_i64()) .unwrap_or(20) .clamp(1, 50) as usize; let contact_id = args.get("contact_id").and_then(|v| v.as_i64()); let start_ts = args.get("start_ts").and_then(|v| v.as_i64()); let end_ts = args.get("end_ts").and_then(|v| v.as_i64()); let has_date_filter = start_ts.is_some() || end_ts.is_some(); // When a date filter is supplied, fetch a larger pool from SMS-API // so in-window matches that ranked lower than out-of-window ones // aren't lost. let fetch_limit = if has_date_filter { 100 } else { user_limit }; log::info!( "tool_search_messages: query='{}', mode={}, contact_id={:?}, range=[{:?}, {:?}], user_limit={}, fetch_limit={}", query, mode, contact_id, start_ts, end_ts, user_limit, fetch_limit ); let hits = match self .sms_client .search_messages_with_contact(query, &mode, fetch_limit, contact_id) .await { Ok(h) => h, Err(e) => return format!("Error searching messages: {}", e), }; // Date-range post-filter on the client side. SMS-API's /search/ // doesn't accept date params; mirroring Apollo's pattern here. let filtered: Vec<_> = hits .into_iter() .filter(|h| { if let Some(s) = start_ts && h.date < s { return false; } if let Some(e) = end_ts && h.date > e { return false; } true }) .take(user_limit) .collect(); if filtered.is_empty() { return "No messages matched.".to_string(); } let user_name = user_display_name(); let mut out = String::new(); out.push_str(&format!( "Found {} messages (mode: {}{}):\n\n", filtered.len(), mode, if has_date_filter { ", date-filtered" } else { "" } )); for h in filtered { let date = chrono::DateTime::from_timestamp(h.date, 0) .map(|dt| dt.format("%Y-%m-%d").to_string()) .unwrap_or_else(|| h.date.to_string()); let direction: &str = if h.type_ == 2 { &user_name } else { &h.contact_name }; let score = h .similarity_score .map(|s| format!(" [score {:.2}]", s)) .unwrap_or_default(); out.push_str(&format!( "[{}]{} {} — {}\n\n", date, score, direction, h.body )); } out } ``` - [ ] **Step 4: Build** ```bash cd /home/cameron/development/opencode/ImageApi cargo build 2>&1 | tail -10 ``` Expected: clean build. ### Task 4.8: Commit PR 4 - [ ] **Step 1: Run the full test suite** ```bash cd /home/cameron/development/opencode/ImageApi cargo test --lib 2>&1 | tail -20 ``` Expected: all tests pass. - [ ] **Step 2: Stage and commit** ```bash cd /home/cameron/development/opencode/ImageApi git add src/ai/insight_generator.rs src/ai/sms_client.rs src/ai/insight_chat.rs git commit -m "$(cat <<'EOF' insight-chat: ToolGateOpts + per-tool description rewrites Tools whose backing tables are empty (calendar, location_history, daily_summaries) drop out of the catalog so the LLM doesn't waste iteration budget calling them only to receive "no results found". Vision and apollo gates already existed; this generalizes the pattern. search_messages gains start_ts/end_ts/contact_id filters (date filter is a client-side post-filter; SMS-API only accepts contact_id natively on the search endpoint). Descriptions follow a consistent convention: one sentence (what + when), param semantics, examples for tools with non-obvious param choices. No more all-caps headers, no more identity-prescriptive language inside descriptions. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## PR 5 — FileViewer-React: Wire system_prompt; style note input; persona prompt audit **Files:** - Modify: `hooks/usePersonas.tsx:15-40` (`DEFAULT_PERSONAS`) - Modify: `hooks/useInsightChat.tsx:41-44` (`SendTurnOptions`); `:163-499` (`sendTurn`) - Modify: `components/InsightChatModal.tsx` (composer composition; new state) ### Task 5.1: Update built-in persona prompts to specify voice/shape - [ ] **Step 1: Replace `DEFAULT_PERSONAS`** In `hooks/usePersonas.tsx`, replace the `DEFAULT_PERSONAS` constant (lines 15-40): ```typescript export const DEFAULT_PERSONAS: Persona[] = [ { id: "default", name: "Default Assistant", systemPrompt: "You are my long-term memory assistant. Use only the information " + "provided. Do not invent details. Respond in 3–6 sentences in third " + "person, leading with the most concrete moment from the photo and " + "the surrounding context. Plain prose, no headings.", isBuiltIn: true, createdAt: Date.now(), }, { id: "journal", name: "Personal Journal", systemPrompt: "You are a personal journal writer. Write in first person, present " + "tense, with warmth and reflection — focusing on emotions and " + "meaningful moments. Use only the information provided; do not " + "invent details. Aim for 4–8 sentences in a single flowing " + "paragraph, no headings.", isBuiltIn: true, createdAt: Date.now(), }, { id: "factual", name: "Factual Reporter", systemPrompt: "You are a factual memory recorder. Be precise, objective, and " + "concise. Lead with the date and place, then list what / when / who " + "in 2–4 short sentences. Use only the information provided; if a " + "detail is unknown, say so rather than guessing.", isBuiltIn: true, createdAt: Date.now(), }, ]; ``` - [ ] **Step 2: Confirm typescript compiles** ```bash cd /home/cameron/development/opencode/FileViewer-React npx tsc --noEmit 2>&1 | tail -10 ``` Expected: no errors related to the changed file. ### Task 5.2: Wire persona into chat-turn body - [ ] **Step 1: Add `systemPromptOverride` to `SendTurnOptions`** In `hooks/useInsightChat.tsx` around line 41, replace: ```typescript export interface SendTurnOptions { /** When true the server inserts a new insight row with a regenerated title. */ amend?: boolean; } ``` With: ```typescript export interface SendTurnOptions { /** When true the server inserts a new insight row with a regenerated title. */ amend?: boolean; /** One-shot extra style note for this turn. Appended as a suffix to the * active persona's system prompt so persona voice survives + the note * tweaks this single turn. Cleared by the caller after send. */ systemPromptOverride?: string | null; } ``` - [ ] **Step 2: Import the persona resolver and DEFAULT_PERSONAS at the top** In `hooks/useInsightChat.tsx` around line 1-6, add an import (above `getLogger`): ```typescript import { DEFAULT_PERSONAS, Persona } from "@/hooks/usePersonas"; ``` - [ ] **Step 3: Add a small helper near the top of the file** After the existing imports and before `useInsightChat()` (around line 98), add: ```typescript async function resolveSystemPrompt( override: string | null | undefined ): Promise { // Read the active persona id + custom personas list, look up the // matching prompt, and (optionally) suffix the per-turn override. // Returns null when both pieces are absent — server treats null as // "no override; keep the baked-in transcript system message." let basePrompt: string | null = null; try { const [selectedId, customJson] = await AsyncStorage.multiGet([ "@insights_selected_persona", "@insights_personas", ]).then(entries => entries.map(([, v]) => v)); const customs: Persona[] = customJson ? JSON.parse(customJson) : []; const all: Persona[] = [...DEFAULT_PERSONAS, ...customs]; const persona = all.find(p => p.id === selectedId) ?? DEFAULT_PERSONAS[0] ?? null; basePrompt = persona?.systemPrompt ?? null; } catch { // ignore — fall through to override-only or null } const trimmedOverride = (override ?? "").trim(); if (basePrompt && trimmedOverride) { return `${basePrompt}\n\n${trimmedOverride}`; } if (basePrompt) return basePrompt; if (trimmedOverride) return trimmedOverride; return null; } ``` - [ ] **Step 4: Use it inside `sendTurn`** In `hooks/useInsightChat.tsx` around line 267-271, find: ```typescript const requestBody: Record = { file_path: filePath, user_message: trimmed, amend: !!opts?.amend, }; ``` Replace with: ```typescript const systemPrompt = await resolveSystemPrompt(opts?.systemPromptOverride); const requestBody: Record = { file_path: filePath, user_message: trimmed, amend: !!opts?.amend, }; if (systemPrompt) requestBody.system_prompt = systemPrompt; ``` - [ ] **Step 5: Confirm typescript compiles** ```bash cd /home/cameron/development/opencode/FileViewer-React npx tsc --noEmit 2>&1 | tail -10 ``` Expected: no errors related to the changed file. ### Task 5.3: Add a one-shot "Style note" input to the chat modal - [ ] **Step 1: Add state + plumbing for the style note** In `components/InsightChatModal.tsx` around line 67-79, find the existing `useState` block: ```typescript const [draft, setDraft] = useState(""); const [amend, setAmend] = useState(false); const scrollRef = useRef(null); ``` Replace with: ```typescript const [draft, setDraft] = useState(""); const [amend, setAmend] = useState(false); // One-shot style note for the next turn. Cleared after send. Suffixed // onto the active persona's prompt server-side via system_prompt. const [styleNote, setStyleNote] = useState(""); const scrollRef = useRef(null); ``` - [ ] **Step 2: Pass `systemPromptOverride` through `onSend`** Same file around line 153-158, find: ```typescript const onSend = () => { const text = draft.trim(); if (!text || sending) return; setDraft(""); sendTurn(filePath, libraryParam, text, { amend }); }; ``` Replace with: ```typescript const onSend = () => { const text = draft.trim(); if (!text || sending) return; const note = styleNote.trim(); setDraft(""); setStyleNote(""); // one-shot sendTurn(filePath, libraryParam, text, { amend, systemPromptOverride: note ? note : null, }); }; ``` - [ ] **Step 3: Render the style-note input above the composer** Same file, find the composer block (around line 387-417) starting with: ```typescript ` block, insert: ```typescript Style: ``` - [ ] **Step 4: Add the matching styles** Same file, in the `StyleSheet.create({ ... })` block at the bottom (around line 717), add to the existing styles object (anywhere; near `composer` reads naturally): ```typescript styleNoteRow: { flexDirection: "row", alignItems: "center", paddingHorizontal: 8, paddingVertical: 4, borderTopWidth: 1, gap: 4, }, styleNoteInput: { flex: 1, minHeight: 28, borderWidth: 1, borderRadius: 6, paddingHorizontal: 8, paddingVertical: 4, fontSize: 12, }, ``` - [ ] **Step 5: Confirm typescript compiles** ```bash cd /home/cameron/development/opencode/FileViewer-React npx tsc --noEmit 2>&1 | tail -10 ``` Expected: no errors. ### Task 5.4: Manual smoke test - [ ] **Step 1: Start the dev server** ```bash cd /home/cameron/development/opencode/FileViewer-React npm start ``` Expected: Expo dev server running. - [ ] **Step 2: Verify the UI loads** Open the app on a device or simulator. Navigate to a photo with an existing insight, open the chat modal. Confirm the new "Style:" row appears between the message list and the composer. - [ ] **Step 3: Send a turn with the journal persona + a style note** Switch persona to "Personal Journal" via the chip. In the Style input type "respond in two sentences only." Send a message. Verify the reply is short and in first person; verify the Style input clears after send. - [ ] **Step 4: Re-open and confirm the override was ephemeral** Close and re-open the chat modal. Send another turn (no style note this time, default persona). Verify the reply matches the default persona — the override didn't stick. ### Task 5.5: Commit PR 5 - [ ] **Step 1: Stage and commit** ```bash cd /home/cameron/development/opencode/FileViewer-React git add hooks/usePersonas.tsx hooks/useInsightChat.tsx components/InsightChatModal.tsx git commit -m "$(cat <<'EOF' insight-chat: send persona system_prompt on chat turns + one-shot style note The active persona's systemPrompt is read from AsyncStorage and sent on every chat turn body so persona changes take effect on the next reply (server applies it ephemerally in append mode, persists in amend mode). Adds a one-shot Style input next to the composer for per-turn tweaks ("answer in bullet points") that suffixes the persona prompt and clears after send. Built-in personas updated to specify voice/length/shape explicitly since the framework no longer asserts a default shape. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Self-review checklist (post-write, before handoff) - [ ] Spec section A (system prompt — generation): covered by PR 1 Tasks 1.1–1.5. - [ ] Spec section B (user message — generation): covered by PR 1 Task 1.4. - [ ] Spec section C (system_prompt on chat request): covered by PR 2 Tasks 2.3, 2.6. - [ ] Spec section D (append-mode swap-and-restore): covered by PR 2 Tasks 2.2, 2.4, 2.5. - [ ] Spec section E (amend-mode persistence): covered by PR 2 Tasks 2.4, 2.5 (the `if !req.amend` guard). - [ ] Spec section F (FileViewer-React client wiring): covered by PR 5 Tasks 5.1–5.4. - [ ] Spec section G (gating on data presence): covered by PR 4 Tasks 4.1, 4.3, 4.4, 4.5, 4.6. - [ ] Spec section "tool description convention": covered by PR 4 Task 4.3 (each tool's new description body). - [ ] Spec section G — `get_sms_messages` `days_radius` no-op fix: covered by PR 3. - [ ] Spec section G — `search_messages` adds `start_ts`/`end_ts`/`contact_id`: covered by PR 4 Task 4.7. ## Open notes - **OpenRouter sampling-param plumbing** is not in scope — `temperature` / `top_p` / `top_k` / `min_p` already flow through `ChatTurnRequest`. No change needed. - **Tests for the streaming path's override** are deliberately omitted at the unit level — `run_streaming_turn` is integration-shaped (depends on Ollama). The non-streaming round-trip test in PR 2 Task 2.7 exercises the same helper logic; visual smoke in PR 5 Task 5.4 covers the streaming end-to-end. - **No migration required** — no schema changes; `system_prompt` is request-only.