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
2 changes: 1 addition & 1 deletion docs/admin-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

| | |
|---|---|
Expand Down
6 changes: 3 additions & 3 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
61 changes: 45 additions & 16 deletions stacklets/docs/bot/diary_room.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 6 additions & 8 deletions stacklets/docs/bot/messages/archivist.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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."
113 changes: 99 additions & 14 deletions tests/unit/stacklets/test_archivist_diary.py
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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 ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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)]},
Expand All @@ -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):
Expand Down Expand Up @@ -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), []) == []
Loading