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 __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 from lyra.llm import Backend, Message
RECALL_K = 3 # raw cross-session "sharp detail" hits 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} 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: def _render(messages: list[Message]) -> str:
"""Human-readable dump of the exact prompt, for the live-log inspector.""" """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) 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. # 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())}) 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 # Semantic memory: the distilled profile (who Brian is) — answers identity
# questions that raw recall can't. Always in context when it exists. # questions that raw recall can't. Always in context when it exists.
profile = memory.get_profile() 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 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: def backlog_stats(ripe_threshold: int = 20) -> dict:
"""Snapshot of the consolidation backlog, for the dream cycle to sense. """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 json
import re import re
from lyra import config, llm, logbus, memory from lyra import clock, config, llm, logbus, memory
from lyra.llm import Backend, Message from lyra.llm import Backend, Message
MAX_REFLECTIONS = 6 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, \ 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 \ 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 \ 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: 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)" convo = "\n".join(f"{e.role}: {e.content}" for e in recent) or "(no recent conversation)"
narrative = memory.get_narrative() or "(no narrative yet)" 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 = ( body = (
f"{time_line}\n\n"
f"YOUR CURRENT INNER STATE:\n{json.dumps(state, indent=2)}\n\n" f"YOUR CURRENT INNER STATE:\n{json.dumps(state, indent=2)}\n\n"
f"RECENT CONVERSATION:\n{convo}\n\n" f"RECENT CONVERSATION:\n{convo}\n\n"
f"CURRENT NARRATIVE ABOUT BRIAN:\n{narrative}" 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