diff --git a/specs/002-insight-chat-improvements-plan.md b/specs/002-insight-chat-improvements-plan.md new file mode 100644 index 0000000..857c464 --- /dev/null +++ b/specs/002-insight-chat-improvements-plan.md @@ -0,0 +1,2147 @@ +# 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.