executor.go
package assist
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"log"
"github.com/readysite/readysite/pkg/assistant"
"github.com/readysite/readysite/website/internal/content"
"github.com/readysite/readysite/website/internal/search"
"github.com/readysite/readysite/website/models"
"golang.org/x/crypto/bcrypt"
)
// =============================================================================
// Error Types
// =============================================================================
// ToolError provides contextual error information for tool failures.
type ToolError struct {
Tool string `json:"tool"`
Code string `json:"code"`
Message string `json:"message"`
Suggestion string `json:"suggestion,omitempty"`
}
func (e *ToolError) Error() string {
if e.Suggestion != "" {
return fmt.Sprintf("%s: %s (hint: %s)", e.Tool, e.Message, e.Suggestion)
}
return fmt.Sprintf("%s: %s", e.Tool, e.Message)
}
// Common error codes
const (
ErrCodeNotFound = "NOT_FOUND"
ErrCodeInvalidArgs = "INVALID_ARGS"
ErrCodeValidation = "VALIDATION_ERROR"
ErrCodeDuplicate = "DUPLICATE"
ErrCodeInternalError = "INTERNAL_ERROR"
ErrCodePermission = "PERMISSION_DENIED"
)
// =============================================================================
// Executor
// =============================================================================
// toolHandler is a function that executes a tool call.
type toolHandler func(*Executor, assistant.ToolCall) (string, error)
// handlers maps tool names to their handler functions.
var handlers = map[string]toolHandler{
// Page tools
"create_page": (*Executor).createPage,
"update_page": (*Executor).updatePage,
"delete_page": (*Executor).deletePage,
"delete_pages": (*Executor).deletePages,
"get_page": (*Executor).getPage,
"list_pages": (*Executor).listPages,
// Collection tools
"create_collection": (*Executor).createCollection,
"update_collection": (*Executor).updateCollection,
"delete_collection": (*Executor).deleteCollection,
"delete_collections": (*Executor).deleteCollections,
"get_collection": (*Executor).getCollection,
"list_collections": (*Executor).listCollections,
// Document tools
"create_document": (*Executor).createDocument,
"create_documents": (*Executor).createDocuments,
"update_document": (*Executor).updateDocument,
"delete_document": (*Executor).deleteDocument,
"get_document": (*Executor).getDocument,
"query_documents": (*Executor).queryDocuments,
// Partial tools
"create_partial": (*Executor).createPartial,
"update_partial": (*Executor).updatePartial,
"delete_partial": (*Executor).deletePartial,
"get_partial": (*Executor).getPartial,
"list_partials": (*Executor).listPartials,
// File tools
"update_file": (*Executor).updateFile,
"get_file": (*Executor).getFile,
"list_files": (*Executor).listFiles,
"read_file": (*Executor).readFile,
// Note tools
"create_note": (*Executor).createNote,
"list_notes": (*Executor).listNotes,
"get_note": (*Executor).getNote,
"update_note": (*Executor).updateNote,
"delete_note": (*Executor).deleteNote,
// User tools
"create_user": (*Executor).createUser,
"update_user": (*Executor).updateUser,
"delete_user": (*Executor).deleteUser,
"list_users": (*Executor).listUsers,
// Utility tools
"validate_template": (*Executor).validateTemplate,
"navigate_user": (*Executor).navigateUser,
"search": (*Executor).search,
}
// Executor executes tool calls and tracks mutations.
type Executor struct {
message *models.Message // For mutation tracking (optional)
}
// NewExecutor creates a new tool executor.
// If message is provided, mutations will be recorded for undo/redo support.
func NewExecutor(message *models.Message) *Executor {
return &Executor{message: message}
}
// Execute runs a tool call and returns the result as JSON.
func (e *Executor) Execute(call assistant.ToolCall) (string, error) {
handler, ok := handlers[call.Name]
if !ok {
return "", &ToolError{
Tool: call.Name,
Code: ErrCodeNotFound,
Message: fmt.Sprintf("unknown tool: %s", call.Name),
Suggestion: "Use one of: " + formatToolList(),
}
}
return handler(e, call)
}
// formatToolList returns a comma-separated list of available tools.
func formatToolList() string {
names := ToolNames()
if len(names) > 10 {
return fmt.Sprintf("%s, ... (%d total)", names[0], len(names))
}
return fmt.Sprintf("%v", names)
}
// =============================================================================
// Page Handlers
// =============================================================================
func (e *Executor) createPage(call assistant.ToolCall) (string, error) {
var args CreatePageArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "create_page", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
page := &models.Page{
ParentID: args.ParentID,
}
// Set ID from arg or generate slug from title
if args.ID != "" {
page.ID = slugify(args.ID)
} else if args.Title != "" {
page.ID = slugify(args.Title)
}
id, err := models.Pages.Insert(page)
if err != nil {
return "", &ToolError{
Tool: "create_page",
Code: ErrCodeInternalError,
Message: fmt.Sprintf("failed to create page: %v", err),
}
}
// Determine content status
contentStatus := models.StatusDraft
if args.Published {
contentStatus = models.StatusPublished
}
// Save content as first version
page.ID = id
pageContent, err := content.SavePageContent(page, args.Title, args.Description, unescapeHTML(args.HTML), "", contentStatus)
if err != nil {
return "", &ToolError{
Tool: "create_page",
Code: ErrCodeInternalError,
Message: fmt.Sprintf("failed to save page content: %v", err),
}
}
e.recordMutation(ActionCreate, "page", id, nil, page)
e.recordMutation(ActionCreate, "page_content", pageContent.ID, nil, pageContent)
return jsonResult(map[string]any{
"id": id,
"title": args.Title,
"path": page.Path(),
"message": fmt.Sprintf("Created page '%s'", args.Title),
})
}
func (e *Executor) updatePage(call assistant.ToolCall) (string, error) {
var args UpdatePageArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "update_page", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
page, err := models.Pages.Get(args.ID)
if err != nil {
return "", &ToolError{
Tool: "update_page",
Code: ErrCodeNotFound,
Message: fmt.Sprintf("page '%s' not found", args.ID),
Suggestion: "Use list_pages to see available pages",
}
}
beforeState := clonePage(page)
// Update page metadata
if args.ParentID != nil {
page.ParentID = *args.ParentID
}
if args.Position != nil {
page.Position = *args.Position
}
if err := models.Pages.Update(page); err != nil {
return "", &ToolError{Tool: "update_page", Code: ErrCodeInternalError, Message: err.Error()}
}
// Handle content changes
currentTitle := page.Title()
currentDescription := page.Description()
currentHTML := page.HTML()
newTitle := currentTitle
newDescription := currentDescription
newHTML := currentHTML
if args.Title != nil {
newTitle = *args.Title
}
if args.Description != nil {
newDescription = *args.Description
}
if args.HTML != nil {
newHTML = unescapeHTML(*args.HTML)
}
// Determine content status
contentStatus := models.StatusDraft
if page.IsPublished() {
contentStatus = models.StatusPublished
}
if args.Published != nil {
if *args.Published {
contentStatus = models.StatusPublished
} else {
contentStatus = models.StatusDraft
}
}
// Create new content version if something changed
contentChanged := newTitle != currentTitle || newDescription != currentDescription || newHTML != currentHTML
statusChanged := args.Published != nil
if contentChanged || statusChanged {
pageContent, err := content.SavePageContent(page, newTitle, newDescription, newHTML, "", contentStatus)
if err != nil {
return "", &ToolError{Tool: "update_page", Code: ErrCodeInternalError, Message: err.Error()}
}
e.recordMutation(ActionCreate, "page_content", pageContent.ID, nil, pageContent)
}
e.recordMutation(ActionUpdate, "page", page.ID, beforeState, page)
return jsonResult(map[string]any{
"id": page.ID,
"title": newTitle,
"message": fmt.Sprintf("Updated page '%s'", newTitle),
})
}
func (e *Executor) deletePage(call assistant.ToolCall) (string, error) {
var args DeletePageArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "delete_page", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
page, err := models.Pages.Get(args.ID)
if err != nil {
return "", &ToolError{
Tool: "delete_page",
Code: ErrCodeNotFound,
Message: fmt.Sprintf("page '%s' not found", args.ID),
Suggestion: "Use list_pages to see available pages",
}
}
title := page.Title()
beforeState := clonePage(page)
if err := models.Pages.Delete(page); err != nil {
return "", &ToolError{Tool: "delete_page", Code: ErrCodeInternalError, Message: err.Error()}
}
e.recordMutation(ActionDelete, "page", page.ID, beforeState, nil)
return jsonResult(map[string]any{
"id": page.ID,
"message": fmt.Sprintf("Deleted page '%s'", title),
})
}
func (e *Executor) deletePages(call assistant.ToolCall) (string, error) {
var args DeletePagesArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "delete_pages", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
// Parse JSON array of IDs
var ids []string
if err := json.Unmarshal([]byte(args.IDs), &ids); err != nil {
return "", &ToolError{
Tool: "delete_pages",
Code: ErrCodeInvalidArgs,
Message: fmt.Sprintf("invalid JSON array: %v", err),
Suggestion: "Provide a JSON array of page IDs, e.g., [\"page1\", \"page2\"]",
}
}
if len(ids) == 0 {
return "", &ToolError{
Tool: "delete_pages",
Code: ErrCodeInvalidArgs,
Message: "ids array is empty",
Suggestion: "Provide at least one page ID to delete",
}
}
// Limit batch size
const maxBatchSize = 50
if len(ids) > maxBatchSize {
return "", &ToolError{
Tool: "delete_pages",
Code: ErrCodeInvalidArgs,
Message: fmt.Sprintf("too many pages (%d), max is %d", len(ids), maxBatchSize),
Suggestion: "Split into multiple batches",
}
}
// Delete pages
deletedIDs := make([]string, 0, len(ids))
deletedTitles := make([]string, 0, len(ids))
var errors []string
for _, id := range ids {
page, err := models.Pages.Get(id)
if err != nil {
errors = append(errors, fmt.Sprintf("page '%s' not found", id))
continue
}
title := page.Title()
beforeState := clonePage(page)
if err := models.Pages.Delete(page); err != nil {
errors = append(errors, fmt.Sprintf("failed to delete '%s': %v", id, err))
continue
}
e.recordMutation(ActionDelete, "page", page.ID, beforeState, nil)
deletedIDs = append(deletedIDs, id)
deletedTitles = append(deletedTitles, title)
}
result := map[string]any{
"deleted_ids": deletedIDs,
"deleted_count": len(deletedIDs),
"total_count": len(ids),
}
if len(errors) > 0 {
result["errors"] = errors
result["message"] = fmt.Sprintf("Deleted %d of %d pages", len(deletedIDs), len(ids))
} else {
result["message"] = fmt.Sprintf("Deleted %d pages", len(deletedIDs))
}
return jsonResult(result)
}
func (e *Executor) getPage(call assistant.ToolCall) (string, error) {
var args GetPageArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "get_page", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
page, err := models.Pages.Get(args.ID)
if err != nil {
return "", &ToolError{
Tool: "get_page",
Code: ErrCodeNotFound,
Message: fmt.Sprintf("page '%s' not found", args.ID),
Suggestion: "Use list_pages to see available pages",
}
}
return jsonResult(map[string]any{
"id": page.ID,
"title": page.Title(),
"description": page.Description(),
"html": page.HTML(),
"parent_id": page.ParentID,
"position": page.Position,
"published": page.IsPublished(),
"status": page.Status(),
"path": page.Path(),
"created_at": page.CreatedAt,
"updated_at": page.UpdatedAt,
})
}
func (e *Executor) listPages(call assistant.ToolCall) (string, error) {
var args ListPagesArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "list_pages", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
var query string
var queryArgs []any
if args.ParentID != "" {
query = "WHERE ParentID = ?"
queryArgs = []any{args.ParentID}
} else {
query = "WHERE ParentID = ''"
}
query += " ORDER BY Position, CreatedAt"
allPages, err := models.Pages.Search(query, queryArgs...)
if err != nil {
return "", &ToolError{Tool: "list_pages", Code: ErrCodeInternalError, Message: err.Error()}
}
// Filter by published status if needed
var pages []*models.Page
if args.IncludeUnpublished {
pages = allPages
} else {
for _, p := range allPages {
if p.IsPublished() {
pages = append(pages, p)
}
}
}
result := make([]map[string]any, len(pages))
for i, p := range pages {
result[i] = map[string]any{
"id": p.ID,
"title": p.Title(),
"path": p.Path(),
"published": p.IsPublished(),
"status": p.Status(),
"position": p.Position,
}
}
return jsonResult(map[string]any{
"pages": result,
"count": len(pages),
})
}
// =============================================================================
// Collection Handlers
// =============================================================================
func (e *Executor) createCollection(call assistant.ToolCall) (string, error) {
var args CreateCollectionArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "create_collection", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
// Validate schema if provided
if args.Schema != "" {
if err := content.ValidateSchema(args.Schema); err != nil {
return "", &ToolError{
Tool: "create_collection",
Code: ErrCodeValidation,
Message: fmt.Sprintf("invalid schema: %v", err),
Suggestion: "Each field needs 'name' and valid 'type'. Select fields need 'options.values', relation fields need 'options.collection'.",
}
}
}
col := &models.Collection{
Name: args.Name,
Description: args.Description,
Schema: args.Schema,
}
if args.ID != "" {
col.ID = args.ID
}
id, err := models.Collections.Insert(col)
if err != nil {
return "", &ToolError{Tool: "create_collection", Code: ErrCodeInternalError, Message: err.Error()}
}
e.recordMutation(ActionCreate, "collection", id, nil, col)
return jsonResult(map[string]any{
"id": id,
"name": col.Name,
"message": fmt.Sprintf("Created collection '%s'", col.Name),
})
}
func (e *Executor) updateCollection(call assistant.ToolCall) (string, error) {
var args UpdateCollectionArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "update_collection", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
col, err := models.Collections.Get(args.ID)
if err != nil {
return "", &ToolError{
Tool: "update_collection",
Code: ErrCodeNotFound,
Message: fmt.Sprintf("collection '%s' not found", args.ID),
Suggestion: "Use list_collections to see available collections",
}
}
// Validate schema if provided
if args.Schema != nil && *args.Schema != "" {
if err := content.ValidateSchema(*args.Schema); err != nil {
return "", &ToolError{
Tool: "update_collection",
Code: ErrCodeValidation,
Message: fmt.Sprintf("invalid schema: %v", err),
Suggestion: "Each field needs 'name' and valid 'type'. Select fields need 'options.values', relation fields need 'options.collection'.",
}
}
}
beforeState := cloneCollection(col)
if args.Name != nil {
col.Name = *args.Name
}
if args.Description != nil {
col.Description = *args.Description
}
if args.Schema != nil {
col.Schema = *args.Schema
}
if err := models.Collections.Update(col); err != nil {
return "", &ToolError{Tool: "update_collection", Code: ErrCodeInternalError, Message: err.Error()}
}
e.recordMutation(ActionUpdate, "collection", col.ID, beforeState, col)
return jsonResult(map[string]any{
"id": col.ID,
"name": col.Name,
"message": fmt.Sprintf("Updated collection '%s'", col.Name),
})
}
func (e *Executor) deleteCollection(call assistant.ToolCall) (string, error) {
var args DeleteCollectionArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "delete_collection", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
col, err := models.Collections.Get(args.ID)
if err != nil {
return "", &ToolError{
Tool: "delete_collection",
Code: ErrCodeNotFound,
Message: fmt.Sprintf("collection '%s' not found", args.ID),
Suggestion: "Use list_collections to see available collections",
}
}
beforeState := cloneCollection(col)
// Delete all documents in the collection first
docs, _ := col.Documents()
for _, doc := range docs {
models.Documents.Delete(doc)
}
if err := models.Collections.Delete(col); err != nil {
return "", &ToolError{Tool: "delete_collection", Code: ErrCodeInternalError, Message: err.Error()}
}
e.recordMutation(ActionDelete, "collection", col.ID, beforeState, nil)
return jsonResult(map[string]any{
"id": col.ID,
"documents_deleted": len(docs),
"message": fmt.Sprintf("Deleted collection '%s' and %d documents", beforeState.Name, len(docs)),
})
}
func (e *Executor) deleteCollections(call assistant.ToolCall) (string, error) {
var args DeleteCollectionsArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "delete_collections", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
// Parse JSON array of IDs
var ids []string
if err := json.Unmarshal([]byte(args.IDs), &ids); err != nil {
return "", &ToolError{
Tool: "delete_collections",
Code: ErrCodeInvalidArgs,
Message: fmt.Sprintf("invalid JSON array: %v", err),
Suggestion: "Provide a JSON array of collection IDs, e.g., [\"collection1\", \"collection2\"]",
}
}
if len(ids) == 0 {
return "", &ToolError{
Tool: "delete_collections",
Code: ErrCodeInvalidArgs,
Message: "ids array is empty",
Suggestion: "Provide at least one collection ID to delete",
}
}
// Limit batch size
const maxBatchSize = 20
if len(ids) > maxBatchSize {
return "", &ToolError{
Tool: "delete_collections",
Code: ErrCodeInvalidArgs,
Message: fmt.Sprintf("too many collections (%d), max is %d", len(ids), maxBatchSize),
Suggestion: "Split into multiple batches",
}
}
// Delete collections
deletedIDs := make([]string, 0, len(ids))
deletedNames := make([]string, 0, len(ids))
totalDocsDeleted := 0
var errors []string
for _, id := range ids {
col, err := models.Collections.Get(id)
if err != nil {
errors = append(errors, fmt.Sprintf("collection '%s' not found", id))
continue
}
beforeState := cloneCollection(col)
// Delete all documents in the collection first
docs, _ := col.Documents()
for _, doc := range docs {
models.Documents.Delete(doc)
}
if err := models.Collections.Delete(col); err != nil {
errors = append(errors, fmt.Sprintf("failed to delete '%s': %v", id, err))
continue
}
e.recordMutation(ActionDelete, "collection", col.ID, beforeState, nil)
deletedIDs = append(deletedIDs, id)
deletedNames = append(deletedNames, beforeState.Name)
totalDocsDeleted += len(docs)
}
result := map[string]any{
"deleted_ids": deletedIDs,
"deleted_count": len(deletedIDs),
"total_count": len(ids),
"documents_deleted": totalDocsDeleted,
}
if len(errors) > 0 {
result["errors"] = errors
result["message"] = fmt.Sprintf("Deleted %d of %d collections (%d documents)", len(deletedIDs), len(ids), totalDocsDeleted)
} else {
result["message"] = fmt.Sprintf("Deleted %d collections (%d documents)", len(deletedIDs), totalDocsDeleted)
}
return jsonResult(result)
}
func (e *Executor) getCollection(call assistant.ToolCall) (string, error) {
var args GetCollectionArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "get_collection", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
col, err := models.Collections.Get(args.ID)
if err != nil {
return "", &ToolError{
Tool: "get_collection",
Code: ErrCodeNotFound,
Message: fmt.Sprintf("collection '%s' not found", args.ID),
Suggestion: "Use list_collections to see available collections",
}
}
return jsonResult(map[string]any{
"id": col.ID,
"name": col.Name,
"description": col.Description,
"schema": col.Schema,
"document_count": col.DocumentCount(),
"created_at": col.CreatedAt,
"updated_at": col.UpdatedAt,
})
}
func (e *Executor) listCollections(call assistant.ToolCall) (string, error) {
cols, err := models.Collections.All()
if err != nil {
return "", &ToolError{Tool: "list_collections", Code: ErrCodeInternalError, Message: err.Error()}
}
result := make([]map[string]any, len(cols))
for i, c := range cols {
result[i] = map[string]any{
"id": c.ID,
"name": c.Name,
"description": c.Description,
"document_count": c.DocumentCount(),
}
}
return jsonResult(map[string]any{
"collections": result,
"count": len(cols),
})
}
// =============================================================================
// Document Handlers
// =============================================================================
func (e *Executor) createDocument(call assistant.ToolCall) (string, error) {
var args CreateDocumentArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "create_document", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
// Verify collection exists
col, err := models.Collections.Get(args.CollectionID)
if err != nil {
return "", &ToolError{
Tool: "create_document",
Code: ErrCodeNotFound,
Message: fmt.Sprintf("collection '%s' not found", args.CollectionID),
Suggestion: "Use list_collections to see available collections",
}
}
// Parse and validate data against schema
var data map[string]any
if args.Data != "" {
if err := json.Unmarshal([]byte(args.Data), &data); err != nil {
return "", &ToolError{
Tool: "create_document",
Code: ErrCodeInvalidArgs,
Message: fmt.Sprintf("invalid JSON data: %v", err),
Suggestion: "Ensure the data is valid JSON object",
}
}
}
if err := content.ValidateDocument(col, data); err != nil {
return "", &ToolError{
Tool: "create_document",
Code: ErrCodeValidation,
Message: fmt.Sprintf("validation failed: %v", err),
Suggestion: "Check the collection schema for required fields",
}
}
doc := &models.Document{
CollectionID: args.CollectionID,
Data: args.Data,
}
id, err := models.Documents.Insert(doc)
if err != nil {
return "", &ToolError{Tool: "create_document", Code: ErrCodeInternalError, Message: err.Error()}
}
e.recordMutation(ActionCreate, "document", id, nil, doc)
return jsonResult(map[string]any{
"id": id,
"collection_id": args.CollectionID,
"message": fmt.Sprintf("Created document in collection '%s'", col.Name),
})
}
func (e *Executor) createDocuments(call assistant.ToolCall) (string, error) {
var args CreateDocumentsArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "create_documents", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
// Verify collection exists
col, err := models.Collections.Get(args.CollectionID)
if err != nil {
return "", &ToolError{
Tool: "create_documents",
Code: ErrCodeNotFound,
Message: fmt.Sprintf("collection '%s' not found", args.CollectionID),
Suggestion: "Use list_collections to see available collections",
}
}
// Parse documents array
var documents []map[string]any
if err := json.Unmarshal([]byte(args.Documents), &documents); err != nil {
return "", &ToolError{
Tool: "create_documents",
Code: ErrCodeInvalidArgs,
Message: fmt.Sprintf("invalid JSON array: %v", err),
Suggestion: "Provide a JSON array of document objects, e.g., [{...}, {...}]",
}
}
if len(documents) == 0 {
return "", &ToolError{
Tool: "create_documents",
Code: ErrCodeInvalidArgs,
Message: "documents array is empty",
Suggestion: "Provide at least one document object",
}
}
// Limit batch size
const maxBatchSize = 100
if len(documents) > maxBatchSize {
return "", &ToolError{
Tool: "create_documents",
Code: ErrCodeInvalidArgs,
Message: fmt.Sprintf("too many documents (%d), max is %d", len(documents), maxBatchSize),
Suggestion: "Split into multiple batches",
}
}
// Validate all documents first
for i, data := range documents {
if err := content.ValidateDocument(col, data); err != nil {
return "", &ToolError{
Tool: "create_documents",
Code: ErrCodeValidation,
Message: fmt.Sprintf("document %d validation failed: %v", i+1, err),
Suggestion: "Check the collection schema for required fields",
}
}
}
// Create all documents
createdIDs := make([]string, 0, len(documents))
for _, data := range documents {
dataJSON, _ := json.Marshal(data)
doc := &models.Document{
CollectionID: args.CollectionID,
Data: string(dataJSON),
}
id, err := models.Documents.Insert(doc)
if err != nil {
// Return partial success
return jsonResult(map[string]any{
"created_ids": createdIDs,
"created_count": len(createdIDs),
"total_count": len(documents),
"error": fmt.Sprintf("failed at document %d: %v", len(createdIDs)+1, err),
"message": fmt.Sprintf("Partially created %d of %d documents", len(createdIDs), len(documents)),
})
}
createdIDs = append(createdIDs, id)
e.recordMutation(ActionCreate, "document", id, nil, doc)
}
return jsonResult(map[string]any{
"created_ids": createdIDs,
"created_count": len(createdIDs),
"collection_id": args.CollectionID,
"message": fmt.Sprintf("Created %d documents in collection '%s'", len(createdIDs), col.Name),
})
}
func (e *Executor) updateDocument(call assistant.ToolCall) (string, error) {
var args UpdateDocumentArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "update_document", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
doc, err := models.Documents.Get(args.ID)
if err != nil {
return "", &ToolError{
Tool: "update_document",
Code: ErrCodeNotFound,
Message: fmt.Sprintf("document '%s' not found", args.ID),
Suggestion: "Use query_documents to find documents in a collection",
}
}
// Get collection for validation
col, err := models.Collections.Get(doc.CollectionID)
if err != nil {
log.Printf("Warning: collection %s not found for document %s", doc.CollectionID, doc.ID)
}
beforeState := cloneDocument(doc)
// Parse existing data and merge with new data
var existingData, newData map[string]any
if doc.Data != "" {
if err := json.Unmarshal([]byte(doc.Data), &existingData); err != nil {
log.Printf("Warning: failed to parse existing document data: %v", err)
existingData = make(map[string]any)
}
} else {
existingData = make(map[string]any)
}
if err := json.Unmarshal([]byte(args.Data), &newData); err != nil {
return "", &ToolError{
Tool: "update_document",
Code: ErrCodeInvalidArgs,
Message: fmt.Sprintf("invalid JSON data: %v", err),
Suggestion: "Ensure the data is valid JSON object",
}
}
// Merge new data into existing
for k, v := range newData {
existingData[k] = v
}
// Validate merged data against schema
if col != nil {
if err := content.ValidateDocument(col, existingData); err != nil {
return "", &ToolError{
Tool: "update_document",
Code: ErrCodeValidation,
Message: fmt.Sprintf("validation failed: %v", err),
Suggestion: "Check the collection schema for field requirements",
}
}
}
mergedData, err := json.Marshal(existingData)
if err != nil {
return "", &ToolError{Tool: "update_document", Code: ErrCodeInternalError, Message: err.Error()}
}
doc.Data = string(mergedData)
if err := models.Documents.Update(doc); err != nil {
return "", &ToolError{Tool: "update_document", Code: ErrCodeInternalError, Message: err.Error()}
}
e.recordMutation(ActionUpdate, "document", doc.ID, beforeState, doc)
return jsonResult(map[string]any{
"id": doc.ID,
"message": "Updated document",
})
}
func (e *Executor) deleteDocument(call assistant.ToolCall) (string, error) {
var args DeleteDocumentArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "delete_document", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
doc, err := models.Documents.Get(args.ID)
if err != nil {
return "", &ToolError{
Tool: "delete_document",
Code: ErrCodeNotFound,
Message: fmt.Sprintf("document '%s' not found", args.ID),
Suggestion: "Use query_documents to find documents in a collection",
}
}
beforeState := cloneDocument(doc)
if err := models.Documents.Delete(doc); err != nil {
return "", &ToolError{Tool: "delete_document", Code: ErrCodeInternalError, Message: err.Error()}
}
e.recordMutation(ActionDelete, "document", doc.ID, beforeState, nil)
return jsonResult(map[string]any{
"id": doc.ID,
"message": "Deleted document",
})
}
func (e *Executor) getDocument(call assistant.ToolCall) (string, error) {
var args GetDocumentArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "get_document", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
doc, err := models.Documents.Get(args.ID)
if err != nil {
return "", &ToolError{
Tool: "get_document",
Code: ErrCodeNotFound,
Message: fmt.Sprintf("document '%s' not found", args.ID),
Suggestion: "Use query_documents to find documents in a collection",
}
}
var data map[string]any
if doc.Data != "" {
json.Unmarshal([]byte(doc.Data), &data)
}
return jsonResult(map[string]any{
"id": doc.ID,
"collection_id": doc.CollectionID,
"data": data,
"created_at": doc.CreatedAt,
"updated_at": doc.UpdatedAt,
})
}
func (e *Executor) queryDocuments(call assistant.ToolCall) (string, error) {
var args QueryDocumentsArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "query_documents", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
// Build query
query := "WHERE CollectionID = ?"
queryArgs := []any{args.CollectionID}
if args.Filter != "" {
// Validate filter to prevent SQL injection
if err := validateSQLFilter(args.Filter); err != nil {
return "", &ToolError{
Tool: "query_documents",
Code: ErrCodeInvalidArgs,
Message: fmt.Sprintf("invalid filter: %v", err),
Suggestion: "Use simple conditions like: Data LIKE '%\"field\":\"value\"%'",
}
}
query += " AND " + args.Filter
}
if args.OrderBy != "" {
if err := validateSQLOrderBy(args.OrderBy); err != nil {
return "", &ToolError{
Tool: "query_documents",
Code: ErrCodeInvalidArgs,
Message: fmt.Sprintf("invalid order_by: %v", err),
Suggestion: "Use simple ordering like: CreatedAt DESC",
}
}
query += " ORDER BY " + args.OrderBy
} else {
query += " ORDER BY CreatedAt DESC"
}
// Enforce maximum limit
limit := args.Limit
if limit <= 0 || limit > 100 {
limit = 100
}
query += fmt.Sprintf(" LIMIT %d", limit)
docs, err := models.Documents.Search(query, queryArgs...)
if err != nil {
return "", &ToolError{Tool: "query_documents", Code: ErrCodeInternalError, Message: err.Error()}
}
result := make([]map[string]any, len(docs))
for i, d := range docs {
var data map[string]any
if d.Data != "" {
json.Unmarshal([]byte(d.Data), &data)
}
result[i] = map[string]any{
"id": d.ID,
"data": data,
"created_at": d.CreatedAt,
"updated_at": d.UpdatedAt,
}
}
return jsonResult(map[string]any{
"documents": result,
"count": len(docs),
})
}
// =============================================================================
// Partial Handlers
// =============================================================================
func (e *Executor) createPartial(call assistant.ToolCall) (string, error) {
var args CreatePartialArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "create_partial", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
partial := &models.Partial{
Name: args.Name,
Description: args.Description,
HTML: unescapeHTML(args.HTML),
Published: args.Published,
}
// Set ID from arg or generate slug from name
if args.ID != "" {
partial.ID = slugify(args.ID)
} else if args.Name != "" {
partial.ID = slugify(args.Name)
}
id, err := models.Partials.Insert(partial)
if err != nil {
return "", &ToolError{Tool: "create_partial", Code: ErrCodeInternalError, Message: err.Error()}
}
e.recordMutation(ActionCreate, "partial", id, nil, partial)
return jsonResult(map[string]any{
"id": id,
"name": args.Name,
"message": fmt.Sprintf("Created partial '%s'. Use {{partial \"%s\"}} in page HTML to include it.", args.Name, id),
})
}
func (e *Executor) updatePartial(call assistant.ToolCall) (string, error) {
var args UpdatePartialArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "update_partial", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
partial, err := models.Partials.Get(args.ID)
if err != nil {
return "", &ToolError{
Tool: "update_partial",
Code: ErrCodeNotFound,
Message: fmt.Sprintf("partial '%s' not found", args.ID),
Suggestion: "Use list_partials to see available partials",
}
}
beforeState := clonePartial(partial)
if args.Name != nil {
partial.Name = *args.Name
}
if args.Description != nil {
partial.Description = *args.Description
}
if args.HTML != nil {
partial.HTML = unescapeHTML(*args.HTML)
}
if args.Published != nil {
partial.Published = *args.Published
}
if err := models.Partials.Update(partial); err != nil {
return "", &ToolError{Tool: "update_partial", Code: ErrCodeInternalError, Message: err.Error()}
}
e.recordMutation(ActionUpdate, "partial", partial.ID, beforeState, partial)
return jsonResult(map[string]any{
"id": partial.ID,
"name": partial.Name,
"message": fmt.Sprintf("Updated partial '%s'", partial.Name),
})
}
func (e *Executor) deletePartial(call assistant.ToolCall) (string, error) {
var args DeletePartialArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "delete_partial", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
partial, err := models.Partials.Get(args.ID)
if err != nil {
return "", &ToolError{
Tool: "delete_partial",
Code: ErrCodeNotFound,
Message: fmt.Sprintf("partial '%s' not found", args.ID),
Suggestion: "Use list_partials to see available partials",
}
}
name := partial.Name
beforeState := clonePartial(partial)
if err := models.Partials.Delete(partial); err != nil {
return "", &ToolError{Tool: "delete_partial", Code: ErrCodeInternalError, Message: err.Error()}
}
e.recordMutation(ActionDelete, "partial", partial.ID, beforeState, nil)
return jsonResult(map[string]any{
"id": partial.ID,
"message": fmt.Sprintf("Deleted partial '%s'", name),
})
}
func (e *Executor) getPartial(call assistant.ToolCall) (string, error) {
var args GetPartialArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "get_partial", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
partial, err := models.Partials.Get(args.ID)
if err != nil {
return "", &ToolError{
Tool: "get_partial",
Code: ErrCodeNotFound,
Message: fmt.Sprintf("partial '%s' not found", args.ID),
Suggestion: "Use list_partials to see available partials",
}
}
return jsonResult(map[string]any{
"id": partial.ID,
"name": partial.Name,
"description": partial.Description,
"html": partial.HTML,
"published": partial.Published,
"usage": fmt.Sprintf("{{partial \"%s\"}}", partial.ID),
"created_at": partial.CreatedAt,
"updated_at": partial.UpdatedAt,
})
}
func (e *Executor) listPartials(call assistant.ToolCall) (string, error) {
var args ListPartialsArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "list_partials", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
var query string
if args.IncludeUnpublished {
query = "ORDER BY Name"
} else {
query = "WHERE Published = true ORDER BY Name"
}
partials, err := models.Partials.Search(query)
if err != nil {
return "", &ToolError{Tool: "list_partials", Code: ErrCodeInternalError, Message: err.Error()}
}
result := make([]map[string]any, len(partials))
for i, p := range partials {
result[i] = map[string]any{
"id": p.ID,
"name": p.Name,
"description": p.Description,
"published": p.Published,
"usage": fmt.Sprintf("{{partial \"%s\"}}", p.ID),
}
}
return jsonResult(map[string]any{
"partials": result,
"count": len(partials),
})
}
// =============================================================================
// File Handlers
// =============================================================================
func (e *Executor) updateFile(call assistant.ToolCall) (string, error) {
var args UpdateFileArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "update_file", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
file, err := models.Files.Get(args.ID)
if err != nil {
return "", &ToolError{
Tool: "update_file",
Code: ErrCodeNotFound,
Message: fmt.Sprintf("file '%s' not found", args.ID),
Suggestion: "Use list_files to see available files",
}
}
beforeState := cloneFile(file)
if args.Published != nil {
file.Published = *args.Published
}
if args.Path != nil {
newPath := *args.Path
// Validate path uniqueness
if file.Published && newPath != "" && newPath != file.Path {
existing, _ := models.Files.First("WHERE Path = ? AND ID != ?", newPath, file.ID)
if existing != nil {
return "", &ToolError{
Tool: "update_file",
Code: ErrCodeDuplicate,
Message: fmt.Sprintf("path '%s' is already in use", newPath),
Suggestion: "Choose a different path",
}
}
}
file.Path = newPath
}
if err := models.Files.Update(file); err != nil {
return "", &ToolError{Tool: "update_file", Code: ErrCodeInternalError, Message: err.Error()}
}
e.recordMutation(ActionUpdate, "file", file.ID, beforeState, file)
return jsonResult(map[string]any{
"id": file.ID,
"name": file.Name,
"published": file.Published,
"path": file.Path,
"public_url": file.PublicURL(),
"message": fmt.Sprintf("Updated file '%s'", file.Name),
})
}
func (e *Executor) getFile(call assistant.ToolCall) (string, error) {
var args GetFileArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "get_file", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
file, err := models.Files.Get(args.ID)
if err != nil {
return "", &ToolError{
Tool: "get_file",
Code: ErrCodeNotFound,
Message: fmt.Sprintf("file '%s' not found", args.ID),
Suggestion: "Use list_files to see available files",
}
}
return jsonResult(map[string]any{
"id": file.ID,
"name": file.Name,
"mime_type": file.MimeType,
"size": file.Size,
"published": file.Published,
"path": file.Path,
"public_url": file.PublicURL(),
"file_url": "/files/" + file.ID,
"created_at": file.CreatedAt,
"updated_at": file.UpdatedAt,
})
}
func (e *Executor) listFiles(call assistant.ToolCall) (string, error) {
var args ListFilesArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "list_files", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
var query string
var queryArgs []any
if args.MimeType != "" {
if args.IncludeUnpublished {
query = "WHERE MimeType LIKE ? ORDER BY CreatedAt DESC"
queryArgs = []any{args.MimeType + "%"}
} else {
query = "WHERE Published = true AND MimeType LIKE ? ORDER BY CreatedAt DESC"
queryArgs = []any{args.MimeType + "%"}
}
} else {
if args.IncludeUnpublished {
query = "ORDER BY CreatedAt DESC"
} else {
query = "WHERE Published = true ORDER BY CreatedAt DESC"
}
}
files, err := models.Files.Search(query, queryArgs...)
if err != nil {
return "", &ToolError{Tool: "list_files", Code: ErrCodeInternalError, Message: err.Error()}
}
result := make([]map[string]any, len(files))
for i, f := range files {
result[i] = map[string]any{
"id": f.ID,
"name": f.Name,
"mime_type": f.MimeType,
"size": f.Size,
"published": f.Published,
"path": f.Path,
"public_url": f.PublicURL(),
"file_url": "/files/" + f.ID,
}
}
return jsonResult(map[string]any{
"files": result,
"count": len(files),
})
}
func (e *Executor) readFile(call assistant.ToolCall) (string, error) {
var args ReadFileArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "read_file", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
file, err := models.Files.Get(args.ID)
if err != nil {
return "", &ToolError{
Tool: "read_file",
Code: ErrCodeNotFound,
Message: fmt.Sprintf("file '%s' not found", args.ID),
Suggestion: "Use list_files to see available files",
}
}
// Get cached or extract content
fileContent, err := GetFileContent(args.ID)
if err != nil {
return "", &ToolError{
Tool: "read_file",
Code: ErrCodeValidation,
Message: err.Error(),
Suggestion: "This tool only works with text files under 100KB",
}
}
return jsonResult(map[string]any{
"id": file.ID,
"name": file.Name,
"mime_type": file.MimeType,
"size": file.Size,
"content": fileContent,
})
}
// =============================================================================
// Note Handlers
// =============================================================================
func (e *Executor) createNote(call assistant.ToolCall) (string, error) {
var args CreateNoteArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "create_note", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
// Validate type
switch args.Type {
case models.NoteTypePreference, models.NoteTypeConvention, models.NoteTypeLearned:
// Valid
default:
return "", &ToolError{
Tool: "create_note",
Code: ErrCodeInvalidArgs,
Message: fmt.Sprintf("invalid note type: %s", args.Type),
Suggestion: "Use 'preference', 'convention', or 'learned'",
}
}
// Validate category
switch args.Category {
case models.NoteCategoryStyle, models.NoteCategoryContent, models.NoteCategoryStructure, models.NoteCategoryBehavior:
// Valid
default:
return "", &ToolError{
Tool: "create_note",
Code: ErrCodeInvalidArgs,
Message: fmt.Sprintf("invalid note category: %s", args.Category),
Suggestion: "Use 'style', 'content', 'structure', or 'behavior'",
}
}
note := &models.Note{
Type: args.Type,
Category: args.Category,
Title: args.Title,
Content: args.Content,
Source: models.NoteSourceAI,
Active: true,
}
id, err := models.Notes.Insert(note)
if err != nil {
return "", &ToolError{Tool: "create_note", Code: ErrCodeInternalError, Message: err.Error()}
}
return jsonResult(map[string]any{
"id": id,
"title": note.Title,
"message": fmt.Sprintf("Created note '%s'", note.Title),
})
}
func (e *Executor) listNotes(call assistant.ToolCall) (string, error) {
var args ListNotesArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "list_notes", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
// Build query
var conditions []string
var queryArgs []any
if !args.IncludeInactive {
conditions = append(conditions, "Active = true")
}
if args.Type != "" {
conditions = append(conditions, "Type = ?")
queryArgs = append(queryArgs, args.Type)
}
if args.Category != "" {
conditions = append(conditions, "Category = ?")
queryArgs = append(queryArgs, args.Category)
}
query := "ORDER BY UpdatedAt DESC"
if len(conditions) > 0 {
query = "WHERE " + conditions[0]
for i := 1; i < len(conditions); i++ {
query += " AND " + conditions[i]
}
query += " ORDER BY UpdatedAt DESC"
}
notes, err := models.Notes.Search(query, queryArgs...)
if err != nil {
return "", &ToolError{Tool: "list_notes", Code: ErrCodeInternalError, Message: err.Error()}
}
result := make([]map[string]any, len(notes))
for i, n := range notes {
result[i] = map[string]any{
"id": n.ID,
"type": n.Type,
"category": n.Category,
"title": n.Title,
"content": n.Content,
"source": n.Source,
"active": n.Active,
}
}
return jsonResult(map[string]any{
"notes": result,
"count": len(notes),
})
}
func (e *Executor) getNote(call assistant.ToolCall) (string, error) {
var args GetNoteArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "get_note", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
note, err := models.Notes.Get(args.ID)
if err != nil {
return "", &ToolError{
Tool: "get_note",
Code: ErrCodeNotFound,
Message: fmt.Sprintf("note '%s' not found", args.ID),
Suggestion: "Use list_notes to see available notes",
}
}
return jsonResult(map[string]any{
"id": note.ID,
"type": note.Type,
"category": note.Category,
"title": note.Title,
"content": note.Content,
"source": note.Source,
"active": note.Active,
"created_at": note.CreatedAt,
"updated_at": note.UpdatedAt,
})
}
func (e *Executor) updateNote(call assistant.ToolCall) (string, error) {
var args UpdateNoteArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "update_note", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
note, err := models.Notes.Get(args.ID)
if err != nil {
return "", &ToolError{
Tool: "update_note",
Code: ErrCodeNotFound,
Message: fmt.Sprintf("note '%s' not found", args.ID),
Suggestion: "Use list_notes to see available notes",
}
}
if args.Title != nil {
note.Title = *args.Title
}
if args.Content != nil {
note.Content = *args.Content
}
if args.Active != nil {
note.Active = *args.Active
}
if err := models.Notes.Update(note); err != nil {
return "", &ToolError{Tool: "update_note", Code: ErrCodeInternalError, Message: err.Error()}
}
return jsonResult(map[string]any{
"id": note.ID,
"title": note.Title,
"message": fmt.Sprintf("Updated note '%s'", note.Title),
})
}
func (e *Executor) deleteNote(call assistant.ToolCall) (string, error) {
var args DeleteNoteArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "delete_note", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
note, err := models.Notes.Get(args.ID)
if err != nil {
return "", &ToolError{
Tool: "delete_note",
Code: ErrCodeNotFound,
Message: fmt.Sprintf("note '%s' not found", args.ID),
Suggestion: "Use list_notes to see available notes",
}
}
title := note.Title
if err := models.Notes.Delete(note); err != nil {
return "", &ToolError{Tool: "delete_note", Code: ErrCodeInternalError, Message: err.Error()}
}
return jsonResult(map[string]any{
"id": args.ID,
"message": fmt.Sprintf("Deleted note '%s'", title),
})
}
// =============================================================================
// User Handlers
// =============================================================================
func (e *Executor) createUser(call assistant.ToolCall) (string, error) {
var args CreateUserArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "create_user", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
if args.Email == "" {
return "", &ToolError{Tool: "create_user", Code: ErrCodeInvalidArgs, Message: "email is required"}
}
if args.Password == "" {
return "", &ToolError{Tool: "create_user", Code: ErrCodeInvalidArgs, Message: "password is required"}
}
if len(args.Password) < 8 {
return "", &ToolError{Tool: "create_user", Code: ErrCodeInvalidArgs, Message: "password must be at least 8 characters"}
}
// Check if email already exists
existing, _ := models.Users.First("WHERE Email = ?", args.Email)
if existing != nil {
return "", &ToolError{Tool: "create_user", Code: ErrCodeDuplicate, Message: "a user with this email already exists"}
}
// Hash password
hash, err := bcrypt.GenerateFromPassword([]byte(args.Password), bcrypt.DefaultCost)
if err != nil {
return "", &ToolError{Tool: "create_user", Code: ErrCodeInternalError, Message: "failed to hash password"}
}
role := args.Role
if role == "" {
role = "user"
}
user := &models.User{
Email: args.Email,
Name: args.Name,
PasswordHash: string(hash),
Role: role,
Verified: true, // Admin-created users are auto-verified
}
id, err := models.Users.Insert(user)
if err != nil {
return "", &ToolError{Tool: "create_user", Code: ErrCodeInternalError, Message: err.Error()}
}
e.recordMutation(ActionCreate, "user", id, nil, user)
e.publishEvent(ActionCreate, "user", id, user)
return jsonResult(map[string]any{
"id": id,
"email": user.Email,
"name": user.Name,
"role": user.Role,
"message": fmt.Sprintf("Created user '%s'", user.Email),
})
}
func (e *Executor) updateUser(call assistant.ToolCall) (string, error) {
var args UpdateUserArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "update_user", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
if args.ID == "" {
return "", &ToolError{Tool: "update_user", Code: ErrCodeInvalidArgs, Message: "id is required"}
}
user, err := models.Users.Get(args.ID)
if err != nil {
return "", &ToolError{Tool: "update_user", Code: ErrCodeNotFound, Message: "user not found"}
}
before := *user
if args.Name != "" {
user.Name = args.Name
}
if args.Email != "" {
// Check if new email already exists
existing, _ := models.Users.First("WHERE Email = ? AND ID != ?", args.Email, user.ID)
if existing != nil {
return "", &ToolError{Tool: "update_user", Code: ErrCodeDuplicate, Message: "a user with this email already exists"}
}
user.Email = args.Email
}
if args.Role != "" {
user.Role = args.Role
}
if args.Verified != nil {
user.Verified = *args.Verified
}
if err := models.Users.Update(user); err != nil {
return "", &ToolError{Tool: "update_user", Code: ErrCodeInternalError, Message: err.Error()}
}
e.recordMutation(ActionUpdate, "user", user.ID, before, user)
e.publishEvent(ActionUpdate, "user", user.ID, user)
return jsonResult(map[string]any{
"id": user.ID,
"email": user.Email,
"name": user.Name,
"role": user.Role,
"message": fmt.Sprintf("Updated user '%s'", user.Email),
})
}
func (e *Executor) deleteUser(call assistant.ToolCall) (string, error) {
var args DeleteUserArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "delete_user", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
if args.ID == "" {
return "", &ToolError{Tool: "delete_user", Code: ErrCodeInvalidArgs, Message: "id is required"}
}
user, err := models.Users.Get(args.ID)
if err != nil {
return "", &ToolError{Tool: "delete_user", Code: ErrCodeNotFound, Message: "user not found"}
}
email := user.Email
if err := models.Users.Delete(user); err != nil {
return "", &ToolError{Tool: "delete_user", Code: ErrCodeInternalError, Message: err.Error()}
}
e.recordMutation(ActionDelete, "user", args.ID, user, nil)
e.publishEvent(ActionDelete, "user", args.ID, nil)
return jsonResult(map[string]any{
"id": args.ID,
"message": fmt.Sprintf("Deleted user '%s'", email),
})
}
func (e *Executor) listUsers(call assistant.ToolCall) (string, error) {
users, err := models.Users.Search("ORDER BY CreatedAt DESC")
if err != nil {
return "", &ToolError{Tool: "list_users", Code: ErrCodeInternalError, Message: err.Error()}
}
result := make([]map[string]any, len(users))
for i, u := range users {
result[i] = map[string]any{
"id": u.ID,
"email": u.Email,
"name": u.Name,
"role": u.Role,
"verified": u.Verified,
"created": u.CreatedAt.Format("2006-01-02"),
}
}
return jsonResult(map[string]any{
"users": result,
"count": len(users),
})
}
// =============================================================================
// Utility Handlers
// =============================================================================
func (e *Executor) validateTemplate(call assistant.ToolCall) (string, error) {
var args ValidateTemplateArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "validate_template", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
if args.HTML == "" {
return jsonResult(map[string]any{
"valid": true,
"message": "Empty template is valid",
})
}
// Parse the template
tmpl, err := template.New("validate").Funcs(content.AllFuncs()).Parse(args.HTML)
if err != nil {
return jsonResult(map[string]any{
"valid": false,
"error": err.Error(),
"suggestion": "Check for unclosed tags, missing braces, or invalid template syntax",
"message": "Template has syntax errors",
})
}
// Try to execute with nil data to catch runtime errors
var buf bytes.Buffer
execErr := tmpl.Execute(&buf, nil)
// Some execution errors are expected (nil pointer, missing data) - that's OK
// We mainly care about parse errors which we already caught above
return jsonResult(map[string]any{
"valid": true,
"warning": execErr != nil,
"message": "Template syntax is valid",
"template": tmpl.Name(),
})
}
func (e *Executor) navigateUser(call assistant.ToolCall) (string, error) {
var args NavigateUserArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "navigate_user", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
if args.URL == "" {
return "", &ToolError{
Tool: "navigate_user",
Code: ErrCodeInvalidArgs,
Message: "url is required",
Suggestion: "Provide a URL like /admin/page/about",
}
}
return jsonResult(map[string]any{
"status": "navigated",
"url": args.URL,
})
}
// =============================================================================
// Search Handler
// =============================================================================
func (e *Executor) search(call assistant.ToolCall) (string, error) {
var args SearchArgs
if err := call.ParseArguments(&args); err != nil {
return "", &ToolError{Tool: "search", Code: ErrCodeInvalidArgs, Message: err.Error()}
}
if args.Query == "" {
return "", &ToolError{
Tool: "search",
Code: ErrCodeInvalidArgs,
Message: "query is required",
}
}
perPage := args.Limit
if perPage <= 0 {
perPage = 20
}
if perPage > 100 {
perPage = 100
}
results, total, err := search.Search(search.SearchOptions{
Query: args.Query,
EntityType: args.Type,
CollectionID: args.CollectionID,
Page: 1,
PerPage: perPage,
})
if err != nil {
return "", &ToolError{Tool: "search", Code: ErrCodeInternalError, Message: err.Error()}
}
items := make([]map[string]any, len(results))
for i, r := range results {
item := map[string]any{
"id": r.EntityID,
"type": r.EntityType,
"title": r.Title,
"rank": r.Rank,
}
if r.Snippet != "" {
item["snippet"] = r.Snippet
}
if r.CollectionID != "" {
item["collection_id"] = r.CollectionID
}
items[i] = item
}
return jsonResult(map[string]any{
"results": items,
"total": total,
"message": fmt.Sprintf("Found %d results for '%s'", total, args.Query),
})
}
// =============================================================================
// Helpers
// =============================================================================
func (e *Executor) recordMutation(action, entityType, entityID string, before, after any) {
if e.message == nil {
return
}
mutation := &models.Mutation{
MessageID: e.message.ID,
Action: action,
EntityType: entityType,
EntityID: entityID,
}
if before != nil {
data, _ := json.Marshal(before)
mutation.BeforeState = string(data)
}
if after != nil {
data, _ := json.Marshal(after)
mutation.AfterState = string(data)
}
models.Mutations.Insert(mutation)
// Publish events for real-time dashboard updates
e.publishEvent(action, entityType, entityID, after)
}
// publishEvent sends events to the event bus for real-time updates.
func (e *Executor) publishEvent(action, entityType, entityID string, entity any) {
var eventType content.EventType
switch action {
case ActionCreate:
eventType = content.EventCreate
case ActionUpdate:
eventType = content.EventUpdate
case ActionDelete:
eventType = content.EventDelete
default:
return
}
switch entityType {
case "page", "page_content":
if entityType == "page_content" {
// For page_content, we send a page_update event
if pc, ok := entity.(*models.PageContent); ok && action == ActionCreate {
data := map[string]any{
"id": pc.PageID,
"title": pc.Title,
}
content.CollectionEvents.PublishPageUpdate(pc.PageID, data)
}
return
}
data := make(map[string]any)
if p, ok := entity.(*models.Page); ok {
data["id"] = p.ID
data["title"] = p.Title()
data["published"] = p.IsPublished()
data["path"] = p.Path()
}
switch eventType {
case content.EventCreate:
content.CollectionEvents.PublishPageCreate(entityID, data)
case content.EventUpdate:
content.CollectionEvents.PublishPageUpdate(entityID, data)
case content.EventDelete:
content.CollectionEvents.PublishPageDelete(entityID)
}
case "collection":
data := make(map[string]any)
if c, ok := entity.(*models.Collection); ok {
data["id"] = c.ID
data["name"] = c.Name
data["documentCount"] = c.DocumentCount()
}
switch eventType {
case content.EventCreate:
content.CollectionEvents.PublishCollectionCreate(entityID, data)
case content.EventUpdate:
content.CollectionEvents.PublishCollectionUpdate(entityID, data)
case content.EventDelete:
content.CollectionEvents.PublishCollectionDelete(entityID)
}
case "document":
var collectionID string
if d, ok := entity.(*models.Document); ok {
collectionID = d.CollectionID
}
switch eventType {
case content.EventCreate:
content.CollectionEvents.PublishCreate(collectionID, entityID, nil)
case content.EventUpdate:
content.CollectionEvents.PublishUpdate(collectionID, entityID, nil)
case content.EventDelete:
content.CollectionEvents.PublishDelete(collectionID, entityID)
}
}
}