package tools import ( "context" "encoding/json" "fmt" "strings" "github.com/lib/pq" "git.gobha.me/xcaliber/chat-switchboard/database" ) // ── Init ──────────────────────────────────── func init() { Register(&NoteCreateTool{}) Register(&NoteSearchTool{}) Register(&NoteUpdateTool{}) Register(&NoteListTool{}) } // ═══════════════════════════════════════════ // note_create // ═══════════════════════════════════════════ type NoteCreateTool struct{} func (t *NoteCreateTool) Definition() ToolDef { return ToolDef{ Name: "note_create", 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 } var id, createdAt string err := database.DB.QueryRow(` INSERT INTO notes (user_id, title, content, folder_path, tags, source_channel_id) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, created_at::text `, execCtx.UserID, args.Title, args.Content, folder, pq.Array(tags), sourceChannelID, ).Scan(&id, &createdAt) if err != nil { return "", fmt.Errorf("failed to create note: %w", err) } result, _ := json.Marshal(map[string]interface{}{ "id": id, "title": args.Title, "folder": folder, "tags": tags, "created_at": createdAt, "message": fmt.Sprintf("Note '%s' created successfully.", args.Title), }) return string(result), nil } // ═══════════════════════════════════════════ // note_search // ═══════════════════════════════════════════ type NoteSearchTool struct{} func (t *NoteSearchTool) Definition() ToolDef { return ToolDef{ Name: "note_search", Description: "Search the user's notes by keyword. Returns matching notes ranked by relevance with highlighted excerpts. Use this to find previously saved information.", Parameters: JSONSchema(map[string]interface{}{ "query": Prop("string", "Search query — natural language keywords"), "limit": Prop("integer", "Maximum results to return (default 10, max 50)"), }, []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"` } 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 } rows, err := database.DB.Query(` SELECT id, title, folder_path, tags, LEFT(content, 500), ts_rank(search_vector, plainto_tsquery('english', $2)) AS rank, ts_headline('english', content, plainto_tsquery('english', $2), 'MaxWords=60, MinWords=20, StartSel=**, StopSel=**') AS headline FROM notes WHERE user_id = $1 AND search_vector @@ plainto_tsquery('english', $2) ORDER BY rank DESC LIMIT $3 `, execCtx.UserID, args.Query, args.Limit) if err != nil { return "", fmt.Errorf("search failed: %w", err) } defer rows.Close() type result 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"` Rank float64 `json:"rank"` } results := make([]result, 0) for rows.Next() { var r result var dbTags pq.StringArray if err := rows.Scan(&r.ID, &r.Title, &r.Folder, &dbTags, &r.Excerpt, &r.Rank, &r.Headline); err != nil { continue } r.Tags = []string(dbTags) if r.Tags == nil { r.Tags = []string{} } results = append(results, r) } out, _ := json.Marshal(map[string]interface{}{ "query": args.Query, "count": len(results), "results": results, }) return string(out), nil } // ═══════════════════════════════════════════ // note_update // ═══════════════════════════════════════════ type NoteUpdateTool struct{} func (t *NoteUpdateTool) Definition() ToolDef { return ToolDef{ Name: "note_update", 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") } // Build dynamic update setClauses := []string{} queryArgs := []interface{}{} argIdx := 1 if args.Title != nil { setClauses = append(setClauses, fmt.Sprintf("title = $%d", argIdx)) queryArgs = append(queryArgs, *args.Title) argIdx++ } if args.Content != nil { mode := strings.ToLower(args.Mode) switch mode { case "append": setClauses = append(setClauses, fmt.Sprintf("content = content || $%d", argIdx)) case "prepend": setClauses = append(setClauses, fmt.Sprintf("content = $%d || content", argIdx)) default: setClauses = append(setClauses, fmt.Sprintf("content = $%d", argIdx)) } queryArgs = append(queryArgs, *args.Content) argIdx++ } if args.Tags != nil { setClauses = append(setClauses, fmt.Sprintf("tags = $%d", argIdx)) queryArgs = append(queryArgs, pq.Array(args.Tags)) argIdx++ } if len(setClauses) == 0 { return "", fmt.Errorf("no fields to update — provide at least title, content, or tags") } queryArgs = append(queryArgs, args.NoteID, execCtx.UserID) query := "UPDATE notes SET " + strings.Join(setClauses, ", ") + fmt.Sprintf(" WHERE id = $%d AND user_id = $%d", argIdx, argIdx+1) + " RETURNING id, title, updated_at::text" var id, title, updatedAt string err := database.DB.QueryRow(query, queryArgs...).Scan(&id, &title, &updatedAt) if err != nil { return "", fmt.Errorf("note not found or update failed") } result, _ := json.Marshal(map[string]interface{}{ "id": id, "title": title, "updated_at": updatedAt, "message": fmt.Sprintf("Note '%s' updated successfully.", title), }) return string(result), nil } // ═══════════════════════════════════════════ // note_list // ═══════════════════════════════════════════ type NoteListTool struct{} func (t *NoteListTool) Definition() ToolDef { return ToolDef{ Name: "note_list", 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 } query := `SELECT id, title, folder_path, tags, LEFT(content, 100), created_at::text, updated_at::text FROM notes WHERE user_id = $1` queryArgs := []interface{}{execCtx.UserID} argIdx := 2 if args.Folder != "" { query += fmt.Sprintf(" AND folder_path = $%d", argIdx) queryArgs = append(queryArgs, normalizePath(args.Folder)) argIdx++ } if args.Tag != "" { query += fmt.Sprintf(" AND $%d = ANY(tags)", argIdx) queryArgs = append(queryArgs, args.Tag) argIdx++ } query += fmt.Sprintf(" ORDER BY updated_at DESC LIMIT $%d", argIdx) queryArgs = append(queryArgs, args.Limit) rows, err := database.DB.Query(query, queryArgs...) if err != nil { return "", fmt.Errorf("failed to list notes: %w", err) } defer rows.Close() 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) for rows.Next() { var n item var dbTags pq.StringArray if err := rows.Scan(&n.ID, &n.Title, &n.Folder, &dbTags, &n.Preview, &n.CreatedAt, &n.UpdatedAt); err != nil { continue } n.Tags = []string(dbTags) if n.Tags == nil { n.Tags = []string{} } items = append(items, n) } 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 }