Files
passepartout/harness/skills.org
Amr Gharbeia 41de20d3f1
Some checks failed
Deploy (Gitea) / deploy (push) Failing after 11s
v0.2.1: polish, deploy, CI, and literate refactor
- Secret Exposure Gate + Privacy Filter (Bouncer)
- Shell actuator safety harness (timeout, blocked patterns)
- REPL-first enforcement (lisp validation gate, system-prompt-augment)
- Engineering Standards lifecycle (two-track Org-first + REPL-first)
- Literate Programming discipline (one function per block, reflect-back)
- AGENTS.md: thin routing layer, skills are authoritative
- SKILLS_DIR removed, ~/notes fallback eliminated
- opencortex.sh: multi-distro (Debian+Fedora), configure, install service, backup, restore, help
- infrastructure/opencortex.service (systemd user unit)
- Docker: updated to debian:trixie, fixed build context
- GitHub CI: lint + test workflows fixed, trigger on tags only
- Gitea CI: deploy workflow paths fixed
- README: one-line curl install, badges
- USER_MANUAL: Deployment section (bare metal, Docker, backup)
- .gitignore: skills/*.lisp and tests/*.lisp as generated artifacts
- Prose/block refactor across all 35 org files
- Test suite Tier 1: 43/45 pass (env-dependent failures isolated)
2026-05-02 17:04:33 -04:00

19 KiB

The Skill Engine (skills.lisp)

Overview

The Skill Engine is the dynamic loading and lifecycle manager for all OpenCortex skills. It discovers skill files in the skills directory, resolves their dependency order, loads them into jailed packages, exports their public symbols into the opencortex package, and provides the defskill macro that skills use to register themselves.

Key concepts:

  • defskill — macro that registers a skill with its trigger, deterministic gate, and optional probabilistic prompt
  • def-cognitive-tool — macro that registers a tool the LLM can invoke
  • load-skill-from-org / load-skill-from-lisp — load a skill from a literate Org file or a pre-tangled Lisp file
  • topological-sort-skills — orders skills by their #+DEPENDS_ON: declarations
  • find-triggered-skill — returns the highest-priority skill whose trigger matches the current context

The engine supports hot-reload — skills can be replaced at runtime without restarting the daemon.

Implementation

Package Context

(in-package :opencortex)

Utility functions

Helper functions used by the skill loader and other components.

Cosine similarity

Computes the cosine similarity between two numeric vectors. Used by the peripheral vision system for semantic relevance scoring.

(defun COSINE-SIMILARITY (v1 v2)
  "Computes cosine similarity between two vectors."
  (let* ((len1 (length v1)) (len2 (length v2)))
    (if (or (zerop len1) (zerop len2))
        0.0
        (let* ((dot 0.0d0) (n1 0.0d0) (n2 0.0d0))
          (dotimes (i (min len1 len2))
            (let* ((x (coerce (elt v1 i) 'double-float)) (y (coerce (elt v2 i) 'double-float)))
              (incf dot (* x y)) (incf n1 (* x x)) (incf n2 (* y y))))
          (if (or (zerop n1) (zerop n2)) 0.0 (/ dot (sqrt (* n1 n2))))))))

Secret masking

Simple mask function and the vault memory hash table. Used by the Bouncer skill and credentials vault.

(defun VAULT-MASK-STRING (s) (declare (ignore s)) "[MASKED]")
(defvar *VAULT-MEMORY* (make-hash-table :test 'equal))

Skill data structures

The skill struct holds all metadata about a loaded skill: its name, priority, dependencies, trigger function, probabilistic prompt generator, deterministic gate, and system prompt augmentor. The skill-entry struct tracks the loading state of each discovered skill file.

(defstruct skill name priority dependencies trigger-fn probabilistic-prompt deterministic-fn system-prompt-augment)
(defvar *skills-registry* (make-hash-table :test 'equal))
(defvar *skill-catalog* (make-hash-table :test 'equal)
  "Tracks all discovered skill files and their loading state.")
(defstruct skill-entry filename (status :discovered) error-log (load-time 0))

Skill discovery (find-triggered-skill)

Iterates the registry and returns the highest-priority skill whose trigger function matches the current context. Only skills with a probabilistic prompt are considered (skills that are purely deterministic don't need LLM intervention).

(defun find-triggered-skill (context)
  "Returns the highest priority skill whose trigger matches context."
  (let ((triggered nil))
    (maphash (lambda (name skill) 
               (declare (ignore name)) 
               (when (and (skill-probabilistic-prompt skill)
                          (ignore-errors (funcall (skill-trigger-fn skill) context)))
                 (push skill triggered))) 
              *skills-registry*)
    (first (sort triggered #'> :key #'skill-priority))))

Skill registration macro (defskill)

The primary API for skills. Each skill file calls this once to register itself. The macro creates a skill struct and stores it in *skills-registry* keyed by the skill's name.

(defmacro defskill (name &key priority dependencies trigger probabilistic deterministic system-prompt-augment)
  "Registers a new skill. NAME is a keyword. TRIGGER is a function (context) → bool."
  `(setf (gethash (string-downcase (string ,name)) *skills-registry*)
         (make-skill :name (string-downcase (string ,name)) 
                    :priority (or ,priority 10) 
                    :dependencies ',dependencies
                    :trigger-fn ,trigger 
                    :probabilistic-prompt ,probabilistic 
                    :deterministic-fn ,deterministic
                    :system-prompt-augment ,system-prompt-augment)))

Dependency resolution (resolve-skill-dependencies)

Recursively resolves all transitive dependencies for a given skill, returning an ordered list. Uses a standard topological sort with cycle detection (a seen set prevents infinite recursion).

(defun resolve-skill-dependencies (skill-name)
  "Resolves transitive dependencies. Returns list of skill names in dependency order."
  (let ((resolved nil) (seen nil))
    (labels ((visit (name) 
               (unless (member name seen :test #'equal) 
                 (push name seen)
                 (let ((skill (gethash (string-downcase (string name)) *skills-registry*)))
                   (when skill 
                     (dolist (dep (skill-dependencies skill)) (visit dep))))
                 (push name resolved))))
      (visit skill-name) 
      (nreverse resolved))))

Skill File Analysis (parse-skill-metadata)

(defun parse-skill-metadata (filepath)
  "Extracts ID and DEPENDS_ON tags from org file."
  (let ((dependencies nil) (id nil) (content (uiop:read-file-string filepath)))
    (let ((id-start (search ":ID:" content)))
      (when id-start
        (let ((id-end (position #\Newline content :start id-start)))
          (when id-end (setf id (string-trim " " (subseq content (+ id-start 4) id-end)))))))
    (let ((pos 0))
      (loop while (setf pos (search "#+DEPENDS_ON:" content :start2 pos))
            do (let ((end (position #\Newline content :start pos)))
              (when end
                (let ((line (string-trim " " (subseq content (+ pos 13) end))))
                  (dolist (d (uiop:split-string line :separator '(#\Space #\Tab)))
                    (unless (string= d "") (push d dependencies))))
                (setf pos end)))))
    (values id (reverse dependencies))))

Dependency Resolution (topological-sort-skills)

(defun topological-sort-skills (skills-dir)
  "Returns a list of skill filepaths sorted by dependency."
  (let* ((org-files (uiop:directory-files skills-dir "org-skill-*.org"))
         (lisp-files (uiop:directory-files skills-dir "org-skill-*.lisp"))
         (files (append org-files lisp-files))
        (adj (make-hash-table :test 'equal))
        (name-to-file (make-hash-table :test 'equal))
        (id-to-file (make-hash-table :test 'equal))
        (result nil)
        (visited (make-hash-table :test 'equal))
        (stack (make-hash-table :test 'equal)))
    (dolist (file files)
      (let ((filename (pathname-name file)))
        (if (uiop:string-suffix-p (namestring file) ".lisp")
            (progn
              (setf (gethash (string-downcase filename) name-to-file) file)
              ;; Don't overwrite dependency info from .org files
              (unless (gethash (string-downcase filename) adj)
                (setf (gethash (string-downcase filename) adj) nil)))
            (multiple-value-bind (id deps) (parse-skill-metadata file)
              (setf (gethash (string-downcase filename) name-to-file) file)
              (when id (setf (gethash (string-downcase id) id-to-file) file))
              (setf (gethash (string-downcase filename) adj) deps)))))
    (labels ((visit (file)
               (let* ((filename (pathname-name file))
                      (node-key (string-downcase filename)))
                 (unless (gethash node-key visited)
                   (setf (gethash node-key stack) t)
                   (dolist (dep (gethash node-key adj))
                     (let* ((is-id-p (uiop:string-prefix-p "id:" (string-downcase dep)))
                            (dep-key (string-downcase (if is-id-p (subseq dep 3) dep)))
                            (dep-file (if is-id-p 
                                          (gethash dep-key id-to-file)
                                          (or (gethash dep-key id-to-file)
                                              (gethash dep-key name-to-file)))))
                       (when dep-file
                         (let ((dep-filename (pathname-name dep-file)))
                           (if (gethash (string-downcase dep-filename) stack)
                               (error "Circular dependency detected")
                               (visit dep-file))))))
                   (setf (gethash node-key stack) nil)
                   (setf (gethash node-key visited) t)
                   (push file result)))))
      (let ((filenames (sort (mapcar #'pathname-name files) #'string<)))
        (dolist (name filenames)
          (let ((file (gethash (string-downcase name) name-to-file)))
            (when file (visit file)))))
      (nreverse result))))

Jailed Loading (load-skill-from-org)

(defun validate-lisp-syntax (code-string)
  "Checks if a string contains valid Common Lisp forms."
  (handler-case
      (let ((*read-eval* nil))
        (with-input-from-string (s (format nil "(progn ~a)" code-string))
          (loop for form = (read s nil :eof) until (eq form :eof)))
        (values t nil))
    (error (c) (values nil (format nil "~a" c)))))

(defun remove-in-package-forms (code-string)
  "Removes in-package forms so symbols get defined in skill package."
  (let ((lines (uiop:split-string code-string :separator '(#\Newline)))
        (result ""))
    (dolist (line lines)
      (let ((trimmed (string-trim '(#\Space #\Tab) line)))
        (unless (uiop:string-prefix-p "(in-package" trimmed)
          (setf result (concatenate 'string result line (string #\Newline))))))
    result))

(defun extract-tangle-target (line)
  "Extracts the value of the :tangle header."
  (let ((pos (search ":tangle" line)))
    (when pos
      (let ((rest (string-trim '(#\Space #\Tab) (subseq line (+ pos 7)))))
        (let ((end (position #\Space rest)))
          (if end (subseq rest 0 end) rest))))))

(defun load-skill-from-org (filepath)
  "Parses and evaluates Lisp blocks from an Org file."
  (let* ((skill-base-name (pathname-name filepath))
         (entry (or (gethash skill-base-name *skill-catalog*) (setf (gethash skill-base-name *skill-catalog*) (make-skill-entry :filename skill-base-name)))))
    (setf (skill-entry-status entry) :loading)
    (handler-case
        (let* ((content (uiop:read-file-string filepath))
               (lines (uiop:split-string content :separator '(#\Newline)))
               (in-lisp-block nil) (collect-this-block nil) (lisp-code "")
               (pkg-name (intern (string-upcase (format nil "OPENCORTEX.SKILLS.~a" skill-base-name)) :keyword)))
          (dolist (line lines)
            (let ((clean-line (string-trim '(#\Space #\Tab #\Return) line)))
              (cond
                ((uiop:string-prefix-p "#+begin_src lisp" clean-line)
                 (setf in-lisp-block t)
                 (let ((target (extract-tangle-target clean-line)))
                   ;; Collect if there's no tangle target (inherits from file)
                   ;; or if it's a lisp file and NOT a test.
                   (setf collect-this-block (or (null target)
                                                (and (not (search "no" target))
                                                     (not (search "/tests" target)))))))
                ((uiop:string-prefix-p "#+end_src" clean-line)
                 (setf in-lisp-block nil) (setf collect-this-block nil))
                ((and in-lisp-block collect-this-block)
                 (unless (or (uiop:string-prefix-p ":PROPERTIES:" (string-upcase clean-line))
                             (uiop:string-prefix-p ":END:" (string-upcase clean-line))
                             (uiop:string-prefix-p ":ID:" (string-upcase clean-line)))
                   (setf lisp-code (concatenate 'string lisp-code line (string #\Newline))))))))
          (if (= (length lisp-code) 0)
              (setf (skill-entry-status entry) :ready)
              (progn
                (multiple-value-bind (valid-p err) (validate-lisp-syntax lisp-code)
                  (unless valid-p (error err)))
                (unless (find-package pkg-name)
                  (let ((new-pkg (make-package pkg-name :use '(:cl)))) (use-package :opencortex new-pkg)))
                (let ((*read-eval* nil) (*package* (find-package pkg-name)))
                  (harness-log "LOADER: Evaluating code for '~a' in package ~a" skill-base-name (package-name *package*))
                  (eval (read-from-string (format nil "(progn ~a)" lisp-code))))

                ;; Export symbols back to :OPENCORTEX for discoverability and testing
                (let* ((target-pkg (find-package :opencortex))
                       (raw-name (string-upcase skill-base-name))
                       (short-name (if (uiop:string-prefix-p "ORG-SKILL-" raw-name)
                                       (subseq raw-name 10)
                                       raw-name)))
                  (harness-log "LOADER: Scanning package ~a for symbols to export..." (package-name (find-package pkg-name)))
                  (do-symbols (sym (find-package pkg-name))
                    (when (eq (symbol-package sym) (find-package pkg-name))
                      (let ((sn (symbol-name sym)))
                        (when (or (uiop:string-prefix-p raw-name sn)
                                  (uiop:string-prefix-p short-name sn)
                                  (string-equal sn "DOCTOR-MAIN")
                                  (string-equal sn "RUN-SETUP-WIZARD"))
                          (harness-log "LOADER: Exporting ~a to :OPENCORTEX" sn)
                          ;; Resolve potential name conflicts by uninterning first
                          (let ((existing (find-symbol sn target-pkg)))
                            (when (and existing (not (eq existing sym)))
                              (unintern existing target-pkg)))
                          (import sym target-pkg)
                          (export sym target-pkg))))))

                (setf (skill-entry-status entry) :ready)))
          t)
      (error (c)
        (harness-log "LOADER ERROR in skill '~a': ~a" skill-base-name c)
        (setf (skill-entry-status entry) :failed) nil))))

(defun load-skill-from-lisp (filepath)
  "Loads a .lisp skill file directly, filtering out in-package forms."
  (let* ((skill-base-name (pathname-name filepath))
         (entry (or (gethash skill-base-name *skill-catalog*) (setf (gethash skill-base-name *skill-catalog*) (make-skill-entry :filename skill-base-name)))))
    (setf (skill-entry-status entry) :loading)
    (handler-case
        (let* ((content (remove-in-package-forms (uiop:read-file-string filepath)))
               (pkg-name (intern (string-upcase (format nil "OPENCORTEX.SKILLS.~a" skill-base-name)) :keyword)))
          (multiple-value-bind (valid-p err) (validate-lisp-syntax content)
            (unless valid-p (error err)))
          (unless (find-package pkg-name)
            (let ((new-pkg (make-package pkg-name :use '(:cl)))) (use-package :opencortex new-pkg)))
          (let ((*read-eval* nil) (*package* (find-package pkg-name)))
            (harness-log "LOADER: Loading .lisp skill '~a' in package ~a" skill-base-name (package-name *package*))
            ;; Evaluate forms individually so one bad form doesn't abort the entire skill
            (with-input-from-string (s content)
              (loop for form = (read s nil :eof) until (eq form :eof)
                    do (handler-case (eval form)
                         (error (c) (harness-log "LOADER WARNING in '~a': ~a" skill-base-name c))))))
          ;; Export symbols
          (let* ((target-pkg (find-package :opencortex))
                 (raw-name (string-upcase skill-base-name))
                 (short-name (if (uiop:string-prefix-p "ORG-SKILL-" raw-name)
                                 (subseq raw-name 10)
                                 raw-name)))
            (harness-log "LOADER: Scanning package ~a for symbols to export..." (package-name (find-package pkg-name)))
            (do-symbols (sym (find-package pkg-name))
              (when (eq (symbol-package sym) (find-package pkg-name))
                (let ((sn (symbol-name sym)))
                  (when (or (uiop:string-prefix-p raw-name sn)
                            (uiop:string-prefix-p short-name sn)
                            (string-equal sn "DOCTOR-MAIN")
                            (string-equal sn "RUN-SETUP-WIZARD"))
                    (harness-log "LOADER: Exporting ~a to :OPENCORTEX" sn)
                    (let ((existing (find-symbol sn target-pkg)))
                      (when (and existing (not (eq existing sym)))
                        (unintern existing target-pkg)))
                    (import sym target-pkg)
                    (export sym target-pkg))))))
          (setf (skill-entry-status entry) :ready))
      (error (c)
        (harness-log "LOADER ERROR in skill '~a': ~a" skill-base-name c)
        (setf (skill-entry-status entry) :failed) nil))))

Initialize (initialize-all-skills)

(defun initialize-all-skills ()
  "Initializes all skills from the XDG skills directory."
  (let* ((data-dir (uiop:ensure-directory-pathname (or (uiop:getenv "OC_DATA_DIR") (namestring (merge-pathnames ".local/share/opencortex/" (user-homedir-pathname))))))
         (skills-dir (merge-pathnames "skills/" data-dir)))
    (unless (uiop:directory-exists-p skills-dir) (return-from initialize-all-skills nil))
    (let ((sorted-files (topological-sort-skills skills-dir)))
      (harness-log "LOADER: Initializing ~a skills..." (length sorted-files))
      (dolist (file sorted-files)
        (if (uiop:string-suffix-p (namestring file) ".lisp")
            (load-skill-from-lisp file)
            (load-skill-from-org file)))
      (harness-log "LOADER: Boot Complete."))))

Test Suite

(eval-when (:compile-toplevel :load-toplevel :execute)
  (ql:quickload :fiveam :silent t))

(defpackage :opencortex-boot-tests
  (:use :cl :fiveam :opencortex)
  (:export #:boot-suite))

(in-package :opencortex-boot-tests)

(def-suite boot-suite :description "Verification of the Skill Engine loader")
(in-suite boot-suite)

(test test-topological-sort-basic
  (let ((tmp-dir "/tmp/opencortex-boot-test/"))
    (uiop:ensure-all-directories-exist (list tmp-dir))
    (with-open-file (out (merge-pathnames "org-skill-a.org" tmp-dir) :direction :output :if-exists :supersede)
      (format out "#+DEPENDS_ON: skill-b-id~%"))
    (with-open-file (out (merge-pathnames "org-skill-b.org" tmp-dir) :direction :output :if-exists :supersede)
      (format out ":PROPERTIES:~%:ID: skill-b-id~%:END:~%"))
    (unwind-protect
         (let ((sorted (opencortex::topological-sort-skills tmp-dir)))
           (let ((pos-a (position "org-skill-a" sorted :key #'pathname-name :test #'string-equal))
                 (pos-b (position "org-skill-b" sorted :key #'pathname-name :test #'string-equal)))
             (is (< pos-b pos-a))))
       (uiop:delete-directory-tree (uiop:ensure-directory-pathname tmp-dir) :validate t))))