diff --git a/docs/admin-guide.md b/docs/admin-guide.md index ea4fc7c3..623a803d 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -567,7 +567,7 @@ Setup seeds the vault with three things: the classification ontology (the topics The wiki maintains itself. A curator sidecar (`stack-memory-curator`) watches the vault: when new filings settle it regenerates the pages of the family members involved plus the home page (a couple of LLM calls, a few minutes after the burst), and once a night it rebuilds everything — topic pages, cross-references, the lot — while the GPU has nothing better to do. To bring the wiki up to date now instead of waiting, `./stack memory wiki update` regenerates what the latest filings touched and `./stack memory wiki update --all` runs the whole nightly rebuild; both return when the pages are written. `--member`, `--topic` and `--home` regenerate just those pages, and `[memory]` in `stack.toml` holds the knobs (see [Configuration](#configuration)). Edits happen in Forgejo (every wiki page links to its source), so the commit log doubles as the household's learning history. -**The family diary.** Everything posted in `#Memories` becomes a diary entry. Each night the curator transcribes the new recordings, has the AI read each entry (a title, a summary, the people, topics from the ontology, facts), writes one card per entry to the vault under `family/diary/entries/`, and rebuilds the diary pages in the wiki from those cards. At 07:30 local time the archivist posts each new card as a quiet notice in the thread under its memory, and one short notice with the count (`diary_job_at` in the archivist's `bot.toml` sets the time; it catches up after a night the Mac slept through). A family member corrects a card by replying in its thread, typed or spoken; the correction is committed to the vault under their name, and a corrected or hand-edited card is never overwritten by a later run. The archivist says nothing else in `#Memories`. The user guide has the family's side: [The Memories Room](user-guide.md#the-memories-room). +**The family diary.** Everything posted in `#Memories` becomes a diary entry. Each night the curator transcribes the new recordings, has the AI read each entry (a title, a summary, the people, topics from the ontology, facts), writes one card per entry to the vault under `family/diary/entries/`, and rebuilds the diary pages in the wiki from those cards. At 07:30 local time the archivist posts each new card as a quiet notice in the thread under its memory; the first time, one notice in the room says how to correct a card, and after two weeks without a new memory one notice invites the next, once per quiet stretch (`diary_job_at` in the archivist's `bot.toml` sets the time; it catches up after a night the Mac slept through). A family member corrects a card by replying in its thread, typed or spoken; the correction is committed to the vault under their name, and a corrected or hand-edited card is never overwritten by a later run. The archivist says nothing else in `#Memories`. The user guide has the family's side: [The Memories Room](user-guide.md#the-memories-room). | | | |---|---| diff --git a/docs/user-guide.md b/docs/user-guide.md index 45567e1c..f5b199df 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -232,11 +232,11 @@ The archivist does not reply to anything you post there. Every night, famstack reads the new memories: it transcribes the recordings, and your own AI reads each one for a title, who it is about, a short summary and the facts worth finding again ("Maggie: first tooth", "Swimming badge: 25 metres"). famstack files each memory as a card in the family's archive. -In the morning, the archivist puts each new card in the thread under its memory, and one short note in the room: +In the morning, the archivist puts each new card in the thread under its memory. The first time, it also posts one short note in the room that says how to correct a card: -> **archivist**: ❤️ 3 new memories in the family diary. [Open the diary] +> **archivist**: ❤️ 3 new memories are in the family diary. Each has a card in the thread under its message: reply there to fix a name, a date or a fact. [Open the diary] -Both are quiet messages, so no phone buzzes. A memory you post today gets its card tomorrow morning. +After two weeks without a new memory, the archivist posts one reminder, and no more until the next memory. All of these are quiet messages, so no phone buzzes. A memory you post today gets its card tomorrow morning. ### Put a memory right diff --git a/stacklets/docs/bot/diary_room.py b/stacklets/docs/bot/diary_room.py index 83a751c5..67d4a5df 100644 --- a/stacklets/docs/bot/diary_room.py +++ b/stacklets/docs/bot/diary_room.py @@ -2,13 +2,15 @@ The memories room is the family's own. The archivist says nothing about what is posted there. Once a day it posts each new diary card as a -notice in a thread on the entry's first message, followed by one short -notice saying how many memories were added (the first one also says how -to correct a card). A family member who replies -in a card's thread, in writing or by voice, is correcting that card: the -archivist hands the words to the memory stacklet, which applies them to -the card in the vault and commits them under that person's name, and -then posts the corrected card in the same thread. +notice in a thread on the entry's first message. The first time, one +notice in the room says how many memories were added and how to correct +a card; after that the cards are the announcement. After two weeks +without a new memory, one notice invites the next, once per quiet +stretch. A family member who replies in a card's thread, in writing or +by voice, is correcting that card: the archivist hands the words to the +memory stacklet, which applies them to the card in the vault and +commits them under that person's name, and then posts the corrected +card in the same thread. The diary itself belongs to the memory stacklet. This module only talks to the family and calls `stack memory diary` through the same entry @@ -57,8 +59,11 @@ # ones get a card notice; the rest are counted in the summary. FIRST_RUN_LOOKBACK_DAYS = 14 # The first announcement introduces the diary cards and how to correct -# one; after that the count is enough. +# one; after that each card in its thread is announcement enough. EXPLAIN_TIMES = 1 +# How long the diary may stay without a new memory before the room gets +# one notice that invites the next. +NUDGE_AFTER = timedelta(days=14) # ── Pure parts ──────────────────────────────────────────────────────── @@ -153,13 +158,28 @@ def card_text(card: dict, t, *, corrected: bool = False, hint: bool = False) -> return "\n".join(lines) -def summary_text(count: int, *, explain: bool, link: str, t) -> str: - """The daily notice in the main timeline: how many memories were added.""" - key = "diary_summary" + ("_first" if explain else "") + ("_one" if count == 1 else "") - text = t(key, count=count) +def summary_text(count: int, *, link: str, t) -> str: + """The introduction in the main timeline: how many memories were added + and how to correct a card.""" + text = t("diary_intro_one" if count == 1 else "diary_intro", count=count) return f"{text} [{t('diary_open')}]({link})" if link else text +def quiet_since(report: dict, *, nudged_for: int, now: datetime) -> int | None: + """The time of the newest memory, if the diary is due for a nudge. + + Due when the newest memory is at least `NUDGE_AFTER` old and that + same memory has not had its nudge yet (`nudged_for`). A newer memory + starts a new quiet stretch. An empty diary is never nudged. + """ + newest = max((int(c.get("at") or 0) for c in report.get("cards") or []), default=0) + if not newest or newest == nudged_for: + return None + if now - datetime.fromtimestamp(newest / 1000, timezone.utc) < NUDGE_AFTER: + return None + return newest + + def envelope(card: dict, kind: str, *, actor: str) -> dict: """The famstack event the card notice carries: the item it files.""" return { @@ -314,13 +334,22 @@ async def run_job(self, now: datetime | None = None) -> dict | None: metadata={self.bot.FAMSTACK_EVENT_KEY: envelope( card, CARD_FILED, actor=self.bot.user_id)}, msgtype="m.notice") + # Only the introduction carries a count. The backlog is in + # it once: its cards never get a notice, so a daily count + # would announce them again every morning. added = len(fresh) + older - if added and room_id: + newest = quiet_since(report, nudged_for=int(state.get("nudged_for", 0)), now=now) + if explain and added and room_id: link = public(go_topic("diary"), self.bot.link_base_url) await self.bot._send(room_id, summary_text( - added, explain=explain, link=link, t=self._t), msgtype="m.notice") - if explain: - state["explained"] = int(state.get("explained", 0)) + 1 + added, link=link, t=self._t), msgtype="m.notice") + state["explained"] = int(state.get("explained", 0)) + 1 + elif newest and room_id: + await self.bot._send(room_id, self._t("diary_nudge"), msgtype="m.notice") + # The introduction invites the next memory as well, so it + # stands in for the nudge of the quiet stretch it opens. + if newest: + state["nudged_for"] = newest state["last_run"] = now.date().isoformat() self._save(state) logger.info("[archivist] diary job: {} card(s) posted, {} counted", len(fresh), older) diff --git a/stacklets/docs/bot/messages/archivist.yml b/stacklets/docs/bot/messages/archivist.yml index c3bafd66..a643a367 100644 --- a/stacklets/docs/bot/messages/archivist.yml +++ b/stacklets/docs/bot/messages/archivist.yml @@ -221,11 +221,10 @@ en: diary_card: "\U0001F4D4 **{title}** · {date}" diary_card_corrected: "\u270F\uFE0F **{title}** · {date} (corrected)" diary_card_hint: "_Reply here to fix a name, a date or a fact._" - diary_summary: "\u2764\uFE0F {count} new memories in the family diary." - diary_summary_one: "\u2764\uFE0F 1 new memory in the family diary." - diary_summary_first: "\u2764\uFE0F {count} new memories are in the family diary. Each has a card in the thread under its message: reply there to fix a name, a date or a fact." - diary_summary_first_one: "\u2764\uFE0F 1 new memory is in the family diary. It has a card in the thread under its message: reply there to fix a name, a date or a fact." + diary_intro: "\u2764\uFE0F {count} new memories are in the family diary. Each has a card in the thread under its message: reply there to fix a name, a date or a fact." + diary_intro_one: "\u2764\uFE0F 1 new memory is in the family diary. It has a card in the thread under its message: reply there to fix a name, a date or a fact." diary_open: "Open the diary" + diary_nudge: "\U0001F4DD No new memory in the family diary for two weeks. What have you been up to? A photo, a voice message or one sentence is enough." diary_correct_failed: "I could not apply that just now. It is kept in this thread; reply again later to retry." de: @@ -430,9 +429,8 @@ de: diary_card: "\U0001F4D4 **{title}** · {date}" diary_card_corrected: "\u270F\uFE0F **{title}** · {date} (korrigiert)" diary_card_hint: "_Antworte hier, um einen Namen, ein Datum oder eine Angabe zu korrigieren._" - diary_summary: "\u2764\uFE0F {count} neue Erinnerungen im Familientagebuch." - diary_summary_one: "\u2764\uFE0F 1 neue Erinnerung im Familientagebuch." - diary_summary_first: "\u2764\uFE0F {count} neue Erinnerungen stehen im Familientagebuch. Jede hat eine Karte im Thread unter ihrer Nachricht: Antworte dort, um einen Namen, ein Datum oder eine Angabe zu korrigieren." - diary_summary_first_one: "\u2764\uFE0F 1 neue Erinnerung steht im Familientagebuch. Sie hat eine Karte im Thread unter ihrer Nachricht: Antworte dort, um einen Namen, ein Datum oder eine Angabe zu korrigieren." + diary_intro: "\u2764\uFE0F {count} neue Erinnerungen stehen im Familientagebuch. Jede hat eine Karte im Thread unter ihrer Nachricht: Antworte dort, um einen Namen, ein Datum oder eine Angabe zu korrigieren." + diary_intro_one: "\u2764\uFE0F 1 neue Erinnerung steht im Familientagebuch. Sie hat eine Karte im Thread unter ihrer Nachricht: Antworte dort, um einen Namen, ein Datum oder eine Angabe zu korrigieren." diary_open: "Tagebuch öffnen" + diary_nudge: "\U0001F4DD Seit zwei Wochen keine neue Erinnerung im Familientagebuch. Was war los bei euch? Ein Foto, eine Sprachnachricht oder ein Satz reicht." diary_correct_failed: "Das konnte ich gerade nicht übernehmen. Es bleibt in diesem Thread; antworte später noch einmal, um es erneut zu versuchen." diff --git a/tests/unit/stacklets/test_archivist_diary.py b/tests/unit/stacklets/test_archivist_diary.py index 6e8386e0..2cdd162b 100644 --- a/tests/unit/stacklets/test_archivist_diary.py +++ b/tests/unit/stacklets/test_archivist_diary.py @@ -1,8 +1,9 @@ """The archivist in the memories room: quiet, except about the diary. The memories room is the family's own. The archivist posts each diary -card as a notice in a thread on the entry's first message, once a day, -and one notice saying how many memories were added. A reply in a card's +card as a notice in a thread on the entry's first message, once a day. +The first time it adds one notice that says how to correct a card, and +after two weeks without a new memory one notice that invites the next. A reply in a card's thread corrects the card. Anything else the family posts there is theirs and gets no answer: no filing, no search, no welcome, no reaction. @@ -133,15 +134,11 @@ def test_the_first_cards_say_how_to_correct_them(self): assert "Reply here" in diary_room.card_text(_card(), _t_en, hint=True) assert "Reply here" not in diary_room.card_text(_card(), _t_en) - def test_the_summary_explains_itself_the_first_times_then_keeps_to_the_count(self): - first = diary_room.summary_text(3, explain=True, link="http://h/go/topic/diary", t=_t_en) - later = diary_room.summary_text(6, explain=False, link="http://h/go/topic/diary", t=_t_en) - one = diary_room.summary_text(1, explain=False, link="", t=_t_en) + def test_the_introduction_says_how_to_correct_a_card(self): + text = diary_room.summary_text(3, link="http://h/go/topic/diary", t=_t_en) - assert first.startswith("❤️ 3 new memories are in the family diary. Each has a card") - assert first.endswith("[Open the diary](http://h/go/topic/diary)") - assert later == "❤️ 6 new memories in the family diary. [Open the diary](http://h/go/topic/diary)" - assert one == "❤️ 1 new memory in the family diary." + assert text.startswith("❤️ 3 new memories are in the family diary. Each has a card") + assert text.endswith("[Open the diary](http://h/go/topic/diary)") # ── The room ────────────────────────────────────────────────────────── @@ -261,9 +258,10 @@ async def correct(*a): class TestTheDailyJobPostsWhatIsNew: @pytest.mark.asyncio - async def test_the_how_to_is_shown_once_then_only_the_count(self, bot, monkeypatch): + async def test_the_how_to_is_shown_once_then_only_the_cards(self, bot, monkeypatch): """The first announcement introduces the feature and how to correct - a card. After that the count is enough.""" + a card. After that each card in its thread is announcement enough: + a daily count in the room repeats it.""" import json as _json days = iter([ {"room_id": ROOM_ID, "cards": [_card("a", at=2_000_000_000_000)]}, @@ -277,10 +275,9 @@ async def cli(*args, timeout): await bot._diary.run_job(datetime(2026, 9, 24, 7, 30, tzinfo=timezone.utc)) await bot._diary.run_job(datetime(2026, 9, 25, 7, 30, tzinfo=timezone.utc)) - first_card, first_summary, second_card, second_summary = [s[1] for s in bot.sent] + first_card, first_summary, second_card = [s[1] for s in bot.sent] assert "Reply here" in first_card and "card in the thread" in first_summary assert "Reply here" not in second_card - assert second_summary.startswith("❤️ 1 new memory in the family diary.") @pytest.mark.asyncio async def test_cards_go_to_their_threads_and_one_summary_to_the_room(self, bot, monkeypatch): @@ -331,3 +328,91 @@ async def cli(*args, timeout): monkeypatch.setattr(diary_room, "_diary_cli", cli) await bot._diary.run_job(datetime(2026, 9, 24, 7, 30, tzinfo=timezone.utc)) assert bot.sent == [] + + @pytest.mark.asyncio + async def test_the_backlog_is_counted_in_the_introduction_and_never_again( + self, bot, monkeypatch): + """Cards older than the first run are counted, not posted, so their + threads never get a card notice. They must not be counted again + every morning as if they were new.""" + import json as _json + old = int(datetime(2026, 1, 10, tzinfo=timezone.utc).timestamp() * 1000) + report = {"room_id": ROOM_ID, "cards": [_card("old", at=old)]} + + async def cli(*args, timeout): + return 0, _json.dumps(report) + "\n", "" + + monkeypatch.setattr(diary_room, "_diary_cli", cli) + await bot._diary.run_job(datetime(2026, 9, 24, 7, 30, tzinfo=timezone.utc)) + await bot._diary.run_job(datetime(2026, 9, 25, 7, 30, tzinfo=timezone.utc)) + await bot._diary.run_job(datetime(2026, 9, 26, 7, 30, tzinfo=timezone.utc)) + + summaries = [s[1] for s in bot.sent if "family diary" in s[1]] + assert len(summaries) == 1 and summaries[0].startswith("❤️ 1 new memory is") + + +def _ms(day: datetime) -> int: + return int(day.timestamp() * 1000) + + +def _day(month: int, day: int) -> datetime: + return datetime(2026, month, day, 7, 30, tzinfo=timezone.utc) + + +class TestAQuietDiaryGetsOneNudge: + """Two weeks without a new memory get one notice that invites the + next. One per quiet stretch: the next memory starts a new one.""" + + @pytest.fixture + def diary(self, bot, monkeypatch): + """Runs the daily job on a day, with the room holding these cards.""" + import json as _json + held: list[dict] = [] + + async def cli(*args, timeout): + return 0, _json.dumps({"room_id": ROOM_ID, "cards": held}) + "\n", "" + + monkeypatch.setattr(diary_room, "_diary_cli", cli) + + async def run(day, cards): + held[:] = cards + await bot._diary.run_job(day) + return [s[1] for s in bot.sent if "two weeks" in s[1]] + + return run + + @pytest.mark.asyncio + async def test_two_quiet_weeks_get_one_nudge(self, diary): + last = _card("a", posted=True, at=_ms(_day(9, 1))) + assert await diary(_day(9, 14), [last]) == [] + nudges = await diary(_day(9, 15), [last]) + assert nudges == ["📝 No new memory in the family diary for two weeks. " + "What have you been up to? A photo, a voice message " + "or one sentence is enough."] + + @pytest.mark.asyncio + async def test_a_longer_silence_is_not_nudged_again(self, diary): + last = _card("a", posted=True, at=_ms(_day(9, 1))) + await diary(_day(9, 15), [last]) + await diary(_day(9, 16), [last]) + assert len(await diary(_day(10, 20), [last])) == 1 + + @pytest.mark.asyncio + async def test_the_next_memory_starts_a_new_quiet_stretch(self, diary): + first = _card("a", posted=True, at=_ms(_day(9, 1))) + await diary(_day(9, 15), [first]) + later = _card("b", posted=True, at=_ms(_day(9, 20))) + assert len(await diary(_day(10, 3), [first, later])) == 1 + assert len(await diary(_day(10, 4), [first, later])) == 2 + + @pytest.mark.asyncio + async def test_the_introduction_stands_in_for_the_nudge(self, diary): + """A first run over a quiet room introduces the diary. A nudge in + the same breath, or the next morning, says the same thing again.""" + backlog = _card("a", at=_ms(_day(8, 1))) + assert await diary(_day(9, 15), [backlog]) == [] + assert await diary(_day(9, 16), [{**backlog, "posted": False}]) == [] + + @pytest.mark.asyncio + async def test_an_empty_diary_is_not_nudged(self, diary): + assert await diary(_day(9, 15), []) == []