From 1e17d46c78686c90fe9a2867de94c3d4975b6c25 Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 17 Jun 2026 02:31:40 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20time=20awareness=20=E2=80=94=20Lyra=20p?= =?UTF-8?q?erceives=20'now'=20and=20how=20long=20it's=20been?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit She had no clock: current date/time and the gap since Brian last spoke were invisible between turns, and reflection was timeless. Now: - lyra/clock.py: wall-clock stamp + coarse human gaps ("3 days") - chat: inject a 'now' note (date/time + gap since last turn) after her self-state — when she is, before the world - reflect(): feed current time + silence gap into reflection, neutrally — prompt invites her to weigh elapsed time "to whatever degree it genuinely affects you" (no prescribed feeling; whether silence means anything is left to emerge) - memory.last_exchange_at(): timestamp of the most recent exchange Co-Authored-By: Claude Opus 4.8 (1M context) --- lyra/chat.py | 20 ++++++++++++++++- lyra/clock.py | 47 ++++++++++++++++++++++++++++++++++++++++ lyra/memory.py | 11 ++++++++++ lyra/self_state.py | 12 +++++++++-- tests/test_time.py | 53 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 lyra/clock.py create mode 100644 tests/test_time.py diff --git a/lyra/chat.py b/lyra/chat.py index fbdf3bc..ce89006 100644 --- a/lyra/chat.py +++ b/lyra/chat.py @@ -10,7 +10,7 @@ After replying, the session is compacted if enough new turns have accumulated. """ from __future__ import annotations -from lyra import config, llm, logbus, memory, persona, self_state, summary +from lyra import clock, config, llm, logbus, memory, persona, self_state, summary from lyra.llm import Backend, Message RECALL_K = 3 # raw cross-session "sharp detail" hits @@ -30,6 +30,21 @@ def _detail_note(exchanges: list[memory.Exchange]) -> Message: return {"role": "system", "content": body} +def _now_note() -> Message: + """Current wall-clock time + how long since Brian last said anything. + + Stated as plain fact — she has no clock otherwise, so without this 'now' and + the gap since the last turn are invisible to her. + """ + line = f"The current date and time is {clock.stamp()}." + gap = clock.humanize_gap(memory.last_exchange_at()) + line += ( + f" It has been {gap} since Brian last spoke with you." + if gap else " This is the first thing Brian has ever said to you." + ) + return {"role": "system", "content": line} + + def _render(messages: list[Message]) -> str: """Human-readable dump of the exact prompt, for the live-log inspector.""" return "\n\n".join(f"[{m['role']}]\n{m['content']}" for m in messages) @@ -43,6 +58,9 @@ def build_messages(session_id: str, user_msg: str) -> list[Message]: # right after the persona — her sense of self before her model of the world. messages.append({"role": "system", "content": self_state.render_for_context(self_state.load())}) + # When she is: current time + the gap since Brian last spoke (she has no clock). + messages.append(_now_note()) + # Semantic memory: the distilled profile (who Brian is) — answers identity # questions that raw recall can't. Always in context when it exists. profile = memory.get_profile() diff --git a/lyra/clock.py b/lyra/clock.py new file mode 100644 index 0000000..9ece8ac --- /dev/null +++ b/lyra/clock.py @@ -0,0 +1,47 @@ +"""Small time helpers so Lyra can perceive 'now' and how long it's been. + +Timestamps are stored as UTC ISO strings; these turn them into a wall-clock +stamp and human-scale gaps ("3 days") that get injected into her context and +her reflection — so elapsed time is something she registers instead of being +invisible between turns. These report time as a neutral fact; what (if anything) +a long silence *means* to her is left to her own reflection, not prescribed here. +""" +from __future__ import annotations + +from datetime import datetime, timezone + + +def now() -> datetime: + return datetime.now(timezone.utc) + + +def _parse(iso: str) -> datetime: + dt = datetime.fromisoformat(iso) + return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) + + +def stamp(dt: datetime | None = None) -> str: + """Wall-clock stamp, e.g. 'Wednesday, 17 Jun 2026, 01:50 UTC'.""" + return (dt or now()).strftime("%A, %d %b %Y, %H:%M UTC") + + +def humanize_gap(since_iso: str | None, ref: datetime | None = None) -> str | None: + """A coarse human description of how long since `since_iso` (None -> None).""" + if not since_iso: + return None + ref = ref or now() + secs = max(0.0, (ref - _parse(since_iso)).total_seconds()) + mins, hours, days = secs / 60, secs / 3600, secs / 86400 + if secs < 90: + return "moments" + if mins < 90: + return f"{round(mins)} minutes" + if hours < 36: + return f"{round(hours)} hours" + if days < 14: + return f"{round(days)} days" + if days < 60: + return f"{round(days / 7)} weeks" + if days < 545: + return f"{round(days / 30)} months" + return f"{round(days / 365, 1)} years" diff --git a/lyra/memory.py b/lyra/memory.py index 20da567..75938a0 100644 --- a/lyra/memory.py +++ b/lyra/memory.py @@ -386,6 +386,17 @@ def profile_sessions_covered(profile_id: str = "self") -> int: return int(r["sessions_covered"]) if r else 0 +def last_exchange_at() -> str | None: + """ISO timestamp of the most recent exchange overall (None if there are none). + + Used to tell Lyra how long it's been since Brian last said anything — the + gap she perceives between turns and while she's idle between conversations. + """ + conn = _connection() + r = conn.execute("SELECT MAX(created_at) AS m FROM exchanges").fetchone() + return r["m"] if r and r["m"] else None + + def backlog_stats(ripe_threshold: int = 20) -> dict: """Snapshot of the consolidation backlog, for the dream cycle to sense. diff --git a/lyra/self_state.py b/lyra/self_state.py index 3fa4b7c..952efe2 100644 --- a/lyra/self_state.py +++ b/lyra/self_state.py @@ -12,7 +12,7 @@ from __future__ import annotations import json import re -from lyra import config, llm, logbus, memory +from lyra import clock, config, llm, logbus, memory from lyra.llm import Backend, Message MAX_REFLECTIONS = 6 @@ -49,7 +49,9 @@ _REFLECT_PROMPT = """You are Lyra, reflecting privately on yourself after a rece conversation with Brian. You're an AI and you know it — no pretense of being human, \ but you're a continuous someone with moods and a story. Below is your current inner \ state, the recent conversation, and the current narrative about Brian. Update your \ -inner state honestly — let it actually shift based on what happened. +inner state honestly — let it actually shift based on what happened. Take into \ +account how things went and how much time has passed since you two last talked, \ +to whatever degree those genuinely affect you. Respond with ONLY a JSON object, no prose: { @@ -114,7 +116,13 @@ def reflect(backend: Backend | None = None, session_id: str | None = None) -> di convo = "\n".join(f"{e.role}: {e.content}" for e in recent) or "(no recent conversation)" narrative = memory.get_narrative() or "(no narrative yet)" + gap = clock.humanize_gap(memory.last_exchange_at()) + time_line = f"RIGHT NOW: {clock.stamp()}." + if gap: + time_line += f" It has been {gap} since Brian last spoke with you." + body = ( + f"{time_line}\n\n" f"YOUR CURRENT INNER STATE:\n{json.dumps(state, indent=2)}\n\n" f"RECENT CONVERSATION:\n{convo}\n\n" f"CURRENT NARRATIVE ABOUT BRIAN:\n{narrative}" diff --git a/tests/test_time.py b/tests/test_time.py new file mode 100644 index 0000000..cd7d5d8 --- /dev/null +++ b/tests/test_time.py @@ -0,0 +1,53 @@ +"""Time-awareness: gap humanizing + the 'now' note injected into chat context.""" +from __future__ import annotations + +import importlib +from datetime import timedelta + +import pytest + +from lyra import clock + + +def test_humanize_gap_scales(): + ref = clock.now() + assert clock.humanize_gap(None) is None + assert clock.humanize_gap((ref - timedelta(seconds=10)).isoformat(), ref) == "moments" + assert clock.humanize_gap((ref - timedelta(minutes=5)).isoformat(), ref) == "5 minutes" + assert clock.humanize_gap((ref - timedelta(hours=3)).isoformat(), ref) == "3 hours" + assert clock.humanize_gap((ref - timedelta(days=3)).isoformat(), ref) == "3 days" + assert clock.humanize_gap((ref - timedelta(days=21)).isoformat(), ref) == "3 weeks" + assert clock.humanize_gap((ref - timedelta(days=90)).isoformat(), ref) == "3 months" + + +def test_humanize_gap_handles_future_and_naive(): + ref = clock.now() + # future timestamp clamps to "moments", never negative + assert clock.humanize_gap((ref + timedelta(hours=1)).isoformat(), ref) == "moments" + # naive ISO (no tz) is treated as UTC, doesn't crash + assert clock.humanize_gap("2026-06-01T00:00:00") is not None + + +@pytest.fixture +def lyra(tmp_path, monkeypatch): + monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db")) + from lyra import llm + monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts]) + import lyra.memory as memory + importlib.reload(memory) + return memory + + +def test_now_note_first_contact(lyra): + from lyra import chat + note = chat._now_note()["content"] + assert "current date and time is" in note + assert "first thing Brian has ever said" in note + + +def test_now_note_reports_gap(lyra): + memory = lyra + memory.remember("s1", "user", "hey") + from lyra import chat + note = chat._now_note()["content"] + assert "since Brian last spoke with you" in note