Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions stacklets/memory/bot/cli/diary.py
Original file line number Diff line number Diff line change
Expand Up @@ -851,8 +851,13 @@ async def run(llm, argv: list[str]) -> int:

messages = diary.resolve(events, burst_window_s=window, zone=zone)
if not messages:
_err(f"nothing in {room_arg} to compile")
return 0
# Still publish. The page is where the wiki's diary link
# lands, and with nothing recorded it carries the note that
# explains how to record something.
_err(f"nothing recorded in {room_arg} yet, publishing an empty diary")
await transcriber.aclose()
return _publish_pages(diary.pages_for([]),
bucket=bucket, dry_run=dry_run)
_err(f"{len(messages)} message(s) in {room_arg}")

# Transcription first and on its own: every later step reads
Expand Down Expand Up @@ -930,6 +935,16 @@ async def run(llm, argv: list[str]) -> int:
summaries_cache.save()

pages = diary.pages_for(entries, room_id=room_id, summaries=summaries)
return _publish_pages(pages, bucket=bucket, dry_run=dry_run)


def _publish_pages(pages, *, bucket: str, dry_run: bool) -> int:
"""Write the compiled pages into the brain working copy.

One publisher for both exits: a full compile and the empty diary
reach the wiki the same way, so the page a family lands on has the
same frontmatter and the same splice contract either way.
"""
if dry_run:
for path, body, _title in pages:
print(f"\n{'=' * 70}\n{bucket}/{path}\n{'=' * 70}\n{body}")
Expand Down
48 changes: 48 additions & 0 deletions stacklets/memory/bot/cli/wiki.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@

from stack.ai.client import LLM, LLMUnavailableError # noqa: E402

# The diary compiler owns the diary's path and its reader-facing words;
# the home page only places the pointer it renders. Imported by bare
# name, the way `cli/diary.py` imports it, so the two commands share one
# module object and therefore one selected language.
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import diary # noqa: E402

HELP = "Regenerate the family wiki's home and member pages"

# Subject prefix the curator's poll loop still filters on. Generation
Expand Down Expand Up @@ -249,6 +256,10 @@ async def run(llm: LLM, argv: list[str]) -> int:
shared_bucket = os.environ.get("SHARED_BUCKET", "family")
lang = os.environ.get("LANGUAGE", "en")

# The diary renders its own pointer, so it needs the household
# language selected here too.
diary.configure_language(lang)

