Skip to content

Latest commit

 

History

History
1649 lines (1495 loc) · 68.5 KB

File metadata and controls

1649 lines (1495 loc) · 68.5 KB

Interface Tweaks

;;; config.el -*- lexical-binding: t; -*-
;;;
;;;
;;; BEGIN_Interface_Tweaks
;;;
;;;

;; When starting a new frame make it maximized
(add-to-list 'initial-frame-alist '(fullscreen . fullboth))
(push '(fullscreen . fullboth)   default-frame-alist)

;; Set your own banner to replace the default doom one "convert image.png
;; -resize 600 -quality 75 OUTPUT.png" try resize 400 for smaller resolutions
;; e.g. 1080p
(setopt fancy-splash-image (expand-file-name "banner/trancendent-gnu.png" doom-user-dir))

;; Doom exposes five (optional) variables for controlling fonts in Doom. Here
;; are the three important ones:
;;
;; + `doom-font'
;; + `doom-variable-pitch-font'
;; + `doom-big-font' -- used for `doom-big-font-mode'; use this for
;;   presentations or streaming.
;;
;; They all accept either a font-spec, font string ("Input Mono-12"), or xlfd
;; font string.

;; There are two ways to load a theme. Both assume the theme is installed and
;; available. You can either set `doom-theme' or manually load a theme with the
;; `load-theme' function. This is the default:
(setopt doom-theme 'doom-dracula)

;; Font configuration with fallbacks
;; Primary fonts: FiraCode Nerd Font, Fira Sans, Source Serif 4, Font Awesome
;; Fallbacks ensure graceful degradation if fonts are missing
(setopt doom-font (font-spec :family "FiraCode Nerd Font Mono" :size 13)
       doom-variable-pitch-font (font-spec :family "Fira Sans" :size 13)
       doom-big-font (font-spec :family "FiraCode Nerd Font Mono" :size 19)
       doom-serif-font (font-spec :family "Source Serif 4" :size 13)
       doom-symbol-font (font-spec :family "Font Awesome 7 Free" :size 13))

;; Verify fonts exist and set fallbacks if needed
(defun bmg/font-available-p (font-family)
  "Check if FONT-FAMILY is available on the system."
  (member font-family (font-family-list)))

(defun bmg/set-font-with-fallback (font-var primary &rest fallbacks)
  "Set FONT-VAR to PRIMARY font, or first available FALLBACK."
  (let ((available (or (and (bmg/font-available-p (plist-get primary :family)) primary)
                       (cl-find-if (lambda (f) (bmg/font-available-p (plist-get f :family)))
                                   fallbacks))))
    (when available
      (set font-var available))))

;; Apply font fallbacks once a graphical frame exists.  Not
;; after-init-hook: in daemon sessions that fires before any frame,
;; where font-family-list returns nil and every probe fails -- the
;; whole mechanism silently no-oped.  The hook removes itself after
;; the first graphical run.
(defun bmg/apply-font-fallbacks-once (&rest _)
  (when (display-graphic-p)
    (remove-hook 'server-after-make-frame-hook #'bmg/apply-font-fallbacks-once)
    (remove-hook 'after-init-hook #'bmg/apply-font-fallbacks-once)
    (bmg/apply-font-fallbacks)))
(add-hook 'server-after-make-frame-hook #'bmg/apply-font-fallbacks-once)
(add-hook 'after-init-hook #'bmg/apply-font-fallbacks-once)

(defun bmg/apply-font-fallbacks ()
  "Set each doom font variable to its first installed candidate."
  (bmg/set-font-with-fallback 'doom-font
                              (font-spec :family "FiraCode Nerd Font Mono" :size 13)
                              (font-spec :family "Fira Code" :size 13)
                              (font-spec :family "JetBrains Mono" :size 13)
                              (font-spec :family "Hack" :size 13)
                              (font-spec :family "monospace" :size 13))
  (bmg/set-font-with-fallback 'doom-variable-pitch-font
                              (font-spec :family "Fira Sans" :size 13)
                              (font-spec :family "Cantarell" :size 13)
                              (font-spec :family "DejaVu Sans" :size 13)
                              (font-spec :family "sans-serif" :size 13))
  (bmg/set-font-with-fallback 'doom-serif-font
                              (font-spec :family "Source Serif 4" :size 13)
                              (font-spec :family "Source Serif Pro" :size 13)
                              (font-spec :family "DejaVu Serif" :size 13)
                              (font-spec :family "serif" :size 13))
  (bmg/set-font-with-fallback 'doom-symbol-font
                              (font-spec :family "Font Awesome 7 Free" :size 13)
                              (font-spec :family "Font Awesome 6 Free" :size 13)
                              (font-spec :family "Symbols Nerd Font" :size 13)
                              (font-spec :family "Noto Color Emoji" :size 13)))

(with-eval-after-load 'doom-themes
  (setopt doom-themes-enable-bold t
         doom-themes-enable-italic t))

;; Fix gnus face inheritance cycle - must run early before gnus loads
;; The cycle is: gnus-group-news-low-empty inherits from gnus-group-news-low
;; which inherits from gnus-group-news-low-empty
;; Use with-eval-after-load for gnus to set faces only when gnus is actually loaded
(with-eval-after-load 'gnus
  (set-face-attribute 'gnus-group-news-low nil
                      :weight 'normal
                      :foreground "#565761")  ; doom-dracula base5
  (set-face-attribute 'gnus-group-news-low-empty nil
                      :weight 'normal
                      :foreground "#3d3f4c")) ; doom-dracula base4

(with-eval-after-load 'org-modern
  (set-face-attribute 'org-modern-symbol nil :family "Font Awesome 7 Free")
  (set-face-attribute 'org-modern-label nil :height 1.0)
  (set-face-attribute 'org-modern-block-name nil :height 1.0))

;; be as colorful as possible.
(setopt treesit-font-lock-level 4)

;; This determines the style of line numbers in effect. If set to `nil', line
;; numbers are disabled. For relative line numbers, set this to `relative'.
(setopt display-line-numbers-type t)

;; Blinking cursors are annoying
(blink-cursor-mode -1)

(setq +dashboard-menu-sections
       '(("Open org-agenda"
          :icon (nerd-icons-octicon "nf-oct-calendar" :face '+dashboard-menu-title)
          :action bmg/switch-to-agenda)
         ("Recently opened files"
          :icon (nerd-icons-faicon "nf-fa-file_text" :face '+dashboard-menu-title)
          :action recentf-open-files)
         ("Reload last session"
          :icon (nerd-icons-octicon "nf-oct-history" :face '+dashboard-menu-title)
          :when (cond ((modulep! :ui workspaces)
                       (file-exists-p (expand-file-name persp-auto-save-fname persp-save-dir)))
                      ((require 'desktop nil t)
                       (file-exists-p (desktop-full-file-name))))
          :action doom/quickload-session)
         ("Open project"
          :icon (nerd-icons-octicon "nf-oct-briefcase" :face '+dashboard-menu-title)
          :action projectile-switch-project)
         ("Open private configuration"
          :icon (nerd-icons-octicon "nf-oct-tools" :face '+dashboard-menu-title)
          :when (file-directory-p doom-user-dir)
          :action doom/open-private-config)
         ("Open documentation"
          :icon (nerd-icons-octicon "nf-oct-book" :face '+dashboard-menu-title)
          :action doom/help)
         ("RSS"
          :icon (nerd-icons-octicon "nf-oct-rss" :face '+dashboard-menu-title)
          :action =rss)
         ))
;;;
;;;
;;; END_Interface_Tweaks
;;;
;;;

General

;;;
;;;
;;; BEGIN_General
;;;
;;;

;; Some functionality uses this to identify you, e.g. GPG configuration, email
;; clients, file templates and snippets.
(setopt user-full-name "Brian McGillion"
       user-mail-address "brian@ssrc.tii.ae"
       ;; Who remembers all the commands :)
       which-key-idle-delay 0.5
       ;; Prefer encrypted auth source
       auth-sources '("~/.authinfo.gpg" "~/.netrc")
       ;; M-x projectile-discover-projects-in-directory
       ;; M-x projectile-discover-projects-in-search-path
       projectile-project-search-path '(("~/.dotfiles" . 1)("~/projects" . 6)("~/.config" . 2)("~/Documents/org" . 2))
       projectile-auto-discover nil)

;; Enable word-wrap for text modes (visual-line-mode wraps at window edge)
;; Using text-mode-hook covers org-mode, markdown-mode, and other text modes
(add-hook 'text-mode-hook #'visual-line-mode)

;; If a file changes on disk update the buffer to match
(global-auto-revert-mode t)

(with-eval-after-load 'dirvish
  (dirvish-side-follow-mode t)
  (setopt dirvish-hide-details '(dirvish dirvish-side))
  ;; The side panel doesn't always reach `dirvish--build-layout', so
  ;; `dired-hide-details-mode' never gets toggled on. Force it here once
  ;; the side dired buffer is set up.
  (add-hook 'dirvish-find-entry-hook
            (defun bmg/dirvish-side-hide-details (_path find-fn)
              (when (and (eq find-fn 'dired)
                         (when-let* ((dv (dirvish-curr)))
                           (eq (dv-type dv) 'side)))
                (dired-hide-details-mode 1))
              nil)))

;; Use a custom dictionary
(setopt ispell-dictionary (if (featurep :system 'macos) "en_GB" "en_US"))

;; NixOS doesn't have /usr/share/dict/words, disable word-list completion in text-mode
;; (aspell handles spell-checking, this only affects M-TAB word completion)
(setopt text-mode-ispell-word-completion nil)

(set-language-environment-charset "UTF-8")

;; Use the Languagetool only in server mode
(setopt langtool-http-server-host "localhost"
       langtool-http-server-port 8081
       langtool-default-language nil)

(with-eval-after-load 'license-snippets
  (license-snippets-init))

;; load all the elfeed configurations
(load! "elfeed-config.el")

;; misc selection of useful functions
(use-package crux
  :defer t)

;; Clone git repos or jump to the project in the code path
;; https://github.com/NinjaTrappeur/my-repo-pins
(use-package my-repo-pins
  :commands (my-repo-pins)
  :init
  (setopt my-repo-pins-code-root "~/projects/code"))

(use-package inheritenv
  :config
  ;; ensure that the environment is carried over to the subshell that is called.
  ;; this was tested against go-mode specifically go-import-add
  (inheritenv-add-advice #'process-lines)
  (inheritenv-add-advice #'shell-command-to-string))

(with-eval-after-load 'consult
  ;; Needed to ensure that preview is working live, without the need to
  ;; C-SPC everytime you need to see a preview. see vertico/config.el
  ;; where we are overriding the override :)
  (consult-customize
   +default/search-project +default/search-other-project
   +default/search-project-for-symbol-at-point
   +default/search-cwd +default/search-other-cwd
   +default/search-notes-for-symbol-at-point
   +default/search-emacsd
   consult-ripgrep consult-git-grep consult-grep
   consult-bookmark consult-recent-file
   consult-source-recent-file consult-source-project-recent-file consult-source-bookmark
   :preview-key (list "C-SPC" :debounce 0.1 'any)))


;; Try to stop killing emacs with C-x C-c and use C-c q f
;; when using the daemon mode.
(defun my-confirm-kill-daemon (prompt)
  "Ask whether to kill daemon Emacs with PROMPT.
Intended as a predicate for `confirm-kill-emacs'."
  (or (not (daemonp))
      (yes-or-no-p prompt)))

(setopt confirm-kill-emacs #'my-confirm-kill-daemon)

;;;
;;;
;;; END_General
;;;
;;;

Code

;;;
;;;
;;; BEGIN_code
;;;
;;;

;; Add handlers for SELinux files and MDX (React/JSX markdown)
(add-to-list 'auto-mode-alist '("\\.te\\'" . m4-mode))
(add-to-list 'auto-mode-alist '("\\.mdx\\'" . gfm-mode)) ;; GitHub Flavored Markdown

;; Live side-by-side markdown preview inside Emacs (eww buffer, no xwidgets needed).
;; markdown-command defaults to the "markdown" binary which isn't installed; point it
;; at pandoc (provided system-wide via programs.pandoc.enable in ~/.dotfiles).
(with-eval-after-load 'markdown-mode
  (setopt markdown-command "pandoc --from=gfm --to=html5 --standalone"))

;; make a shell script executable automatically on save
(add-hook 'after-save-hook
          'executable-make-buffer-file-executable-if-script-p)

(setopt c-default-style "linux") ;; set style to "linux"

;;setup clangd lsp
(with-eval-after-load 'lsp-clangd
  (setopt lsp-clients-clangd-args
        '("-j=3"
          "--background-index"
          "--clang-tidy"
          "--completion-style=detailed"
          "--header-insertion=never"
          "--header-insertion-decorators=0"))
  (set-lsp-priority! 'clangd 2))

;; use just mode for setting tasks to run at the cmdline
;; (justl binds "e" to justl-exec-recipe itself; no extra map! needed)
(use-package justl)

;; Use a variable for the dotfiles path to avoid hardcoding
(defvar bmg/dotfiles-path (expand-file-name "~/.dotfiles")
  "Path to dotfiles repository for Nix flake expressions.")

(with-eval-after-load 'lsp-mode
  (setopt lsp-nix-nixd-server-path "nixd"
        lsp-nix-nixd-formatting-command ["nixfmt"]
        lsp-nix-nixd-nixpkgs-expr (format "import (builtins.getFlake \"%s\").inputs.nixpkgs { }" bmg/dotfiles-path)
        lsp-nix-nixd-nixos-options-expr (format "(builtins.getFlake \"%s\").nixosConfigurations.arcadia.options" bmg/dotfiles-path)))
;; Note: home-manager options commented out - home-manager appears to be integrated as a NixOS module
;; If you have standalone home-manager configs, use:
;; lsp-nix-nixd-home-manager-options-expr (format "(builtins.getFlake \"%s\").homeConfigurations.\"user@host\".options" bmg/dotfiles-path)

;; NASM mode for .nasm and .asm files using NASM syntax
(use-package nasm-mode
  :mode "\\.\\(nasm\\|asm\\)\\'")

;; x86 instruction documentation lookup
;; Download the manual from: https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
;; (the "Intel 64 and IA-32 Architectures Software Developer's Manual" combined PDF)
(use-package x86-lookup
  :commands x86-lookup
  :config
  ;; setq, not setopt: the defcustom type is (file :must-match t) and the
  ;; SDM PDF is not always on disk, so setopt would warn at every startup.
  (setq x86-lookup-pdf (expand-file-name "~/Documents/Papers/325462-sdm-vol-1-2abcd-3abcd.pdf")))

;; ensure that worktrees are available in magit with %
(with-eval-after-load 'magit
  (magit-add-section-hook 'magit-status-sections-hook
                          'magit-insert-worktrees
                          'magit-insert-stashes
                          'append))

;;;
;;;
;;; END_code
;;;
;;;

LLM/ML

;;;
;;;
;;; BEGIN_llm
;;;
;;;

;; Inline code completions via GitHub Copilot
;; Primary LLM interaction is via GitHub Copilot CLI (external)
;; Doom's :tools llm module provides gptel-magit for commit messages
(use-package copilot
  :hook (prog-mode . copilot-mode)
  :bind (:map copilot-completion-map
              ("<tab>" . 'copilot-accept-completion)
              ("TAB" . 'copilot-accept-completion)
              ("C-TAB" . 'copilot-accept-completion-by-word)
              ("C-<tab>" . 'copilot-accept-completion-by-word))
  :config
  (setopt copilot-indent-offset-warning-disable t))

;; Configure gptel with GitHub Copilot as the backend
;; Uses existing GitHub Copilot subscription - no separate API key needed
;; Authentication handled automatically via GitHub login
(with-eval-after-load 'gptel
  (setopt gptel-model 'claude-opus-4.5
         gptel-backend (gptel-make-gh-copilot "Copilot")))

;; Claude Agent via ACP (agent-shell + acp.el, driven by the claude-agent-acp
;; adapter). The acp/agent-shell packages and the claude-agent-acp binary are
;; provided by Nix (dotfiles: modules/features/development/emacs.nix and
;; modules/profiles/client.nix). Uses the Claude subscription — run `claude`
;; once in a terminal to log in; no API key needed here.
;; Start with M-x agent-shell-anthropic-start-claude-code.
(with-eval-after-load 'agent-shell
  (setopt agent-shell-anthropic-authentication
        (agent-shell-anthropic-make-authentication :login t))
  ;; Inherit PATH/HOME so the spawned claude-agent-acp process finds `claude`,
  ;; the adapter binary, and the login credentials.
  (setopt agent-shell-anthropic-claude-environment
        (agent-shell-make-environment-variables :inherit-env t)))

;;; AI-Powered Knowledge Management Functions

(defgroup bmg/llm nil
  "Configuration for LLM-powered knowledge management."
  :group 'tools
  :prefix "bmg/llm-")

(defcustom bmg/llm-context-small 4000
  "Small context window size for quick LLM operations (e.g., tag suggestions)."
  :type 'integer
  :group 'bmg/llm)

(defcustom bmg/llm-context-medium 8000
  "Medium context window size for detailed LLM operations (e.g., summaries)."
  :type 'integer
  :group 'bmg/llm)

(defcustom bmg/llm-context-tiny 2000
  "Tiny context window size for minimal LLM operations."
  :type 'integer
  :group 'bmg/llm)

(cl-defun bmg/llm--request (prompt &key system callback (label "LLM request"))
  "Send PROMPT to the LLM via gptel with uniform error handling.
SYSTEM is the system prompt.  CALLBACK is called as (response info)
only for a non-nil, non-empty response; nil/empty responses and
request errors are all reported as \"LABEL failed: ...\" messages."
  (condition-case err
      (gptel-request prompt
        :system system
        :callback (lambda (response info)
                    (cond
                     ((not response)
                      (message "%s failed: %s" label (plist-get info :status)))
                     ((string-empty-p (string-trim response))
                      (message "%s failed: LLM returned an empty response" label))
                     (t (funcall callback response info)))))
    (error (message "%s failed: %s" label (error-message-string err)))))

(defun bmg/llm--display-org-buffer (name &rest content)
  "Show CONTENT strings in an org-mode buffer called NAME."
  (with-current-buffer (get-buffer-create name)
    (erase-buffer)
    (apply #'insert content)
    (org-mode)
    (goto-char (point-min))
    (pop-to-buffer (current-buffer))))

(defun bmg/suggest-tags-for-buffer ()
  "Use LLM to suggest filetags for current org-roam buffer.
Copies suggested tags to kill ring for easy insertion.
Bound to SPC z S."
  (interactive)
  (unless (derived-mode-p 'org-mode)
    (user-error "Not in an org buffer"))
  ;; Budget is relative to the accessible region start: in a narrowed
  ;; buffer, (min (point-max) budget) is an absolute position that can
  ;; fall before point-min -> args-out-of-range.
  (let ((content (buffer-substring-no-properties
                  (point-min)
                  (min (point-max) (+ (point-min) bmg/llm-context-small)))))
    (when (string-empty-p (string-trim content))
      (user-error "Buffer is empty, nothing to analyze"))
    (message "Requesting tag suggestions...")
    (bmg/llm--request content
      :label "Tag suggestion"
      :system "Suggest org-mode filetags for this note. Return ONLY a single line in colon-separated format like :paper:security:tpm: with no explanation.
Focus on these categories:
- Document types: paper, website, book, meeting, project, reference
- Security domains: security, tpm, tee, sgx, trustzone, confidential_computing, attestation, secure_boot
- Attack types: exploit, side_channel, vulnerability, fuzzing
- Systems: virtualization, containers, android, linux, firmware, hardware
- Topics: cryptography, ml, networking, performance, architecture
Keep to 3-6 most relevant tags."
      :callback (lambda (response _info)
                  (let ((tags (string-trim response)))
                    (kill-new tags)
                    (message "Suggested tags: %s (copied to kill ring)" tags))))))

(defun bmg/summarize-paper ()
  "Generate AI summary for current paper note and insert at end of buffer.
Bound to SPC z a s and C-c A s."
  (interactive)
  (unless (derived-mode-p 'org-mode)
    (user-error "Not in an org buffer"))
  (let ((content (buffer-substring-no-properties
                  (point-min)
                  (min (point-max) (+ (point-min) bmg/llm-context-medium)))))
    (when (string-empty-p (string-trim content))
      (user-error "Buffer is empty, nothing to summarize"))
    (message "Generating summary...")
    (bmg/llm--request content
      :label "Summarization"
      :system "Summarize this academic paper or research note. Provide:

1. **One-paragraph summary** - The key contribution and findings
2. **Key contributions** (3-5 bullet points)
3. **Methodology** - How they achieved their results
4. **Relevance** - How this connects to security/systems research
5. **Potential connections** - Related areas or follow-up questions

Use org-mode formatting with ** for headings."
      :callback (lambda (response info)
                  ;; Insert into the buffer the request came from,
                  ;; not whatever is current when the reply arrives.
                  (let ((buf (plist-get info :buffer)))
                    (if (not (buffer-live-p buf))
                        (message "Buffer gone, summary discarded")
                      (with-current-buffer buf
                        (save-excursion
                          (goto-char (point-max))
                          (insert "\n\n* AI Summary\n:PROPERTIES:\n:GENERATED: "
                                  (format-time-string "[%Y-%m-%d %a %H:%M]")
                                  "\n:END:\n\n" response))
                        (message "Summary inserted at end of %s" (buffer-name)))))))))

(defun bmg/process-inbox-item ()
  "Get AI suggestions for processing current GTD inbox item.
Run on a heading tagged :REFILE: for processing guidance.
Bound to localleader B in org-mode and C-c A p."
  (interactive)
  (unless (derived-mode-p 'org-mode)
    (user-error "Not in an org buffer"))
  (when (org-before-first-heading-p)
    (user-error "Point must be on an inbox item"))
  ;; save-restriction, not a manual widen: save-excursion does not save
  ;; the restriction, so the old code wiped any narrowing the user had.
  (let ((content (save-restriction
                   (org-narrow-to-subtree)
                   (buffer-substring-no-properties
                    (point-min)
                    (min (point-max) (+ (point-min) bmg/llm-context-medium))))))
    (bmg/llm--request content
      :label "Inbox processing"
      :system "You are a GTD (Getting Things Done) assistant. Analyze this inbox item and suggest:

1. **Actionable?** - Is this actionable or reference material?
2. **Next action** - If actionable, what's the specific next physical action?
3. **Project** - Suggested project category:
   - @Project (code/engineering work)
   - @Research (academic/investigation)
   - @Reading (papers, books, articles)
   - @Training (learning, courses)
   - @Someday (future/maybe items)
4. **Tags** - 2-3 relevant org-mode tags in :tag1:tag2: format
5. **Org-roam?** - Should this become a permanent note in the knowledge base?
6. **Priority** - Suggested priority (A/B/C)

Be concise and actionable."
      :callback (lambda (response _info)
                  (bmg/llm--display-org-buffer "*Inbox Processing*"
                                               "* Processing Suggestion\n\n"
                                               response)))))

(defun bmg/kb--search-files (question)
  "Return org files under `org-roam-directory' ranked by relevance to QUESTION.
Extracts keywords (words longer than 3 chars) from QUESTION, greps for
each with ripgrep, and ranks files by how many distinct keywords hit."
  (unless (executable-find "rg")
    (user-error "rg (ripgrep) not found in PATH"))
  (let ((keywords (seq-filter (lambda (w) (> (length w) 3))
                              (split-string (downcase question) "[^[:alnum:]]+" t)))
        (dir (expand-file-name org-roam-directory))
        (scores (make-hash-table :test #'equal)))
    (dolist (kw keywords)
      (dolist (file (split-string
                     (shell-command-to-string
                      (format "rg -li --glob '*.org' %s %s"
                              (shell-quote-argument kw)
                              (shell-quote-argument dir)))
                     "\n" t))
        (puthash file (1+ (gethash file scores 0)) scores)))
    (let (ranked)
      (maphash (lambda (f n) (push (cons f n) ranked)) scores)
      (mapcar #'car (seq-sort-by #'cdr #'> ranked)))))

(defun bmg/ask-knowledge-base (question)
  "Ask a question answered from your org-roam notes (RAG).
Searches notes, retrieves relevant content, and uses LLM to answer.
Bound to SPC s Q."
  (interactive "sQuestion: ")
  (when (string-empty-p (string-trim question))
    (user-error "Please provide a question"))
  (message "Searching knowledge base...")
  (let* ((top-files (seq-take (bmg/kb--search-files question) 5))
         (context ""))
    (if (null top-files)
        (message "No relevant notes found for: %s" question)
      ;; Build context from top matching files
      (dolist (file top-files)
        (when (and file (file-exists-p file))
          (condition-case nil
              (with-temp-buffer
                (insert-file-contents file nil 0 bmg/llm-context-small)
                (setq context (concat context
                                      "\n\n--- " (file-name-nondirectory file) " ---\n"
                                      (buffer-string))))
            (file-error nil))))  ; Skip files that can't be read
      (if (string-empty-p context)
          (message "Could not read any matching files for: %s" question)
        (bmg/llm--request
         (format "Context from knowledge base:\n%s\n\n---\n\nQuestion: %s" context question)
         :label "Knowledge base query"
         :system "You are a research assistant with access to the user's personal knowledge base.
Answer the question based ONLY on the provided context from their notes.
- Cite which notes/files support your answer
- Quote relevant passages when helpful
- If the context doesn't contain relevant information, say so clearly
- Be concise but thorough
- Use org-mode formatting"
         :callback (lambda (response _info)
                     (bmg/llm--display-org-buffer "*KB Answer*"
                       "* Answer to: " question "\n\n" response "\n\n* Sources\n"
                       (mapconcat (lambda (f)
                                    (format "- [[file:%s][%s]]\n"
                                            f (file-name-nondirectory f)))
                                  (seq-filter #'identity top-files)
                                  ""))))))))

(defun bmg/find-related-notes ()
  "Use AI to find semantically related notes to current buffer.
Analyzes current note and suggests related notes from org-roam.
Bound to SPC z R."
  (interactive)
  (require 'org-roam)
  (let* ((node-at-point (org-roam-node-at-point))
         (current-title (or (and node-at-point (org-roam-node-title node-at-point))
                            (org-get-title)
                            (buffer-name)))
         (current-tags (if node-at-point
                           (org-roam-node-tags node-at-point)
                         '()))
         (current-content (buffer-substring-no-properties
                           (point-min)
                           (min (point-max)
                                (+ (point-min) bmg/llm-context-tiny))))
         (all-nodes (org-roam-node-list))
         ;; The whole DB does not fit in the prompt, so send the 100
         ;; most plausible candidates -- ranked by shared tags and
         ;; title-word overlap -- instead of whatever arbitrary order
         ;; the DB returned (which silently biased the old sample).
         (title-words (seq-filter (lambda (w) (> (length w) 3))
                                  (split-string (downcase current-title)
                                                "[^[:alnum:]]+" t)))
         (sample-nodes
          (seq-take
           (seq-sort-by
            (lambda (n)
              (+ (* 2 (length (seq-intersection (org-roam-node-tags n)
                                                current-tags #'equal)))
                 (length (seq-intersection
                          (split-string (downcase (org-roam-node-title n))
                                        "[^[:alnum:]]+" t)
                          title-words #'equal))))
            #'> all-nodes)
           100)))
    (unless all-nodes
      (user-error "No org-roam nodes found. Is org-roam database initialized?"))
    (when (string-empty-p (string-trim current-content))
      (user-error "Buffer is empty, nothing to analyze"))
    (let ((node-list (mapcar (lambda (n)
                               (format "- %s [tags: %s]"
                                       (org-roam-node-title n)
                                       (string-join (or (org-roam-node-tags n) '()) ", ")))
                             sample-nodes)))
      (message "Finding related notes among %d of %d nodes..."
               (length sample-nodes) (length all-nodes))
      (bmg/llm--request
       (format "Current note: %s
Tags: %s

Content excerpt:
%s

---

Candidate notes from the knowledge base (a relevance-ranked sample of %d out of %d):
%s

---

Which 5-10 notes are most likely related to the current note? Consider:
1. Topic similarity
2. Shared concepts or terminology
3. Research connections (same authors, citations, domains)
4. Potential for linking

For each suggested note, briefly explain WHY it might be related."
               current-title
               (string-join current-tags ", ")
               current-content
               (length sample-nodes)
               (length all-nodes)
               (string-join node-list "\n"))
       :label "Related-notes search"
       :system "You are a knowledge management assistant helping discover connections in a Zettelkasten.
Identify notes that are semantically or conceptually related, even if they don't share obvious tags.
Focus on finding non-obvious but meaningful connections."
       :callback (lambda (response _info)
                   (bmg/llm--display-org-buffer "*Related Notes*"
                                                "* Notes Related to: " current-title
                                                "\n\n" response))))))

(defun bmg/generate-weekly-review ()
  "Generate AI-powered weekly review of knowledge base activity.
Summarizes notes modified this week, identifies themes, suggests connections."
  (interactive)
  (require 'org-roam)
  (let* ((week-ago (time-subtract (current-time) (days-to-time 7)))
         (recent-files '())
         (summaries ""))
    ;; Find recently modified org-roam files
    (dolist (file (org-roam-list-files))
      (when (time-less-p week-ago (file-attribute-modification-time (file-attributes file)))
        (push file recent-files)))
    (if (null recent-files)
        (message "No notes modified in the past week")
      ;; Build summary of each file; one unreadable file (deleted but
      ;; still indexed) must not abort the whole review
      (dolist (file (seq-take recent-files 15))
        (if (not (file-readable-p file))
            (message "Skipping unreadable file: %s" file)
          (with-temp-buffer
            (insert-file-contents file nil 0 1500)
            (setq summaries (concat summaries
                                    "\n\n--- " (file-name-nondirectory file) " ---\n"
                                    (buffer-string))))))
      (message "Generating weekly review for %d notes..." (length recent-files))
      (bmg/llm--request summaries
        :label "Weekly review"
        :system "Generate a weekly review of knowledge base activity. Based on the notes modified this week:

1. **Activity Summary** - What areas received attention this week?
2. **Key Themes** - What patterns or topics emerge across the notes?
3. **Notable Insights** - Any interesting ideas or connections worth highlighting?
4. **Suggested Connections** - Notes that might benefit from being linked together
5. **Gaps Identified** - Areas that might need more exploration
6. **Recommended Focus** - Suggestions for next week's focus

Use org-mode formatting. Be concise but insightful."
        :callback (lambda (response _info)
                    (bmg/llm--display-org-buffer "*Weekly Review*"
                      (format "#+title: Weekly Review %s\n#+filetags: :review:weekly:\n\n"
                              (format-time-string "%Y-%m-%d"))
                      response
                      (format "\n\n* Files Modified This Week (%d total)\n"
                              (length recent-files))
                      (mapconcat (lambda (f)
                                   (format "- [[file:%s][%s]]\n"
                                           f (file-name-nondirectory f)))
                                 (seq-take recent-files 20)
                                 "")))))))

(defun bmg/check-tag-consistency ()
  "Scan org-roam notes and report on tag usage patterns.
Shows tag frequency, potential duplicates, and suggestions."
  (interactive)
  (require 'org-roam)
  (let ((tag-counts (make-hash-table :test 'equal))
        (nodes (org-roam-node-list)))
    ;; Count all tags
    (dolist (node nodes)
      (dolist (tag (org-roam-node-tags node))
        (puthash tag (1+ (gethash tag tag-counts 0)) tag-counts)))
    ;; Build report
    (let* ((sorted-tags (let (acc)
                          (maphash (lambda (k v) (push (cons k v) acc)) tag-counts)
                          (sort acc (lambda (a b) (> (cdr a) (cdr b))))))
           ;; Potential duplicates: same tag under different casing
           (dup-lines (let ((lower-tags (make-hash-table :test 'equal))
                            (lines ""))
                        (dolist (tag-count sorted-tags)
                          (let ((lower (downcase (car tag-count))))
                            (push (car tag-count) (gethash lower lower-tags))))
                        (maphash (lambda (_k v)
                                   (when (> (length v) 1)
                                     (setq lines (concat lines (format "- %s\n" (string-join v ", "))))))
                                 lower-tags)
                        lines)))
      (bmg/llm--display-org-buffer "*Tag Consistency Report*"
        "* Tag Consistency Report\n\n"
        (format "Total nodes: %d\n" (length nodes))
        (format "Unique tags: %d\n\n" (hash-table-count tag-counts))
        "** Tag Frequency\n"
        (mapconcat (lambda (tc) (format "| %s | %d |\n" (car tc) (cdr tc)))
                   sorted-tags "")
        "\n** Potential Duplicates (case variations)\n"
        dup-lines))))

(defun bmg/find-orphan-notes ()
  "Find org-roam notes with no backlinks or forward links.
These might need attention or could be candidates for archival."
  (interactive)
  (require 'org-roam)
  (let ((orphans '())
        (nodes (org-roam-node-list)))
    (dolist (node nodes)
      (let* ((backlinks (org-roam-backlinks-get node))
             (file (org-roam-node-file node)))
        ;; Check for links in the file; a stale DB entry pointing at a
        ;; deleted file must not abort the whole scan
        (when (file-readable-p file)
          (with-temp-buffer
            (insert-file-contents file)
            (let ((has-links (re-search-forward "\\[\\[id:" nil t)))
              (when (and (null backlinks) (not has-links))
                (push node orphans)))))))
    (bmg/llm--display-org-buffer "*Orphan Notes*"
      "* Orphan Notes (no links in or out)\n\n"
      (format "Found %d orphan notes out of %d total\n\n"
              (length orphans) (length nodes))
      (mapconcat (lambda (node)
                   (format "- [[file:%s][%s]] [%s]\n"
                           (org-roam-node-file node)
                           (org-roam-node-title node)
                           (string-join (org-roam-node-tags node) ", ")))
                 orphans ""))))

;;;
;;;
;;; END_llm
;;;
;;;

Org

;;;
;;;
;;; BEGIN_ORG
;;;
;;;

(defun bmg/switch-to-agenda ()
  "Switch to org-agenda overview view.
Opens the custom `o' agenda command configured in
`org-agenda-custom-commands'."
  (interactive)
  (org-agenda nil "o"))

(defun bmg/archive-all-done ()
  "Archive all DONE tasks in the current buffer to the archive file."
  (interactive)
  (let ((count 0))
    (org-map-entries
     (lambda ()
       (org-archive-subtree)
       (setq count (1+ count))
       (setq org-map-continue-from (org-element-property :begin (org-element-at-point))))
     "/DONE" 'file)
    (message "Archived %d DONE tasks" count)))

;; change `org-directory'. It must be set before org loads!
(setopt org-directory "~/Documents/org/"
       org-archive-location (concat org-directory "/archive.org_archive::datetree/")
       org-id-link-to-org-use-id t
       org-ellipsis ""
       org-src-fontify-natively t
       org-hide-emphasis-markers t
       org-modern-star 'replace)

;; Set AFTER org loads: Doom's +org-init-appearance-h runs on
;; org-load-hook and resets org-startup-folded to nil, silently
;; clobbering a value set at config time.
(with-eval-after-load 'org
  (setopt org-startup-folded 'fold))


(setopt org-roam-directory (file-truename (concat org-directory "roam/"))
       org-roam-extract-new-file-path "${slug}.org"
       org-default-notes-file (expand-file-name (format "inbox-%s.org" (system-name)) org-roam-directory)
       ;; defcustom type is (repeat string) -- a bare string makes
       ;; org-noter's dolist crash with wrong-type-argument listp
       org-noter-notes-search-path (list org-roam-directory))

;; Point Doom's org-capture templates to roam inbox for unified capture
(setq +org-capture-todo-file org-default-notes-file
      +org-capture-notes-file org-default-notes-file)

;; org-protocol for browser integration (Chrome/Firefox capture)
;; Requires desktop file handler configured in NixOS/home-manager
;; Deferred to after org loads to improve startup time

;; Add org-protocol capture templates for web capture
;; "w" - Web page capture (link + optional selection)
;; "W" - Web page capture with immediate finish (quick bookmark)
(with-eval-after-load 'org
  (require 'org-protocol)
  (setopt org-protocol-default-template-key "w")
  (add-to-list 'org-capture-templates
               '("w" "Web Capture" entry
                 (file+headline org-default-notes-file "Inbox")
                 "* %?[[%:link][%:description]] :REFILE:web:\n:PROPERTIES:\n:CREATED: %U\n:SOURCE: %:link\n:END:\n%i"
                 :prepend t))
  (add-to-list 'org-capture-templates
               '("W" "Web Capture (quick)" entry
                 (file+headline org-default-notes-file "Inbox")
                 "* [[%:link][%:description]] :REFILE:web:\n:PROPERTIES:\n:CREATED: %U\n:SOURCE: %:link\n:END:\n%i"
                 :prepend t
                 :immediate-finish t))
  ;; Fleeting/quick note - appends to inbox with REFILE tag for GTD processing.
  ;; Lives here (not in org-roam-capture-templates) because file+headline is
  ;; an org-capture target type, which org-roam templates do not accept.
  (add-to-list 'org-capture-templates
               '("f" "Fleeting note" entry
                 (file+headline org-default-notes-file "Inbox")
                 "* %? :REFILE:\n:PROPERTIES:\n:CREATED: %U\n:END:\n%i"
                 :prepend t)))

;; Org-roam capture templates
;; Uses subdirectories for organization + filetags for filtering.
;; Subdirectories are pre-created below -- there is no :mkdir template
;; keyword in org-roam v2 (the old one here was silently ignored).
(with-eval-after-load 'org-roam
  (dolist (d '("refs" "meetings" "projects"))
    (make-directory (expand-file-name d org-roam-directory) t)))

(setq org-roam-capture-templates
       '(;; Default note - flat in roam directory
         ("d" "default" plain "%?"
          :target (file+head "${slug}.org"
                   "#+title: ${title}\n#+created: %u\n#+last_modified: %U\n#+filetags:\n\n")
          :unnarrowed t)

         ;; Literature/reference note
         ("r" "reference" plain "%?"
          :target (file+head "refs/${slug}.org"
                   "#+title: ${title}\n#+created: %u\n#+roam_refs: ${ref}\n#+filetags: :reference:\n\n* Summary\n\n* Notes\n")
          :unnarrowed t)

         ;; Meeting note
         ("m" "meeting" plain "%?"
          :target (file+head "meetings/%<%Y%m%d>-${slug}.org"
                   "#+title: ${title}\n#+date: %<%Y-%m-%d>\n#+created: %u\n#+filetags: :meeting:\n\n* Attendees\n\n* Agenda\n\n* Notes\n\n* Action Items\n")
          :unnarrowed t)))

;; Org-roam dailies configuration
;; Ensure daily notes directory exists
(setopt org-roam-dailies-directory "daily/")
(with-eval-after-load 'org-roam-dailies
  (make-directory (expand-file-name org-roam-dailies-directory org-roam-directory) t))

(setq org-roam-dailies-capture-templates
       '(("d" "default" entry
          "* %?"
          :target (file+head "%<%Y-%m-%d>.org"
                   "#+title: %<%Y-%m-%d %A>\n#+created: %u\n#+filetags: :daily:\n\n"))
         ("t" "task" entry
          "* TODO %?"
          :target (file+head+olp "%<%Y-%m-%d>.org"
                   "#+title: %<%Y-%m-%d %A>\n#+created: %u\n#+filetags: :daily:\n\n"
                   ("Tasks")))
         ("j" "journal" entry
          "* %<%H:%M> %?"
          :target (file+head+olp "%<%Y-%m-%d>.org"
                   "#+title: %<%Y-%m-%d %A>\n#+created: %u\n#+filetags: :daily:\n\n"
                   ("Journal")))))

;; Per-project capture system
;; Stores project files under roam/projects/{project-name}/ instead of in code repos
;; This keeps org files out of shared repositories while maintaining project organization

(defun bmg/get-project-name ()
  "Get current project name or prompt for one.
Uses projectile if in a project, otherwise offers existing project directories."
  (or (and (fboundp 'projectile-project-p)
           (projectile-project-p)
           (projectile-project-name))
      (completing-read "Project: "
                       (when (file-directory-p (expand-file-name "projects" org-roam-directory))
                         (directory-files
                          (expand-file-name "projects" org-roam-directory)
                          nil "^[^.]")))))

(defun bmg/project-org-file (filename)
  "Return path to FILENAME in current project's org directory.
Creates directory and initializes file with proper headers if needed."
  (let* ((project (bmg/get-project-name))
         (dir (expand-file-name (concat "projects/" project) org-roam-directory))
         (file (expand-file-name filename dir)))
    (unless (file-directory-p dir)
      (make-directory dir t))
    ;; Initialize file with proper headers if it doesn't exist
    (unless (file-exists-p file)
      (with-temp-file file
        (pcase filename
          ("todo.org"
           (insert (format "#+title: %s Tasks\n#+created: %s\n#+filetags: :project:todo:\n\n* Inbox\n\n* Active\n\n* Completed\n"
                           project (format-time-string "[%Y-%m-%d %a]"))))
          ("notes.org"
           (insert (format "#+title: %s Notes\n#+created: %s\n#+filetags: :project:notes:\n\n* Inbox\n\n* Notes\n"
                           project (format-time-string "[%Y-%m-%d %a]"))))
          ("changelog.org"
           (insert (format "#+title: %s Changelog\n#+created: %s\n#+filetags: :project:changelog:\n\n* Unreleased\n\n"
                           project (format-time-string "[%Y-%m-%d %a]"))))
          (_
           (insert (format "#+title: %s - %s\n#+created: %s\n#+filetags: :project:\n\n"
                           project (file-name-sans-extension filename)
                           (format-time-string "[%Y-%m-%d %a]")))))))
    file))

(defun bmg/capture-project-todo ()
  "Capture a TODO for the current project.
Adds to Inbox heading with REFILE tag for GTD processing."
  (interactive)
  (let ((file (bmg/project-org-file "todo.org")))
    (find-file file)
    (goto-char (point-min))
    (re-search-forward "^\\* Inbox" nil t)
    (org-end-of-subtree)
    ;; Land between the two spaces so the typed title keeps a
    ;; separating space before :REFILE: (8 puts point flush against
    ;; the tag, and typing there destroys it).
    (insert "\n** TODO  :REFILE:")
    (backward-char 9)))

(defun bmg/capture-project-note ()
  "Capture a note for the current project.
Adds to Inbox heading with REFILE tag for GTD processing."
  (interactive)
  (let ((file (bmg/project-org-file "notes.org")))
    (find-file file)
    (goto-char (point-min))
    (re-search-forward "^\\* Inbox" nil t)
    (org-end-of-subtree)
    (insert (format "\n** %s  :REFILE:" (format-time-string "[%Y-%m-%d %a]")))
    (backward-char 9)))

(defun bmg/capture-project-roam-note ()
  "Capture a roam note for the current project.
Creates a new org-roam node in projects/{project-name}/ directory."
  (interactive)
  (let* ((project (bmg/get-project-name))
         (org-roam-capture-templates
          `(("p" "project note" plain "%?"
             :target (file+head
                      ,(format "projects/%s/${slug}.org" project)
                      ,(format "#+title: ${title}\n#+created: %%u\n#+filetags: :project:%s:\n\n" project))
             :unnarrowed t))))
    ;; Per-project dir cannot be pre-created at load time; do it here.
    (make-directory (expand-file-name (format "projects/%s" project)
                                      org-roam-directory)
                    t)
    (org-roam-capture nil "p")))

(defun bmg/open-project-todo ()
  "Open the TODO file for the current project."
  (interactive)
  (find-file (bmg/project-org-file "todo.org")))

(defun bmg/open-project-notes ()
  "Open the notes file for the current project."
  (interactive)
  (find-file (bmg/project-org-file "notes.org")))

;; Override Doom's project capture functions to use roam/projects/ instead of project-root
;; This makes C-c n n → p → t/n/c use our roam directory
(defadvice! bmg/org-capture-project-todo-file-a ()
  "Override to use roam/projects/{project}/todo.org instead of project-root."
  :override #'+org-capture-project-todo-file
  (bmg/project-org-file "todo.org"))

(defadvice! bmg/org-capture-project-notes-file-a ()
  "Override to use roam/projects/{project}/notes.org instead of project-root."
  :override #'+org-capture-project-notes-file
  (bmg/project-org-file "notes.org"))

(defadvice! bmg/org-capture-project-changelog-file-a ()
  "Override to use roam/projects/{project}/changelog.org instead of project-root."
  :override #'+org-capture-project-changelog-file
  (bmg/project-org-file "changelog.org"))

;; org-mem https://github.com/meedstrom/org-mem is a complimentary package to org roam
;; can use it ot extract certain properties of the roam db. In addition it also provides fast search functionality,
;; such as the ability to populate the org aganda file list with only relevant TODO items
(use-package org-mem
  :after org
  :config
  (setopt org-mem-watch-dirs (list org-roam-directory)
         ;;org-mem-do-sync-with-org-id t
         org-roam-db-update-on-save nil
         org-mem-roamy-do-overwrite-real-db t)
  ;; Reduce GC during org-roam operations
  ;; Use setq because org-roam-db-gc-threshold has a broken (int) widget type
  (setq org-roam-db-gc-threshold most-positive-fixnum)

  (defun bmg/org-mem-set-agenda-files (&rest _)
    "Derive `org-agenda-files' from org-mem: files with TODOs or dates.
Runs on every org-mem full scan, so it uses the raw -int accessors
\(no string formatting/memoization churn) and only sets the
variable when the list actually changed.  The archive filter only
looks at the file NAME -- a note like internet-archive.org must
not be dropped because of its path."
    (let ((files (cl-loop
                  for file in (org-mem-all-files)
                  unless (string-search "archive" (file-name-nondirectory file))
                  when (seq-find (lambda (entry)
                                   (or (org-mem-entry-todo-state entry)
                                       (org-mem-entry-active-timestamps-int entry)
                                       (org-mem-entry-scheduled-int entry)
                                       (org-mem-entry-deadline-int entry)))
                                 (org-mem-entries-in file))
                  collect file)))
      (unless (equal files org-agenda-files)
        (setopt org-agenda-files files))))
  (add-hook 'org-mem-post-full-scan-functions #'bmg/org-mem-set-agenda-files)
  (org-mem-updater-mode)
  (org-mem-roamy-db-mode)
  ;; Doom's +roam module calls org-roam-db-autosync-enable which adds a
  ;; save hook that conflicts with org-mem-roamy-db-mode (both try to
  ;; insert into the same SQLite table, causing UNIQUE constraint errors).
  ;; Disable org-roam's autosync since org-mem handles DB updates.
  (org-roam-db-autosync-mode -1)

  ;; Workaround: org-mem's DB insert uses `prin1' which preserves Emacs
  ;; text properties (e.g. ws-butler-chg) as #(...) syntax.  EmacSQL's
  ;; `read-from-string' chokes on these, causing "unhandled condition"
  ;; errors in org-roam-node-list queries.  Strip properties before insert.
  (define-advice org-mem-roamy--mk-literal-input-quoted-like-emacsql
      (:filter-args (args) strip-text-properties)
    (list (mapcar (lambda (row)
                    (mapcar (lambda (val)
                              (if (stringp val)
                                  (substring-no-properties val)
                                val))
                            row))
                  (car args)))))

(setopt org-log-done 'time
       org-log-into-drawer t
       org-log-state-notes-insert-after-drawers nil)

(use-package org-roam-ui
  :after org-roam
  :commands (org-roam-ui-mode org-roam-ui-open)
  :config
  (setopt org-roam-ui-sync-theme t
         org-roam-ui-follow t
         org-roam-ui-update-on-save t
         org-roam-ui-open-on-start nil))

;; citations and reference managment
(setopt citar-bibliography (list (concat org-directory "emacs_lit.bib"))
       citar-library-paths '("~/Documents/Papers/")
       citar-notes-paths (list org-roam-directory))

(with-eval-after-load 'citar
  ;; define the keymap to allow citar to hand over to biblio to do
  ;; a reference lookup
  (defvar bmg/my-citar-embark-become-map
    (let ((map (make-sparse-keymap)))
      ;; embark-become only offers this map when the CURRENT minibuffer
      ;; command is bound in it, so the umbrella citar-open must be here
      ;; for the citar->biblio hand-over to activate at all.
      (define-key map (kbd "o") 'citar-open)
      (define-key map (kbd "f") 'citar-open-files)
      (define-key map (kbd "x") 'biblio-arxiv-lookup)
      (define-key map (kbd "c") 'biblio-crossref-lookup)
      (define-key map (kbd "i") 'biblio-ieee-lookup)
      (define-key map (kbd "h") 'biblio-hal-lookup)
      (define-key map (kbd "s") 'biblio-dissemin-lookup)
      (define-key map (kbd "b") 'biblio-dblp-lookup)
      (define-key map (kbd "d") 'biblio-doi-insert-bibtex)
      map)
    "Citar Embark become keymap for biblio lookup."))

;; tell embark about the citar keymap after embark loads
(with-eval-after-load 'embark
  (add-to-list 'embark-become-keymaps 'bmg/my-citar-embark-become-map))


;; Only genuinely exclusive @contexts live inside the :startgroup --
;; tags in a group are mutually exclusive in fast tag selection, so
;; modifier tags (Important, Emacs, uni, PERSONAL) must stay outside
;; or adding one silently removes the @context already on the item.
(setopt org-tag-alist '((:startgroup . nil)
                       ("@Project" . ?p)
                       ("@Reading" . ?r)
                       ("@Someday" . ?s)
                       ("@Training" . ?t)
                       ("@Courses" . ?c)
                       ("@Research" . ?R)
                       ("@Issue" . ?i)
                       (:endgroup . nil)
                       ("uni" . ?u)
                       ("Emacs" . ?e)
                       ("Important" . ?I)
                       ("PERSONAL" . ?P)))

(use-package org-super-agenda
  :after org-agenda
  :init
  (setopt
   ;; more structured view
   org-agenda-prefix-format
   '((agenda . " %i %-20:c %?-12t %12s")
     (todo . " %i %-20:c ")
     (tags . " %i %-20:c ")
     (search . " %i %-20:c "))
   org-agenda-todo-keyword-format "%-6s"
   org-agenda-tags-column -120)

  (setopt org-agenda-time-grid '((daily today require-timed)
                                (800 1200 1600 2000)
                                "......"
                                "----------------")
         org-agenda-skip-scheduled-if-done t
         org-agenda-skip-deadline-if-done t
         org-agenda-include-deadlines t
         org-agenda-include-diary nil
         org-agenda-block-separator nil
         org-agenda-compact-blocks t
         org-agenda-start-with-log-mode 'clockcheck
         org-agenda-span 1
         org-agenda-start-day nil) ;; i.e. today

  ;; Custom agenda view aligned with Doom's org-todo-keywords
  ;; GTD workflow: Inbox/REFILE items appear at top for processing
  (setq org-agenda-custom-commands
         '(("o" "Overview"
            ((agenda "" ((org-agenda-span 'week)
                         (org-agenda-start-on-weekday 0) ;; Sunday
                         (org-super-agenda-groups
                          '((:name "Today"
                             :time-grid t
                             :date today
                             :scheduled today
                             :order 1)))))
             (alltodo "" ((org-agenda-overriding-header "")
                          ;; Groups match in LIST order (:order only affects
                          ;; display), so selective groups must precede the
                          ;; broad TODO/STRT catch-alls or they never match.
                          (org-super-agenda-groups
                           '(;; Discard first, before any group can claim these.
                             ;; NB: tag match is case-insensitive, so this also
                             ;; drops items inheriting a :daily: filetag.
                             (:discard (:tag ("Chore" "Routine" "Daily")))
                             (:name "📥 Inbox - Process these first"
                              :tag "REFILE"
                              :order 0)
                             (:name "Due Today"
                              :deadline today
                              :order 2)
                             (:name "Overdue"
                              :deadline past
                              :face error
                              :order 7)
                             (:name "Due Soon"
                              :deadline future
                              :order 8)
                             (:name "Important"
                              :tag "Important"
                              :priority "A"
                              :order 6)
                             (:name "Personal"
                              :tag "PERSONAL"
                              :order 12)
                             (:name "Issues"
                              :tag "@Issue"
                              :order 12)
                             (:name "Emacs"
                              :tag "Emacs"
                              :order 13)
                             (:name "Projects"
                              :todo "PROJ"
                              :tag "@Project"
                              :order 14)
                             (:name "Research"
                              :tag "@Research"
                              :order 15)
                             (:name "Training/Courses"
                              :tag ("@Training" "@Courses")
                              :order 16)
                             (:name "To read"
                              :tag "@Reading"
                              :order 30)
                             (:name "Waiting"
                              :todo ("HOLD" "WAIT")
                              :order 20)
                             (:name "University"
                              :tag "uni"
                              :order 32)
                             (:name "Someday"
                              :priority<= "C"
                              :tag "@Someday"
                              :todo "IDEA"
                              :order 90)
                             (:name "Recurring"
                              :todo "LOOP"
                              :order 4)
                             (:name "Ongoing"
                              :todo "STRT"
                              :order 3)
                             (:name "Next to do"
                              :todo "TODO"
                              :order 3)))))))))
  :config
  (org-super-agenda-mode))

;; Helper functions for org workflow

(defun bmg/org-roam-review-week ()
  "Open all daily notes from the past week for review."
  (interactive)
  (require 'org-roam-dailies)
  (let ((files '()))
    (dotimes (i 7)
      (let* ((date (time-subtract (current-time) (days-to-time i)))
             ;; No org-roam-dailies--daily-note-path in org-roam;
             ;; mirror the dailies template's "%<%Y-%m-%d>.org" target.
             (file (expand-file-name
                    (format-time-string "%Y-%m-%d.org" date)
                    (expand-file-name org-roam-dailies-directory
                                      org-roam-directory))))
        (when (file-exists-p file)
          (push file files))))
    (if files
        (progn
          (dolist (file (reverse files))
            (find-file-other-window file))
          (message "Opened %d daily notes from the past week" (length files)))
      (message "No daily notes found for the past week"))))

;;;
;;;
;;; END_ORG
;;;
;;;

Map

;;;
;;;
;;; BEGIN_Map
;;;
;;;

;; consult-org-roam for enhanced search with live preview
(use-package consult-org-roam
  :after org-roam
  :config
  (consult-org-roam-mode 1)
  (setopt consult-org-roam-grep-func #'consult-ripgrep))

;; SPC z for org-roam - shorter than Doom's M-SPC m m / C-c l m
;; Uses consult-org-roam for enhanced find/search with live preview
(with-eval-after-load 'org-roam
  (map! :leader
        (:prefix-map ("z" . "org-roam")
         "c" #'org-roam-capture
         "D" #'org-roam-demote-entire-buffer
         "f" #'consult-org-roam-file-find      ; Enhanced with preview
         "F" #'org-roam-ref-find
         "g" #'org-roam-graph
         "i" #'org-roam-node-insert
         "I" #'org-id-get-create
         "t" #'org-roam-buffer-toggle
         "T" #'org-roam-buffer-display-dedicated
         "r" #'org-roam-refile
         "R" #'bmg/find-related-notes          ; AI: Find related notes
         "s" #'consult-org-roam-search         ; Full-text search
         "S" #'bmg/suggest-tags-for-buffer     ; AI: Suggest tags
         "b" #'consult-org-roam-backlinks      ; Interactive backlinks
         "l" #'consult-org-roam-forward-links  ; Forward links
         "w" #'bmg/generate-weekly-review      ; AI: Weekly review
         (:prefix ("a" . "AI/analysis")
          :desc "Suggest tags"        "t" #'bmg/suggest-tags-for-buffer
          :desc "Summarize paper"     "s" #'bmg/summarize-paper
          :desc "Find related"        "r" #'bmg/find-related-notes
          :desc "Weekly review"       "w" #'bmg/generate-weekly-review
          :desc "Check tags"          "c" #'bmg/check-tag-consistency
          :desc "Find orphans"        "o" #'bmg/find-orphan-notes)
         (:prefix ("d" . "dailies")
          "b" #'org-roam-dailies-goto-previous-note
          "d" #'org-roam-dailies-goto-date
          "D" #'org-roam-dailies-capture-date
          "f" #'org-roam-dailies-goto-next-note
          "m" #'org-roam-dailies-goto-tomorrow
          "M" #'org-roam-dailies-capture-tomorrow
          "n" #'org-roam-dailies-capture-today
          "t" #'org-roam-dailies-goto-today
          "T" #'org-roam-dailies-capture-today
          "y" #'org-roam-dailies-goto-yesterday
          "Y" #'org-roam-dailies-capture-yesterday
          "-" #'org-roam-dailies-find-directory)
         (:prefix ("o" . "node properties")
          "a" #'org-roam-alias-add
          "A" #'org-roam-alias-remove
          "t" #'org-roam-tag-add
          "T" #'org-roam-tag-remove
          "r" #'org-roam-ref-add
          "R" #'org-roam-ref-remove)
         (:prefix ("p" . "project")
          :desc "Project todo"      "t" #'bmg/capture-project-todo
          :desc "Project note"      "n" #'bmg/capture-project-note
          :desc "Project roam note" "p" #'bmg/capture-project-roam-note
          :desc "Open project todo" "T" #'bmg/open-project-todo
          :desc "Open project notes" "N" #'bmg/open-project-notes)
         (:prefix ("u" . "UI")
          :desc "Open graph"        "u" #'org-roam-ui-open
          :desc "Toggle UI mode"    "m" #'org-roam-ui-mode))))

(map!
 (;;: org-agenda
  (:leader
        ;;; <leader> n --- notes
   (:prefix "n"
    :desc "Org agenda"  "a" #'bmg/switch-to-agenda))

  (:map org-agenda-mode-map
        "i"                       #'org-agenda-clock-in
        "R"                       #'org-agenda-refile))

 (;;: open submenu
  (:leader
        ;;; <leader> o --- open
   (:prefix "o"
    :desc "Url"  "u" #'browse-url
    :desc "Web"  "w" #'browse-url)))

 (;;: crux and stuff
  (:leader
        ;;;  <leader> b --- prelude
   (:prefix-map ("b" . "prelude")
    :desc "crux-cleanup-buffer-or-region"          "c" #'crux-cleanup-buffer-or-region
    :desc "crux-duplicate-current-line-or-region"  "d" #'crux-duplicate-current-line-or-region
    :desc "crux-delete-file-and-buffer"            "D" #'crux-delete-file-and-buffer
    :desc "crux-kill-other-buffers"                "k" #'crux-kill-other-buffers
    :desc "crux-open-with"                         "o" #'crux-open-with
    ;; the canonical autoloaded command; the -buffer-and-file alias is
    ;; void until crux happens to load
    :desc "crux-rename-file-and-buffer"            "r" #'crux-rename-file-and-buffer
    :desc "crux-transpose-windows"                 "s" #'crux-transpose-windows
    :desc "crux-view-url"                          "u" #'crux-view-url
    :desc "crux-indent-defun"                      "TAB" #'crux-indent-defun
    :desc "Elfeed RSS enter"                       "e" #'=rss))

  (:leader
   (:prefix "s"
    :desc "Search papers (rga)"                    "P" #'bmg/rga-search
    :desc "Ask knowledge base"                     "Q" #'bmg/ask-knowledge-base
    :desc "Search knowledge base"                  "k" #'bmg/search-knowledge-base)))
 ) ;; END MAP

;; Localleader bindings for org-mode AI functions (C-c l in org-mode)
(with-eval-after-load 'org
  (map! :map org-mode-map
        :localleader
        ;; "B" not "P": localleader P is Doom's org-publish prefix
        ;; (org-publish-all & friends) and must stay reachable.
        :desc "Process inbox item" "B" #'bmg/process-inbox-item
        :desc "Summarize paper"    "S" #'bmg/summarize-paper))

;; AI knowledge management prefix on the leader (C-c A / M-SPC A).
;; NOT a plain (global-set-key (kbd "C-c a") ...): Doom's leader map wins
;; for C-c sequences and already binds "a" to embark-act, which fully
;; shadowed the global binding.
(defvar bmg/ai-command-map
  (let ((map (make-sparse-keymap)))
    ;; Tag and summarization
    (define-key map (kbd "t") #'bmg/suggest-tags-for-buffer)
    (define-key map (kbd "s") #'bmg/summarize-paper)
    (define-key map (kbd "p") #'bmg/process-inbox-item)
    ;; Discovery and search
    (define-key map (kbd "r") #'bmg/find-related-notes)
    (define-key map (kbd "q") #'bmg/ask-knowledge-base)
    (define-key map (kbd "k") #'bmg/search-knowledge-base)
    ;; Review and maintenance
    (define-key map (kbd "w") #'bmg/generate-weekly-review)
    (define-key map (kbd "c") #'bmg/check-tag-consistency)
    (define-key map (kbd "o") #'bmg/find-orphan-notes)
    map)
  "Keymap for AI-powered knowledge management commands.")

(define-key doom-leader-map (kbd "A") (cons "AI" bmg/ai-command-map))

;; ripgrep-all for searching PDFs and documents
(defun bmg/rga-search (query)
  "Search PDFs and documents in Papers directory with ripgrep-all."
  (interactive "sSearch papers: ")
  (unless (executable-find "rga")
    (user-error "rga (ripgrep-all) not found in PATH"))
  (let ((default-directory "~/Documents/Papers/"))
    (compilation-start
     ;; --no-heading --with-filename: grep-mode needs file:line:text
     ;; on every match line to make RET jump to the hit.
     (format "rga --color=never --no-heading --with-filename --line-number %s"
             (shell-quote-argument query))
     'grep-mode
     (lambda (_) "*rga-search*"))))

(defun bmg/search-knowledge-base (query)
  "Search across org-roam notes, PDFs, and bibliography.
Presents results in a unified interface."
  (interactive "sSearch: ")
  (unless (executable-find "rga")
    (user-error "rga (ripgrep-all) not found in PATH"))
  (let ((results-buffer (get-buffer-create "*Knowledge Base Search*")))
    (with-current-buffer results-buffer
      (erase-buffer)
      (insert (format "* Knowledge Base Search: %s\n\n" query))

      ;; 1. Org-roam notes search
      (insert "** Org-roam Notes\n")
      (let ((default-directory org-roam-directory))
        (insert (shell-command-to-string
                 (format "rg --color=never -l -i %s --type org 2>/dev/null | head -20"
                         (shell-quote-argument query)))))
      (insert "\n")

      ;; 2. PDF content search
      (insert "** Papers (PDF content)\n")
      (let ((default-directory "~/Documents/Papers/"))
        (insert (shell-command-to-string
                 (format "rga --color=never --files-with-matches %s 2>/dev/null | head -15"
                         (shell-quote-argument query)))))
      (insert "\n")

      ;; 3. Bibliography search
      (insert "** Bibliography\n")
      (insert (shell-command-to-string
               (format "rg --color=never -i %s %s 2>/dev/null | head -20"
                       (shell-quote-argument query)
                       (shell-quote-argument (expand-file-name (car citar-bibliography))))))

      (org-mode)
      (goto-char (point-min)))
    (pop-to-buffer results-buffer)))

(map! :map dirvish-mode-map
      ;; left click for expand/collapse dir or open file
      "<mouse-1>" #'dirvish-subtree-toggle-or-open
      ;; middle click for opening file / entering dir in other window
      "<mouse-2>" #'dired-mouse-find-file-other-window
      ;; right click for opening file / entering dir
      "<mouse-3>" #'dired-mouse-find-file
      "?"   #'dirvish-dispatch
      "q"   #'dirvish-quit
      "b"   #'dirvish-quick-access
      "f"   #'dirvish-file-info-menu
      "S"   #'dirvish-quicksort
      "F"   #'dirvish-layout-toggle
      "z"   #'dirvish-history-jump
      "TAB" #'dirvish-subtree-toggle
      "M-b" #'dirvish-history-go-backward
      "M-f" #'dirvish-history-go-forward
      "M-n" #'dirvish-narrow
      "M-m" #'dirvish-mark-menu
      "M-s" #'dirvish-setup-menu
      "M-e" #'dirvish-emerge-menu
      ;; dirvish-yank lives under the y-prefix; a top-level "p" would
      ;; shadow dired-previous-line ("P" would shadow dired-do-print).
      (:prefix ("y" . "yank")
               "l"   #'dirvish-copy-file-true-path
               "n"   #'dirvish-copy-file-name
               "p"   #'dirvish-copy-file-path
               "P"   #'dirvish-yank
               "r"   #'dirvish-copy-remote-path
               "y"   #'dired-do-copy)
      (:prefix ("s" . "symlinks")
               "s"   #'dirvish-symlink
               "S"   #'dirvish-relative-symlink
               "h"   #'dirvish-hardlink))

;;;
;;;
;;; END_Map
;;;
;;;

Elfeed Enhancements

;;;
;;;
;;; BEGIN_Elfeed_Enhancements
;;;
;;;

(defun bmg/elfeed-mark-all-as-read ()
  "Mark all entries in current elfeed filter as read."
  (interactive)
  (when (yes-or-no-p "Mark all visible entries as read? ")
    (let ((count 0))
      (save-excursion
        (goto-char (point-min))
        (while (not (eobp))
          (when-let ((entry (elfeed-search-selected :ignore-region)))
            (elfeed-untag entry 'unread)
            (setq count (1+ count)))
          (forward-line)))
      (elfeed-search-update--force)
      (message "Marked %d entries as read" count))))

;; arXiv capture: `C' and `a' both run bmg/elfeed-arxiv-intake
;; (defined in elfeed-config.el), which fetches PDF + BibTeX and
;; creates the roam note in one pass.  The old separate
;; bmg/elfeed-capture-arxiv spliced feed-controlled text into an
;; org-capture template, which would execute %(elisp) from a
;; malicious feed -- do not reintroduce that pattern.
(with-eval-after-load 'elfeed
  (map! :map elfeed-search-mode-map
        "R" #'bmg/elfeed-mark-all-as-read)
  (map! :map elfeed-show-mode-map
        "C" #'bmg/elfeed-arxiv-intake))

;;;
;;;
;;; END_Elfeed_Enhancements
;;;
;;;

reMarkable Integration

Integration between reMarkable Paper Pro tablet and Emacs org-mode. Uses USB Web Interface (http://10.11.99.1) - NO developer mode required!

Setup:

  1. On reMarkable: Settings → Storage → Enable “USB web interface”
  2. Connect device via USB cable
  3. Test: C-c r t (remarkable-test-connection)

The implementation lives in its own file, remarkable-config.el, rather than inline. It is a self-contained pseudo-package (its own defgroup and remarkable- prefix), and it generates a lot of org text: keeping it in a .el file removes the tangling hazard where a column-0 * inside a template string would silently terminate a src block (which previously dropped six functions).

;; Loaded here (end of config) so citar-library-paths and doom-leader-map,
;; referenced at load time, are already set.
(load! "remarkable-config.el")