package tools import ( "context" "encoding/json" "fmt" "log" "strings" "chat-switchboard/knowledge" "chat-switchboard/models" "chat-switchboard/store" ) // ── Late Registration ──────────────────────── // Note tools use late registration (like kb_search) because // note_create, note_update, and note_search need the embedder for // vector operations. The embedder is optional — if nil, notes still // work but semantic search degrades to full-text search. // RegisterNoteTools registers all note tools with captured dependencies. // Call from main.go after stores + embedder init. func RegisterNoteTools(stores store.Stores, embedder *knowledge.Embedder) { Register(¬eCreateTool{stores: stores, embedder: embedder}) Register(¬eSearchTool{stores: stores, embedder: embedder}) Register(¬eUpdateTool{stores: stores, embedder: embedder}) Register(¬eListTool{stores: stores}) } // ── Shared helpers ───────────────────────────── // embedNote generates a vector for a note's title+content and stores it. // Silently no-ops if embedder is nil or not configured. func embedNote(ctx context.Context, embedder *knowledge.Embedder, stores store.Stores, noteID, userID, title, content string) { if embedder == nil || !embedder.IsConfigured(ctx) { return } text := title + "\n\n" + content if len(text) > 8000 { text = text[:8000] // limit to ~2k tokens for embedding } // Resolve team for embedding role var teamID *string if stores.Teams != nil { ids, _ := stores.Teams.GetUserTeamIDs(ctx, userID) if len(ids) > 0 { teamID = &ids[0] } } result, err := embedder.EmbedChunks(ctx, userID, teamID, []string{text}) if err != nil { log.Printf("⚠ note embed failed for %s: %v", noteID, err) return } if len(result.Vectors) == 0 { return } // Store the vector via store method err = stores.Notes.SetEmbedding(ctx, noteID, vectorToString(result.Vectors[0])) if err != nil { log.Printf("⚠ note embed store failed for %s: %v", noteID, err) return } // Track embedding usage embedder.LogUsage(ctx, userID, nil, result) log.Printf("📝 note %s embedded (%d tokens)", noteID, result.InputTokens) } // vectorToString converts a float64 slice to pgvector literal format: [0.1,0.2,...] func vectorToString(vec []float64) string { parts := make([]string, len(vec)) for i, v := range vec { parts[i] = fmt.Sprintf("%g", v) } return "[" + strings.Join(parts, ",") + "]" } // ═══════════════════════════════════════════ // note_create // ═══════════════════════════════════════════ type noteCreateTool struct { visitorDeniedBase stores store.Stores embedder *knowledge.Embedder } func (t *noteCreateTool) Definition() ToolDef { return ToolDef{ Name: "note_create", DisplayName: "Create", Category: "notes", Description: "Create a new note for the user. Use this to save information, insights, summaries, or anything the user wants to remember. Notes are persistent and searchable.", Parameters: JSONSchema(map[string]interface{}{ "title": Prop("string", "Title of the note"), "content": Prop("string", "Markdown content of the note"), "folder": Prop("string", "Folder path, e.g. '/projects/switchboard/'. Defaults to '/'"), "tags": PropArray("Tags for categorization, e.g. ['research', 'golang']"), }, []string{"title", "content"}), } } func (t *noteCreateTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { var args struct { Title string `json:"title"` Content string `json:"content"` Folder string `json:"folder"` Tags []string `json:"tags"` } if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } if args.Title == "" { return "", fmt.Errorf("title is required") } folder := normalizePath(args.Folder) tags := args.Tags if tags == nil { tags = []string{} } // source_channel_id links the note to the conversation that created it var sourceChannelID *string if execCtx.ChannelID != "" { sourceChannelID = &execCtx.ChannelID } note := &models.Note{ UserID: execCtx.UserID, Title: args.Title, Content: args.Content, FolderPath: folder, Tags: tags, SourceChannelID: sourceChannelID, } if err := t.stores.Notes.Create(ctx, note); err != nil { return "", fmt.Errorf("failed to create note: %w", err) } // Embed async — don't block the tool response go embedNote(context.Background(), t.embedder, t.stores, note.ID, execCtx.UserID, args.Title, args.Content) result, _ := json.Marshal(map[string]interface{}{ "id": note.ID, "title": args.Title, "folder": folder, "tags": tags, "created_at": note.CreatedAt.Format("2006-01-02T15:04:05Z"), "message": fmt.Sprintf("Note '%s' created successfully.", args.Title), }) return string(result), nil } // ═══════════════════════════════════════════ // note_search // ═══════════════════════════════════════════ type noteSearchTool struct { visitorDeniedBase stores store.Stores embedder *knowledge.Embedder } func (t *noteSearchTool) Definition() ToolDef { return ToolDef{ Name: "note_search", DisplayName: "Search", Category: "notes", Description: "Search the user's notes by keyword or semantic meaning. " + "Set semantic=true for meaning-based search using AI embeddings, " + "or omit for fast keyword matching. Returns matching notes ranked by relevance.", Parameters: JSONSchema(map[string]interface{}{ "query": Prop("string", "Search query — natural language keywords or question"), "limit": Prop("integer", "Maximum results to return (default 10, max 50)"), "semantic": map[string]interface{}{ "type": "boolean", "description": "Use semantic (vector) search instead of keyword search. Better for meaning-based queries.", }, }, []string{"query"}), } } func (t *noteSearchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { var args struct { Query string `json:"query"` Limit int `json:"limit"` Semantic bool `json:"semantic"` } if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } if args.Query == "" { return "", fmt.Errorf("query is required") } if args.Limit <= 0 || args.Limit > 50 { args.Limit = 10 } // Semantic search via vector similarity if args.Semantic { return t.semanticSearch(ctx, execCtx, args.Query, args.Limit) } // Default: full-text keyword search return t.keywordSearch(ctx, execCtx, args.Query, args.Limit) } func (t *noteSearchTool) keywordSearch(ctx context.Context, execCtx ExecutionContext, query string, limit int) (string, error) { storeResults, err := t.stores.Notes.SearchKeyword(ctx, execCtx.UserID, query, limit) if err != nil { return "", fmt.Errorf("search failed: %w", err) } results := make([]noteSearchResult, 0, len(storeResults)) for _, sr := range storeResults { results = append(results, noteSearchResult{ ID: sr.ID, Title: sr.Title, Folder: sr.FolderPath, Tags: sr.Tags, Excerpt: sr.Excerpt, Headline: sr.Headline, Rank: sr.Rank, }) } out, _ := json.Marshal(map[string]interface{}{ "query": query, "mode": "keyword", "count": len(results), "results": results, }) return string(out), nil } func (t *noteSearchTool) semanticSearch(ctx context.Context, execCtx ExecutionContext, query string, limit int) (string, error) { // Check embedder availability — fall back to keyword search if not configured if t.embedder == nil || !t.embedder.IsConfigured(ctx) { log.Printf("⚠ note semantic search: embedder not configured, falling back to keyword") return t.keywordSearch(ctx, execCtx, query, limit) } // Resolve team for embedding role var teamID *string if t.stores.Teams != nil { ids, _ := t.stores.Teams.GetUserTeamIDs(ctx, execCtx.UserID) if len(ids) > 0 { teamID = &ids[0] } } // Embed the query embedResult, err := t.embedder.EmbedChunks(ctx, execCtx.UserID, teamID, []string{query}) if err != nil { log.Printf("⚠ note semantic search embed failed: %v — falling back to keyword", err) return t.keywordSearch(ctx, execCtx, query, limit) } if len(embedResult.Vectors) == 0 { return t.keywordSearch(ctx, execCtx, query, limit) } // Track embedding usage chanPtr := &execCtx.ChannelID t.embedder.LogUsage(ctx, execCtx.UserID, chanPtr, embedResult) queryVec := vectorToString(embedResult.Vectors[0]) // Vector similarity search via store storeResults, err := t.stores.Notes.SearchSemantic(ctx, execCtx.UserID, queryVec, limit) if err != nil { log.Printf("⚠ note semantic search query failed: %v — falling back to keyword", err) return t.keywordSearch(ctx, execCtx, query, limit) } results := make([]noteSearchResult, 0, len(storeResults)) for _, sr := range storeResults { results = append(results, noteSearchResult{ ID: sr.ID, Title: sr.Title, Folder: sr.FolderPath, Tags: sr.Tags, Excerpt: sr.Excerpt, Similarity: sr.Rank, }) } out, _ := json.Marshal(map[string]interface{}{ "query": query, "mode": "semantic", "count": len(results), "results": results, }) return string(out), nil } type noteSearchResult struct { ID string `json:"id"` Title string `json:"title"` Folder string `json:"folder"` Tags []string `json:"tags"` Excerpt string `json:"excerpt"` Headline string `json:"headline,omitempty"` Rank float64 `json:"rank,omitempty"` Similarity float64 `json:"similarity,omitempty"` } // ═══════════════════════════════════════════ // note_update // ═══════════════════════════════════════════ type noteUpdateTool struct { visitorDeniedBase stores store.Stores embedder *knowledge.Embedder } func (t *noteUpdateTool) Definition() ToolDef { return ToolDef{ Name: "note_update", DisplayName: "Update", Category: "notes", Description: "Update an existing note. Can replace content entirely, append to it, or prepend. Use note_search first to find the note ID.", Parameters: JSONSchema(map[string]interface{}{ "note_id": Prop("string", "UUID of the note to update (from note_search results)"), "title": Prop("string", "New title (omit to keep current)"), "content": Prop("string", "New content or text to append/prepend"), "mode": PropEnum("How to apply content", "replace", "append", "prepend"), "tags": PropArray("New tags (replaces existing tags)"), }, []string{"note_id"}), } } func (t *noteUpdateTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { var args struct { NoteID string `json:"note_id"` Title *string `json:"title"` Content *string `json:"content"` Mode string `json:"mode"` Tags []string `json:"tags"` } if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } if args.NoteID == "" { return "", fmt.Errorf("note_id is required") } // Verify ownership + get existing content for append/prepend existing, err := t.stores.Notes.GetByID(ctx, args.NoteID) if err != nil || existing.UserID != execCtx.UserID { return "", fmt.Errorf("note not found or update failed") } // Build fields map for store.Update fields := map[string]interface{}{} if args.Title != nil { fields["title"] = *args.Title } if args.Content != nil { mode := strings.ToLower(args.Mode) switch mode { case "append": fields["content"] = existing.Content + *args.Content case "prepend": fields["content"] = *args.Content + existing.Content default: fields["content"] = *args.Content } } if args.Tags != nil { fields["tags"] = args.Tags } if len(fields) == 0 { return "", fmt.Errorf("no fields to update — provide at least title, content, or tags") } if err := t.stores.Notes.Update(ctx, args.NoteID, fields); err != nil { return "", fmt.Errorf("note not found or update failed") } // Re-fetch to get updated values updated, err := t.stores.Notes.GetByID(ctx, args.NoteID) if err != nil { return "", fmt.Errorf("note not found or update failed") } // Re-embed async with updated content go embedNote(context.Background(), t.embedder, t.stores, updated.ID, execCtx.UserID, updated.Title, updated.Content) result, _ := json.Marshal(map[string]interface{}{ "id": updated.ID, "title": updated.Title, "updated_at": updated.UpdatedAt.Format("2006-01-02T15:04:05Z"), "message": fmt.Sprintf("Note '%s' updated successfully.", updated.Title), }) return string(result), nil } // ═══════════════════════════════════════════ // note_list // ═══════════════════════════════════════════ type noteListTool struct { visitorDeniedBase stores store.Stores } func (t *noteListTool) Definition() ToolDef { return ToolDef{ Name: "note_list", DisplayName: "List", Category: "notes", Description: "List the user's notes, optionally filtered by folder or tag. Shows titles and metadata without full content. Use note_search for keyword searching.", Parameters: JSONSchema(map[string]interface{}{ "folder": Prop("string", "Filter by folder path, e.g. '/projects/' (omit for all)"), "tag": Prop("string", "Filter by tag (omit for all)"), "limit": Prop("integer", "Maximum results (default 20, max 100)"), }, nil), } } func (t *noteListTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { var args struct { Folder string `json:"folder"` Tag string `json:"tag"` Limit int `json:"limit"` } if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } if args.Limit <= 0 || args.Limit > 100 { args.Limit = 20 } opts := store.NoteListOptions{ ListOptions: store.ListOptions{Limit: args.Limit}, } if args.Folder != "" { opts.FolderPath = normalizePath(args.Folder) } if args.Tag != "" { opts.Tag = args.Tag } notes, _, err := t.stores.Notes.ListForUser(ctx, execCtx.UserID, opts) if err != nil { return "", fmt.Errorf("failed to list notes: %w", err) } type item struct { ID string `json:"id"` Title string `json:"title"` Folder string `json:"folder"` Tags []string `json:"tags"` Preview string `json:"preview"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } items := make([]item, 0, len(notes)) for _, n := range notes { tags := n.Tags if tags == nil { tags = []string{} } preview := n.Content if len(preview) > 100 { preview = preview[:100] } items = append(items, item{ ID: n.ID, Title: n.Title, Folder: n.FolderPath, Tags: tags, Preview: preview, CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"), UpdatedAt: n.UpdatedAt.Format("2006-01-02T15:04:05Z"), }) } out, _ := json.Marshal(map[string]interface{}{ "count": len(items), "notes": items, }) return string(out), nil } // ── Path Helper ───────────────────────────── func normalizePath(p string) string { p = strings.TrimSpace(p) if p == "" { return "/" } if !strings.HasPrefix(p, "/") { p = "/" + p } if !strings.HasSuffix(p, "/") { p = p + "/" } for strings.Contains(p, "//") { p = strings.ReplaceAll(p, "//", "/") } return p }