File Reorganization: - Extracted core-context → symbolic-awareness (skill) - Extracted heartbeat → symbolic-events (skill) - Relocated 6 utility fragments, renamed 23 files, deleted system-model.lisp - Renamed gateway-* → channel-*, split gateway-messaging → 4 channel-* files - Renamed defskill/defpackage names to match new file prefixes - Deleted gateway-messaging.org/.lisp, removed core-context filter - Documented self-repair criterion, added AGENTS.md core boundary rule Token Economics (v0.5.0, skills not core): - tokenizer.lisp: count-tokens, model-token-ratio, token-cost, provider-token-cost (11 tests) - cost-tracker.lisp: cost-track-call, cost-session-total, cost-by-provider (6 tests) - token-economics.lisp: prompt-prefix-cached, context-assemble-cached, enforce-token-budget with CONTEXT_MAX_TOKENS env var (9 tests) Bug Fixes: - Fixed DeepSeek 400 (removed malformed tools from cascade) - Fixed UNDEFINED-FUNCTION crash (fboundp guards in think()) - Fixed gate-trace duplication (setf replaces list* in cognitive-verify) - Tightened dexador connect-timeout 10s→5s Test suite: 116/116 (100%)
15 KiB
SKILL: Event Orchestrator (symbolic-events.org)
Architectural Intent
The Event Orchestrator unifies three control-plane mechanisms that were previously scattered across the system:
- Hooks — actions triggered when Org nodes with specific
#+HOOK:properties are modified - Cron — time-based scheduled jobs using Org-mode timestamp repeat expressions
- Routing — three-tier complexity classifier that decides whether a job needs the LLM at all
Before the Orchestrator, each of these was handled ad-hoc. The heartbeat thread injected raw :heartbeat signals that skills had to parse themselves. Memory auto-save was a hardcoded counter in core-loop. There was no way to say "when this file changes, verify its integrity" or "archive old tasks every Sunday."
The Orchestrator attaches to the heartbeat as a deterministic gate (same pattern as the Dispatcher, the Archivist, and every other heartbeat-driven skill). On each tick, it checks the cron registry for due jobs and dispatches them at the appropriate tier.
The three tiers:
| Tier | LLM? | Mechanism | Example |
|---|---|---|---|
:reflex |
No | Direct function call | "Run integrity check" |
:cognition |
Light | Injected as user-input | "Summarize today's notes" |
:reasoning |
Full | Injected as user-input | "Plan the project architecture" |
The default classifier uses keywords in the context to determine the tier: rm, write-file, shell → :reflex; summarize, list, find → :cognition; everything else → :reasoning. This can be overridden by setting *tier-classifier* to a custom function.
Implementation
Package definition
(defpackage :passepartout.symbolic-events
(:use :cl :passepartout)
(:export
:orchestrator-register-hook
:orchestrator-register-cron
:orchestrator-classify
:orchestrator-on-heartbeat
:orchestrator-bootstrap
:orchestrator-dispatch
:default-classifier
:parse-org-repeat
:*hook-registry*
:*cron-registry*
:*tier-classifier*))
(in-package :passepartout.symbolic-events)
Registries
The hook registry maps Org-mode property names (like verify-integrity from a #+HOOK: verify-integrity headline property) to lists of gate function symbols. When a node with that hook is modified, the orchestrator calls each gate in sequence.
The cron registry maps job names (keywords like :weekly-report) to configuration plists. Each entry contains the repeat expression, the action function, and the dispatch tier.
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defvar *hook-registry* (make-hash-table :test 'equal)
"Maps hook property string → list of gate function symbols.")
cron-registry
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defvar *cron-registry* (make-hash-table :test 'equal)
"Maps job name string → plist (:next-run :expression :repeat :action :tier).")
tier-classifier
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defvar *tier-classifier* nil
"Optional function (context) → :reflex | :cognition | :reasoning.")
#+end_src
Default tier classifier
Uses keyword matching on the context text to determine which tier to dispatch at. The matching is deliberately coarse — it's a heuristic, not an exact science. Users who need precise control can set *tier-classifier* to their own function.
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun default-classifier (context)
"Rule-based tier classification.
:reflex — file/shell operations, deterministic checks
:cognition — text processing, summarization, simple Q&A
:reasoning — planning, analysis, multi-step decisions"
(let* ((text (or (getf context :text) ""))
(lower (string-downcase text)))
(cond
((or (search "rm " lower)
(search "write-file" lower)
(search "shell" lower)
(search "verify-" lower))
:reflex)
((or (search "summarize" lower)
(search "list" lower)
(search "find " lower)
(search "what is" lower)
(search "search" lower))
:cognition)
(t :reasoning))))
Parsing Org-mode repeat timestamps
Org-mode timestamps use the format +<2026-05-02 Sat +1w> for repeating events. The +1w means "repeat every week," +1d means "every day," etc. This function extracts the repeat unit and value.
Returns (UNIT VALUE) like (:W 1) for weekly, or NIL if there's no repeat clause.
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun parse-org-repeat (timestamp-string)
(let* ((cleaned (string-trim '(#\< #\> #\Newline #\Tab) timestamp-string))
(parts (uiop:split-string cleaned :separator '(#\space)))
(repeat-part (ignore-errors (car (last parts)))))
(when (and repeat-part (uiop:string-prefix-p "+" repeat-part))
(let* ((rest (subseq repeat-part 1))
(num-end (position-if (lambda (c) (not (digit-char-p c))) rest))
(num (parse-integer (subseq rest 0 num-end)))
(unit-str (subseq rest num-end)))
(list (intern (string-upcase unit-str) :keyword) num)))))
Registering a hook
Called at boot or when a new #+HOOK: property is discovered. Appends the gate function to the registry entry for that hook.
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun orchestrator-register-hook (hook-property gate-function)
"Registers a deterministic gate to fire when an Org node with
the #+HOOK: property matching HOOK-PROPERTY is modified."
(push gate-function
(gethash (string-downcase (string hook-property)) *hook-registry*))
(log-message "ORCHESTRATOR: Hook ~a → ~a" hook-property gate-function))
Registering a cron job
Each cron job has a name, an Org-mode timestamp with optional repeat, an action function, and a dispatch tier. The :next-run field is initialized to the current time so the job fires on the first heartbeat cycle (it will be rescheduled according to the repeat pattern after execution).
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun orchestrator-register-cron (name expression action-function tier)
"Register a cron job. NAME is a keyword, EXPRESSION is an Org-mode
timestamp string with optional repeat. TIER is :reflex :cognition :reasoning."
(let* ((repeat (parse-org-repeat expression))
(now (get-universal-time)))
(setf (gethash (string-downcase (string name)) *cron-registry*)
(list :next-run now
:expression expression
:repeat repeat
:action action-function
:tier tier))
(log-message "ORCHESTRATOR: Cron ~a (tier: ~a, repeat: ~a)"
name tier repeat)))
Dispatch
Routes an action to the appropriate executor based on its tier. Reflex actions are called directly (deterministic, no LLM overhead). Cognition and reasoning actions are injected as user-input events, which triggers the normal Perceive → Reason → Act pipeline (but at different model tiers).
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun orchestrator-dispatch (action tier)
"Execute ACTION at the specified TIER."
(flet ((safe-inject (text)
(when (fboundp (find-symbol "STIMULUS-INJECT" :passepartout))
(funcall (find-symbol "STIMULUS-INJECT" :passepartout)
(list :type :EVENT
:payload (list :sensor :user-input :text text))))))
(ecase tier
(:reflex
(if (functionp action)
(funcall action)
(when (and (symbolp action) (fboundp action))
(funcall action)))
:dispatched)
(:cognition
(safe-inject (format nil "~a" action))
:injected)
(:reasoning
(safe-inject (format nil "~a" action))
:injected))))
Heartbeat handler
Called on each heartbeat cycle. Checks the cron registry for jobs whose :next-run time has passed, dispatches them, and reschedules repeating jobs.
The rescheduling computes the next run based on the repeat unit: :d (days), :w (weeks), :m (months), defaulting to :h (hours). This is deliberately simple — full calendar-aware scheduling (skip weekends, respect business hours) can be added later.
Returns nil so it doesn't block the heartbeat signal from reaching other skills.
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun orchestrator-on-heartbeat (context)
"Called on each heartbeat tick. Checks and dispatches due cron jobs."
(declare (ignore context))
(let ((now (get-universal-time))
(due-jobs nil))
(maphash (lambda (name config)
(let ((next-run (getf config :next-run)))
(when (>= now next-run)
(push (cons name config) due-jobs))))
*cron-registry*)
(dolist (job due-jobs)
(let* ((name (car job))
(config (cdr job))
(action (getf config :action))
(tier (getf config :tier))
(repeat (getf config :repeat))
(result (orchestrator-dispatch action tier)))
(log-message "ORCHESTRATOR: Heartbeat dispatched ~a (tier: ~a) → ~a"
name tier result)
(when repeat
(let* ((unit (first repeat))
(value (second repeat))
(interval (case unit
(:d (* 86400 value))
(:w (* 604800 value))
(:m (* 2592000 value))
(t (* 3600 value)))))
(setf (getf (gethash name *cron-registry*) :next-run)
(+ now interval))))))
nil))
Bootstrap
Scans all Org files in the memex for #+HOOK: and #+CRON: properties in
headline property drawers and auto-registers them.
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun orchestrator-scan-org-file (filepath)
"Scans a single Org file for HOOK and CRON properties in property drawers.
Returns a list of plists (:type :hook/:cron :name <str> :value <str>)."
(let ((results nil)
(in-properties nil)
(lines nil))
(handler-case
(setf lines (uiop:split-string (uiop:read-file-string filepath)
:separator '(#\Newline)))
(error (c)
(log-message "ORCHESTRATOR: Could not read ~a: ~a" filepath c)
(return-from orchestrator-scan-org-file nil)))
(dolist (line lines)
(let ((trimmed (string-trim '(#\Space) line)))
(when (string= trimmed ":PROPERTIES:")
(setf in-properties t))
(when (string= trimmed ":END:")
(setf in-properties nil))
(when in-properties
(cond
((uiop:string-prefix-p ":HOOK:" trimmed)
(let ((val (string-trim '(#\Space) (subseq trimmed 6))))
(push (list :type :hook :name val :file filepath) results)
(log-message "ORCHESTRATOR: Found hook ~a in ~a" val filepath)))
((uiop:string-prefix-p ":CRON:" trimmed)
(let ((val (string-trim '(#\Space) (subseq trimmed 6))))
(push (list :type :cron :name val :file filepath) results)
(log-message "ORCHESTRATOR: Found cron ~a in ~a" val filepath)))))))
(nreverse results)))
orchestrator-bootstrap
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun orchestrator-bootstrap ()
"Scans all Org files in the memex for #+HOOK: and #+CRON: properties
and registers them. Scans ~/memex/projects/ and ~/memex/system/ by default."
(let* ((memex-dir (or (uiop:getenv "MEMEX_DIR")
(namestring (merge-pathnames "memex/" (user-homedir-pathname)))))
(scan-dirs (list (merge-pathnames "projects/" memex-dir)
(merge-pathnames "system/" memex-dir)))
(hook-count 0)
(cron-count 0))
(dolist (dir scan-dirs)
(handler-case
(let ((files (uiop:directory-files dir "*.org")))
(dolist (file files)
(let* ((path (namestring file))
(entries (orchestrator-scan-org-file path)))
(dolist (entry entries)
(let ((type (getf entry :type))
(name (getf entry :name)))
(cond
((eq type :hook)
(orchestrator-register-hook name
(lambda ()
(log-message "ORCHESTRATOR: Hook ~a fired" name))))
((eq type :cron)
(orchestrator-register-cron
(intern (string-upcase (format nil "cron-~a" name)) :keyword)
name
(lambda ()
(log-message "ORCHESTRATOR: Cron ~a fired" name))
:cognition))))
(if (eq (getf entry :type) :hook) (incf hook-count) (incf cron-count))))))
(error (c)
(log-message "ORCHESTRATOR: Could not scan ~a: ~a" dir c))))
(log-message "ORCHESTRATOR: Bootstrap complete (~d hooks, ~d cron jobs)"
hook-count cron-count)))
#+end_src
Heartbeat Generation (events-start-heartbeat)
The heartbeat generator was extracted from core-pipeline.lisp in v0.5.0. It creates a background thread that periodically injects :heartbeat signals into the pipeline.
If heartbeat is corrupted or missing, the agent has no background ticks — no cron jobs, no auto-save. But it remains fully functional: degraded, not dead. This is the self-repair criterion.
(defun events-start-heartbeat ()
"Starts the background heartbeat thread. v0.5.0: extracted from core-loop."
(let ((interval (or (ignore-errors (parse-integer (uiop:getenv "HEARTBEAT_INTERVAL"))) 60))
(auto-save (or (ignore-errors (parse-integer (uiop:getenv "MEMORY_AUTO_SAVE_INTERVAL"))) *memory-auto-save-interval*)))
(setf *memory-auto-save-interval* auto-save)
(setf *heartbeat-save-counter* 0)
(setf *heartbeat-thread*
(bt:make-thread
(lambda ()
(loop
(sleep interval)
(incf *heartbeat-save-counter*)
(when (>= *heartbeat-save-counter* (/ *memory-auto-save-interval* interval))
(setf *heartbeat-save-counter* 0)
(save-memory-to-disk))
(stimulus-inject
(list :type :EVENT :payload (list :sensor :heartbeat :unix-time (get-universal-time))))))
:name "passepartout-heartbeat"))))
Skill registration
The orchestrator registers as a skill with low priority so it runs after critical skills (policy, dispatcher) but before the heartbeat processing. The trigger matches :heartbeat sensor events.
(defskill :passepartout-symbolic-events
:priority 80
:trigger (lambda (ctx)
(eq (getf (getf ctx :payload) :sensor) :heartbeat))
:deterministic (lambda (action context)
(declare (ignore action))
(orchestrator-on-heartbeat context)
nil))