diff --git a/stacklets/memory/bot/cli/diary.py b/stacklets/memory/bot/cli/diary.py index d93fd274..a175201f 100644 --- a/stacklets/memory/bot/cli/diary.py +++ b/stacklets/memory/bot/cli/diary.py @@ -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 @@ -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}") diff --git a/stacklets/memory/bot/cli/wiki.py b/stacklets/memory/bot/cli/wiki.py index 85ffb344..9d400e83 100644 --- a/stacklets/memory/bot/cli/wiki.py +++ b/stacklets/memory/bot/cli/wiki.py @@ -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 @@ -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) @@ -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 @@ -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) diff --git a/stacklets/memory/bot/diary.py b/stacklets/memory/bot/diary.py index e6a19635..38460101 100644 --- a/stacklets/memory/bot/diary.py +++ b/stacklets/memory/bot/diary.py @@ -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": { @@ -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", }, } @@ -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. @@ -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: @@ -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, @@ -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): @@ -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] diff --git a/stacklets/messages/cli/setup.py b/stacklets/messages/cli/setup.py index 5bda4166..826ca5d2 100644 --- a/stacklets/messages/cli/setup.py +++ b/stacklets/messages/cli/setup.py @@ -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 @@ -316,7 +328,15 @@ def _post_welcome_messages(bot, room_ids, server_name, config=None): "what the kids want to tell their future selves.
" "There's no wrong way to use this. Just start recording.
" ) - 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): diff --git a/tests/stacklets/test_memory_diary.py b/tests/stacklets/test_memory_diary.py index f248209a..d6c72180 100644 --- a/tests/stacklets/test_memory_diary.py +++ b/tests/stacklets/test_memory_diary.py @@ -1024,3 +1024,131 @@ def test_a_long_addressed_memo_is_not_distilled(self): assert "Should never render." not in page assert "Full transcript" not in page assert "— for Bart" in page + + +class TestHomeLink: + """The pointer the diary hands to pages that link to it. + + The diary owns its own path and its own wording, so the wiki home + page renders this string verbatim. Both halves are pinned here. + """ + + def test_points_at_the_diary_front_page(self): + assert "(/family/diary/about)" in diary.home_link("family") + + def test_the_bucket_is_the_callers(self): + """Diary paths are relative to the shared bucket, which is named + in config. The compiler never reads it.""" + assert "(/office/diary/about)" in diary.home_link("office") + + def test_is_a_callout_so_it_reads_as_a_signpost(self): + assert diary.home_link("family").startswith("> [!tip] ") + + def test_carries_the_diary_title_and_a_line_of_its_own(self): + link = diary.home_link("family") + assert "Family Diary" in link + assert len(link.splitlines()) == 2 + + def test_renders_in_the_household_language(self): + diary.configure_language("de") + try: + link = diary.home_link("family") + assert "Familientagebuch" in link + assert "Recordings" not in link + finally: + diary.configure_language("en") + + +class TestEmptyDiary: + """What the diary page says before anything has been recorded. + + This page is where the wiki's diary link lands, so on a fresh + install it is the first thing a family reads about the feature. A + count of zero teaches nobody anything; these tests pin that it + explains how to record instead. + """ + + def test_it_says_how_to_record_something(self): + page = diary.render_index([]) + + assert "Memories" in page # names the room + assert "voice message" in page.lower() + assert "Press record." in page + + def test_it_keeps_the_diary_title_and_opening(self): + """Not a separate error page: the same front door, with the + years replaced by the note on how to fill them.""" + page = diary.render_index([]) + + assert page.startswith("# Family Diary") + assert "## Years" not in page + + def test_it_explains_the_two_things_that_are_not_obvious(self): + """Speaking the date and replying to a message both change what + the compiler does with a recording, and neither is guessable + from the room.""" + page = diary.render_index([]) + + assert "Today is the third of March" in page + assert "Reply to a message" in page + + def test_it_is_written_in_the_household_language(self): + diary.configure_language("de") + try: + page = diary.render_index([]) + assert "Noch nichts aufgenommen" in page + assert "Nothing recorded yet" not in page + finally: + diary.configure_language("en") + + def test_a_compiled_diary_shows_years_instead(self): + """The note is for the empty case only. One entry and the page + goes back to being an index.""" + page = diary.render_index(_compile()) + + assert "Press record." not in page + assert "## Years" in page + + +class TestBotMessagesAreNotMemories: + """A bot writes in the room; none of it belongs in the diary. + + `stack messages setup` posts a welcome into the memories room when + it creates it. Without this filter that welcome is the first entry + of every fresh install's diary, and the family's own diary opens + with someone else's words. + """ + + def _event(self, sender: str, body: str, ts: int = 1_700_000_000_000): + return { + "type": "m.room.message", + "event_id": f"${sender}-{ts}", + "sender": sender, + "origin_server_ts": ts, + "content": {"msgtype": "m.text", "body": body}, + } + + def test_a_bot_message_is_dropped(self): + messages = diary.resolve([ + self._event("@stacker-bot:home.local", "This is your family's..."), + ]) + + assert messages == [] + + def test_the_family_still_comes_through(self): + messages = diary.resolve([ + self._event("@stacker-bot:home.local", "welcome", ts=1), + self._event("@marge:home.local", "Bart lost a tooth", ts=2), + ]) + + assert [m.sender for m in messages] == ["marge"] + + def test_a_person_whose_name_ends_in_bot_is_not_a_bot(self): + """The convention is a localpart suffix on the whole account, so + the check must not fire on a name that merely ends in it.""" + messages = diary.resolve([ + self._event("@abbot:home.local", "Bart lost a tooth"), + ]) + + assert [m.sender for m in messages] == ["abbot"] + diff --git a/tests/stacklets/test_memory_wiki.py b/tests/stacklets/test_memory_wiki.py index 929d4d7f..9034ea47 100644 --- a/tests/stacklets/test_memory_wiki.py +++ b/tests/stacklets/test_memory_wiki.py @@ -46,6 +46,7 @@ _topic_entries, _topic_locations, _topic_preamble, + _with_diary_link, _yaml_str, ) @@ -950,3 +951,86 @@ def test_nothing_to_clean_is_ok(self, tmp_path): (tmp_path / "n.md").write_text("---\ntype: note\n---\n\nx\n", encoding="utf-8") rc = _clean_generated(brain=tmp_path, dry_run=False, assume_yes=True) assert rc == 0 + + +class TestDiaryLink: + """The home page's one pointer into the diary. + + The diary is not reachable from the landing page on its own: it + sits two folders down, and Quartz files it into the sidebar + alphabetically between unrelated entries. These tests pin where + the pointer lands and the case where it must not appear at all. + """ + + PAGE = ( + "# The Simpsons\n" + "> 742 Evergreen Terrace\n" + "\n" + "## Members\n" + "- **[Homer Simpson](homer/about)** - safety inspector. [1]\n" + ) + + def _compile_diary(self, brain: Path, bucket: str = "family") -> None: + page = brain / bucket / "diary" / "about.md" + page.parent.mkdir(parents=True, exist_ok=True) + page.write_text("---\ntitle: Family Diary\n---\n", encoding="utf-8") + + def _link_line(self, page: str) -> int: + lines = page.splitlines() + return next(i for i, ln in enumerate(lines) if "/diary/about" in ln) + + def test_pointer_sits_above_the_first_section(self, tmp_path, monkeypatch): + monkeypatch.setenv("BRAIN_REPO_DIR", str(tmp_path)) + self._compile_diary(tmp_path) + + out = _with_diary_link(self.PAGE, shared_bucket="family") + + assert "/family/diary/about" in out + assert self._link_line(out) < out.splitlines().index("## Members") + + def test_pointer_starts_its_own_blockquote(self, tmp_path, monkeypatch): + """The address under the H1 is a blockquote and the pointer is a + callout, which is one too. Run together with no blank line + between them, markdown reads the pair as a single quote.""" + monkeypatch.setenv("BRAIN_REPO_DIR", str(tmp_path)) + self._compile_diary(tmp_path) + + out = _with_diary_link(self.PAGE, shared_bucket="family") + + assert out.splitlines()[self._link_line(out) - 1] == "" + + def test_nothing_is_added_until_the_diary_is_compiled( + self, tmp_path, monkeypatch, + ): + """A landing page that opens with a 404 is worse than one that + says nothing. The diary command runs on its own schedule, so a + fresh install has a home page before it has a diary.""" + monkeypatch.setenv("BRAIN_REPO_DIR", str(tmp_path)) + + assert _with_diary_link(self.PAGE, shared_bucket="family") == self.PAGE + + def test_the_bucket_comes_from_config(self, tmp_path, monkeypatch): + monkeypatch.setenv("BRAIN_REPO_DIR", str(tmp_path)) + self._compile_diary(tmp_path, bucket="office") + + out = _with_diary_link(self.PAGE, shared_bucket="office") + + assert "/office/diary/about" in out + + def test_a_page_with_no_sections_still_gets_the_pointer( + self, tmp_path, monkeypatch, + ): + monkeypatch.setenv("BRAIN_REPO_DIR", str(tmp_path)) + self._compile_diary(tmp_path) + + out = _with_diary_link("# The Simpsons\n", shared_bucket="family") + + assert "/family/diary/about" in out + + def test_an_unset_brain_dir_is_not_fatal_here(self, monkeypatch): + """Generation already fails on a missing BRAIN_REPO_DIR at the + point it writes. A navigation line is not where that is + reported.""" + monkeypatch.delenv("BRAIN_REPO_DIR", raising=False) + + assert _with_diary_link(self.PAGE, shared_bucket="family") == self.PAGE diff --git a/tests/stacklets/test_messages_setup.py b/tests/stacklets/test_messages_setup.py index a50cfa7c..511f0912 100644 --- a/tests/stacklets/test_messages_setup.py +++ b/tests/stacklets/test_messages_setup.py @@ -64,7 +64,10 @@ def add_space_child(self, space_id, child_id): return True def send(self, room_alias, plain, html=None): - pass + # Same shape as the real client: (ok, detail). Setup reports the + # welcome message's outcome, so a fake that returns None here + # would pass while the real call path raises. + return True, "ok" def test_stacker_bot_canonical_password(tmp_path):