Phase 1 — dedup + hardening (~9 items): - Remove duplicate *skill-registry* defvar from core-skills - Merge *backend-registry* into *probabilistic-backends*, delete backend-register - Remove inject-stimulus alias, standardize on stimulus-inject - Add pre-eval sandbox (skill-source-scan) blocks restricted symbols before eval - Remove dead plist-get function; remove duplicate json-alist-to-plist export - Fix read-framed-message whitespace DoS (4096-iteration max) - Add *read-eval* nil to dispatcher-approvals-process read-from-string (RCE) - Add test-op to ASDF; update .asd version 0.4.3→0.7.2 Phase 2 — prose + contracts + reorder: - Split ROADMAP: 2623→1089 lines (TODO only), CHANGELOG: 260→1528 lines (full DONE history, 14 versions reverse chron) - Add Contracts + Overview to 6 channel files + embedding-native + programming-standards + symbolic-scope - Reorder 28 .org files: Contract → Test Suite → Implementation (TDD order) - Add 7-phase inline prose to think() in core-reason - Expand USER_MANUAL: 183→461 lines (10 new sections) Phase 3 — decomposition + export organization: - Decompose think() into think-assemble-prompt, think-call-llm, think-parse-response orchestrator - Organize 188 exports into 16 grouped sections by module Phase 4 — budget enforcement + error protocol: - Per-session budget enforcement (SESSION_BUDGET_USD env var, budget-exhausted-p, guard in think-call-llm) - Error condition hierarchy (6 conditions: pipeline-error, llm-error, gate-error, budget-error, protocol-error) - Restarts in loop-process: skip-signal, use-fallback, abort-pipeline
170 lines
7.7 KiB
Common Lisp
170 lines
7.7 KiB
Common Lisp
(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))))
|
|
|
|
(in-package :passepartout)
|
|
|
|
(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))))
|
|
|
|
(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)))))
|
|
|
|
(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))))))
|
|
|
|
(sensor-time-initialize)
|