373 lines
11 KiB
Go
373 lines
11 KiB
Go
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
|
|
}
|