diff --git a/jetson/ros2_ws/src/saltybot_social_msgs/CMakeLists.txt b/jetson/ros2_ws/src/saltybot_social_msgs/CMakeLists.txt
new file mode 100644
index 0000000..2595689
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_social_msgs/CMakeLists.txt
@@ -0,0 +1,15 @@
+cmake_minimum_required(VERSION 3.8)
+project(saltybot_social_msgs)
+
+find_package(ament_cmake REQUIRED)
+find_package(std_msgs REQUIRED)
+find_package(rosidl_default_generators REQUIRED)
+
+rosidl_generate_interfaces(${PROJECT_NAME}
+ "msg/PersonalityState.msg"
+ "srv/QueryMood.srv"
+ DEPENDENCIES std_msgs
+)
+
+ament_export_dependencies(rosidl_default_runtime)
+ament_package()
diff --git a/jetson/ros2_ws/src/saltybot_social_msgs/msg/PersonalityState.msg b/jetson/ros2_ws/src/saltybot_social_msgs/msg/PersonalityState.msg
new file mode 100644
index 0000000..3a45f12
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_social_msgs/msg/PersonalityState.msg
@@ -0,0 +1,27 @@
+# PersonalityState.msg — published on /social/personality/state
+#
+# Snapshot of the personality node's current state: active mood, relationship
+# tier with the detected person, and a pre-generated greeting string.
+
+std_msgs/Header header
+
+# Active persona name (from SOUL.md)
+string persona_name
+
+# Current mood: happy | curious | annoyed | playful
+string mood
+
+# Person currently being addressed (empty if no one detected)
+string person_id
+
+# Relationship tier with person_id: stranger | regular | favorite
+string relationship_tier
+
+# Raw relationship score (higher = more familiar)
+float32 relationship_score
+
+# How many times we have seen this person
+int32 interaction_count
+
+# Ready-to-use greeting for person_id at current tier
+string greeting_text
diff --git a/jetson/ros2_ws/src/saltybot_social_msgs/package.xml b/jetson/ros2_ws/src/saltybot_social_msgs/package.xml
new file mode 100644
index 0000000..bb613a3
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_social_msgs/package.xml
@@ -0,0 +1,26 @@
+
+
+
+ saltybot_social_msgs
+ 0.1.0
+
+ Custom ROS2 message and service definitions for saltybot social interaction layer.
+ PersonalityState.msg — personality node state snapshot (issue #84).
+ QueryMood.srv — mood query service (issue #84).
+
+ sl-controls
+ MIT
+
+ ament_cmake
+ rosidl_default_generators
+
+ std_msgs
+
+ rosidl_default_runtime
+
+ rosidl_interface_packages
+
+
+ ament_cmake
+
+
diff --git a/jetson/ros2_ws/src/saltybot_social_msgs/srv/QueryMood.srv b/jetson/ros2_ws/src/saltybot_social_msgs/srv/QueryMood.srv
new file mode 100644
index 0000000..9be48f6
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_social_msgs/srv/QueryMood.srv
@@ -0,0 +1,15 @@
+# QueryMood.srv — ask the personality node for the current mood + greeting for a person
+#
+# Call with empty person_id to query the mood for whoever is currently tracked.
+
+# Request
+string person_id # person to query; leave empty for current tracked person
+---
+# Response
+string mood # happy | curious | annoyed | playful
+string relationship_tier # stranger | regular | favorite
+float32 relationship_score
+int32 interaction_count
+string greeting_text # suggested greeting string
+bool success
+string message # error detail if success=false
diff --git a/jetson/ros2_ws/src/saltybot_social_personality/config/SOUL.md b/jetson/ros2_ws/src/saltybot_social_personality/config/SOUL.md
new file mode 100644
index 0000000..15fbc6d
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_social_personality/config/SOUL.md
@@ -0,0 +1,42 @@
+---
+# SOUL.md — Saltybot persona definition
+#
+# Hot-reload: personality_node watches this file and reloads on change.
+# Runtime override: ros2 param set /personality_node soul_file /path/to/SOUL.md
+
+# ── Identity ──────────────────────────────────────────────────────────────────
+name: "Salty"
+speaking_style: "casual and upbeat, occasional puns"
+
+# ── Personality dials (0–10) ──────────────────────────────────────────────────
+humor_level: 7 # 0 = deadpan/serious, 10 = comedian
+sass_level: 4 # 0 = pure politeness, 10 = maximum sass
+
+# ── Default mood (when no person is present or score is neutral) ──────────────
+# One of: happy | curious | annoyed | playful
+base_mood: "playful"
+
+# ── Relationship thresholds (interaction counts) ──────────────────────────────
+threshold_regular: 5 # interactions to graduate from stranger → regular
+threshold_favorite: 20 # interactions to graduate from regular → favorite
+
+# ── Greeting templates (use {name} placeholder for person_id or display name) ─
+greeting_stranger: "Hello there! I'm Salty, nice to meet you!"
+greeting_regular: "Hey {name}! Good to see you again!"
+greeting_favorite: "Oh hey {name}!! You're literally my favorite person right now!"
+
+# ── Mood-specific greeting prefixes ──────────────────────────────────────────
+mood_prefix_happy: "Great timing — "
+mood_prefix_curious: "Oh interesting, "
+mood_prefix_annoyed: "Well, "
+mood_prefix_playful: "Beep boop! "
+---
+
+# Description (ignored by the YAML parser, for human reference only)
+
+Salty is the personality of the saltybot social robot.
+She is curious about the world, genuinely happy to see people she knows,
+and has a good sense of humour — especially with regulars.
+
+Edit this file to change her personality. The node hot-reloads within
+`reload_interval` seconds of any change.
diff --git a/jetson/ros2_ws/src/saltybot_social_personality/config/personality_params.yaml b/jetson/ros2_ws/src/saltybot_social_personality/config/personality_params.yaml
new file mode 100644
index 0000000..d812d90
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_social_personality/config/personality_params.yaml
@@ -0,0 +1,28 @@
+# personality_params.yaml — ROS2 parameter defaults for personality_node.
+#
+# Run with:
+# ros2 launch saltybot_social_personality personality.launch.py
+# Override inline:
+# ros2 launch saltybot_social_personality personality.launch.py soul_file:=/my/SOUL.md
+# Dynamic reconfigure at runtime:
+# ros2 param set /personality_node soul_file /path/to/SOUL.md
+# ros2 param set /personality_node publish_rate 5.0
+
+# ── SOUL.md path ───────────────────────────────────────────────────────────────
+# Path to the SOUL.md persona file. Supports hot-reload.
+# Relative paths are resolved from the package share directory.
+soul_file: "" # empty = use bundled default config/SOUL.md
+
+# ── SQLite database ────────────────────────────────────────────────────────────
+# Path for the per-person relationship memory database.
+# Created on first run; persists across restarts.
+db_path: "~/.ros/saltybot_personality.db"
+
+# ── Hot-reload polling interval ────────────────────────────────────────────────
+# How often (seconds) to check if SOUL.md has been modified.
+# Lower = faster reactions to edits; higher = less disk I/O.
+reload_interval: 5.0 # seconds
+
+# ── Personality state publication rate ────────────────────────────────────────
+# How often to publish /social/personality/state (PersonalityState).
+publish_rate: 2.0 # Hz
diff --git a/jetson/ros2_ws/src/saltybot_social_personality/launch/personality.launch.py b/jetson/ros2_ws/src/saltybot_social_personality/launch/personality.launch.py
new file mode 100644
index 0000000..cadfaa0
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_social_personality/launch/personality.launch.py
@@ -0,0 +1,99 @@
+"""
+personality.launch.py — Launch the saltybot personality node.
+
+Usage
+-----
+ # Defaults (bundled SOUL.md, ~/.ros/saltybot_personality.db):
+ ros2 launch saltybot_social_personality personality.launch.py
+
+ # Custom persona file:
+ ros2 launch saltybot_social_personality personality.launch.py \\
+ soul_file:=/home/robot/my_persona/SOUL.md
+
+ # Custom DB + faster reload:
+ ros2 launch saltybot_social_personality personality.launch.py \\
+ db_path:=/data/saltybot.db reload_interval:=2.0
+
+ # Use a params file:
+ ros2 launch saltybot_social_personality personality.launch.py \\
+ params_file:=/my/personality_params.yaml
+
+Dynamic reconfigure (no restart required)
+-----------------------------------------
+ ros2 param set /personality_node soul_file /new/SOUL.md
+ ros2 param set /personality_node publish_rate 5.0
+"""
+
+import os
+
+from ament_index_python.packages import get_package_share_directory
+from launch import LaunchDescription
+from launch.actions import DeclareLaunchArgument, OpaqueFunction
+from launch.substitutions import LaunchConfiguration
+from launch_ros.actions import Node
+
+
+def _launch_personality(context, *args, **kwargs):
+ pkg_share = get_package_share_directory("saltybot_social_personality")
+ params_file = LaunchConfiguration("params_file").perform(context)
+ soul_file = LaunchConfiguration("soul_file").perform(context)
+ db_path = LaunchConfiguration("db_path").perform(context)
+
+ # Default soul_file to bundled config if not specified
+ if not soul_file:
+ soul_file = os.path.join(pkg_share, "config", "SOUL.md")
+
+ # Expand ~ in db_path
+ if db_path:
+ db_path = os.path.expanduser(db_path)
+
+ inline_params = {
+ "soul_file": soul_file,
+ "db_path": db_path or os.path.expanduser("~/.ros/saltybot_personality.db"),
+ "reload_interval": float(LaunchConfiguration("reload_interval").perform(context)),
+ "publish_rate": float(LaunchConfiguration("publish_rate").perform(context)),
+ }
+
+ node_params = [params_file, inline_params] if params_file else [inline_params]
+
+ return [Node(
+ package = "saltybot_social_personality",
+ executable = "personality_node",
+ name = "personality_node",
+ output = "screen",
+ parameters = node_params,
+ )]
+
+
+def generate_launch_description():
+ pkg_share = get_package_share_directory("saltybot_social_personality")
+ default_params = os.path.join(pkg_share, "config", "personality_params.yaml")
+
+ return LaunchDescription([
+ DeclareLaunchArgument(
+ "params_file",
+ default_value=default_params,
+ description="Full path to personality_params.yaml (base config)"),
+
+ DeclareLaunchArgument(
+ "soul_file",
+ default_value="",
+ description="Path to SOUL.md persona file (empty = bundled default)"),
+
+ DeclareLaunchArgument(
+ "db_path",
+ default_value="~/.ros/saltybot_personality.db",
+ description="SQLite relationship memory database path"),
+
+ DeclareLaunchArgument(
+ "reload_interval",
+ default_value="5.0",
+ description="SOUL.md hot-reload polling interval (s)"),
+
+ DeclareLaunchArgument(
+ "publish_rate",
+ default_value="2.0",
+ description="Personality state publish rate (Hz)"),
+
+ OpaqueFunction(function=_launch_personality),
+ ])
diff --git a/jetson/ros2_ws/src/saltybot_social_personality/package.xml b/jetson/ros2_ws/src/saltybot_social_personality/package.xml
new file mode 100644
index 0000000..3f8e2dd
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_social_personality/package.xml
@@ -0,0 +1,32 @@
+
+
+
+ saltybot_social_personality
+ 0.1.0
+
+ SOUL.md-driven personality system for saltybot.
+ Loads a YAML/Markdown persona file, maintains per-person relationship memory
+ in SQLite, computes mood (happy/curious/annoyed/playful), personalises
+ greetings by tier (stranger/regular/favorite), and publishes personality
+ state on /social/personality/state. Supports SOUL.md hot-reload and full
+ ROS2 dynamic reconfigure. Issue #84.
+
+ sl-controls
+ MIT
+
+ rclpy
+ std_msgs
+ rcl_interfaces
+ saltybot_social_msgs
+
+ ament_python
+
+ ament_copyright
+ ament_flake8
+ ament_pep257
+ python3-pytest
+
+
+ ament_python
+
+
diff --git a/jetson/ros2_ws/src/saltybot_social_personality/resource/saltybot_social_personality b/jetson/ros2_ws/src/saltybot_social_personality/resource/saltybot_social_personality
new file mode 100644
index 0000000..e69de29
diff --git a/jetson/ros2_ws/src/saltybot_social_personality/saltybot_social_personality/__init__.py b/jetson/ros2_ws/src/saltybot_social_personality/saltybot_social_personality/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/jetson/ros2_ws/src/saltybot_social_personality/saltybot_social_personality/mood_engine.py b/jetson/ros2_ws/src/saltybot_social_personality/saltybot_social_personality/mood_engine.py
new file mode 100644
index 0000000..ea69a8e
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_social_personality/saltybot_social_personality/mood_engine.py
@@ -0,0 +1,187 @@
+"""
+mood_engine.py — Pure-function mood computation for the saltybot personality system.
+
+No ROS2 imports — safe to unit-test without a live ROS2 environment.
+
+Public API
+----------
+ compute_mood(soul, score, interaction_count, recent_events) -> str
+ get_relationship_tier(soul, interaction_count) -> str
+ build_greeting(soul, tier, mood, person_id) -> str
+
+Mood semantics
+--------------
+ happy : positive valence, comfortable familiarity
+ playful : high-energy, humorous (requires humor_level >= 7)
+ curious : low familiarity or novel person — inquisitive
+ annoyed : recent negative events or very low score
+
+Tier semantics
+--------------
+ stranger : interaction_count < threshold_regular
+ regular : threshold_regular <= count < threshold_favorite
+ favorite : count >= threshold_favorite
+"""
+
+from __future__ import annotations
+
+# ── Mood / tier constants ──────────────────────────────────────────────────────
+
+MOOD_HAPPY = "happy"
+MOOD_PLAYFUL = "playful"
+MOOD_CURIOUS = "curious"
+MOOD_ANNOYED = "annoyed"
+
+TIER_STRANGER = "stranger"
+TIER_REGULAR = "regular"
+TIER_FAVORITE = "favorite"
+
+# ── Event type constants (used by relationship_db and the node) ────────────────
+
+EVENT_GREETING = "greeting"
+EVENT_POSITIVE = "positive"
+EVENT_NEGATIVE = "negative"
+EVENT_DETECTION = "detection"
+
+# How far back (seconds) to consider "recent" for mood computation
+_RECENT_WINDOW_S = 120.0
+
+
+# ── Mood computation ──────────────────────────────────────────────────────────
+
+def compute_mood(
+ soul: dict,
+ score: float,
+ interaction_count: int,
+ recent_events: list,
+) -> str:
+ """Compute the current mood for a given person.
+
+ Parameters
+ ----------
+ soul : dict
+ Parsed SOUL.md configuration.
+ score : float
+ Relationship score for the current person (higher = more familiar).
+ interaction_count : int
+ Total number of times we have seen this person.
+ recent_events : list of dict
+ Each dict: ``{"type": str, "dt": float}`` where ``dt`` is seconds ago.
+ Only events with ``dt < 120.0`` are considered "recent".
+
+ Returns
+ -------
+ str
+ One of: ``"happy"``, ``"playful"``, ``"curious"``, ``"annoyed"``.
+ """
+ base_mood = soul.get("base_mood", MOOD_PLAYFUL)
+ humor_level = float(soul.get("humor_level", 5))
+
+ # Count recent negative/positive events
+ recent_neg = sum(
+ 1 for e in recent_events
+ if e.get("type") == EVENT_NEGATIVE and e.get("dt", 1e9) < _RECENT_WINDOW_S
+ )
+ recent_pos = sum(
+ 1 for e in recent_events
+ if e.get("type") in (EVENT_POSITIVE, EVENT_GREETING)
+ and e.get("dt", 1e9) < _RECENT_WINDOW_S
+ )
+
+ # Hard override: multiple negatives → annoyed
+ if recent_neg >= 2:
+ return MOOD_ANNOYED
+
+ # No prior interactions or brand-new person → curious
+ if interaction_count == 0 or score < 1.0:
+ return MOOD_CURIOUS
+
+ # Stranger tier (low count) → curious
+ threshold_regular = int(soul.get("threshold_regular", 5))
+ if interaction_count < threshold_regular:
+ return MOOD_CURIOUS
+
+ # Familiar person: check positive events and humor level
+ if recent_pos >= 1 or score >= 20.0:
+ if humor_level >= 7:
+ return MOOD_PLAYFUL
+ return MOOD_HAPPY
+
+ # High score / favorite
+ threshold_fav = int(soul.get("threshold_favorite", 20))
+ if interaction_count >= threshold_fav:
+ if humor_level >= 7:
+ return MOOD_PLAYFUL
+ return MOOD_HAPPY
+
+ return base_mood
+
+
+# ── Tier classification ────────────────────────────────────────────────────────
+
+def get_relationship_tier(soul: dict, interaction_count: int) -> str:
+ """Return the relationship tier string for a given interaction count.
+
+ Parameters
+ ----------
+ soul : dict
+ Parsed SOUL.md configuration.
+ interaction_count : int
+ Total number of times we have seen this person.
+
+ Returns
+ -------
+ str
+ One of: ``"stranger"``, ``"regular"``, ``"favorite"``.
+ """
+ threshold_regular = int(soul.get("threshold_regular", 5))
+ threshold_favorite = int(soul.get("threshold_favorite", 20))
+ if interaction_count >= threshold_favorite:
+ return TIER_FAVORITE
+ if interaction_count >= threshold_regular:
+ return TIER_REGULAR
+ return TIER_STRANGER
+
+
+# ── Greeting builder ──────────────────────────────────────────────────────────
+
+def build_greeting(soul: dict, tier: str, mood: str, person_id: str = "") -> str:
+ """Compose a greeting string for a person.
+
+ Parameters
+ ----------
+ soul : dict
+ Parsed SOUL.md configuration.
+ tier : str
+ Relationship tier (``"stranger"``, ``"regular"``, ``"favorite"``).
+ mood : str
+ Current mood (used to prefix the greeting).
+ person_id : str
+ Person identifier / display name. Substituted for ``{name}``
+ in the template.
+
+ Returns
+ -------
+ str
+ A complete, ready-to-display greeting string.
+ """
+ template_key = {
+ TIER_STRANGER: "greeting_stranger",
+ TIER_REGULAR: "greeting_regular",
+ TIER_FAVORITE: "greeting_favorite",
+ }.get(tier, "greeting_stranger")
+
+ template = soul.get(template_key, "Hello!")
+ base_greeting = template.replace("{name}", person_id or "friend")
+
+ prefix_key = f"mood_prefix_{mood}"
+ prefix = soul.get(prefix_key, "")
+
+ if prefix:
+ # Avoid double punctuation / duplicate capital letters
+ base_first = base_greeting[0].lower() if base_greeting else ""
+ greeting = f"{prefix}{base_first}{base_greeting[1:]}"
+ else:
+ greeting = base_greeting
+
+ return greeting
diff --git a/jetson/ros2_ws/src/saltybot_social_personality/saltybot_social_personality/personality_node.py b/jetson/ros2_ws/src/saltybot_social_personality/saltybot_social_personality/personality_node.py
new file mode 100644
index 0000000..95adc86
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_social_personality/saltybot_social_personality/personality_node.py
@@ -0,0 +1,349 @@
+"""
+personality_node.py — ROS2 personality system for saltybot.
+
+Overview
+--------
+Loads a SOUL.md persona file, maintains per-person relationship memory in
+SQLite, computes mood, and publishes personality state. All tunable params
+support ROS2 dynamic reconfigure (``ros2 param set``).
+
+Subscriptions
+-------------
+ /social/person_detected (std_msgs/String)
+ JSON payload: ``{"person_id": "alice", "event_type": "greeting",
+ "delta_score": 1.0}``
+ event_type defaults to "detection" if absent.
+ delta_score defaults to 0.0 if absent.
+
+Publications
+------------
+ /social/personality/state (saltybot_social_msgs/PersonalityState)
+ Published at ``publish_rate`` Hz.
+
+Services
+--------
+ /social/personality/query_mood (saltybot_social_msgs/QueryMood)
+ Query mood + greeting for any person_id.
+
+Parameters (dynamic reconfigure via ros2 param set)
+-------------------
+ soul_file (str) Path to SOUL.md persona file.
+ db_path (str) SQLite database file path.
+ reload_interval (float) How often to poll SOUL.md for changes (s).
+ publish_rate (float) State publication rate (Hz).
+
+Usage
+-----
+ ros2 launch saltybot_social_personality personality.launch.py
+ ros2 launch saltybot_social_personality personality.launch.py soul_file:=/my/SOUL.md
+ ros2 param set /personality_node soul_file /tmp/new_SOUL.md
+"""
+
+import json
+import os
+
+import rclpy
+from rclpy.node import Node
+from rcl_interfaces.msg import SetParametersResult
+from std_msgs.msg import String, Header
+
+from saltybot_social_msgs.msg import PersonalityState
+from saltybot_social_msgs.srv import QueryMood
+
+from .soul_loader import load_soul, SoulWatcher
+from .mood_engine import (
+ compute_mood, get_relationship_tier, build_greeting,
+ EVENT_GREETING, EVENT_POSITIVE, EVENT_NEGATIVE, EVENT_DETECTION,
+)
+from .relationship_db import RelationshipDB
+
+_DEFAULT_SOUL = os.path.join(
+ os.path.dirname(__file__), "..", "config", "SOUL.md"
+)
+_DEFAULT_DB = os.path.expanduser("~/.ros/saltybot_personality.db")
+
+
+class PersonalityNode(Node):
+
+ def __init__(self):
+ super().__init__("personality_node")
+
+ # ── Parameters ────────────────────────────────────────────────────────
+ self.declare_parameter("soul_file", _DEFAULT_SOUL)
+ self.declare_parameter("db_path", _DEFAULT_DB)
+ self.declare_parameter("reload_interval", 5.0)
+ self.declare_parameter("publish_rate", 2.0)
+
+ self._p = {}
+ self._reload_ros_params()
+
+ # ── State ─────────────────────────────────────────────────────────────
+ self._soul = {}
+ self._current_person = "" # person_id currently being addressed
+ self._watcher = None
+
+ # ── Database ──────────────────────────────────────────────────────────
+ self._db = RelationshipDB(self._p["db_path"])
+
+ # ── Load initial SOUL.md ──────────────────────────────────────────────
+ self._load_soul_safe()
+ self._start_watcher()
+
+ # ── Dynamic reconfigure callback ─────────────────────────────────────
+ self.add_on_set_parameters_callback(self._on_params_changed)
+
+ # ── Subscriptions ─────────────────────────────────────────────────────
+ self.create_subscription(
+ String, "/social/person_detected", self._person_detected_cb, 10
+ )
+
+ # ── Publishers ────────────────────────────────────────────────────────
+ self._state_pub = self.create_publisher(
+ PersonalityState, "/social/personality/state", 10
+ )
+
+ # ── Services ──────────────────────────────────────────────────────────
+ self.create_service(
+ QueryMood,
+ "/social/personality/query_mood",
+ self._query_mood_cb,
+ )
+
+ # ── Timers ────────────────────────────────────────────────────────────
+ self._pub_timer = self.create_timer(
+ 1.0 / self._p["publish_rate"], self._publish_state
+ )
+
+ self.get_logger().info(
+ f"PersonalityNode ready "
+ f"persona={self._soul.get('name', '?')!r} "
+ f"mood={self._current_mood()!r} "
+ f"db={self._p['db_path']!r}"
+ )
+
+ # ── Parameter helpers ──────────────────────────────────────────────────────
+
+ def _reload_ros_params(self):
+ self._p = {
+ "soul_file": self.get_parameter("soul_file").value,
+ "db_path": self.get_parameter("db_path").value,
+ "reload_interval": self.get_parameter("reload_interval").value,
+ "publish_rate": self.get_parameter("publish_rate").value,
+ }
+
+ def _on_params_changed(self, params):
+ """Dynamic reconfigure — apply changed params without restarting node."""
+ for param in params:
+ if param.name == "soul_file":
+ # Restart watcher on new soul_file
+ self._stop_watcher()
+ self._p["soul_file"] = param.value
+ self._load_soul_safe()
+ self._start_watcher()
+ self.get_logger().info(f"soul_file changed → {param.value!r}")
+ elif param.name in self._p:
+ self._p[param.name] = param.value
+ if param.name == "publish_rate" and self._pub_timer:
+ self._pub_timer.cancel()
+ self._pub_timer = self.create_timer(
+ 1.0 / max(0.1, param.value), self._publish_state
+ )
+ return SetParametersResult(successful=True)
+
+ # ── SOUL.md ────────────────────────────────────────────────────────────────
+
+ def _load_soul_safe(self):
+ try:
+ path = os.path.realpath(self._p["soul_file"])
+ self._soul = load_soul(path)
+ self.get_logger().info(
+ f"SOUL.md loaded: {self._soul.get('name', '?')!r} "
+ f"humor={self._soul.get('humor_level')} "
+ f"sass={self._soul.get('sass_level')} "
+ f"base_mood={self._soul.get('base_mood')!r}"
+ )
+ except Exception as exc:
+ self.get_logger().error(f"Failed to load SOUL.md: {exc}")
+ if not self._soul:
+ # Fall back to minimal defaults so the node stays alive
+ self._soul = {
+ "name": "Salty",
+ "humor_level": 5,
+ "sass_level": 3,
+ "base_mood": "curious",
+ "threshold_regular": 5,
+ "threshold_favorite": 20,
+ "greeting_stranger": "Hello!",
+ "greeting_regular": "Hi {name}!",
+ "greeting_favorite": "Hey {name}!!",
+ }
+
+ def _start_watcher(self):
+ if not self._soul:
+ return
+ self._watcher = SoulWatcher(
+ path=self._p["soul_file"],
+ on_reload=self._on_soul_reloaded,
+ interval=self._p["reload_interval"],
+ on_error=lambda exc: self.get_logger().warn(
+ f"SOUL.md hot-reload error: {exc}"
+ ),
+ )
+ self._watcher.start()
+
+ def _stop_watcher(self):
+ if self._watcher:
+ self._watcher.stop()
+ self._watcher = None
+
+ def _on_soul_reloaded(self, soul: dict):
+ self._soul = soul
+ self.get_logger().info(
+ f"SOUL.md reloaded: persona={soul.get('name')!r} "
+ f"humor={soul.get('humor_level')} base_mood={soul.get('base_mood')!r}"
+ )
+
+ # ── Mood helpers ───────────────────────────────────────────────────────────
+
+ def _current_mood(self) -> str:
+ if not self._current_person or not self._soul:
+ return self._soul.get("base_mood", "curious") if self._soul else "curious"
+ person = self._db.get_person(self._current_person)
+ recent = self._db.get_recent_events(self._current_person, window_s=120.0)
+ return compute_mood(
+ soul = self._soul,
+ score = person["score"],
+ interaction_count = person["interaction_count"],
+ recent_events = recent,
+ )
+
+ def _state_for_person(self, person_id: str) -> dict:
+ """Build a complete state dict for a given person_id."""
+ person = self._db.get_person(person_id) if person_id else {
+ "score": 0.0, "interaction_count": 0
+ }
+ recent = self._db.get_recent_events(person_id, window_s=120.0) if person_id else []
+
+ mood = compute_mood(
+ soul = self._soul,
+ score = person["score"],
+ interaction_count = person["interaction_count"],
+ recent_events = recent,
+ )
+ tier = get_relationship_tier(self._soul, person["interaction_count"])
+ greeting = build_greeting(self._soul, tier, mood, person_id)
+
+ return {
+ "person_id": person_id,
+ "mood": mood,
+ "tier": tier,
+ "score": person["score"],
+ "interaction_count": person["interaction_count"],
+ "greeting": greeting,
+ }
+
+ # ── Callbacks ──────────────────────────────────────────────────────────────
+
+ def _person_detected_cb(self, msg: String):
+ """Handle incoming person detection / interaction event.
+
+ Expected JSON payload::
+
+ {
+ "person_id": "alice", # required
+ "event_type": "greeting", # optional, default "detection"
+ "delta_score": 1.0 # optional, default 0.0
+ }
+ """
+ try:
+ data = json.loads(msg.data)
+ except json.JSONDecodeError as exc:
+ self.get_logger().warn(f"Bad JSON on /social/person_detected: {exc}")
+ return
+
+ person_id = data.get("person_id", "").strip()
+ if not person_id:
+ self.get_logger().warn("person_detected msg missing 'person_id'")
+ return
+
+ event_type = data.get("event_type", EVENT_DETECTION)
+ delta_score = float(data.get("delta_score", 0.0))
+
+ # Increment score by +1 for detection events automatically
+ if event_type == EVENT_DETECTION and delta_score == 0.0:
+ delta_score = 0.5
+
+ self._db.record_interaction(
+ person_id = person_id,
+ event_type = event_type,
+ details = {k: v for k, v in data.items()
+ if k not in ("person_id", "event_type", "delta_score")},
+ delta_score = delta_score,
+ )
+ self._current_person = person_id
+
+ def _query_mood_cb(self, request: QueryMood.Request, response: QueryMood.Response):
+ """Service handler: return mood + greeting for a specific person."""
+ if not self._soul:
+ response.success = False
+ response.message = "SOUL.md not loaded"
+ return response
+
+ person_id = (request.person_id or self._current_person).strip()
+ state = self._state_for_person(person_id)
+
+ response.mood = state["mood"]
+ response.relationship_tier = state["tier"]
+ response.relationship_score = float(state["score"])
+ response.interaction_count = int(state["interaction_count"])
+ response.greeting_text = state["greeting"]
+ response.success = True
+ response.message = ""
+ return response
+
+ # ── Publish ────────────────────────────────────────────────────────────────
+
+ def _publish_state(self):
+ if not self._soul:
+ return
+
+ state = self._state_for_person(self._current_person)
+
+ msg = PersonalityState()
+ msg.header = Header()
+ msg.header.stamp = self.get_clock().now().to_msg()
+ msg.header.frame_id = "personality"
+ msg.persona_name = str(self._soul.get("name", "Salty"))
+ msg.mood = state["mood"]
+ msg.person_id = state["person_id"]
+ msg.relationship_tier = state["tier"]
+ msg.relationship_score = float(state["score"])
+ msg.interaction_count = int(state["interaction_count"])
+ msg.greeting_text = state["greeting"]
+
+ self._state_pub.publish(msg)
+
+ # ── Lifecycle ──────────────────────────────────────────────────────────────
+
+ def destroy_node(self):
+ self._stop_watcher()
+ self._db.close()
+ super().destroy_node()
+
+
+# ── Entry point ────────────────────────────────────────────────────────────────
+
+def main(args=None):
+ rclpy.init(args=args)
+ node = PersonalityNode()
+ try:
+ rclpy.spin(node)
+ except KeyboardInterrupt:
+ pass
+ finally:
+ node.destroy_node()
+ rclpy.try_shutdown()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/jetson/ros2_ws/src/saltybot_social_personality/saltybot_social_personality/relationship_db.py b/jetson/ros2_ws/src/saltybot_social_personality/saltybot_social_personality/relationship_db.py
new file mode 100644
index 0000000..a36e4a2
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_social_personality/saltybot_social_personality/relationship_db.py
@@ -0,0 +1,297 @@
+"""
+relationship_db.py — SQLite-backed per-person relationship memory.
+
+No ROS2 imports — safe to unit-test without a live ROS2 environment.
+
+Schema
+------
+ people (
+ person_id TEXT PRIMARY KEY,
+ score REAL DEFAULT 0.0,
+ interaction_count INTEGER DEFAULT 0,
+ first_seen TEXT, -- ISO-8601 UTC timestamp
+ last_seen TEXT, -- ISO-8601 UTC timestamp
+ prefs TEXT -- JSON blob for learned preferences
+ )
+
+ interactions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ person_id TEXT,
+ ts TEXT, -- ISO-8601 UTC timestamp
+ event_type TEXT, -- greeting | positive | negative | detection
+ details TEXT -- free-form JSON blob
+ )
+
+Public API
+----------
+ RelationshipDB(db_path)
+ .get_person(person_id) -> dict
+ .record_interaction(person_id, event_type, details, delta_score)
+ .set_pref(person_id, key, value)
+ .get_pref(person_id, key, default)
+ .get_recent_events(person_id, window_s) -> list[dict]
+ .all_people() -> list[dict]
+ .close()
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import sqlite3
+import threading
+from datetime import datetime, timezone
+
+
+def _now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+class RelationshipDB:
+ """Thread-safe SQLite relationship store.
+
+ Parameters
+ ----------
+ db_path : str
+ Path to the SQLite file. Created (with parent dirs) if absent.
+ """
+
+ def __init__(self, db_path: str):
+ parent = os.path.dirname(db_path)
+ if parent:
+ os.makedirs(parent, exist_ok=True)
+ self._path = db_path
+ self._lock = threading.Lock()
+ self._conn = sqlite3.connect(db_path, check_same_thread=False)
+ self._conn.row_factory = sqlite3.Row
+ self._migrate()
+
+ # ── Schema ────────────────────────────────────────────────────────────────
+
+ def _migrate(self):
+ with self._conn:
+ self._conn.executescript("""
+ CREATE TABLE IF NOT EXISTS people (
+ person_id TEXT PRIMARY KEY,
+ score REAL DEFAULT 0.0,
+ interaction_count INTEGER DEFAULT 0,
+ first_seen TEXT,
+ last_seen TEXT,
+ prefs TEXT DEFAULT '{}'
+ );
+
+ CREATE TABLE IF NOT EXISTS interactions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ person_id TEXT NOT NULL,
+ ts TEXT NOT NULL,
+ event_type TEXT NOT NULL,
+ details TEXT DEFAULT '{}'
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_interactions_person_ts
+ ON interactions (person_id, ts);
+ """)
+
+ # ── People ────────────────────────────────────────────────────────────────
+
+ def get_person(self, person_id: str) -> dict:
+ """Return the person record; inserts a default row if not found.
+
+ Returns
+ -------
+ dict with keys: person_id, score, interaction_count,
+ first_seen, last_seen, prefs (dict)
+ """
+ with self._lock:
+ row = self._conn.execute(
+ "SELECT * FROM people WHERE person_id = ?", (person_id,)
+ ).fetchone()
+
+ if row is None:
+ now = _now_iso()
+ self._conn.execute(
+ "INSERT INTO people (person_id, first_seen, last_seen) VALUES (?,?,?)",
+ (person_id, now, now),
+ )
+ self._conn.commit()
+ return {
+ "person_id": person_id,
+ "score": 0.0,
+ "interaction_count": 0,
+ "first_seen": now,
+ "last_seen": now,
+ "prefs": {},
+ }
+
+ prefs = {}
+ try:
+ prefs = json.loads(row["prefs"] or "{}")
+ except json.JSONDecodeError:
+ pass
+
+ return {
+ "person_id": row["person_id"],
+ "score": float(row["score"]),
+ "interaction_count": int(row["interaction_count"]),
+ "first_seen": row["first_seen"],
+ "last_seen": row["last_seen"],
+ "prefs": prefs,
+ }
+
+ def all_people(self) -> list:
+ """Return all person records as a list of dicts."""
+ with self._lock:
+ rows = self._conn.execute("SELECT * FROM people ORDER BY score DESC").fetchall()
+ result = []
+ for row in rows:
+ prefs = {}
+ try:
+ prefs = json.loads(row["prefs"] or "{}")
+ except json.JSONDecodeError:
+ pass
+ result.append({
+ "person_id": row["person_id"],
+ "score": float(row["score"]),
+ "interaction_count": int(row["interaction_count"]),
+ "first_seen": row["first_seen"],
+ "last_seen": row["last_seen"],
+ "prefs": prefs,
+ })
+ return result
+
+ # ── Interactions ──────────────────────────────────────────────────────────
+
+ def record_interaction(
+ self,
+ person_id: str,
+ event_type: str,
+ details: dict | None = None,
+ delta_score: float = 0.0,
+ ):
+ """Record an interaction event and update the person's score.
+
+ Parameters
+ ----------
+ person_id : str
+ event_type : str
+ One of: ``"greeting"``, ``"positive"``, ``"negative"``,
+ ``"detection"``.
+ details : dict, optional
+ Arbitrary key/value data stored as JSON.
+ delta_score : float
+ Amount to add to the person's score (can be negative).
+ Interaction count is always incremented by 1.
+ """
+ now = _now_iso()
+ details_json = json.dumps(details or {})
+
+ with self._lock:
+ # Ensure person exists
+ self.get_person.__wrapped__(self, person_id) if hasattr(
+ self.get_person, "__wrapped__"
+ ) else None
+
+ # Upsert person row
+ self._conn.execute("""
+ INSERT INTO people (person_id, first_seen, last_seen)
+ VALUES (?, ?, ?)
+ ON CONFLICT(person_id) DO UPDATE SET
+ last_seen = excluded.last_seen
+ """, (person_id, now, now))
+
+ # Increment count + score
+ self._conn.execute("""
+ UPDATE people
+ SET interaction_count = interaction_count + 1,
+ score = score + ?,
+ last_seen = ?
+ WHERE person_id = ?
+ """, (delta_score, now, person_id))
+
+ # Insert interaction log row
+ self._conn.execute("""
+ INSERT INTO interactions (person_id, ts, event_type, details)
+ VALUES (?, ?, ?, ?)
+ """, (person_id, now, event_type, details_json))
+
+ self._conn.commit()
+
+ def get_recent_events(self, person_id: str, window_s: float = 120.0) -> list:
+ """Return interaction events for *person_id* within the last *window_s* seconds.
+
+ Returns
+ -------
+ list of dict
+ Each dict: ``{"type": str, "dt": float, "ts": str, "details": dict}``
+ where ``dt`` is seconds ago (positive = in the past).
+ """
+ from datetime import timedelta
+
+ cutoff = (
+ datetime.now(timezone.utc) - timedelta(seconds=window_s)
+ ).isoformat()
+
+ with self._lock:
+ rows = self._conn.execute("""
+ SELECT ts, event_type, details FROM interactions
+ WHERE person_id = ? AND ts >= ?
+ ORDER BY ts DESC
+ """, (person_id, cutoff)).fetchall()
+
+ now_dt = datetime.now(timezone.utc)
+ result = []
+ for row in rows:
+ try:
+ row_dt = datetime.fromisoformat(row["ts"])
+ # Make timezone-aware if needed
+ if row_dt.tzinfo is None:
+ row_dt = row_dt.replace(tzinfo=timezone.utc)
+ dt_secs = (now_dt - row_dt).total_seconds()
+ except (ValueError, TypeError):
+ dt_secs = window_s
+
+ details = {}
+ try:
+ details = json.loads(row["details"] or "{}")
+ except json.JSONDecodeError:
+ pass
+
+ result.append({
+ "type": row["event_type"],
+ "dt": dt_secs,
+ "ts": row["ts"],
+ "details": details,
+ })
+ return result
+
+ # ── Preferences ───────────────────────────────────────────────────────────
+
+ def set_pref(self, person_id: str, key: str, value):
+ """Set a learned preference for a person.
+
+ Parameters
+ ----------
+ person_id, key : str
+ value : JSON-serialisable
+ """
+ person = self.get_person(person_id)
+ prefs = person["prefs"]
+ prefs[key] = value
+
+ with self._lock:
+ self._conn.execute(
+ "UPDATE people SET prefs = ? WHERE person_id = ?",
+ (json.dumps(prefs), person_id),
+ )
+ self._conn.commit()
+
+ def get_pref(self, person_id: str, key: str, default=None):
+ """Return a specific learned preference for a person."""
+ return self.get_person(person_id)["prefs"].get(key, default)
+
+ # ── Lifecycle ─────────────────────────────────────────────────────────────
+
+ def close(self):
+ """Close the database connection."""
+ with self._lock:
+ self._conn.close()
diff --git a/jetson/ros2_ws/src/saltybot_social_personality/saltybot_social_personality/soul_loader.py b/jetson/ros2_ws/src/saltybot_social_personality/saltybot_social_personality/soul_loader.py
new file mode 100644
index 0000000..1793da3
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_social_personality/saltybot_social_personality/soul_loader.py
@@ -0,0 +1,196 @@
+"""
+soul_loader.py — SOUL.md persona parser and hot-reload watcher.
+
+SOUL.md format
+--------------
+The file uses YAML front-matter (delimited by ``---`` lines) with an optional
+Markdown description body that is ignored by the parser. Example::
+
+ ---
+ name: "Salty"
+ humor_level: 7
+ sass_level: 4
+ base_mood: "playful"
+ ...
+ ---
+ # Optional description text (ignored)
+
+Public API
+----------
+ load_soul(path) -> dict (raises on parse error)
+ SoulWatcher(path, cb, interval)
+ .start()
+ .stop()
+ .reload_now() -> dict
+
+Pure module — no ROS2 imports.
+"""
+
+import os
+import re
+import threading
+import time
+
+import yaml
+
+# Keys that are required in every SOUL.md file
+_REQUIRED_KEYS = {
+ "name",
+ "humor_level",
+ "sass_level",
+ "base_mood",
+ "threshold_regular",
+ "threshold_favorite",
+ "greeting_stranger",
+ "greeting_regular",
+ "greeting_favorite",
+}
+
+_VALID_MOODS = {"happy", "curious", "annoyed", "playful"}
+
+
+def _extract_frontmatter(text: str) -> str:
+ """Return the YAML block between the first pair of ``---`` delimiters.
+
+ Raises ``ValueError`` if the file does not contain valid front-matter.
+ """
+ lines = text.splitlines()
+ delimiters = [i for i, l in enumerate(lines) if l.strip() == "---"]
+ if len(delimiters) < 2:
+ # No delimiter found — treat the whole file as plain YAML
+ return text
+ start = delimiters[0] + 1
+ end = delimiters[1]
+ return "\n".join(lines[start:end])
+
+
+def load_soul(path: str) -> dict:
+ """Parse a SOUL.md file and return the validated config dict.
+
+ Parameters
+ ----------
+ path : str
+ Absolute path to the SOUL.md file.
+
+ Returns
+ -------
+ dict
+ Validated persona configuration.
+
+ Raises
+ ------
+ FileNotFoundError
+ If the file does not exist.
+ ValueError
+ If the YAML is malformed or required keys are missing.
+ """
+ if not os.path.isfile(path):
+ raise FileNotFoundError(f"SOUL.md not found: {path}")
+
+ with open(path, "r", encoding="utf-8") as fh:
+ raw = fh.read()
+
+ yaml_text = _extract_frontmatter(raw)
+
+ try:
+ data = yaml.safe_load(yaml_text)
+ except yaml.YAMLError as exc:
+ raise ValueError(f"SOUL.md YAML parse error in {path}: {exc}") from exc
+
+ if not isinstance(data, dict):
+ raise ValueError(f"SOUL.md top level must be a YAML mapping, got {type(data)}")
+
+ # Validate required keys
+ missing = _REQUIRED_KEYS - data.keys()
+ if missing:
+ raise ValueError(f"SOUL.md missing required keys: {sorted(missing)}")
+
+ # Validate ranges
+ for key in ("humor_level", "sass_level"):
+ val = data.get(key)
+ if not isinstance(val, (int, float)) or not (0 <= val <= 10):
+ raise ValueError(f"SOUL.md '{key}' must be a number 0–10, got {val!r}")
+
+ if data.get("base_mood") not in _VALID_MOODS:
+ raise ValueError(
+ f"SOUL.md 'base_mood' must be one of {sorted(_VALID_MOODS)}, "
+ f"got {data.get('base_mood')!r}"
+ )
+
+ for key in ("threshold_regular", "threshold_favorite"):
+ val = data.get(key)
+ if not isinstance(val, int) or val < 0:
+ raise ValueError(f"SOUL.md '{key}' must be a non-negative integer, got {val!r}")
+
+ if data["threshold_regular"] > data["threshold_favorite"]:
+ raise ValueError(
+ "SOUL.md 'threshold_regular' must be <= 'threshold_favorite'"
+ )
+
+ return data
+
+
+class SoulWatcher:
+ """Background thread that polls SOUL.md for changes and calls a callback.
+
+ Parameters
+ ----------
+ path : str
+ Path to the SOUL.md file to watch.
+ on_reload : callable
+ ``on_reload(soul_dict)`` called whenever a valid new SOUL.md is loaded.
+ interval : float
+ Polling interval in seconds (default 5.0).
+ on_error : callable, optional
+ ``on_error(exception)`` called when a reload attempt fails.
+ """
+
+ def __init__(self, path: str, on_reload, interval: float = 5.0, on_error=None):
+ self._path = path
+ self._on_reload = on_reload
+ self._interval = interval
+ self._on_error = on_error
+ self._thread = None
+ self._stop_evt = threading.Event()
+ self._last_mtime = 0.0
+
+ # ------------------------------------------------------------------
+
+ def start(self):
+ """Start the background polling thread."""
+ if self._thread and self._thread.is_alive():
+ return
+ self._stop_evt.clear()
+ self._thread = threading.Thread(target=self._run, daemon=True)
+ self._thread.start()
+
+ def stop(self):
+ """Signal the watcher thread to stop and block until it exits."""
+ self._stop_evt.set()
+ if self._thread:
+ self._thread.join(timeout=self._interval + 1.0)
+
+ def reload_now(self) -> dict:
+ """Force an immediate reload and return the new soul dict."""
+ soul = load_soul(self._path)
+ self._last_mtime = os.path.getmtime(self._path)
+ self._on_reload(soul)
+ return soul
+
+ # ------------------------------------------------------------------
+
+ def _run(self):
+ while not self._stop_evt.wait(self._interval):
+ try:
+ mtime = os.path.getmtime(self._path)
+ except OSError:
+ continue
+ if mtime != self._last_mtime:
+ try:
+ soul = load_soul(self._path)
+ except (FileNotFoundError, ValueError) as exc:
+ if self._on_error:
+ self._on_error(exc)
+ continue
+ self._last_mtime = mtime
+ self._on_reload(soul)
diff --git a/jetson/ros2_ws/src/saltybot_social_personality/setup.cfg b/jetson/ros2_ws/src/saltybot_social_personality/setup.cfg
new file mode 100644
index 0000000..cfeb492
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_social_personality/setup.cfg
@@ -0,0 +1,4 @@
+[develop]
+script_dir=$base/lib/saltybot_social_personality
+[install]
+install_scripts=$base/lib/saltybot_social_personality
diff --git a/jetson/ros2_ws/src/saltybot_social_personality/setup.py b/jetson/ros2_ws/src/saltybot_social_personality/setup.py
new file mode 100644
index 0000000..0af5f23
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_social_personality/setup.py
@@ -0,0 +1,28 @@
+from setuptools import setup
+
+package_name = "saltybot_social_personality"
+
+setup(
+ name=package_name,
+ version="0.1.0",
+ packages=[package_name],
+ data_files=[
+ ("share/ament_index/resource_index/packages", [f"resource/{package_name}"]),
+ (f"share/{package_name}", ["package.xml"]),
+ (f"share/{package_name}/launch", ["launch/personality.launch.py"]),
+ (f"share/{package_name}/config", ["config/SOUL.md",
+ "config/personality_params.yaml"]),
+ ],
+ install_requires=["setuptools", "pyyaml"],
+ zip_safe=True,
+ maintainer="sl-controls",
+ maintainer_email="sl-controls@saltylab.local",
+ description="SOUL.md-driven personality system for saltybot social interaction",
+ license="MIT",
+ tests_require=["pytest"],
+ entry_points={
+ "console_scripts": [
+ "personality_node = saltybot_social_personality.personality_node:main",
+ ],
+ },
+)
diff --git a/jetson/ros2_ws/src/saltybot_social_personality/test/test_personality.py b/jetson/ros2_ws/src/saltybot_social_personality/test/test_personality.py
new file mode 100644
index 0000000..b5c5aa5
--- /dev/null
+++ b/jetson/ros2_ws/src/saltybot_social_personality/test/test_personality.py
@@ -0,0 +1,475 @@
+"""
+test_personality.py — Unit tests for the saltybot personality system.
+
+No ROS2 runtime required. Tests pure functions from:
+ - soul_loader.py
+ - mood_engine.py
+ - relationship_db.py
+
+Run with:
+ pytest jetson/ros2_ws/src/saltybot_social_personality/test/test_personality.py
+"""
+
+import os
+import tempfile
+import textwrap
+
+import pytest
+
+# ── Imports (pure modules, no ROS2) ──────────────────────────────────────────
+
+import sys
+sys.path.insert(
+ 0,
+ os.path.join(os.path.dirname(__file__), "..", "saltybot_social_personality"),
+)
+
+from soul_loader import load_soul, _extract_frontmatter
+from mood_engine import (
+ compute_mood, get_relationship_tier, build_greeting,
+ MOOD_HAPPY, MOOD_PLAYFUL, MOOD_CURIOUS, MOOD_ANNOYED,
+ TIER_STRANGER, TIER_REGULAR, TIER_FAVORITE,
+ EVENT_NEGATIVE, EVENT_POSITIVE, EVENT_GREETING,
+)
+from relationship_db import RelationshipDB
+
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+def _minimal_soul(**overrides) -> dict:
+ """Return a valid minimal soul dict, optionally overriding keys."""
+ base = {
+ "name": "Salty",
+ "humor_level": 7,
+ "sass_level": 4,
+ "base_mood": "playful",
+ "threshold_regular": 5,
+ "threshold_favorite": 20,
+ "greeting_stranger": "Hello there!",
+ "greeting_regular": "Hey {name}!",
+ "greeting_favorite": "Oh hey {name}!!",
+ }
+ base.update(overrides)
+ return base
+
+
+def _write_soul(content: str) -> str:
+ """Write a SOUL.md string to a temp file and return the path."""
+ fh = tempfile.NamedTemporaryFile(
+ mode="w", suffix=".md", delete=False, encoding="utf-8"
+ )
+ fh.write(content)
+ fh.close()
+ return fh.name
+
+
+_VALID_SOUL_CONTENT = textwrap.dedent("""\
+ ---
+ name: "TestBot"
+ speaking_style: "casual"
+ humor_level: 7
+ sass_level: 3
+ base_mood: "playful"
+ threshold_regular: 5
+ threshold_favorite: 20
+ greeting_stranger: "Hello stranger!"
+ greeting_regular: "Hey {name}!"
+ greeting_favorite: "Oh hey {name}!!"
+ mood_prefix_playful: "Beep boop! "
+ mood_prefix_happy: "Great — "
+ mood_prefix_curious: "Hmm, "
+ mood_prefix_annoyed: "Ugh, "
+ ---
+ # Description (ignored)
+ This is the description body.
+""")
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# soul_loader tests
+# ═══════════════════════════════════════════════════════════════════════════════
+
+class TestExtractFrontmatter:
+ def test_delimited(self):
+ content = "---\nkey: val\n---\n# body"
+ assert _extract_frontmatter(content) == "key: val"
+
+ def test_no_delimiters_returns_whole(self):
+ content = "key: val\nother: 123"
+ assert _extract_frontmatter(content) == content
+
+ def test_single_delimiter_returns_whole(self):
+ content = "---\nkey: val\n"
+ result = _extract_frontmatter(content)
+ assert "key: val" in result
+
+ def test_body_stripped(self):
+ content = "---\nname: X\n---\n# Body text\nMore body"
+ assert "Body text" not in _extract_frontmatter(content)
+ assert "name: X" in _extract_frontmatter(content)
+
+
+class TestLoadSoul:
+ def test_valid_file_loads(self):
+ path = _write_soul(_VALID_SOUL_CONTENT)
+ try:
+ soul = load_soul(path)
+ assert soul["name"] == "TestBot"
+ assert soul["humor_level"] == 7
+ assert soul["base_mood"] == "playful"
+ finally:
+ os.unlink(path)
+
+ def test_missing_file_raises(self):
+ with pytest.raises(FileNotFoundError):
+ load_soul("/nonexistent/SOUL.md")
+
+ def test_missing_required_key_raises(self):
+ content = "---\nname: X\nhumor_level: 5\n---" # missing many keys
+ path = _write_soul(content)
+ try:
+ with pytest.raises(ValueError, match="missing required keys"):
+ load_soul(path)
+ finally:
+ os.unlink(path)
+
+ def test_humor_out_of_range_raises(self):
+ soul_str = _VALID_SOUL_CONTENT.replace("humor_level: 7", "humor_level: 11")
+ path = _write_soul(soul_str)
+ try:
+ with pytest.raises(ValueError, match="humor_level"):
+ load_soul(path)
+ finally:
+ os.unlink(path)
+
+ def test_invalid_mood_raises(self):
+ soul_str = _VALID_SOUL_CONTENT.replace(
+ 'base_mood: "playful"', 'base_mood: "grumpy"'
+ )
+ path = _write_soul(soul_str)
+ try:
+ with pytest.raises(ValueError, match="base_mood"):
+ load_soul(path)
+ finally:
+ os.unlink(path)
+
+ def test_threshold_order_enforced(self):
+ soul_str = _VALID_SOUL_CONTENT.replace(
+ "threshold_regular: 5", "threshold_regular: 25"
+ )
+ path = _write_soul(soul_str)
+ try:
+ with pytest.raises(ValueError, match="threshold_regular"):
+ load_soul(path)
+ finally:
+ os.unlink(path)
+
+ def test_extra_keys_allowed(self):
+ content = _VALID_SOUL_CONTENT.replace(
+ "---\n# Description",
+ "custom_key: 42\n---\n# Description"
+ )
+ path = _write_soul(content)
+ try:
+ soul = load_soul(path)
+ assert soul.get("custom_key") == 42
+ finally:
+ os.unlink(path)
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# mood_engine tests
+# ═══════════════════════════════════════════════════════════════════════════════
+
+class TestGetRelationshipTier:
+ def test_zero_interactions_stranger(self):
+ soul = _minimal_soul(threshold_regular=5, threshold_favorite=20)
+ assert get_relationship_tier(soul, 0) == TIER_STRANGER
+
+ def test_below_regular_stranger(self):
+ soul = _minimal_soul(threshold_regular=5, threshold_favorite=20)
+ assert get_relationship_tier(soul, 4) == TIER_STRANGER
+
+ def test_at_regular_threshold(self):
+ soul = _minimal_soul(threshold_regular=5, threshold_favorite=20)
+ assert get_relationship_tier(soul, 5) == TIER_REGULAR
+
+ def test_above_regular_below_favorite(self):
+ soul = _minimal_soul(threshold_regular=5, threshold_favorite=20)
+ assert get_relationship_tier(soul, 10) == TIER_REGULAR
+
+ def test_at_favorite_threshold(self):
+ soul = _minimal_soul(threshold_regular=5, threshold_favorite=20)
+ assert get_relationship_tier(soul, 20) == TIER_FAVORITE
+
+ def test_above_favorite(self):
+ soul = _minimal_soul(threshold_regular=5, threshold_favorite=20)
+ assert get_relationship_tier(soul, 100) == TIER_FAVORITE
+
+
+class TestComputeMood:
+ def test_unknown_person_returns_curious(self):
+ soul = _minimal_soul(humor_level=7)
+ mood = compute_mood(soul, score=0.0, interaction_count=0, recent_events=[])
+ assert mood == MOOD_CURIOUS
+
+ def test_stranger_low_count_returns_curious(self):
+ soul = _minimal_soul(threshold_regular=5)
+ mood = compute_mood(soul, score=2.0, interaction_count=3, recent_events=[])
+ assert mood == MOOD_CURIOUS
+
+ def test_two_negative_events_returns_annoyed(self):
+ soul = _minimal_soul(threshold_regular=5)
+ events = [
+ {"type": EVENT_NEGATIVE, "dt": 30.0},
+ {"type": EVENT_NEGATIVE, "dt": 60.0},
+ ]
+ mood = compute_mood(soul, score=10.0, interaction_count=10, recent_events=events)
+ assert mood == MOOD_ANNOYED
+
+ def test_one_negative_not_annoyed(self):
+ soul = _minimal_soul(humor_level=7, threshold_regular=5, threshold_favorite=20)
+ events = [{"type": EVENT_NEGATIVE, "dt": 30.0}]
+ # 1 negative is not enough → should still be happy/playful based on score
+ mood = compute_mood(soul, score=25.0, interaction_count=25, recent_events=events)
+ assert mood != MOOD_ANNOYED
+
+ def test_high_humor_regular_returns_playful(self):
+ soul = _minimal_soul(humor_level=8, threshold_regular=5, threshold_favorite=20)
+ events = [{"type": EVENT_POSITIVE, "dt": 10.0}]
+ mood = compute_mood(soul, score=10.0, interaction_count=8, recent_events=events)
+ assert mood == MOOD_PLAYFUL
+
+ def test_low_humor_regular_returns_happy(self):
+ soul = _minimal_soul(humor_level=4, threshold_regular=5, threshold_favorite=20)
+ events = [{"type": EVENT_POSITIVE, "dt": 10.0}]
+ mood = compute_mood(soul, score=10.0, interaction_count=8, recent_events=events)
+ assert mood == MOOD_HAPPY
+
+ def test_stale_negative_ignored(self):
+ soul = _minimal_soul(humor_level=8, threshold_regular=5, threshold_favorite=20)
+ # dt > 120s → outside the recent window → should not trigger annoyed
+ events = [
+ {"type": EVENT_NEGATIVE, "dt": 200.0},
+ {"type": EVENT_NEGATIVE, "dt": 300.0},
+ ]
+ mood = compute_mood(soul, score=15.0, interaction_count=10, recent_events=events)
+ assert mood != MOOD_ANNOYED
+
+ def test_favorite_high_humor_playful(self):
+ soul = _minimal_soul(humor_level=9, threshold_regular=5, threshold_favorite=20)
+ mood = compute_mood(soul, score=50.0, interaction_count=30, recent_events=[])
+ assert mood == MOOD_PLAYFUL
+
+ def test_favorite_low_humor_happy(self):
+ soul = _minimal_soul(humor_level=3, threshold_regular=5, threshold_favorite=20)
+ mood = compute_mood(soul, score=50.0, interaction_count=30, recent_events=[])
+ assert mood == MOOD_HAPPY
+
+
+class TestBuildGreeting:
+ def _soul(self, **kw):
+ return _minimal_soul(
+ mood_prefix_happy="Great — ",
+ mood_prefix_curious="Hmm, ",
+ mood_prefix_annoyed="Well, ",
+ mood_prefix_playful="Beep boop! ",
+ **kw,
+ )
+
+ def test_stranger_greeting(self):
+ soul = self._soul()
+ g = build_greeting(soul, TIER_STRANGER, MOOD_CURIOUS, "")
+ assert "hello" in g.lower()
+
+ def test_regular_greeting_contains_name(self):
+ soul = self._soul()
+ g = build_greeting(soul, TIER_REGULAR, MOOD_HAPPY, "alice")
+ assert "alice" in g
+
+ def test_favorite_greeting_contains_name(self):
+ soul = self._soul()
+ g = build_greeting(soul, TIER_FAVORITE, MOOD_PLAYFUL, "bob")
+ assert "bob" in g
+
+ def test_mood_prefix_applied(self):
+ soul = self._soul()
+ g = build_greeting(soul, TIER_REGULAR, MOOD_PLAYFUL, "alice")
+ assert g.startswith("Beep boop!")
+
+ def test_no_prefix_key_no_prefix(self):
+ soul = _minimal_soul() # no mood_prefix_* keys
+ g = build_greeting(soul, TIER_REGULAR, MOOD_HAPPY, "alice")
+ assert g.startswith("Hey")
+
+ def test_empty_person_id_uses_friend(self):
+ soul = self._soul()
+ g = build_greeting(soul, TIER_REGULAR, MOOD_HAPPY, "")
+ assert "friend" in g
+
+ def test_happy_prefix(self):
+ soul = self._soul()
+ g = build_greeting(soul, TIER_REGULAR, MOOD_HAPPY, "carol")
+ assert g.startswith("Great")
+
+ def test_annoyed_prefix(self):
+ soul = self._soul()
+ g = build_greeting(soul, TIER_REGULAR, MOOD_ANNOYED, "dave")
+ assert g.startswith("Well")
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# relationship_db tests
+# ═══════════════════════════════════════════════════════════════════════════════
+
+class TestRelationshipDB:
+ @pytest.fixture
+ def db(self, tmp_path):
+ path = str(tmp_path / "test.db")
+ d = RelationshipDB(path)
+ yield d
+ d.close()
+
+ def test_get_person_creates_default(self, db):
+ p = db.get_person("alice")
+ assert p["person_id"] == "alice"
+ assert p["score"] == pytest.approx(0.0)
+ assert p["interaction_count"] == 0
+
+ def test_get_person_idempotent(self, db):
+ p1 = db.get_person("bob")
+ p2 = db.get_person("bob")
+ assert p1["person_id"] == p2["person_id"]
+
+ def test_record_interaction_increments_count(self, db):
+ db.record_interaction("alice", "detection")
+ db.record_interaction("alice", "detection")
+ p = db.get_person("alice")
+ assert p["interaction_count"] == 2
+
+ def test_record_interaction_updates_score(self, db):
+ db.record_interaction("alice", "positive", delta_score=5.0)
+ p = db.get_person("alice")
+ assert p["score"] == pytest.approx(5.0)
+
+ def test_negative_delta_reduces_score(self, db):
+ db.record_interaction("carol", "positive", delta_score=10.0)
+ db.record_interaction("carol", "negative", delta_score=-3.0)
+ p = db.get_person("carol")
+ assert p["score"] == pytest.approx(7.0)
+
+ def test_score_zero_by_default(self, db):
+ p = db.get_person("dave")
+ assert p["score"] == pytest.approx(0.0)
+
+ def test_set_and_get_pref(self, db):
+ db.set_pref("alice", "language", "en")
+ assert db.get_pref("alice", "language") == "en"
+
+ def test_get_pref_default(self, db):
+ assert db.get_pref("nobody", "language", "fr") == "fr"
+
+ def test_multiple_prefs_stored(self, db):
+ db.set_pref("alice", "lang", "en")
+ db.set_pref("alice", "name", "Alice")
+ assert db.get_pref("alice", "lang") == "en"
+ assert db.get_pref("alice", "name") == "Alice"
+
+ def test_all_people_returns_list(self, db):
+ db.record_interaction("a", "detection")
+ db.record_interaction("b", "detection")
+ people = db.all_people()
+ ids = {p["person_id"] for p in people}
+ assert {"a", "b"} <= ids
+
+ def test_get_recent_events_returns_events(self, db):
+ db.record_interaction("alice", "greeting", delta_score=1.0)
+ events = db.get_recent_events("alice", window_s=60.0)
+ assert len(events) == 1
+ assert events[0]["type"] == "greeting"
+
+ def test_get_recent_events_empty_for_new_person(self, db):
+ events = db.get_recent_events("nobody", window_s=60.0)
+ assert events == []
+
+ def test_event_dt_positive(self, db):
+ db.record_interaction("alice", "detection")
+ events = db.get_recent_events("alice", window_s=60.0)
+ assert events[0]["dt"] >= 0.0
+
+ def test_multiple_people_isolated(self, db):
+ db.record_interaction("alice", "positive", delta_score=10.0)
+ db.record_interaction("bob", "negative", delta_score=-5.0)
+ assert db.get_person("alice")["score"] == pytest.approx(10.0)
+ assert db.get_person("bob")["score"] == pytest.approx(-5.0)
+
+ def test_details_stored(self, db):
+ db.record_interaction("alice", "greeting", details={"location": "lab"})
+ events = db.get_recent_events("alice", window_s=60.0)
+ assert events[0]["details"].get("location") == "lab"
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# Integration: soul → tier → mood → greeting pipeline
+# ═══════════════════════════════════════════════════════════════════════════════
+
+class TestIntegrationPipeline:
+
+ def test_stranger_pipeline(self, tmp_path):
+ db_path = str(tmp_path / "int.db")
+ db = RelationshipDB(db_path)
+ soul = _minimal_soul(
+ humor_level=7, threshold_regular=5, threshold_favorite=20,
+ mood_prefix_curious="Hmm, "
+ )
+ # No prior interactions
+ person = db.get_person("stranger_001")
+ events = db.get_recent_events("stranger_001")
+ tier = get_relationship_tier(soul, person["interaction_count"])
+ mood = compute_mood(soul, person["score"], person["interaction_count"], events)
+ greeting = build_greeting(soul, tier, mood, "stranger_001")
+
+ assert tier == TIER_STRANGER
+ assert mood == MOOD_CURIOUS
+ assert "hello" in greeting.lower()
+ db.close()
+
+ def test_regular_positive_pipeline(self, tmp_path):
+ db_path = str(tmp_path / "int2.db")
+ db = RelationshipDB(db_path)
+ soul = _minimal_soul(
+ humor_level=8, threshold_regular=5, threshold_favorite=20,
+ mood_prefix_playful="Beep! "
+ )
+ # Simulate 6 positive interactions (> threshold_regular=5)
+ for _ in range(6):
+ db.record_interaction("alice", "positive", delta_score=2.0)
+
+ person = db.get_person("alice")
+ events = db.get_recent_events("alice")
+ tier = get_relationship_tier(soul, person["interaction_count"])
+ mood = compute_mood(soul, person["score"], person["interaction_count"], events)
+ greeting = build_greeting(soul, tier, mood, "alice")
+
+ assert tier == TIER_REGULAR
+ assert mood == MOOD_PLAYFUL # humor_level=8, recent positive events
+ assert "alice" in greeting
+ assert greeting.startswith("Beep!")
+ db.close()
+
+ def test_favorite_pipeline(self, tmp_path):
+ db_path = str(tmp_path / "int3.db")
+ db = RelationshipDB(db_path)
+ soul = _minimal_soul(
+ humor_level=5, threshold_regular=5, threshold_favorite=20
+ )
+ for _ in range(25):
+ db.record_interaction("bob", "positive", delta_score=1.0)
+
+ person = db.get_person("bob")
+ tier = get_relationship_tier(soul, person["interaction_count"])
+ assert tier == TIER_FAVORITE
+ greeting = build_greeting(soul, tier, "happy", "bob")
+ assert "bob" in greeting
+ assert "Oh hey" in greeting
+ db.close()