# One walk feeds every surface. The home page reads the whole index;
# each member page reads its slice. No re-walking per member.
index = _index_vault(vault)
Expand Down Expand Up @@ -405,6 +416,10 @@ async def _generate_home(
# the LLM cites reliably, but the citation→document mapping is ours
# to render so links and dates can't be fabricated. Home page lives
# at the vault root, so links are root-relative (page_dir="").
# Above the sections, because it is the one link on this page that
# leads somewhere a reader browses rather than looks something up.
page = _with_diary_link(page, shared_bucket=shared_bucket)

page = _with_references(page, index, page_dir="")

# Index pages for the shared bucket's own captures (notes dropped in the
Expand Down Expand Up @@ -960,6 +975,39 @@ def _load_facts(vault: Path, slug: str) -> list[tuple[str, str]]:

# ── References ─────────────────────────────────────────────────────────────

def _with_diary_link(page: str, *, shared_bucket: str) -> str:
"""Put a pointer to the diary above the home page's first section.

Nothing else on the landing page leads there: the diary is two
folders down and the sidebar files it alphabetically between
unrelated entries, so a family that never ran the command has no
way to find out it exists.

Only linked once a compiled diary is on disk. The command that
writes those pages runs on its own schedule, and a landing page
that opens with a 404 is worse than one that says nothing.
"""
try:
published = _brain_dir() / shared_bucket / diary.DIARY_DIR / "about.md"
except RuntimeError:
return page
if not published.exists():
return page

link = diary.home_link(shared_bucket)
lines = page.splitlines()
for i, line in enumerate(lines):
if line.startswith("## "):
head = lines[:i]
# The address under the H1 is a blockquote, and a callout is
# one too: without the blank line between them markdown reads
# the pair as a single quote.
if head and head[-1].strip():
head.append("")
return "\n".join(head + [link, ""] + lines[i:])
return page.rstrip() + "\n\n" + link


def _with_references(page: str, entries: list[dict], *, page_dir: str) -> str:
"""Append a `## References` block for the citations the page used."""
section = _build_references_section(page, entries, page_dir=page_dir)
Expand Down
67 changes: 61 additions & 6 deletions stacklets/memory/bot/diary.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,25 @@
"recorded_by": "recorded by",
"months_h": "Months", "years_h": "Years",
"diary_title": "Family Diary",
"home_teaser": "Recordings and notes, read back month by month.",
"index_intro": (
"Your memories, kept in a chronicle to read back. Voice "
"notes, photos, conversations you recorded. Every entry "
"leads back to the original recording, there to be "
"listened to, today or in twenty years."),
"nothing_compiled": "Nothing has been compiled yet.",
"getting_started": """## Nothing recorded yet

This diary is written from **Memories**, the room in your family chat.
Whatever you send there becomes an entry here: a voice message, a photo,
a video, or a few lines of writing.

- Record a voice message. It is written out here in full, and the
recording stays one tap away.
- Open with the date ("Today is the third of March") and the entry is
filed on that day. Without one, the day you sent it counts.
- Reply to a message to add to that memory later.

There is no wrong way to use it. Press record.""",
"across": "across",
},
"de": {
Expand Down Expand Up @@ -129,13 +142,28 @@
"recorded_by": "aufgenommen von",
"months_h": "Monate", "years_h": "Jahre",
"diary_title": "Familientagebuch",
"home_teaser": ("Aufnahmen und Notizen, Monat f\u00fcr Monat "
"zum Nachlesen."),
"index_intro": (
"Eure Erinnerungen, festgehalten in einer Chronik zum "
"Nachlesen. Sprachnotizen, Fotos, Gespr\u00e4che, die ihr "
"aufgenommen habt. Jeder Eintrag f\u00fchrt zur\u00fcck "
"zur Originalaufnahme \u2014 zum Nachh\u00f6ren, heute "
"zur Originalaufnahme, zum Nachh\u00f6ren, heute "
"oder in zwanzig Jahren."),
"nothing_compiled": "Noch nichts zusammengestellt.",
"getting_started": """## Noch nichts aufgenommen

Dieses Tagebuch entsteht aus **Memories**, dem Raum in eurem Familienchat.
Alles, was ihr dort sendet, wird hier zu einem Eintrag: eine Sprachnachricht,
ein Foto, ein Video oder ein paar Zeilen Text.

- Nehmt eine Sprachnachricht auf. Sie wird hier vollst\u00e4ndig
ausgeschrieben, und die Aufnahme bleibt einen Fingertipp entfernt.
- Beginnt mit dem Datum ("Heute ist der dritte M\u00e4rz"), dann wird der
Eintrag auf diesen Tag datiert. Ohne Datum z\u00e4hlt der Tag, an dem
ihr gesendet habt.
- Antwortet auf eine Nachricht, um sp\u00e4ter etwas zu erg\u00e4nzen.

Es gibt kein falsches Vorgehen. Dr\u00fcckt auf Aufnahme.""",
"across": "in",
},
}
Expand Down Expand Up @@ -347,6 +375,15 @@ def _kind_of(msgtype: str) -> str | None:
}.get(msgtype)


# Bot accounts are named by convention: a localpart ending in `-bot`.
# The framework owns that definition (`MicroBot.is_bot_user`), which we
# cannot import here without pulling a Matrix client into a module that
# is pure on purpose. What a bot posts in the room is instruction, not
# memory, and a diary that opens with the welcome message opens with
# someone else's words.
_BOT_SUFFIX = "-bot"


