feat(ai): port opencode agents and tools for gptel-agent
This commit is contained in:
@@ -124,6 +124,10 @@
|
||||
(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
|
||||
@@ -146,7 +150,354 @@
|
||||
|
||||
(use-package gptel-agent
|
||||
:ensure t
|
||||
:config (gptel-agent-update))
|
||||
: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/<nombre>.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")
|
||||
|
||||
Reference in New Issue
Block a user