This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/tools/attachment_recall.go
2026-02-27 00:12:46 +00:00

185 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/")
}