def resolve(events, *, burst_window_s: float = DEFAULT_BURST_WINDOW_S,
zone: tzinfo = timezone.utc):
"""Room events to messages: edits applied, replies linked, bursts marked.
Expand All @@ -362,6 +399,9 @@ def resolve(events, *, burst_window_s: float = DEFAULT_BURST_WINDOW_S,
for ev in events:
if ev.get("type") != "m.room.message":
continue
sender = (ev.get("sender") or "").split(":")[0].lstrip("@")
if sender.endswith(_BOT_SUFFIX):
continue
content = ev.get("content") or {}
kind = _kind_of(content.get("msgtype", ""))
if kind is None:
Expand Down Expand Up @@ -396,7 +436,7 @@ def resolve(events, *, burst_window_s: float = DEFAULT_BURST_WINDOW_S,

plain.append(Message(
event_id=ev.get("event_id", ""),
sender=(ev.get("sender") or "").split(":")[0].lstrip("@"),
sender=sender,
ts=ts,
kind=kind,
body=body,
Expand Down Expand Up @@ -1014,8 +1054,12 @@ def render_index(entries) -> str:
"",
]
if not entries:
lines += [_L["nothing_compiled"], ""]
return "\n".join(lines)
# An empty diary is the one moment a family needs to be told
# how to fill one. This page is where the wiki's diary link
# lands, so it has to answer "what now" rather than report a
# count of zero.
lines += [_L["getting_started"], ""]
return "\n".join(lines).rstrip() + "\n"

lines += [f"## {_L['years_h']}", ""]
for key, year in sorted(_by_year(entries).items(), reverse=True):
Expand All @@ -1035,6 +1079,17 @@ def render_index(entries) -> str:
return "\n".join(lines).rstrip() + "\n"


def home_link(bucket: str) -> str:
"""The diary's pointer, for a page that wants to link to it.

Both the path and the wording live here, so the linking page needs
to know neither. `bucket` is the shared bucket the caller prefixes
every diary path with; see `pages_for`.
"""
return (f"> [!tip] [{_L['diary_title']}](/{bucket}/{DIARY_DIR}/about)\n"
f"> {_L['home_teaser']}")


def _and_list(names: list[str]) -> str:
if len(names) == 1:
return names[0]
Expand Down
30 changes: 25 additions & 5 deletions stacklets/messages/cli/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,20 +222,32 @@ def _setup(client, users, config, secrets=None):
if bot_created:
results.append({"item": f"@{BOT_NAME}:{server_name}", "action": "ready"})

# Join bot to Server Room only — Family Room is for humans
# Server Room, and Memories so the welcome there can be posted.
# Family Room is for humans. Synapse rejects a message from a
# non-member, so a room the bot writes to is a room it joins.
if "famstack" in room_ids:
client.join_user(room_ids["famstack"], BOT_NAME)
if "memories" in room_ids:
client.join_user(room_ids["memories"], BOT_NAME)

# Log in as stacker-bot to post welcome messages
bot_client = MatrixClient(client.base_url, server_name, client.repo_root)
if bot_client.login(BOT_NAME, bot_pass):
_post_welcome_messages(bot_client, room_ids, server_name, config)
_post_welcome_messages(bot_client, room_ids, server_name,
config, results)

return {"ok": True, "results": results}


def _post_welcome_messages(bot, room_ids, server_name, config=None):
"""Post welcome messages from stacker-bot to Server Room."""
def _post_welcome_messages(bot, room_ids, server_name, config=None,
results=None):
"""Post the welcome messages from stacker-bot.

`results` collects the same item/action rows the rest of setup
reports, so a welcome that does not land is visible in the
summary instead of vanishing into a discarded return value.
"""
results = results if results is not None else []

if "famstack" not in room_ids:
return
Expand Down Expand Up @@ -316,7 +328,15 @@ def _post_welcome_messages(bot, room_ids, server_name, config=None):
"what the kids want to tell their future selves.</p>"
"<p><em>There's no wrong way to use this. Just start recording.</em></p>"
)
bot.send("memories", memories_plain, html=memories_html)
ok, detail = bot.send("memories", memories_plain, html=memories_html)
# Not fatal: the room and the accounts are already built. But a
# silent failure leaves a new family in an empty room with
# nothing saying what it is for, which is the one thing this
# message exists to prevent.
results.append({
"item": "#memories welcome",
"action": "posted" if ok else f"failed: {detail}",
})


def _pretty(result):
Expand Down
Loading
Loading