Files
confi-emacs-actual/configs/init-ai.el

625 lines
27 KiB
EmacsLisp

;;; init-ai.el --- Configuración de inteligencias artificales -*- lexical-binding: t -*-
;; Author: kj <webmaster@outcontrol.net>
;; 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)
;; El "thinking" de los modelos se muestra en los buffers gptel.
;; `ignore' (valor por defecto de gptel) lo muestra pero NO lo reenvía
;; al modelo en turnos posteriores (recomendado: evita llenar el
;; contexto, ver NEWS de gptel). Usar `t' si además se quiere
;; reenviarlo y `nil' para deshabilitarlo (útil para respuestas mas rápidas).
(setopt gptel-include-reasoning 'ignore)
(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-<return>" . 'copilot-accept-completion)
;; ("S-<return>" . 'copilot-accept-completion-by-word)))
(use-package gptel-magit
:ensure t
:hook (magit-mode . gptel-magit-install)
:config
;; gptel-magit no sabe manejar el contenido de "reasoning": gptel entrega
;; el thinking al callback como (reasoning . TEXTO) y gptel-magit lo
;; inserta como si fuera la respuesta final, fallando con
;; Wrong type argument: char-or-string-p, (reasoning . "...")
;; (https://github.com/ragnard/gptel-magit/issues/8). Esto ocurre
;; aunque `gptel-include-reasoning' sea nil, porque gptel entrega las
;; celdas (reasoning . ...) al callback de forma incondicional (solo
;; controla si se insertan en el buffer gptel y si se reenvían).
;; En vez de deshabilitar el thinking globalmente, sanitizamos la
;; respuesta en el callback: se descartan las celdas de reasoning y solo
;; se deja pasar la respuesta final (string). Así el thinking sigue
;; activo en las sesiones gptel normales.
(defun kj-gptel-magit--string-response-callback (callback)
"Return a callback that forwards only string responses to CALLBACK.
Discards the (reasoning . TEXT) cells through which gptel delivers the
thinking content to the callback (see `gptel-request')."
(lambda (response &rest args)
(when (stringp response)
(apply callback response args))))
(defun kj-gptel-magit--sanitize-callback (orig-fn &rest args)
"Around advice for `gptel-magit--request'.
Wraps the :callback argument of ARGS so that responses with reasoning
content do not break gptel-magit."
(let ((pos (cl-position :callback args)))
(when (and pos (functionp (nth (1+ pos) args)))
(setf (nth (1+ pos) args)
(kj-gptel-magit--string-response-callback
(nth (1+ pos) args)))))
(apply orig-fn args))
(advice-add 'gptel-magit--request :around
#'kj-gptel-magit--sanitize-callback))
(use-package gptel-autocomplete
:defer nil
:ensure (:host github :repo "JDNdeveloper/gptel-autocomplete")
:bind (("M-<return>" . gptel-complete)
:map gptel-autocomplete-completion-map
("C-<return>" . 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)
"Directory with the agents and prompts ported from opencode for gptel-agent.")
(defcustom kj-gptel-opencode-small-model nil
"Model for session titles/summaries (must exist in the active backend).
nil uses the current session's model."
:type '(choice (const :tag "Current session model" nil) string)
:group 'gptel)
(defcustom kj-gptel-plans-dir
(expand-file-name "gptel-plans" user-emacs-directory)
"Directory where gptel-agent plans are stored, outside the project.
Plans are named <hash>--<project>--<name>.md, where <hash> is a
truncated md5 of the project root, so plans never pollute the project
directory and can be mapped back to their project."
:type 'directory
: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: could not add AGENTS.md %s" f)))))))
(defun kj-gptel--agent-buffer-setup ()
"Load AGENTS.md rules when a gptel-agent session buffer is created.
Fires for new sessions (buffer *gptel-agent:*) and for sessions
restored from a saved file (gptel--preset of the agent)."
(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)
"Toggle between the default agent (build) and plan mode.
Equivalent to clicking the header button: it invokes the same action
that gptel-agent uses for that button. DATA is ignored (compatibility
with `buttonize')."
(interactive)
(unless (bound-and-true-p gptel-mode)
(user-error "You are not in a gptel session"))
(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 "Mode %s activated" (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 starting 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 "Could not open file %s" path)
(with-current-buffer buf
(if (not (eglot-current-server))
(format "No LSP server for %s (mode %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 "No diagnostics for %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)
"There is no buffer with a connected LSP server in the project."
(condition-case err
(with-current-buffer buf
(let ((res (eglot--workspace-symbols query)))
(if (null res)
(format "No symbols matching \"%s\"." query)
(mapconcat #'substring-no-properties res "\n"))))
(error (format "Error in the LSP query: %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 (e.g. \"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 "Patch applied successfully.\n%s" (cdr r1))
(let ((r0 (kj-gptel--patch-apply diff root 0)))
(if (zerop (car r0))
(format "Patch applied successfully (with -p0).\n%s" (cdr r0))
(format "Could not apply the patch (exit %s).\n%s"
(car r1) (cdr r1)))))))
(defun kj-gptel-plan-write (filename content)
"Write CONTENT to a plan file for the current project and return status.
Plans are stored in `kj-gptel-plans-dir' (outside the project), named
<md5-prefix>--<project>--<name>.md so that each project keeps its own
plans without adding extra files to the project directory."
(let* ((root (kj-gptel--project-root))
(hash (substring (md5 root) 0 12))
(project (replace-regexp-in-string
"[^A-Za-z0-9._-]" "-"
(file-name-nondirectory (directory-file-name root))))
(name (file-name-nondirectory filename))
(file (expand-file-name
(format "%s--%s--%s" hash project name)
kj-gptel-plans-dir)))
(make-directory kj-gptel-plans-dir t)
(with-temp-file file (insert content))
(format "Plan saved to %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 "You are not in a gptel session"))
(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 "Summary generated in *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 "You are not in a gptel session"))
(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 "Title: %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))))
;; Sobrescribir la tool "Eval" para que sea segura: con `inhibit-interaction',
;; las llamadas interactivas (read-*, y-or-n-p, call-interactively, ...)
;; lanzan error en vez de quedarse esperando input y colgar al agente.
(gptel-make-tool
:name "Eval"
:function
(lambda (expression)
(let ((standard-output (generate-new-buffer " *gptel-agent-eval-elisp*"))
(result nil) (output nil))
(unwind-protect
(condition-case err
(progn
(let ((inhibit-interaction t))
(setq result (eval (read expression) t)))
(when (> (buffer-size standard-output) 0)
(setq output (with-current-buffer standard-output (buffer-string))))
(concat
(format "Result:\n%S" result)
(and output (format "\n\nSTDOUT:\n%s" output))))
((error user-error)
(concat
(if (eq (car err) 'inhibited-interaction)
"Error: the expression tried to prompt the user for input interactively, which is blocked in the Eval tool."
(format "Error: eval failed with error %S: %S" (car err) (cdr err)))
(and output (format "\n\nSTDOUT:\n%s" output)))))
(kill-buffer standard-output))))
:description "Evaluate Elisp EXPRESSION and return result and any printed output.
EXPRESSION can be anything to evaluate. It can be a function call, a
variable, a quasi-quoted expression. The only requirement is that only
the first sexp will be read and evaluated, so if you need to evaluate
multiple expressions, make one call per expression. Do not combine
expressions using progn etc. Just go expression by expression and try
to make standalone single expressions.
Interactive operations are blocked: if the expression tries to prompt
for input (read-*, y-or-n-p, call-interactively, etc.) it will error
instead of waiting.
The return value is formatted to a string using %S, so a string will be
returned as an escaped embedded string and literal forms will be
compatible with `read' where possible. Some forms have no printed
representation that can be read and will be represented with
#<hash-notation> instead.
Output from `print', `prin1', and `princ' is captured and returned as STDOUT.
Use `print' for diagnostic output, not `message' (which goes to *Messages*
and is not captured)."
:args '((:name "expression"
:type string
:description "A single elisp sexp to evaluate."))
:category "gptel-agent"
:confirm t
:include t)
(gptel-agent-update)
;; Herramientas del agente (equivalente a las tools de opencode)
(gptel-make-tool
:name "lsp_diagnostics"
:function #'kj-gptel-lsp-diagnostics
:description "Get the LSP diagnostics (errors and warnings) of a file via the language server (eglot).
Use it to know if a file has errors after editing it or to check the state of the code. It takes the file path and returns one line per diagnostic in the format path:line:column [type] message. If the LSP server is not connected for that file, it tries to start it."
:args '((:name "path"
:type string
:description "Path of the file to get diagnostics from."))
:category "gptel-agent"
:include t)
(gptel-make-tool
:name "lsp_symbols"
:function #'kj-gptel-lsp-symbols
:description "Search workspace symbols (functions, classes, variables, etc.) via LSP (eglot).
Useful for locating definitions by name or seeing which symbols exist. It takes a query (pattern) and returns the list of matching symbols in the format \"container name Type\"."
:args '((:name "query"
:type string
:description "Pattern to search in the workspace symbols.")
(:name "path"
:type string
:description "Path of a project file whose LSP server to use (optional)."
:optional t))
:category "gptel-agent"
:include t)
(gptel-make-tool
:name "apply_patch"
:function #'kj-gptel-apply-patch
:description "Apply a unified diff to the project files.
Takes a unified diff (git format or --- a/ / +++ b/). It is applied with the patch tool (tries -p1 and then -p0). Use it for large edits or edits that touch multiple files, instead of multiple Edit calls. Modifies project files."
:args '((:name "diff"
:type string
:description "The unified diff to apply, with file headers (--- a/... and +++ b/...)."))
:category "gptel-agent"
:confirm t
:include t)
(gptel-make-tool
:name "plan_write"
:function #'kj-gptel-plan-write
:description "Save the current plan as a Markdown file in the Emacs directory, outside the project.
Use it when you have drafted a plan, to persist it to disk and be able to resume it later. Plans are stored in `kj-gptel-plans-dir' (inside the Emacs directory) named <hash>--<project>--<name>.md, where <hash> is a truncated md5 of the project root, so each project keeps its own plans without adding files to the project directory. Does not modify any project file."
:args '((:name "filename"
:type string
:description "Name of the plan file (e.g. refactor-auth.md).")
(:name "content"
:type string
:description "Markdown content of the 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