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:
+19
-1
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user