Files
passepartout/org/system-context-manager.org
Amr Gharbeia 61ea5767d6
Some checks failed
Deploy (Gitea) / deploy (push) Failing after 3s
v0.3.0 deferred: tab completion, multi-line, /help, activity indicator, context persistence, theming
- Tab completion: Tab key autocompletes / commands (Tab handler in on-key)
- Multi-line input: backslash + Enter inserts literal newline instead of sending
- /help command: displays full command listing with descriptions
- Activity indicator: :busy flag shows "...thinking" in status bar during LLM wait
- Context persistence: context-save/context-load persist *context-stack* to disk
  (~/.cache/passepartout/context.lisp). Auto-restores on skill load.
  Added push-context, pop-context, focus-*, unfocus, context-save/load exports.
- Theming: *tui-theme* plist with semantic color roles, /theme command
  View functions (view-chat, view-status, view-input) use theme-color
- TUI test suite: 19 tests, 53 checks (100% pass)
- Context test suite: 2 tests, 6 checks (100% pass)
- Total: 5 suites, 81 checks, 0 failures
2026-05-05 18:02:50 -04:00

11 KiB

SKILL: Context Manager (org-skill-context-manager.org)

Overview

The Context Manager provides stack-based project focusing. When the agent "focuses" on a project, file paths resolve relative to it and memory queries auto-filter by scope. This enables the agent to work within a bounded context without being distracted by unrelated memory.

The core provides the mechanism (memory-object-scope, context-query with scope parameter). This skill provides the policy — what to focus on, what scope means for each project, and how the stack is managed.

Implementation

Context Stack

;; REPL-VERIFIED: 2026-05-03T13:00:00

(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.")

context-max-depth

;; REPL-VERIFIED: 2026-05-03T13:00:00

(defvar *context-max-depth* 10
  "Maximum context stack depth. Prevents runaway pushes.")

#+end_src

Context Accessors

;; REPL-VERIFIED: 2026-05-03T13:00:00

(defun current-context ()
  "Returns the current context plist, or nil if no context is set."
  (car *context-stack*))

current-scope

;; REPL-VERIFIED: 2026-05-03T13:00:00

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

current-project

;; REPL-VERIFIED: 2026-05-03T13:00:00

(defun current-project ()
  "Returns the current project name, or nil."
  (getf (current-context) :project))

current-base-path

;; REPL-VERIFIED: 2026-05-03T13:00:00

(defun current-base-path ()
  "Returns the current base path for file resolution, or nil."
  (getf (current-context) :base-path))

context-stack-depth

;; REPL-VERIFIED: 2026-05-03T13:00:00

(defun context-stack-depth ()
  "Returns the current depth of the context stack."
  (length *context-stack*))

#+end_src

Stack Operations

;; REPL-VERIFIED: 2026-05-03T13:00:00

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

pop-context

;; REPL-VERIFIED: 2026-05-03T13:00:00

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

with-context

;; REPL-VERIFIED: 2026-05-03T13:00:00

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

#+end_src

Path Resolution

Resolves file paths relative to the current project's base path.

;; REPL-VERIFIED: 2026-05-03T13:00:00

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

Memory Scope Filtering

Provides scope-aware query access. When a context is active (scope ≠ :memex), queries only return objects whose scope is :memex (global) or matches the current scope.

;; REPL-VERIFIED: 2026-05-03T13:00:00

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

project-objects

;; REPL-VERIFIED: 2026-05-03T13:00:00

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

#+end_src

Project Focus Convenience

;; REPL-VERIFIED: 2026-05-03T13:00:00

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

focus-session

;; REPL-VERIFIED: 2026-05-03T13:00:00

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

focus-memex

;; REPL-VERIFIED: 2026-05-03T13:00:00

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

unfocus

;; REPL-VERIFIED: 2026-05-03T13:00:00

(defun unfocus ()
  "Pop the top context and return to the previous one."
  (pop-context))

#+end_src

Skill Registration

Persistence

;; REPL-VERIFIED: 2026-05-05T12:00:00

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

Skill Registration

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

Auto-Init: Wire Scope Resolver

Registers current-scope into the core *scope-resolver* hook so the perceive gate tags ingested objects with the active context scope. Also restores any previously saved context stack.

(when (boundp '*scope-resolver*)
  (setf *scope-resolver* #'current-scope))

;; Restore persisted context on load
(context-load)

Contract

  1. (push-context &key project base-path scope): pushes a context plist onto *context-stack* and persists to disk.
  2. (pop-context): pops the top context, persists, returns restored context.
  3. (context-save): serializes *context-stack* to the persistence file.
  4. (context-load): restores *context-stack* from persistence file on boot.

Test Suite

(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 ((passepartout::*context-stack* nil))
    (push-context :project "testapp" :base-path "/tmp" :scope :project)
    (fiveam:is (= 1 (length passepartout::*context-stack*)))
    (fiveam:is (string= "testapp" (getf (car passepartout::*context-stack*) :project)))
    (pop-context)
    (fiveam:is (null passepartout::*context-stack*))))

(fiveam:test test-context-save-load
  "Contract 3-4: context-save and context-load round-trip."
  (let* ((tmpfile (merge-pathnames "test-context.lisp" (uiop:temporary-directory)))
         (passepartout::*context-persistence-file* tmpfile)
         (passepartout::*context-stack* (list '(:project "test" :base-path "/tmp" :scope :project))))
    (context-save)
    (fiveam:is (probe-file tmpfile))
    (setf passepartout::*context-stack* nil)
    (context-load)
    (fiveam:is (= 1 (length passepartout::*context-stack*)))
    (fiveam:is (string= "test" (getf (car passepartout::*context-stack*) :project)))
    (delete-file tmpfile)))