feat: project-aware Hermes session integration

- SPC p h: new session (prompts for title, captures ID, opens vterm)
- SPC p H: resume menu (newest-first, completing-read)
- SPC p l: link external session to project (browse or by ID)
- hermes: org link type opens in vterm, not shell-mode
- One-shot init message: "Init session for PROJECT. Reply ONLY with OK."
- Regex fix: [0-9a-f_]+ for hex session IDs
- .hermes-sessions dotfile at project root (git-tracked)
- gortium/ensure-node-structure creates .hermes-sessions + gitignore whitelist
- Refactored: gortium/hermes--cmd, gortium/hermes--project-info
This commit is contained in:
2026-07-18 17:53:32 -04:00
parent f64e1e571c
commit 104a7b0764
4 changed files with 650 additions and 132 deletions

View File

@@ -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-<left>"))
(define-key org-read-date-minibuffer-local-map (kbd "M-j") (kbd "S-<down>"))
(define-key org-read-date-minibuffer-local-map (kbd "M-k") (kbd "S-<up>"))
(define-key org-read-date-minibuffer-local-map (kbd "M-l") (kbd "S-<right>")))
#+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*")))
;;; ====================================================================
;;; 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 "ssh -t lazyworkhorse 'docker exec -it hermes /opt/hermes/.venv/bin/hermes'\n")))))))
(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
@@ -3439,7 +3721,7 @@ Optional PROJECT-TAG and CONTEXT-TAG can be given to create .projectile and .dir
(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)))

View File

@@ -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-<left>"))
(define-key org-read-date-minibuffer-local-map (kbd "M-j") (kbd "S-<down>"))
(define-key org-read-date-minibuffer-local-map (kbd "M-k") (kbd "S-<up>"))
(define-key org-read-date-minibuffer-local-map (kbd "M-l") (kbd "S-<right>")))
(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*")))
;;; ====================================================================
;;; 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 "ssh -t lazyworkhorse 'docker exec -it hermes /opt/hermes/.venv/bin/hermes'\n")))))))
(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
@@ -2523,7 +2792,7 @@ Optional PROJECT-TAG and CONTEXT-TAG can be given to create .projectile and .dir
(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)))

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

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