;;; init-ai.el --- Configuración de inteligencias artificales -*- lexical-binding: t -*- ;; Author: kj ;; URL: https://git.kj2.me/kj/confi-emacs-actual ;;; Commentary: ;; Configuración para Inteligencia artifical en Emacs. ;;; Code: ;; Cliente LLM (ollama, chatgpt, gemini, etc.) (use-package gptel :defer nil :config (when (getenv "GEMINI_KEY") (gptel-make-gemini "Gemini" :key (getenv "GEMINI_KEY") :stream t)) (gptel-make-openai "llama.cpp" :stream t :protocol "http" :host "127.0.0.1:1945" :models '("Qwen2.5-Coder" "Qwen3.5-0.8B" "Qwen3.5-4B" "Qwen3.5-9B" "Qwen3.6-27B" "Qwen3Coder-Q2" "Qwen3Coder" "QwenSeek-2B" "gemma-4-12B" "gemma-4-26B-Q2" "gemma-4-26B" "gemma-4-31B-Q2" "gemma-4-31B" "gemma-4-E2B" "gemma-4-E4B-UNCENSORED" "gemma-4-E4B" "gptoss-20b")) (gptel-make-ollama "Ollama" :host "localhost:11434" :stream t :request-params '(:think :json-false) :models '("gemma4:31b-cloud" "glm-5.1:cloud" "kimi-k2.6:cloud" "gpt-oss:20b-cloud" "minimax-m2.5:cloud" "qwen3-coder-next:cloud")) ;; Backend opencode-go: suscripción OpenCode Go vía endpoint OpenAI-compatible (defun kj-opencode-go-key () "Return the OpenCode Go API key from opencode's auth file." (let ((auth (with-temp-buffer (insert-file-contents "~/.local/share/opencode/auth.json") (json-parse-buffer :object-type 'alist)))) (alist-get 'key (alist-get 'opencode-go auth)))) (gptel-make-openai "OpenCode Go" :host "opencode.ai" :endpoint "/zen/go/v1/chat/completions" :key #'kj-opencode-go-key :stream t :models '(minimax-m3 minimax-m2.7 minimax-m2.5 kimi-k3 kimi-k2.7-code kimi-k2.6 kimi-k2.5 glm-5.2 glm-5.1 glm-5 deepseek-v4-pro deepseek-v4-flash qwen3.7-max qwen3.8-max qwen3.7-plus qwen3.6-plus qwen3.5-plus mimo-v2-pro mimo-v2-omni mimo-v2.5-pro mimo-v2.5 hy3 hy3-preview gpt-5.6-luna grok-4.5)) ;; Modelos gratuitos de OpenCode Go, servidos vía el endpoint /zen/v1 (gptel-make-openai "OpenCode Free" :host "opencode.ai" :endpoint "/zen/v1/chat/completions" :key #'kj-opencode-go-key :stream t :models '(big-pickle longcat-2.0-free deepseek-v4-flash-free mimo-v2.5-free north-mini-code-free nemotron-3-ultra-free ling-3.0-flash-free laguna-s-2.1-free)) ;; Backend y modelo por defecto (setq gptel-backend (gptel-get-backend "OpenCode Go") gptel-model 'deepseek-v4-flash) (setq gptel-default-mode 'markdown-mode gptel-prompt-prefix-alist '((markdown-mode . "# User\n\n") (org-mode . "* User\n\n") (text-mode . "# User\n\n")) gptel-response-prefix-alist '((markdown-mode . "# AI\n\n") (org-mode . "* AI\n\n") (text-mode . "# AI\n\n")) gptel-directives '((default . "You are a large language model living in Emacs and a helpful assistant. Respond concisely in Spanish.")) ) (add-hook 'gptel-post-response-functions 'gptel-end-of-response) (setopt gptel-include-reasoning nil) ;; Mantener hasta resolver: https://github.com/ragnard/gptel-magit/issues/8 (defun gptel-switch+model () "Switch to gptel backend and model in a single completion prompt." (interactive) (let (choices) (dolist (pair gptel--known-backends) (let* ((backend-name (car pair)) (backend (cdr pair)) (models (and (fboundp 'gptel-backend-models) (gptel-backend-models backend)))) (when models (dolist (model models) (push (cons ; (format "%s:%s" backend-name model) (format "%s → %s" (propertize backend-name 'face 'font-lock-keyword-face) (propertize (symbol-name model) 'face 'font-lock-function-name-face)) (cons backend-name model)) choices))))) (let* ((choice (completing-read "Model: " (mapcar #'car choices) nil t)) (sel (cdr (assoc choice choices)))) (setq gptel-backend (cdr (assoc (car sel) gptel--known-backends)) gptel-model (cdr sel)) (message "gptel set to %s:%s" (car sel) (cdr sel))))) ;; Al cargar gptel, cargar gptel-agent para que sus presets estén ;; disponibles (p. ej. al reabrir una sesión guardada de agente). (require 'gptel-agent) ) ;; (use-package copilot ;; :hook (prog-mode . copilot-mode) ;; :bind (:map copilot-completion-map ;; ("C-g" . 'copilot-clear-overlay) ;; ("C-" . 'copilot-accept-completion) ;; ("S-" . 'copilot-accept-completion-by-word))) (use-package gptel-magit :ensure t :hook (magit-mode . gptel-magit-install)) (use-package gptel-autocomplete :defer nil :ensure (:host github :repo "JDNdeveloper/gptel-autocomplete") :bind (("M-" . gptel-complete) :map gptel-autocomplete-completion-map ("C-" . gptel-accept-completion))) (use-package gptel-agent :ensure t :defer nil :init (defvar kj-gptel-agents-dir (expand-file-name "configs/gptel-agents" user-emacs-directory) "Directorio con los agentes y prompts portados de opencode para gptel-agent.") (defcustom kj-gptel-opencode-small-model nil "Modelo para títulos/resúmenes de sesión (debe existir en el backend activo). nil usa el modelo actual de la sesión." :type '(choice (const :tag "Modelo actual de la sesión" nil) string) :group 'gptel) (defun kj-gptel-load-agents-md () "Add AGENTS.md rules to the current gptel buffer context. Loads AGENTS.md from the project root and from `user-emacs-directory', if they exist." (interactive) (require 'gptel-context nil t) (let ((root (or (when-let* ((proj (project-current))) (project-root proj)) (vc-root-dir) default-directory)) (seen '())) (dolist (f (list (expand-file-name "AGENTS.md" root) (expand-file-name "AGENTS.md" user-emacs-directory))) (when (and (file-readable-p f) (not (member f seen))) (push f seen) (condition-case nil (gptel-context-add-file f) (error (message "kj-gptel: no se pudo añadir AGENTS.md %s" f))))))) (defun kj-gptel--agent-buffer-setup () "Load AGENTS.md rules when a gptel-agent session buffer is created. Dispara para sesiones nuevas (buffer *gptel-agent:*) y para sesiones restauradas desde un archivo guardado (gptel--preset de agente)." (when (and (bound-and-true-p gptel-mode) (or (string-prefix-p "*gptel-agent" (buffer-name)) (and (boundp 'gptel--preset) (memq gptel--preset '(gptel-agent gptel-plan))))) (kj-gptel-load-agents-md))) (defun kj-gptel--header-button-action () "Return the action function of the `[Agent]' / `[Plan]' header button, or nil. Invoking it is exactly what clicking the button does. The button lives in gptel-agent's header, rendered as (:eval (funcall DISPLAY))." (let* ((elt (and (listp header-line-format) (car header-line-format))) (expr (and (listp elt) (eq (car elt) :eval) (cadr elt))) (fn (and (listp expr) (eq (car expr) 'funcall) (cadr expr))) (str (and (functionp fn) (funcall fn)))) (or (and (stringp str) (catch 'found (dotimes (i (length str)) (let ((action (get-text-property i 'action str))) (when (functionp action) (throw 'found action)))))) ;; Fallback: render vía `format-mode-line' (let ((str2 (ignore-errors (format-mode-line header-line-format)))) (and (stringp str2) (catch 'found (dotimes (i (length str2)) (let ((action (get-text-property i 'action str2))) (when (functionp action) (throw 'found action)))))))))) (defun kj-gptel-toggle-plan-agent (&optional _data) "Alternar entre el agente por defecto (build) y el modo plan. Equivale a hacer clic en el botón del header: invoca la misma acción que gptel-agent usa para ese botón. DATA se ignora (compatibilidad con `buttonize')." (interactive) (unless (bound-and-true-p gptel-mode) (user-error "No estás en una sesión gptel")) (let ((action (kj-gptel--header-button-action))) (if action (funcall action nil) ; exactamente lo que hace el clic ;; Sin header disponible: aplicamos directamente el toggle (gptel--apply-preset (if (eq gptel--preset 'gptel-plan) 'gptel-agent 'gptel-plan) (lambda (sym val) (set (make-local-variable sym) val))))) (message "Modo %s activado" (if (eq gptel--preset 'gptel-plan) "plan" "build"))) (defun kj-gptel--eglot-start () "Start eglot immediately for the current buffer if it is not managed. Unlike `eglot-ensure', this connects right away instead of waiting for a `post-command-hook', so gptel tools can use LSP on their first call." (require 'eglot) (unless eglot--managed-mode (condition-case err (apply #'eglot--connect (eglot--guess-contact)) (error (message "kj-gptel: error iniciando eglot: %s" (error-message-string err)))))) (defun kj-gptel--lsp-buffer (&optional path) "Return a buffer with a live eglot server for PATH. When PATH is nil, return any buffer visiting a file with a live server, preferring one in the current project." (if path (let ((buf (or (find-buffer-visiting path) (and (file-exists-p path) (find-file-noselect path))))) (when buf (with-current-buffer buf (unless (eglot-current-server) (kj-gptel--eglot-start)) buf))) (let* ((root (and-let* ((proj (project-current))) (project-root proj))) (in-project (cl-find-if (lambda (b) (with-current-buffer b (and (eglot-current-server) (buffer-file-name b) root (file-in-directory-p (buffer-file-name b) root)))) (buffer-list))) (any (cl-find-if (lambda (b) (with-current-buffer b (and (eglot-current-server) (buffer-file-name b)))) (buffer-list)))) (or in-project any)))) (defun kj-gptel-lsp-diagnostics (path) "Return LSP diagnostics (errors/warnings) for the file at PATH. Uses eglot and flymake; starts the language server if needed." (require 'flymake nil t) (let ((buf (kj-gptel--lsp-buffer path))) (if (not buf) (format "No se pudo abrir el archivo %s" path) (with-current-buffer buf (if (not (eglot-current-server)) (format "Sin servidor LSP para %s (modo %s)." path major-mode) (let ((deadline (+ (float-time) 5))) (while (and (< (float-time) deadline) (null (flymake-diagnostics (point-min) (point-max)))) (sit-for 0.2))) (when (null (flymake-diagnostics (point-min) (point-max))) ;; Forzar la recolección de diagnósticos del servidor (ignore-errors (flymake-start)) (let ((deadline (+ (float-time) 5))) (while (and (< (float-time) deadline) (null (flymake-diagnostics (point-min) (point-max)))) (sit-for 0.2)))) (let ((diags (flymake-diagnostics (point-min) (point-max)))) (if (null diags) (format "Sin diagnósticos para %s." path) (mapconcat (lambda (d) (let ((beg (flymake-diagnostic-beg d)) (type (flymake-diagnostic-type d))) (save-excursion (goto-char beg) (format "%s:%d:%d [%s] %s" path (line-number-at-pos) (1+ (current-column)) (or type "unknown") (flymake-diagnostic-text d))))) diags "\n")))))))) (defun kj-gptel-lsp-symbols (query &optional path) "Search workspace symbols matching QUERY via LSP (eglot)." (require 'eglot) (let ((buf (kj-gptel--lsp-buffer path))) (if (not buf) "No hay un buffer con servidor LSP conectado en el proyecto." (condition-case err (with-current-buffer buf (let ((res (eglot--workspace-symbols query))) (if (null res) (format "Sin símbolos que coincidan con \"%s\"." query) (mapconcat #'substring-no-properties res "\n")))) (error (format "Error en la consulta LSP: %S" err)))))) (defun kj-gptel--project-root () "Return the current project root, or `default-directory'." (or (when-let* ((proj (project-current))) (project-root proj)) (vc-root-dir) default-directory)) (defun kj-gptel--read-prompt (name) "Read the opencode prompt file NAME (p. ej. \"title.txt\") from the agents dir." (string-trim (with-temp-buffer (insert-file-contents (expand-file-name (format "prompts/%s" name) kj-gptel-agents-dir)) (buffer-string)))) (defun kj-gptel--patch-apply (diff root level) "Apply DIFF in ROOT with `patch -pLEVEL'. Return (EXIT . OUTPUT)." (let* ((tmp (make-temp-file "gptel-patch-" nil ".diff")) (default-directory root)) (with-temp-file tmp (insert diff)) (let ((out (shell-command-to-string (format "patch --batch --forward --fuzz=3 -p%d < %s 2>&1; echo PATCH_EXIT=$?" level tmp)))) (delete-file tmp) (if (string-match "PATCH_EXIT=\\([0-9]+\\)" out) (cons (string-to-number (match-string 1 out)) (replace-regexp-in-string "PATCH_EXIT=[0-9]+" "" out)) (cons -1 out))))) (defun kj-gptel-apply-patch (diff) "Apply the unified DIFF to the project and return the result. Tries `patch -p1' first and falls back to `-p0'." (let* ((root (kj-gptel--project-root)) (r1 (kj-gptel--patch-apply diff root 1))) (if (zerop (car r1)) (format "Parche aplicado correctamente.\n%s" (cdr r1)) (let ((r0 (kj-gptel--patch-apply diff root 0))) (if (zerop (car r0)) (format "Parche aplicado correctamente (con -p0).\n%s" (cdr r0)) (format "No se pudo aplicar el parche (exit %s).\n%s" (car r1) (cdr r1))))))) (defun kj-gptel-plan-write (filename content) "Write CONTENT to `.gptel/plans/FILENAME' in the project. Return status." (let* ((root (kj-gptel--project-root)) (dir (expand-file-name ".gptel/plans" root)) (file (expand-file-name (file-name-nondirectory filename) dir))) (make-directory dir t) (with-temp-file file (insert content)) (format "Plan guardado en %s" file))) (defun kj-gptel-opencode-summary () "Generate a PR-style summary of the current gptel session (prompt summary.txt)." (interactive) (unless (bound-and-true-p gptel-mode) (user-error "El buffer actual no es una sesión gptel")) (let* ((buf (current-buffer)) (transcript (buffer-substring-no-properties (point-min) (point-max))) (out (get-buffer-create "*opencode-summary*"))) (with-current-buffer out (let ((inhibit-read-only t)) (erase-buffer)) (gptel-mode 1) (setq-local gptel-backend (with-current-buffer buf gptel-backend) gptel-model (or kj-gptel-opencode-small-model (with-current-buffer buf gptel-model)))) (display-buffer out) (gptel-request transcript :buffer out :stream t :system (kj-gptel--read-prompt "summary.txt") :callback (lambda (_info &rest _) (message "Resumen generado en *opencode-summary*"))))) (defun kj-gptel-opencode-title () "Generate a title for the current gptel session (prompt title.txt)." (interactive) (unless (bound-and-true-p gptel-mode) (user-error "El buffer actual no es una sesión gptel")) (let* ((buf (current-buffer)) (transcript (buffer-substring-no-properties (point-min) (point-max))) (tmp (generate-new-buffer " *gptel-title*"))) (with-current-buffer tmp (gptel-mode 1) (setq-local gptel-backend (with-current-buffer buf gptel-backend) gptel-model (or kj-gptel-opencode-small-model (with-current-buffer buf gptel-model)))) (gptel-request transcript :buffer tmp :stream nil :system (kj-gptel--read-prompt "title.txt") :callback (lambda (_info &rest _) (let ((title (string-trim (with-current-buffer tmp (buffer-string))))) (kill-buffer tmp) (with-current-buffer buf (rename-buffer (format "*gptel-agent:%s*" title) t)) (message "Título: %s" title)))))) :custom (kj-gptel-opencode-small-model nil) :bind (("C-c g" . gptel-agent) :map gptel-mode-map ("C-c C-a" . kj-gptel-toggle-plan-agent)) :hook (gptel-mode . kj-gptel--agent-buffer-setup) :config (add-to-list 'gptel-agent-dirs kj-gptel-agents-dir t) (setq gptel-agent-compact-prompt (string-trim (with-temp-buffer (insert-file-contents (expand-file-name "prompts/compaction.txt" kj-gptel-agents-dir)) (buffer-string)))) ;; En modo agente, que no pidan confirmación las tools de uso frecuente ;; (comandos y escritura de archivos); Eval y Agent siguen confirmando. (dolist (name '("Bash" "Edit" "Write" "Insert" "Mkdir")) (let ((tool (gptel-get-tool name))) (when tool (setf (gptel-tool-confirm tool) nil)))) (gptel-agent-update) ;; Herramientas del agente (equivalente a las tools de opencode) (gptel-make-tool :name "lsp_diagnostics" :function #'kj-gptel-lsp-diagnostics :description "Obtener los diagnósticos LSP (errores y advertencias) de un archivo, vía el servidor de lenguaje (eglot). Úsalo para saber si un archivo tiene errores tras editarlo o para comprobar el estado del código. Recibe la ruta del archivo y devuelve una línea por diagnóstico con formato ruta:línea:columna [tipo] mensaje. Si el servidor LSP no está conectado para ese archivo, intenta iniciarlo." :args '((:name "path" :type string :description "Ruta del archivo del que obtener los diagnósticos.")) :category "gptel-agent" :include t) (gptel-make-tool :name "lsp_symbols" :function #'kj-gptel-lsp-symbols :description "Buscar símbolos (funciones, clases, variables, etc.) en el workspace mediante LSP (eglot). Útil para localizar definiciones por nombre o ver qué símbolos existen. Recibe una consulta (patrón) y devuelve la lista de símbolos coincidentes en formato \"contenedor nombre Tipo\"." :args '((:name "query" :type string :description "Patrón a buscar en los símbolos del workspace.") (:name "path" :type string :description "Ruta de un archivo del proyecto cuyo servidor LSP usar (opcional)." :optional t)) :category "gptel-agent" :include t) (gptel-make-tool :name "apply_patch" :function #'kj-gptel-apply-patch :description "Aplicar un diff unificado a los archivos del proyecto. Recibe un diff unificado (formato git o --- a/ / +++ b/). Se aplica con la herramienta patch (intenta -p1 y luego -p0). Úsalo para ediciones grandes o que tocan varios archivos, en lugar de múltiples llamadas a Edit. Modifica archivos del proyecto." :args '((:name "diff" :type string :description "El diff unificado a aplicar, con cabeceras de archivo (--- a/... y +++ b/...).")) :category "gptel-agent" :confirm t :include t) (gptel-make-tool :name "plan_write" :function #'kj-gptel-plan-write :description "Guardar el plan actual como archivo Markdown en .gptel/plans/ del proyecto. Úsalo cuando hayas elaborado un plan, para persistirlo en disco y poder retomarlo después. Guarda el contenido en .gptel/plans/.md. No modifica ningún otro archivo." :args '((:name "filename" :type string :description "Nombre del archivo del plan (p. ej. refactor-auth.md).") (:name "content" :type string :description "Contenido Markdown del plan.")) :category "gptel-agent" :confirm t :include t)) (use-package macher :ensure (:host github :repo "kmontag/macher") :custom (macher-action-buffer-ui 'org)) (provide 'init-ai) ;;; init-ai.el ends here