Files
passepartout/org/sensor-time.org
Amr Gharbeia b9a4318ef8 reorg: tangle to XDG, remove stale lisp files, fix tui input
- Changed all 50 org file :tangle targets from ../lisp/ to
  ~/.local/share/passepartout/lisp/ (XDG data dir)
- Removed 49 generated .lisp files from project lisp/ directory
- Removed tests/system-integration-tests.lisp (generated)
- Removed lisp/*.fasl (compiled, stale)
- Updated core-manifest.org to tangle .asd to XDG root
- Remapped quicklisp symlink: local-projects/passepartout → XDG

TUI fixes in channel-tui-main.org:
- Removed with-raw-terminal (stty raw breaks fd 0 reads in this SBCL)
- Use cat subprocess + pipe for keyboard input (via :input :interactive)
- Blocking read-char on pipe with with-timeout 0.1s for daemon processing
- Key events queued via drain-queue alongside daemon messages
- Full dialog key routing (Escape, Up/Down, Enter, filters, Backspace)
- SIGWINCH resize handling
- Post-handshake backend-size re-query
- Daemon version in status bar (was v0.5.0 hardcoded)
- Handshake version stored in state, no add-msg
- :daemon-version and :size-queried in state plist
- view-status uses draw-rect for background
- Test section gated with #+passepartout-tests
2026-05-14 12:34:06 -04:00

9.1 KiB

Sensor-Time — temporal awareness skill

Architectural Intent

The heartbeat fires every 60 seconds for maintenance. It can also carry temporal awareness — scanning for approaching deadlines, tracking session duration, and injecting temporal context so the LLM knows the current time without triggering a call.

This skill provides:

  1. format-time-for-llm — injectable TIME section for system prompt
  2. session-duration — session start tracking
  3. sensor-time-tick — deadline scanning registered as cron job

All pure Lisp, 0 LLM tokens for temporal awareness.

Contract

  1. (format-time-for-llm &key session-duration): returns a human-readable TIME section string. Respects TIME_AWARENESS and TIME_FORMAT env vars.
  2. (session-duration): returns seconds since skill load, or nil.
  3. (sensor-time-tick): scans memory for headlines with :DEADLINE or :SCHEDULED properties. If any are within DEADLINE_WARNING_MINUTES, returns a formatted deadline note string. Returns nil otherwise.

Implementation

Package context

(in-package :passepartout)

Session tracking

(defvar *session-start-time* nil
  "Universal time when sensor-time skill was loaded.")

(defun session-duration ()
  "Returns duration in seconds since skill load, or nil if not initialized."
  (when *session-start-time*
    (- (get-universal-time) *session-start-time*)))

(defun sensor-time-initialize ()
  "Record session start and register deadline-scanning cron."
  (setf *session-start-time* (get-universal-time))
  (handler-case
      (when (fboundp 'orchestrator-register-cron)
        (orchestrator-register-cron "time-tick"
          :action (lambda () (sensor-time-tick))
          :tier :reflex
          :repeat "+1m"))
    (error (c)
      (log-message "SENSOR-TIME: Could not register cron: ~a" c))))

Contract 1: format-time-for-llm

(defun format-time-for-llm (&key (session-duration-seconds nil))
  "Returns a TIME: section string for the system prompt.
When TIME_AWARENESS=false, returns empty string.
TIME_FORMAT: iso = 2026-05-08T06:30:00Z, natural = 6:30 AM UTC, Thu May 8 2026.
When session-duration-seconds is provided, includes session info."
  (unless (or (uiop:getenv "TIME_AWARENESS")
              (not (string-equal "false" (or (uiop:getenv "TIME_AWARENESS") "true"))))
    (return-from format-time-for-llm ""))
  (let ((time-aware (uiop:getenv "TIME_AWARENESS")))
    (when (and time-aware (string-equal time-aware "false"))
      (return-from format-time-for-llm "")))
  (multiple-value-bind (sec minute hour date month year day daylight zone)
      (decode-universal-time (get-universal-time) 0)
    (declare (ignore daylight zone))
    (let* ((format (or (uiop:getenv "TIME_FORMAT") "iso"))
           (iso-str (format nil "~4,'0d-~2,'0d-~2,'0dT~2,'0d:~2,'0d:~2,'0dZ"
                            year month date hour minute (round sec)))
           (day-names '("Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun"))
           (month-names '("Jan" "Feb" "Mar" "Apr" "May" "Jun"
                          "Jul" "Aug" "Sep" "Oct" "Nov" "Dec"))
           (natural-str (format nil "~2,'0d:~2,'0d UTC, ~a ~a ~d ~d"
                                hour minute (nth day day-names)
                                (nth (1- month) month-names) date year))
           (time-str (if (string-equal format "natural") natural-str iso-str))
           (dur-str (when session-duration-seconds
                      (let* ((hours (floor session-duration-seconds 3600))
                             (mins (floor (mod session-duration-seconds 3600) 60)))
                        (if (> hours 0)
                            (format nil " Session: ~dh ~dm." hours mins)
                            (format nil " Session: ~dm." mins))))))
      (if dur-str
          (format nil "TIME: ~a.~a" time-str dur-str)
          (format nil "TIME: ~a." time-str)))))

Contract 2: sensor-time-tick (deadline scanning)

(defvar *deadline-warning-minutes* nil)

(defun sensor-time-tick ()
  "Scans memory for approaching deadlines. Returns a formatted note string
if any deadlines are within *deadline-warning-minutes*, nil otherwise.
Called by the time-tick cron job every minute."
  (let ((warning-min (or *deadline-warning-minutes*
                         (ignore-errors
                           (parse-integer (uiop:getenv "DEADLINE_WARNING_MINUTES")))
                         60)))
    (setf *deadline-warning-minutes* warning-min)
    (let ((now (get-universal-time))
          (deadlines nil))
      (maphash (lambda (id obj)
                 (declare (ignore id))
                 (let ((attrs (memory-object-attributes obj)))
                   (let ((deadline (getf attrs :DEADLINE))
                         (scheduled (getf attrs :SCHEDULED))
                         (title (getf attrs :TITLE)))
                     (dolist (prop (list deadline scheduled))
                       (when prop
                         (handler-case
                             (let* ((parsed (parse-integer prop :junk-allowed t))
                                    (d-minutes (if parsed
                                                   (- (round (/ (- parsed now) 60))
                                                       warning-min)
                                                   nil)))
                               (when (and d-minutes (< d-minutes warning-min))
                                 (push (list :title title
                                             :minutes (- (round (/ (- (or parsed 0) now) 60))))
                                       deadlines)))
                           (error () nil)))))))
               *memory-store*)
      (when deadlines
        (let* ((sorted (sort deadlines #'< :key (lambda (d) (getf d :minutes))))
               (parts (loop for d in sorted collect
                           (let* ((mins (getf d :minutes))
                                  (label (cond
                                           ((< mins 0) (format nil "~dmin overdue" (- mins)))
                                           ((= mins 0) "now")
                                           (t (format nil "~dmin" mins)))))
                             (format nil "~a (~a)" (getf d :title) label)))))
          (format nil "~d deadlines approaching: ~{~a; ~}" (length parts) parts))))))

Initialization

(sensor-time-initialize)

Test Suite

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

(defpackage :passepartout-sensor-time-tests
  (:use :cl :fiveam :passepartout)
  (:export #:sensor-time-suite))

(in-package :passepartout-sensor-time-tests)

(def-suite sensor-time-suite :description "Temporal awareness: time formatting, session, deadlines")
(in-suite sensor-time-suite)

(test test-format-time-for-llm-includes-year
  "Contract 1: format-time-for-llm returns a string with the current year."
  (let ((result (passepartout::format-time-for-llm)))
    (is (stringp result))
    (is (search "202" result))
    (is (search "TIME" result))))

(test test-format-time-for-llm-utc
  "Contract 1: iso format includes Z suffix."
  (let ((result (passepartout::format-time-for-llm)))
    (is (stringp result))
    (is (search "Z" result))))

(test test-format-time-for-llm-natural
  "Contract 1: natural format produces human-readable date."
  (let ((old-env (or (uiop:getenv "TIME_FORMAT") "")))
    (unwind-protect
         (progn
           (setf (uiop:getenv "TIME_FORMAT") "natural")
           (let ((result (passepartout::format-time-for-llm)))
             (is (stringp result))
             (is (search "UTC" result))))
      (setf (uiop:getenv "TIME_FORMAT") old-env))))

(test test-format-time-for-llm-with-session
  "Contract 1: with session duration, includes session info."
  (let ((result (passepartout::format-time-for-llm :session-duration-seconds 3720)))
    (is (search "1h 2m" result))))

(test test-session-duration
  "Contract 2: session-duration returns a positive number after init."
  (passepartout::sensor-time-initialize)
  (let ((dur (passepartout::session-duration)))
    (is (numberp dur))
    (is (>= dur 0))))

(test test-sensor-time-tick-empty
  "Contract 3: sensor-time-tick returns nil when no deadlines are near."
  (clrhash passepartout::*memory-store*)
  (let ((result (passepartout::sensor-time-tick)))
    (is (null result))))

(test test-sensor-time-tick-detects-deadline
  "Contract 3: sensor-time-tick detects a deadline close in time."
  (clrhash passepartout::*memory-store*)
  (setf passepartout::*deadline-warning-minutes* 120)
  (let ((near-future-time (- (get-universal-time) 60)))  ; 1 minute ago
    (ingest-ast (list :type :HEADLINE
                       :properties (list :ID "deadline-test"
                                         :TITLE "Submit report"
                                         :DEADLINE (write-to-string near-future-time))
                       :contents nil)))
  (let ((result (passepartout::sensor-time-tick)))
    (is (not (null result)))
    (is (search "Submit report" result))))