From 388eb22cd28710fd1e57f2042c53b3bef739bf3c Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Thu, 7 May 2026 17:29:04 -0400 Subject: [PATCH] Remove full plan file, just keep spec --- specs/002-insight-chat-improvements-plan.md | 2147 ------------------- 1 file changed, 2147 deletions(-) delete mode 100644 specs/002-insight-chat-improvements-plan.md diff --git a/specs/002-insight-chat-improvements-plan.md b/specs/002-insight-chat-improvements-plan.md deleted file mode 100644 index 857c464..0000000 --- a/specs/002-insight-chat-improvements-plan.md +++ /dev/null @@ -1,2147 +0,0 @@ -# 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.