;;; 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
;;;
;;;
;;;
;;;
;;; 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
;;;
;;;;;;
;;;
;;; 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
;;;
;;;;;;
;;;
;;; 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
;;;
;;;;;;
;;;
;;; 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
;;;
;;;;;;
;;;
;;; 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
;;;
;;;;;;
;;;
;;; 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
;;;
;;;Integration between reMarkable Paper Pro tablet and Emacs org-mode. Uses USB Web Interface (http://10.11.99.1) - NO developer mode required!
Setup:
- On reMarkable: Settings → Storage → Enable “USB web interface”
- Connect device via USB cable
- 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")