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>
2148 lines
80 KiB
Markdown
2148 lines
80 KiB
Markdown
# 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 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) <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` (`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<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**
|
||
|
||
```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<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**
|
||
|
||
```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<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:
|
||
|
||
```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) <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:
|
||
|
||
```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<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**
|
||
|
||
```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) <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_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<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**
|
||
|
||
```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 <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**
|
||
|
||
```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<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_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) <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):
|
||
|
||
```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<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:
|
||
|
||
```typescript
|
||
const requestBody: Record<string, unknown> = {
|
||
file_path: filePath,
|
||
user_message: trimmed,
|
||
amend: !!opts?.amend,
|
||
};
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```typescript
|
||
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**
|
||
|
||
```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<ScrollView>(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<ScrollView>(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
|
||
<View
|
||
style={[
|
||
styles.composer,
|
||
```
|
||
|
||
Just **above** that `<View style={[styles.composer, ...]}>` block, insert:
|
||
|
||
```typescript
|
||
<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):
|
||
|
||
```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) <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.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.
|