Files
serversdown cb4ed10c1a feat: session timeline (running log) + reliable live logging
She was logging stacks but skipping hands — the CASH card framed logging as one of
two registers, so she'd 'talk about' a hand instead of recording it (streaming
returns content OR tool calls). Fixes:

- CASH card: logging is mandatory and log-FIRST — trackable facts get the tool call
  before the reply, both not either/or, hands never skipped for conversation.
- log_stack gains a note ('card dead', 'doubled up vs the LAG') -> timeline context;
  tool spec + handler updated. Migration adds poker_stack_log.note.
- poker.timeline(): interleaves session start, stack updates (+context), hands
  (linkable), reads, and rituals chronologically in local time (clock.short()).
  Added to the hud() bundle.
- Session HUD: a 📜 Timeline card renders the running log with hand links — the
  '10:19pm start … 1:34a doubled up, $750 (hand)' view Brian wanted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 07:26:14 +00:00

76 lines
2.7 KiB
Python

"""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
from zoneinfo import ZoneInfo
from lyra import config
def now() -> datetime:
return datetime.now(timezone.utc)
def _local_tz() -> ZoneInfo | timezone:
"""Brian's configured local zone (falls back to UTC if it can't be loaded)."""
try:
return ZoneInfo(config.load().timezone)
except Exception:
return timezone.utc
def _parse(iso: str) -> datetime:
dt = datetime.fromisoformat(iso)
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
def short(iso_or_dt: str | datetime | None = None) -> str:
"""Local time-of-day like '10:45pm', for timeline rows."""
dt = _parse(iso_or_dt) if isinstance(iso_or_dt, str) else (iso_or_dt or now())
return dt.astimezone(_local_tz()).strftime("%-I:%M%p").lower()
def stamp(dt: datetime | None = None) -> str:
"""Wall-clock stamp in Brian's local timezone, e.g.
'Friday, 27 Jun 2026, 01:50 EDT'. Times are stored UTC; this is what she *reads*,
so 'what time is it' answers in his time, not UTC."""
return (dt or now()).astimezone(_local_tz()).strftime("%A, %d %b %Y, %H:%M %Z")
def gap_seconds(since_iso: str | None, ref: datetime | None = None) -> float | None:
"""Seconds elapsed since `since_iso` (None -> None). The numeric counterpart to
humanize_gap, for code that needs to threshold on elapsed time."""
if not since_iso:
return None
ref = ref or now()
return max(0.0, (ref - _parse(since_iso)).total_seconds())
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"