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 ──────────────────────── // file_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 RegisterFileRecall(stores store.Stores, objStore storage.ObjectStore) { if objStore == nil { log.Printf("ℹ file_recall: storage not configured, tool not registered") return } Register(&fileRecallTool{ stores: stores, objStore: objStore, }) } // ═══════════════════════════════════════════ // file_recall // ═══════════════════════════════════════════ const maxImageBytes = 10 * 1024 * 1024 // 10 MB cap for base64 images type fileRecallTool struct { BaseTool stores store.Stores objStore storage.ObjectStore } func (t *fileRecallTool) Definition() ToolDef { return ToolDef{ Name: "file_recall", DisplayName: "Files", Category: "context", Description: "Re-read files from this conversation. " + "Use action='list' to see available files, then action='read' " + "with a file_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"), "file_id": Prop("string", "File ID to read (required for action='read')"), }, []string{"action"}), } } func (t *fileRecallTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { var args struct { Action string `json:"action"` FileID string `json:"file_id"` } if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } switch args.Action { case "list": return t.listFiles(ctx, execCtx) case "read": if args.FileID == "" { return "", fmt.Errorf("file_id is required for action='read'") } return t.readFile(ctx, execCtx, args.FileID) default: return "", fmt.Errorf("invalid action: %q (use 'list' or 'read')", args.Action) } } // listFiles returns metadata for all files in the channel. func (t *fileRecallTool) listFiles(ctx context.Context, execCtx ExecutionContext) (string, error) { atts, err := t.stores.Files.GetByChannel(ctx, execCtx.ChannelID, "") if err != nil { return "", fmt.Errorf("failed to list files: %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("📎 file_recall: list → %d files in channel %s", len(items), execCtx.ChannelID) out, _ := json.Marshal(map[string]interface{}{ "files": items, "count": len(items), "channel_id": execCtx.ChannelID, }) return string(out), nil } // readFile returns the content of a specific file. func (t *fileRecallTool) readFile(ctx context.Context, execCtx ExecutionContext, fileID string) (string, error) { att, err := t.stores.Files.GetByID(ctx, fileID) if err != nil { return "", fmt.Errorf("file not found: %s", fileID) } // Security: verify file belongs to this channel if att.ChannelID != execCtx.ChannelID { return "", fmt.Errorf("file %s does not belong to this conversation", fileID) } // Documents: return extracted text if !isImageType(att.ContentType) { if att.ExtractedText != nil && *att.ExtractedText != "" { log.Printf("📎 file_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 file 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 file data: %w", err) } b64 := base64.StdEncoding.EncodeToString(data) dataURI := fmt.Sprintf("data:%s;base64,%s", att.ContentType, b64) log.Printf("📎 file_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/") }