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/file_recall.go
2026-03-04 16:06:12 +00:00

185 lines
5.8 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 ────────────────────────
// 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 {
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/")
}