diff --git a/docs/superpowers/plans/2026-08-23-x-article-vision-search.md b/docs/superpowers/plans/2026-08-23-x-article-vision-search.md new file mode 100644 index 0000000..db40265 --- /dev/null +++ b/docs/superpowers/plans/2026-08-23-x-article-vision-search.md @@ -0,0 +1,474 @@ +# X Article, Vision Summaries, and Summary Search 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 X Articles summarize their archived article text, let a user explicitly attach eligible archived images to a requested summary, and search the latest completed summary text (including its JSON tags). + +**Architecture:** Keep `archivr-core` synchronous and make the summary input the single carrier of both reduced text and an opt-in, bounded list of image descriptors. The image-selection policy is serialized into the existing `input_sha256` preimage, preserving the existing `entry_summaries` uniqueness key without a migration. Extend the existing server-side entry search query with a correlated latest-completed-summary predicate; the endpoint response and frontend search transport stay unchanged. + +**Tech Stack:** Rust 2024, `anyhow`, `rusqlite`, `reqwest` blocking HTTP, `serde_json`, Axum, React JSX, and plain CSS. + +--- + +## File map and interfaces + +| File | Responsibility | +| --- | --- | +| `crates/archivr-core/src/summarizer.rs` | X Article reducer, image candidate policy, `SummaryBuildOptions`, `SummaryImage`, cache digest preimage, provider request payloads, Codex invocation, and unit tests. | +| `crates/archivr-server/src/routes.rs` | Parse `include_images`, reject an unsupported Claude CLI vision request before a job is created, and pass build options to both cache lookup and the background worker. | +| `frontend/src/api.js` | Send the explicit `include_images` boolean in the existing summary POST. | +| `frontend/src/components/ContextRail.jsx` | Per-generation checkbox, provider-specific disabled Claude state, privacy/cap warning, and request wiring. | +| `frontend/src/styles.css` | Dedicated summary-image option layout and disabled-note treatment. | +| `crates/archivr-core/src/archive.rs` | Correlated SQL predicate for the latest completed summary and search tests. | +| `docs/README.md`, `AGENTS.md`, `ARCHIVR-MENTAL-MODEL.md` | User, contributor, and architectural documentation after the implementation is complete. | + +Define the following core interfaces before server or UI tasks use them. `archive_file` is an absolute local path derived from `ArchivePaths.store_path` plus the stored artifact `relpath`; it is never returned from an API. + +```rust +pub const MAX_SUMMARY_IMAGES: usize = 4; +pub const MAX_SUMMARY_IMAGE_BYTES: u64 = 5 * 1024 * 1024; +pub const MAX_SUMMARY_IMAGE_TOTAL_BYTES: u64 = 12 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct SummaryBuildOptions { + pub include_images: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SummaryImage { + pub sha256: String, + pub mime_type: String, + pub byte_size: u64, + pub archive_file: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SummaryRequest { + pub entry_uid: String, + pub title: Option, + pub source_kind: String, + pub entity_kind: String, + pub content: String, + pub images: Vec, +} + +pub fn build_summary_input( + paths: &ArchivePaths, + entry_uid: &str, + options: SummaryBuildOptions, +) -> Result; + +pub fn summarize_entry( + archive_paths: &ArchivePaths, + entry_uid: &str, + options: SummaryBuildOptions, + provider: &dyn SummaryProvider, + prompt_version: &str, +) -> Result; +``` + +Use one deterministic digest preimage: `content` bytes, then `"\0images="`, then `include_images` as `"0"` or `"1"`, followed by each selected image in query order as `"\0" + sha256 + "\0" + mime_type + "\0" + byte_size`. Hash that complete byte sequence with the existing `hash::hash_bytes`. This means an unchecked request has no images and a different digest from a checked request even when no candidate qualifies. + +### Task 1: Add the X Article reducer with test-first precedence + +**Files:** + +- Modify: `crates/archivr-core/src/summarizer.rs` +- Test: `crates/archivr-core/src/summarizer.rs` (`#[cfg(test)] mod tests`) + +- [ ] **Step 1: Write failing tests for each per-status X Article precedence rule.** + + Add assertions against `extract_tweet_text` using values that retain a link-only top-level tweet body: + + ```rust + #[test] + fn extract_tweet_text_prefers_x_article_plain_text_over_tco_body() { + let tweet = serde_json::json!({ + "full_text": "https://t.co/article", + "article": { "title": "Skin guide", "plain_text": "Use sunscreen daily." } + }); + assert_eq!(extract_tweet_text(&tweet).as_deref(), + Some("Skin guide\n\nUse sunscreen daily.")); + } + + #[test] + fn extract_tweet_text_uses_article_blocks_when_plain_text_is_empty() { + let tweet = serde_json::json!({"article": { + "title": "Blocks", "plain_text": " ", + "blocks": [{"text": "First"}, {"children": [{"text": "Second"}]}] + }}); + assert_eq!(extract_tweet_text(&tweet).as_deref(), Some("Blocks\n\nFirst\n\nSecond")); + } + + #[test] + fn extract_tweet_text_falls_back_from_article_to_link_only_tweet_body() { + let tweet = serde_json::json!({ + "full_text": "https://t.co/fallback", + "article": {"title": "Preview", "preview_text": "Preview copy", "summary_text": "Later"} + }); + assert_eq!(extract_tweet_text(&tweet).as_deref(), Some("Preview\n\nPreview copy")); + } + ``` + +- [ ] **Step 2: Run the focused test target and observe it fail.** + + Run: `cargo test -p archivr-core extract_tweet_text_` + + Expected: FAIL because the current reducer returns the top-level `full_text` or does not descend into `article.blocks`. + +- [ ] **Step 3: Implement `article_text` and deterministic block flattening.** + + Add private helpers before `extract_tweet_text`: + + ```rust + fn nonempty_string(v: &serde_json::Value, key: &str) -> Option; + fn flatten_article_blocks(v: &serde_json::Value, out: &mut Vec); + fn article_text(status: &serde_json::Value) -> Option; + ``` + + `article_text` must inspect the status's `article` object before ordinary fields. With a nonempty title, format each successful source as `title + "\n\n" + body`; if title is empty, return only `body`. Select the body in this exact order: nonblank `plain_text`; recursive text leaves from `blocks` in JSON array/object encounter order; nonblank `preview_text`; nonblank `summary_text`. `flatten_article_blocks` must collect only textual scalar values from conventional textual keys (`text`, `plain_text`, `content`, `body`, `title`, `heading`) and recursively visit arrays and objects; it must not stringify IDs, URLs, booleans, media metadata, or arbitrary scalar fields. Join block leaves with `"\n\n"`. + +- [ ] **Step 4: Integrate the helper into all tweet-status paths.** + + Make `one(v)` call `article_text(v).or_else(|| normal_tweet_text(v))`, where `normal_tweet_text` preserves the existing `full_text`, `text`, `content`, `body` sequence. Keep support for a top-level `{ "tweet": ... }` wrapper and for embedded `thread`, `tweets`, and `replies` members. + +- [ ] **Step 5: Add the thread-artifact regression test and run the focused tests.** + + Add a fixture archive with two `raw_tweet_json` artifacts, where each JSON status has article text, then assert `build_summary_input(..., SummaryBuildOptions::default())?.request.content` contains both article bodies separated by `"\n\n---\n\n"`. Run: `cargo test -p archivr-core extract_tweet_text_ build_summary_input_` + + Expected: PASS, including the existing wrapped/thread tweet tests. + +- [ ] **Step 6: Commit the atomic reducer change.** + + ```bash + git add crates/archivr-core/src/summarizer.rs + git commit -m "fix: summarize X Article text" + ``` + +### Task 2: Model and select explicit image inputs in core + +**Files:** + +- Modify: `crates/archivr-core/src/summarizer.rs` +- Test: `crates/archivr-core/src/summarizer.rs` (`#[cfg(test)] mod tests`) + +- [ ] **Step 1: Write failing candidate-selection and digest tests.** + + Build a temporary archive entry containing `media` artifacts for valid `jpg`, `png`, `webp`, `gif`, and `avif`, plus `avatar`, `video`, `audio`, unsupported `svg`, one 5 MiB + 1 byte image, and enough valid images to exceed both the four-image and 12 MiB limits. Assert only role `media`, allowed MIME/extension pairs, at most four descriptors, no descriptor over 5 MiB, and total selected bytes at most 12 MiB. Also assert: + + ```rust + let text_only = build_summary_input(&paths, &uid, SummaryBuildOptions { include_images: false })?; + let visual = build_summary_input(&paths, &uid, SummaryBuildOptions { include_images: true })?; + assert!(text_only.request.images.is_empty()); + assert!(!visual.request.images.is_empty()); + assert_ne!(text_only.input_sha256, visual.input_sha256); + ``` + +- [ ] **Step 2: Run the new core tests and observe failure.** + + Run: `cargo test -p archivr-core summary_image_` + + Expected: FAIL because `SummaryRequest` has no images and input construction has no image-selection mode. + +- [ ] **Step 3: Define the shared image model and query candidates from existing blobs.** + + Add the constants and `SummaryBuildOptions`, `SummaryImage`, and `SummaryRequest.images` definitions from the file map. Add a private `load_summary_image_candidates(conn, entry_id) -> Result>` querying `entry_artifacts ea JOIN blobs b` for `ea.entry_id = ?1 AND ea.artifact_role = 'media'`, ordered by `ea.id ASC`, selecting `b.sha256`, `b.mime_type`, `b.extension`, `b.byte_size`, and `ea.relpath`. + + Accept a candidate only when both of the following are true: its extension is one of `jpg`, `jpeg`, `png`, `webp`, `gif`, `avif`, and its MIME is the matching `image/jpeg`, `image/png`, `image/webp`, `image/gif`, or `image/avif` family. Resolve `archive_file` under the configured store path and reject a candidate whose canonicalized/normalized path escapes that store root. Stop at the first candidate that would exceed either image count, per-image, or aggregate byte limit; continue scanning later candidates so a too-large or unsupported early artifact cannot hide a valid later one. + +- [ ] **Step 4: Build options-aware input and a complete cache digest.** + + Change every current `build_summary_input` call to pass `SummaryBuildOptions::default()` until Task 4 changes the server. Populate `request.images` only if `options.include_images` is true; text extraction and `MAX_INPUT_CHARS` handling remain identical. Replace `hash_bytes(content.as_bytes())` with a private `summary_input_digest(content, include_images, images)` implementing the stated NUL-delimited preimage so the existing database uniqueness constraint continues to distinguish all modes. Do not alter `database.rs`: `input_sha256` already participates in the cache key. + +- [ ] **Step 5: Run core regression tests.** + + Run: `cargo test -p archivr-core summary_image_ build_summary_input_` + + Expected: PASS; the text-only request has zero image descriptors, and selection is deterministic by artifact insertion order. + +- [ ] **Step 6: Commit the core input model.** + + ```bash + git add crates/archivr-core/src/summarizer.rs + git commit -m "feat: model opt-in summary images" + ``` + +### Task 3: Make provider transports honor image descriptors + +**Files:** + +- Modify: `crates/archivr-core/src/summarizer.rs` +- Test: `crates/archivr-core/src/summarizer.rs` (`#[cfg(test)] mod tests`) + +- [ ] **Step 1: Write failing payload, command, and capability tests.** + + Construct a `SummaryRequest` with one tiny fixture `SummaryImage` and assert `anthropic_request_body` puts a text block and `{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "..." } }` in `messages[0].content`. Assert `openai_request_body` emits a text content part plus `{ "type": "image_url", "image_url": { "url": "data:image/png;base64,..." } }`. Unit-test a pure Codex argument builder so its primary arguments contain `exec`, `--image`, the fixture path, `--output-last-message`, output path, and final `-`; test its positional fallback also keeps `--image`. Assert `ClaudeCliProvider::summarize` returns an error containing `Claude CLI cannot attach local images` when `request.images` is nonempty. + +- [ ] **Step 2: Run the provider tests and observe failure.** + + Run: `cargo test -p archivr-core "anthropic_request_body|openai_request_body|codex.*image|claude.*images"` + + Expected: FAIL because HTTP bodies are string-only, Codex has no `--image`, and Claude silently accepts the request. + +- [ ] **Step 3: Encode images for each HTTP protocol.** + + Add `read_image_base64(image: &SummaryImage) -> Result` which reads only the already bounded selected file and uses `base64::Engine` with the existing dependency or workspace dependency. Make `anthropic_request_body` and `openai_request_body` return their present text-only JSON shapes when `request.images.is_empty()` and their documented content-part arrays otherwise. Preserve `SYSTEM_PROMPT`, `build_user_prompt`, provider URL, headers, and response parsing. + +- [ ] **Step 4: Extend Codex safely and reject Claude at the provider boundary.** + + Refactor `codex::run` to accept `&[SummaryImage]`; add `--image ` once per selected image before `--output-last-message` in both primary stdin and positional-prompt forms. Keep the existing last-message temporary-file contract and cleanup behavior. Have `ClaudeCliProvider::summarize` `bail!("Claude CLI cannot attach local images; choose an HTTP provider or Codex CLI")` before spawning when images are supplied. + +- [ ] **Step 5: Thread image-aware options through synchronous orchestration.** + + Change `summarize_entry` to accept `SummaryBuildOptions` and call the options-aware builder before provider invocation. This is a synchronous core function; do not introduce Tokio, async traits, or new database fields. + +- [ ] **Step 6: Run provider and existing summary tests.** + + Run: `cargo test -p archivr-core summarizer::tests` + + Expected: PASS, with all four providers retaining their text-only behavior when `images` is empty. + +- [ ] **Step 7: Commit the provider implementation.** + + ```bash + git add crates/archivr-core/src/summarizer.rs + git commit -m "feat: attach opted-in images to summaries" + ``` + +### Task 4: Expose the opt-in flag through the server API + +**Files:** + +- Modify: `crates/archivr-server/src/routes.rs` +- Test: `crates/archivr-server/src/routes.rs` (`#[cfg(test)] mod tests`) + +- [ ] **Step 1: Write failing route tests.** + + Add authenticated POST tests that deserialize a request without `include_images` and assert it takes the text-only build path, and with `{ "provider": "claude_cli", "include_images": true }` assert status `400 BAD_REQUEST` and an error containing `Claude CLI cannot attach local images`. Add a successful non-Claude request test with `include_images: true` using a configured test provider and assert the returned cache key differs from the equivalent text-only request. Keep GET assertions unchanged: it remains `{ "entry_uid", "summary" }`. + +- [ ] **Step 2: Run the focused route tests and observe failure.** + + Run: `cargo test -p archivr-server "summary.*include_images|claude.*images"` + + Expected: FAIL because the request body does not accept the field and no capability check occurs. + +- [ ] **Step 3: Add the backward-compatible request field and capability guard.** + + Extend `SummaryRequestBody` exactly as follows: + + ```rust + #[derive(Debug, serde::Deserialize)] + struct SummaryRequestBody { + provider: String, + #[serde(default)] + force: bool, + #[serde(default)] + include_images: bool, + } + ``` + + After resolving `provider_cfg` and before cache lookup/upsert/spawn, return `ApiError::bad_request("Claude CLI cannot attach local images; choose an HTTP provider or Codex CLI")` when `body.include_images && matches!(provider_cfg, ProviderConfig::ClaudeCli(_))`. Construct `SummaryBuildOptions { include_images: body.include_images }` once and pass it to the preflight builder and cloned into `spawn_blocking` for `summarize_entry`. + +- [ ] **Step 4: Verify cache and lifecycle consistency.** + + Ensure preflight `build_summary_input` and background `summarize_entry` receive the same options, so `find_entry_summary` and `upsert_pending_entry_summary` use the same digest. Do not change `entry_summaries`, `latest_entry_summary`, or the GET route; the existing input-hash uniqueness constraint is sufficient. + +- [ ] **Step 5: Run the focused server tests.** + + Run: `cargo test -p archivr-server "summary.*include_images|claude.*images"` + + Expected: PASS; omitting the new field is text-only and a Claude image request produces no pending summary row. + +- [ ] **Step 6: Commit the API wiring.** + + ```bash + git add crates/archivr-server/src/routes.rs + git commit -m "feat: accept image summary requests" + ``` + +### Task 5: Add the explicit, provider-aware image consent control + +**Files:** + +- Modify: `frontend/src/api.js` +- Modify: `frontend/src/components/ContextRail.jsx` +- Modify: `frontend/src/styles.css` +- Test: manual browser smoke test (no frontend test harness exists) + +- [ ] **Step 1: Inspect the existing provider selector and write the manual failure script.** + + In a locally authenticated entry detail, select an HTTP provider and verify the Summary section currently has no `Include attached images` checkbox; select Claude CLI and verify there is no capability explanation. Record this as the observed pre-implementation failure. Do not add a frontend test framework. + +- [ ] **Step 2: Change the API client contract.** + + Change the function signature and POST body only: + + ```js + export async function requestEntrySummary( + archiveId, entryUid, { provider, force = false, includeImages = false } = {} + ) { + // existing fetch and error parsing + body: JSON.stringify({ provider, force, include_images: includeImages }) + } + ``` + + Keep all `fetch` calls inside `frontend/src/api.js`; do not add an inline fetch in the component. + +- [ ] **Step 3: Implement the local per-generation control.** + + Add `const [includeSummaryImages, setIncludeSummaryImages] = useState(false)` beside the summary provider state. In `handleGenerateSummary`, pass `includeImages: includeSummaryImages`. Reset this state to `false` whenever `detail?.summary?.entry_uid` changes, so a consent choice cannot carry to another entry. Do not persist the checkbox in `sessionStorage`; the consent is per generation and defaults off. + +- [ ] **Step 4: Render clear consent, scope, and Claude capability states.** + + In `.rail-summary-controls`, below the provider `