Five sequenced PRs:
1. Split generation system prompt + neutralize user message
2. system_prompt field on chat request (ephemeral / amend-persisted)
3. fetch_messages_for_contact honors days_radius
4. ToolGateOpts + per-tool description rewrites + search_messages
gains start_ts/end_ts/contact_id
5. FileViewer-React: persona system_prompt on every turn + style note
Each PR independently mergeable. Tests inline TDD per task.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
80 KiB
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<String> 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<String> 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 testsat 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:
#[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
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):
/// 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
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:
// 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
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
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:
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:
// 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
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
cd /home/cameron/development/opencode/ImageApi
cargo test --lib 2>&1 | tail -20
Expected: all tests pass.
- Step 2: Stage and commit
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) <noreply@anthropic.com>
EOF
)"
PR 2 — ImageApi: system_prompt field on chat request + swap/restore + amend persistence
Files:
- Modify:
src/ai/insight_chat.rs:30-51(ChatTurnRequeststruct) - 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-699and:900-913(request mapping in both handlers) - Test:
src/ai/insight_chat.rs(existing#[cfg(test)] mod testsat 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:
#[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
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:
/// 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<ChatMessage>,
override_prompt: Option<&str>,
) -> Option<SystemPromptStash> {
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<ChatMessage>,
stash: Option<SystemPromptStash>,
) {
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
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:
#[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<String>,
/// 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<String>,
pub num_ctx: Option<i32>,
pub temperature: Option<f32>,
pub top_p: Option<f32>,
pub top_k: Option<i32>,
pub min_p: Option<f32>,
pub max_iterations: Option<usize>,
/// 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<String>,
/// 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
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:
// 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:
// 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:
// 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:
// 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
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:
messages.push(ChatMessage::user(req.user_message.clone()));
let original_system_content = annotate_system_with_budget(&mut messages, max_iterations);
Replace with:
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:
// 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:
// 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
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:
#[derive(Debug, Deserialize)]
pub struct ChatTurnHttpRequest {
pub file_path: String,
#[serde(default)]
pub library: Option<String>,
pub user_message: String,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub backend: Option<String>,
#[serde(default)]
pub num_ctx: Option<i32>,
#[serde(default)]
pub temperature: Option<f32>,
#[serde(default)]
pub top_p: Option<f32>,
#[serde(default)]
pub top_k: Option<i32>,
#[serde(default)]
pub min_p: Option<f32>,
#[serde(default)]
pub max_iterations: Option<usize>,
/// Per-turn system-prompt override. Ephemeral in append mode,
/// persisted in amend mode. See ChatTurnRequest for semantics.
#[serde(default)]
pub system_prompt: Option<String>,
#[serde(default)]
pub amend: bool,
}
- Step 2: Plumb into the non-streaming handler
In src/ai/handlers.rs around line 686, find:
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:
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
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:
#[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
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
cd /home/cameron/development/opencode/ImageApi
cargo test --lib 2>&1 | tail -20
Expected: all tests pass.
- Step 2: Stage and commit
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) <noreply@anthropic.com>
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:
#[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
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:
/// 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<Vec<SmsMessage>> {
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
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_messagesto passdays_radius
In src/ai/insight_generator.rs around line 1836-1840, find:
match self
.sms_client
.fetch_messages_for_contact(contact.as_deref(), timestamp)
.await
Replace with:
match self
.sms_client
.fetch_messages_for_contact(contact.as_deref(), timestamp, days_radius)
.await
- Step 2: Find any other callers and update them
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
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
cd /home/cameron/development/opencode/ImageApi
cargo test --lib 2>&1 | tail -20
Expected: all tests pass.
- Step 2: Stage and commit
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) <noreply@anthropic.com>
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_messagesto accept new params) - Modify:
src/ai/insight_chat.rs— chat service must computeToolGateOptsper turn - Test:
src/ai/insight_generator.rs(new tests in existingmod 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:
/// 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
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):
#[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
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:
/// 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<Tool> {
let mut tools: Vec<Tool> = 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
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):
/// Compute the per-call tool gate options by probing each backing
/// table. Cheap (`SELECT 1 FROM <t> 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
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:
// 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:
// 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
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_definitionscalls ininsight_chat.rs
In src/ai/insight_chat.rs, find (around line 362):
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:
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
cd /home/cameron/development/opencode/ImageApi
cargo build 2>&1 | tail -10
Expected: clean build.
- Step 3: Run the new gate tests
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
SmsApiClientfor the broader search call
In src/ai/sms_client.rs, just below the existing search_messages method (around line 290), add:
/// 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<i64>,
) -> Result<Vec<SmsSearchHit>> {
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_messagesbody
In src/ai/insight_generator.rs, replace the tool_search_messages function (lines 1718-1796) with:
/// 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
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
cd /home/cameron/development/opencode/ImageApi
cargo test --lib 2>&1 | tail -20
Expected: all tests pass.
- Step 2: Stage and commit
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) <noreply@anthropic.com>
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):
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
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
systemPromptOverridetoSendTurnOptions
In hooks/useInsightChat.tsx around line 41, replace:
export interface SendTurnOptions {
/** When true the server inserts a new insight row with a regenerated title. */
amend?: boolean;
}
With:
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):
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:
async function resolveSystemPrompt(
override: string | null | undefined
): Promise<string | null> {
// 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:
const requestBody: Record<string, unknown> = {
file_path: filePath,
user_message: trimmed,
amend: !!opts?.amend,
};
Replace with:
const systemPrompt = await resolveSystemPrompt(opts?.systemPromptOverride);
const requestBody: Record<string, unknown> = {
file_path: filePath,
user_message: trimmed,
amend: !!opts?.amend,
};
if (systemPrompt) requestBody.system_prompt = systemPrompt;
- Step 5: Confirm typescript compiles
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:
const [draft, setDraft] = useState("");
const [amend, setAmend] = useState(false);
const scrollRef = useRef<ScrollView>(null);
Replace with:
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<ScrollView>(null);
- Step 2: Pass
systemPromptOverridethroughonSend
Same file around line 153-158, find:
const onSend = () => {
const text = draft.trim();
if (!text || sending) return;
setDraft("");
sendTurn(filePath, libraryParam, text, { amend });
};
Replace with:
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:
<View
style={[
styles.composer,
Just above that <View style={[styles.composer, ...]}> block, insert:
<View
style={[
styles.styleNoteRow,
{
borderTopColor: theme.colors.text + "10",
backgroundColor: theme.colors.background,
},
]}
>
<Text
style={{
color: theme.colors.textSecondary,
fontSize: 11,
marginRight: 6,
}}
>
Style:
</Text>
<TextInput
style={[
styles.styleNoteInput,
{
color: theme.colors.text,
backgroundColor: theme.colors.card,
borderColor: theme.colors.text + "20",
},
]}
placeholder='one-shot, e.g. "in bullet points"'
placeholderTextColor={theme.colors.textSecondary}
value={styleNote}
onChangeText={setStyleNote}
editable={!sending}
numberOfLines={1}
/>
</View>
- 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):
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
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
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
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) <noreply@anthropic.com>
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.amendguard). - 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_messagesdays_radiusno-op fix: covered by PR 3. - Spec section G —
search_messagesaddsstart_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_palready flow throughChatTurnRequest. No change needed. - Tests for the streaming path's override are deliberately omitted at the unit level —
run_streaming_turnis 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_promptis request-only.