| """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] |
|
|
| 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: |
| |
| 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." |
|
|
| |
| 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.", |
| ), |
| } |
|
|
| |
| 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() |
|
|
| |
| 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() |
|
|