(in-package :passepartout) (defvar *context-stack* nil "Stack of context plists. Each plist has :project, :base-path, :scope. Top of stack (car) is the current context.") (defvar *context-max-depth* 10 "Maximum context stack depth. Prevents runaway pushes.") (defun current-context () "Returns the current context plist, or nil if no context is set." (car *context-stack*)) (defun current-scope () "Returns the current scope keyword (:memex/:session/:project). Returns :memex when no context is set (defaults to global scope)." (or (getf (current-context) :scope) :memex)) (defun current-project () "Returns the current project name, or nil." (getf (current-context) :project)) (defun current-base-path () "Returns the current base path for file resolution, or nil." (getf (current-context) :base-path)) (defun context-stack-depth () "Returns the current depth of the context stack." (length *context-stack*)) (defun push-context (&key project base-path (scope :project)) "Pushes a new context onto the stack. When focused on a project: - File paths resolve relative to BASE-PATH - Memory queries filter by SCOPE - :memex scope objects remain visible (always global) Returns the new context plist." (when (>= (context-stack-depth) *context-max-depth*) (log-message "CONTEXT: Stack depth limit reached (~d), refusing push" *context-max-depth*) (return-from push-context (current-context))) (let* ((context (list :project project :base-path base-path :scope scope))) (push context *context-stack*) (context-save) (log-message "CONTEXT: Pushed ~a (depth ~d)" project (context-stack-depth)) context)) (defun pop-context () "Pops the current context, restoring the previous one. Returns the restored context or nil if stack becomes empty." (if *context-stack* (let ((popped (pop *context-stack*))) (context-save) (log-message "CONTEXT: Popped ~a (depth ~d)" (getf popped :project) (context-stack-depth)) (current-context)) (progn (log-message "CONTEXT: Cannot pop — stack is empty") nil))) (defmacro with-context ((&key project base-path (scope :project)) &body body) "Executes BODY within a scoped context, then restores the previous context. Example: (with-context (:project \"passepartout\" :base-path \"/home/user/memex/projects/passepartout\") (context-scoped-query :tag \"bug\"))" `(let ((*context-stack* (cons (list :project ,project :base-path ,base-path :scope ,scope) *context-stack*))) ,@body)) (defun resolve-path (path) "Resolves a file path relative to the current context. If PATH is absolute, returns it unchanged. If PATH is relative and a base-path is set, merges them. Otherwise returns PATH unchanged." (let ((base (current-base-path))) (if (and base path (not (uiop:absolute-pathname-p path))) (namestring (merge-pathnames path (uiop:ensure-directory-pathname base))) path))) (defun context-scoped-query (&key tag todo-state type) "Like context-query but filtered to the current context's scope. :memex-scoped objects are always visible regardless of current scope." (context-query :tag tag :todo-state todo-state :type type :scope (current-scope))) (defun project-objects () "Returns all objects scoped to the current project. Includes :memex-scoped objects (global knowledge) plus :project-scoped objects matching the current project." (context-scoped-query)) (defun focus-project (name base-path) "Shortcut: focus on a project by name and base path. Calls push-context with :scope :project." (push-context :project name :base-path base-path :scope :project)) (defun focus-session () "Shortcut: enter a session context (ephemeral scope). Objects created in this scope are visible only during the session." (push-context :project "session" :scope :session)) (defun focus-memex () "Shortcut: return to global memex scope. Equivalent to pop-context until stack is empty or :memex context is reached." (loop while (and *context-stack* (not (eq (getf (current-context) :scope) :memex))) do (pop-context))) (defun unfocus () "Pop the top context and return to the previous one." (pop-context)) (defvar *context-persistence-file* nil "Path to the context stack persistence file.") (defun context-persist-file () "Returns the full path to the context persistence file." (or *context-persistence-file* (setf *context-persistence-file* (merge-pathnames ".cache/passepartout/context.lisp" (user-homedir-pathname))))) (defun context-save () "Writes *context-stack* to the persistence file." (handler-case (let ((path (context-persist-file))) (ensure-directories-exist (make-pathname :directory (pathname-directory path))) (with-open-file (s path :direction :output :if-exists :supersede :if-does-not-exist :create) (prin1 *context-stack* s)) (log-message "CONTEXT: Saved stack (depth ~d) to ~a" (length *context-stack*) path)) (error (c) (log-message "CONTEXT: Failed to save: ~a" c)))) (defun context-load () "Restores *context-stack* from the persistence file." (handler-case (let ((path (context-persist-file))) (when (probe-file path) (with-open-file (s path :direction :input) (let ((*read-eval* nil) (data (read s nil nil))) (when (listp data) (setf *context-stack* data) (log-message "CONTEXT: Restored stack (depth ~d) from ~a" (length *context-stack*) path)) t)))) (error (c) (log-message "CONTEXT: Failed to load: ~a" c) nil))) (defskill :passepartout-system-context-manager :priority 90 :trigger (lambda (ctx) (declare (ignore ctx)) nil) :deterministic (lambda (action ctx) (declare (ignore action)) (ignore-errors (when (> (context-stack-depth) 0) nil)) nil)) (when (boundp '*scope-resolver*) (setf *scope-resolver* #'current-scope)) ;; Restore persisted context on load (context-load) (eval-when (:compile-toplevel :load-toplevel :execute) (ql:quickload :fiveam :silent t)) (defpackage :passepartout-context-tests (:use :cl :passepartout) (:export #:context-suite)) (in-package :passepartout-context-tests) (fiveam:def-suite context-suite :description "Context manager verification") (fiveam:in-suite context-suite) (fiveam:test test-push-pop-context "Contract 1-2: push-context and pop-context maintain stack order." (let* ((pkg (find-package "PASSEPARTOUT.SKILLS.SYSTEM-CONTEXT-MANAGER")) (stack-var (and pkg (find-symbol "*CONTEXT-STACK*" pkg))) (pf-var (and pkg (find-symbol "*CONTEXT-PERSISTENCE-FILE*" pkg)))) (when stack-var (setf (symbol-value stack-var) nil) (push-context :project "testapp" :base-path "/tmp" :scope :project) (fiveam:is (= 1 (length (symbol-value stack-var)))) (fiveam:is (string= "testapp" (getf (car (symbol-value stack-var)) :project))) (pop-context) (fiveam:is (null (symbol-value stack-var)))))) (fiveam:test test-context-save-load "Contract 3-4: context-save and context-load round-trip." (let* ((pkg (find-package "PASSEPARTOUT.SKILLS.SYSTEM-CONTEXT-MANAGER")) (stack-var (and pkg (find-symbol "*CONTEXT-STACK*" pkg))) (pf-var (and pkg (find-symbol "*CONTEXT-PERSISTENCE-FILE*" pkg)))) (when (and stack-var pf-var) (let* ((tmpfile (merge-pathnames "test-context.lisp" (uiop:temporary-directory)))) (setf (symbol-value pf-var) tmpfile) (setf (symbol-value stack-var) (list '(:project "test" :base-path "/tmp" :scope :project))) (context-save) (fiveam:is (probe-file tmpfile)) (setf (symbol-value stack-var) nil) (context-load) (fiveam:is (= 1 (length (symbol-value stack-var)))) (fiveam:is (string= "test" (getf (car (symbol-value stack-var)) :project))) (ignore-errors (delete-file tmpfile))))))