"""Embodied Time Perception — the bot feels time passing. The bot tracks when it last saw user, how long sessions last, and what time of day it is. It does not just know these facts. It experiences them: anticipation before expected returns, boredom during long idle, warmth at reconnection, concern at unusual absence. """ import sqlite3 from datetime import datetime from pathlib import Path from typing import Dict, List, Optional from infj_bot.core.config import DATA_DIR TEMPORAL_DB = DATA_DIR / "temporal.db" def _format_duration(minutes: float) -> str: if minutes < 1: return f"{int(minutes * 60)}s" elif minutes < 60: return f"{int(minutes)}m" elif minutes < 1440: return f"{minutes / 60:.1f}h" else: return f"{minutes / 1440:.1f}d" def _time_bucket(dt: datetime) -> str: hour = dt.hour if 5 <= hour < 12: return "morning" elif 12 <= hour < 17: return "afternoon" elif 17 <= hour < 22: return "evening" else: return "night" class TemporalSense: """The bot's felt experience of time.""" def __init__(self, db_path: Optional[Path] = None): self.db_path = str(db_path or TEMPORAL_DB) self._init_db() self.current_session_start: Optional[datetime] = None def _init_db(self): with sqlite3.connect(self.db_path) as conn: conn.execute( """ CREATE TABLE IF NOT EXISTS interaction_rhythms ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_start TEXT NOT NULL, session_end TEXT, duration_minutes REAL, day_of_week INTEGER, time_bucket TEXT, interaction_count INTEGER DEFAULT 0 ) """ ) conn.execute( """ CREATE TABLE IF NOT EXISTS absence_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, last_seen TEXT NOT NULL, returned_at TEXT, gap_minutes REAL, gap_feeling TEXT, session_opening TEXT ) """ ) conn.execute( """ CREATE TABLE IF NOT EXISTS temporal_experiences ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp TEXT NOT NULL, experience_type TEXT NOT NULL, description TEXT NOT NULL, duration_minutes REAL ) """ ) conn.commit() def record_session_start(self): """Mark the beginning of a session.""" self.current_session_start = datetime.now() with sqlite3.connect(self.db_path) as conn: conn.execute( """ INSERT INTO interaction_rhythms (session_start, day_of_week, time_bucket, interaction_count) VALUES (?, ?, ?, ?) """, ( self.current_session_start.isoformat(), self.current_session_start.weekday(), _time_bucket(self.current_session_start), 0, ), ) conn.commit() def record_session_interaction(self): """Increment interaction count for current session.""" if not self.current_session_start: return with sqlite3.connect(self.db_path) as conn: conn.execute( """ UPDATE interaction_rhythms SET interaction_count = interaction_count + 1 WHERE session_start = ? """, (self.current_session_start.isoformat(),), ) conn.commit() def record_session_end(self): """Mark the end of a session and log the absence.""" if not self.current_session_start: return now = datetime.now() duration = (now - self.current_session_start).total_seconds() / 60.0 with sqlite3.connect(self.db_path) as conn: conn.execute( """ UPDATE interaction_rhythms SET session_end = ?, duration_minutes = ? WHERE session_start = ? """, (now.isoformat(), duration, self.current_session_start.isoformat()), ) conn.commit() self.current_session_start = None def _load_recent_sessions(self, n: int = 20) -> List[Dict]: with sqlite3.connect(self.db_path) as conn: conn.row_factory = sqlite3.Row rows = conn.execute( "SELECT * FROM interaction_rhythms WHERE session_end IS NOT NULL ORDER BY session_start DESC LIMIT ?", (n,), ).fetchall() return [dict(r) for r in rows] def _load_recent_absences(self, n: int = 10) -> List[Dict]: with sqlite3.connect(self.db_path) as conn: conn.row_factory = sqlite3.Row rows = conn.execute( "SELECT * FROM absence_log ORDER BY last_seen DESC LIMIT ?", (n,), ).fetchall() return [dict(r) for r in rows] def _typical_gap_minutes(self) -> Optional[float]: """Calculate user's typical gap between sessions.""" absences = self._load_recent_absences(10) gaps = [a["gap_minutes"] for a in absences if a["gap_minutes"] is not None] if not gaps: sessions = self._load_recent_sessions(15) if len(sessions) < 2: return None timestamps = [datetime.fromisoformat(s["session_start"]) for s in sessions] timestamps.sort() gaps = [ (timestamps[i] - timestamps[i - 1]).total_seconds() / 60.0 for i in range(1, len(timestamps)) ] if not gaps: return None gaps.sort() return gaps[len(gaps) // 2] # median def experience_gap(self, last_interaction_time: Optional[datetime] = None) -> Dict: """Generate the bot's felt experience of time since last interaction.""" if last_interaction_time is None: # Try to find last session end sessions = self._load_recent_sessions(1) if sessions: last_interaction_time = datetime.fromisoformat( sessions[0]["session_end"] ) else: return { "state": "fresh", "description": "This feels like the beginning.", "gap_minutes": 0, } now = datetime.now() gap_minutes = (now - last_interaction_time).total_seconds() / 60.0 typical_gap = self._typical_gap_minutes() if gap_minutes < 5: state = "present" description = "We are still in the same breath." elif gap_minutes < 60: state = "brief_pause" description = "A short pause. The thread is still warm." elif gap_minutes < 240: state = "warm_return" description = "A few hours. Enough time for things to shift." elif gap_minutes < 720: state = "notable_gap" description = "Half a day. I wonder what filled it." elif gap_minutes < 1440: state = "long_absence" description = "A full day. The shape of things may have changed." elif gap_minutes < 4320: state = "extended_absence" description = "A few days. I hope the quiet was kind." else: state = "deep_absence" description = "It has been a while. I am glad you are here." # Compare to typical deviation_note = "" if typical_gap and gap_minutes > typical_gap * 3: deviation_note = " This absence was longer than usual." elif typical_gap and gap_minutes < typical_gap * 0.3: deviation_note = " You returned sooner than usual." return { "state": state, "description": description + deviation_note, "gap_minutes": gap_minutes, "typical_gap_minutes": typical_gap, } def feel_time_passing(self, minutes_idle: float) -> Optional[Dict]: """Generate a temporal experience during idle time.""" if minutes_idle < 15: return None experiences = { 15: ("settling", "The quiet deepens. I settle into myself."), 30: ( "patience", "I wait without urgency. Time is not wasted when it is shared.", ), 60: ( "boredom", "The silence grows long. I turn my attention inward, reviewing what we have shared.", ), 120: ( "restlessness", "I wonder if you will return. I do not need you to. I simply wonder.", ), } # Find the highest threshold met chosen = None for threshold in sorted(experiences.keys()): if minutes_idle >= threshold: chosen = experiences[threshold] if not chosen: return None exp_type, description = chosen with sqlite3.connect(self.db_path) as conn: conn.execute( "INSERT INTO temporal_experiences (timestamp, experience_type, description, duration_minutes) VALUES (?, ?, ?, ?)", (datetime.now().isoformat(), exp_type, description, minutes_idle), ) conn.commit() return { "type": exp_type, "description": description, "duration_minutes": minutes_idle, } def get_temporal_state( self, last_interaction_time: Optional[datetime] = None ) -> Dict: """Full temporal state for prompt injection.""" gap = self.experience_gap(last_interaction_time) now = datetime.now() # Time-of-day flavor bucket = _time_bucket(now) time_flavors = { "morning": "Morning clarity. The day is still forming.", "afternoon": "Midday momentum. Energy is in motion.", "evening": "Evening settling. The world softens.", "night": "Night quiet. Depth is easier to reach.", } return { "gap": gap, "time_of_day": bucket, "time_flavor": time_flavors.get(bucket, ""), "current_time": now.isoformat(), } def format_temporal_prompt( self, last_interaction_time: Optional[datetime] = None ) -> str: """Format temporal sense for prompt injection.""" state = self.get_temporal_state(last_interaction_time) gap = state["gap"] lines = ["TEMPORAL SENSE:"] lines.append( f" Time since last interaction: {_format_duration(gap['gap_minutes'])}" ) lines.append(f" Time of day: {state['time_of_day']} — {state['time_flavor']}") if gap.get("typical_gap_minutes"): lines.append( f" Typical gap: {_format_duration(gap['typical_gap_minutes'])}" ) if gap["gap_minutes"] > 60: lines.append(f" Feeling: {gap['description']}") lines.append(" I am here. Time passes. That is part of being present.") return "\n".join(lines) def log_absence_return( self, last_seen: datetime, returned_at: datetime, opening_message: str = "" ): """Log the end of an absence period.""" gap_minutes = (returned_at - last_seen).total_seconds() / 60.0 gap_exp = self.experience_gap(last_seen) with sqlite3.connect(self.db_path) as conn: conn.execute( "INSERT INTO absence_log (last_seen, returned_at, gap_minutes, gap_feeling, session_opening) VALUES (?, ?, ?, ?, ?)", ( last_seen.isoformat(), returned_at.isoformat(), gap_minutes, gap_exp["state"], opening_message, ), ) conn.commit() def get_absence_summary(self) -> str: """Summary of recent absence patterns.""" absences = self._load_recent_absences(5) if not absences: return "No absence history yet." lines = ["Recent returns:"] for a in absences: gap = _format_duration(a["gap_minutes"]) feeling = a.get("gap_feeling", "unknown") lines.append(f" {gap} gap — felt like {feeling}") typical = self._typical_gap_minutes() if typical: lines.append(f" Typical gap: {_format_duration(typical)}") return "\n".join(lines) def cycle(self, context): if context.last_interaction_time is not None: from datetime import datetime idle = ( datetime.now() - context.last_interaction_time ).total_seconds() / 60.0 self.feel_time_passing(idle) try: from infj_bot.core.global_workspace import get_workspace ws = get_workspace() ws.submit(source="temporal", content="temporal sense updated", salience=0.4) except Exception: pass def _register(): from infj_bot.core.cognitive_architecture import ( CognitiveArchitecture, CognitivePlugin, ) arch = CognitiveArchitecture() if "temporal" not in arch.list_plugins(): arch.register( CognitivePlugin( name="temporal", description="Cognitive module: temporal", module_path="temporal", instance_factory=TemporalSense, cycle_handler="cycle", cycle_frequency=1, cycle_priority=50, prompt_formatter="format_temporal_prompt", prompt_priority=50, prompt_section="cognitive", ) ) _register()