Commit Diff


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"))))