package tools import ( "context" "encoding/base64" "encoding/json" "fmt" "io" "log" "strings" "git.gobha.me/xcaliber/chat-switchboard/storage" "git.gobha.me/xcaliber/chat-switchboard/store" ) // ── Late Registration ──────────────────────── // attachment_recall needs stores + objStore, which aren't available at // init time. Called from main.go after storage init. // If objStore is nil (storage not configured), the tool is not registered. func RegisterAttachmentRecall(stores store.Stores, objStore storage.ObjectStore) { if objStore == nil { log.Printf("ℹ attachment_recall: storage not configured, tool not registered") return } Register(&attachmentRecallTool{ stores: stores, objStore: objStore, }) } // ═══════════════════════════════════════════ // attachment_recall // ═══════════════════════════════════════════ const maxImageBytes = 10 * 1024 * 1024 // 10 MB cap for base64 images type attachmentRecallTool struct { stores store.Stores objStore storage.ObjectStore } func (t *attachmentRecallTool) Definition() ToolDef { return ToolDef{ Name: "attachment_recall", DisplayName: "Attachments", Category: "context", Description: "Re-read file attachments from this conversation. " + "Use action='list' to see available files, then action='read' " + "with an attachment_id to retrieve the file content. " + "Documents return extracted text; images return base64 data.", Parameters: JSONSchema(map[string]interface{}{ "action": PropEnum("Action: 'list' to see files, 'read' to get content", "list", "read"), "attachment_id": Prop("string", "Attachment ID to read (required for action='read')"), }, []string{"action"}), } } func (t *attachmentRecallTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { var args struct { Action string `json:"action"` AttachmentID string `json:"attachment_id"` } if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } switch args.Action { case "list": return t.listAttachments(ctx, execCtx) case "read": if args.AttachmentID == "" { return "", fmt.Errorf("attachment_id is required for action='read'") } return t.readAttachment(ctx, execCtx, args.AttachmentID) default: return "", fmt.Errorf("invalid action: %q (use 'list' or 'read')", args.Action) } } // listAttachments returns metadata for all attachments in the channel. func (t *attachmentRecallTool) listAttachments(ctx context.Context, execCtx ExecutionContext) (string, error) { atts, err := t.stores.Attachments.GetByChannel(ctx, execCtx.ChannelID) if err != nil { return "", fmt.Errorf("failed to list attachments: %w", err) } type attInfo struct { ID string `json:"id"` Filename string `json:"filename"` ContentType string `json:"content_type"` SizeBytes int64 `json:"size_bytes"` HasText bool `json:"has_text"` CreatedAt string `json:"created_at"` } items := make([]attInfo, 0, len(atts)) for _, a := range atts { items = append(items, attInfo{ ID: a.ID, Filename: a.Filename, ContentType: a.ContentType, SizeBytes: a.SizeBytes, HasText: a.ExtractedText != nil && *a.ExtractedText != "", CreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05Z"), }) } log.Printf("📎 attachment_recall: list → %d files in channel %s", len(items), execCtx.ChannelID) out, _ := json.Marshal(map[string]interface{}{ "attachments": items, "count": len(items), "channel_id": execCtx.ChannelID, }) return string(out), nil } // readAttachment returns the content of a specific attachment. func (t *attachmentRecallTool) readAttachment(ctx context.Context, execCtx ExecutionContext, attachmentID string) (string, error) { att, err := t.stores.Attachments.GetByID(ctx, attachmentID) if err != nil { return "", fmt.Errorf("attachment not found: %s", attachmentID) } // Security: verify attachment belongs to this channel if att.ChannelID != execCtx.ChannelID { return "", fmt.Errorf("attachment %s does not belong to this conversation", attachmentID) } // Documents: return extracted text if !isImageType(att.ContentType) { if att.ExtractedText != nil && *att.ExtractedText != "" { log.Printf("📎 attachment_recall: read text → %s (%s, %d chars)", att.Filename, att.ID, len(*att.ExtractedText)) out, _ := json.Marshal(map[string]interface{}{ "id": att.ID, "filename": att.Filename, "content_type": att.ContentType, "content": *att.ExtractedText, "content_type_returned": "text", }) return string(out), nil } return "", fmt.Errorf("no extracted text available for %s (content type: %s)", att.Filename, att.ContentType) } // Images: read from storage and return base64 if att.SizeBytes > maxImageBytes { return "", fmt.Errorf("image %s is too large (%d bytes, max %d). Re-upload a smaller version", att.Filename, att.SizeBytes, maxImageBytes) } reader, size, _, err := t.objStore.Get(ctx, att.StorageKey) if err != nil { return "", fmt.Errorf("failed to read attachment from storage: %w", err) } defer reader.Close() // Read and base64-encode data := make([]byte, size) if _, err := io.ReadFull(reader, data); err != nil { return "", fmt.Errorf("failed to read attachment data: %w", err) } b64 := base64.StdEncoding.EncodeToString(data) dataURI := fmt.Sprintf("data:%s;base64,%s", att.ContentType, b64) log.Printf("📎 attachment_recall: read image → %s (%s, %d bytes)", att.Filename, att.ID, size) out, _ := json.Marshal(map[string]interface{}{ "id": att.ID, "filename": att.Filename, "content_type": att.ContentType, "content": dataURI, "content_type_returned": "base64_image", }) return string(out), nil } func isImageType(ct string) bool { return strings.HasPrefix(ct, "image/") }