feat: era-rollup + narrative engine (consolidation steps 3-4)

Complete the consolidation pipeline: summaries -> profile + eras -> narrative.

- memory: eras table (per-month digests) + Era, summaries_by_month, store_era,
  list_eras, recall_eras; narrative table + set/get_narrative
- lyra/era.py (lyra-era): groups session gists by the month the session occurred
  (real timestamps) and map-reduces each month into a "what was happening" digest
- lyra/narrative.py (lyra-narrative): distills profile + recent eras into the
  current arc/trends/callbacks ("remember when…", "you're trending toward…")
- chat.build_messages injects the narrative alongside the profile

Verified on the real corpus: 17 monthly eras (Dec 2024-Jun 2026) + a narrative
that surfaces specific callbacks (the $573 Hollywood session, 4 years sober).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 19:28:01 +00:00
parent d7e2fce694
commit bfb81428ab
5 changed files with 277 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
"""Era rollups: per-month "what was happening" digests (consolidation step 3).
Groups session gists by the calendar month the session occurred (from real
exchange timestamps) and map-reduces each month into one digest. These are the
temporal memory tier — they answer "what was going on last December" and feed
the narrative engine. Runs on the consolidation backend (MI50 in steady state).
"""
from __future__ import annotations
from lyra import config, llm, logbus, memory
from lyra.llm import Backend, Message
BATCH_CHARS = 18000
_PROMPT = """You are writing a monthly memory digest about Brian from the session \
summaries below (all from the same month). Capture: what he was focused on (poker \
and otherwise), notable events/results/decisions, recurring themes, and his mood \
and arc across the month. Third person, referring to him as "Brian". 5-10 \
sentences. This is a memory record, not a reply. No preamble."""
_MERGE_PROMPT = """Merge these partial monthly digests (same month) into one \
coherent digest about Brian for that month. Keep it tight, 5-10 sentences, no \
repetition. Third person."""
def _batch_texts(texts: list[str], budget: int) -> list[str]:
blocks, buf, size = [], [], 0
for t in texts:
if size + len(t) > budget and buf:
blocks.append("\n\n".join(buf))
buf, size = [], 0
buf.append(t)
size += len(t)
if buf:
blocks.append("\n\n".join(buf))
return blocks
def _call(prompt: str, body: str, backend: Backend) -> str:
messages: list[Message] = [
{"role": "system", "content": prompt},
{"role": "user", "content": body},
]
return llm.complete(messages, backend=backend)
def _digest_month(gists: list[str], backend: Backend) -> str:
"""Map-reduce a month's session gists into one digest."""
blocks = _batch_texts(gists, BATCH_CHARS)
partials = [_call(_PROMPT, b, backend) for b in blocks]
while len(partials) > 1:
partials = [_call(_MERGE_PROMPT, g, backend) for g in _batch_texts(partials, BATCH_CHARS)]
return partials[0]
def rebuild_eras(backend: Backend | None = None) -> dict:
"""(Re)build a digest for every month that has session gists."""
backend = backend or config.load().summary_backend
by_month = memory.summaries_by_month()
months = 0
for month in sorted(by_month):
digest = _digest_month(by_month[month], backend)
memory.store_era(month, digest, len(by_month[month]))
months += 1
logbus.log("info", "era built", month=month, sessions=len(by_month[month]))
report = {"months": months}
logbus.log("info", "eras complete", **report)
return report
def main() -> int:
report = rebuild_eras()
if not report["months"]:
print("No summaries yet — run lyra-summarize first.")
return 1
for era in memory.list_eras():
print(f"\n## {era.month} ({era.session_count} sessions)\n{era.content}")
return 0
if __name__ == "__main__":
raise SystemExit(main())