commit 53b6364d5d04cee453e08bdb4300c821032b5a18 from: mtmn date: Wed Jul 22 18:20:23 2026 UTC gptel and other goodies for literate setup commit - 01d0687853d8d8f5b6330e1f4868ab44f4949c9e commit + 53b6364d5d04cee453e08bdb4300c821032b5a18 blob - 64f55fd228a5a0582d8c89894c334ad7cce4f0e9 blob + 3ead197b17b7252ef3b872d0955548377ea4a1e2 --- config.org +++ config.org @@ -72,14 +72,16 @@ (error "Emacs configuration only works with Emacs 29 and newer; you have version %s" emacs-major-version)) - (setopt initial-major-mode 'fundamental-mode) - (setopt inhibit-startup-screen t) - (setopt initial-scratch-message nil) - (setopt display-time-default-load-average nil) + (setopt initial-major-mode 'fundamental-mode + inhibit-startup-screen t + initial-scratch-message nil + display-time-default-load-average nil + sentence-end-double-space nil + use-short-answers t) - (setopt auto-revert-avoid-polling t) - (setopt auto-revert-interval 5) - (setopt auto-revert-check-vc-info t) + (setopt auto-revert-avoid-polling t + auto-revert-interval 5 + auto-revert-check-vc-info t) (global-auto-revert-mode 1) (savehist-mode 1) @@ -87,30 +89,34 @@ (windmove-default-keybindings 'control) - (setopt sentence-end-double-space nil) - (when (display-graphic-p) (context-menu-mode -1) (tool-bar-mode -1) (menu-bar-mode -1) - (scroll-bar-mode -1) - (set-frame-parameter nil 'font "Go Mono-14")) + (scroll-bar-mode -1)) + ;; Use Go Mono everywhere. `default' is set directly here; `fixed-pitch' + ;; and `variable-pitch' must be pointed at it explicitly so that e.g. + ;; `variable-pitch-mode' in org-mode still renders in Go Mono. + (set-face-attribute 'default nil :family "Go Mono" :height 140) + (set-face-attribute 'fixed-pitch nil :family "Go Mono" :height 1.0) + (set-face-attribute 'variable-pitch nil :family "Go Mono" :height 1.0) + (unless (display-graphic-p) (xterm-mouse-mode 1)) (cua-mode 1) - (setopt display-line-numbers-width 3) + (keymap-set global-map "C-x k" #'kill-current-buffer) + (add-hook 'before-save-hook #'whitespace-cleanup) + + (setopt display-line-numbers-width 3 + tab-bar-show 1) (add-hook 'prog-mode-hook #'display-line-numbers-mode) (add-hook 'text-mode-hook #'visual-line-mode) (dolist (hook '(text-mode-hook prog-mode-hook)) (add-hook hook #'hl-line-mode)) - (setopt tab-bar-show 1) - - ;; Start the Emacs server if it is not already running so `emacsclient' can - ;; attach to this session. Remove the server socket when this Emacs exits. (require 'server) (unless (server-running-p) (server-start)) @@ -135,8 +141,8 @@ (lock-dir (expand-file-name "locks/" "~/.cache/emacs"))) (make-directory auto-save-dir t) (make-directory lock-dir t) - (setopt auto-save-file-name-transforms `((".*" ,auto-save-dir t))) - (setopt lock-file-name-transforms `((".*" ,lock-dir t)))) + (setopt auto-save-file-name-transforms `((".*" ,auto-save-dir t)) + lock-file-name-transforms `((".*" ,lock-dir t)))) #+end_src * UI @@ -157,28 +163,49 @@ :defer 1 :config (which-key-mode)) + (use-package smart-mode-line + :straight t + :demand t + :custom + (sml/no-confirm-load-theme t) + (sml/theme 'respectful) + :config + (sml/setup)) + + (use-package minions + :straight t + :demand t + :config + (minions-mode)) + + ;; Lets `:diminish' hide minor-mode lighters from the mode line. + (use-package diminish + :straight t + :demand t) + (add-to-list 'tab-bar-format 'tab-bar-format-align-right 'append) (add-to-list 'tab-bar-format 'tab-bar-format-global 'append) - (setopt display-time-format "%a %F %T") - (setopt display-time-interval 1) + (setopt display-time-format "%a %F %T" + display-time-interval 1) (display-time-mode 1) #+end_src * Scrolling #+begin_src emacs-lisp - (setopt mouse-wheel-tilt-scroll t) - (setopt mouse-wheel-flip-direction t) - (setopt mouse-wheel-scroll-amount '(1 ((shift) . 5) ((control) . 0))) - (setopt mouse-wheel-progressive-speed nil) + (setopt mouse-wheel-tilt-scroll t + mouse-wheel-flip-direction t + mouse-wheel-scroll-amount '(1 ((shift) . 5) ((control) . 0)) + mouse-wheel-progressive-speed nil) + (setopt scroll-margin 4 + scroll-conservatively 101 + scroll-up-aggressively 0.01 + scroll-down-aggressively 0.01 + auto-window-vscroll nil) + (blink-cursor-mode -1) (pixel-scroll-precision-mode 1) - (setopt scroll-margin 4) - (setopt scroll-conservatively 101) - (setopt scroll-up-aggressively 0.01) - (setopt scroll-down-aggressively 0.01) - (setopt auto-window-vscroll nil) #+end_src * Appearance @@ -206,9 +233,9 @@ :straight (circadian :type git :host github :repo "guidoschmidt/circadian.el") :demand t :config - (setopt calendar-latitude 48.1386) - (setopt calendar-longitude 17.1053) - (setopt circadian-themes '((:sunrise . ef-orange) + (setopt calendar-latitude 48.1386 + calendar-longitude 17.1053 + circadian-themes '((:sunrise . ef-orange) (:sunset . modus-vivendi))) (circadian-setup)) #+end_src @@ -216,8 +243,8 @@ * Completion #+begin_src emacs-lisp - (setopt completion-cycle-threshold 3) - (setopt tab-always-indent 'complete) + (setopt completion-cycle-threshold 3 + tab-always-indent 'complete) (keymap-set minibuffer-mode-map "TAB" #'minibuffer-complete) @@ -414,14 +441,28 @@ :straight t :after eshell :custom - (eat-term-name "xterm") + ;; A universally-installed TERM, so shells eat spawns never look up the + ;; bundled `eat-truecolor' entry (which `tput' fails to find otherwise). + (eat-term-name "xterm-256color") :config (eat-eshell-mode) (eat-eshell-visual-command-mode)) #+end_src -* Development core +#+begin_src emacs-lisp + (use-package tramp + :straight (:type built-in) + :defer t + :custom + (tramp-default-method "ssh") + (tramp-verbose 2) + (remote-file-name-inhibit-cache 60) + :config + (add-to-list 'tramp-remote-path 'tramp-own-remote-path)) +#+end_src +* Core + #+begin_src emacs-lisp (use-package treesit-auto :straight t @@ -443,7 +484,7 @@ :bind (("C-x g" . magit-status))) #+end_src -* Language modes +* Languages #+begin_src emacs-lisp (use-package yaml-mode @@ -486,6 +527,13 @@ :config (fset #'jsonrpc--log-event #'ignore)) #+end_src +#+begin_src emacs-lisp + ;; GC and the file-name handler are restored on `emacs-startup-hook' + ;; (see early-init). Larger process reads make eglot/LSP feel snappier. + (setopt read-process-output-max (* 4 1024 1024) + process-adaptive-read-buffering nil) +#+end_src + * Templates #+begin_src emacs-lisp @@ -546,7 +594,7 @@ :after denote) #+end_src -* Org +* org-mode #+begin_src emacs-lisp (setopt org-directory (expand-file-name "~/.nb/notes") @@ -679,6 +727,28 @@ (keymap-set denote-prefix-map "t" #'org-agenda-list) (keymap-set denote-prefix-map "c" #'org-capture) + (defvar-keymap gptel-prefix-map + :doc "Prefix map for gptel commands.") + (keymap-set global-map "C-c L" gptel-prefix-map) + (keymap-set gptel-prefix-map "s" #'gptel-send) + (keymap-set gptel-prefix-map "m" #'gptel-menu) + (keymap-set gptel-prefix-map "r" #'gptel-rewrite) + (keymap-set gptel-prefix-map "a" #'gptel-add) + (keymap-set gptel-prefix-map "A" #'gptel-add-file) + (keymap-set gptel-prefix-map "p" #'gptel-preset) + (keymap-set gptel-prefix-map "t" #'gptel-tools) + (keymap-set gptel-prefix-map "n" #'mine/gptel-denote-session) + + (defvar-keymap devops-prefix-map + :doc "Prefix map for devops / container / shell commands.") + (keymap-set global-map "C-c d" devops-prefix-map) + (keymap-set devops-prefix-map "d" #'docker) + (keymap-set devops-prefix-map "c" #'docker-compose) + (keymap-set devops-prefix-map "s" #'shell-command) + (keymap-set devops-prefix-map "b" #'org-babel-execute-buffer) + (keymap-set devops-prefix-map "B" #'org-babel-execute-subtree) + (keymap-set devops-prefix-map "t" #'ob-tmux-open) + (with-eval-after-load 'corfu (keymap-set corfu-map "C-b" #'corfu-scroll-down) (keymap-set corfu-map "C-f" #'corfu-scroll-up) @@ -725,11 +795,434 @@ ("C-c t s" . tmr-stop))) #+end_src -* Finalize +* gptel +LLMs in Emacs. A single backend talks to Ollama Cloud over its +OpenAI-compatible endpoint; the API key is read from `auth-source' +(default `~/.authinfo.gpg'), so no secret lives in this repository. On +top of that we register web, filesystem and Emacs tools, and a set of +presets (`@research', `@agent', per-model `@qwen' …) selectable per +request. + +** Denote sessions and Ollama helpers + #+begin_src emacs-lisp - ;; GC and the file-name handler are restored on `emacs-startup-hook' - ;; (see early-init). Larger process reads make eglot/LSP feel snappier. - (setopt read-process-output-max (* 4 1024 1024) - process-adaptive-read-buffering nil) + (defun mine/gptel-denote-session () + "Create a Denote note and start a gptel session in it." + (interactive) + (let ((title (read-string "GPTel session title: "))) + (denote title '("ai" "gptel")) + (gptel-mode))) + + (defun mine/gptel-auto-save (_beg _end) + "Save current gptel buffer after each response, if it is a Denote note. +Called from `gptel-post-response-functions', which passes the +response beginning and end positions; we ignore both." + (when (and buffer-file-name + (derived-mode-p 'org-mode) + (string-prefix-p (expand-file-name denote-directory) + (file-name-directory buffer-file-name))) + (save-buffer))) + + (add-hook 'gptel-post-response-functions #'mine/gptel-auto-save) + + ;; --- Ollama Cloud REST helpers (shared by the gptel web tools) ------- + + (defun mine/ollama-api-key () + "Return the Ollama Cloud API key stored in auth-source. +Expects a line in `auth-sources' for host ollama.com, user apikey." + (or (auth-source-pick-first-password :host "ollama.com" :user "apikey") + (user-error "No Ollama API key in auth-source for host ollama.com"))) + + (defun mine/ollama-api-post (path payload) + "POST PAYLOAD (an alist) as JSON to Ollama API PATH, return parsed JSON. +Signals an error on a non-2xx response. Runs synchronously." + (let* ((url-request-method "POST") + (url-request-extra-headers + `(("Content-Type" . "application/json") + ("Authorization" . ,(concat "Bearer " (mine/ollama-api-key))))) + (url-request-data (encode-coding-string (json-encode payload) 'utf-8)) + (buf (url-retrieve-synchronously (concat "https://ollama.com" path) + 'silent nil 60))) + (unless buf (error "Ollama API request to %s timed out" path)) + (unwind-protect + (with-current-buffer buf + (goto-char (point-min)) + (let ((status (and (re-search-forward "^HTTP/[0-9.]+ \\([0-9]+\\)" nil t) + (string-to-number (match-string 1))))) + (unless (re-search-forward "\n\n" nil t) + (error "Malformed response from Ollama API")) + (let* ((json-object-type 'alist) + (json-array-type 'list) + (json-key-type 'symbol) + (body (json-read))) + (when (and status (>= status 400)) + (error "Ollama API %s failed (HTTP %d): %s" path status + (or (alist-get 'error body) body))) + body))) + (kill-buffer buf)))) #+end_src + +** Client, backend, tools and presets + +#+begin_src emacs-lisp + (use-package gptel + :straight (gptel :type git :host github :repo "karthink/gptel") + :demand t + :bind (:map gptel-mode-map + ("C-c RET" . gptel-send) + ("C-c C-r" . gptel-rewrite) + ("C-c C-a" . gptel-add)) + :custom + (gptel-use-curl t) + (gptel-stream t) + (gptel-default-mode 'org-mode) + (gptel-use-header-line t) + (gptel-org-ignore-elements '(property-drawer)) + (gptel-directives + '((default . "You are a large language model living in Emacs and a helpful assistant. Respond concisely.") + (programming . "You are a careful programmer working from inside Emacs. Provide code, with minimal surrounding prose.") + (reviewing . "You are reviewing code or text in Emacs. Be terse, point out concrete issues, and suggest fixes.") + (literate . "You are helping with a literate configuration in an Org file. Prefer Org markup and explain only what is non-obvious.") + (chat . "You are a conversation partner. Respond concisely."))) + (gptel-use-tools t) + (gptel-confirm-tool-calls 'auto) + (gptel-include-tool-results 'auto) + :config + ;; `gptel-ollama' is loaded only so `ob-llm' keeps working; the backend + ;; itself is the OpenAI-compatible Ollama Cloud endpoint below. + (require 'gptel-ollama) + (require 'gptel-openai) + (gptel-make-openai "Ollama Cloud" + :host "ollama.com" + :protocol "https" + :endpoint "/v1/chat/completions" + :stream t + :key (lambda () + (auth-source-pick-first-password + :host "ollama.com" :user "apikey")) + :models '(kimi-k2.7-code:cloud + kimi-k2.6:cloud + qwen3.5:cloud + glm-5.2:cloud + glm-5.1:cloud + gpt-oss:120b-cloud + minimax-m3:cloud + minimax-m2.7:cloud)) + + (setopt gptel-backend (gptel-get-backend "Ollama Cloud") + gptel-model 'kimi-k2.7-code:cloud) + + ;; Tools: web access via Ollama Cloud's web API, plus filesystem/buffer/ + ;; command access. Only `run_command' and `obscura_scrape' confirm first. + + (gptel-make-tool + :name "web_search" + :category "web" + :description (concat "Search the web and return a list of results, each with a " + "title, URL and a content snippet. Use this for current " + "events or anything outside your training data, then call " + "`web_fetch' on a promising URL to read it in full.") + :args (list '(:name "query" :type string + :description "The search query.") + '(:name "max_results" :type integer :optional t + :description "How many results to return (1-10, default 5).")) + :function + (lambda (query &optional max_results) + (let* ((payload `(("query" . ,query) + ,@(when (and (integerp max_results) (> max_results 0)) + `(("max_results" . ,(min max_results 10)))))) + (results (alist-get 'results (mine/ollama-api-post "/api/web_search" payload)))) + (if (null results) + "No results found." + (mapconcat + (lambda (r) + (format "## %s\n%s\n\n%s" + (alist-get 'title r) (alist-get 'url r) (alist-get 'content r))) + results "\n\n---\n\n"))))) + + (gptel-make-tool + :name "web_fetch" + :category "web" + :description "Fetch and return the main text content of a web page, given its URL." + :args (list '(:name "url" :type string + :description "The full URL of the page to fetch.")) + :function + (lambda (url) + (let* ((resp (mine/ollama-api-post "/api/web_fetch" `(("url" . ,url)))) + (content (alist-get 'content resp))) + (if (and (stringp content) (not (string-empty-p content))) + (format "# %s\n\n%s" (or (alist-get 'title resp) url) content) + "Could not extract any content from that URL.")))) + + (gptel-make-tool + :name "read_url" + :category "web" + :description (concat "Fetch a URL directly (no API) and return its rendered text. " + "A fallback for `web_fetch'.") + :args (list '(:name "url" :type string :description "The URL to read.")) + :function + (lambda (url) + (with-current-buffer (url-retrieve-synchronously url t t 30) + (goto-char (point-min)) + (forward-paragraph) + (let ((dom (libxml-parse-html-region (point) (point-max)))) + (run-at-time 0 nil #'kill-buffer (current-buffer)) + (with-temp-buffer + (shr-insert-document dom) + (buffer-substring-no-properties (point-min) (point-max))))))) + + (gptel-make-tool + :name "read_buffer" + :category "emacs" + :description "Return the full contents of a live Emacs buffer." + :args (list '(:name "buffer" :type string + :description "The name of the buffer to read.")) + :function + (lambda (buffer) + (unless (buffer-live-p (get-buffer buffer)) + (error "Error: buffer %s is not live" buffer)) + (with-current-buffer buffer + (buffer-substring-no-properties (point-min) (point-max))))) + + (gptel-make-tool + :name "read_file" + :category "filesystem" + :description "Read and return the contents of a file on disk." + :args (list '(:name "filepath" :type string + :description "Path to the file. Supports relative paths and ~.")) + :function + (lambda (filepath) + (with-temp-buffer + (insert-file-contents (expand-file-name filepath)) + (buffer-string)))) + + (gptel-make-tool + :name "list_directory" + :category "filesystem" + :description "List the entries in a directory." + :args (list '(:name "directory" :type string + :description "The path to the directory to list.")) + :function + (lambda (directory) + (mapconcat #'identity (directory-files (expand-file-name directory)) "\n"))) + + (gptel-make-tool + :name "run_command" + :category "command" + :description "Execute a shell command and return its combined output." + :args (list '(:name "command" :type string + :description "The complete shell command to run.") + '(:name "working_dir" :type string :optional t + :description "Directory to run in (default: current).")) + :function + (lambda (command &optional working_dir) + (with-temp-message (format "Executing: %s" command) + (let ((default-directory + (if (and working_dir (not (string-empty-p working_dir))) + (expand-file-name working_dir) + default-directory))) + (shell-command-to-string command)))) + :confirm t + :include t) + + (gptel-make-tool + :name "obscura_scrape" + :category "web" + :description (concat "Scrape a web page with the Obscura headless browser " + "(real JS rendering, stealth). Use this instead of " + "`web_fetch'/`read_url' for JavaScript-heavy or " + "bot-protected pages. Returns the page as markdown by " + "default; other formats give raw html, plain text, or a " + "list of links.") + :args (list '(:name "url" :type string + :description "The URL to scrape.") + '(:name "format" :type string :optional t + :description "Output: markdown (default), text, html, or links.") + '(:name "selector" :type string :optional t + :description "Optional CSS selector to wait for before extracting.")) + :function + (lambda (url &optional format selector) + (if-let* ((exe (executable-find "obscura"))) + (with-temp-message (format "Obscura scraping %s" url) + (with-output-to-string + (apply #'call-process exe nil (list standard-output nil) nil + "fetch" url + "--dump" (if (member format '("text" "html" "links" "markdown")) + format "markdown") + "--wait-until" "networkidle0" + (when (and selector (not (string-empty-p selector))) + (list "--selector" selector))))) + "Error: the `obscura' binary is not on PATH. Install it (https://github.com/h4ckf0r0day/obscura) or use `web_fetch'/`read_url' instead.")) + :confirm t + :include t) + + (setq gptel-tools + (append (gptel-get-tool "web") + (gptel-get-tool "emacs") + (gptel-get-tool "filesystem") + (gptel-get-tool "command"))) + + ;; Presets: apply with `C-c L p' or an `@name' cookie at the start of a prompt. + + (gptel-make-preset 'research + :description "Web research: search, fetch and scrape, then synthesise." + :system (concat "You are a research assistant working inside Emacs. Use the " + "web tools to gather up-to-date information before answering: " + "`web_search' to find sources, `web_fetch'/`read_url' to read " + "them, and `obscura_scrape' for JavaScript-heavy or protected " + "pages. Always cite the URLs you used. Be concise.") + :tools '("web_search" "web_fetch" "read_url" "obscura_scrape") + :confirm-tool-calls nil) + + (gptel-make-preset 'agent + :description "Coding agent: read files/buffers and run commands in Emacs." + :system (concat "You are a careful coding agent operating inside Emacs. Use " + "the filesystem and buffer tools to inspect the project, and " + "`run_command' to build, test or inspect the repo. Explain " + "what you will do, then act. Prefer minimal, surgical changes.") + :tools '("read_file" "read_buffer" "list_directory" "run_command") + :confirm-tool-calls t) + + (gptel-make-preset 'full + :description "Everything: web + filesystem + Emacs + commands." + :system 'default + :tools '("web_search" "web_fetch" "read_url" "obscura_scrape" + "read_file" "read_buffer" "list_directory" "run_command")) + + (gptel-make-preset 'chat + :description "Plain conversation, no tools." + :system 'chat + :tools nil + :use-tools nil) + + ;; One preset per model: `@qwen', `@glm', ... switch model for one prompt. + (dolist (spec '((kimi-code . kimi-k2.7-code:cloud) + (kimi . kimi-k2.6:cloud) + (qwen . qwen3.5:cloud) + (glm . glm-5.2:cloud) + (glm51 . glm-5.1:cloud) + (gpt-oss . gpt-oss:120b-cloud) + (minimax . minimax-m3:cloud) + (minimax2 . minimax-m2.7:cloud))) + (gptel-make-preset (car spec) + :description (format "Use the %s model on Ollama Cloud." (cdr spec)) + :backend "Ollama Cloud" + :model (cdr spec)))) + + ;; Bundled with gptel (same repo, already on `load-path'); no recipe needed. + (use-package gptel-transient + :after gptel + :commands (gptel-menu gptel-system-prompt gptel-tools)) +#+end_src + +* Literate programming + +Tools for working with infrastructure and configuration files in a literate +way: Dockerfiles, Compose files, shell scripts, REST clients, and org-babel +sources so we can tangle and run code blocks from this very file. + +** Languages and file modes + +#+begin_src emacs-lisp + (use-package dockerfile-mode + :straight t + :mode (("\\.Dockerfile\\'" . dockerfile-mode) + ("/Dockerfile\\'" . dockerfile-mode))) + + (use-package docker + :straight t + :commands (docker docker-compose)) + + (use-package nginx-mode + :straight t + :mode "\\.nginx\\'") + + (use-package toml-mode + :straight t + :mode "\\.toml\\'") + + (use-package ini-mode + :straight t + :mode (("\\.ini\\'" . ini-mode) + ("/\\.env\\.\\(?:local\\|development\\|production\\)?\\'" . ini-mode))) + + (use-package restclient + :straight t + :mode (("\\.http\\'" . restclient-mode) + ("\\.rest\\'" . restclient-mode))) + + (use-package graphql-mode + :straight t + :mode "\\.gql\\(?:s\\)?\\'") + + (use-package docker-compose-mode + :straight t + :mode (("docker-compose\\(?:\\..*\\)?\\.ya?ml\\'" . docker-compose-mode) + ("compose\\.ya?ml\\'" . docker-compose-mode))) + +#+end_src + +** Org-babel languages + +These give us executable Org source blocks for shell scripts, REST calls, +and inline LLM completions. `ob-async' lets long blocks (model downloads, +container pulls) run in the background. + +#+begin_src emacs-lisp + (use-package ob-async + :straight t + :after org) + + (use-package ob-restclient + :straight t + :after org) + + (use-package ob-http + :straight t + :after org) + + (use-package ob-graphql + :straight t + :after org) + + (use-package ob-mermaid + :straight t + :after org + :config + (setopt ob-mermaid-cli-path (or (executable-find "mmdc") "npx"))) + + (use-package ob-d2 + :straight t + :after org) + + (use-package ob-llm + :straight t + :after org + :config + ;; Send babel blocks through gptel rather than a hosted API directly. + (setopt ob-llm-backend #'gptel-request + ob-llm-stream t)) + + (use-package ob-tmux + :straight t + :after org + :config + (setopt ob-tmux-location-alist + '(("default" . "/tmp") + ("work" . "/var/tmp")))) +#+end_src + +Enable a useful default set of babel languages and configure evaluation +safety so we can run trusted devops blocks without per-block confirmation. + +#+begin_src emacs-lisp + (with-eval-after-load 'org + (setopt org-babel-default-languages + '(emacs-lisp shell python docker dockerfile restclient http graphql mermaid d2 tmux llm)) + (setopt org-confirm-babel-evaluate nil + org-babel-default-header-args:shell '((:results . "output") (:async . "yes")) + org-babel-default-header-args:tmux '((:results . "output") (:async . "yes")) + org-babel-default-header-args:restclient '((:results . "raw")) + org-babel-default-header-args:http '((:results . "raw")))) +#+end_src blob - fc0d6353b586367a4b8757c52c851e55e6dbd7dc blob + 818c440582ccc4c765cfb3a11da03af6330409bb --- init.el +++ init.el @@ -2,14 +2,16 @@ (error "Emacs configuration only works with Emacs 29 and newer; you have version %s" emacs-major-version)) -(setopt initial-major-mode 'fundamental-mode) -(setopt inhibit-startup-screen t) -(setopt initial-scratch-message nil) -(setopt display-time-default-load-average nil) +(setopt initial-major-mode 'fundamental-mode + inhibit-startup-screen t + initial-scratch-message nil + display-time-default-load-average nil + sentence-end-double-space nil + use-short-answers t) -(setopt auto-revert-avoid-polling t) -(setopt auto-revert-interval 5) -(setopt auto-revert-check-vc-info t) +(setopt auto-revert-avoid-polling t + auto-revert-interval 5 + auto-revert-check-vc-info t) (global-auto-revert-mode 1) (savehist-mode 1) @@ -17,30 +19,34 @@ (windmove-default-keybindings 'control) -(setopt sentence-end-double-space nil) - (when (display-graphic-p) (context-menu-mode -1) (tool-bar-mode -1) (menu-bar-mode -1) - (scroll-bar-mode -1) - (set-frame-parameter nil 'font "Go Mono-14")) + (scroll-bar-mode -1)) +;; Use Go Mono everywhere. `default' is set directly here; `fixed-pitch' +;; and `variable-pitch' must be pointed at it explicitly so that e.g. +;; `variable-pitch-mode' in org-mode still renders in Go Mono. +(set-face-attribute 'default nil :family "Go Mono" :height 140) +(set-face-attribute 'fixed-pitch nil :family "Go Mono" :height 1.0) +(set-face-attribute 'variable-pitch nil :family "Go Mono" :height 1.0) + (unless (display-graphic-p) (xterm-mouse-mode 1)) (cua-mode 1) -(setopt display-line-numbers-width 3) +(keymap-set global-map "C-x k" #'kill-current-buffer) +(add-hook 'before-save-hook #'whitespace-cleanup) + +(setopt display-line-numbers-width 3 + tab-bar-show 1) (add-hook 'prog-mode-hook #'display-line-numbers-mode) (add-hook 'text-mode-hook #'visual-line-mode) (dolist (hook '(text-mode-hook prog-mode-hook)) (add-hook hook #'hl-line-mode)) -(setopt tab-bar-show 1) - -;; Start the Emacs server if it is not already running so `emacsclient' can -;; attach to this session. Remove the server socket when this Emacs exits. (require 'server) (unless (server-running-p) (server-start)) @@ -61,8 +67,8 @@ (lock-dir (expand-file-name "locks/" "~/.cache/emacs"))) (make-directory auto-save-dir t) (make-directory lock-dir t) - (setopt auto-save-file-name-transforms `((".*" ,auto-save-dir t))) - (setopt lock-file-name-transforms `((".*" ,lock-dir t)))) + (setopt auto-save-file-name-transforms `((".*" ,auto-save-dir t)) + lock-file-name-transforms `((".*" ,lock-dir t)))) (use-package modus-themes :straight t @@ -79,24 +85,45 @@ :defer 1 :config (which-key-mode)) +(use-package smart-mode-line + :straight t + :demand t + :custom + (sml/no-confirm-load-theme t) + (sml/theme 'respectful) + :config + (sml/setup)) + +(use-package minions + :straight t + :demand t + :config + (minions-mode)) + +;; Lets `:diminish' hide minor-mode lighters from the mode line. +(use-package diminish + :straight t + :demand t) + (add-to-list 'tab-bar-format 'tab-bar-format-align-right 'append) (add-to-list 'tab-bar-format 'tab-bar-format-global 'append) -(setopt display-time-format "%a %F %T") -(setopt display-time-interval 1) +(setopt display-time-format "%a %F %T" + display-time-interval 1) (display-time-mode 1) -(setopt mouse-wheel-tilt-scroll t) -(setopt mouse-wheel-flip-direction t) -(setopt mouse-wheel-scroll-amount '(1 ((shift) . 5) ((control) . 0))) -(setopt mouse-wheel-progressive-speed nil) +(setopt mouse-wheel-tilt-scroll t + mouse-wheel-flip-direction t + mouse-wheel-scroll-amount '(1 ((shift) . 5) ((control) . 0)) + mouse-wheel-progressive-speed nil) +(setopt scroll-margin 4 + scroll-conservatively 101 + scroll-up-aggressively 0.01 + scroll-down-aggressively 0.01 + auto-window-vscroll nil) + (blink-cursor-mode -1) (pixel-scroll-precision-mode 1) -(setopt scroll-margin 4) -(setopt scroll-conservatively 101) -(setopt scroll-up-aggressively 0.01) -(setopt scroll-down-aggressively 0.01) -(setopt auto-window-vscroll nil) (use-package lin :straight t @@ -120,14 +147,14 @@ :straight (circadian :type git :host github :repo "guidoschmidt/circadian.el") :demand t :config - (setopt calendar-latitude 48.1386) - (setopt calendar-longitude 17.1053) - (setopt circadian-themes '((:sunrise . ef-orange) + (setopt calendar-latitude 48.1386 + calendar-longitude 17.1053 + circadian-themes '((:sunrise . ef-orange) (:sunset . modus-vivendi))) (circadian-setup)) -(setopt completion-cycle-threshold 3) -(setopt tab-always-indent 'complete) +(setopt completion-cycle-threshold 3 + tab-always-indent 'complete) (keymap-set minibuffer-mode-map "TAB" #'minibuffer-complete) @@ -304,11 +331,23 @@ :straight t :after eshell :custom - (eat-term-name "xterm") + ;; A universally-installed TERM, so shells eat spawns never look up the + ;; bundled `eat-truecolor' entry (which `tput' fails to find otherwise). + (eat-term-name "xterm-256color") :config (eat-eshell-mode) (eat-eshell-visual-command-mode)) +(use-package tramp + :straight (:type built-in) + :defer t + :custom + (tramp-default-method "ssh") + (tramp-verbose 2) + (remote-file-name-inhibit-cache 60) + :config + (add-to-list 'tramp-remote-path 'tramp-own-remote-path)) + (use-package treesit-auto :straight t :demand t @@ -363,6 +402,11 @@ (eglot-extend-to-xref t) :config (fset #'jsonrpc--log-event #'ignore)) +;; GC and the file-name handler are restored on `emacs-startup-hook' +;; (see early-init). Larger process reads make eglot/LSP feel snappier. +(setopt read-process-output-max (* 4 1024 1024) + process-adaptive-read-buffering nil) + (use-package tempel :straight t :bind (("M-*" . tempel-insert) @@ -533,6 +577,28 @@ (keymap-set denote-prefix-map "t" #'org-agenda-list) (keymap-set denote-prefix-map "c" #'org-capture) +(defvar-keymap gptel-prefix-map + :doc "Prefix map for gptel commands.") +(keymap-set global-map "C-c L" gptel-prefix-map) +(keymap-set gptel-prefix-map "s" #'gptel-send) +(keymap-set gptel-prefix-map "m" #'gptel-menu) +(keymap-set gptel-prefix-map "r" #'gptel-rewrite) +(keymap-set gptel-prefix-map "a" #'gptel-add) +(keymap-set gptel-prefix-map "A" #'gptel-add-file) +(keymap-set gptel-prefix-map "p" #'gptel-preset) +(keymap-set gptel-prefix-map "t" #'gptel-tools) +(keymap-set gptel-prefix-map "n" #'mine/gptel-denote-session) + +(defvar-keymap devops-prefix-map + :doc "Prefix map for devops / container / shell commands.") +(keymap-set global-map "C-c d" devops-prefix-map) +(keymap-set devops-prefix-map "d" #'docker) +(keymap-set devops-prefix-map "c" #'docker-compose) +(keymap-set devops-prefix-map "s" #'shell-command) +(keymap-set devops-prefix-map "b" #'org-babel-execute-buffer) +(keymap-set devops-prefix-map "B" #'org-babel-execute-subtree) +(keymap-set devops-prefix-map "t" #'ob-tmux-open) + (with-eval-after-load 'corfu (keymap-set corfu-map "C-b" #'corfu-scroll-down) (keymap-set corfu-map "C-f" #'corfu-scroll-up) @@ -570,7 +636,393 @@ ("C-c t k" . tmr-remove) ("C-c t s" . tmr-stop))) -;; GC and the file-name handler are restored on `emacs-startup-hook' -;; (see early-init). Larger process reads make eglot/LSP feel snappier. -(setopt read-process-output-max (* 4 1024 1024) - process-adaptive-read-buffering nil) +(defun mine/gptel-denote-session () + "Create a Denote note and start a gptel session in it." + (interactive) + (let ((title (read-string "GPTel session title: "))) + (denote title '("ai" "gptel")) + (gptel-mode))) + + (defun mine/gptel-auto-save (_beg _end) + "Save current gptel buffer after each response, if it is a Denote note. +Called from `gptel-post-response-functions', which passes the +response beginning and end positions; we ignore both." + (when (and buffer-file-name + (derived-mode-p 'org-mode) + (string-prefix-p (expand-file-name denote-directory) + (file-name-directory buffer-file-name))) + (save-buffer))) + + (add-hook 'gptel-post-response-functions #'mine/gptel-auto-save) + + ;; --- Ollama Cloud REST helpers (shared by the gptel web tools) ------- + + (defun mine/ollama-api-key () + "Return the Ollama Cloud API key stored in auth-source. +Expects a line in `auth-sources' for host ollama.com, user apikey." + (or (auth-source-pick-first-password :host "ollama.com" :user "apikey") + (user-error "No Ollama API key in auth-source for host ollama.com"))) + + (defun mine/ollama-api-post (path payload) + "POST PAYLOAD (an alist) as JSON to Ollama API PATH, return parsed JSON. +Signals an error on a non-2xx response. Runs synchronously." + (let* ((url-request-method "POST") + (url-request-extra-headers + `(("Content-Type" . "application/json") + ("Authorization" . ,(concat "Bearer " (mine/ollama-api-key))))) + (url-request-data (encode-coding-string (json-encode payload) 'utf-8)) + (buf (url-retrieve-synchronously (concat "https://ollama.com" path) + 'silent nil 60))) + (unless buf (error "Ollama API request to %s timed out" path)) + (unwind-protect + (with-current-buffer buf + (goto-char (point-min)) + (let ((status (and (re-search-forward "^HTTP/[0-9.]+ \\([0-9]+\\)" nil t) + (string-to-number (match-string 1))))) + (unless (re-search-forward "\n\n" nil t) + (error "Malformed response from Ollama API")) + (let* ((json-object-type 'alist) + (json-array-type 'list) + (json-key-type 'symbol) + (body (json-read))) + (when (and status (>= status 400)) + (error "Ollama API %s failed (HTTP %d): %s" path status + (or (alist-get 'error body) body))) + body))) + (kill-buffer buf)))) + +(use-package gptel + :straight (gptel :type git :host github :repo "karthink/gptel") + :demand t + :bind (:map gptel-mode-map + ("C-c RET" . gptel-send) + ("C-c C-r" . gptel-rewrite) + ("C-c C-a" . gptel-add)) + :custom + (gptel-use-curl t) + (gptel-stream t) + (gptel-default-mode 'org-mode) + (gptel-use-header-line t) + (gptel-org-ignore-elements '(property-drawer)) + (gptel-directives + '((default . "You are a large language model living in Emacs and a helpful assistant. Respond concisely.") + (programming . "You are a careful programmer working from inside Emacs. Provide code, with minimal surrounding prose.") + (reviewing . "You are reviewing code or text in Emacs. Be terse, point out concrete issues, and suggest fixes.") + (literate . "You are helping with a literate configuration in an Org file. Prefer Org markup and explain only what is non-obvious.") + (chat . "You are a conversation partner. Respond concisely."))) + (gptel-use-tools t) + (gptel-confirm-tool-calls 'auto) + (gptel-include-tool-results 'auto) + :config + ;; `gptel-ollama' is loaded only so `ob-llm' keeps working; the backend + ;; itself is the OpenAI-compatible Ollama Cloud endpoint below. + (require 'gptel-ollama) + (require 'gptel-openai) + (gptel-make-openai "Ollama Cloud" + :host "ollama.com" + :protocol "https" + :endpoint "/v1/chat/completions" + :stream t + :key (lambda () + (auth-source-pick-first-password + :host "ollama.com" :user "apikey")) + :models '(kimi-k2.7-code:cloud + kimi-k2.6:cloud + qwen3.5:cloud + glm-5.2:cloud + glm-5.1:cloud + gpt-oss:120b-cloud + minimax-m3:cloud + minimax-m2.7:cloud)) + + (setopt gptel-backend (gptel-get-backend "Ollama Cloud") + gptel-model 'kimi-k2.7-code:cloud) + + ;; Tools: web access via Ollama Cloud's web API, plus filesystem/buffer/ + ;; command access. Only `run_command' and `obscura_scrape' confirm first. + + (gptel-make-tool + :name "web_search" + :category "web" + :description (concat "Search the web and return a list of results, each with a " + "title, URL and a content snippet. Use this for current " + "events or anything outside your training data, then call " + "`web_fetch' on a promising URL to read it in full.") + :args (list '(:name "query" :type string + :description "The search query.") + '(:name "max_results" :type integer :optional t + :description "How many results to return (1-10, default 5).")) + :function + (lambda (query &optional max_results) + (let* ((payload `(("query" . ,query) + ,@(when (and (integerp max_results) (> max_results 0)) + `(("max_results" . ,(min max_results 10)))))) + (results (alist-get 'results (mine/ollama-api-post "/api/web_search" payload)))) + (if (null results) + "No results found." + (mapconcat + (lambda (r) + (format "## %s\n%s\n\n%s" + (alist-get 'title r) (alist-get 'url r) (alist-get 'content r))) + results "\n\n---\n\n"))))) + + (gptel-make-tool + :name "web_fetch" + :category "web" + :description "Fetch and return the main text content of a web page, given its URL." + :args (list '(:name "url" :type string + :description "The full URL of the page to fetch.")) + :function + (lambda (url) + (let* ((resp (mine/ollama-api-post "/api/web_fetch" `(("url" . ,url)))) + (content (alist-get 'content resp))) + (if (and (stringp content) (not (string-empty-p content))) + (format "# %s\n\n%s" (or (alist-get 'title resp) url) content) + "Could not extract any content from that URL.")))) + + (gptel-make-tool + :name "read_url" + :category "web" + :description (concat "Fetch a URL directly (no API) and return its rendered text. " + "A fallback for `web_fetch'.") + :args (list '(:name "url" :type string :description "The URL to read.")) + :function + (lambda (url) + (with-current-buffer (url-retrieve-synchronously url t t 30) + (goto-char (point-min)) + (forward-paragraph) + (let ((dom (libxml-parse-html-region (point) (point-max)))) + (run-at-time 0 nil #'kill-buffer (current-buffer)) + (with-temp-buffer + (shr-insert-document dom) + (buffer-substring-no-properties (point-min) (point-max))))))) + + (gptel-make-tool + :name "read_buffer" + :category "emacs" + :description "Return the full contents of a live Emacs buffer." + :args (list '(:name "buffer" :type string + :description "The name of the buffer to read.")) + :function + (lambda (buffer) + (unless (buffer-live-p (get-buffer buffer)) + (error "Error: buffer %s is not live" buffer)) + (with-current-buffer buffer + (buffer-substring-no-properties (point-min) (point-max))))) + + (gptel-make-tool + :name "read_file" + :category "filesystem" + :description "Read and return the contents of a file on disk." + :args (list '(:name "filepath" :type string + :description "Path to the file. Supports relative paths and ~.")) + :function + (lambda (filepath) + (with-temp-buffer + (insert-file-contents (expand-file-name filepath)) + (buffer-string)))) + + (gptel-make-tool + :name "list_directory" + :category "filesystem" + :description "List the entries in a directory." + :args (list '(:name "directory" :type string + :description "The path to the directory to list.")) + :function + (lambda (directory) + (mapconcat #'identity (directory-files (expand-file-name directory)) "\n"))) + + (gptel-make-tool + :name "run_command" + :category "command" + :description "Execute a shell command and return its combined output." + :args (list '(:name "command" :type string + :description "The complete shell command to run.") + '(:name "working_dir" :type string :optional t + :description "Directory to run in (default: current).")) + :function + (lambda (command &optional working_dir) + (with-temp-message (format "Executing: %s" command) + (let ((default-directory + (if (and working_dir (not (string-empty-p working_dir))) + (expand-file-name working_dir) + default-directory))) + (shell-command-to-string command)))) + :confirm t + :include t) + + (gptel-make-tool + :name "obscura_scrape" + :category "web" + :description (concat "Scrape a web page with the Obscura headless browser " + "(real JS rendering, stealth). Use this instead of " + "`web_fetch'/`read_url' for JavaScript-heavy or " + "bot-protected pages. Returns the page as markdown by " + "default; other formats give raw html, plain text, or a " + "list of links.") + :args (list '(:name "url" :type string + :description "The URL to scrape.") + '(:name "format" :type string :optional t + :description "Output: markdown (default), text, html, or links.") + '(:name "selector" :type string :optional t + :description "Optional CSS selector to wait for before extracting.")) + :function + (lambda (url &optional format selector) + (if-let* ((exe (executable-find "obscura"))) + (with-temp-message (format "Obscura scraping %s" url) + (with-output-to-string + (apply #'call-process exe nil (list standard-output nil) nil + "fetch" url + "--dump" (if (member format '("text" "html" "links" "markdown")) + format "markdown") + "--wait-until" "networkidle0" + (when (and selector (not (string-empty-p selector))) + (list "--selector" selector))))) + "Error: the `obscura' binary is not on PATH. Install it (https://github.com/h4ckf0r0day/obscura) or use `web_fetch'/`read_url' instead.")) + :confirm t + :include t) + + (setq gptel-tools + (append (gptel-get-tool "web") + (gptel-get-tool "emacs") + (gptel-get-tool "filesystem") + (gptel-get-tool "command"))) + + ;; Presets: apply with `C-c L p' or an `@name' cookie at the start of a prompt. + + (gptel-make-preset 'research + :description "Web research: search, fetch and scrape, then synthesise." + :system (concat "You are a research assistant working inside Emacs. Use the " + "web tools to gather up-to-date information before answering: " + "`web_search' to find sources, `web_fetch'/`read_url' to read " + "them, and `obscura_scrape' for JavaScript-heavy or protected " + "pages. Always cite the URLs you used. Be concise.") + :tools '("web_search" "web_fetch" "read_url" "obscura_scrape") + :confirm-tool-calls nil) + + (gptel-make-preset 'agent + :description "Coding agent: read files/buffers and run commands in Emacs." + :system (concat "You are a careful coding agent operating inside Emacs. Use " + "the filesystem and buffer tools to inspect the project, and " + "`run_command' to build, test or inspect the repo. Explain " + "what you will do, then act. Prefer minimal, surgical changes.") + :tools '("read_file" "read_buffer" "list_directory" "run_command") + :confirm-tool-calls t) + + (gptel-make-preset 'full + :description "Everything: web + filesystem + Emacs + commands." + :system 'default + :tools '("web_search" "web_fetch" "read_url" "obscura_scrape" + "read_file" "read_buffer" "list_directory" "run_command")) + + (gptel-make-preset 'chat + :description "Plain conversation, no tools." + :system 'chat + :tools nil + :use-tools nil) + + ;; One preset per model: `@qwen', `@glm', ... switch model for one prompt. + (dolist (spec '((kimi-code . kimi-k2.7-code:cloud) + (kimi . kimi-k2.6:cloud) + (qwen . qwen3.5:cloud) + (glm . glm-5.2:cloud) + (glm51 . glm-5.1:cloud) + (gpt-oss . gpt-oss:120b-cloud) + (minimax . minimax-m3:cloud) + (minimax2 . minimax-m2.7:cloud))) + (gptel-make-preset (car spec) + :description (format "Use the %s model on Ollama Cloud." (cdr spec)) + :backend "Ollama Cloud" + :model (cdr spec)))) + +;; Bundled with gptel (same repo, already on `load-path'); no recipe needed. +(use-package gptel-transient + :after gptel + :commands (gptel-menu gptel-system-prompt gptel-tools)) + +(use-package dockerfile-mode + :straight t + :mode (("\\.Dockerfile\\'" . dockerfile-mode) + ("/Dockerfile\\'" . dockerfile-mode))) + +(use-package docker + :straight t + :commands (docker docker-compose)) + +(use-package nginx-mode + :straight t + :mode "\\.nginx\\'") + +(use-package toml-mode + :straight t + :mode "\\.toml\\'") + +(use-package ini-mode + :straight t + :mode (("\\.ini\\'" . ini-mode) + ("/\\.env\\.\\(?:local\\|development\\|production\\)?\\'" . ini-mode))) + +(use-package restclient + :straight t + :mode (("\\.http\\'" . restclient-mode) + ("\\.rest\\'" . restclient-mode))) + +(use-package graphql-mode + :straight t + :mode "\\.gql\\(?:s\\)?\\'") + +(use-package docker-compose-mode + :straight t + :mode (("docker-compose\\(?:\\..*\\)?\\.ya?ml\\'" . docker-compose-mode) + ("compose\\.ya?ml\\'" . docker-compose-mode))) + +(use-package ob-async + :straight t + :after org) + +(use-package ob-restclient + :straight t + :after org) + +(use-package ob-http + :straight t + :after org) + +(use-package ob-graphql + :straight t + :after org) + +(use-package ob-mermaid + :straight t + :after org + :config + (setopt ob-mermaid-cli-path (or (executable-find "mmdc") "npx"))) + +(use-package ob-d2 + :straight t + :after org) + +(use-package ob-llm + :straight t + :after org + :config + ;; Send babel blocks through gptel rather than a hosted API directly. + (setopt ob-llm-backend #'gptel-request + ob-llm-stream t)) + +(use-package ob-tmux + :straight t + :after org + :config + (setopt ob-tmux-location-alist + '(("default" . "/tmp") + ("work" . "/var/tmp")))) + +(with-eval-after-load 'org + (setopt org-babel-default-languages + '(emacs-lisp shell python docker dockerfile restclient http graphql mermaid d2 tmux llm)) + (setopt org-confirm-babel-evaluate nil + org-babel-default-header-args:shell '((:results . "output") (:async . "yes")) + org-babel-default-header-args:tmux '((:results . "output") (:async . "yes")) + org-babel-default-header-args:restclient '((:results . "raw")) + org-babel-default-header-args:http '((:results . "raw"))))