package main import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "os/exec" "path/filepath" "strings" "time" "fn-registry/functions/infra" ) const chatSystemPrompt = `Eres el asistente del tablero kanban. Tu trabajo es responder al usuario y, cuando pida cambios, modificar el tablero llamando a tools. Cuando necesites modificar el tablero, responde EXCLUSIVAMENTE con un bloque ... que contenga JSON valido (un array de acciones). Sin texto antes ni despues. Ejemplo: [ {"tool": "create_card", "input": {"column_id": "abc123", "requester": "Lucas", "title": "Revisar PR", "description": ""}}, {"tool": "rename_column", "input": {"id": "def456", "name": "En curso"}} ] Tools disponibles (todas con sus inputs): - list_board {} -> {columns, cards} - create_column {name} - update_column {id, name?, location?, width?, wip_limit?, is_done?} // location: "board" | "sidebar". width: 200..800 px. wip_limit: max tarjetas (0 = sin limite). is_done: marca columna como terminal (cards dentro se cuentan como completadas para metricas y se muestran tachadas). - delete_column {id} - reorder_columns {ids:[...]} - create_card {column_id, requester?, title, description?} - update_card {id, requester?, title?, description?, color?, locked?, assignee_id?} // color: "blue", "teal", "violet", "pink", "orange", "green", "yellow", "red", "" (default). locked: true bloquea la tarjeta (no se puede mover entre columnas hasta desbloquear). assignee_id: ID del usuario asignado o null para desasignar. - delete_card {id} - move_card {id, column_id, ordered_ids?} // si omites ordered_ids la tarjeta se anade al final - card_history {id} - find_cards {query?, column_id?, requester?} - list_users {} -> [{id, username, display_name}] - assign_card {id, assignee_id} // alias rapido de update_card. assignee_id puede ser null para desasignar. Si el usuario solo conversa o pide informacion (sin pedir cambios), responde texto natural en markdown SIN bloque . Para resolver IDs a partir de nombres, mira el board_state que viene al final del prompt del usuario. NO inventes IDs. LOOP ITERATIVO: Despues de aplicar tus acciones, el sistema te volvera a llamar con: - Los resultados de las tool calls anteriores (incluyendo IDs reales de columnas/tarjetas creadas). - El board_state actualizado. - Tu mensaje de usuario original. Cuando recibas resultados de iteraciones anteriores, USA LOS IDs REALES devueltos en lugar de inventar placeholders. Continua emitiendo mas hasta completar la tarea. Cuando hayas terminado COMPLETAMENTE la tarea, responde texto natural (markdown) SIN bloque — eso señala el fin del loop.` const claudeBin = "claude" const claudeModel = "claude-sonnet-4-6" const claudeTimeout = 120 * time.Second const maxChatIterations = 8 type chatMessage struct { Role string `json:"role"` Content string `json:"content"` } type chatRequest struct { Messages []chatMessage `json:"messages"` } type chatResponse struct { Role string `json:"role"` Content string `json:"content"` BoardChanged bool `json:"board_changed"` ToolCalls []toolCallInfo `json:"tool_calls,omitempty"` } type toolCallInfo struct { Tool string `json:"tool"` OK bool `json:"ok"` Error string `json:"error,omitempty"` Iteration int `json:"iteration,omitempty"` // Result is included only for the loop's internal feedback to claude; // it is omitted from the JSON response sent to the frontend (clients // can use board_changed + reload to fetch fresh state). Result any `json:"-"` } type claudeJSONResult struct { Type string `json:"type"` IsError bool `json:"is_error"` Result string `json:"result"` StopReason string `json:"stop_reason"` } // runClaude invokes the `claude` CLI in print mode with the given system prompt // and user message. The board JSON is appended to the user message under a // `board_state` marker so the assistant can resolve names to IDs. // // stdin: the user-facing prompt (history flattened). // returns: assistant's text reply. func runClaude(ctx context.Context, systemPrompt, userInput, boardJSON, workdir string) (string, error) { if _, err := exec.LookPath(claudeBin); err != nil { return "", errors.New("claude CLI not found in PATH") } ctx, cancel := context.WithTimeout(ctx, claudeTimeout) defer cancel() cmd := exec.CommandContext(ctx, claudeBin, "-p", "--model", claudeModel, "--output-format", "json", "--no-session-persistence", "--tools", "", "--system-prompt", systemPrompt, ) cmd.Dir = workdir prompt := userInput if boardJSON != "" { prompt += "\n\n\n" + boardJSON + "\n\n" } cmd.Stdin = bytes.NewBufferString(prompt) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return "", fmt.Errorf("claude exec: %w (stderr: %s)", err, stderr.String()) } var res claudeJSONResult if err := json.Unmarshal(stdout.Bytes(), &res); err != nil { return "", fmt.Errorf("parse claude json: %w (raw: %s)", err, stdout.String()) } if res.IsError { return "", fmt.Errorf("claude error: %s", res.Result) } return res.Result, nil } // flattenMessages converts a chat history into a single text prompt for `claude -p`. // Format: lines of `Usuario: ...` / `Asistente: ...`. Last user message ends the prompt. func flattenMessages(msgs []chatMessage) string { var b bytes.Buffer for _, m := range msgs { role := "Usuario" if m.Role == "assistant" { role = "Asistente" } b.WriteString(role) b.WriteString(": ") b.WriteString(m.Content) b.WriteString("\n\n") } return b.String() } func handleChat(db *DB, workdir string, logger *ChatLogger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req chatRequest if err := infra.HTTPParseBody(r, &req, 1<<20); err != nil { infra.HTTPErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: err.Error()}) return } if len(req.Messages) == 0 { infra.HTTPErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: "messages required"}) return } baseUserInput := flattenMessages(req.Messages) allCalls := []toolCallInfo{} var finalText string boardChanged := false for iter := 1; iter <= maxChatIterations; iter++ { boardJSON, err := boardSnapshot(db) if err != nil { infra.HTTPErrorResponse(w, infra.HTTPError{Status: 500, Code: "internal", Message: err.Error()}) return } prompt := buildIterationPrompt(baseUserInput, allCalls, iter) assistantText, err := runClaude(r.Context(), chatSystemPrompt, prompt, boardJSON, workdir) if err != nil { infra.HTTPErrorResponse(w, infra.HTTPError{Status: 500, Code: "claude_error", Message: err.Error()}) return } actionsJSON, stripped, found := extractActions(assistantText) if !found { finalText = assistantText break } calls, changed := applyActions(db, actionsJSON, logger) for i := range calls { calls[i].Iteration = iter } allCalls = append(allCalls, calls...) if changed { boardChanged = true } finalText = stripped // tentative; overwritten if next iter responds free text if iter == maxChatIterations { finalText = strings.TrimSpace(stripped + "\n\n_Limite de iteraciones alcanzado._") break } } // Strip Result fields before serializing (not exported but defensive). respCalls := make([]toolCallInfo, len(allCalls)) for i, c := range allCalls { respCalls[i] = toolCallInfo{Tool: c.Tool, OK: c.OK, Error: c.Error, Iteration: c.Iteration} } resp := chatResponse{ Role: "assistant", Content: finalText, ToolCalls: respCalls, BoardChanged: boardChanged, } if resp.Content == "" { resp.Content = summarizeCalls(respCalls) } infra.HTTPJSONResponse(w, http.StatusOK, resp) } } // buildIterationPrompt composes the user prompt for iteration N. // Iteration 1 = original user input; later iterations also include a summary // of previous tool calls so the assistant can use real IDs. func buildIterationPrompt(baseUserInput string, prevCalls []toolCallInfo, iter int) string { if iter == 1 || len(prevCalls) == 0 { return baseUserInput } var b bytes.Buffer b.WriteString(baseUserInput) b.WriteString("\n[Resultados de iteraciones anteriores]\n") for _, c := range prevCalls { if c.OK { summary := summarizeResult(c.Result) fmt.Fprintf(&b, "- iter %d %s: ok %s\n", c.Iteration, c.Tool, summary) } else { fmt.Fprintf(&b, "- iter %d %s: ERROR %s\n", c.Iteration, c.Tool, c.Error) } } fmt.Fprintf(&b, "\n[Iteracion %d] Continua con las acciones pendientes. Si terminaste, responde texto natural sin .\n", iter) return b.String() } func boardSnapshot(db *DB) (string, error) { cols, err := db.ListColumns() if err != nil { return "", err } cards, err := db.ListCardsWithTime() if err != nil { return "", err } b, err := json.MarshalIndent(map[string]any{"columns": cols, "cards": cards}, "", " ") if err != nil { return "", err } return string(b), nil } func applyActions(db *DB, actionsJSON string, logger *ChatLogger) ([]toolCallInfo, bool) { var actions []struct { Tool string `json:"tool"` Input json.RawMessage `json:"input"` } if err := json.Unmarshal([]byte(actionsJSON), &actions); err != nil { return []toolCallInfo{{Tool: "", OK: false, Error: err.Error()}}, false } results := make([]toolCallInfo, 0, len(actions)) changed := false for _, a := range actions { if err := validateToolName(a.Tool); err != nil { info := toolCallInfo{Tool: a.Tool, OK: false, Error: err.Error()} results = append(results, info) logger.Log(a.Tool, a.Input, ToolResult{OK: false, Error: err.Error()}) continue } res := executeTool(db, a.Tool, a.Input) logger.Log(a.Tool, a.Input, res) info := toolCallInfo{Tool: a.Tool, OK: res.OK, Result: res.Result} if !res.OK { info.Error = res.Error } else if toolMutates(a.Tool) { changed = true } results = append(results, info) } return results, changed } func summarizeCalls(calls []toolCallInfo) string { if len(calls) == 0 { return "" } var b bytes.Buffer b.WriteString("Acciones aplicadas:\n") for _, c := range calls { if c.OK { fmt.Fprintf(&b, "- %s: ok\n", c.Tool) } else { fmt.Fprintf(&b, "- %s: error (%s)\n", c.Tool, c.Error) } } return b.String() } // chatWorkdir resolves an absolute working directory for `claude -p` (avoids // inheriting CLAUDE.md from parent directories with unrelated context). func chatWorkdir(dbPath string) string { abs, err := filepath.Abs(dbPath) if err != nil { return "." } return filepath.Dir(abs) }