diff --git a/doom/.config/doom/README.org b/doom/.config/doom/README.org index 28c9f9f..d841215 100644 --- a/doom/.config/doom/README.org +++ b/doom/.config/doom/README.org @@ -42,6 +42,24 @@ they are implemented. #+begin_src emacs-lisp ;;; $DOOMDIR/config.el -*- lexical-binding: t; -*- #+end_src +** Banner +#+begin_src emacs-lisp +;; 1. Set the image path +(setq fancy-splash-image (expand-file-name "hermes_logo_ascii.png" doom-user-dir)) + +;; 2. Define the quote function +(defun my-dashboard-quote () + (insert "\n" (+dashboard--center +dashboard--width "Simplicity is the ultimate sophistication.") "\n")) + +;; 3. Override the functions list using the correct variable from the docs +(after! doom-dashboard + (setq +dashboard-functions + '(+dashboard-widget-banner + my-dashboard-quote + +dashboard-widget-loaded + +dashboard-widget-shortmenu + +dashboard-widget-footer))) +#+end_src ** User Info @@ -110,7 +128,7 @@ No more auto-discovery of random =.git= repos. #+begin_src emacs-lisp (setq bookmark-save-flag 1) -(setq bookmark-default-file (expand-file-name "bookmarks" (expand-file-name "~/ExoKortex/2-Areas/Home-Infra/2-Assets/bookmark"))) +(setq bookmark-default-file (expand-file-name "~/ExoKortex/2-Areas/Home-Infra/2-Assets/bookmark")) #+end_src ** Buffer auto reload @@ -127,17 +145,7 @@ My current workflow consist in having the 3-5 files I work on open in vertical s #+begin_src emacs-lisp (map! :leader (:prefix ("w" . "window") - :desc "Minimize window" "O" #'minimize-window) - (:prefix ("z" . "Personal") - :desc "Hermes Agent" "h" #'gortium/hermes-vterm)) -#+end_src - -** Hermes remote shortcut - -#+begin_src emacs-lisp -(map! :leader - (:prefix ("z" . "Personal") - :desc "Hermes Agent" "h" #'gortium/hermes-vterm)) + :desc "Minimize window" "O" #'minimize-window)) #+end_src ** Ultra-scroll @@ -333,9 +341,11 @@ org-agenda-files (seq-uniq org-agenda-files) (setq org-agenda-exporter-settings '((ps-print-color-p 'nil))) (setq org-agenda-follow-mode nil - org-agenda-skip-deadline-if-done nil - org-agenda-skip-scheduled-if-done nil - org-agenda-skip-timestamp-if-done nil + org-agenda-skip-deadline-if-done t + org-agenda-skip-scheduled-if-done t + org-agenda-skip-timestamp-if-done t + org-agenda-skip-deadline-prewarning-if-scheduled t + org-agenda-skip-scheduled-if-deadline-is-shown t org-agenda-sticky t org-journal-enable-agenda-integration t org-log-done 'time @@ -726,6 +736,27 @@ org-agenda-files (seq-uniq org-agenda-files) (append '(("\\.x?html?\\'" . default)) (assq-delete-all "\\.x?html?\\'" org-file-apps)))) #+end_src +** Calendar +#+begin_src emacs-lisp +(after! calendar + ;; Define a function to open org-roam daily note for selected date in calendar + (defun gortium/org-roam-dailies-open-at-point () + "Open org-roam daily note for date at point in calendar." + (interactive) + (let ((date (calendar-cursor-to-date))) + (org-roam-dailies-goto-date date))) + ) + +;; Bind RET in calendar mode to open daily note instead of default exit +(evil-define-key 'normal calendar-mode-map + (kbd "RET") #'gortium/org-roam-dailies-open-at-point) + +(after! org + (define-key org-read-date-minibuffer-local-map (kbd "M-h") (kbd "S-")) + (define-key org-read-date-minibuffer-local-map (kbd "M-j") (kbd "S-")) + (define-key org-read-date-minibuffer-local-map (kbd "M-k") (kbd "S-")) + (define-key org-read-date-minibuffer-local-map (kbd "M-l") (kbd "S-"))) +#+end_src ** +Org Superstar mode+ org-modern instead @@ -1205,19 +1236,6 @@ Handles org-clock and context link capture for tasks." (gortium/connecteam-clock-out gortium/connecteam-clock-id)))) (add-hook 'org-clock-out-hook #'gortium/org-clock-out-from-connecteam) - -(after! calendar - ;; Define a function to open org-roam daily note for selected date in calendar - (defun gortium/org-roam-dailies-open-at-point () - "Open org-roam daily note for date at point in calendar." - (interactive) - (let ((date (calendar-cursor-to-date))) - (org-roam-dailies-goto-date date))) - ) - -;; Bind RET in calendar mode to open daily note instead of default exit -(evil-define-key 'normal calendar-mode-map - (kbd "RET") #'gortium/org-roam-dailies-open-at-point) #+end_src *** Old config @@ -1949,23 +1967,260 @@ Org-AI (org-ai-install-yasnippets)) ; if you are using yasnippet and want `ai` snippets #+end_src -** +Hermes Agent+ -Hermes Agent runs on lazyworkhorse server. Launched via SSH in a vterm buffer. +** +Hermes Agent — Project Session Tracking+ + +Hermes chat sessions are automatically linked to ExoKortex projects. +All logic runs inside Emacs — no bash aliases, no wrapper scripts. + +| Binding | Function | +|-------------+---------------------------------------| +| ~SPC p h~ | New session (prompts for title) | +| ~C-u SPC p h~ | New remote session (SSH + Docker) | +| ~SPC p H~ | Browse/resume past sessions (menu) | +| ~hermes:~ | Org link type — click to resume | + +Session ID is captured at session *start* (before vterm opens), so +even if the terminal window is closed immediately, the session is +already saved to ~.hermes-sessions~ at the project root. #+begin_src emacs-lisp -(after! vterm - (defun gortium/hermes-vterm () - "Launch Hermes Agent in vterm via SSH to lazyworkhorse (CLI mode)." - (interactive) - (let ((buf (get-buffer-create "*hermes*"))) - (switch-to-buffer buf) - (unless (and (eq major-mode 'vterm-mode) - (get-buffer-process buf)) - (vterm-mode) - (let ((proc (get-buffer-process buf))) - (when proc - (process-send-string - proc "ssh -t lazyworkhorse 'docker exec -it hermes /opt/hermes/.venv/bin/hermes'\n"))))))) +;;; ==================================================================== +;;; Hermes Session ↔ Project Integration +;;; ==================================================================== + +;; Configuration (customize per-host) +(defvar gortium/hermes-remote-host "lazyworkhorse" + "SSH host for remote Hermes. nil = use local Hermes.") +(defvar gortium/hermes-remote-docker "hermes" + "Docker container name for remote Hermes.") +(defvar gortium/hermes-remote-path + "/opt/hermes/.venv/bin/hermes" + "Path to the hermes executable inside the Docker container.") + +;; ------------------------------------------------------------------- +;; Org link type: [[hermes:20260718_162500_a1b2c3][Resume — Title]] +;; ------------------------------------------------------------------- +(org-link-set-parameters "hermes" + :follow (lambda (id) + (gortium/hermes--open-vterm + (format "hermes chat --resume %s" id) + "*hermes*")) + :help-echo "Resume this Hermes chat session in vterm") + +;; ------------------------------------------------------------------- +;; Helpers — build commands, detect project +;; ------------------------------------------------------------------- + +(defun gortium/hermes--remote-prefix (&optional host) + "Build SSH/Docker prefix for running Hermes commands remotely." + (let ((h (or host gortium/hermes-remote-host))) + (format "ssh %s docker exec %s %s" h + gortium/hermes-remote-docker + gortium/hermes-remote-path))) + +(defun gortium/hermes--cmd (subcmd &optional remote-host) + "Prefix SUBCMD with local 'hermes' or remote SSH/Docker." + (if remote-host + (format "%s %s" (gortium/hermes--remote-prefix remote-host) subcmd) + (format "hermes %s" subcmd))) + +(defun gortium/hermes--project-info () + "Return (ROOT SESSIONS-FILE NAME) or signal error if not in project." + (let ((root (projectile-project-root))) + (if root + (list root + (expand-file-name ".hermes-sessions" root) + (file-name-nondirectory (directory-file-name root))) + (user-error "Not in a Projectile project")))) + +;; ------------------------------------------------------------------- +;; Capture session ID at session start (one-shot, before vterm opens) +;; ------------------------------------------------------------------- +(defun gortium/hermes--capture-session-id (&optional remote-host) + "Create a quick Hermes session and return its ID. +Sends a meaningful init message so history starts with project context. +session_id: is on stderr; 2>&1 merges it so shell-command-to-string catches it." + (let* ((proj (nth 2 (gortium/hermes--project-info))) + (cmd (gortium/hermes--cmd + (format "chat -q \"Init session for %s. Reply ONLY with OK.\" --quiet 2>&1" proj) + remote-host)) + (output (shell-command-to-string cmd))) + (if (string-match "session_id: \\([0-9a-f_]+\\)" output) + (match-string 1 output) + (user-error "Could not capture Hermes session ID")))) + +;; ------------------------------------------------------------------- +;; Append session entry to .hermes-sessions +;; ------------------------------------------------------------------- +(defun gortium/hermes-append-session (sessions-file id title) + "Append session ID and TITLE to SESSIONS-FILE (org format)." + (let ((proj (file-name-nondirectory + (directory-file-name + (file-name-directory sessions-file))))) + (unless (file-exists-p sessions-file) + (with-temp-buffer + (insert "# -*- mode: org -*-\n" + "#+TITLE: Sessions — " proj "\n\n* Sessions\n\n") + (write-region (point-min) (point-max) sessions-file nil 'silent))) + (with-temp-buffer + (insert (format "\n** %s — %s\n:PROPERTIES:\n:HERMES_SESSION: %s\n:END:\n[[hermes:%s][Resume — %s]]\n\n" + (format-time-string "%Y-%m-%d %H:%M") title + id id title)) + (write-region (point-min) (point-max) sessions-file t 'silent)))) + +;; ------------------------------------------------------------------- +;; Rename session globally +;; ------------------------------------------------------------------- +(defun gortium/hermes--rename-session (id fancy-title &optional remote-host) + "Rename a Hermes session globally (adds project prefix)." + (shell-command-to-string + (gortium/hermes--cmd + (format "sessions rename %s \"%s\"" id fancy-title) remote-host))) + +;; ------------------------------------------------------------------- +;; Build launch command for interactive vterm +;; ------------------------------------------------------------------- +(defun gortium/hermes--launch-cmd (id &optional remote-host) + "Build shell command to resume session ID in a terminal. +Remote needs ssh -t + docker exec -it (PTY required)." + (if remote-host + (format "ssh -t %s docker exec -it %s %s chat --resume %s" + remote-host gortium/hermes-remote-docker + gortium/hermes-remote-path id) + (format "hermes chat --resume %s" id))) + +;; ------------------------------------------------------------------- +;; Open vterm buffer and run command +;; ------------------------------------------------------------------- +(defun gortium/hermes--open-vterm (command &optional name) + "Open a vterm buffer and run COMMAND inside it." + (let ((buf (get-buffer-create (or name "*hermes*")))) + (switch-to-buffer buf) + (unless (and (eq major-mode 'vterm-mode) + (get-buffer-process buf)) + (vterm-mode) + (let ((proc (get-buffer-process buf))) + (when proc (process-send-string proc (concat command "\n"))))))) + +;; ------------------------------------------------------------------- +;; Start a new Hermes session (with title prompt) +;; ------------------------------------------------------------------- +;;;###autoload +(defun gortium/hermes-start (title &optional remote-host) + "Start a new Hermes session for the current project. +With prefix arg, prompt for remote SSH host (default: lazyworkhorse)." + (interactive + (let* ((remote (when current-prefix-arg + (let ((h (read-string + "Remote host [lazyworkhorse]: " nil nil "lazyworkhorse"))) + (unless (string-empty-p h) h)))) + (title (read-string "Session title: "))) + (list title remote))) + (pcase-let ((`(,root ,sfile ,name) (gortium/hermes--project-info))) + (let ((id (gortium/hermes--capture-session-id remote-host))) + (gortium/hermes--rename-session id (format "[%s] %s" name title) remote-host) + (gortium/hermes-append-session sfile id title) + (gortium/hermes--open-vterm + (gortium/hermes--launch-cmd id remote-host) + (format "*hermes-%s*" name))))) + +;; ------------------------------------------------------------------- +;; Browse / resume past sessions (completing-read menu) +;; ------------------------------------------------------------------- +;;;###autoload +(defun gortium/hermes-resume () + "Browse and resume a past Hermes session from the current project." + (interactive) + (pcase-let ((`(,root ,sfile ,_name) (gortium/hermes--project-info))) + (if (not (file-exists-p sfile)) + (user-error (format "No .hermes-sessions in %s" root)) + (let (sessions current) + (with-temp-buffer + (insert-file-contents sfile) + (while (not (eobp)) + (forward-line 1) + (cond + ((looking-at "^\\*\\* \\([^—]+\\) — \\(.+\\)$") + (setq current (list :date (string-trim (match-string 1)) + :title (match-string 2) + :id nil))) + ((and current (looking-at "^:HERMES_SESSION: \\(.+\\)$")) + (plist-put current :id (match-string 1)) + (push current sessions) + (setq current nil))))) + ;; sessions is in push-order = newest first + (if (null sessions) + (message "No Hermes sessions recorded for this project.") + (let* ((items (mapcar (lambda (s) + (format "%s — %s" + (plist-get s :date) + (plist-get s :title))) + sessions)) + (choice (completing-read "Resume Hermes session: " items nil t)) + (idx (cl-position choice items :test #'string=)) + (session (nth idx sessions)) + (id (plist-get session :id))) + (gortium/hermes--open-vterm + (format "hermes chat --resume %s" id)))))))) + +;; ------------------------------------------------------------------- +;; Link external session to current project +;; ------------------------------------------------------------------- +;;;###autoload +(defun gortium/hermes-link-session (session-id title) + "Link an existing Hermes session to the current project. +If SESSION-ID is empty, show a menu of recent sessions to browse." + (interactive + (let ((id (read-string "Session ID (empty to browse recent): "))) + (if (string-empty-p id) + (let* ((output (shell-command-to-string "hermes sessions list --limit 50 2>&1")) + (lines (split-string output "\n" t)) + (data-lines (nthcdr 2 lines)) ; skip header + separator + (id-re "[0-9]\\{8\\}_[0-9]\\{6\\}_[a-f0-9]\\{6\\}") + (items (delq nil + (mapcar (lambda (line) + (when (string-match (concat id-re "$") line) + (let* ((id (match-string 0 line)) + (before-id (substring line 0 (match-beginning 0))) + (full-preview (string-trim + (replace-regexp-in-string + "\\s-+[a-z][a-z0-9]*\\s-*$" "" before-id)))) + (cons full-preview id))) + data-lines)) + (titles (delq nil + (mapcar (lambda (line) + (when (string-match (concat id-re "$") line) + (let* ((id (match-string 0 line)) + (before-id (substring line 0 (match-beginning 0))) + (full (string-trim + (replace-regexp-in-string + "\\s-+[a-z][a-z0-9]*\\s-*$" "" before-id))) + (clean (string-trim + (replace-regexp-in-string + "\\s-+\\(just now\\|[0-9]+[mhd] ago\\|[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\)$" + "" full)))) + (cons id clean))) + data-lines)))) + (if (null items) + (user-error "No recent sessions found") + (let* ((vertico-sort-function 'identity) ; keep vertico, no re-sort + (choice (completing-read "Session: " items nil t)) + (sid (cdr (assoc choice items))) + (default-title (cdr (assoc sid titles)))) + (list sid (read-string "Title: " default-title))))) + (list id (read-string "Title: "))))) + (pcase-let ((`(,root ,sfile ,name) (gortium/hermes--project-info))) + (gortium/hermes--rename-session id (format "[%s] %s" name title)) + (gortium/hermes-append-session sfile id title) + (message "Session %s linked to project %s as \"%s\"" id name title))) + +;; ------------------------------------------------------------------- +;; Keybindings (under SPC p) +;; ------------------------------------------------------------------- +(map! :leader + :desc "New Hermes session" "p h" #'gortium/hermes-start + :desc "Resume Hermes session" "p H" #'gortium/hermes-resume + :desc "Link session to project" "p l" #'gortium/hermes-link-session) #+end_src * Tramp @@ -2043,7 +2298,7 @@ Recently added the quick access entries. Game changer. ;; Use `p` to open the yank menu (map! :after dirvish-yank :map dirvish-mode-map - :n "p" #'dirvish-yank-menu) + :n "P" #'dirvish-yank-menu) (map! :after dirvish :leader @@ -2466,6 +2721,27 @@ only injects when the project's context-tag matches current mu4e context." (emms-player-set emms-player-mpv 'regex (rx (or (: "http" (? "s") "://" (* nonl) "youtube.com" (* nonl)) (: "http" (? "s") "://" (* nonl) "youtu.be" (* nonl)))))) + +(defun my-elfeed-tube-add-to-emms () + "Add the currently selected Elfeed video(s) to the EMMS playlist." + (interactive) + (let ((entries (if (eq major-mode 'elfeed-show-mode) + (list elfeed-show-entry) + (elfeed-search-selected)))) + (dolist (entry entries) + (let ((link (elfeed-entry-link entry))) + (if link + (progn + (emms-add-url link) + (message "Queued in EMMS: %s" link)) + (message "No link found for this entry.")))))) + +;; Bind the function to 'e' in both elfeed views +(map! :map elfeed-search-mode-map + :n "e" #'my-elfeed-tube-add-to-emms) + +(map! :map elfeed-show-mode-map + :n "e" #'my-elfeed-tube-add-to-emms) #+end_src * EAF #+begin_src emacs-lisp @@ -3291,7 +3567,7 @@ Suppresses all logging, notes, and custom odometer hooks." (delete-window old-win))))))) #+end_src -** ExoKortex System +** ExoKortex File System #+begin_src emacs-lisp ;;; ==================================================================== @@ -3324,7 +3600,7 @@ with a project-tagged custom agenda entry and optional CONTEXT-TAG (mu4e/agenda) ;; 2. Enforce text-only gitignore rules if absent (unless (file-exists-p gitignore-file) (with-temp-file gitignore-file - (insert "# Local Node Rule: Track text layers only\n*\n!.gitignore\n!.projectile\n!.dir-locals.el\n!.bookmarks\n!1-Plan/\n!1-Plan/**\n!3-Notes/\n!3-Notes/**\n"))) + (insert "# Local Node Rule: Track text layers only\n*\n!.gitignore\n!.projectile\n!.dir-locals.el\n!.bookmarks\n!.hermes-sessions\n!1-Plan/\n!1-Plan/**\n!3-Notes/\n!3-Notes/**\n"))) ;; 3. Build index Org block if absent (unless (file-exists-p org-file) @@ -3369,6 +3645,12 @@ with a project-tagged custom agenda entry and optional CONTEXT-TAG (mu4e/agenda) (prin1 vars (current-buffer)) (princ "))" (current-buffer))))))) + ;; Create .hermes-sessions skeleton + (let ((hs-file (expand-file-name ".hermes-sessions" node-dir))) + (unless (file-exists-p hs-file) + (with-temp-file hs-file + (insert (format "# -*- mode: org -*-\n#+TITLE: Sessions — %s\n\n* Sessions\n\n" node-name))))) + ;; Mark these directory-local variables as safe (put 'gortium/project-tag 'safe-local-variable #'stringp) (put 'gortium/context-tag 'safe-local-variable #'stringp) @@ -3381,14 +3663,14 @@ with a project-tagged custom agenda entry and optional CONTEXT-TAG (mu4e/agenda) (shell-command "git init") (let ((add-files ".gitignore 1-Plan/ 3-Notes/")) (when project-tag - (setq add-files (concat add-files " .projectile .dir-locals.el"))) + (setq add-files (concat add-files " .projectile .dir-locals.el .hermes-sessions"))) (shell-command (concat "git add -f " add-files))) (shell-command "git commit -m 'System: Initialize node repository'")) ;; existing repo: add new marker files if they exist (let ((add-files ".gitignore 1-Plan/ 3-Notes/")) (when project-tag - (setq add-files (concat add-files " .projectile .dir-locals.el"))) + (setq add-files (concat add-files " .projectile .dir-locals.el .hermes-sessions"))) (shell-command (concat "git add -f " add-files))))) ;; Pass the target file location back up to the caller @@ -3432,14 +3714,14 @@ Optional PROJECT-TAG and CONTEXT-TAG can be given to create .projectile and .dir dired-elements)))) (dirs (or marked-dirs (let* ((cursor-path (when (derived-mode-p 'dired-mode) - (dired-get-filename nil t))) + (dired-get-filename nil t))) (initial-path (cond ((and cursor-path (file-directory-p cursor-path)) cursor-path) (cursor-path (file-name-directory cursor-path)) (t default-directory)))) - (list (read-directory-name "Directory to convert: " nil initial-path t initial-path))))) + (list (read-directory-name "Directory to convert: " initial-path initial-path t))))) (tag (read-string "Project tag (blank to skip): ")) (ctx (read-string "Context tag (or blank): "))) (list dirs tag ctx))) @@ -3449,9 +3731,9 @@ Optional PROJECT-TAG and CONTEXT-TAG can be given to create .projectile and .dir (dolist (dir node-dirs) (let* ((node-dir (expand-file-name dir)) (org-file (gortium/ensure-node-structure node-dir - "Audit existing assets and reconcile tracking nodes" - (unless (string-empty-p project-tag) project-tag) - (unless (string-empty-p context-tag) context-tag)))) + "Audit existing assets and reconcile tracking nodes" + (unless (string-empty-p project-tag) project-tag) + (unless (string-empty-p context-tag) context-tag)))) (setq last-org-file org-file) (cl-incf count))) diff --git a/doom/.config/doom/config.el b/doom/.config/doom/config.el index 5e75c09..0ec798b 100644 --- a/doom/.config/doom/config.el +++ b/doom/.config/doom/config.el @@ -1,5 +1,21 @@ ;;; $DOOMDIR/config.el -*- lexical-binding: t; -*- +;; 1. Set the image path +(setq fancy-splash-image (expand-file-name "hermes_logo_ascii.png" doom-user-dir)) + +;; 2. Define the quote function +(defun my-dashboard-quote () + (insert "\n" (+dashboard--center +dashboard--width "Simplicity is the ultimate sophistication.") "\n")) + +;; 3. Override the functions list using the correct variable from the docs +(after! doom-dashboard + (setq +dashboard-functions + '(+dashboard-widget-banner + my-dashboard-quote + +dashboard-widget-loaded + +dashboard-widget-shortmenu + +dashboard-widget-footer))) + (setq user-full-name "Thierry Pouplier" user-mail-address "tpouplier@tdnde.com" user-mail-address "tpouplier@tdnde.com") @@ -20,19 +36,13 @@ (setq projectile-auto-discover nil)) (setq bookmark-save-flag 1) -(setq bookmark-default-file (expand-file-name "bookmarks" (expand-file-name "~/ExoKortex/2-Areas/Home-Infra/2-Assets/bookmark"))) +(setq bookmark-default-file (expand-file-name "~/ExoKortex/2-Areas/Home-Infra/2-Assets/bookmark")) (setq global-auto-revert-mode 1) (map! :leader (:prefix ("w" . "window") - :desc "Minimize window" "O" #'minimize-window) - (:prefix ("z" . "Personal") - :desc "Hermes Agent" "h" #'gortium/hermes-vterm)) - -(map! :leader - (:prefix ("z" . "Personal") - :desc "Hermes Agent" "h" #'gortium/hermes-vterm)) + :desc "Minimize window" "O" #'minimize-window)) (use-package! ultra-scroll :init @@ -190,9 +200,11 @@ org-agenda-files (seq-uniq org-agenda-files) (setq org-agenda-exporter-settings '((ps-print-color-p 'nil))) (setq org-agenda-follow-mode nil - org-agenda-skip-deadline-if-done nil - org-agenda-skip-scheduled-if-done nil - org-agenda-skip-timestamp-if-done nil + org-agenda-skip-deadline-if-done t + org-agenda-skip-scheduled-if-done t + org-agenda-skip-timestamp-if-done t + org-agenda-skip-deadline-prewarning-if-scheduled t + org-agenda-skip-scheduled-if-deadline-is-shown t org-agenda-sticky t org-journal-enable-agenda-integration t org-log-done 'time @@ -567,6 +579,25 @@ org-agenda-files (seq-uniq org-agenda-files) (append '(("\\.x?html?\\'" . default)) (assq-delete-all "\\.x?html?\\'" org-file-apps)))) +(after! calendar + ;; Define a function to open org-roam daily note for selected date in calendar + (defun gortium/org-roam-dailies-open-at-point () + "Open org-roam daily note for date at point in calendar." + (interactive) + (let ((date (calendar-cursor-to-date))) + (org-roam-dailies-goto-date date))) + ) + +;; Bind RET in calendar mode to open daily note instead of default exit +(evil-define-key 'normal calendar-mode-map + (kbd "RET") #'gortium/org-roam-dailies-open-at-point) + +(after! org + (define-key org-read-date-minibuffer-local-map (kbd "M-h") (kbd "S-")) + (define-key org-read-date-minibuffer-local-map (kbd "M-j") (kbd "S-")) + (define-key org-read-date-minibuffer-local-map (kbd "M-k") (kbd "S-")) + (define-key org-read-date-minibuffer-local-map (kbd "M-l") (kbd "S-"))) + (after! grip-mode (setq grip-binary-path "/home/tpouplier/.local/bin/grip") ) @@ -780,19 +811,6 @@ Handles org-clock and context link capture for tasks." (add-hook 'org-clock-out-hook #'gortium/org-clock-out-from-connecteam) -(after! calendar - ;; Define a function to open org-roam daily note for selected date in calendar - (defun gortium/org-roam-dailies-open-at-point () - "Open org-roam daily note for date at point in calendar." - (interactive) - (let ((date (calendar-cursor-to-date))) - (org-roam-dailies-goto-date date))) - ) - -;; Bind RET in calendar mode to open daily note instead of default exit -(evil-define-key 'normal calendar-mode-map - (kbd "RET") #'gortium/org-roam-dailies-open-at-point) - (after! dap-mode (require 'dap-python) (setq dap-python-debugger 'debugpy) @@ -1162,19 +1180,243 @@ DIFF: :desc "GPT generate commit" "g" #'gortium/magit-gptel-generate-commit) -(after! vterm - (defun gortium/hermes-vterm () - "Launch Hermes Agent in vterm via SSH to lazyworkhorse (CLI mode)." - (interactive) - (let ((buf (get-buffer-create "*hermes*"))) - (switch-to-buffer buf) - (unless (and (eq major-mode 'vterm-mode) - (get-buffer-process buf)) - (vterm-mode) - (let ((proc (get-buffer-process buf))) - (when proc - (process-send-string - proc "ssh -t lazyworkhorse 'docker exec -it hermes /opt/hermes/.venv/bin/hermes'\n"))))))) +;;; ==================================================================== +;;; Hermes Session ↔ Project Integration +;;; ==================================================================== + +;; Configuration (customize per-host) +(defvar gortium/hermes-remote-host "lazyworkhorse" + "SSH host for remote Hermes. nil = use local Hermes.") +(defvar gortium/hermes-remote-docker "hermes" + "Docker container name for remote Hermes.") +(defvar gortium/hermes-remote-path + "/opt/hermes/.venv/bin/hermes" + "Path to the hermes executable inside the Docker container.") + +;; ------------------------------------------------------------------- +;; Org link type: [[hermes:20260718_162500_a1b2c3][Resume — Title]] +;; ------------------------------------------------------------------- +(org-link-set-parameters "hermes" + :follow (lambda (id) + (gortium/hermes--open-vterm + (format "hermes chat --resume %s" id) + "*hermes*")) + :help-echo "Resume this Hermes chat session in vterm") + +;; ------------------------------------------------------------------- +;; Helpers — build commands, detect project +;; ------------------------------------------------------------------- + +(defun gortium/hermes--remote-prefix (&optional host) + "Build SSH/Docker prefix for running Hermes commands remotely." + (let ((h (or host gortium/hermes-remote-host))) + (format "ssh %s docker exec %s %s" h + gortium/hermes-remote-docker + gortium/hermes-remote-path))) + +(defun gortium/hermes--cmd (subcmd &optional remote-host) + "Prefix SUBCMD with local 'hermes' or remote SSH/Docker." + (if remote-host + (format "%s %s" (gortium/hermes--remote-prefix remote-host) subcmd) + (format "hermes %s" subcmd))) + +(defun gortium/hermes--project-info () + "Return (ROOT SESSIONS-FILE NAME) or signal error if not in project." + (let ((root (projectile-project-root))) + (if root + (list root + (expand-file-name ".hermes-sessions" root) + (file-name-nondirectory (directory-file-name root))) + (user-error "Not in a Projectile project")))) + +;; ------------------------------------------------------------------- +;; Capture session ID at session start (one-shot, before vterm opens) +;; ------------------------------------------------------------------- +(defun gortium/hermes--capture-session-id (&optional remote-host) + "Create a quick Hermes session and return its ID. +Sends a meaningful init message so history starts with project context. +session_id: is on stderr; 2>&1 merges it so shell-command-to-string catches it." + (let* ((proj (nth 2 (gortium/hermes--project-info))) + (cmd (gortium/hermes--cmd + (format "chat -q \"Init session for %s. Reply ONLY with OK.\" --quiet 2>&1" proj) + remote-host)) + (output (shell-command-to-string cmd))) + (if (string-match "session_id: \\([0-9a-f_]+\\)" output) + (match-string 1 output) + (user-error "Could not capture Hermes session ID")))) + +;; ------------------------------------------------------------------- +;; Append session entry to .hermes-sessions +;; ------------------------------------------------------------------- +(defun gortium/hermes-append-session (sessions-file id title) + "Append session ID and TITLE to SESSIONS-FILE (org format)." + (let ((proj (file-name-nondirectory + (directory-file-name + (file-name-directory sessions-file))))) + (unless (file-exists-p sessions-file) + (with-temp-buffer + (insert "# -*- mode: org -*-\n" + "#+TITLE: Sessions — " proj "\n\n* Sessions\n\n") + (write-region (point-min) (point-max) sessions-file nil 'silent))) + (with-temp-buffer + (insert (format "\n** %s — %s\n:PROPERTIES:\n:HERMES_SESSION: %s\n:END:\n[[hermes:%s][Resume — %s]]\n\n" + (format-time-string "%Y-%m-%d %H:%M") title + id id title)) + (write-region (point-min) (point-max) sessions-file t 'silent)))) + +;; ------------------------------------------------------------------- +;; Rename session globally +;; ------------------------------------------------------------------- +(defun gortium/hermes--rename-session (id fancy-title &optional remote-host) + "Rename a Hermes session globally (adds project prefix)." + (shell-command-to-string + (gortium/hermes--cmd + (format "sessions rename %s \"%s\"" id fancy-title) remote-host))) + +;; ------------------------------------------------------------------- +;; Build launch command for interactive vterm +;; ------------------------------------------------------------------- +(defun gortium/hermes--launch-cmd (id &optional remote-host) + "Build shell command to resume session ID in a terminal. +Remote needs ssh -t + docker exec -it (PTY required)." + (if remote-host + (format "ssh -t %s docker exec -it %s %s chat --resume %s" + remote-host gortium/hermes-remote-docker + gortium/hermes-remote-path id) + (format "hermes chat --resume %s" id))) + +;; ------------------------------------------------------------------- +;; Open vterm buffer and run command +;; ------------------------------------------------------------------- +(defun gortium/hermes--open-vterm (command &optional name) + "Open a vterm buffer and run COMMAND inside it." + (let ((buf (get-buffer-create (or name "*hermes*")))) + (switch-to-buffer buf) + (unless (and (eq major-mode 'vterm-mode) + (get-buffer-process buf)) + (vterm-mode) + (let ((proc (get-buffer-process buf))) + (when proc (process-send-string proc (concat command "\n"))))))) + +;; ------------------------------------------------------------------- +;; Start a new Hermes session (with title prompt) +;; ------------------------------------------------------------------- +;;;###autoload +(defun gortium/hermes-start (title &optional remote-host) + "Start a new Hermes session for the current project. +With prefix arg, prompt for remote SSH host (default: lazyworkhorse)." + (interactive + (let* ((remote (when current-prefix-arg + (let ((h (read-string + "Remote host [lazyworkhorse]: " nil nil "lazyworkhorse"))) + (unless (string-empty-p h) h)))) + (title (read-string "Session title: "))) + (list title remote))) + (pcase-let ((`(,root ,sfile ,name) (gortium/hermes--project-info))) + (let ((id (gortium/hermes--capture-session-id remote-host))) + (gortium/hermes--rename-session id (format "[%s] %s" name title) remote-host) + (gortium/hermes-append-session sfile id title) + (gortium/hermes--open-vterm + (gortium/hermes--launch-cmd id remote-host) + (format "*hermes-%s*" name))))) + +;; ------------------------------------------------------------------- +;; Browse / resume past sessions (completing-read menu) +;; ------------------------------------------------------------------- +;;;###autoload +(defun gortium/hermes-resume () + "Browse and resume a past Hermes session from the current project." + (interactive) + (pcase-let ((`(,root ,sfile ,_name) (gortium/hermes--project-info))) + (if (not (file-exists-p sfile)) + (user-error (format "No .hermes-sessions in %s" root)) + (let (sessions current) + (with-temp-buffer + (insert-file-contents sfile) + (while (not (eobp)) + (forward-line 1) + (cond + ((looking-at "^\\*\\* \\([^—]+\\) — \\(.+\\)$") + (setq current (list :date (string-trim (match-string 1)) + :title (match-string 2) + :id nil))) + ((and current (looking-at "^:HERMES_SESSION: \\(.+\\)$")) + (plist-put current :id (match-string 1)) + (push current sessions) + (setq current nil))))) + ;; sessions is in push-order = newest first + (if (null sessions) + (message "No Hermes sessions recorded for this project.") + (let* ((items (mapcar (lambda (s) + (format "%s — %s" + (plist-get s :date) + (plist-get s :title))) + sessions)) + (choice (completing-read "Resume Hermes session: " items nil t)) + (idx (cl-position choice items :test #'string=)) + (session (nth idx sessions)) + (id (plist-get session :id))) + (gortium/hermes--open-vterm + (format "hermes chat --resume %s" id)))))))) + +;; ------------------------------------------------------------------- +;; Link external session to current project +;; ------------------------------------------------------------------- +;;;###autoload +(defun gortium/hermes-link-session (session-id title) + "Link an existing Hermes session to the current project. +If SESSION-ID is empty, show a menu of recent sessions to browse." + (interactive + (let ((id (read-string "Session ID (empty to browse recent): "))) + (if (string-empty-p id) + (let* ((output (shell-command-to-string "hermes sessions list --limit 50 2>&1")) + (lines (split-string output "\n" t)) + (data-lines (nthcdr 2 lines)) ; skip header + separator + (id-re "[0-9]\\{8\\}_[0-9]\\{6\\}_[a-f0-9]\\{6\\}") + (items (delq nil + (mapcar (lambda (line) + (when (string-match (concat id-re "$") line) + (let* ((id (match-string 0 line)) + (before-id (substring line 0 (match-beginning 0))) + (full-preview (string-trim + (replace-regexp-in-string + "\\s-+[a-z][a-z0-9]*\\s-*$" "" before-id)))) + (cons full-preview id))) + data-lines)) + (titles (delq nil + (mapcar (lambda (line) + (when (string-match (concat id-re "$") line) + (let* ((id (match-string 0 line)) + (before-id (substring line 0 (match-beginning 0))) + (full (string-trim + (replace-regexp-in-string + "\\s-+[a-z][a-z0-9]*\\s-*$" "" before-id))) + (clean (string-trim + (replace-regexp-in-string + "\\s-+\\(just now\\|[0-9]+[mhd] ago\\|[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\)$" + "" full)))) + (cons id clean))) + data-lines)))) + (if (null items) + (user-error "No recent sessions found") + (let* ((vertico-sort-function 'identity) ; keep vertico, no re-sort + (choice (completing-read "Session: " items nil t)) + (sid (cdr (assoc choice items))) + (default-title (cdr (assoc sid titles)))) + (list sid (read-string "Title: " default-title))))) + (list id (read-string "Title: "))))) + (pcase-let ((`(,root ,sfile ,name) (gortium/hermes--project-info))) + (gortium/hermes--rename-session id (format "[%s] %s" name title)) + (gortium/hermes-append-session sfile id title) + (message "Session %s linked to project %s as \"%s\"" id name title))) + +;; ------------------------------------------------------------------- +;; Keybindings (under SPC p) +;; ------------------------------------------------------------------- +(map! :leader + :desc "New Hermes session" "p h" #'gortium/hermes-start + :desc "Resume Hermes session" "p H" #'gortium/hermes-resume + :desc "Link session to project" "p l" #'gortium/hermes-link-session) ;; set `tramp-direct-async-process' locally in all ssh connections (connection-local-set-profile-variables @@ -1240,7 +1482,7 @@ DIFF: ;; Use `p` to open the yank menu (map! :after dirvish-yank :map dirvish-mode-map - :n "p" #'dirvish-yank-menu) + :n "P" #'dirvish-yank-menu) (map! :after dirvish :leader @@ -1620,6 +1862,27 @@ only injects when the project's context-tag matches current mu4e context." (rx (or (: "http" (? "s") "://" (* nonl) "youtube.com" (* nonl)) (: "http" (? "s") "://" (* nonl) "youtu.be" (* nonl)))))) +(defun my-elfeed-tube-add-to-emms () + "Add the currently selected Elfeed video(s) to the EMMS playlist." + (interactive) + (let ((entries (if (eq major-mode 'elfeed-show-mode) + (list elfeed-show-entry) + (elfeed-search-selected)))) + (dolist (entry entries) + (let ((link (elfeed-entry-link entry))) + (if link + (progn + (emms-add-url link) + (message "Queued in EMMS: %s" link)) + (message "No link found for this entry.")))))) + +;; Bind the function to 'e' in both elfeed views +(map! :map elfeed-search-mode-map + :n "e" #'my-elfeed-tube-add-to-emms) + +(map! :map elfeed-show-mode-map + :n "e" #'my-elfeed-tube-add-to-emms) + (use-package! eaf :load-path "~/.config/emacs/.local/straight/repos/emacs-application-framework" :config @@ -2408,7 +2671,7 @@ with a project-tagged custom agenda entry and optional CONTEXT-TAG (mu4e/agenda) ;; 2. Enforce text-only gitignore rules if absent (unless (file-exists-p gitignore-file) (with-temp-file gitignore-file - (insert "# Local Node Rule: Track text layers only\n*\n!.gitignore\n!.projectile\n!.dir-locals.el\n!.bookmarks\n!1-Plan/\n!1-Plan/**\n!3-Notes/\n!3-Notes/**\n"))) + (insert "# Local Node Rule: Track text layers only\n*\n!.gitignore\n!.projectile\n!.dir-locals.el\n!.bookmarks\n!.hermes-sessions\n!1-Plan/\n!1-Plan/**\n!3-Notes/\n!3-Notes/**\n"))) ;; 3. Build index Org block if absent (unless (file-exists-p org-file) @@ -2453,6 +2716,12 @@ with a project-tagged custom agenda entry and optional CONTEXT-TAG (mu4e/agenda) (prin1 vars (current-buffer)) (princ "))" (current-buffer))))))) + ;; Create .hermes-sessions skeleton + (let ((hs-file (expand-file-name ".hermes-sessions" node-dir))) + (unless (file-exists-p hs-file) + (with-temp-file hs-file + (insert (format "# -*- mode: org -*-\n#+TITLE: Sessions — %s\n\n* Sessions\n\n" node-name))))) + ;; Mark these directory-local variables as safe (put 'gortium/project-tag 'safe-local-variable #'stringp) (put 'gortium/context-tag 'safe-local-variable #'stringp) @@ -2465,14 +2734,14 @@ with a project-tagged custom agenda entry and optional CONTEXT-TAG (mu4e/agenda) (shell-command "git init") (let ((add-files ".gitignore 1-Plan/ 3-Notes/")) (when project-tag - (setq add-files (concat add-files " .projectile .dir-locals.el"))) + (setq add-files (concat add-files " .projectile .dir-locals.el .hermes-sessions"))) (shell-command (concat "git add -f " add-files))) (shell-command "git commit -m 'System: Initialize node repository'")) ;; existing repo: add new marker files if they exist (let ((add-files ".gitignore 1-Plan/ 3-Notes/")) (when project-tag - (setq add-files (concat add-files " .projectile .dir-locals.el"))) + (setq add-files (concat add-files " .projectile .dir-locals.el .hermes-sessions"))) (shell-command (concat "git add -f " add-files))))) ;; Pass the target file location back up to the caller @@ -2516,14 +2785,14 @@ Optional PROJECT-TAG and CONTEXT-TAG can be given to create .projectile and .dir dired-elements)))) (dirs (or marked-dirs (let* ((cursor-path (when (derived-mode-p 'dired-mode) - (dired-get-filename nil t))) + (dired-get-filename nil t))) (initial-path (cond ((and cursor-path (file-directory-p cursor-path)) cursor-path) (cursor-path (file-name-directory cursor-path)) (t default-directory)))) - (list (read-directory-name "Directory to convert: " nil initial-path t initial-path))))) + (list (read-directory-name "Directory to convert: " initial-path initial-path t))))) (tag (read-string "Project tag (blank to skip): ")) (ctx (read-string "Context tag (or blank): "))) (list dirs tag ctx))) @@ -2533,9 +2802,9 @@ Optional PROJECT-TAG and CONTEXT-TAG can be given to create .projectile and .dir (dolist (dir node-dirs) (let* ((node-dir (expand-file-name dir)) (org-file (gortium/ensure-node-structure node-dir - "Audit existing assets and reconcile tracking nodes" - (unless (string-empty-p project-tag) project-tag) - (unless (string-empty-p context-tag) context-tag)))) + "Audit existing assets and reconcile tracking nodes" + (unless (string-empty-p project-tag) project-tag) + (unless (string-empty-p context-tag) context-tag)))) (setq last-org-file org-file) (cl-incf count))) diff --git a/doom/.config/doom/hermes_logo_ascii.png b/doom/.config/doom/hermes_logo_ascii.png new file mode 100644 index 0000000..05e80ab Binary files /dev/null and b/doom/.config/doom/hermes_logo_ascii.png differ diff --git a/doom/.config/doom/nil b/doom/.config/doom/nil deleted file mode 100644 index 951d4bc..0000000 --- a/doom/.config/doom/nil +++ /dev/null @@ -1,33 +0,0 @@ -(setq doom-theme 'doom-gruvbox) -;; Enable flashing mode-line on errors -(doom-themes-visual-bell-config) -;; Corrects (and improves) org-mode's native fontification. -(doom-themes-org-config) - -(after! org - ;; 1. Update existing faces (Org defines 1-8 by default) - (set-face-attribute 'org-document-title nil :height 2.0 :weight 'bold) - (set-face-attribute 'org-level-1 nil :weight 'semi-bold :height 1.45) - (set-face-attribute 'org-level-2 nil :weight 'semi-bold :height 1.35) - (set-face-attribute 'org-level-3 nil :weight 'semi-bold :height 1.25) - (set-face-attribute 'org-level-4 nil :weight 'semi-bold :height 1.15) - (set-face-attribute 'org-level-5 nil :weight 'normal :height 1.10) - (set-face-attribute 'org-level-6 nil :weight 'normal :height 1.05) - (set-face-attribute 'org-level-7 nil :weight 'normal :height 1.02) - (set-face-attribute 'org-level-8 nil :weight 'normal :height 1.00) - - ;; 2. Create missing faces (9-20) and inherit them directly from org-level-8 - (cl-loop for lvl from 9 to 20 - do (let ((face-name (intern (format "org-level-%d" lvl)))) - (custom-declare-face face-name - '((t :inherit org-level-8)) - (format "Face for org heading level %d." lvl) - :group 'org-faces))) - - ;; 3. Add the newly created faces to Org's level list - (setq org-level-faces - (append org-level-faces - (mapcar (lambda (lvl) (intern (format "org-level-%d" lvl))) - (number-sequence 9 20)))) - - (setq org-n-level-faces (length org-level-faces)))