package tools import ( "context" "encoding/json" "fmt" "log" "strings" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/knowledge" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) // ── Late Registration ──────────────────────── // Memory tools use late registration because they need stores + embedder. // RegisterMemoryTools registers the memory_save and memory_recall tools. // Called from main.go after stores and embedder are initialized. func RegisterMemoryTools(stores store.Stores, embedder *knowledge.Embedder) { if stores.Memories == nil { log.Println("⚠ memory tools: MemoryStore not available, skipping registration") return } Register(&memorySaveTool{stores: stores, embedder: embedder}) Register(&memoryRecallTool{stores: stores, embedder: embedder}) log.Println("✅ memory tools registered (memory_save, memory_recall)") } // ═══════════════════════════════════════════ // memory_save // ═══════════════════════════════════════════ type memorySaveTool struct { stores store.Stores embedder *knowledge.Embedder } func (t *memorySaveTool) Definition() ToolDef { return ToolDef{ Name: "memory_save", DisplayName: "Save Memory", Category: "memory", Description: "Save a fact or preference about the user for future conversations. " + "Use this when the user shares something worth remembering long-term " + "(preferences, technical stack, project details, personal facts). " + "Memories persist across all conversations.", Parameters: JSONSchema(map[string]interface{}{ "key": Prop("string", "Short label for the memory (2-5 words, e.g. 'preferred language', 'deployment target')"), "value": Prop("string", "The fact or detail to remember (1-2 sentences)"), "confidence": map[string]interface{}{ "type": "number", "description": "How confident you are in this fact (0.0-1.0, default 0.9)", }, }, []string{"key", "value"}), } } func (t *memorySaveTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { var args struct { Key string `json:"key"` Value string `json:"value"` Confidence float64 `json:"confidence"` } if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } if args.Key == "" || args.Value == "" { return "", fmt.Errorf("key and value are required") } confidence := args.Confidence if confidence <= 0 || confidence > 1.0 { confidence = 0.9 } mem := &models.Memory{ ID: store.NewID(), Scope: models.MemoryScopeUser, OwnerID: execCtx.UserID, Key: strings.TrimSpace(args.Key), Value: strings.TrimSpace(args.Value), Confidence: confidence, Status: models.MemoryStatusActive, } if execCtx.ChannelID != "" { mem.SourceChannelID = &execCtx.ChannelID } // If persona is active, use persona_user scope if execCtx.PersonaID != "" { mem.Scope = models.MemoryScopePersonaUser mem.OwnerID = execCtx.PersonaID mem.UserID = &execCtx.UserID } if err := t.stores.Memories.Upsert(ctx, mem); err != nil { return "", fmt.Errorf("save memory: %w", err) } // Embed for semantic recall t.embedMemory(ctx, mem, execCtx.UserID) return fmt.Sprintf("Remembered: %s = %s (confidence: %.0f%%)", args.Key, args.Value, confidence*100), nil } func (t *memorySaveTool) embedMemory(ctx context.Context, m *models.Memory, userID string) { if t.embedder == nil || !t.embedder.IsConfigured(ctx) { return } text := m.Key + ": " + m.Value var teamID *string if t.stores.Teams != nil { ids, _ := t.stores.Teams.GetUserTeamIDs(ctx, userID) if len(ids) > 0 { teamID = &ids[0] } } result, err := t.embedder.EmbedChunks(ctx, userID, teamID, []string{text}) if err != nil || len(result.Vectors) == 0 { return } vecStr := vectorToString(result.Vectors[0]) if database.IsSQLite() { database.DB.Exec(`UPDATE memories SET embedding = ? WHERE id = ?`, vecStr, m.ID) } else { database.DB.Exec(`UPDATE memories SET embedding = $1::vector WHERE id = $2`, vecStr, m.ID) } log.Printf("🧠 memory %s embedded (%d dims)", m.ID[:8], len(result.Vectors[0])) } // ═══════════════════════════════════════════ // memory_recall // ═══════════════════════════════════════════ type memoryRecallTool struct { stores store.Stores embedder *knowledge.Embedder } func (t *memoryRecallTool) Definition() ToolDef { return ToolDef{ Name: "memory_recall", DisplayName: "Recall Memories", Category: "memory", Description: "Search your memories about this user. Use this at the start of conversations " + "or when you need context about the user's preferences, projects, or history.", Parameters: JSONSchema(map[string]interface{}{ "query": Prop("string", "Optional search query to filter memories (leave empty for all)"), }, nil), } } func (t *memoryRecallTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { var args struct { Query string `json:"query"` } if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } var personaID *string if execCtx.PersonaID != "" { personaID = &execCtx.PersonaID } // Try hybrid recall if embedder available and query provided var memories []models.Memory var err error if t.embedder != nil && t.embedder.IsConfigured(ctx) && args.Query != "" { memories, err = t.recallWithEmbedding(ctx, execCtx.UserID, personaID, args.Query) } // Fall back to keyword recall if err != nil || len(memories) == 0 { memories, err = t.stores.Memories.Recall(ctx, execCtx.UserID, personaID, args.Query, 20) } if err != nil { return "", fmt.Errorf("recall memories: %w", err) } if len(memories) == 0 { suffix := "" if args.Query != "" { suffix = " matching '" + args.Query + "'" } return "No memories found" + suffix + ".", nil } var sb strings.Builder sb.WriteString(fmt.Sprintf("Found %d memories:\n\n", len(memories))) for _, m := range memories { scope := m.Scope if scope == "persona_user" { scope = "persona" } sb.WriteString(fmt.Sprintf("• [%s] %s: %s (%.0f%% confidence)\n", scope, m.Key, m.Value, m.Confidence*100)) } return sb.String(), nil } func (t *memoryRecallTool) recallWithEmbedding(ctx context.Context, userID string, personaID *string, query string) ([]models.Memory, error) { text := query if len(text) > 2000 { text = text[:2000] } var teamID *string if t.stores.Teams != nil { ids, _ := t.stores.Teams.GetUserTeamIDs(ctx, userID) if len(ids) > 0 { teamID = &ids[0] } } result, err := t.embedder.EmbedChunks(ctx, userID, teamID, []string{text}) if err != nil || len(result.Vectors) == 0 { return nil, err } return t.stores.Memories.RecallHybrid(ctx, userID, personaID, query, result.Vectors[0], 20) }