feat: time awareness — Lyra perceives 'now' and how long it's been

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 02:31:40 +00:00
parent 1301f12e74
commit 1e17d46c78
5 changed files with 140 additions and 3 deletions
+19 -1
View File
@@ -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()
+47
View File
@@ -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"
+11
View File
@@ -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.
+10 -2
View File
@@ -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}"
+53
View File
@@ -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