diff --git a/docs/design-docs/channel-attachment-persistence.md b/docs/design-docs/channel-attachment-persistence.md new file mode 100644 index 000000000..845de4303 --- /dev/null +++ b/docs/design-docs/channel-attachment-persistence.md @@ -0,0 +1,373 @@ +# Channel Attachment Persistence + +Persistent file/image storage for channel attachments, with history integration and a recall tool so the channel can reference saved files across conversation turns without re-downloading them. + +## Problem + +Today, when a user sends an image or file in a channel (Discord, Slack, Telegram), the attachment is downloaded, converted to base64 (images) or inlined text, and injected into the current LLM turn as `UserContent`. Once that turn completes, the actual file data is gone. The LLM's response may describe the image, but the raw file is not retained anywhere. + +This creates several problems: + +1. **No recall.** If the user says "let's talk about that image from earlier," the channel has no way to re-analyze it. The image only exists as whatever the LLM said about it in its previous response. +2. **No handoff.** If the channel needs to delegate work involving a file to a worker ("resize these images", "extract data from this PDF"), it has no path to give the worker access to the actual file. The channel would need to spawn a worker, and that worker would have no way to get the file. +3. **No persistence.** Platform URLs (especially Slack's `url_private`) expire. If history is replayed or the channel restarts, the URLs are dead. +4. **No file identity.** The LLM sees base64 blobs or inline text with a filename, but there's no stable identifier linking a history entry to an on-disk file. + +## Design + +### Saved Attachments Table + +A new `saved_attachments` table tracks every file persisted to disk. This is the source of truth for what's been saved and where it lives. + +```sql +CREATE TABLE IF NOT EXISTS saved_attachments ( + id TEXT PRIMARY KEY, -- UUID + channel_id TEXT NOT NULL, -- which channel received the file + message_id TEXT, -- conversation_messages.id (nullable, for files from non-message sources) + original_filename TEXT NOT NULL, -- the filename as it appeared on the platform + saved_filename TEXT NOT NULL, -- the actual filename on disk (may have suffix for dedup) + mime_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + disk_path TEXT NOT NULL, -- absolute path on disk + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE +); + +CREATE INDEX idx_saved_attachments_channel ON saved_attachments(channel_id, created_at); +CREATE INDEX idx_saved_attachments_message ON saved_attachments(message_id); +``` + +Key fields: + +- `original_filename` — what the user named the file (e.g., `screenshot.png`). Preserved for display. +- `saved_filename` — what's on disk. Usually the same as `original_filename`, but with a numeric suffix when there's a name collision (e.g., `screenshot_2.png`). +- `disk_path` — absolute path. Redundant with `workspace/saved/{saved_filename}` but avoids assumptions about workspace location if it changes. +- `message_id` — links back to the conversation message that contained this attachment. Nullable because files could come from non-message sources in the future. + +### Disk Layout + +Saved attachments live in a new `saved/` directory under the workspace: + +```text +workspace/ + ingest/ -- existing: user drops files for ingestion + skills/ -- existing: skill files + saved/ -- NEW: persisted channel attachments + screenshot.png + diagram.png + report_2.pdf -- deduped name (second "report.pdf" received) + SOUL.md + IDENTITY.md + USER.md +``` + +The `saved/` directory is created at startup alongside `ingest/` and `skills/`. + +A `saved_dir()` helper is added to `ResolvedAgentConfig`: + +```rust +pub fn saved_dir(&self) -> PathBuf { + self.workspace.join("saved") +} +``` + +### Filename Deduplication + +When saving a file, if `saved/{original_filename}` already exists on disk AND the existing file has a different `id` in the database: + +1. Strip the extension: `report` from `report.pdf` +2. Append `_N` where N starts at 2: `report_2.pdf` +3. Increment until a unique name is found + +This is a filesystem + DB check, not just filesystem — if a file was saved and then deleted from disk, the DB record still exists and the name is still "taken" to avoid confusion in history references. + +### Channel Behavior Setting: `save_attachments` + +A new boolean field on `ChannelConfig`: + +```rust +pub struct ChannelConfig { + pub listen_only_mode: bool, + pub save_attachments: bool, // NEW — default: false +} +``` + +TOML schema: + +```toml +[channel] +listen_only_mode = false +save_attachments = true +``` + +When `save_attachments` is `true` for a channel, every attachment received in that channel is: + +1. Downloaded (as it is today) +2. Saved to `workspace/saved/` with dedup +3. Recorded in `saved_attachments` table +4. Processed for the LLM as today (base64 image, inline text, etc.) +5. The history entry includes a metadata annotation with the attachment ID and saved filename + +When `false` (default), behavior is unchanged — files are downloaded, processed for the current turn, and discarded. + +### History Integration + +The key insight: when an attachment is saved, the conversation history entry for that message needs to carry metadata that the LLM can see on future turns, even after the base64 image data is gone from the context window. + +#### User Message Logging + +When `save_attachments` is enabled and attachments are processed, the `ConversationLogger::log_user_message()` call includes attachment metadata in the `metadata` JSON blob: + +```json +{ + "platform": "discord", + "attachments": [ + { + "id": "a1b2c3d4-...", + "filename": "screenshot.png", + "saved_filename": "screenshot.png", + "mime_type": "image/png", + "size_bytes": 245760 + }, + { + "id": "e5f6g7h8-...", + "filename": "diagram.png", + "saved_filename": "diagram.png", + "mime_type": "image/png", + "size_bytes": 189440 + } + ] +} +``` + +#### History Reconstruction + +When history is loaded for LLM context (the `Vec` that gets passed to `agent.prompt().with_history()`), messages with attachment metadata get an annotation appended to their content: + +```text +[User sent 2 attachments: screenshot.png (image/png, 240 KB, id:a1b2c3d4), diagram.png (image/png, 185 KB, id:e5f6g7h8)] +``` + +This is a text annotation — not the actual image. The LLM can see *that* images were sent, *what* they were named, and *how* to reference them. On the turn when the image was originally sent, the LLM also sees the base64 image data (as today). On subsequent turns, only the annotation remains. + +This gives the LLM enough context to: +- Know which files exist in the conversation +- Reference them by name or ID +- Use the recall tool to re-analyze them or get their paths + +### Recall Tool: `attachment_recall` + +A new channel-level tool that lets the channel retrieve saved attachment info and optionally re-load file content. + +```rust +pub struct AttachmentRecallTool { + pool: SqlitePool, + workspace: PathBuf, +} +``` + +**Arguments:** + +```json +{ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["list", "get_path", "get_content"], + "description": "What to do: list recent attachments, get the disk path of a specific file, or re-load its content for analysis." + }, + "attachment_id": { + "type": "string", + "description": "The attachment ID (from history metadata). Required for get_path and get_content." + }, + "filename": { + "type": "string", + "description": "Alternative to attachment_id — look up by original filename. If multiple matches, returns the most recent." + }, + "limit": { + "type": "integer", + "default": 10, + "description": "For list action: how many recent attachments to return." + } + }, + "required": ["action"] +} +``` + +**Actions:** + +- **`list`** — Returns a summary of recent saved attachments for the current channel. Output: JSON array of `{id, filename, saved_filename, mime_type, size_bytes, disk_path, created_at}`. + +- **`get_path`** — Returns the absolute disk path of a specific attachment (by ID or filename). This is the primary use case for delegation: the channel calls `get_path`, gets `/home/user/.spacebot/agents/mybot/workspace/saved/screenshot.png`, and includes that path in a worker task description. + +- **`get_content`** — Re-loads the file from disk and returns it as `UserContent` (base64 for images, inline text for text files). This lets the channel re-analyze an image that was sent many turns ago. **Size limit: 10 MB.** Files larger than this return an error directing the channel to delegate to a worker instead. + +**Tool availability:** Channel-level only (added/removed per-turn like `send_file`). Branches and workers don't need this — the channel passes paths to workers via task descriptions, and branches can see the attachment metadata in the cloned history. + +### Processing Flow + +#### Inbound (attachment received) + +```text +User sends message with attachments + → Adapter extracts Attachment structs (unchanged) + → Channel receives InboundMessage with MessageContent::Media + → download_attachments() runs (unchanged — produces Vec) + → IF save_attachments is enabled for this channel: + → save_channel_attachments() runs in parallel: + → For each attachment: + → Download bytes (reuses download_attachment_bytes()) + → Compute saved_filename (dedup against DB + disk) + → Write to workspace/saved/{saved_filename} + → INSERT into saved_attachments table + → Returns Vec metadata + → Attachment metadata is merged into the message metadata HashMap + → log_user_message() persists content + metadata (including attachment info) + → run_agent_turn() with attachment UserContent (unchanged) +``` + +The save operation runs concurrently with the download-for-LLM operation. Since both need the raw bytes, the download happens once and the bytes are shared (via `Arc>` or by saving first and reading from disk for the LLM). + +Actually, simpler: download once, save to disk, then read from disk for base64 encoding. The disk write is fast (local SSD) and avoids holding large byte buffers in memory. This also means the save is the source of truth — if the base64 encoding fails, the file is still on disk. + +Revised flow: + +```text +For each attachment (when save_attachments = true): + 1. Download raw bytes from URL + 2. Compute saved_filename (dedup) + 3. Write bytes to workspace/saved/{saved_filename} + 4. INSERT row into saved_attachments + 5. Read bytes back from disk for base64/inline processing → UserContent +``` + +For step 5, images are re-read and base64 encoded. Text files are re-read and inlined. This is a trivial read from local disk. When `save_attachments = false`, the existing direct-from-download flow is unchanged. + +#### Recall (channel wants to reference a past attachment) + +```text +User: "Can you analyze that screenshot I sent earlier?" + → Channel sees attachment annotation in history: + [User sent 1 attachment: screenshot.png (image/png, 240 KB, id:a1b2c3d4)] + → Channel calls attachment_recall(action: "get_content", attachment_id: "a1b2c3d4") + → Tool reads file from workspace/saved/screenshot.png + → Returns UserContent::Image (base64) — injected into current turn + → Channel can now see and analyze the image again +``` + +#### Delegation (channel passes file to worker) + +```text +User: "Resize that screenshot to 800x600" + → Channel sees attachment annotation in history + → Channel calls attachment_recall(action: "get_path", attachment_id: "a1b2c3d4") + → Returns: "/home/user/.spacebot/agents/mybot/workspace/saved/screenshot.png" + → Channel calls spawn_worker with task: + "Resize the image at /home/user/.spacebot/agents/mybot/workspace/saved/screenshot.png to 800x600. + Save the result in the same directory." + → Worker has file tool access to the workspace, can read/write the file +``` + +### What the LLM Sees + +**Turn 1 (image sent):** +```text +[User content] + ← actual image for vision analysis +screenshot.png (image/png) + +"Here's a screenshot of the bug" +``` + +**Turn 3 (later in conversation, image data is gone from context):** +```text +[History entry for Turn 1] +Jamie: Here's a screenshot of the bug +[Attachments: screenshot.png (image/png, 240 KB, id:a1b2c3d4)] +``` + +The LLM sees the annotation, knows the file exists, and can use `attachment_recall` to get it back if needed. + +### Edge Cases + +**Large files.** The `get_content` action has a 10 MB limit. For larger files, the channel should use `get_path` and delegate to a worker. The tool error message guides this. + +**Deleted files.** If someone manually deletes a file from `workspace/saved/`, the DB record still exists. `get_content` and `get_path` return an error indicating the file is missing on disk. The history annotation remains (it's in the conversation log). + +**Disk space.** No automatic cleanup in v1. The `saved/` directory grows indefinitely. Future work: configurable retention, size limits, or LRU eviction. The DB table makes cleanup queries easy (`DELETE FROM saved_attachments WHERE created_at < ?` + unlink files). + +**Concurrent saves.** Two messages arriving simultaneously with the same filename: the dedup logic uses a DB check (`SELECT COUNT(*) FROM saved_attachments WHERE saved_filename = ?`) inside a transaction, so concurrent saves get unique names. + +**Non-image/non-text files.** PDFs, videos, archives, etc. are saved to disk and recorded in the DB, but `get_content` only supports images and text files (same as today's processing). For other file types, `get_content` returns a metadata description and the disk path, encouraging delegation to a worker. + +**Channel restart / history replay.** On restart, the channel loads history from the DB. Messages with attachment metadata show the `[Attachments: ...]` annotation. The files are on disk. The `attachment_recall` tool works immediately. No re-download needed. + +## Implementation + +### New Files + +- `migrations/2026MMDD000001_saved_attachments.sql` — table + indexes +- No new Rust source files — attachment saving logic goes in `channel_attachments.rs`, the tool goes in `tools/attachment_recall.rs`, and the config change is a one-line addition to `ChannelConfig` + +### Modified Files + +| File | Change | +|------|--------| +| `src/config/types.rs` | Add `save_attachments: bool` to `ChannelConfig`, add `saved_dir()` to `ResolvedAgentConfig` | +| `src/config/toml_schema.rs` | Add `save_attachments: Option` to `TomlChannelConfig` | +| `src/config/runtime.rs` | Wire through `save_attachments` in `ChannelConfig` ArcSwap | +| `src/main.rs` | Create `saved/` directory at startup | +| `src/agent/channel_attachments.rs` | Add `save_channel_attachments()` function, `SavedAttachment` struct | +| `src/agent/channel.rs` | Call `save_channel_attachments()` when enabled, merge metadata into message log, add history annotation reconstruction | +| `src/tools/attachment_recall.rs` | New tool implementation | +| `src/tools.rs` | Register `AttachmentRecallTool` in channel tool server (add/remove per-turn) | +| `src/conversation/history.rs` | Add helper to reconstruct attachment annotations from message metadata during history load | +| `prompts/tools/attachment_recall.md` | Tool description for LLM | + +### Phases + +**Phase 1: Storage Layer** +- Migration +- `SavedAttachment` struct and `save_channel_attachments()` in `channel_attachments.rs` +- `saved_dir()` helper, `save_attachments` config field +- Startup directory creation +- Filename dedup logic + +**Phase 2: Channel Integration** +- Wire save flow into `channel.rs` attachment processing +- Merge attachment metadata into message metadata +- History annotation reconstruction (when loading history, append `[Attachments: ...]` to messages that have attachment metadata) + +**Phase 3: Recall Tool** +- `AttachmentRecallTool` implementation (list, get_path, get_content) +- Register in channel tool server +- Prompt file + +**Phase 4: Polish** +- API endpoint to list saved attachments for a channel (for the dashboard) +- Dashboard UI: attachment gallery or inline previews in timeline +- Configurable retention / size limits + +### Phase Ordering + +```text +Phase 1 (storage) — standalone +Phase 2 (channel) — depends on Phase 1 +Phase 3 (tool) — depends on Phase 1, independent of Phase 2 +Phase 4 (polish) — depends on Phases 2 + 3 +``` + +Phases 2 and 3 can run in parallel after Phase 1. + +## Open Questions + +**Save all channels or opt-in?** Current design is opt-in via `save_attachments` per-channel config. Alternative: save by default, with an opt-out. The opt-in approach is safer for disk space and avoids surprises, but means users have to discover and enable the setting. Could default to `true` in a future version once retention policies exist. + +**Attachment dedup by content hash.** Two users sending the same image would create two copies on disk. A content-hash-based dedup (SHA-256 of the bytes, store once, reference twice) would save disk space but adds complexity. Not worth it for v1 — most attachments are unique. + +**Branch access to files.** Branches clone the channel history, so they see attachment annotations. Should branches also get the `attachment_recall` tool? Currently no — branches don't need to re-analyze images (that's the channel's job) and they can see the metadata in history to include paths in worker task descriptions. If a branch needs to spawn a worker with a file path, it can read the path from the history annotation. Revisit if this becomes a friction point. + +**Audio transcription.** Transcribed audio attachments are currently ephemeral too. Should the original audio file be saved to `saved/` alongside images and documents? Probably yes — the transcription text is in history, but the audio file itself might be useful for re-processing or delegation. The design supports this naturally since audio has a MIME type and the save logic is MIME-agnostic. + +**Video files.** Currently unsupported by the LLM processing pipeline (no vision model handles video). Saving them to disk is still useful for worker delegation ("extract frames from this video"). The save logic handles them; `get_content` returns metadata + path instead of trying to base64 a video. diff --git a/migrations/20260306000001_saved_attachments.sql b/migrations/20260306000001_saved_attachments.sql new file mode 100644 index 000000000..86e35948f --- /dev/null +++ b/migrations/20260306000001_saved_attachments.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS saved_attachments ( + id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + message_id TEXT, + original_filename TEXT NOT NULL, + saved_filename TEXT NOT NULL, + mime_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + disk_path TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_saved_attachments_channel ON saved_attachments(channel_id, created_at); +CREATE INDEX IF NOT EXISTS idx_saved_attachments_message ON saved_attachments(message_id); diff --git a/migrations/20260306000002_saved_attachments_unique_filename.sql b/migrations/20260306000002_saved_attachments_unique_filename.sql new file mode 100644 index 000000000..1273120ff --- /dev/null +++ b/migrations/20260306000002_saved_attachments_unique_filename.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_attachments_saved_filename ON saved_attachments(saved_filename); diff --git a/prompts/en/tools/attachment_recall_description.md.j2 b/prompts/en/tools/attachment_recall_description.md.j2 new file mode 100644 index 000000000..702e4f202 --- /dev/null +++ b/prompts/en/tools/attachment_recall_description.md.j2 @@ -0,0 +1,8 @@ +Recall saved file attachments from this channel's history. Files sent by users are automatically saved to disk when attachment persistence is enabled. + +Three actions: +- **list**: Show recent saved attachments (filename, type, size, ID). +- **get_path**: Get the absolute disk path of a saved file. Use this when delegating work to a worker — include the path in the task description so the worker can access the file directly. +- **get_content**: Re-load a saved image or text file for analysis. Use this when you need to see a file that was sent earlier in the conversation. Only works for images and text files under 10 MB — for other file types or larger files, use get_path and delegate to a worker. + +You can identify attachments by their ID (shown in history annotations like `id:a1b2c3d4`) or by their original filename. diff --git a/scripts/gate-pr.sh b/scripts/gate-pr.sh index c4934a156..76a4f4b11 100755 --- a/scripts/gate-pr.sh +++ b/scripts/gate-pr.sh @@ -177,7 +177,7 @@ run_step "cargo check --all-targets" cargo check --all-targets if $fast_mode; then log "fast mode enabled: skipping clippy and integration test compile" else - run_step "cargo clippy --all-targets" cargo clippy --all-targets + run_step "RUSTFLAGS=\"-Dwarnings\" cargo clippy --all-targets" env RUSTFLAGS="-Dwarnings" cargo clippy --all-targets fi run_step "cargo test --lib" cargo test --lib diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 0f325ea5e..85e7c1306 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -1,5 +1,6 @@ //! Channel: User-facing conversation process. +use crate::agent::channel_attachments; use crate::agent::channel_attachments::download_attachments; use crate::agent::channel_dispatch::spawn_memory_persistence_branch; use crate::agent::channel_history::{ @@ -562,7 +563,12 @@ impl Channel { persisted } - fn persist_inbound_user_message(&self, message: &InboundMessage, raw_text: &str) { + fn persist_inbound_user_message( + &self, + message: &InboundMessage, + raw_text: &str, + saved_attachments: Option<&[channel_attachments::SavedAttachmentMeta]>, + ) { if message.source == "system" { return; } @@ -571,16 +577,28 @@ impl Channel { .get("sender_display_name") .and_then(|v| v.as_str()) .unwrap_or(&message.sender_id); + + // If attachments were saved, enrich the metadata with their info + let metadata = if let Some(saved) = saved_attachments { + let mut enriched = message.metadata.clone(); + if let Ok(attachments_json) = serde_json::to_value(saved) { + enriched.insert("attachments".to_string(), attachments_json); + } + enriched + } else { + message.metadata.clone() + }; + self.state.conversation_logger.log_user_message( &self.state.channel_id, sender_name, &message.sender_id, raw_text, - &message.metadata, + &metadata, ); self.state .channel_store - .upsert(&message.conversation_id, &message.metadata); + .upsert(&message.conversation_id, &metadata); } fn suppress_plaintext_fallback(&self) -> bool { @@ -1120,7 +1138,20 @@ impl Channel { } // Persist each message to conversation log (individual audit trail) - let mut pending_batch_entries: Vec<(String, Vec<_>)> = Vec::new(); + let save_attachments_enabled = self + .deps + .runtime_config + .channel_config + .load() + .save_attachments; + let saved_dir = self.deps.runtime_config.saved_dir(); + + // Entries: (formatted_text, attachments, optional saved bytes per attachment) + let mut pending_batch_entries: Vec<( + String, + Vec, + Option>, + )> = Vec::new(); let mut conversation_id = String::new(); let temporal_context = TemporalContext::from_runtime(self.deps.runtime_config.as_ref()); let mut batch_has_invoke = false; @@ -1151,16 +1182,44 @@ impl Channel { invoked_by_command || invoked_by_mention || invoked_by_reply; } + // Save attachments to disk when enabled + let saved_data = if save_attachments_enabled && !attachments.is_empty() { + Some( + channel_attachments::save_channel_attachments( + &self.deps.sqlite_pool, + self.deps.llm_manager.http_client(), + self.state.channel_id.as_ref(), + &saved_dir, + &attachments, + ) + .await, + ) + } else { + None + }; + + // Enrich metadata with saved attachment info + let metadata = if let Some(ref data) = saved_data { + let metas: Vec<_> = data.iter().map(|(meta, _)| meta.clone()).collect(); + let mut enriched = message.metadata.clone(); + if let Ok(json) = serde_json::to_value(&metas) { + enriched.insert("attachments".to_string(), json); + } + enriched + } else { + message.metadata.clone() + }; + self.state.conversation_logger.log_user_message( &self.state.channel_id, sender_name, &message.sender_id, &raw_text, - &message.metadata, + &metadata, ); self.state .channel_store - .upsert(&message.conversation_id, &message.metadata); + .upsert(&message.conversation_id, &metadata); conversation_id = message.conversation_id.clone(); @@ -1187,7 +1246,7 @@ impl Channel { &raw_text, ); - pending_batch_entries.push((formatted_text, attachments)); + pending_batch_entries.push((formatted_text, attachments, saved_data)); } } @@ -1204,9 +1263,31 @@ impl Channel { } let mut user_contents: Vec = Vec::new(); - for (formatted_text, attachments) in pending_batch_entries { + for (formatted_text, attachments, saved_data) in pending_batch_entries { if !attachments.is_empty() { - let attachment_content = download_attachments(&self.deps, &attachments).await; + let attachment_content = if let Some(ref saved) = saved_data { + let mut content = Vec::new(); + let mut unsaved = Vec::new(); + for (index, attachment) in attachments.iter().enumerate() { + if let Some((_, bytes)) = saved.get(index) { + if attachment.mime_type.starts_with("audio/") { + unsaved.push(attachment.clone()); + } else { + content.push(channel_attachments::content_from_bytes( + bytes, attachment, + )); + } + } else { + unsaved.push(attachment.clone()); + } + } + if !unsaved.is_empty() { + content.extend(download_attachments(&self.deps, &unsaved).await); + } + content + } else { + download_attachments(&self.deps, &attachments).await + }; for content in attachment_content { user_contents.push(content); } @@ -1362,7 +1443,34 @@ impl Channel { crate::MessageContent::Interaction { .. } => (message.content.to_string(), Vec::new()), }; - self.persist_inbound_user_message(&message, &raw_text); + // Save attachments to disk when enabled, capturing bytes for LLM reuse + let save_attachments_enabled = self + .deps + .runtime_config + .channel_config + .load() + .save_attachments; + let saved_attachment_data = if save_attachments_enabled && !attachments.is_empty() { + let saved_dir = self.deps.runtime_config.saved_dir(); + Some( + channel_attachments::save_channel_attachments( + &self.deps.sqlite_pool, + self.deps.llm_manager.http_client(), + self.state.channel_id.as_ref(), + &saved_dir, + &attachments, + ) + .await, + ) + } else { + None + }; + + let saved_metas: Option> = saved_attachment_data + .as_ref() + .map(|data| data.iter().map(|(meta, _)| meta.clone()).collect()); + + self.persist_inbound_user_message(&message, &raw_text, saved_metas.as_deref()); // Deterministic built-in command: bypass model output drift for agent identity checks. if message.source != "system" && raw_text.trim() == "/agent-id" { @@ -1480,7 +1588,35 @@ impl Channel { let is_retrigger = message.source == "system"; let attachment_content = if !attachments.is_empty() { - download_attachments(&self.deps, &attachments).await + if let Some(ref saved_data) = saved_attachment_data { + // Reuse already-downloaded bytes for images/text; audio still + // needs transcription via the normal path so we fall through. + let mut content = Vec::new(); + let mut unsaved_attachments = Vec::new(); + + for (index, attachment) in attachments.iter().enumerate() { + if let Some((_, bytes)) = saved_data.get(index) { + // Audio attachments need transcription, not just bytes + if attachment.mime_type.starts_with("audio/") { + unsaved_attachments.push(attachment.clone()); + } else { + content + .push(channel_attachments::content_from_bytes(bytes, attachment)); + } + } else { + unsaved_attachments.push(attachment.clone()); + } + } + + // Process any attachments that weren't saved (or need transcription) + if !unsaved_attachments.is_empty() { + let extra = download_attachments(&self.deps, &unsaved_attachments).await; + content.extend(extra); + } + content + } else { + download_attachments(&self.deps, &attachments).await + } } else { Vec::new() }; diff --git a/src/agent/channel_attachments.rs b/src/agent/channel_attachments.rs index 7a7c98f98..7d7d21bb5 100644 --- a/src/agent/channel_attachments.rs +++ b/src/agent/channel_attachments.rs @@ -3,10 +3,16 @@ //! Handles image, text, and audio attachments — downloading from URLs, //! base64 encoding for vision models, inlining text content, and //! transcribing audio via the configured voice model. +//! +//! When `save_attachments` is enabled on a channel, downloaded files are +//! persisted to `workspace/saved/` and tracked in the `saved_attachments` +//! table for later recall. use crate::AgentDeps; use crate::config::ApiType; use rig::message::{ImageMediaType, MimeType, UserContent}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; /// Image MIME types we support for vision. const IMAGE_MIME_PREFIXES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"]; @@ -438,3 +444,356 @@ async fn download_text_attachment( attachment.filename, attachment.mime_type, truncated )) } + +/// A saved attachment paired with its raw bytes, used to avoid re-downloading. +pub(crate) type SavedAttachmentWithBytes = (SavedAttachmentMeta, Vec); + +/// Build LLM-ready `UserContent` from pre-downloaded bytes. Used when +/// `save_attachments` is enabled so we don't re-download from the URL. +/// +/// Audio attachments are NOT re-transcribed here — audio requires an LLM call +/// which needs `AgentDeps`. When saving is enabled, audio files are saved to +/// disk and transcribed via the normal `download_attachments` path (which will +/// be called separately). +pub(crate) fn content_from_bytes(bytes: &[u8], attachment: &crate::Attachment) -> UserContent { + let is_image = IMAGE_MIME_PREFIXES + .iter() + .any(|p| attachment.mime_type.starts_with(p)); + let is_text = TEXT_MIME_PREFIXES + .iter() + .any(|p| attachment.mime_type.starts_with(p)); + + if is_image { + use base64::Engine as _; + let base64_data = base64::engine::general_purpose::STANDARD.encode(bytes); + let media_type = ImageMediaType::from_mime_type(&attachment.mime_type); + UserContent::image_base64(base64_data, media_type, None) + } else if is_text { + let content = String::from_utf8_lossy(bytes).into_owned(); + let truncated = if content.len() > 50_000 { + let end = content.floor_char_boundary(50_000); + format!( + "{}...\n[truncated — {} bytes total]", + &content[..end], + content.len() + ) + } else { + content + }; + UserContent::text(format!( + "\n{}\n", + attachment.filename, attachment.mime_type, truncated + )) + } else { + let size_str = attachment + .size_bytes + .map(|s| format!("{:.1} KB", s as f64 / 1024.0)) + .unwrap_or_else(|| format!("{:.1} KB", bytes.len() as f64 / 1024.0)); + UserContent::text(format!( + "[Attachment: {} ({}, {})]", + attachment.filename, attachment.mime_type, size_str + )) + } +} + +// --------------------------------------------------------------------------- +// Attachment persistence +// --------------------------------------------------------------------------- + +/// Metadata for a saved attachment, returned after persisting to disk and DB. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SavedAttachmentMeta { + pub id: String, + pub filename: String, + pub saved_filename: String, + pub mime_type: String, + pub size_bytes: u64, +} + +/// Download and save channel attachments to `workspace/saved/`, recording each +/// in the `saved_attachments` table. Returns metadata for each saved file so +/// the caller can annotate the conversation message. +/// +/// Also returns the raw bytes keyed by index so the caller can reuse them for +/// LLM processing without a second download. +pub(crate) async fn save_channel_attachments( + pool: &sqlx::SqlitePool, + http: &reqwest::Client, + channel_id: &str, + saved_dir: &Path, + attachments: &[crate::Attachment], +) -> Vec<(SavedAttachmentMeta, Vec)> { + let mut results = Vec::with_capacity(attachments.len()); + + for attachment in attachments { + let safe_name = match sanitize_filename(&attachment.filename) { + Ok(name) => name, + Err(error) => { + tracing::warn!( + %error, + filename = %attachment.filename, + "rejected unsafe attachment filename" + ); + continue; + } + }; + + let bytes = match download_attachment_bytes(http, attachment).await { + Ok(bytes) => bytes, + Err(error) => { + tracing::warn!( + %error, + filename = %attachment.filename, + "failed to download attachment for saving" + ); + continue; + } + }; + + let saved_filename = match deduplicate_filename(pool, saved_dir, &safe_name).await { + Ok(name) => name, + Err(error) => { + tracing::warn!( + %error, + filename = %attachment.filename, + "failed to compute unique filename" + ); + continue; + } + }; + + let disk_path = saved_dir.join(&saved_filename); + + // Use create_new for atomic creation — prevents race conditions where + // two concurrent saves compute the same deduplicated name. + match write_file_atomic(&disk_path, &bytes).await { + Ok(()) => {} + Err(error) => { + tracing::warn!( + %error, + path = %disk_path.display(), + "failed to write attachment to disk" + ); + continue; + } + } + + let id = uuid::Uuid::new_v4().to_string(); + let size_bytes = bytes.len() as u64; + let disk_path_str = disk_path.to_string_lossy().to_string(); + + let insert_result = sqlx::query( + "INSERT INTO saved_attachments \ + (id, channel_id, original_filename, saved_filename, mime_type, size_bytes, disk_path) \ + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(channel_id) + .bind(&attachment.filename) + .bind(&saved_filename) + .bind(&attachment.mime_type) + .bind(size_bytes as i64) + .bind(&disk_path_str) + .execute(pool) + .await; + + if let Err(error) = insert_result { + tracing::warn!( + %error, + filename = %attachment.filename, + "failed to record saved attachment in database" + ); + // File is on disk but not tracked — clean up + let _ = tokio::fs::remove_file(&disk_path).await; + continue; + } + + tracing::info!( + attachment_id = %id, + original = %attachment.filename, + saved = %saved_filename, + size = size_bytes, + "saved channel attachment" + ); + + results.push(( + SavedAttachmentMeta { + id, + filename: attachment.filename.clone(), + saved_filename, + mime_type: attachment.mime_type.clone(), + size_bytes, + }, + bytes, + )); + } + + results +} + +/// Build a text annotation summarising saved attachments for inclusion in +/// conversation history. This lets the LLM see file references on later turns. +pub(crate) fn format_attachment_annotation(saved: &[SavedAttachmentMeta]) -> String { + if saved.is_empty() { + return String::new(); + } + + let items: Vec = saved + .iter() + .map(|attachment| { + let size_str = if attachment.size_bytes >= 1024 * 1024 { + format!("{:.1} MB", attachment.size_bytes as f64 / (1024.0 * 1024.0)) + } else { + format!("{:.0} KB", attachment.size_bytes as f64 / 1024.0) + }; + format!( + "{} ({}, {}, id:{})", + attachment.filename, + attachment.mime_type, + size_str, + attachment.id.get(..8).unwrap_or(&attachment.id) + ) + }) + .collect(); + + if items.len() == 1 { + format!("[Attachment saved: {}]", items[0]) + } else { + format!("[{} attachments saved: {}]", items.len(), items.join(", ")) + } +} + +/// Reconstruct attachment annotation from message metadata JSON. +/// +/// Called when loading conversation history so older messages that had +/// attachments still show the `[Attachments: ...]` annotation. +pub(crate) fn annotation_from_metadata(metadata: &serde_json::Value) -> Option { + let attachments = metadata.get("attachments")?.as_array()?; + if attachments.is_empty() { + return None; + } + + let saved: Vec = attachments + .iter() + .filter_map(|value| serde_json::from_value(value.clone()).ok()) + .collect(); + + if saved.is_empty() { + return None; + } + + Some(format_attachment_annotation(&saved)) +} + +/// Sanitize a user-provided filename to prevent path traversal attacks. +/// +/// Extracts only the file name component (strips directory separators and +/// parent references), rejects `.`, `..`, and empty results. Falls back to +/// `attachment` if the stem is entirely stripped. +fn sanitize_filename(raw: &str) -> Result { + // Extract just the filename component — strips any directory prefixes + let basename = Path::new(raw) + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default(); + + // Reject dangerous or empty names + let trimmed = basename.trim(); + if trimmed.is_empty() || trimmed == "." || trimmed == ".." { + // If the original had an extension, preserve it with a safe stem + let extension = Path::new(raw) + .extension() + .map(|e| e.to_string_lossy().to_string()); + return match extension { + Some(ext) => Ok(format!("attachment.{ext}")), + None => Ok("attachment".to_string()), + }; + } + + Ok(trimmed.to_string()) +} + +/// Compute a unique filename within `saved_dir`, appending `_N` suffixes +/// to avoid collisions with existing files on disk or in the database. +async fn deduplicate_filename( + pool: &sqlx::SqlitePool, + saved_dir: &Path, + original: &str, +) -> Result { + // Check if the original name is available (no DB record AND no file on disk) + if !filename_taken(pool, saved_dir, original).await { + return Ok(original.to_string()); + } + + // Split into stem + extension + let path = PathBuf::from(original); + let stem = path + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| original.to_string()); + let extension = path + .extension() + .map(|e| format!(".{}", e.to_string_lossy())); + + for counter in 2..=999 { + let candidate = match &extension { + Some(ext) => format!("{stem}_{counter}{ext}"), + None => format!("{stem}_{counter}"), + }; + if !filename_taken(pool, saved_dir, &candidate).await { + return Ok(candidate); + } + } + + Err(format!( + "could not find unique filename for '{original}' after 998 attempts" + )) +} + +/// Write bytes to a file atomically using `create_new` to prevent races. +/// +/// If the file already exists (`AlreadyExists` error), returns an error +/// rather than silently overwriting. +async fn write_file_atomic(path: &Path, bytes: &[u8]) -> std::result::Result<(), String> { + use tokio::io::AsyncWriteExt; + + let file = tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .await + .map_err(|error| format!("failed to create file {}: {error}", path.display()))?; + + let mut writer = tokio::io::BufWriter::new(file); + writer + .write_all(bytes) + .await + .map_err(|error| format!("failed to write to {}: {error}", path.display()))?; + writer + .flush() + .await + .map_err(|error| format!("failed to flush {}: {error}", path.display()))?; + + Ok(()) +} + +/// Check whether a filename is already used — either by a DB record or a file +/// on disk. Both must be clear for the name to be available. +async fn filename_taken(pool: &sqlx::SqlitePool, saved_dir: &Path, filename: &str) -> bool { + // Check database first (fast) + let db_exists = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM saved_attachments WHERE saved_filename = ?", + ) + .bind(filename) + .fetch_one(pool) + .await + .unwrap_or(1) + > 0; + + if db_exists { + return true; + } + + // Also check filesystem in case of orphaned files + saved_dir.join(filename).exists() +} diff --git a/src/config/load.rs b/src/config/load.rs index d919396ed..6bbc79a72 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -1458,6 +1458,9 @@ impl Config { listen_only_mode: channel_config .listen_only_mode .unwrap_or(base_defaults.channel.listen_only_mode), + save_attachments: channel_config + .save_attachments + .unwrap_or(base_defaults.channel.save_attachments), }) .unwrap_or(base_defaults.channel), mcp: default_mcp, @@ -1626,10 +1629,13 @@ impl Config { ), chrome_cache_dir: defaults.browser.chrome_cache_dir.clone(), }), - channel: a.channel.and_then(|channel_config| { - channel_config + channel: a.channel.map(|channel_config| ChannelConfig { + listen_only_mode: channel_config .listen_only_mode - .map(|listen_only_mode| ChannelConfig { listen_only_mode }) + .unwrap_or(defaults.channel.listen_only_mode), + save_attachments: channel_config + .save_attachments + .unwrap_or(defaults.channel.save_attachments), }), mcp: match a.mcp { Some(mcp_servers) => Some( diff --git a/src/config/runtime.rs b/src/config/runtime.rs index 47dfb70df..8c6faed6b 100644 --- a/src/config/runtime.rs +++ b/src/config/runtime.rs @@ -191,6 +191,11 @@ impl RuntimeConfig { self.work_readiness().ready } + /// Path to the saved attachments directory for persisted channel files. + pub fn saved_dir(&self) -> std::path::PathBuf { + self.workspace_dir.join("saved") + } + /// Reload tunable config values from a freshly parsed Config. /// /// Finds the matching agent by ID, re-resolves it against defaults, and @@ -240,6 +245,7 @@ impl RuntimeConfig { next.listen_only_mode = configured_listen_only .or(persisted_listen_only) .unwrap_or(current.as_ref().listen_only_mode); + // save_attachments has no persisted override — config is authoritative Arc::new(next) }); self.max_turns.store(Arc::new(resolved.max_turns)); diff --git a/src/config/toml_schema.rs b/src/config/toml_schema.rs index b1f3041c0..9514de626 100644 --- a/src/config/toml_schema.rs +++ b/src/config/toml_schema.rs @@ -374,6 +374,7 @@ pub(super) struct TomlBrowserConfig { #[derive(Deserialize)] pub(super) struct TomlChannelConfig { pub(super) listen_only_mode: Option, + pub(super) save_attachments: Option, } #[derive(Deserialize)] diff --git a/src/config/types.rs b/src/config/types.rs index 3588b2618..539edd110 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -773,6 +773,10 @@ impl Default for BrowserConfig { pub struct ChannelConfig { /// When true, unsolicited chat messages are ignored unless command/mention/reply. pub listen_only_mode: bool, + /// When true, file attachments received in the channel are saved to + /// `workspace/saved/` and tracked in the `saved_attachments` table so + /// they can be recalled on later turns. + pub save_attachments: bool, } /// OpenCode subprocess worker configuration. @@ -1200,6 +1204,11 @@ impl ResolvedAgentConfig { pub fn ingest_dir(&self) -> PathBuf { self.workspace.join("ingest") } + + /// Path to the saved attachments directory for persisted channel files. + pub fn saved_dir(&self) -> PathBuf { + self.workspace.join("saved") + } } // --------------------------------------------------------------------------- diff --git a/src/main.rs b/src/main.rs index ecd26328c..600892917 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2371,6 +2371,12 @@ async fn initialize_agents( agent_config.logs_dir().display() ) })?; + std::fs::create_dir_all(agent_config.saved_dir()).with_context(|| { + format!( + "failed to create saved dir: {}", + agent_config.saved_dir().display() + ) + })?; // Per-agent database connections let db = spacebot::db::Db::connect(&agent_config.data_dir) diff --git a/src/prompts/text.rs b/src/prompts/text.rs index 1daf00edb..b78027b3d 100644 --- a/src/prompts/text.rs +++ b/src/prompts/text.rs @@ -195,6 +195,9 @@ fn lookup(lang: &str, key: &str) -> &'static str { ("en", "tools/config_inspect") => { include_str!("../../prompts/en/tools/config_inspect_description.md.j2") } + ("en", "tools/attachment_recall") => { + include_str!("../../prompts/en/tools/attachment_recall_description.md.j2") + } // Fallback: unknown language or key -> try English (lang, key) if lang != "en" => { diff --git a/src/tools.rs b/src/tools.rs index e88d9e820..8d27b802b 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -28,6 +28,7 @@ //! **Cortex Chat ToolServer** (interactive admin chat): //! - branch + worker tool superset plus `spacebot_docs` and `config_inspect` +pub mod attachment_recall; pub mod branch_tool; pub mod browser; pub mod cancel; @@ -60,6 +61,9 @@ pub mod task_update; pub mod web_search; pub mod worker_inspect; +pub use attachment_recall::{ + AttachmentRecallArgs, AttachmentRecallError, AttachmentRecallOutput, AttachmentRecallTool, +}; pub use branch_tool::{BranchArgs, BranchError, BranchOutput, BranchTool}; pub use browser::{ ActKind, BrowserAction, BrowserArgs, BrowserError, BrowserOutput, BrowserTool, ElementSummary, @@ -337,6 +341,21 @@ pub async fn add_channel_tools( state.deps.sandbox.clone(), )) .await?; + // Add attachment recall tool when save_attachments is enabled + if state + .deps + .runtime_config + .channel_config + .load() + .save_attachments + { + handle + .add_tool(AttachmentRecallTool::new( + state.deps.sqlite_pool.clone(), + state.channel_id.clone(), + )) + .await?; + } handle.add_tool(CancelTool::new(state)).await?; handle .add_tool(SkipTool::new(skip_flag.clone(), response_tx.clone())) @@ -381,10 +400,12 @@ pub async fn remove_channel_tools( handle.remove_tool(SkipTool::NAME).await?; handle.remove_tool(SendFileTool::NAME).await?; handle.remove_tool(ReactTool::NAME).await?; - // Cron, send_message, and send_agent_message removal is best-effort since not all channels have them + // Cron, send_message, send_agent_message, and attachment_recall removal is + // best-effort since not all channels have them let _ = handle.remove_tool(CronTool::NAME).await; let _ = handle.remove_tool(SendMessageTool::NAME).await; let _ = handle.remove_tool(SendAgentMessageTool::NAME).await; + let _ = handle.remove_tool(AttachmentRecallTool::NAME).await; Ok(()) } diff --git a/src/tools/attachment_recall.rs b/src/tools/attachment_recall.rs new file mode 100644 index 000000000..1e840dcd2 --- /dev/null +++ b/src/tools/attachment_recall.rs @@ -0,0 +1,473 @@ +//! Attachment recall tool for channels. Retrieves saved attachment info +//! and optionally re-loads file content for re-analysis or delegation. + +use crate::ChannelId; + +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; +use std::path::PathBuf; + +/// Maximum file size for `get_content` action (10 MB). +const MAX_CONTENT_SIZE: u64 = 10 * 1024 * 1024; + +/// Image MIME types supported for re-loading as base64. +const IMAGE_MIME_PREFIXES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"]; + +/// Text-based MIME types supported for inline re-loading. +const TEXT_MIME_PREFIXES: &[&str] = &[ + "text/", + "application/json", + "application/xml", + "application/javascript", + "application/typescript", + "application/toml", + "application/yaml", +]; + +/// Tool for recalling saved attachments from the channel's history. +/// +/// Added per-turn to channels that have `save_attachments` enabled. +/// Provides three actions: list recent attachments, get a file's disk path +/// (for delegation to workers), or re-load file content for analysis. +#[derive(Debug, Clone)] +pub struct AttachmentRecallTool { + pool: SqlitePool, + channel_id: ChannelId, +} + +impl AttachmentRecallTool { + pub fn new(pool: SqlitePool, channel_id: ChannelId) -> Self { + Self { pool, channel_id } + } +} + +/// Error type for attachment recall tool. +#[derive(Debug, thiserror::Error)] +#[error("Attachment recall failed: {0}")] +pub struct AttachmentRecallError(String); + +/// Arguments for attachment recall tool. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct AttachmentRecallArgs { + /// What to do: "list" recent attachments, "get_path" for a specific file's + /// absolute disk path, or "get_content" to re-load file content for analysis. + pub action: String, + /// The attachment ID (from the history annotation). Required for get_path + /// and get_content. + #[serde(default)] + pub attachment_id: Option, + /// Alternative to attachment_id — look up by original filename. If multiple + /// matches, returns the most recent. + #[serde(default)] + pub filename: Option, + /// For list action: how many recent attachments to return (default 10). + #[serde(default = "default_limit")] + pub limit: i64, +} + +fn default_limit() -> i64 { + 10 +} + +/// Output from attachment recall tool. +/// +/// Note: `UserContent` is not serializable, so image content cannot be +/// delivered through the tool's JSON output. Text file content is inlined +/// directly into `summary` so the LLM receives it. For images, the summary +/// confirms the image was loaded; the base64 data would need to be injected +/// into the conversation history through a separate mechanism (future work). +#[derive(Debug, Serialize)] +pub struct AttachmentRecallOutput { + pub action: String, + pub attachments: Vec, + /// Human-readable summary for the LLM. For text files via `get_content`, + /// the file content is inlined here. + pub summary: String, + /// Whether this was an error result (unknown action, not found, etc.) + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Info about a saved attachment. +#[derive(Debug, Clone, Serialize)] +pub struct AttachmentInfo { + pub id: String, + pub filename: String, + pub saved_filename: String, + pub mime_type: String, + pub size_bytes: i64, + /// Absolute disk path. Only populated for `get_path` responses to avoid + /// leaking filesystem layout in `list` responses. + #[serde(skip_serializing_if = "Option::is_none")] + pub disk_path: Option, + pub created_at: String, +} + +impl Tool for AttachmentRecallTool { + const NAME: &'static str = "attachment_recall"; + + type Error = AttachmentRecallError; + type Args = AttachmentRecallArgs; + type Output = AttachmentRecallOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: crate::prompts::text::get("tools/attachment_recall").to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["list", "get_path", "get_content"], + "description": "What to do: list recent attachments, get the disk path of a specific file, or re-load its content for analysis." + }, + "attachment_id": { + "type": "string", + "description": "The attachment ID (from history metadata). Required for get_path and get_content." + }, + "filename": { + "type": "string", + "description": "Alternative to attachment_id — look up by original filename. If multiple matches, returns the most recent." + }, + "limit": { + "type": "integer", + "default": 10, + "description": "For list action: how many recent attachments to return." + } + }, + "required": ["action"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + match args.action.as_str() { + "list" => self.list_attachments(args.limit.clamp(1, 50)).await, + "get_path" => { + self.get_attachment_path(args.attachment_id.as_deref(), args.filename.as_deref()) + .await + } + "get_content" => { + self.get_attachment_content(args.attachment_id.as_deref(), args.filename.as_deref()) + .await + } + other => Ok(AttachmentRecallOutput { + action: other.to_string(), + attachments: vec![], + summary: format!( + "Unknown action: '{other}'. Use 'list', 'get_path', or 'get_content'." + ), + error: Some(format!("unknown_action: {other}")), + }), + } + } +} + +impl AttachmentRecallTool { + async fn list_attachments( + &self, + limit: i64, + ) -> Result { + let rows = sqlx::query_as::<_, AttachmentRow>( + "SELECT id, original_filename, saved_filename, mime_type, size_bytes, disk_path, created_at \ + FROM saved_attachments \ + WHERE channel_id = ? \ + ORDER BY created_at DESC \ + LIMIT ?", + ) + .bind(self.channel_id.as_ref()) + .bind(limit) + .fetch_all(&self.pool) + .await + .map_err(|error| { + AttachmentRecallError(format!("Failed to query saved attachments: {error}")) + })?; + + let attachments: Vec = rows + .into_iter() + .map(|row| AttachmentInfo { + id: row.id, + filename: row.original_filename, + saved_filename: row.saved_filename, + mime_type: row.mime_type, + size_bytes: row.size_bytes, + disk_path: None, + created_at: row.created_at, + }) + .collect(); + + let summary = if attachments.is_empty() { + "No saved attachments in this channel.".to_string() + } else { + let mut lines = vec![format!( + "{} saved attachment(s) in this channel:", + attachments.len() + )]; + for attachment in &attachments { + let size_str = format_size(attachment.size_bytes); + lines.push(format!( + " - {} ({}, {}, id:{})", + attachment.filename, + attachment.mime_type, + size_str, + attachment.id.get(..8).unwrap_or(&attachment.id) + )); + } + lines.join("\n") + }; + + Ok(AttachmentRecallOutput { + action: "list".to_string(), + attachments, + summary, + error: None, + }) + } + + async fn get_attachment_path( + &self, + attachment_id: Option<&str>, + filename: Option<&str>, + ) -> Result { + let attachment = self.resolve_attachment(attachment_id, filename).await?; + let disk_path = attachment.disk_path.clone().unwrap_or_default(); + + // Verify the file exists on disk + let path = PathBuf::from(&disk_path); + if !path.exists() { + let summary = format!( + "File '{}' was saved but is no longer on disk at {}", + attachment.filename, disk_path + ); + return Ok(AttachmentRecallOutput { + action: "get_path".to_string(), + attachments: vec![attachment], + summary, + error: Some("file_missing".to_string()), + }); + } + + let summary = format!("File '{}' is saved at: {}", attachment.filename, disk_path); + + Ok(AttachmentRecallOutput { + action: "get_path".to_string(), + attachments: vec![attachment], + summary, + error: None, + }) + } + + async fn get_attachment_content( + &self, + attachment_id: Option<&str>, + filename: Option<&str>, + ) -> Result { + let attachment = self.resolve_attachment(attachment_id, filename).await?; + let disk_path = attachment.disk_path.clone().unwrap_or_default(); + + let path = PathBuf::from(&disk_path); + if !path.exists() { + let summary = format!( + "File '{}' was saved but is no longer on disk at {}", + attachment.filename, disk_path + ); + return Ok(AttachmentRecallOutput { + action: "get_content".to_string(), + attachments: vec![attachment], + summary, + error: Some("file_missing".to_string()), + }); + } + + // Check live file size from disk, not the DB value which may be stale + let live_size = tokio::fs::metadata(&path) + .await + .map(|metadata| metadata.len()) + .unwrap_or(attachment.size_bytes as u64); + + if live_size > MAX_CONTENT_SIZE { + let size_str = format_size(live_size as i64); + let summary = format!( + "File '{}' is too large for inline content ({}, max 10 MB). \ + Use get_path instead and delegate to a worker.", + attachment.filename, size_str + ); + return Ok(AttachmentRecallOutput { + action: "get_content".to_string(), + attachments: vec![attachment], + summary, + error: Some("file_too_large".to_string()), + }); + } + + let is_image = IMAGE_MIME_PREFIXES + .iter() + .any(|p| attachment.mime_type.starts_with(p)); + let is_text = TEXT_MIME_PREFIXES + .iter() + .any(|p| attachment.mime_type.starts_with(p)); + + if !is_image && !is_text { + let summary = format!( + "File '{}' ({}) cannot be loaded inline — only images and text files \ + are supported for get_content. Use get_path to get the disk path \ + and delegate to a worker.\nPath: {}", + attachment.filename, attachment.mime_type, disk_path + ); + return Ok(AttachmentRecallOutput { + action: "get_content".to_string(), + attachments: vec![attachment], + summary, + error: None, + }); + } + + let bytes: Vec = tokio::fs::read(&path).await.map_err(|error| { + AttachmentRecallError(format!( + "Failed to read file '{}': {error}", + attachment.filename + )) + })?; + + let summary = if is_image { + // Images can't be delivered through JSON tool output — the LLM + // would need the image injected into conversation history as + // UserContent::Image, which requires a separate mechanism. + // For now, confirm the image exists and suggest using get_path + // to delegate to a worker for image analysis. + format!( + "Image '{}' ({}, {}) exists on disk at: {}\n\ + Note: Image content cannot be inlined in tool output. \ + Use get_path and delegate to a worker, or re-send the image in chat.", + attachment.filename, + attachment.mime_type, + format_size(attachment.size_bytes), + disk_path + ) + } else { + // Text file — inline content directly into summary so the LLM + // receives it through the serialized tool output. + let text = String::from_utf8_lossy(&bytes); + let truncated = if text.len() > 50_000 { + let end = text.floor_char_boundary(50_000); + format!( + "{}...\n[truncated — {} bytes total]", + &text[..end], + text.len() + ) + } else { + text.into_owned() + }; + format!( + "\n{}\n", + attachment.filename, attachment.mime_type, truncated + ) + }; + + Ok(AttachmentRecallOutput { + action: "get_content".to_string(), + attachments: vec![attachment], + summary, + error: None, + }) + } + + /// Resolve an attachment by ID or filename. + async fn resolve_attachment( + &self, + attachment_id: Option<&str>, + filename: Option<&str>, + ) -> Result { + let row = if let Some(id) = attachment_id { + // Look up by exact ID or literal ID prefix (no LIKE wildcards) + let row = sqlx::query_as::<_, AttachmentRow>( + "SELECT id, original_filename, saved_filename, mime_type, size_bytes, disk_path, created_at \ + FROM saved_attachments \ + WHERE channel_id = ? AND substr(id, 1, length(?)) = ? \ + ORDER BY created_at DESC \ + LIMIT 1", + ) + .bind(self.channel_id.as_ref()) + .bind(id) + .bind(id) + .fetch_optional(&self.pool) + .await + .map_err(|error| { + AttachmentRecallError(format!("Failed to look up attachment: {error}")) + })?; + + match row { + Some(row) => row, + None => { + return Err(AttachmentRecallError(format!( + "not_found: No attachment found with ID '{id}'" + ))); + } + } + } else if let Some(name) = filename { + // Look up by original filename, most recent match + let row = sqlx::query_as::<_, AttachmentRow>( + "SELECT id, original_filename, saved_filename, mime_type, size_bytes, disk_path, created_at \ + FROM saved_attachments \ + WHERE channel_id = ? AND original_filename = ? \ + ORDER BY created_at DESC \ + LIMIT 1", + ) + .bind(self.channel_id.as_ref()) + .bind(name) + .fetch_optional(&self.pool) + .await + .map_err(|error| { + AttachmentRecallError(format!("Failed to look up attachment: {error}")) + })?; + + match row { + Some(row) => row, + None => { + return Err(AttachmentRecallError(format!( + "not_found: No attachment found with filename '{name}'" + ))); + } + } + } else { + return Err(AttachmentRecallError( + "missing_args: Either attachment_id or filename is required for get_path and get_content." + .to_string(), + )); + }; + + Ok(AttachmentInfo { + id: row.id, + filename: row.original_filename, + saved_filename: row.saved_filename, + mime_type: row.mime_type, + size_bytes: row.size_bytes, + disk_path: Some(row.disk_path), + created_at: row.created_at, + }) + } +} + +/// Internal row type for sqlx query mapping. +#[derive(sqlx::FromRow)] +struct AttachmentRow { + id: String, + original_filename: String, + saved_filename: String, + mime_type: String, + size_bytes: i64, + disk_path: String, + created_at: String, +} + +fn format_size(bytes: i64) -> String { + if bytes >= 1024 * 1024 { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + } else { + format!("{:.0} KB", bytes as f64 / 1024.0) + } +} diff --git a/src/tools/channel_recall.rs b/src/tools/channel_recall.rs index 4d8744081..9150d740b 100644 --- a/src/tools/channel_recall.rs +++ b/src/tools/channel_recall.rs @@ -174,11 +174,33 @@ impl Tool for ChannelRecallTool { let transcript: Vec = messages .iter() - .map(|message| TranscriptMessage { - role: message.role.clone(), - sender: message.sender_name.clone(), - content: message.content.clone(), - timestamp: message.created_at.to_rfc3339(), + .map(|message| { + // Append saved attachment annotations from metadata if present + let content = if let Some(ref metadata_json) = message.metadata { + if let Ok(metadata_value) = + serde_json::from_str::(metadata_json) + { + if let Some(annotation) = + crate::agent::channel_attachments::annotation_from_metadata( + &metadata_value, + ) + { + format!("{}\n{}", message.content, annotation) + } else { + message.content.clone() + } + } else { + message.content.clone() + } + } else { + message.content.clone() + }; + TranscriptMessage { + role: message.role.clone(), + sender: message.sender_name.clone(), + content, + timestamp: message.created_at.to_rfc3339(), + } }) .collect();