From 1c38fd686d684bf9f58e16566017c1b8f43c0555 Mon Sep 17 00:00:00 2001 From: Arthur <516104+arthware@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:25:43 +0200 Subject: [PATCH 01/17] fix(voice): transcribe in the household language A whisper server started with a fixed language decodes every clip as that language, so an English voice message sent to a server set to German came back as a German translation. The transcriber now states the household language (`[core] language`) on every request, cut to the ISO-639-1 code whisper expects. Without a configured language whisper still detects it. A household that records in more than one language gets every clip decoded as its configured language. --- lib/stack/ai/client.py | 16 +++++++-- tests/unit/framework/test_ai_client.py | 47 ++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/lib/stack/ai/client.py b/lib/stack/ai/client.py index 24e3a69d..94a59762 100644 --- a/lib/stack/ai/client.py +++ b/lib/stack/ai/client.py @@ -450,12 +450,18 @@ class Transcriber: """ def __init__(self, client: AsyncOpenAI, *, namespace: str | None = None, - model: str = _DEFAULT_WHISPER_MODEL): + model: str = _DEFAULT_WHISPER_MODEL, + language: str | None = None): self._client = client self.namespace = namespace # The speech model to ask for. OpenAI's is `whisper-1`; other # servers name theirs and refuse any other name. self.model = model + # The language the household speaks, as ISO-639-1, or None to let + # whisper detect it. A server started with a fixed language + # decodes every clip as that language, translating anything else, + # so the request states it. + self.language = language @classmethod def from_env(cls, *, namespace: str | None = None, @@ -472,6 +478,9 @@ def from_env(cls, *, namespace: str | None = None, would silently fall back to ``api.openai.com``, which for a privacy-first family server is the wrong default to ever reach by accident. + + ``LANGUAGE`` (the household language, rendered by core) becomes + the language stated on every request; unset, whisper detects it. """ url = os.environ.get("WHISPER_URL", "").rstrip("/") if url.endswith("/audio/transcriptions"): @@ -484,8 +493,10 @@ def from_env(cls, *, namespace: str | None = None, # on *some* key — same trick as LLM.from_env. key = os.environ.get("WHISPER_KEY", "") or "not-needed" client = AsyncOpenAI(base_url=url, api_key=key, max_retries=max_retries) + language = os.environ.get("LANGUAGE", "").strip().lower()[:2] or None return cls(client, namespace=namespace, - model=os.environ.get("WHISPER_MODEL", "") or _DEFAULT_WHISPER_MODEL) + model=os.environ.get("WHISPER_MODEL", "") or _DEFAULT_WHISPER_MODEL, + language=language) async def transcribe(self, audio: bytes, *, filename: str = "voice.ogg", model: str | None = None, @@ -625,6 +636,7 @@ async def _stt(self, audio: bytes, *, filename: str, file=(filename, audio), **params, **({"prompt": vocabulary} if vocabulary.strip() else {}), + **({"language": self.language} if self.language else {}), ) except openai.APITimeoutError as e: raise LLMTimeoutError( diff --git a/tests/unit/framework/test_ai_client.py b/tests/unit/framework/test_ai_client.py index 295cd7f4..9260013b 100644 --- a/tests/unit/framework/test_ai_client.py +++ b/tests/unit/framework/test_ai_client.py @@ -547,6 +547,53 @@ def handler(request): assert b"whisper-large-v3-turbo" in captured["body"] await tr.aclose() + async def test_the_household_language_tells_whisper_what_it_hears( + self, httpserver: HTTPServer, monkeypatch): + """A whisper server started with a fixed language decodes every + clip as that language: English audio against a German server + comes back as a German translation. Stating the household's + language on each request is what makes the transcript the words + that were spoken. Whisper takes an ISO-639-1 code, so a regional + form such as `de_DE` is cut to its language.""" + captured: dict = {} + + def handler(request): + captured["form"] = dict(request.form) + from werkzeug import Response + return Response('{"text": "ok"}', content_type="application/json") + + httpserver.expect_request( + "/v1/audio/transcriptions", method="POST").respond_with_handler(handler) + monkeypatch.setenv("WHISPER_URL", httpserver.url_for("/v1")) + monkeypatch.setenv("LANGUAGE", "de_DE") + + tr = Transcriber.from_env() + await tr.transcribe(b"fake-audio") + + assert captured["form"].get("language") == "de" + await tr.aclose() + + async def test_without_a_household_language_whisper_detects_it( + self, httpserver: HTTPServer, monkeypatch): + """No configured language leaves detection to whisper, as before.""" + captured: dict = {} + + def handler(request): + captured["form"] = dict(request.form) + from werkzeug import Response + return Response('{"text": "ok"}', content_type="application/json") + + httpserver.expect_request( + "/v1/audio/transcriptions", method="POST").respond_with_handler(handler) + monkeypatch.setenv("WHISPER_URL", httpserver.url_for("/v1")) + monkeypatch.delenv("LANGUAGE", raising=False) + + tr = Transcriber.from_env() + await tr.transcribe_verbose(b"fake-audio") + + assert "language" not in captured["form"] + await tr.aclose() + def test_without_a_configured_model_it_asks_for_openais_name(self, monkeypatch): monkeypatch.setenv("WHISPER_URL", "http://whisper.local/v1") monkeypatch.delenv("WHISPER_MODEL", raising=False) From aa1de2871a1c7da54aa1e6c2f1c32f4d3407e6cc Mon Sep 17 00:00:00 2001 From: Arthur <516104+arthware@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:25:44 +0200 Subject: [PATCH 02/17] test: render the memories corpus with any speech endpoint `FAMSTACK_TTS_URL` points `tools/family-memories/generate.py` at an OpenAI-compatible speech service other than the ai stacklet's, for a Mac that does not run it. --- tools/family-memories/generate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/family-memories/generate.py b/tools/family-memories/generate.py index 2deb0e42..8e530cba 100644 --- a/tools/family-memories/generate.py +++ b/tools/family-memories/generate.py @@ -20,6 +20,7 @@ import argparse import io import json +import os import sys import urllib.request import wave @@ -29,7 +30,11 @@ HERE = Path(__file__).resolve().parent OUT = HERE / "out" -TTS_URL = "http://localhost:42063/v1/audio/speech" +# The ai stacklet's speech service by default. FAMSTACK_TTS_URL points it +# at another OpenAI-compatible speech endpoint, for a Mac without the ai +# stacklet running. +TTS_URL = os.environ.get("FAMSTACK_TTS_URL", + "http://localhost:42063/v1/audio/speech") TURN_GAP_MS = 400 From 41af4db037ef5257e17d3cc4755ca6a1f5fcb63e Mon Sep 17 00:00:00 2001 From: Arthur <516104+arthware@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:31:33 +0200 Subject: [PATCH 03/17] refactor: share the briefing callout between vault writers The `> [!summary]` block with summary, facts and action items is the shape every model-read vault record carries. It lived in the archivist's `vault_entry`, where the diary records that come next could only reach it by importing across stacklets. It moves to `stack.briefing`, which the archivist now imports. No behaviour change. --- lib/stack/briefing.py | 107 ++++++++++++++++++++++++++ stacklets/docs/bot/git_mirror.py | 6 +- stacklets/docs/bot/vault_entry.py | 100 +----------------------- stacklets/memory/bot/cli/todo_list.py | 2 +- 4 files changed, 115 insertions(+), 100 deletions(-) create mode 100644 lib/stack/briefing.py diff --git a/lib/stack/briefing.py b/lib/stack/briefing.py new file mode 100644 index 00000000..c8f7ae4c --- /dev/null +++ b/lib/stack/briefing.py @@ -0,0 +1,107 @@ +"""The briefing callout: summary, facts and action items on a vault page. + +Every record in the vault that a model has read carries the same block +under its title, a `> [!summary]` callout holding the prose summary, the +facts and the action items. Documents, captures, emails and diary +records all render it here, so search, the agent and Obsidian read one +shape whoever wrote the page (docs/design/brain/content-pattern.md). + +Stdlib only: the host CLI and every container import it. +""" + +from __future__ import annotations + + +def render_briefing( + *, + summary: str | None, + facts: list | None, + action_items: list | None, + source_link: tuple[str, str] | None = None, +) -> str: + """Render the briefing as a ``> [!summary]`` callout. + + Sections are conditional: an empty prose summary, empty facts, + or empty action items all drop out. When everything is empty the + callout itself is suppressed — no stale ``> [!summary]`` shell. + + ``source_link`` is ``(label, url)``; when both are non-empty it + renders as ``[label](url)`` directly under the prose. + + Args: + summary: Prose summary text. + facts: List of fact strings. + action_items: List of action item dicts or strings. + source_link: ``(label, url)`` tuple for a source link. + + Returns: + Briefing callout string, or "" when all sections are empty. + """ + sections: list[str] = [] + + if summary and isinstance(summary, str) and summary.strip(): + sections.append(summary.strip()) + + if source_link: + label, url = source_link + if label and url: + sections.append(f"[{label}]({url})") + + fact_rows = fact_lines(facts or []) + if fact_rows: + sections.append("**Facts**\n" + "\n".join(fact_rows)) + + task_lines = action_item_lines(action_items or []) + if task_lines: + sections.append("**Action items**\n" + "\n".join(task_lines)) + + if not sections: + return "" + + inner = "\n\n".join(sections) + lines = ["> [!summary]"] + for ln in inner.split("\n"): + lines.append(f"> {ln}" if ln else ">") + return "\n".join(lines) + + +def fact_lines(facts: list) -> list[str]: + out = [] + for f in facts: + if isinstance(f, str) and f.strip(): + out.append(f"- {f.strip()}") + return out + + +def action_item_lines(items: list) -> list[str]: + out: list[str] = [] + for ai in items: + line = format_action_item(ai) + if line: + out.append(line) + return out + + +def format_action_item(ai) -> str | None: + """``{action, due}`` → ``- [ ] action — YYYY-MM-DD`` or ``- [ ] action``. + + Args: + ai: Action item as a dict (with "action" and optional "due") + or a plain string. + + Returns: + Formatted checkbox line, or None when the item is empty/invalid. + """ + if isinstance(ai, str): + return f"- [ ] {ai.strip()}" if ai.strip() else None + if not isinstance(ai, dict): + return None + action = (ai.get("action") or "").strip() + if not action: + return None + due = ai.get("due") + if isinstance(due, str): + due_clean = due.strip() + if due_clean and due_clean.lower() not in ("null", "none", "n/a"): + return f"- [ ] {action} — {due_clean}" + return f"- [ ] {action}" diff --git a/stacklets/docs/bot/git_mirror.py b/stacklets/docs/bot/git_mirror.py index 36be69f5..6a3b2775 100644 --- a/stacklets/docs/bot/git_mirror.py +++ b/stacklets/docs/bot/git_mirror.py @@ -69,8 +69,8 @@ render_capture, render_email_message_section, fold_email_message, - _format_action_item, ) +from stack.briefing import format_action_item # noqa: E402 # The shared family knowledge vault. The memory stacklet creates and @@ -503,7 +503,7 @@ def _action_item_texts(self, action_items: list | None) -> list[str]: """Action items in the same text form rendered into capture callouts.""" texts: list[str] = [] for item in action_items or []: - line = _format_action_item(item) + line = format_action_item(item) if not line: continue texts.append(line.removeprefix("- [ ] ").strip()) @@ -687,7 +687,7 @@ async def publish( # `summary` parameter on `publish()` is the multi-section # Markdown that goes into Paperless and the commit body — it # already contains its own `## Summary` / `## Facts` headings, - # so feeding it into `_briefing_block` (which would wrap it in + # so feeding it into `render_briefing` (which would wrap it in # ANOTHER `## Summary`) double-nests headings and duplicates the # facts list. The briefing block expects prose; that lives on # `classification["summary"]`. diff --git a/stacklets/docs/bot/vault_entry.py b/stacklets/docs/bot/vault_entry.py index ed0afd55..00f10b35 100644 --- a/stacklets/docs/bot/vault_entry.py +++ b/stacklets/docs/bot/vault_entry.py @@ -49,6 +49,7 @@ # so the memory wiki and this docs archivist share one source. from stack.vault import slug, entity_relpath # noqa: F401 (slug re-exported for callers/tests) from stack.email_message import defang_links +from stack.briefing import render_briefing def document_filepath( @@ -414,7 +415,7 @@ def render_document( parts.append("> " + " · ".join(bits)) parts.append("") - briefing = _briefing_block( + briefing = render_briefing( summary=summary, facts=facts, action_items=action_items, source_link=source_link, ) @@ -514,7 +515,7 @@ def render_capture( # Briefing — summary + facts, plus action items when the note carried # any (bookmarks pass None, so nothing changes for them). - briefing = _briefing_block( + briefing = render_briefing( summary=summary, facts=facts, action_items=action_items, ) if briefing: @@ -598,7 +599,7 @@ def render_email_message_section( summary = defang_links(summary) if summary else summary facts = [defang_links(f) if isinstance(f, str) else f for f in facts] if facts else facts - briefing = _briefing_block( + briefing = render_briefing( summary=summary, facts=facts, action_items=action_items, ) if briefing: @@ -757,96 +758,3 @@ def fold_email_message( # in Obsidian and remain Tasks-plugin-queryable). -def _briefing_block( - *, - summary: str | None, - facts: list | None, - action_items: list | None, - source_link: tuple[str, str] | None = None, -) -> str: - """Render the briefing as a ``> [!summary]`` callout. - - Sections are conditional: an empty prose summary, empty facts, - or empty action items all drop out. When everything is empty the - callout itself is suppressed — no stale ``> [!summary]`` shell. - - ``source_link`` is ``(label, url)``; when both are non-empty it - renders as ``[label](url)`` directly under the prose. - - Args: - summary: Prose summary text. - facts: List of fact strings. - action_items: List of action item dicts or strings. - source_link: ``(label, url)`` tuple for a source link. - - Returns: - Briefing callout string, or "" when all sections are empty. - """ - sections: list[str] = [] - - if summary and isinstance(summary, str) and summary.strip(): - sections.append(summary.strip()) - - if source_link: - label, url = source_link - if label and url: - sections.append(f"[{label}]({url})") - - fact_lines = _fact_lines(facts or []) - if fact_lines: - sections.append("**Facts**\n" + "\n".join(fact_lines)) - - task_lines = _action_item_lines(action_items or []) - if task_lines: - sections.append("**Action items**\n" + "\n".join(task_lines)) - - if not sections: - return "" - - inner = "\n\n".join(sections) - lines = ["> [!summary]"] - for ln in inner.split("\n"): - lines.append(f"> {ln}" if ln else ">") - return "\n".join(lines) - - -def _fact_lines(facts: list) -> list[str]: - out = [] - for f in facts: - if isinstance(f, str) and f.strip(): - out.append(f"- {f.strip()}") - return out - - -def _action_item_lines(items: list) -> list[str]: - out: list[str] = [] - for ai in items: - line = _format_action_item(ai) - if line: - out.append(line) - return out - - -def _format_action_item(ai) -> str | None: - """``{action, due}`` → ``- [ ] action — YYYY-MM-DD`` or ``- [ ] action``. - - Args: - ai: Action item as a dict (with "action" and optional "due") - or a plain string. - - Returns: - Formatted checkbox line, or None when the item is empty/invalid. - """ - if isinstance(ai, str): - return f"- [ ] {ai.strip()}" if ai.strip() else None - if not isinstance(ai, dict): - return None - action = (ai.get("action") or "").strip() - if not action: - return None - due = ai.get("due") - if isinstance(due, str): - due_clean = due.strip() - if due_clean and due_clean.lower() not in ("null", "none", "n/a"): - return f"- [ ] {action} — {due_clean}" - return f"- [ ] {action}" diff --git a/stacklets/memory/bot/cli/todo_list.py b/stacklets/memory/bot/cli/todo_list.py index 452f0003..e98eedaf 100644 --- a/stacklets/memory/bot/cli/todo_list.py +++ b/stacklets/memory/bot/cli/todo_list.py @@ -3,7 +3,7 @@ One vocabulary across the stack: **action_items** are the extracted concept (documents already produce them; a note announced as a list produces them too), and **todos** are those action_items *transformed* into rendered, tickable -`- [ ]` lines (`_format_action_item` in vault_entry.py is that transform). This +`- [ ]` lines (`format_action_item` in `stack.briefing` is that transform). This module is the note half of the extraction plus the `todos.md` surface. A list is something a family member *wrote on purpose* — a note whose first line From c0081cdf373e1ba87b3e664aa6c45e2f3a1c8a5d Mon Sep 17 00:00:00 2001 From: Arthur <516104+arthware@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:04:49 +0200 Subject: [PATCH 04/17] feat(diary): keep every diary entry as a card in the memory vault The diary existed only as generated wiki pages, so a wrong name or date could not be corrected: the next compile wrote the page again. Each entry now becomes a card of `type: diary` in the vault, at `family/diary/entries/YYYY/MM/YYYY-MM-DD-.md`, shaped like a captured note: the words as recorded, the files, and a title, summary, facts, people and topics read out of the entry by the configured model. The diary pages are compiled from the cards alone. - A card someone edited in Forgejo or Obsidian is never overwritten. `stack memory diary` lists it as kept, and the pages show the edit. - Tags use the ontology's topics and `Person: `, as documents do. A name that is not a household member is dropped. - Cards are committed in one commit per sender, authored by them. A file changed in Forgejo since it was read fails the commit instead of being overwritten. An unchanged room commits nothing. - A card whose messages were deleted in the room is removed, but only after a run that read the whole room (not with `--limit`). - The cards stay in the brain for search and the agent; the wiki does not publish `diary/entries/`. --- docs/design/brain/vault-format.md | 37 ++ lib/stack/briefing.py | 39 ++ lib/stack/forgejo.py | 60 ++- lib/stack/frontmatter.py | 6 +- stacklets/memory/bot/cli/diary.py | 400 ++++++++++++-- stacklets/memory/bot/diary.py | 40 +- stacklets/memory/bot/diary_card.py | 508 ++++++++++++++++++ stacklets/memory/bot/diary_store.py | 19 + stacklets/memory/cli/diary.py | 20 +- stacklets/memory/quartz/quartz.config.ts | 6 +- .../framework/test_forgejo_change_files.py | 86 +++ .../unit/stacklets/test_memory_diary_card.py | 326 +++++++++++ 12 files changed, 1472 insertions(+), 75 deletions(-) create mode 100644 stacklets/memory/bot/diary_card.py create mode 100644 tests/unit/framework/test_forgejo_change_files.py create mode 100644 tests/unit/stacklets/test_memory_diary_card.py diff --git a/docs/design/brain/vault-format.md b/docs/design/brain/vault-format.md index 923e1cd9..5cce6785 100644 --- a/docs/design/brain/vault-format.md +++ b/docs/design/brain/vault-format.md @@ -79,6 +79,8 @@ cheap). - `note` — a captured note (verbatim paste). - `bookmark` — a captured URL. - `email` — a folded email thread. +- `diary` — one entry from the memories room (a recording, photo or note, + with its joined fragments and replies). **Entities & structure** (generated projections; MUST carry `generated: true`): @@ -135,6 +137,41 @@ message is preceded by an idempotency marker comment `` in the body (not frontmatter). `resource` may carry the mailbox/thread reference. +### `diary` +One file per entry at +`/diary/entries/YYYY/MM/YYYY-MM-DD-.md`. +The diary pages in brain are compiled from these files and nothing +else, so a correction made here reaches every page. +The records are mirrored into brain like every source file, so search +and the agent read them, but the wiki does not publish `diary/entries/`. + +| Field | | Notes | +|---|---|---| +| `type` | R | `diary` | +| `title` | R | | +| `timestamp` | R | when the entry's first message reached the room. | +| `date` | R | the day it happened (spoken date, else the day it was sent). | +| `entry_id` | R | short hash of the first message's event id; the path's identity. | +| `date_basis` | O | `spoken` \| `sent` \| `uncertain`. | +| `description` | O | one sentence. | +| `persons` | O | list. | +| `tags` | O | list. | +| `filed_by` | O | Matrix localpart of the sender. | +| `addressee` | O | who the message is spoken to, as it names them. | +| `medium` | O | `voice` \| `image` \| `video` \| `file` \| `text`. | +| `mode` | O | `monologue` \| `dialogue` \| `note`. | +| `duration_ms` | O | integer. | +| `resource` | O | Matrix permalink of the first message. | +| `event_ids` | O | list; every message folded into the entry. | +| `media` | O | list; site paths of the archived files. | +| `model` | O | | +| `digest` | O | hash of the file as the compiler wrote it. A file that no longer matches was edited by a person, and the compiler leaves it alone. | + +The body holds the briefing callout, then `## Quotes`, `## Replies`, +and last the family's own words under `## Transcript`, `## Text` or +`## Caption`. The words come last so nothing in them can be mistaken +for a section. + ### `person` (generated) | Field | | Notes | |---|---|---| diff --git a/lib/stack/briefing.py b/lib/stack/briefing.py index c8f7ae4c..277fab7e 100644 --- a/lib/stack/briefing.py +++ b/lib/stack/briefing.py @@ -105,3 +105,42 @@ def format_action_item(ai) -> str | None: if due_clean and due_clean.lower() not in ("null", "none", "n/a"): return f"- [ ] {action} — {due_clean}" return f"- [ ] {action}" + + +def read_briefing(text: str) -> tuple[str, list[str]]: + """The summary and facts of the first briefing callout in `text`. + + The inverse of `render_briefing` for the two parts a reader needs + back. A page a person edited is read the same way, so a fact they + corrected in the callout is the fact that comes back. Action items + are left out: they live on in the todo lists they were folded into. + Returns ``("", [])`` when the page has no briefing. + """ + lines = text.splitlines() + try: + start = next(i for i, ln in enumerate(lines) + if ln.strip() == "> [!summary]") + except StopIteration: + return "", [] + + inner: list[str] = [] + for ln in lines[start + 1:]: + if not ln.startswith(">"): + break + inner.append(ln[2:] if ln.startswith("> ") else ln[1:]) + + summary_parts: list[str] = [] + facts: list[str] = [] + for section in "\n".join(inner).split("\n\n"): + section = section.strip() + if not section: + continue + head, _, rest = section.partition("\n") + if head == "**Facts**": + facts = [ln[2:].strip() for ln in rest.splitlines() + if ln.startswith("- ") and ln[2:].strip()] + elif head.startswith("**"): + continue + elif not (section.startswith("[") and section.endswith(")")): + summary_parts.append(section) + return "\n\n".join(summary_parts), facts diff --git a/lib/stack/forgejo.py b/lib/stack/forgejo.py index 3bf6b4ce..c695334d 100644 --- a/lib/stack/forgejo.py +++ b/lib/stack/forgejo.py @@ -34,6 +34,21 @@ class ForgejoError(RuntimeError): """HTTP error from Forgejo. Message carries status + response body.""" +@dataclass(frozen=True) +class FileChange: + """One file in a multi-file commit (`ForgejoClient.change_files`). + + `operation` is `create`, `update` or `delete`. `sha` is the blob hash + the caller read the file at; Forgejo refuses the commit when the file + has changed since, so a batch never overwrites an edit it did not see. + """ + + operation: str + path: str + content: str = "" + sha: str = "" + + @dataclass class ForgejoClient: url: str @@ -55,6 +70,12 @@ def _token_header(self) -> dict[str, str]: raise ForgejoError("token not configured") return {"Authorization": f"token {self.token}"} + def _content_header(self) -> dict[str, str]: + """Auth for the contents API: the token when there is one, else the + admin account. Containers that run batch writers hold the stack + admin credentials but not a per-stacklet token.""" + return self._token_header() if self.token else self._admin_header() + def _basic_for(self, username: str, password: str) -> dict[str, str]: creds = f"{username}:{password}".encode() return {"Authorization": "Basic " + base64.b64encode(creds).decode()} @@ -289,7 +310,7 @@ def get_file(self, owner: str, repo: str, path: str, data = self._req( "GET", f"/api/v1/repos/{owner}/{repo}/contents/{urllib.parse.quote(path)}", - headers=self._token_header(), + headers=self._content_header(), params={"ref": ref}, allow_404=True, ) @@ -373,6 +394,43 @@ def delete_file(self, owner: str, repo: str, path: str, *, body={"sha": sha, "message": message, "branch": branch}, ) + def change_files(self, owner: str, repo: str, changes: "list[FileChange]", *, + message: str, branch: str = "main", + author_name: str | None = None, + author_email: str | None = None) -> dict: + """Create, update and delete several files in one commit. + + All or nothing: when any update or delete names a `sha` the file + no longer has, Forgejo rejects the commit and nothing is written. + """ + files: list[dict[str, Any]] = [] + for change in changes: + entry: dict[str, Any] = {"operation": change.operation, "path": change.path} + if change.operation != "delete": + entry["content"] = base64.b64encode(change.content.encode("utf-8")).decode() + if change.sha: + entry["sha"] = change.sha + files.append(entry) + payload: dict[str, Any] = {"files": files, "message": message, "branch": branch} + if author_name and author_email: + payload["author"] = {"name": author_name, "email": author_email} + payload["committer"] = {"name": author_name, "email": author_email} + return self._req("POST", f"/api/v1/repos/{owner}/{repo}/contents", + headers=self._content_header(), body=payload) + + def list_dir(self, owner: str, repo: str, path: str, + ref: str = "main") -> list[dict]: + """The entries of one directory, each with `type`, `path` and `sha`. + + Not recursive. Empty when the directory does not exist. + """ + data = self._req( + "GET", + f"/api/v1/repos/{owner}/{repo}/contents/{urllib.parse.quote(path)}", + headers=self._content_header(), params={"ref": ref}, allow_404=True, + ) + return data if isinstance(data, list) else [] + def list_tree(self, owner: str, repo: str, ref: str = "main") -> list[dict]: """Full recursive tree of `owner/repo` at `ref` — used to rebuild the archivist's paperless_id → path cache after it's wiped.""" diff --git a/lib/stack/frontmatter.py b/lib/stack/frontmatter.py index 3c0ae9b4..7f00cf61 100644 --- a/lib/stack/frontmatter.py +++ b/lib/stack/frontmatter.py @@ -27,7 +27,7 @@ class FrontmatterError(ValueError): # ── Type vocabulary (§4) ────────────────────────────────────────────── # Records: source records (not generated) -RECORD_TYPES = {"document", "note", "bookmark", "email"} +RECORD_TYPES = {"document", "note", "bookmark", "email", "diary"} # Projections: generated wiki pages PROJECTION_TYPES = {"person", "correspondent", "topic", "index"} @@ -63,6 +63,10 @@ class TypeSchema: required={"type", "title", "timestamp"}, list_fields={"persons", "tags"}, ), + "diary": TypeSchema( + required={"type", "title", "timestamp", "date", "entry_id"}, + list_fields={"persons", "tags", "event_ids", "media"}, + ), "person": TypeSchema( required={"type", "generated", "title", "slug", "canonical"}, list_fields={"aliases"}, diff --git a/stacklets/memory/bot/cli/diary.py b/stacklets/memory/bot/cli/diary.py index 352b01bf..a083e195 100644 --- a/stacklets/memory/bot/cli/diary.py +++ b/stacklets/memory/bot/cli/diary.py @@ -46,6 +46,7 @@ from __future__ import annotations import asyncio +import hashlib import json import os import re @@ -67,14 +68,30 @@ # bot-runner has it baked at /app; the curator, which schedules this # nightly, only mounts /stacklets. Both see it here. sys.path.append("/stacklets/core/bot-runner") +# `memory.lib`, the memory stacklet's own vault readers (ontology, person +# pages, the repository's name), as the archivist imports them. +sys.path.append("/stacklets") import diary # noqa: E402 +import diary_card # noqa: E402 import diary_store # noqa: E402 import voice # noqa: E402 from stack.ai.client import LLMError, Transcriber # noqa: E402 +from stack.forgejo import FileChange, ForgejoClient, ForgejoError # noqa: E402 from stack.ai import transcripts # noqa: E402 from stack import media # noqa: E402 +from memory.lib import ( # noqa: E402 + BOT_EMAIL, + BOT_USERNAME, + REPO_NAME, + REPO_OWNER, + get_ontology, + load_persons_from_vault, + persons_prompt_section, + request_mirror, +) + from . import wiki # noqa: E402 HELP = "Compile the memories room into the family diary" @@ -413,9 +430,7 @@ async def produce() -> dict: # One small JSON object per message in the slice, plus the enclosing # structure. A model that loops instead of closing the array is capped # here rather than at the client timeout. -# The budget covers the facts plus the distillation: a gist sentence -# and up to three copied passages for long messages. -_READ_TOKENS_PER_MESSAGE = 240 +_READ_TOKENS_PER_MESSAGE = 120 _READ_TIMEOUT_S = 300.0 # Two to four sentences. @@ -474,28 +489,6 @@ async def produce() -> dict: recording from weeks ago and is still its own memory, not a footnote to it. Use this only when the message would make no sense on its own page. Otherwise null. - -"gist": for a message longer than about 100 words: one or two short - sentences in {language} saying what the message is about. Write - with verbs, as things that happened, never as a list of topics. - Name at most two moments and let the rest go; the full text sits - below the gist on the page. You may name a conversation participant - only when the words address them by name. No marketing words. Use - null for shorter messages, and for a message spoken to one person - as a personal message -- those are posted whole. - - Wanted register (invented examples, not this family): - "Anna erzählt mit einem der Kinder vom Tag am See und vom - Schuh, der im Wasser landete." - "Anna and one of the kids talk about the day at the lake and the - shoe that landed in the water." - Not wanted: "Anna und ein Kind berichten über einen Ausflug, - ein Picknick, einen verlorenen Schuh und das Wetter." - -"moments": for a message longer than about 100 words: up to three - short passages copied word-for-word from the message, the lines most - worth keeping. Copy them exactly as written, complete sentences - only, no edits. Otherwise an empty list. """ @@ -559,28 +552,11 @@ async def _read_room(messages, llm, cache=None): refers_to: dict[str, str] = {} known: set[str] = set() - def _sure_moments(event_id: str, moments) -> tuple: - """Drop claimed quotes that contain a word whisper was unsure - of. A quote is the most prominent text in an entry; it must - not showcase a word the recognizer flagged.""" - record = voice.TRANSCRIPTS.read(event_id) or {} - unsure = {(w.get("word") or "").strip().lower() - for w in (record.get("quality") or {}).get("low_words") or []} - unsure.discard("") - if not unsure: - return tuple(moments or ()) - return tuple( - m for m in moments or () - if not (set(re.findall(r"[\\w\\u00c0-\\u024f]+", str(m).lower())) - & unsure)) - def remember(event_id: str, row: dict) -> None: readings[event_id] = diary.Reading( mode=str(row.get("mode") or "monologue"), spoken_date=row.get("spoken_date") or None, addressee=row.get("addressee") or None, - gist=row.get("gist") or None, - moments=_sure_moments(event_id, row.get("moments")), ) if target := row.get("continues"): continues[event_id] = target @@ -604,8 +580,7 @@ def remember(event_id: str, row: dict) -> None: continue _err(f" reading slice {n} of {len(slices)}") - prompt = _READ_PROMPT.format( - language=_household_language(), messages=_as_prompt(chunk)) + prompt = _READ_PROMPT.format(messages=_as_prompt(chunk)) try: raw = await llm.complete( "classifier", prompt, json_mode=True, temperature=0, @@ -632,10 +607,6 @@ def remember(event_id: str, row: dict) -> None: "addressee": row.get("addressee") or None, "continues": _link(row.get("continues"), chunk), "refers_to": _link(row.get("refers_to"), chunk), - "gist": (str(row.get("gist")).strip() - if row.get("gist") else None), - "moments": [str(m) for m in row.get("moments") or [] - if str(m).strip()][:5], } remember(here.event_id, found) if cache is not None: @@ -831,6 +802,268 @@ async def _summarise(entries, llm) -> str: return cleaned +# ── Reading an entry for its card ───────────────────────────────────── +# +# One call per entry, after the room has been read and the entries +# compiled: the entry is the unit a card describes, so it is the unit the +# model reads. A recording joined from two uploads is read once, whole, +# with its replies. What comes back is held to the household's vocabulary +# (`diary_card.extraction_from`) before it touches a card. + + +_EXTRACT_TOKENS = 900 +_EXTRACT_TIMEOUT_S = 180.0 + +_EXTRACT_PROMPT = """\ +You are filing one entry from a family's private memories room into the +family archive. Report what the entry says. Never translate its words, +never add to them. When you quote, copy the words exactly. + +{vocabulary} + +{people} + +The entry: +{entry} + +Reply with a JSON object with these keys: + +"title": a short name for the entry, three to six words, in {language}. + Name what happened, not the medium: "Bart's first bike ride", not + "Voice note". + +"description": one sentence in {language} saying what this entry is. + +"summary": one to three short sentences in {language} saying what the + entry is about. Write with verbs, as things that happened, never as a + list of topics. You may name a conversation participant only when the + words address them by name. No marketing words. + Wanted register (invented examples, not this family): + "Anna erzählt mit einem der Kinder vom Tag am See und vom Schuh, + der im Wasser landete." + "Anna and one of the kids talk about the day at the lake and the + shoe that landed in the water." + Not wanted: "Anna und ein Kind berichten über einen Ausflug, ein + Picknick, einen verlorenen Schuh und das Wetter." + +"facts": up to six short facts from the entry, in {language}, each + written as "Label: value" and each anchored on a name, a date, a + number or a place. A sentence without such an anchor is summary, not + a fact. An empty list when the entry states none. + +"persons": the family members the entry is about or spoken to, using + the names from the family list above. Only names from that list. + +"topics": up to three topics from the vocabulary above that fit the + entry. Only topics from that vocabulary. An empty list when none fits. + +"quotes": when the entry's words are longer than about 100 words: up to + three short passages copied word for word from them, complete + sentences only, the lines most worth keeping. Otherwise an empty list. +""" + + +def _entry_prompt(entry) -> str: + """One entry as the model reads it: who, when, what, and the words.""" + when = ("date unknown" if entry.confidence == "uncertain" + else entry.on.isoformat()) + kind = {"voice": "voice recording", "image": "photo", "video": "video", + "file": "file"}.get(entry.kind, "written note") + if entry.kind == "voice" and entry.mode == "dialogue": + kind = "recorded conversation (no speaker labels)" + lines = [f"Recorded by {entry.sender.title()}, {when}, {kind}."] + if entry.addressee: + lines.append(f"Spoken to: {entry.addressee}.") + lines.append("") + lines.append(entry.body.strip() or "(no words)") + for who, text in entry.comments: + lines += ["", f"{who.title()} replied: {text.strip()}"] + return "\n".join(lines) + + +def _unsure_words(event_ids) -> set[str]: + """Words whisper flagged as unsure in any of these recordings.""" + unsure: set[str] = set() + for event_id in event_ids: + record = voice.TRANSCRIPTS.read(event_id) or {} + for w in (record.get("quality") or {}).get("low_words") or []: + if word := (w.get("word") or "").strip().lower(): + unsure.add(word) + return unsure + + +def _sure_quotes(quotes, event_ids) -> list[str]: + """Drop quotes holding a word whisper was unsure of. + + A quote is the most prominent text on a diary page; it must not + showcase a word the recognizer flagged. + """ + unsure = _unsure_words(event_ids) + if not unsure: + return list(quotes) + return [q for q in quotes + if not set(re.findall(r"[\wÀ-ɏ]+", q.lower())) & unsure] + + +async def _extract_all(entries, llm, cache, *, ontology, people: dict, + vocabulary: str, people_section: str, + language_code: str, force: bool = False) -> list: + """What a model reads out of each entry, cached per entry. + + An entry the model fails on still gets an empty extraction: its card + is written with a plain title and the family's words, and a later run + fills in the rest once the entry's words or the prompt change. + """ + model = os.environ.get("AI_DEFAULT_MODEL", "") + + # The whole prompt is the cache key, not only the entry: a topic added + # to the ontology or a new person page changes what the model is + # offered, and every entry is read again under the new vocabulary. + def prompt_for(entry) -> str: + return _EXTRACT_PROMPT.format( + vocabulary=vocabulary, people=people_section or "", + entry=_entry_prompt(entry), language=_household_language()) + + out = [] + todo = len(entries) if force else sum( + 1 for e in entries + if cache.get(diary_card.entry_id_for(e.event_ids[0]), + prompt_for(e)) is None) + if todo: + _err(f" reading {todo} entr{'y' if todo == 1 else 'ies'} for their cards") + for entry in entries: + entry_id = diary_card.entry_id_for(entry.event_ids[0]) + prompt = prompt_for(entry) + raw = None if force else cache.get(entry_id, prompt) + if raw is None: + try: + answer = await llm.complete( + "classifier", prompt, json_mode=True, temperature=0, + max_tokens=_EXTRACT_TOKENS, timeout=_EXTRACT_TIMEOUT_S) + raw = json.loads(answer) + except (LLMError, json.JSONDecodeError, TypeError) as e: + _err(f" could not read the entry {entry_id}: {e}") + raw = None + if isinstance(raw, dict): + cache.put(entry_id, raw, prompt) + cache.save() + extraction = diary_card.extraction_from( + raw, ontology=ontology, language=language_code, people=people, + model=model) + out.append(diary_card.Extraction(**{ + **extraction.__dict__, + "quotes": _sure_quotes(extraction.quotes, entry.event_ids)})) + return out + + +# ── The vault ───────────────────────────────────────────────────────── +# +# Cards are records, so they go to the memory vault in Forgejo, the same +# repository documents and captures are filed into. What is on disk in +# the local clone can be behind Forgejo (a person just corrected a card +# in the web editor), and deciding "nobody touched this" from a stale +# copy would overwrite that correction. So the listing comes from +# Forgejo, the local copy is used only where its blob hash matches, and +# every write names the hash it expects: a file that changed in between +# fails the commit instead of losing the change. + + +# Files per commit. A first compile over years of a room writes hundreds +# of cards; batches keep each request a modest size. +_COMMIT_BATCH = 100 + + +def _vault_client(): + url = os.environ.get("CODE_URL", "").rstrip("/") + user = os.environ.get("MATRIX_ADMIN_USER", "") + password = os.environ.get("MATRIX_ADMIN_PASSWORD", "") + if not (url and user and password): + return None + return ForgejoClient(url=url, admin_user=user, admin_password=password, + timeout=60) + + +def _blob_sha(data: bytes) -> str: + """The hash git gives a file's content, as Forgejo reports it.""" + return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest() + + +def _read_diary_tree(client, bucket: str) -> "dict[str, tuple[str, str]]": + """Every diary record in the vault: path -> (blob hash, content).""" + vault = Path(os.environ.get("MEMORY_VAULT_DIR", "")) + root = f"{bucket}/{diary.DIARY_DIR}/{diary_card.ENTRIES_DIR}" + found: dict[str, tuple[str, str]] = {} + pending = [root] + while pending: + for item in client.list_dir(REPO_OWNER, REPO_NAME, pending.pop()): + path, sha = item.get("path", ""), item.get("sha", "") + if item.get("type") == "dir": + pending.append(path) + continue + if not path.endswith(".md"): + continue + local = vault / path + data = local.read_bytes() if local.is_file() else b"" + if data and _blob_sha(data) == sha: + found[path] = (sha, data.decode("utf-8")) + continue + fetched = client.get_file(REPO_OWNER, REPO_NAME, path) or {} + found[path] = (sha, fetched.get("content", "")) + return found + + +def _identity(localpart: str) -> tuple[str, str]: + server = os.environ.get("MATRIX_SERVER_NAME", "") or "local" + return localpart, f"{localpart}@{server}" + + +def _commit_message(cards, *, new: int) -> str: + """The commit a family reads in the vault's history.""" + verb = "learn" if new == len(cards) else "update" + subject = (cards[0].title if len(cards) == 1 + else f"{len(cards)} diary entries") + lines = [f"{verb}: {subject}", ""] + lines += [f"- {c.on.isoformat()} {c.title}" for c in cards] + lines += ["", "Source: memories room"] + return "\n".join(lines) + + +def _commit(client, plan, tree, cards_by_path) -> "set[str]": + """Write a plan to Forgejo, one commit per sender. Returns the paths + that did not land, so the pages can be built from what did.""" + failed: set[str] = set() + by_sender: dict[str, list[str]] = {} + for path in plan.writes: + by_sender.setdefault(cards_by_path[path].sender, []).append(path) + + batches = [] + for sender, paths in sorted(by_sender.items()): + for i in range(0, len(paths), _COMMIT_BATCH): + chunk = paths[i:i + _COMMIT_BATCH] + changes = [FileChange("update" if p in tree else "create", p, + content=plan.writes[p], + sha=tree[p][0] if p in tree else "") + for p in chunk] + new = sum(1 for p in chunk if p not in tree) + batches.append((changes, _commit_message( + [cards_by_path[p] for p in chunk], new=new), _identity(sender))) + if plan.deletes: + changes = [FileChange("delete", p, sha=tree[p][0]) for p in plan.deletes] + subject = (f"forget: {len(changes)} diary " + f"entr{'y' if len(changes) == 1 else 'ies'} deleted in the room") + batches.append((changes, subject, (BOT_USERNAME, BOT_EMAIL))) + + for changes, message, (name, email) in batches: + try: + client.change_files(REPO_OWNER, REPO_NAME, changes, message=message, + author_name=name, author_email=email) + except ForgejoError as e: + _err(f" could not write {len(changes)} card(s): {e}") + failed.update(c.path for c in changes) + return failed + + + # ── The command ─────────────────────────────────────────────────────── @@ -910,6 +1143,8 @@ async def run(llm, argv: list[str]) -> int: readings_cache, summaries_cache = diary_store.open_stores( reading_fingerprint=transcripts.fingerprint(_READ_PROMPT), summary_fingerprint=transcripts.fingerprint(_SUMMARY_PROMPT)) + extractions_cache = diary_store.open_extractions( + fingerprint=transcripts.fingerprint(_EXTRACT_PROMPT)) homeserver = os.environ.get("MATRIX_HOMESERVER", "").rstrip("/") if not homeserver: _err("MATRIX_HOMESERVER not set; is core up?") @@ -1003,6 +1238,71 @@ async def run(llm, argv: list[str]) -> int: refers_to=refers_to) _err(f"{len(entries)} diary entr{'y' if len(entries) == 1 else 'ies'}") + # ── Cards: one record per entry, in the vault ── + vault_dir = Path(os.environ.get("MEMORY_VAULT_DIR", "")) + language_code = (os.environ.get("LANGUAGE") or "en").strip().lower()[:2] + ontology = get_ontology(vault_dir if vault_dir.is_dir() else None) + # Person pages are generated, so they live in the brain, not the vault. + brain_dir = Path(os.environ.get("BRAIN_REPO_DIR", "")) + persons = load_persons_from_vault(brain_dir, bucket) if brain_dir.is_dir() else [] + people = {name.lower(): p.canonical for p in persons for name in p.all_known_names()} + if not people: + # No person pages yet: the household's names from the wiki, as + # whisper is primed with, so a first compile still knows them. + people = {name.lower(): name for name in _household_people()} + extractions = await _extract_all( + entries, llm, extractions_cache, ontology=ontology, people=people, + vocabulary=ontology.classifier_prompt_section(language_code), + people_section=(persons_prompt_section(persons) + or "Family members: " + ", ".join(sorted(set(people.values())))), + language_code=language_code, force=rebuild) + cards = [diary_card.to_card(e, x, room_id=room_id, media=kept) + for e, x in zip(entries, extractions)] + + client = _vault_client() + if client is None: + _err("no Forgejo address or admin credentials here; cards not written, " + "diary pages left as they were") + return 1 + try: + tree = _read_diary_tree(client, bucket) + except ForgejoError as e: + _err(f"could not read the diary records from the vault: {e}") + return 1 + plan = diary_card.plan({p: t for p, (_, t) in tree.items()}, cards, + bucket=bucket, complete=not limit) + _err(f" cards: {len(plan.writes)} to write, {len(plan.deletes)} to remove, " + f"{len(plan.kept)} changed by hand and kept") + for path in plan.kept: + _err(f" kept as edited: {path}") + + failed: set[str] = set() + if dry_run: + for path in sorted(plan.writes): + _err(f" would write {path}") + for path in plan.deletes: + _err(f" would remove {path}") + elif plan.writes or plan.deletes: + by_path = {diary_card.card_path(c, bucket=bucket): c for c in cards} + failed = _commit(client, plan, tree, by_path) + try: + request_mirror(Path(os.environ.get( + "CURATOR_STATE_DIR", "/data/memory/curator"))) + except OSError as e: + _err(f" could not ask the curator to mirror: {e}") + + # ── The pages: a view of the records, including anyone's corrections ── + records = {p: t for p, (_, t) in tree.items()} + for path, text in plan.writes.items(): + if path not in failed: + records[path] = text + for path in plan.deletes: + if path not in failed: + records.pop(path, None) + on_file = [diary_card.parse(t) for t in records.values()] + entries = sorted((diary_card.to_entry(c) for c in on_file), + key=lambda e: (e.on, e.at)) + months: dict[str, list] = {} for entry in entries: key = f"{diary.year_key(entry.on)}-{diary.month_key(entry.on)}" @@ -1025,8 +1325,8 @@ async def run(llm, argv: list[str]) -> int: summaries_cache.save() pages = diary.pages_for(entries, room_id=room_id, summaries=summaries, - media=kept) - return _publish_pages(pages, bucket=bucket, dry_run=dry_run) + media=diary_card.media_of(on_file)) + return _publish_pages(pages, bucket=bucket, dry_run=dry_run) or (1 if failed else 0) def _publish_pages(pages, *, bucket: str, dry_run: bool) -> int: diff --git a/stacklets/memory/bot/diary.py b/stacklets/memory/bot/diary.py index 822c7710..4a3e0757 100644 --- a/stacklets/memory/bot/diary.py +++ b/stacklets/memory/bot/diary.py @@ -251,12 +251,6 @@ class Reading: mode: str = "monologue" # "monologue" | "dialogue" | "note" spoken_date: str | None = None addressee: str | None = None - # Distillation, for long recordings. `gist` is one narrative - # sentence about the message. `moments` are passages the model - # copied from the text; verify_moments() checks each one against - # the body before it can render as a quote. - gist: str | None = None - moments: tuple = () @dataclass @@ -281,8 +275,9 @@ class Entry: mode: str = "monologue" comments: list[tuple[str, str]] = field(default_factory=list) # Distilled view for long recordings: one narrative sentence and - # verified word-for-word quotes. Empty for short entries; the - # renderer then shows the body in full. + # verified word-for-word quotes, both from the entry's diary card + # (`diary_card.to_entry`). Empty for short entries; the renderer + # then shows the body in full. gist: str = "" moments: list[str] = field(default_factory=list) @@ -616,10 +611,29 @@ def date_for(msg: Message, reading: Reading) -> tuple[date, str, str]: """ spoken = parse_spoken_date(reading.spoken_date) if spoken is not None: - return spoken, "spoken", _L["basis_spoken"] + return spoken, "spoken", basis_text("spoken") if msg.burst: - return msg.sent_on, "uncertain", _L["basis_burst"] - return msg.sent_on, "sent", _L["basis_sent"] + return msg.sent_on, "uncertain", basis_text("uncertain") + return msg.sent_on, "sent", basis_text("sent") + + +def basis_text(confidence: str) -> str: + """How a date was arrived at, in the household language. + + A diary record stores only the word (`spoken`, `sent`, `uncertain`); + the sentence is chrome and follows the language the pages render in. + """ + return _L[{"spoken": "basis_spoken", "uncertain": "basis_burst"} + .get(confidence, "basis_sent")] + + +def plain_title(entry: "Entry") -> str: + """A title that needs no model: what the entry is, and its day. + + For a record whose reading failed. It says nothing the room does not + say, so a later run with a working model can replace it. + """ + return f"{_kind_label(replace(entry, duration_ms=None))}, {_day_heading(entry.on)}" # ── Step 4: compile ─────────────────────────────────────────────────── @@ -703,8 +717,6 @@ def compile_entries(messages, readings, *, addressee=reading.addressee, duration_ms=_total_duration(group), mode=reading.mode, - gist=(reading.gist or "").strip(), - moments=verify_moments(_joined_body(group), reading.moments), ) entries.append(entry) for m in group: @@ -723,8 +735,6 @@ def compile_entries(messages, readings, *, sender=msg.sender, body=_joined_body(group), at=msg.ts, event_ids=[m.event_id for m in group], addressee=reading.addressee, mode=reading.mode, - gist=(reading.gist or "").strip(), - moments=verify_moments(_joined_body(group), reading.moments), ) entries.append(orphan) by_event[msg.event_id] = orphan diff --git a/stacklets/memory/bot/diary_card.py b/stacklets/memory/bot/diary_card.py new file mode 100644 index 00000000..b2358e86 --- /dev/null +++ b/stacklets/memory/bot/diary_card.py @@ -0,0 +1,508 @@ +"""Diary records: one card in the vault per entry. + +The compiler in `diary.py` turns the memories room into entries. This +module turns each entry into a card, a vault record of `type: diary`, +and a card back into an entry. The card is the source: the diary pages +and every other warm view of the family's memories are compiled from +cards, never from the room directly. That is what makes a correction +stick. A person fixes a misheard name or a wrong day on the card, and +the next compile shows the fix everywhere and leaves the card alone. + +A card is raw on purpose, shaped like a captured note: frontmatter a +machine reads, the briefing callout (summary and facts), the quotes and +replies, and the words the family recorded. It is not the page the +family reads; `diary.pages_for` renders that from it. + +Pure: no Matrix, no model, no Forgejo. `cli/diary.py` does the I/O. + + to_card(entry, extraction, ...) entry + what a model read -> Card + render(card) / parse(text) Card <-> the file in the vault + to_entry(card) Card -> the entry the pages render + card_path(card, bucket=) where the file lives + edited_by_hand(text) has a person changed this file? + plan(existing, cards, ...) which files a compile writes +""" + +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass, field +from datetime import date, datetime, timezone +from pathlib import Path + +import diary +from stack.briefing import read_briefing, render_briefing +from stack.frontmatter import dump as frontmatter_dump +from stack.frontmatter import parse as frontmatter_parse +from stack.media import safe_name + +DIARY_TYPE = "diary" +ENTRIES_DIR = "entries" + + +# ── What a model reads out of an entry ──────────────────────────────── + + +@dataclass(frozen=True) +class Extraction: + """The machine half of a card: what a model read out of the entry. + + Everything here is generated and may be wrong, which is why it all + lands on a card a person can correct. Empty is valid: an entry the + model could not read still becomes a record, with a plain title. + `quotes` are passages the model copied; they are checked against the + entry's words again whenever a page renders them. + """ + + title: str = "" + description: str = "" + persons: list[str] = field(default_factory=list) + tags: list[str] = field(default_factory=list) + summary: str = "" + facts: list[str] = field(default_factory=list) + quotes: list[str] = field(default_factory=list) + model: str = "" + + +_MAX_QUOTES = 3 + + +def _strings(value) -> list[str]: + if isinstance(value, str): + value = [value] + if not isinstance(value, list): + return [] + return [" ".join(v.split()) for v in value if isinstance(v, str) and v.strip()] + + +def _unique(values: list[str]) -> list[str]: + out: list[str] = [] + for v in values: + if v not in out: + out.append(v) + return out + + +def extraction_from(raw, *, ontology, language: str, + people: "dict[str, str]", model: str) -> Extraction: + """A model's answer about one entry, held to the household's vocabulary. + + `people` maps every known name, lowercased, to its canonical form + (a person page's canonical name and synonyms). A name outside it is + dropped: a mishearing must not become a family member. Topics go + through the ontology's own canonicalisation, which also rejects a + document type offered as a topic, and an unknown topic is dropped so + the diary tags with the same vocabulary as documents. Tags are the + topics, then `Person: ` for each person, as on a document. + """ + if not isinstance(raw, dict): + return Extraction(model=model) + + persons = _unique([people[n.lower()] for n in _strings(raw.get("persons")) + if n.lower() in people]) + topics: list[str] = [] + for offered in _strings(raw.get("topics")): + resolved = ontology.canonicalize_topic(offered, language) + if resolved.canonical: + topics.append(resolved.canonical) + topics = _unique(topics) + + def text(key: str) -> str: + value = raw.get(key) + return " ".join(value.split()) if isinstance(value, str) else "" + + return Extraction( + title=text("title"), + description=text("description"), + persons=persons, + tags=[*topics, *(f"Person: {p}" for p in persons)], + summary=text("summary"), + facts=_strings(raw.get("facts")), + quotes=_strings(raw.get("quotes"))[:_MAX_QUOTES], + model=model, + ) + + +# ── The card ────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class Card: + """One diary record, as it is kept in the vault. + + `entry_id` is the card's identity. It comes from the entry's first + message, which never changes, so a recompile finds the same card + whatever the model calls it this time. `at` is when that message + reached the room, in epoch milliseconds. + """ + + entry_id: str + on: date + confidence: str + kind: str + sender: str + at: int + event_ids: list[str] + resource: str = "" + addressee: str = "" + mode: str = "monologue" + duration_ms: int | None = None + media: list[str] = field(default_factory=list) + body: str = "" + replies: list[tuple[str, str]] = field(default_factory=list) + title: str = "" + description: str = "" + persons: list[str] = field(default_factory=list) + tags: list[str] = field(default_factory=list) + summary: str = "" + facts: list[str] = field(default_factory=list) + quotes: list[str] = field(default_factory=list) + model: str = "" + + +def entry_id_for(first_event_id: str) -> str: + """The short, stable identity of an entry: a hash of its first message.""" + return hashlib.sha256(first_event_id.encode("utf-8")).hexdigest()[:10] + + +def to_card(entry: "diary.Entry", extraction: Extraction, *, room_id: str, + media: "dict[str, str]") -> Card: + """An entry and what a model read out of it, as a vault record. + + `media` maps event ids to the archived copy of each message's file, + as `diary.pages_for` takes it; the card keeps the paths of this + entry's own files. + """ + first = entry.event_ids[0] + return Card( + entry_id=entry_id_for(first), + on=entry.on, + confidence=entry.confidence, + kind=entry.kind, + sender=entry.sender, + at=entry.at, + event_ids=list(entry.event_ids), + resource=f"https://matrix.to/#/{room_id}/{first}" if room_id else "", + addressee=(entry.addressee or "").strip(), + mode=entry.mode, + duration_ms=entry.duration_ms, + media=[media[e] for e in entry.event_ids if e in media], + body=entry.body.strip(), + replies=[(who, text.strip()) for who, text in entry.comments], + title=extraction.title.strip() or diary.plain_title(entry), + description=extraction.description.strip(), + persons=list(extraction.persons), + tags=list(extraction.tags), + summary=" ".join(extraction.summary.split()), + facts=[f.strip() for f in extraction.facts if f.strip()], + quotes=[" ".join(q.split()) for q in extraction.quotes if q.strip()], + model=extraction.model, + ) + + +def to_entry(card: Card) -> "diary.Entry": + """The entry a card describes, ready for the diary pages. + + The card's summary is the entry's narrative line and its quotes are + re-checked against its words, so a person who edits the transcript + cannot leave behind a quote that no longer appears in it. + """ + return diary.Entry( + on=card.on, + confidence=card.confidence, + basis=diary.basis_text(card.confidence), + kind=card.kind, + sender=card.sender, + body=card.body, + at=card.at, + event_ids=list(card.event_ids), + addressee=card.addressee or None, + duration_ms=card.duration_ms, + mode=card.mode, + comments=list(card.replies), + gist=card.summary, + moments=diary.verify_moments(card.body, card.quotes), + ) + + +def card_path(card: Card, *, bucket: str) -> str: + """Where the card lives: its day, then its identity. + + The title is left out on purpose. It is generated and correctable, + and a path that followed it would move the file on every rename. + + Records sit under `entries/`, apart from the diary pages. The vault + is mirrored into the wiki as it is, and a month's folder of records + next to that month's page (`2026/03/` beside `2026/03.md`) would be + two things at one address. + """ + day = card.on + return (f"{bucket}/{diary.DIARY_DIR}/{ENTRIES_DIR}/{day:%Y}/{day:%m}/" + f"{day:%Y-%m-%d}-{card.entry_id}.md") + + +def media_of(cards) -> "dict[str, str]": + """The event-id-to-file map `diary.pages_for` takes, from cards. + + The archive names every file, and every derived copy of it, after + the event it came from (`stack.media.safe_name`), so a card needs + to list only its paths. + """ + out: dict[str, str] = {} + for card in cards: + stems = {safe_name(e): e for e in card.event_ids} + for path in card.media: + if (event_id := stems.get(Path(path).stem)) is not None: + out[event_id] = path + return out + + +# ── The file ────────────────────────────────────────────────────────── +# +# The family's words go last, under a heading of their own, so nothing +# in them can be read as a section of the card: replies are blockquoted +# and quotes are list items, and only the words are free text. + +_BODY_HEADINGS = {"voice": "Transcript", "text": "Text"} +_CAPTION = "Caption" +_QUOTES = "## Quotes" +_REPLIES = "## Replies" +_DIGEST_LINE = re.compile(r"^digest: .*\n", re.MULTILINE) +_REPLY_HEAD = re.compile(r"^\*\*(.+)\*\*$") + + +def _iso_ms(ms: int) -> str: + moment = datetime.fromtimestamp(ms / 1000, timezone.utc) + return moment.isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +def _ms(iso: str) -> int: + moment = datetime.fromisoformat(str(iso).replace("Z", "+00:00")) + return round(moment.timestamp() * 1000) + + +def _frontmatter(card: Card) -> dict: + fm: dict = { + "type": DIARY_TYPE, + "title": card.title, + "date": card.on.isoformat(), + "date_basis": card.confidence, + "description": card.description, + "persons": card.persons, + "tags": card.tags, + "filed_by": card.sender, + "addressee": card.addressee, + "medium": card.kind, + "mode": card.mode, + } + if card.duration_ms: + fm["duration_ms"] = int(card.duration_ms) + fm.update({ + "resource": card.resource, + "entry_id": card.entry_id, + "event_ids": card.event_ids, + "media": card.media, + "model": card.model, + "timestamp": _iso_ms(card.at), + }) + return fm + + +def _meta_line(card: Card) -> str: + bits = [f"**From** {card.sender}"] + if card.addressee: + bits.append(f"**For** {card.addressee}") + bits.append(f"**Date** {card.on.isoformat()} ({card.confidence})") + return "> " + " · ".join(bits) + + +def _unsigned(card: Card) -> str: + lines = ["---", frontmatter_dump(_frontmatter(card)), "---", "", + f"# {card.title}", "", _meta_line(card), ""] + + briefing = render_briefing(summary=card.summary, facts=card.facts, + action_items=None) + if briefing: + lines += [briefing, ""] + + for path in card.media: + embed = card.kind in ("voice", "image", "video") + lines += [f"![[{path}]]" if embed else f"[{Path(path).name}]({path})", ""] + + if card.quotes: + lines += [_QUOTES, ""] + [f"- {q}" for q in card.quotes] + [""] + + if card.replies: + lines += [_REPLIES, ""] + for who, text in card.replies: + lines.append(f"**{who}**") + lines += [f"> {ln}" if ln else ">" for ln in text.splitlines()] + lines.append("") + + if card.body: + lines += [f"## {_BODY_HEADINGS.get(card.kind, _CAPTION)}", "", card.body, ""] + + return "\n".join(lines).rstrip() + "\n" + + +def _digest_of(text: str) -> str: + """A hash of the file's content, blind to the digest line itself and + to trailing whitespace an editor may add or trim on save.""" + unsigned = _DIGEST_LINE.sub("", text) + normal = "\n".join(ln.rstrip() for ln in unsigned.splitlines()).strip() + return hashlib.sha256(normal.encode("utf-8")).hexdigest()[:16] + + +def render(card: Card) -> str: + """The card as a vault file, signed with the digest of its content.""" + unsigned = _unsigned(card) + head, sep, rest = unsigned.partition("\n---\n") + return f"{head}\ndigest: {_digest_of(unsigned)}{sep}{rest}" + + +def edited_by_hand(text: str) -> bool: + """Whether a person has changed this file since the compiler wrote it. + + A file without a digest was never written by the compiler, and a + file whose content no longer matches its digest was changed after. + Either way it is a person's, and the compiler leaves it alone. + """ + stored = str(frontmatter_parse(text).get("digest") or "") + return not stored or stored != _digest_of(text) + + +def _sections(body: str) -> "tuple[list[str], list[tuple[str, str]], str]": + """The quotes, replies and words of a card body, in that order.""" + quotes: list[str] = [] + replies: list[tuple[str, str]] = [] + words = "" + lines = body.splitlines() + heads = [f"## {h}" for h in (*_BODY_HEADINGS.values(), _CAPTION)] + for i, ln in enumerate(lines): + if ln.strip() in heads: + words = "\n".join(lines[i + 1:]).strip("\n") + lines = lines[:i] + break + + section = "" + for ln in lines: + if ln.startswith("## "): + section = ln.strip() + continue + if section == _QUOTES and ln.startswith("- "): + quotes.append(ln[2:].strip()) + elif section == _REPLIES: + if m := _REPLY_HEAD.match(ln.strip()): + replies.append((m.group(1), "")) + elif ln.startswith(">") and replies: + who, text = replies[-1] + part = ln[2:] if ln.startswith("> ") else ln[1:] + replies[-1] = (who, f"{text}\n{part}" if text else part) + replies = [(who, text.strip("\n")) for who, text in replies] + return quotes, replies, words + + +def parse(text: str) -> Card: + """A card file back into a Card, including whatever a person changed.""" + fm = frontmatter_parse(text) + _, _, after = text.partition("\n---\n") + summary, facts = read_briefing(after) + quotes, replies, words = _sections(after) + + def listed(key: str) -> list[str]: + value = fm.get(key) or [] + return [str(v) for v in value] if isinstance(value, list) else [str(value)] + + duration = fm.get("duration_ms") + return Card( + entry_id=str(fm.get("entry_id") or ""), + on=date.fromisoformat(str(fm.get("date"))[:10]), + confidence=str(fm.get("date_basis") or "sent"), + kind=str(fm.get("medium") or "text"), + sender=str(fm.get("filed_by") or ""), + at=_ms(fm["timestamp"]) if fm.get("timestamp") else 0, + event_ids=listed("event_ids"), + resource=str(fm.get("resource") or ""), + addressee=str(fm.get("addressee") or ""), + mode=str(fm.get("mode") or "monologue"), + duration_ms=int(duration) if duration else None, + media=listed("media"), + body=words, + replies=replies, + title=str(fm.get("title") or ""), + description=str(fm.get("description") or ""), + persons=listed("persons"), + tags=listed("tags"), + summary=summary, + facts=facts, + quotes=quotes, + model=str(fm.get("model") or ""), + ) + + +# ── Which files a compile writes ────────────────────────────────────── + + +@dataclass +class Plan: + """What a compile does to the vault's diary tree. + + `writes` maps a path to its new content, `deletes` lists files to + remove, and `kept` lists files a person edited that the compile + would otherwise have changed or removed. Those are reported, never + touched. + """ + + writes: dict[str, str] = field(default_factory=dict) + deletes: list[str] = field(default_factory=list) + kept: list[str] = field(default_factory=list) + + +def plan(existing: "dict[str, str]", cards, *, bucket: str, + complete: bool) -> Plan: + """Compare the vault's diary files with a compile's cards. + + `existing` maps each diary file in the vault to its content. Files + are matched to cards by `entry_id`, not by path, so a card whose day + changed is moved rather than duplicated. `complete` says the compile + read the whole room; only then does a card with no entry mean its + messages are gone. + """ + on_disk: dict[str, tuple[str, str]] = {} + for path, text in existing.items(): + fm = frontmatter_parse(text) + if fm.get("type") == DIARY_TYPE and fm.get("entry_id"): + on_disk[str(fm["entry_id"])] = (path, text) + + out = Plan() + seen: set[str] = set() + for card in cards: + seen.add(card.entry_id) + path, text = card_path(card, bucket=bucket), render(card) + if card.entry_id not in on_disk: + out.writes[path] = text + continue + old_path, old_text = on_disk[card.entry_id] + if edited_by_hand(old_text): + # The digest a person's file still carries is the one the + # compiler signed it with, so it says whether the room has + # moved on since. Only then is there anything to report. + if str(frontmatter_parse(old_text).get("digest") or "") != _digest_of(text): + out.kept.append(old_path) + continue + if old_path != path: + out.deletes.append(old_path) + out.writes[path] = text + elif old_text != text: + out.writes[path] = text + + if complete: + for entry_id, (path, text) in on_disk.items(): + if entry_id in seen: + continue + if edited_by_hand(text): + out.kept.append(path) + else: + out.deletes.append(path) + return out diff --git a/stacklets/memory/bot/diary_store.py b/stacklets/memory/bot/diary_store.py index c12c2f65..03e4e628 100644 --- a/stacklets/memory/bot/diary_store.py +++ b/stacklets/memory/bot/diary_store.py @@ -172,3 +172,22 @@ def open_stores(directory: Path | None = None, *, reading_fingerprint).load(), SummaryStore(root / "summaries.json", summary_fingerprint).load()) + + +class ExtractionStore(ReadingStore): + """What a model read out of each entry for its diary card. + + Keyed by the entry's identity and by the words it was read from, the + same way a reading is: a new reply or a better transcript changes the + words, and the entry is read again. An unchanged entry is never sent + to the model twice, so a nightly compile pays for new entries only. + """ + + section = "extractions" + + +def open_extractions(directory: Path | None = None, *, + fingerprint: str = "") -> ExtractionStore: + """The extraction cache, loaded. A missing file is an empty one.""" + root = Path(directory) if directory else state_dir() + return ExtractionStore(root / "extractions.json", fingerprint).load() diff --git a/stacklets/memory/cli/diary.py b/stacklets/memory/cli/diary.py index 1710a8f9..88baa050 100644 --- a/stacklets/memory/cli/diary.py +++ b/stacklets/memory/cli/diary.py @@ -2,8 +2,9 @@ The memories room is where a family records things for its future self: voice notes to a child, a photo with a caption, a dinner conversation -someone hit record on. This reads the whole room back and publishes it -as diary pages in the wiki, in the words it was recorded in. +someone hit record on. This reads the whole room back, files one card +per entry in the memory vault, and publishes the diary pages in the wiki +from those cards, in the words it was recorded in. stack memory diary compile and publish stack memory diary --dry-run print the pages, write nothing @@ -38,11 +39,16 @@ The habit that prevents the third case costs nothing: say the date at the start of the recording. -NOTHING IS SUMMARISED - The model is asked to read each message, never to rewrite one. What - lands on the page is what was said. Transcription and reading both - run on the local AI stacklet; the room's contents never leave the - box. +CARDS ARE THE RECORD + Each entry becomes a card under `family/diary/entries/` in the + memory vault: the words as they were recorded, the files, and a + title, summary, facts, people and topics a model read out of it. + The diary pages are compiled from the cards alone, so a correction + made on a card (in Forgejo or Obsidian) shows on the pages. A card + someone edited is never overwritten; the run lists it as kept. + + The words themselves are never rewritten. Transcription and reading + both run on the AI stacklet configured for this household. Runs inside `stack-core-bot-runner` (it has the whisper client, the LLM client, and the brain working copy); this is a thin docker-exec, the diff --git a/stacklets/memory/quartz/quartz.config.ts b/stacklets/memory/quartz/quartz.config.ts index 7d05f776..f4d8dbbc 100644 --- a/stacklets/memory/quartz/quartz.config.ts +++ b/stacklets/memory/quartz/quartz.config.ts @@ -53,8 +53,12 @@ const config: QuartzConfig = { // them, the Assets emitter copies or unlinks a path that is already // gone, and Quartz 4.5.2 keeps the failed change and replays it on // every rebuild, so no later asset reaches the site. + // `**/diary/entries/**` keeps the diary's records off the site. They + // are the raw source the diary pages are compiled from, and stay in + // the brain for search and the agent; the family reads the pages. ignorePatterns: [".git", ".obsidian", "private", "templates", - "media/**/*.json", "media/**/.tmp-*"], + "media/**/*.json", "media/**/.tmp-*", + "**/diary/entries/**"], defaultDateType: "modified", theme: { // Self-hosted. The comment above about analytics applies with more diff --git a/tests/unit/framework/test_forgejo_change_files.py b/tests/unit/framework/test_forgejo_change_files.py new file mode 100644 index 00000000..b0f22535 --- /dev/null +++ b/tests/unit/framework/test_forgejo_change_files.py @@ -0,0 +1,86 @@ +"""Several files in one commit, and a directory listing with blob hashes. + +A batch writer (the diary compiler) changes many vault files at once. +One commit per batch keeps the vault's history readable, and the blob +hash sent with each update makes Forgejo refuse the write when the file +changed since it was read, rather than overwrite a person's edit. + +The request shape is Forgejo's documented `ChangeFilesOptions` +(`POST /repos/{owner}/{repo}/contents`, Forgejo 1.20 and later); the rig +runs the same call against a real Forgejo. +""" + +from __future__ import annotations + +import base64 +import json + +import pytest + +from stack.forgejo import FileChange, ForgejoClient, ForgejoError + + +def _client(httpserver) -> ForgejoClient: + return ForgejoClient(url=httpserver.url_for(""), admin_user="admin", + admin_password="pw") + + +def test_one_commit_creates_updates_and_deletes_with_the_persons_name(httpserver): + seen = {} + + def handler(request): + seen["body"] = json.loads(request.get_data()) + seen["auth"] = request.headers.get("Authorization", "") + from werkzeug import Response + return Response(json.dumps({"commit": {"sha": "abc"}}), status=201, + content_type="application/json") + + httpserver.expect_request("/api/v1/repos/family/memory/contents", + method="POST").respond_with_handler(handler) + + _client(httpserver).change_files( + "family", "memory", + [FileChange("create", "family/diary/a.md", content="Ä new"), + FileChange("update", "family/diary/b.md", content="changed", sha="b1"), + FileChange("delete", "family/diary/c.md", sha="c1")], + message="Diary: 3 entries", author_name="marge", + author_email="marge@simpson") + + body = seen["body"] + assert seen["auth"].startswith("Basic ") + assert body["message"] == "Diary: 3 entries" + assert body["branch"] == "main" + assert body["author"] == {"name": "marge", "email": "marge@simpson"} + ops = {f["path"]: f for f in body["files"]} + assert ops["family/diary/a.md"]["operation"] == "create" + assert base64.b64decode(ops["family/diary/a.md"]["content"]).decode() == "Ä new" + assert ops["family/diary/b.md"]["sha"] == "b1" + assert ops["family/diary/c.md"] == {"operation": "delete", + "path": "family/diary/c.md", "sha": "c1"} + + +def test_a_file_changed_since_it_was_read_fails_the_whole_commit(httpserver): + httpserver.expect_request("/api/v1/repos/family/memory/contents", + method="POST").respond_with_json( + {"message": "sha does not match"}, status=409) + with pytest.raises(ForgejoError): + _client(httpserver).change_files( + "family", "memory", + [FileChange("update", "x.md", content="y", sha="stale")], + message="m") + + +def test_a_directory_lists_its_files_and_folders_with_their_hashes(httpserver): + httpserver.expect_request("/api/v1/repos/family/memory/contents/family/diary").respond_with_json([ + {"type": "dir", "path": "family/diary/2026", "sha": "d1"}, + {"type": "file", "path": "family/diary/about.md", "sha": "f1"}, + ]) + listing = _client(httpserver).list_dir("family", "memory", "family/diary") + assert [(e["type"], e["path"], e["sha"]) for e in listing] == [ + ("dir", "family/diary/2026", "d1"), ("file", "family/diary/about.md", "f1")] + + +def test_a_missing_directory_is_empty(httpserver): + httpserver.expect_request("/api/v1/repos/family/memory/contents/family/diary").respond_with_json( + {"message": "not found"}, status=404) + assert _client(httpserver).list_dir("family", "memory", "family/diary") == [] diff --git a/tests/unit/stacklets/test_memory_diary_card.py b/tests/unit/stacklets/test_memory_diary_card.py new file mode 100644 index 00000000..f67f5d0c --- /dev/null +++ b/tests/unit/stacklets/test_memory_diary_card.py @@ -0,0 +1,326 @@ +"""Diary records in the vault: the card is the source, the pages are views. + +A diary entry is a record, so it lives in the vault as one file, a card: +machine frontmatter, the summary and facts a model read out of it, the +family's words as they were recorded, and the files they sent. Every +warm surface (the diary pages, a "Do you remember" card, whatever comes +next) is compiled from cards and from nothing else, so a correction made +on a card is a correction everywhere. + +That puts three promises on this module, and each has a test class: + +- A card holds the whole entry. Reading one back gives the entry that + was written, and the diary pages built from cards are the pages built + from the entries themselves. +- A card lives at one path. Recompiling the same room writes the same + files, and a generated title never moves one. +- A person's edit wins. The compiler rewrites only cards it wrote and + nobody touched since; an edited card is left alone on every later run, + and the pages show the edit. + +Entries come from the same corpus as `test_memory_diary.py` +(`tools/family-memories/spec.en.yaml`), so the cards are made from what +the compiler really produces for a messy room. +""" + +from __future__ import annotations + +import sys +from dataclasses import replace +from datetime import date +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent +sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory" / "bot")) + +import diary # noqa: E402 +import diary_card # noqa: E402 +from stack.frontmatter import parse as parse_frontmatter, validate # noqa: E402 +from test_memory_diary import _compile, _entry_for # noqa: E402 + +ROOM = "!memories:test" +BUCKET = "family" + + +def _extraction(**kw) -> diary_card.Extraction: + base = dict( + title="Bart's report card", description="Marge tells Bart she is proud of him.", + persons=["Bart", "Marge"], tags=["Topic:School", "Person:Bart"], + summary="Marge records a message for Bart about his report card.", + facts=["Bart: helped the new student", "Caller: Principal Skinner"], + quotes=[], + ) + base.update(kw) + return diary_card.Extraction(**base) + + +def _cards(entries, media=None): + return [diary_card.to_card(e, _extraction(title=f"Entry {i}"), + room_id=ROOM, media=media or {}) + for i, e in enumerate(entries)] + + +# ── A card holds the whole entry ────────────────────────────────────── + + +class TestACardHoldsTheWholeEntry: + + def test_every_entry_of_a_messy_room_reads_back_unchanged(self): + """Joined fragments, replies, captions, a spoken date, an + undateable memo: the corpus covers each, and each must survive + the trip into a file and back. What a card cannot give back, the + diary pages cannot show.""" + entries = _compile() + for entry in entries: + card = diary_card.to_card(entry, _extraction(), room_id=ROOM, media={}) + back = diary_card.parse(diary_card.render(card)) + assert diary_card.to_entry(back) == diary_card.to_entry(card), entry.event_ids + + def test_the_diary_pages_from_cards_are_the_pages_from_entries(self): + """The pages are a view of the cards. Built from cards read back + off disk, they must match the pages built straight from the + compiled entries, word for word, media included.""" + entries = _compile() + media = {e.event_ids[0]: f"/media/2026/09/{e.event_ids[0].lstrip('$')}.m4a" + for e in entries if e.kind == "voice"} + cards = [diary_card.parse(diary_card.render(c)) for c in _cards(entries, media)] + + direct = diary.pages_for( + [replace(e, gist="", moments=[]) for e in entries], + room_id=ROOM, media=media) + from_cards = diary.pages_for( + [replace(diary_card.to_entry(c), gist="", moments=[]) for c in cards], + room_id=ROOM, media=diary_card.media_of(cards)) + assert from_cards == direct + + def test_a_long_recording_shows_the_cards_summary_and_its_quotes(self): + """The distilled view of a long recording (a narrative line, a + few quotes, the transcript folded away) is drawn from the card: + its summary and its quotes, each still checked against the + words on the card before it may render as a quote.""" + spoken = " ".join(["We went to the lake today."] * 40 + + ["Bart lost a shoe in the water."]) + entry = diary.Entry(on=date(2026, 6, 12), confidence="sent", basis="", + kind="voice", sender="marge", body=spoken, + event_ids=["$lake"]) + card = diary_card.to_card(entry, _extraction( + summary="Marge tells of a day at the lake.", + quotes=["Bart lost a shoe in the water.", "Words nobody said."]), + room_id=ROOM, media={}) + page = diary.render_month([diary_card.to_entry(diary_card.parse( + diary_card.render(card)))], room_id=ROOM) + + assert "Marge tells of a day at the lake." in page + assert "> [!quote] Bart lost a shoe in the water." in page + assert "Words nobody said." not in page + + def test_a_card_is_an_okf_record_of_type_diary(self): + """The vault's own validator accepts it, and it carries the OKF + fields every record has, so search, the agent and an OKF export + read it like a note or a document.""" + entry = _entry_for(_compile(), "memo-bart-zeugnis") + text = diary_card.render(diary_card.to_card(entry, _extraction(), room_id=ROOM, media={})) + fm = parse_frontmatter(text) + + assert validate(fm) == [] + assert fm["type"] == "diary" + assert fm["date"] == "2026-03-16" + assert fm["resource"] == f"https://matrix.to/#/{ROOM}/{entry.event_ids[0]}" + for field in ("title", "description", "persons", "tags", "timestamp", "filed_by"): + assert fm.get(field), field + assert "> [!summary]" in text + + def test_a_card_without_a_reading_is_still_a_record(self): + """When the model gives nothing back, the family's words are still + worth keeping. The card is written with what the room says and a + plain title, and a later run can fill in the rest.""" + entry = _entry_for(_compile(), "memo-bart-zeugnis") + card = diary_card.to_card(entry, diary_card.Extraction(), room_id=ROOM, media={}) + fm = parse_frontmatter(diary_card.render(card)) + + assert validate(fm) == [] + assert fm["title"] + assert entry.body.strip() in diary_card.render(card) + + +# ── A card lives at one path ────────────────────────────────────────── + + +class TestACardLivesAtOnePath: + + def test_the_path_is_the_day_and_the_first_message(self): + """Under `entries/`, apart from the month pages the wiki serves at + `diary//`.""" + entry = _entry_for(_compile(), "memo-bart-zeugnis") + card = diary_card.to_card(entry, _extraction(), room_id=ROOM, media={}) + path = diary_card.card_path(card, bucket=BUCKET) + + assert path.startswith(f"{BUCKET}/diary/entries/2026/03/2026-03-16-") + assert path.endswith(".md") + + def test_a_new_title_does_not_move_a_card(self): + entry = _entry_for(_compile(), "memo-bart-zeugnis") + first = diary_card.to_card(entry, _extraction(title="Report card"), room_id=ROOM, media={}) + again = diary_card.to_card(entry, _extraction(title="Proud of Bart"), room_id=ROOM, media={}) + assert diary_card.card_path(first, bucket=BUCKET) == diary_card.card_path(again, bucket=BUCKET) + + def test_recompiling_the_same_room_writes_the_same_files(self): + first = {diary_card.card_path(c, bucket=BUCKET): diary_card.render(c) + for c in _cards(_compile())} + again = {diary_card.card_path(c, bucket=BUCKET): diary_card.render(c) + for c in _cards(_compile())} + assert first == again + assert len(first) == len(_compile()) + + +# ── A person's edit wins ────────────────────────────────────────────── + + +def _vault(cards) -> dict[str, str]: + return {diary_card.card_path(c, bucket=BUCKET): diary_card.render(c) for c in cards} + + +class TestAPersonsEditWins: + + def test_a_card_as_written_is_the_compilers_own(self): + card = _cards(_compile())[0] + assert not diary_card.edited_by_hand(diary_card.render(card)) + + def test_changing_a_word_makes_it_a_persons_card(self): + text = diary_card.render(_cards(_compile())[0]) + assert diary_card.edited_by_hand(text.replace("Entry 0", "Entry zero")) + + def test_trailing_whitespace_from_an_editor_is_not_an_edit(self): + text = diary_card.render(_cards(_compile())[0]) + assert not diary_card.edited_by_hand(text.rstrip("\n") + "\n\n") + + def test_an_unchanged_room_writes_nothing(self): + cards = _cards(_compile()) + plan = diary_card.plan(_vault(cards), cards, bucket=BUCKET, complete=True) + assert plan.writes == {} and plan.deletes == [] and plan.kept == [] + + def test_a_new_entry_is_written(self): + cards = _cards(_compile()) + plan = diary_card.plan(_vault(cards[:-1]), cards, bucket=BUCKET, complete=True) + assert list(plan.writes) == [diary_card.card_path(cards[-1], bucket=BUCKET)] + + def test_a_card_the_compiler_owns_follows_its_source(self): + """A reply arrives, a message is edited in the room: the card is + rewritten, because nobody has put anything of their own in it.""" + cards = _cards(_compile()) + changed = replace(cards[0], replies=[("homer", "Mmm, report card.")]) + plan = diary_card.plan(_vault(cards), [changed, *cards[1:]], bucket=BUCKET, complete=True) + path = diary_card.card_path(changed, bucket=BUCKET) + assert plan.writes == {path: diary_card.render(changed)} + + def test_an_edited_card_is_never_overwritten(self): + """The family fixed a misheard name. The room changes afterwards + (a new reply), and the next run must still leave the card as the + person left it, and say so rather than skip it silently.""" + cards = _cards(_compile()) + vault = _vault(cards) + path = diary_card.card_path(cards[0], bucket=BUCKET) + vault[path] = vault[path].replace("Entry 0", "Bart's first day") + changed = replace(cards[0], replies=[("homer", "Woohoo.")]) + + plan = diary_card.plan(vault, [changed, *cards[1:]], bucket=BUCKET, complete=True) + assert path not in plan.writes + assert plan.kept == [path] + + def test_the_pages_show_the_persons_correction(self): + """What a person writes on the card is what the diary says.""" + entry = _entry_for(_compile(), "memo-bart-zeugnis") + text = diary_card.render(diary_card.to_card(entry, _extraction(), room_id=ROOM, media={})) + fixed = text.replace("Principal Skinner called", "Principal Seymour Skinner called").replace( + 'date: "2026-03-16"', 'date: "2026-03-17"') + page = diary.render_month([diary_card.to_entry(diary_card.parse(fixed))], room_id=ROOM) + + assert "Tuesday, 17 March" in page + assert "Principal Seymour Skinner called" in page + + def test_a_moved_date_moves_the_card_the_compiler_owns(self): + cards = _cards(_compile()) + moved = replace(cards[0], on=date(2026, 1, 2)) + plan = diary_card.plan(_vault(cards), [moved, *cards[1:]], bucket=BUCKET, complete=True) + + assert plan.deletes == [diary_card.card_path(cards[0], bucket=BUCKET)] + assert list(plan.writes) == [diary_card.card_path(moved, bucket=BUCKET)] + + def test_a_card_whose_messages_are_gone_is_removed_after_a_full_read(self): + """A message deleted in the room takes its card with it, but only + when the run read the whole room: a bounded preview (`--limit`) + has not seen the older messages and must not treat them as gone.""" + cards = _cards(_compile()) + gone = diary_card.card_path(cards[0], bucket=BUCKET) + + full = diary_card.plan(_vault(cards), cards[1:], bucket=BUCKET, complete=True) + partial = diary_card.plan(_vault(cards), cards[1:], bucket=BUCKET, complete=False) + assert full.deletes == [gone] + assert partial.deletes == [] + + def test_an_edited_card_outlives_its_messages(self): + cards = _cards(_compile()) + vault = _vault(cards) + gone = diary_card.card_path(cards[0], bucket=BUCKET) + vault[gone] = vault[gone].replace("Entry 0", "Keep this") + + plan = diary_card.plan(vault, cards[1:], bucket=BUCKET, complete=True) + assert plan.deletes == [] and plan.kept == [gone] + + +# ── What a model read, held to the household's vocabulary ───────────── + +from stack.ontology import Ontology # noqa: E402 + +SEED_ONTOLOGY = Ontology.load(_REPO_ROOT / "stacklets" / "memory" / "seeds" / "ontology.toml") +PEOPLE = {"bart": "Bart", "bartholomew": "Bart", "marge": "Marge", "homer": "Homer"} + + +def _read(**raw) -> diary_card.Extraction: + return diary_card.extraction_from(raw, ontology=SEED_ONTOLOGY, language="en", + people=PEOPLE, model="test-model") + + +class TestWhatAModelReadIsHeldToTheHouseholdsVocabulary: + """The card's tags and people mean the same thing as a document's: + topics from the ontology, people from the household's own pages. + Anything else the model offers is dropped, because a tag nobody + defined splits search, and a person nobody is becomes a stranger in + the family's diary.""" + + def test_a_topic_is_named_the_way_the_household_names_it(self): + assert _read(topics=["school"]).tags == ["Education"] + assert _read(topics=["Schule"]).tags == ["Education"] + + def test_a_document_type_offered_as_a_topic_is_dropped(self): + assert _read(topics=["Invoice", "Education"]).tags == ["Education"] + + def test_a_topic_the_ontology_does_not_know_is_dropped(self): + assert _read(topics=["Unicorns"]).tags == [] + + def test_a_misheard_name_does_not_become_a_person(self): + """Whisper heard "Bark". The household has no Bark.""" + assert _read(persons=["Bark", "Marge"]).persons == ["Marge"] + + def test_a_person_is_named_by_their_canonical_name(self): + read = _read(persons=["Bartholomew", "bart"]) + assert read.persons == ["Bart"] + assert read.tags == ["Person: Bart"] + + def test_topics_come_before_people_in_the_tags_like_on_a_document(self): + assert _read(topics=["school"], persons=["Marge"]).tags == ["Education", "Person: Marge"] + + def test_the_words_come_through_trimmed_and_facts_stay_facts(self): + read = _read(title=" Report card ", summary="Marge records\na message.", + facts=["Bart: helped", "", 7], quotes=["One.", "Two.", "Three.", "Four."]) + assert read.title == "Report card" + assert read.summary == "Marge records a message." + assert read.facts == ["Bart: helped"] + assert read.quotes == ["One.", "Two.", "Three."] + assert read.model == "test-model" + + def test_an_answer_that_is_not_an_object_reads_as_nothing(self): + empty = diary_card.extraction_from(None, ontology=SEED_ONTOLOGY, language="en", + people=PEOPLE, model="m") + assert empty == diary_card.Extraction(model="m") + From c3adb93845e928b10d1b9eb7aaae5b1c695e13c2 Mon Sep 17 00:00:00 2001 From: Arthur <516104+arthware@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:04:58 +0200 Subject: [PATCH 05/17] feat(memory): add family-life topics to the ontology The seed ontology was written for documents. On the demo memories room the diary's reading fell back to Child and Memory for nearly every entry, because music, sport, hobbies, celebrations, friends and family life had no topic. Six topics join the seed, in German and English: Music, Sport, Hobby, Celebration, Friends and Family Life. Documents can carry them too (a music school invoice is Music), and the docs taxonomy gains the same tags. None of their names or synonyms is used by another topic or document type. Upgrade: an existing vault keeps its own `ontology.toml`. Preview the change with `./stack memory ontology --dry-run`; `./stack memory ontology` replaces the vault's file with the seed, so merge any hand-made topics first. --- stacklets/docs/taxonomy.toml | 12 +++++++ stacklets/memory/seeds/ontology.toml | 28 +++++++++++++++ .../unit/stacklets/test_memory_diary_card.py | 36 +++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/stacklets/docs/taxonomy.toml b/stacklets/docs/taxonomy.toml index bbba5089..342d5a98 100644 --- a/stacklets/docs/taxonomy.toml +++ b/stacklets/docs/taxonomy.toml @@ -40,6 +40,12 @@ tags = [ "Telekommunikation", # phone, internet, mobile contracts "Rezept", # family recipes, grandma's classics, cooking and baking "Erinnerung", # kids' artwork, letters, postcards, milestone docs + "Musik", # music school, concerts, instruments + "Sport", # clubs, swimming, tournaments + "Hobby", + "Feier", # birthdays, parties, holidays at home + "Freunde", + "Familienleben", "Urkunde", # birth/death certificates, passports, wills, Vollmacht "Rente", # state pension, Riester, company pension, Altersvorsorge "Spende", # charity donations, tax-deductible receipts @@ -101,6 +107,12 @@ tags = [ "Telecom", # phone, internet, mobile contracts "Recipe", # family recipes, grandma's classics, cooking and baking "Memory", # kids' artwork, letters, postcards, milestone docs + "Music", # music school, concerts, instruments + "Sport", # clubs, swimming, tournaments + "Hobby", + "Celebration", # birthdays, parties, holidays at home + "Friends", + "Family Life", "Personal Record", # birth/death certificates, passports, wills, power of attorney "Pension", # state pension, 401k, company pension, retirement "Donation", # charity donations, tax-deductible receipts diff --git a/stacklets/memory/seeds/ontology.toml b/stacklets/memory/seeds/ontology.toml index a145a012..d2873410 100644 --- a/stacklets/memory/seeds/ontology.toml +++ b/stacklets/memory/seeds/ontology.toml @@ -184,6 +184,34 @@ synonyms = { de = ["Kochrezept"], en = ["cooking", "baking"] } names = { de = "Erinnerung", en = "Memory" } synonyms = { de = ["Erinnerungsstück"], en = ["keepsake", "milestone"] } +# Family life: what the memories room and the diary are about. A letter +# from the music school can be Music too; that is the point of one +# vocabulary for documents and diary alike. + +[topic.music] +names = { de = "Musik", en = "Music" } +synonyms = { de = ["Konzert", "Instrument", "Chor", "Musikschule"], en = ["concert", "instrument", "choir", "recital", "music school"] } + +[topic.sport] +names = { de = "Sport", en = "Sport" } +synonyms = { de = ["Schwimmen", "Fußball", "Sportverein", "Turnier"], en = ["sports", "swimming", "football", "soccer", "tournament"] } + +[topic.hobby] +names = { de = "Hobby", en = "Hobby" } +synonyms = { de = ["Basteln", "Zeichnen", "Freizeit"], en = ["pastime", "crafts", "drawing"] } + +[topic.celebration] +names = { de = "Feier", en = "Celebration" } +synonyms = { de = ["Geburtstag", "Party", "Weihnachten", "Jubiläum", "Fest"], en = ["birthday", "party", "christmas", "anniversary"] } + +[topic.friends] +names = { de = "Freunde", en = "Friends" } +synonyms = { de = ["Freundschaft", "Spielverabredung"], en = ["friend", "friendship", "playdate"] } + +[topic.family_life] +names = { de = "Familienleben", en = "Family Life" } +synonyms = { de = ["Familienzeit", "Alltag"], en = ["family time", "everyday life"] } + [topic.personal_record] names = { de = "Urkunde", en = "Personal Record" } synonyms = { de = ["Geburtsurkunde", "Sterbeurkunde"], en = ["birth certificate", "death certificate", "passport"] } diff --git a/tests/unit/stacklets/test_memory_diary_card.py b/tests/unit/stacklets/test_memory_diary_card.py index f67f5d0c..e632532f 100644 --- a/tests/unit/stacklets/test_memory_diary_card.py +++ b/tests/unit/stacklets/test_memory_diary_card.py @@ -324,3 +324,39 @@ def test_an_answer_that_is_not_an_object_reads_as_nothing(self): people=PEOPLE, model="m") assert empty == diary_card.Extraction(model="m") + +class TestTheSeedOntologyCoversFamilyLife: + """The seed was written for documents. The diary is the first reader + that is about family life, and without topics for it the model files + a saxophone concert under "Memory" and a bike ride under "Child".""" + + def test_family_life_resolves_to_its_own_topics(self): + assert _read(topics=["concert"]).tags == ["Music"] + assert _read(topics=["swimming"]).tags == ["Sport"] + assert _read(topics=["birthday"]).tags == ["Celebration"] + assert _read(topics=["playdate"]).tags == ["Friends"] + assert _read(topics=["drawing"]).tags == ["Hobby"] + assert _read(topics=["family time"]).tags == ["Family Life"] + + def test_the_household_language_names_them(self): + read = diary_card.extraction_from( + {"topics": ["Konzert", "Geburtstag"]}, ontology=SEED_ONTOLOGY, + language="de", people=PEOPLE, model="") + assert read.tags == ["Musik", "Feier"] + + def test_the_family_life_topics_share_no_word_with_anything_else(self): + """A name or synonym shared by two topics resolves to whichever + comes first, silently. The family-life topics must not take a + word another topic or document type already answers to.""" + life = {"music", "sport", "hobby", "celebration", "friends", "family_life"} + owners: dict[tuple[str, str], set[str]] = {} + for kind, items in (("topic", SEED_ONTOLOGY.topics), + ("doctype", SEED_ONTOLOGY.doctypes)): + for key, item in items.items(): + for lang in ("de", "en"): + for word in [item.name(lang), *item.synonyms_for(lang)]: + owners.setdefault((lang, word.lower()), set()).add(f"{kind}.{key}") + shared = {w: o for w, o in owners.items() + if len(o) > 1 and o & {f"topic.{k}" for k in life}} + assert shared == {} + assert life <= set(SEED_ONTOLOGY.topics) From abafbeba39268774478702fe003954c76c2fd646 Mon Sep 17 00:00:00 2001 From: Arthur <516104+arthware@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:05:05 +0200 Subject: [PATCH 06/17] feat(memory): show every vocabulary block in stack memory prompt `stack memory prompt` showed only the topics and document types, in English unless told otherwise. It now prints every block a model is given from the family's pages: topics and document types, family members with their synonyms, and correspondents with their aliases. Each block names the prompts that receive it. The language defaults to the household's. Family members and correspondents are generated pages and live in the brain. The document classifier still looks for them in the vault, where there are none, so the output says it receives neither today. --- stacklets/memory/cli/prompt.py | 78 ++++++++++++++++++++----- tests/unit/stacklets/test_memory_cli.py | 19 +++++- 2 files changed, 79 insertions(+), 18 deletions(-) diff --git a/stacklets/memory/cli/prompt.py b/stacklets/memory/cli/prompt.py index 9a8c9de9..d41bb221 100644 --- a/stacklets/memory/cli/prompt.py +++ b/stacklets/memory/cli/prompt.py @@ -1,12 +1,18 @@ -"""stack memory prompt [--lang=en] — render the classifier prompt section. +"""stack memory prompt [--lang=de] — show the vocabulary the models are fed. -Outputs the topics + doctypes block the Archivist injects into its -classification prompt. Useful for debugging classifier behaviour -(does the LLM actually see the synonyms?) and for previewing what -shifts when the ontology evolves. +Prints every vocabulary block a model receives from the vault, read +fresh, each under a heading naming the prompts that use it: the topics +and document types from `ontology.toml`, the family members from their +person pages, and the known correspondents. Useful for reviewing what +the archivist and the diary see (does the model get the synonyms?) and +for previewing what shifts when the ontology or a person page changes. + +The language is the household's (`[core] language`) unless `--lang` +names another. Nothing is written. Example: + stack memory prompt stack memory prompt --lang=de """ @@ -16,17 +22,29 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from lib import get_ontology, vault_path_for # noqa: E402 +from lib import ( # noqa: E402 + DEFAULT_SHARED_BUCKET, + brain_path_for, + correspondents_prompt_section, + get_ontology, + load_correspondents_from_vault, + load_persons_from_vault, + persons_prompt_section, + vault_path_for, +) + +HELP = "Show the vocabulary the models are fed: topics, family, correspondents" -HELP = "Render the classifier prompt section for a language" +def _core(config) -> dict: + return ((config or {}).get("stack") or {}).get("core") or {} -def _parse_args(args): - lang = "en" + +def _language(args, config) -> str: for a in args: if a.startswith("--lang="): - lang = a.split("=", 1)[1] - return lang + return a.split("=", 1)[1] + return _core(config).get("language") or "en" def _vault_path(config): @@ -34,9 +52,37 @@ def _vault_path(config): return vault_path_for(Path(data_dir)) if data_dir else None +def _block(heading: str, used_by: str, body: str, empty: str) -> str: + return f"## {heading}\nUsed by: {used_by}\n\n{body.strip() or empty}\n" + + def run(args, stacklet, config): - lang = _parse_args(args or []) - ont = get_ontology(_vault_path(config)) - section = ont.classifier_prompt_section(lang) - print(section) - return {"ok": True, "lang": lang, "length": len(section)} + lang = _language(args or [], config) + vault = _vault_path(config) + bucket = _core(config).get("shared_bucket") or DEFAULT_SHARED_BUCKET + + ontology = get_ontology(vault).classifier_prompt_section(lang) + # Person pages are generated projections and live in the brain. + data_dir = (config or {}).get("data_dir") + brain = brain_path_for(Path(data_dir)) if data_dir else None + persons = persons_prompt_section( + load_persons_from_vault(brain, bucket) if brain and brain.exists() else []) + # So are correspondent pages. + correspondents = correspondents_prompt_section( + load_correspondents_from_vault(brain, bucket) if brain and brain.exists() else []) + + print("\n".join([ + _block("Topics and document types", + "document classifier, search query rewrite, diary cards", + ontology, "(the ontology is empty)"), + # The document classifier reads both of these from the vault, + # where generated pages no longer are, so today it receives + # neither. The labels say so until it reads the brain. + _block("Family members", + "diary cards (the document classifier looks in the vault and finds none)", + persons, "(no person pages yet)"), + _block("Correspondents", + "nothing yet (the document classifier looks in the vault and finds none)", + correspondents, "(no correspondent pages yet)"), + ])) + return {"ok": True, "lang": lang, "length": len(ontology)} diff --git a/tests/unit/stacklets/test_memory_cli.py b/tests/unit/stacklets/test_memory_cli.py index 3953a16b..85880a18 100644 --- a/tests/unit/stacklets/test_memory_cli.py +++ b/tests/unit/stacklets/test_memory_cli.py @@ -95,8 +95,10 @@ def test_swaps_to_german_with_lang_flag(self, stack_cli): assert code == 0 assert "Versicherung" in out assert "Rechnung" in out - # English names must not leak into the German prompt. - assert "Insurance" not in out + # English topic names must not leak into the German topics. + # (Correspondents keep their own names in any language.) + topics = out.split("## Family members")[0] + assert "Insurance" not in topics def test_includes_synonyms_inline(self, stack_cli): code, out, _ = stack_cli("memory", "prompt") @@ -104,6 +106,19 @@ def test_includes_synonyms_inline(self, stack_cli): # `coverage` is a synonym of Insurance; the LLM must see it. assert "coverage" in out + def test_shows_every_vocabulary_block_and_which_prompts_read_it(self, stack_cli): + """What the models are fed is more than the topics: the family's + names and the known correspondents ride along. Each block is shown + under a heading naming the prompts that receive it, so reviewing + the vocabulary answers "what does the diary see" as well as "what + does the archivist see".""" + code, out, _ = stack_cli("memory", "prompt") + assert code == 0 + assert "## Topics and document types" in out + assert "## Family members" in out + assert "## Correspondents" in out + assert "diary cards" in out + class TestTopicReaderRouting: """Bare topic reads brain; todo reads stay on source memory.""" From 5a42581f17b7fdda48f404aef5952b4c38c60694 Mon Sep 17 00:00:00 2001 From: Arthur <516104+arthware@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:14:10 +0200 Subject: [PATCH 07/17] feat(core): let a bot take part in rooms another stacklet owns A bot could be joined only to the one room its `bot.toml` declares, and the bot runner creates that room and joins the admins to it. A room that belongs to another stacklet, such as the memories room, could not be joined that way without being recreated or getting members it should not have. `join_rooms` in `bot.toml` lists such rooms. The bot runner joins the bot to each one on every start when it exists, never creates it, and joins no one else. --- docs/stack-reference.md | 1 + stacklets/core/bot-runner/accounts.py | 21 +++++++++++++++++++-- stacklets/core/bot-runner/main.py | 2 ++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/stack-reference.md b/docs/stack-reference.md index 83370902..8bce95c3 100644 --- a/docs/stack-reference.md +++ b/docs/stack-reference.md @@ -321,6 +321,7 @@ reformat = true | `name` | Display name in Matrix. | | `room` | Room alias to create/join. Optional — omit for bots that only respond to DMs/invites. | | `room_topic` | Topic set on auto-created room. | +| `join_rooms` | Aliases of rooms another stacklet owns that the bot takes part in. Joined on every start when they exist; never created, and no one else is joined to them. | | `settings` | Arbitrary key-value pairs passed as kwargs to the bot constructor. | Module convention: strip `-bot` from the ID → `archivist.py` → class `ArchivistBot`. diff --git a/stacklets/core/bot-runner/accounts.py b/stacklets/core/bot-runner/accounts.py index 11ce740d..4ece26d0 100644 --- a/stacklets/core/bot-runner/accounts.py +++ b/stacklets/core/bot-runner/accounts.py @@ -105,7 +105,7 @@ def ensure_rooms(bots, homeserver, server_name, admin_user, admin_password, Creates rooms, joins bots, joins admin-role users. """ # Only process bots that declare a room - bots_with_rooms = [b for b in bots if b.get("room")] + bots_with_rooms = [b for b in bots if b.get("room") or b.get("join_rooms")] if not bots_with_rooms: return @@ -118,8 +118,18 @@ def ensure_rooms(bots, homeserver, server_name, admin_user, admin_password, for bot in bots_with_rooms: bot_id = bot["id"] - room_alias = bot["room"] + room_alias = bot.get("room") + # Rooms another stacklet owns, which the bot takes part in: + # joined when they exist, never created here, and nobody else is + # joined to them. + for alias in bot.get("join_rooms") or []: + existing = _resolve_room(base, token, server_name, alias) + if existing: + _join_user(base, token, existing, f"@{bot_id}:{server_name}") + + if not room_alias: + continue room_id = _ensure_room(base, token, server_name, room_alias, bot.get("room_topic"), space_id) if not room_id: @@ -131,6 +141,13 @@ def ensure_rooms(bots, homeserver, server_name, admin_user, admin_password, _join_user(base, token, room_id, f"@{uid}:{server_name}") +def _resolve_room(base, token, server_name, alias): + """The id of the room with this alias, or None if there is none.""" + encoded = urllib.parse.quote(f"#{alias}:{server_name}") + status, resp = _api("GET", f"{base}/_matrix/client/v3/directory/room/{encoded}", token=token) + return resp.get("room_id") if status == 200 else None + + def _ensure_room(base, token, server_name, alias, topic, space_id): """Create a room or resolve an existing one.""" full_alias = f"#{alias}:{server_name}" diff --git a/stacklets/core/bot-runner/main.py b/stacklets/core/bot-runner/main.py index 402fff63..7f40c9a3 100644 --- a/stacklets/core/bot-runner/main.py +++ b/stacklets/core/bot-runner/main.py @@ -158,6 +158,7 @@ def discover_bots(): "display_name": decl.get("name", bot_id), "room": decl.get("room"), "room_topic": decl.get("room_topic"), + "join_rooms": list(decl.get("join_rooms") or []), "settings": decl.get("settings", {}), }) @@ -277,6 +278,7 @@ def _ensure_bot_accounts(configs): "id": c["bot_id"], "room": c.get("room"), "room_topic": c.get("room_topic"), + "join_rooms": c.get("join_rooms") or [], } for c in configs] ensure_rooms( all_bots, MATRIX_HOMESERVER, MATRIX_SERVER_NAME, From e26fc69a887a2e7f06337c42e7ccd588efa2d098 Mon Sep 17 00:00:00 2001 From: Arthur <516104+arthware@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:14:11 +0200 Subject: [PATCH 08/17] feat(core): let a bot skip voice messages it has no use for Every bot decodes every voice message in its rooms before any handler sees it: whisper runs and the bot shows as typing. A bot present in a room for only part of what happens there caused both for messages it then ignored. A bot can now decline a voice message by room and message before the decode. Declined voice messages reach no handler. Every bot still decodes everything unless it says otherwise. --- stacklets/core/bot-runner/microbot.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/stacklets/core/bot-runner/microbot.py b/stacklets/core/bot-runner/microbot.py index f528f4be..b76d3550 100644 --- a/stacklets/core/bot-runner/microbot.py +++ b/stacklets/core/bot-runner/microbot.py @@ -521,6 +521,8 @@ async def _dispatch(self, room_id: str, event) -> None: if room is None: return + if voice.is_voice(event) and not self._wants_voice(room, event): + return decoding = voice.is_voice(event) if decoding: # Transcription can run for minutes and happens before any @@ -541,6 +543,17 @@ async def _dispatch(self, room_id: str, event) -> None: if decoding and not handled: await self._set_typing(room_id, on=False) + def _wants_voice(self, room, event) -> bool: + """Whether this bot decodes a voice message in `room`. + + Every voice message it can hear, by default. A bot present in a + room only for part of what happens there declines the rest: the + decode costs a transcription and shows the bot typing, which is + noise to the people in the room. A declined voice message is + dispatched to no handler. + """ + return True + async def _decode_voice(self, room_id: str, event): """Return the text event this voice message decodes to, or None. From 9aea0a7bd33156dcc51adc0b5f9b5a42dc5fbeaa Mon Sep 17 00:00:00 2001 From: Arthur <516104+arthware@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:14:19 +0200 Subject: [PATCH 09/17] feat(diary): correct a diary card and keep the correction in the vault A diary card could be corrected only by editing it in Forgejo or Obsidian. `stack memory diary correct --by --event --text ` applies a family member's correction to the card as it is in the vault: title, people, topics, summary, facts and, when the correction states one, the date. The recorded words never change. - The vault commit is `correct: `, authored by that person, with their words in the body. A new date moves the card in the same commit. - A corrected card is the family's from then on: the nightly compile leaves it alone, as it does a hand-edited one. - Replies in a card's thread in the memories room never become diary entries or replies on the page. - `stack memory diary --json` reports every card, its thread root and whether its thread already holds a card notice, for the archivist. --- docs/agent/dev.md | 2 + docs/design/brain/vault-format.md | 7 +- stacklets/memory/bot/cli/diary.py | 307 ++++++++++++++++-- stacklets/memory/bot/diary.py | 58 +++- stacklets/memory/bot/diary_card.py | 98 ++++-- tests/unit/stacklets/test_memory_diary.py | 60 ++++ .../unit/stacklets/test_memory_diary_card.py | 85 +++++ 7 files changed, 563 insertions(+), 54 deletions(-) diff --git a/docs/agent/dev.md b/docs/agent/dev.md index e3a1670d..cb39f9b5 100644 --- a/docs/agent/dev.md +++ b/docs/agent/dev.md @@ -61,6 +61,8 @@ famstack/ Reprocessing re-derives from the source, never from the vault file. For a chat filing the source is the **whole thread** - the original message **plus its reply chain** (corrections) - folded in timeline order. Machine-derived vault state must stay reproducible this way; only user hand-edits are irreducible. See [adr-010](../adr/adr-010-event-pipeline.md). +The diary is the exception. A reply in a diary card's thread is applied to the card once, recorded in the vault commit that applied it (`correct: <title>`, authored by the person), and the corrected card is the family's from then on: the compile leaves it alone, like a hand-edited card. The card thread is never replayed into the card. See [vault-format.md](../design/brain/vault-format.md) (`diary`). + ## Stacklet anatomy Required: diff --git a/docs/design/brain/vault-format.md b/docs/design/brain/vault-format.md index 5cce6785..6c622e9e 100644 --- a/docs/design/brain/vault-format.md +++ b/docs/design/brain/vault-format.md @@ -152,7 +152,12 @@ and the agent read them, but the wiki does not publish `diary/entries/`. | `timestamp` | R | when the entry's first message reached the room. | | `date` | R | the day it happened (spoken date, else the day it was sent). | | `entry_id` | R | short hash of the first message's event id; the path's identity. | -| `date_basis` | O | `spoken` \| `sent` \| `uncertain`. | +| `date_basis` | O | `spoken` \| `sent` \| `uncertain` \| `corrected` (a family member's correction set it). | + +A correction is a reply in the card's thread in the room. It is read +again on every compile, applied to the card, and recorded in the vault +commit (`correct: <title>`, authored by whoever wrote it), not on the +card; its event id joins `event_ids`. | `description` | O | one sentence. | | `persons` | O | list. | | `tags` | O | list. | diff --git a/stacklets/memory/bot/cli/diary.py b/stacklets/memory/bot/cli/diary.py index a083e195..30c5fdb4 100644 --- a/stacklets/memory/bot/cli/diary.py +++ b/stacklets/memory/bot/cli/diary.py @@ -1017,12 +1017,19 @@ def _identity(localpart: str) -> tuple[str, str]: return localpart, f"{localpart}@{server}" -def _commit_message(cards, *, new: int) -> str: - """The commit a family reads in the vault's history.""" - verb = "learn" if new == len(cards) else "update" +def _commit_message(cards, *, new: int, verb: str | None = None, + corrections=()) -> str: + """The commit a family reads in the vault's history. + + A correction's own words go in the body: the card shows what it + changed, and this is where what was said is kept. + """ + verb = verb or ("learn" if new == len(cards) else "update") subject = (cards[0].title if len(cards) == 1 else f"{len(cards)} diary entries") lines = [f"{verb}: {subject}", ""] + for who, text, _ in corrections: + lines += [f"{who.title()}:"] + [f"> {ln}" for ln in text.splitlines()] + [""] lines += [f"- {c.on.isoformat()} {c.title}" for c in cards] lines += ["", "Source: memories room"] return "\n".join(lines) @@ -1088,7 +1095,7 @@ def _household_zone(): # Flags that consume the token after them, so the room can be picked out # of the rest without mistaking a flag's value for it. -_TAKES_A_VALUE = ("--burst-window", "--limit") +_TAKES_A_VALUE = ("--burst-window", "--limit", "--by", "--event", "--text") def _opt(argv: list[str], flag: str, fallback: str) -> str: @@ -1118,8 +1125,13 @@ def _positional(argv: list[str], fallback: str) -> str: async def run(llm, argv: list[str]) -> int: + if argv and argv[0] == "correct": + return await correct(llm, argv[1:]) room_arg = _positional(argv, "memories") dry_run = "--dry-run" in argv + # A machine-readable report of the cards on stdout, for the archivist, + # which posts them. Progress stays on stderr. + as_json = "--json" in argv rebuild = "--force" in argv # --force re-reads and re-summarises but keeps transcripts: whisper # costs minutes of GPU per recording and its output only changes @@ -1235,27 +1247,17 @@ async def run(llm, argv: list[str]) -> int: entries = diary.compile_entries(decoded, readings, continues=continues, - refers_to=refers_to) + refers_to=refers_to, + card_roots=diary.card_threads(events)) _err(f"{len(entries)} diary entr{'y' if len(entries) == 1 else 'ies'}") # ── Cards: one record per entry, in the vault ── - vault_dir = Path(os.environ.get("MEMORY_VAULT_DIR", "")) - language_code = (os.environ.get("LANGUAGE") or "en").strip().lower()[:2] - ontology = get_ontology(vault_dir if vault_dir.is_dir() else None) - # Person pages are generated, so they live in the brain, not the vault. - brain_dir = Path(os.environ.get("BRAIN_REPO_DIR", "")) - persons = load_persons_from_vault(brain_dir, bucket) if brain_dir.is_dir() else [] - people = {name.lower(): p.canonical for p in persons for name in p.all_known_names()} - if not people: - # No person pages yet: the household's names from the wiki, as - # whisper is primed with, so a first compile still knows them. - people = {name.lower(): name for name in _household_people()} + ontology, people, people_section, language_code = _vocabulary(bucket) extractions = await _extract_all( entries, llm, extractions_cache, ontology=ontology, people=people, vocabulary=ontology.classifier_prompt_section(language_code), - people_section=(persons_prompt_section(persons) - or "Family members: " + ", ".join(sorted(set(people.values())))), - language_code=language_code, force=rebuild) + people_section=people_section, language_code=language_code, + force=rebuild) cards = [diary_card.to_card(e, x, room_id=room_id, media=kept) for e, x in zip(entries, extractions)] @@ -1272,9 +1274,9 @@ async def run(llm, argv: list[str]) -> int: plan = diary_card.plan({p: t for p, (_, t) in tree.items()}, cards, bucket=bucket, complete=not limit) _err(f" cards: {len(plan.writes)} to write, {len(plan.deletes)} to remove, " - f"{len(plan.kept)} changed by hand and kept") + f"{len(plan.kept)} the family's and kept") for path in plan.kept: - _err(f" kept as edited: {path}") + _err(f" kept as the family left it: {path}") failed: set[str] = set() if dry_run: @@ -1285,13 +1287,8 @@ async def run(llm, argv: list[str]) -> int: elif plan.writes or plan.deletes: by_path = {diary_card.card_path(c, bucket=bucket): c for c in cards} failed = _commit(client, plan, tree, by_path) - try: - request_mirror(Path(os.environ.get( - "CURATOR_STATE_DIR", "/data/memory/curator"))) - except OSError as e: - _err(f" could not ask the curator to mirror: {e}") + _mirror() - # ── The pages: a view of the records, including anyone's corrections ── records = {p: t for p, (_, t) in tree.items()} for path, text in plan.writes.items(): if path not in failed: @@ -1299,6 +1296,54 @@ async def run(llm, argv: list[str]) -> int: for path in plan.deletes: if path not in failed: records.pop(path, None) + readings_cache.save() + rc = await _project(records, llm, summaries_cache, room_id=room_id, + bucket=bucket, rebuild=rebuild, + dry_run=dry_run and not as_json) + if as_json: + print(json.dumps(_report(cards, plan, tree, failed, bucket=bucket, + room_id=room_id, + posted=diary.card_threads(events)), + ensure_ascii=False)) + return rc or (1 if failed else 0) + + +def _vocabulary(bucket: str): + """What the model is offered and held to: the ontology, the family's + names (canonical form by every known name), the family block for the + prompt, and the household language code.""" + vault_dir = Path(os.environ.get("MEMORY_VAULT_DIR", "")) + language_code = (os.environ.get("LANGUAGE") or "en").strip().lower()[:2] + ontology = get_ontology(vault_dir if vault_dir.is_dir() else None) + # Person pages are generated, so they live in the brain, not the vault. + brain_dir = Path(os.environ.get("BRAIN_REPO_DIR", "")) + persons = load_persons_from_vault(brain_dir, bucket) if brain_dir.is_dir() else [] + people = {name.lower(): p.canonical for p in persons for name in p.all_known_names()} + if not people: + # No person pages yet: the household's names from the wiki, as + # whisper is primed with, so a first compile still knows them. + people = {name.lower(): name for name in _household_people()} + section = (persons_prompt_section(persons) + or "Family members: " + ", ".join(sorted(set(people.values())))) + return ontology, people, section, language_code + + +def _mirror() -> None: + """Ask the curator to bring the new records into the brain.""" + try: + request_mirror(Path(os.environ.get("CURATOR_STATE_DIR", "/data/memory/curator"))) + except OSError as e: + _err(f" could not ask the curator to mirror: {e}") + + +async def _project(records, llm, summaries_cache, *, room_id: str, + bucket: str, rebuild: bool, dry_run: bool) -> int: + """Build and publish the diary pages from the records in the vault. + + `records` maps each record's path to its content, as the vault holds + it after this run's writes. The pages are a view of these and of + nothing else, so every correction and hand edit shows. + """ on_file = [diary_card.parse(t) for t in records.values()] entries = sorted((diary_card.to_entry(c) for c in on_file), key=lambda e: (e.on, e.at)) @@ -1320,13 +1365,215 @@ async def run(llm, argv: list[str]) -> int: if summaries[key]: summaries_cache.put(key, digest, summaries[key]) summaries_cache.save() - - readings_cache.save() summaries_cache.save() pages = diary.pages_for(entries, room_id=room_id, summaries=summaries, media=diary_card.media_of(on_file)) - return _publish_pages(pages, bucket=bucket, dry_run=dry_run) or (1 if failed else 0) + return _publish_pages(pages, bucket=bucket, dry_run=dry_run) + + +def _card_json(card, *, path: str, room_id: str) -> dict: + """A card as the archivist posts it.""" + return { + "entry_id": card.entry_id, "path": path, + "root": card.event_ids[0], "room_id": room_id, + "date": card.on.isoformat(), "date_basis": card.confidence, + "date_label": diary.day_label(card.on), "at": card.at, + "title": card.title, "summary": card.summary, + "facts": card.facts, "persons": card.persons, + "tags": [t for t in card.tags if not t.startswith("Person: ")], + } + + +def _report(cards, plan, tree, failed, *, bucket: str, room_id: str, + posted: "set[str]") -> dict: + """What this run filed, for whoever posts the cards to the family. + + Every card of the run: `new`, `changed` or `unchanged`, and whether + its thread already holds a card notice (`posted`), so a card filed by + a run nobody announced still gets posted. `root` is the entry's first + message, which never changes, so a card's thread never moves. A card + the family owns is reported as they left it. Cards whose write failed + are left out: announcing them would promise something the vault does + not hold. + """ + on_file = {} + for _, text in tree.values(): + before = diary_card.parse(text) + on_file[before.entry_id] = before + out = [] + for card in cards: + path = diary_card.card_path(card, bucket=bucket) + if path in failed: + continue + before = on_file.get(card.entry_id) + if path in plan.writes: + state, shown = ("changed" if before is not None else "new"), card + else: + state, shown = "unchanged", (before or card) + out.append({**_card_json(shown, path=path, room_id=room_id), + "state": state, "posted": card.event_ids[0] in posted}) + return {"room_id": room_id, "cards": out, + "new": sum(1 for c in out if c["state"] == "new"), + "kept_as_edited": list(plan.kept)} + + +# ── Correcting a card ───────────────────────────────────────────────── +# +# A family member replies in a card's thread: "that was Lisa, not Bart". +# The archivist hands the words here. The correction is applied once, to +# the card as it is in the vault, and committed by the person who made +# it, their words in the commit. From then on the card is the family's +# and the compile leaves it alone. + +_CORRECT_TOKENS = 900 + +_CORRECT_PROMPT = """\ +A family member corrected an entry in the family archive. Apply the +correction to the entry's card and return the corrected card. Change +what the correction says is wrong and anything that follows from it; +keep everything else as it is. The correction is right wherever it +disagrees with the card or with the entry's words. + +{vocabulary} + +{people} + +The card as it is now: +{card} + +The entry's words: +{words} + +The correction, from {who}: +{correction} + +Reply with a JSON object with these keys, all in {language} except the +names of people and topics: +"title", "description", "summary" (one to three short sentences), +"facts" (each "Label: value", anchored on a name, a date, a number or a +place), "persons" (names from the family list only), "topics" (topics +from the vocabulary only), "quotes" (passages copied word for word from +the entry's words; keep the card's quotes unless the correction touches +them), and "date": the day the entry happened as YYYY-MM-DD when the +correction states it, otherwise null. +""" + + +def _card_prompt(card) -> str: + lines = [f"Title: {card.title}", f"Date: {card.on.isoformat()}", + f"Description: {card.description}", + f"People: {', '.join(card.persons)}", + f"Topics: {', '.join(t for t in card.tags if not t.startswith('Person: '))}", + f"Summary: {card.summary}"] + lines += ["Facts:"] + [f"- {f}" for f in card.facts] + if card.quotes: + lines += ["Quotes:"] + [f"- {q}" for q in card.quotes] + return "\n".join(lines) + + +def _words_prompt(card) -> str: + lines = [f"Recorded by {card.sender.title()}:", card.body.strip() or "(no words)"] + for who, text in card.replies: + lines += ["", f"{who.title()} replied: {text}"] + return "\n".join(lines) + + +def _room_of(card) -> str: + """The room a card came from, read off its permalink.""" + tail = card.resource.split("/#/", 1)[-1] if card.resource else "" + return tail.split("/", 1)[0] if tail.startswith("!") else "" + + +async def correct(llm, argv: list[str]) -> int: + """`diary correct <entry_id> --by <person> --event <event_id> --text <words>` + + Prints the corrected card as JSON. Running it again for the same + correction event changes nothing: the event id is on the card once + it is applied. + """ + entry_id = argv[0] if argv and not argv[0].startswith("-") else "" + by, event_id, words = (_opt(argv, "--by", ""), _opt(argv, "--event", ""), + _opt(argv, "--text", "").strip()) + if not (entry_id and by and event_id and words): + _err("usage: diary correct <entry_id> --by <person> --event <event_id> --text <words>") + return 2 + bucket = os.environ.get("SHARED_BUCKET", "family") + diary.configure_language(os.environ.get("LANGUAGE", "")) + + client = _vault_client() + if client is None: + _err("no Forgejo address or admin credentials here") + return 1 + try: + tree = _read_diary_tree(client, bucket) + except ForgejoError as e: + _err(f"could not read the diary records from the vault: {e}") + return 1 + found = [(path, sha, text) for path, (sha, text) in tree.items() + if diary_card.parse(text).entry_id == entry_id] + if not found: + _err(f"no diary card with entry id {entry_id}") + return 1 + old_path, old_sha, old_text = found[0] + card = diary_card.parse(old_text) + room_id = _room_of(card) + if event_id in card.event_ids: + _err(f" {event_id} is already applied to {old_path}") + print(json.dumps({**_card_json(card, path=old_path, room_id=room_id), + "changed": False}, ensure_ascii=False)) + return 0 + + ontology, people, people_section, language_code = _vocabulary(bucket) + prompt = _CORRECT_PROMPT.format( + vocabulary=ontology.classifier_prompt_section(language_code), + people=people_section, card=_card_prompt(card), + words=_words_prompt(card), who=by.title(), correction=words, + language=_household_language()) + try: + raw = json.loads(await llm.complete( + "classifier", prompt, json_mode=True, temperature=0, + max_tokens=_CORRECT_TOKENS, timeout=_EXTRACT_TIMEOUT_S)) + except (LLMError, json.JSONDecodeError, TypeError) as e: + _err(f"could not apply the correction: {e}") + return 1 + extraction = diary_card.extraction_from( + raw, ontology=ontology, language=language_code, people=people, + model=os.environ.get("AI_DEFAULT_MODEL", "")) + extraction = diary_card.Extraction(**{ + **extraction.__dict__, + "quotes": _sure_quotes(extraction.quotes, card.event_ids)}) + fixed = diary_card.correct(card, extraction, event_id=event_id) + new_path = diary_card.card_path(fixed, bucket=bucket) + text = diary_card.render(fixed, family_owned=True) + + if new_path == old_path: + changes = [FileChange("update", old_path, content=text, sha=old_sha)] + else: + changes = [FileChange("delete", old_path, sha=old_sha), + FileChange("create", new_path, content=text)] + name, email = _identity(by) + try: + client.change_files(REPO_OWNER, REPO_NAME, changes, author_name=name, + author_email=email, message=_commit_message( + [fixed], new=0, verb="correct", + corrections=[(by, words, event_id)])) + except ForgejoError as e: + _err(f"could not write the corrected card: {e}") + return 1 + _err(f" corrected {new_path}" + (f" (moved from {old_path})" if new_path != old_path else "")) + _mirror() + + records = {p: t for p, (_, t) in tree.items() if p != old_path} + records[new_path] = text + _, summaries_cache = diary_store.open_stores( + summary_fingerprint=transcripts.fingerprint(_SUMMARY_PROMPT)) + rc = await _project(records, llm, summaries_cache, room_id=room_id, + bucket=bucket, rebuild=False, dry_run=False) + print(json.dumps({**_card_json(fixed, path=new_path, room_id=room_id), + "changed": True, "moved_from": old_path if new_path != old_path else ""}, + ensure_ascii=False)) + return rc def _publish_pages(pages, *, bucket: str, dry_run: bool) -> int: diff --git a/stacklets/memory/bot/diary.py b/stacklets/memory/bot/diary.py index 4a3e0757..65e7addb 100644 --- a/stacklets/memory/bot/diary.py +++ b/stacklets/memory/bot/diary.py @@ -59,6 +59,7 @@ "vocab_topics": "They often talk about {topics}.", "basis_spoken": "dated from the spoken opening", "basis_sent": "dated from when it was sent", + "basis_corrected": "dated by a correction from the family", "basis_burst": ("arrived in a sync burst with no spoken date, " "so this is the week it surfaced, not when it " "happened"), @@ -117,6 +118,7 @@ "vocab_topics": "Sie sprechen oft \u00fcber {topics}.", "basis_spoken": "datiert nach dem gesprochenen Datum", "basis_sent": "datiert nach dem Sendezeitpunkt", + "basis_corrected": "datiert nach einer Korrektur aus der Familie", "basis_burst": ("kam in einem Sync-Schub ohne gesprochenes " "Datum an; eingeordnet in der Woche des " "Auftauchens, nicht des Geschehens"), @@ -192,6 +194,11 @@ def _day_heading(on: date) -> str: day=_L["days"][on.weekday()], dom=on.day, month=_month_name(on)) +def day_label(on: date) -> str: + """A day as a family reads it, with the year: "Sunday, 20 September 2026".""" + return f"{_day_heading(on)} {on.year}" + + def _counted(n: int, one: str, many: str) -> str: return f"{n} {_L[one] if n == 1 else _L[many]}" @@ -220,6 +227,8 @@ class Message: reply_to: str | None = None burst: str | None = None zone: tzinfo = timezone.utc + # The first message of the thread this one was written in, if any. + thread_root: str | None = None @property def sent_on(self) -> date: @@ -418,6 +427,13 @@ def resolve(events, *, burst_window_s: float = DEFAULT_BURST_WINDOW_S, info = content.get("info") or {} in_reply_to = (relates.get("m.in_reply_to") or {}).get("event_id") + # In a thread, a client adds `m.in_reply_to` pointing at the + # thread's latest event so older clients can show something. + # The thread is the relation that means something here. + thread_root = (relates.get("event_id") + if relates.get("rel_type") == "m.thread" else None) + if thread_root: + in_reply_to = None body = content.get("body", "") if in_reply_to: @@ -441,6 +457,7 @@ def resolve(events, *, burst_window_s: float = DEFAULT_BURST_WINDOW_S, duration_ms=info.get("duration"), reply_to=in_reply_to, zone=zone, + thread_root=thread_root, )) plain.sort(key=lambda m: (m.ts, m.event_id)) @@ -451,6 +468,32 @@ def resolve(events, *, burst_window_s: float = DEFAULT_BURST_WINDOW_S, return mark_bursts(resolved, window_s=burst_window_s) +# The envelope types of the archivist's notices carrying an entry's card: +# the first filing, and each refiling after a correction (the archivist's +# own pattern for documents). Their thread is where a family member talks +# to the archivist about the card. +CARD_EVENTS = ("diary.filed", "diary.reclassified") + + +def card_threads(events) -> set[str]: + """The roots of the threads that hold a diary card. + + Found from the room itself, like everything else the compiler knows: + a thread is a card's thread when the archivist's card notice is in + it. Read from the raw events, because `resolve` drops bot messages. + """ + roots: set[str] = set() + for ev in events: + content = ev.get("content") or {} + envelope = content.get("dev.famstack.event") or {} + relates = content.get("m.relates_to") or {} + if (isinstance(envelope, dict) and envelope.get("type") in CARD_EVENTS + and relates.get("rel_type") == "m.thread" + and relates.get("event_id")): + roots.add(relates["event_id"]) + return roots + + def mark_bursts(messages, *, window_s: float = DEFAULT_BURST_WINDOW_S): """Label runs of messages that reached the server together. @@ -623,8 +666,8 @@ def basis_text(confidence: str) -> str: A diary record stores only the word (`spoken`, `sent`, `uncertain`); the sentence is chrome and follows the language the pages render in. """ - return _L[{"spoken": "basis_spoken", "uncertain": "basis_burst"} - .get(confidence, "basis_sent")] + return _L[{"spoken": "basis_spoken", "uncertain": "basis_burst", + "corrected": "basis_corrected"}.get(confidence, "basis_sent")] def plain_title(entry: "Entry") -> str: @@ -673,7 +716,9 @@ def _remarks_only_on_what_is_still_in_view(messages, refers_to): def compile_entries(messages, readings, *, continues: "dict[str, str] | None" = None, - refers_to: "dict[str, str] | None" = None) -> list[Entry]: + refers_to: "dict[str, str] | None" = None, + card_roots: "set[str] | frozenset[str]" = frozenset(), + ) -> list[Entry]: """Messages and their readings to dated diary entries. Some messages do not earn an entry of their own. A reply belongs to @@ -685,6 +730,13 @@ def compile_entries(messages, readings, *, remark floating with no subject. """ continues = continues or {} + # A card's thread is a conversation with the archivist about that + # card, corrections included, and none of it is a memory: corrections + # reach the card through `stack memory diary correct`, and the vault + # commit keeps them. Any other thread is a conversation about the + # message it hangs off, which is what a reply is, so it becomes one. + messages = [replace(m, reply_to=m.thread_root) if m.thread_root else m + for m in messages if m.thread_root not in card_roots] about = _remarks_only_on_what_is_still_in_view(messages, refers_to or {}) # Join first (a split recording is two adjacent uploads), then diff --git a/stacklets/memory/bot/diary_card.py b/stacklets/memory/bot/diary_card.py index b2358e86..abe2a9b8 100644 --- a/stacklets/memory/bot/diary_card.py +++ b/stacklets/memory/bot/diary_card.py @@ -27,7 +27,7 @@ import hashlib import re -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from datetime import date, datetime, timezone from pathlib import Path @@ -63,9 +63,13 @@ class Extraction: facts: list[str] = field(default_factory=list) quotes: list[str] = field(default_factory=list) model: str = "" + # A day a family member's correction states, as YYYY-MM-DD. Honoured + # only for an entry that has corrections (see `to_card`). + date: str = "" _MAX_QUOTES = 3 +_LIST_MARKER = re.compile(r"^(?:\s*[-*\u2022]\s+)+") def _strings(value) -> list[str]: @@ -76,6 +80,16 @@ def _strings(value) -> list[str]: return [" ".join(v.split()) for v in value if isinstance(v, str) and v.strip()] +def _iso_day(value) -> str: + """`value` if it is a YYYY-MM-DD day, else "".""" + if not isinstance(value, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value.strip()): + return "" + try: + return date.fromisoformat(value.strip()).isoformat() + except ValueError: + return "" + + def _unique(values: list[str]) -> list[str]: out: list[str] = [] for v in values: @@ -118,9 +132,10 @@ def text(key: str) -> str: persons=persons, tags=[*topics, *(f"Person: {p}" for p in persons)], summary=text("summary"), - facts=_strings(raw.get("facts")), + facts=[f for f in (_LIST_MARKER.sub("", f) for f in _strings(raw.get("facts"))) if f], quotes=_strings(raw.get("quotes"))[:_MAX_QUOTES], model=model, + date=_iso_day(raw.get("date")), ) @@ -175,6 +190,9 @@ def to_card(entry: "diary.Entry", extraction: Extraction, *, room_id: str, entry's own files. """ first = entry.event_ids[0] + # The room's evidence dates an entry: a spoken date, or the day it was + # sent. A date the model offers is ignored here; only a family + # member's correction can move it (`correct`). return Card( entry_id=entry_id_for(first), on=entry.on, @@ -201,6 +219,35 @@ def to_card(entry: "diary.Entry", extraction: Extraction, *, room_id: str, ) +def correct(card: Card, extraction: Extraction, *, event_id: str) -> Card: + """The card after a family member's correction. + + `extraction` is the model's reading of the current card with the + correction applied. It replaces everything the model reads out of an + entry (title, description, people, tags, summary, facts, quotes) and, + when it names a day, the date. The family's words, replies and files + stay as they are. The correction's event id joins the card's, so the + card leads back to the message that changed it; the words of the + correction are kept by the vault commit, not on the card. + """ + on, confidence = card.on, card.confidence + if extraction.date: + on, confidence = date.fromisoformat(extraction.date), "corrected" + return replace( + card, on=on, confidence=confidence, + title=extraction.title.strip() or card.title, + description=extraction.description.strip(), + persons=list(extraction.persons), + tags=list(extraction.tags), + summary=" ".join(extraction.summary.split()), + facts=[f.strip() for f in extraction.facts if f.strip()], + quotes=[" ".join(q.split()) for q in extraction.quotes if q.strip()], + model=extraction.model or card.model, + event_ids=[*card.event_ids, event_id] if event_id not in card.event_ids + else list(card.event_ids), + ) + + def to_entry(card: Card) -> "diary.Entry": """The entry a card describes, ready for the diary pages. @@ -354,9 +401,15 @@ def _digest_of(text: str) -> str: return hashlib.sha256(normal.encode("utf-8")).hexdigest()[:16] -def render(card: Card) -> str: - """The card as a vault file, signed with the digest of its content.""" +def render(card: Card, *, family_owned: bool = False) -> str: + """The card as a vault file, signed with the digest of its content. + + A card a family member corrected is written unsigned: it is theirs + from then on, and the compile leaves it alone (`edited_by_hand`). + """ unsigned = _unsigned(card) + if family_owned: + return unsigned head, sep, rest = unsigned.partition("\n---\n") return f"{head}\ndigest: {_digest_of(unsigned)}{sep}{rest}" @@ -364,18 +417,19 @@ def render(card: Card) -> str: def edited_by_hand(text: str) -> bool: """Whether a person has changed this file since the compiler wrote it. - A file without a digest was never written by the compiler, and a - file whose content no longer matches its digest was changed after. - Either way it is a person's, and the compiler leaves it alone. + A file without a digest was written by a person or corrected by one + (`render(..., family_owned=True)`), and a file whose content no longer + matches its digest was changed after the compiler wrote it. Either + way it is the family's, and the compiler leaves it alone. """ stored = str(frontmatter_parse(text).get("digest") or "") return not stored or stored != _digest_of(text) -def _sections(body: str) -> "tuple[list[str], list[tuple[str, str]], str]": - """The quotes, replies and words of a card body, in that order.""" +def _sections(body: str): + """The quotes, replies and words of a card body.""" quotes: list[str] = [] - replies: list[tuple[str, str]] = [] + said: dict[str, list[tuple[str, str]]] = {_REPLIES: []} words = "" lines = body.splitlines() heads = [f"## {h}" for h in (*_BODY_HEADINGS.values(), _CAPTION)] @@ -392,15 +446,16 @@ def _sections(body: str) -> "tuple[list[str], list[tuple[str, str]], str]": continue if section == _QUOTES and ln.startswith("- "): quotes.append(ln[2:].strip()) - elif section == _REPLIES: + elif section in said: + block = said[section] if m := _REPLY_HEAD.match(ln.strip()): - replies.append((m.group(1), "")) - elif ln.startswith(">") and replies: - who, text = replies[-1] + block.append((m.group(1), "")) + elif ln.startswith(">") and block: + who, text = block[-1] part = ln[2:] if ln.startswith("> ") else ln[1:] - replies[-1] = (who, f"{text}\n{part}" if text else part) - replies = [(who, text.strip("\n")) for who, text in replies] - return quotes, replies, words + block[-1] = (who, f"{text}\n{part}" if text else part) + tidy = {k: [(who, text.strip("\n")) for who, text in v] for k, v in said.items()} + return quotes, tidy[_REPLIES], words def parse(text: str) -> Card: @@ -485,10 +540,13 @@ def plan(existing: "dict[str, str]", cards, *, bucket: str, continue old_path, old_text = on_disk[card.entry_id] if edited_by_hand(old_text): - # The digest a person's file still carries is the one the + # The digest a hand-edited file still carries is the one the # compiler signed it with, so it says whether the room has - # moved on since. Only then is there anything to report. - if str(frontmatter_parse(old_text).get("digest") or "") != _digest_of(text): + # moved on since. Only then is there anything to report. A + # file with no digest (written or corrected by the family) + # is theirs by design and kept without a word. + signed = str(frontmatter_parse(old_text).get("digest") or "") + if signed and signed != _digest_of(text): out.kept.append(old_path) continue if old_path != path: diff --git a/tests/unit/stacklets/test_memory_diary.py b/tests/unit/stacklets/test_memory_diary.py index 75417f66..a6cd40c5 100644 --- a/tests/unit/stacklets/test_memory_diary.py +++ b/tests/unit/stacklets/test_memory_diary.py @@ -1240,3 +1240,63 @@ def test_a_person_whose_name_ends_in_bot_is_not_a_bot(self): assert [m.sender for m in messages] == ["abbot"] + + +# ── Corrections in a card's thread ──────────────────────────────────── + + +class TestACardsThreadIsNotTheDiary: + """The archivist posts each entry's card as a notice in a thread on + the entry's first message. What the family writes in that thread is + addressed to the archivist, corrections included, and never becomes + a diary entry or a reply on the page. A thread without a card is a + family conversation about a memory, and stays what it was: a reply + attached to it.""" + + ROOT = "$memo" + + def _memo(self): + return {"type": "m.room.message", "event_id": self.ROOT, + "sender": "@marge:simpson", "origin_server_ts": BASE_TS, + "content": {"msgtype": "m.text", "body": "Bart played the saxophone today."}} + + def _card_notice(self, kind="diary.filed"): + return {"type": "m.room.message", "event_id": "$card", + "sender": "@archivist-bot:simpson", "origin_server_ts": BASE_TS + 60_000, + "content": {"msgtype": "m.notice", "body": "Saxophone", + "dev.famstack.event": {"type": kind, "data": {"entry_id": "x"}}, + "m.relates_to": {"rel_type": "m.thread", + "event_id": self.ROOT}}} + + def _in_thread(self, event_id, body, reply_to="$card"): + return {"type": "m.room.message", "event_id": event_id, + "sender": "@homer:simpson", "origin_server_ts": BASE_TS + 120_000, + "content": {"msgtype": "m.text", "body": body, + "m.relates_to": { + "rel_type": "m.thread", "event_id": self.ROOT, + "is_falling_back": True, + "m.in_reply_to": {"event_id": reply_to}}}} + + def _compile(self, events): + return diary.compile_entries(diary.resolve(events), {}, + card_roots=diary.card_threads(events)) + + def test_the_cards_thread_is_found_from_the_notice_in_it(self): + assert diary.card_threads([self._memo(), self._card_notice()]) == {self.ROOT} + assert diary.card_threads([self._memo(), + self._card_notice("diary.reclassified")]) == {self.ROOT} + + def test_a_correction_in_the_cards_thread_is_neither_entry_nor_reply(self): + entries = self._compile([self._memo(), self._card_notice(), + self._in_thread("$fix", "That was Lisa, not Bart.")]) + + assert len(entries) == 1 + assert entries[0].comments == [] + assert "$fix" not in entries[0].event_ids + + def test_a_thread_without_a_card_is_a_conversation_about_the_memory(self): + entries = self._compile([self._memo(), + self._in_thread("$chat", "He was so proud!", reply_to=self.ROOT)]) + + assert len(entries) == 1 + assert entries[0].comments == [("homer", "He was so proud!")] diff --git a/tests/unit/stacklets/test_memory_diary_card.py b/tests/unit/stacklets/test_memory_diary_card.py index e632532f..b78cbe16 100644 --- a/tests/unit/stacklets/test_memory_diary_card.py +++ b/tests/unit/stacklets/test_memory_diary_card.py @@ -319,6 +319,13 @@ def test_the_words_come_through_trimmed_and_facts_stay_facts(self): assert read.quotes == ["One.", "Two.", "Three."] assert read.model == "test-model" + def test_a_fact_is_the_fact_not_the_list_marker_the_model_copied(self): + """Shown a card's facts as a list, a model hands them back with the + markers on, and the callout would render "- - Date".""" + assert _read(facts=["- Date: 2026-09-20", "* Place: lake", "• Who: Bart", + "- - Action: Lisa built the tower"]).facts == [ + "Date: 2026-09-20", "Place: lake", "Who: Bart", "Action: Lisa built the tower"] + def test_an_answer_that_is_not_an_object_reads_as_nothing(self): empty = diary_card.extraction_from(None, ontology=SEED_ONTOLOGY, language="en", people=PEOPLE, model="m") @@ -360,3 +367,81 @@ def test_the_family_life_topics_share_no_word_with_anything_else(self): if len(o) > 1 and o & {f"topic.{k}" for k in life}} assert shared == {} assert life <= set(SEED_ONTOLOGY.topics) + + +# ── Corrections ─────────────────────────────────────────────────────── + + +def _card(): + entry = _entry_for(_compile(), "memo-bart-zeugnis") + return diary_card.to_card(entry, _extraction(), room_id=ROOM, media={}) + + +class TestACorrectionMakesTheCardTheFamilys: + """A correction is a family member's reply in the card's thread. It is + applied once, to the card as it is in the vault, and from then on the + card is the family's: the nightly compile leaves it alone, like a card + edited by hand. The vault commit that applies it keeps the words.""" + + def test_a_correction_updates_what_the_model_read_and_leaves_the_words(self): + card = _card() + fixed = diary_card.correct(card, _extraction( + title="Lisa's report card", persons=["Lisa", "Marge"], + facts=["Report card: Lisa's"]), event_id="$fix") + + assert fixed.title == "Lisa's report card" + assert fixed.persons == ["Lisa", "Marge"] + assert fixed.facts == ["Report card: Lisa's"] + assert fixed.body == card.body + assert fixed.replies == card.replies + assert fixed.event_ids == [*card.event_ids, "$fix"] + + def test_a_correction_can_move_the_date(self): + fixed = diary_card.correct(_card(), _extraction(date="2026-03-17"), event_id="$fix") + + assert fixed.on == date(2026, 3, 17) + assert fixed.confidence == "corrected" + assert diary_card.to_entry(fixed).basis == diary.basis_text("corrected") + + def test_a_correction_without_a_date_keeps_the_date(self): + fixed = diary_card.correct(_card(), _extraction(), event_id="$fix") + + assert fixed.on == date(2026, 3, 16) + assert fixed.confidence == "spoken" + + def test_the_model_cannot_redate_an_entry_outside_a_correction(self): + """The spoken date or the day it was sent is the room's own + evidence. A model's guess does not override it; a person does.""" + entry = _entry_for(_compile(), "memo-bart-zeugnis") + card = diary_card.to_card(entry, _extraction(date="2020-01-01"), room_id=ROOM, media={}) + + assert card.on == date(2026, 3, 16) + + def test_a_corrected_card_is_the_familys_and_the_compile_keeps_it(self): + cards = _cards(_compile()) + fixed = diary_card.correct(cards[0], _extraction(title="Fixed"), event_id="$fix") + vault = _vault(cards) + del vault[diary_card.card_path(cards[0], bucket=BUCKET)] + vault[diary_card.card_path(fixed, bucket=BUCKET)] = diary_card.render(fixed, family_owned=True) + + assert diary_card.edited_by_hand(diary_card.render(fixed, family_owned=True)) + plan = diary_card.plan(vault, cards, bucket=BUCKET, complete=True) + assert plan.writes == {} and plan.deletes == [] + + def test_a_corrected_card_is_kept_without_being_reported_every_night(self): + """"Kept" tells the family the room moved on under a card they + edited. A corrected card is theirs by design; saying so every + night would be noise.""" + cards = _cards(_compile()) + fixed = diary_card.correct(cards[0], _extraction(title="Fixed"), event_id="$fix") + vault = _vault(cards) + vault[diary_card.card_path(cards[0], bucket=BUCKET)] = diary_card.render(fixed, family_owned=True) + changed = replace(cards[0], replies=[("homer", "A new reply.")]) + + plan = diary_card.plan(vault, [changed, *cards[1:]], bucket=BUCKET, complete=True) + assert plan.kept == [] and plan.writes == {} + + def test_the_model_offers_a_date_only_as_an_iso_day(self): + assert _read(date="17 March").date == "" + assert _read(date="2026-03-17").date == "2026-03-17" + From ce55f046d4177305f7bc45470a4317113950d5c5 Mon Sep 17 00:00:00 2001 From: Arthur <516104+arthware@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:01:13 +0200 Subject: [PATCH 10/17] feat(archivist): post diary cards in the memories room The diary's cards lived only in the vault, so the family never saw what was read out of their memories and had no easy way to put it right. The archivist now joins the memories room and, once a day (07:30 local time by default, `diary_job_at` in its `bot.toml`), posts each new card as a notice in a thread on the entry's message, laid out like its other filing replies, then one notice with the count and a link to the diary. The first one also explains how to correct a card. A reply in a card's thread, typed or spoken, corrects it: the archivist reacts, applies it, posts the corrected card in the thread and confirms. - Everything else in the memories room is left alone: no filing, no search, no welcome, and voice memos are not decoded by the archivist. - All posts are notices, which Element does not push to phones. - On its first run the archivist posts cards for the last 14 days only and counts the rest, so an existing room is not flooded. - The job runs on the household's clock, catches up when the machine slept through its time, and waits 30 minutes after a failed run. --- stacklets/docs/bot/archivist.py | 39 ++ stacklets/docs/bot/bot.toml | 3 + stacklets/docs/bot/diary_room.py | 344 ++++++++++++++++++ stacklets/docs/bot/messages/archivist.yml | 22 ++ tests/unit/stacklets/test_archivist_diary.py | 333 +++++++++++++++++ tests/unit/stacklets/test_archivist_source.py | 2 +- 6 files changed, 742 insertions(+), 1 deletion(-) create mode 100644 stacklets/docs/bot/diary_room.py create mode 100644 tests/unit/stacklets/test_archivist_diary.py diff --git a/stacklets/docs/bot/archivist.py b/stacklets/docs/bot/archivist.py index 81f1e4c0..4bad34c1 100644 --- a/stacklets/docs/bot/archivist.py +++ b/stacklets/docs/bot/archivist.py @@ -89,6 +89,7 @@ split_scan_command as _split_scan_command, strip_reply_fallback as _strip_reply_fallback, ) +from diary_room import DEFAULT_JOB_AT, DiaryRoom # noqa: E402 from reply_presenter import ( # noqa: E402 render_capture_reply, render_filing_reply, @@ -374,6 +375,16 @@ def __init__( self.documents_room_alias = normalize_alias( settings.get("documents_room_alias", "documents"), ) + # The memories room is the family's own. The archivist posts the + # diary's cards there once a day and takes corrections in their + # threads, and says nothing else (see diary_room.py). + self._diary = DiaryRoom( + self, + alias=normalize_alias(settings.get("memories_room_alias", "memories")), + job_at=str(settings.get("diary_job_at", DEFAULT_JOB_AT)), + state_path=Path(self._session_dir) / "diary-room.json", + ) + self._diary_task: asyncio.Task | None = None # Captures are bookmarks (URL pointer + LLM summary) by default — # the source URL is the truth, the digest is the marker. Flip # `capture_keep_body = true` in bot.toml to also archive the @@ -430,6 +441,10 @@ async def start(self) -> None: classifier = self._services.classifier if self.classify_enabled and classifier is not None: asyncio.create_task(classifier.has_vision()) + # The diary's daily job, on every boot for the same reason: the + # first-sync hook runs once per install, not once per start. + if self._diary_task is None: + self._diary_task = asyncio.create_task(self._diary.loop()) # The framework's start() owns the session loop and closes the # http session (via _aclose) on shutdown. await super().start() @@ -680,6 +695,14 @@ def _is_documents_room(self, ctx) -> bool: """ return bool(self.documents_room_alias) and ctx.alias == self.documents_room_alias + def _wants_voice(self, room, event) -> bool: + """In the memories room, only a voice reply in a thread is ours to + hear: it may be a correction to a card. A memo is the family's, + and the diary transcribes it on its own schedule.""" + if self._diary.is_room(self._room_context(room)): + return self.get_thread_root(event) is not None + return True + # ── Topic-room routing ───────────────────────────────────────────── # # A Matrix room whose name starts with `Thema:` (de) or `Topic:` (en) @@ -1015,6 +1038,10 @@ async def _send_room_welcome_if_needed(self, room, ctx) -> None: events in a fresh room cannot produce a second welcome while the first send is in flight. """ + # The memories room gets no welcome: the diary's first daily + # notices explain what the cards are, in the family's own room. + if self._diary.is_room(ctx): + return if room is None: return @@ -1527,6 +1554,10 @@ async def _on_file(self, room, event) -> None: return ctx = self._room_context(room) + # A photo or a recording in the memories room is a memory, not a + # document: the diary files it, and the archivist leaves it be. + if self._diary.is_room(ctx): + return # First-encounter welcome -- idempotent, gated by a per-room # state event. Runs ahead of the routing decision so the user # always sees the intro before any other reply from the bot. @@ -1626,6 +1657,12 @@ async def _on_text(self, room, event: RoomMessageText) -> None: if event.sender == self.user_id: return + # The memories room is the family's; only replies in our card + # threads are ours, and they are corrections (diary_room.py). + if self._diary.is_room(self._room_context(room)): + await self._diary.on_text(room, event) + return + # Inbound source event: a `dev.famstack.source` block means another # bot (the mail bot today) already fetched external content and # posted it here. Fold it through the capture pipeline — one capture @@ -1880,6 +1917,8 @@ async def _on_reaction(self, room, event) -> None: """ if event.sender == self.user_id or self.is_bot_user(event.sender): return + if self._diary.is_room(self._room_context(room)): + return emoji = self.normalize_emoji(getattr(event, "key", "")) handler = self._reaction_handlers().get(emoji) if handler is None: diff --git a/stacklets/docs/bot/bot.toml b/stacklets/docs/bot/bot.toml index d7528b8e..3e0a8fc0 100644 --- a/stacklets/docs/bot/bot.toml +++ b/stacklets/docs/bot/bot.toml @@ -3,6 +3,9 @@ name = "Archivist" description = "Auto-files documents with AI classification" room = "documents" room_topic = "Drop photos, PDFs, or links here. They get filed and tagged automatically. Type 'help' for tips." +# The memories room belongs to the messages stacklet. The archivist posts +# the diary's cards there and takes corrections in their threads. +join_rooms = ["memories"] [settings] classify = true diff --git a/stacklets/docs/bot/diary_room.py b/stacklets/docs/bot/diary_room.py new file mode 100644 index 00000000..83a751c5 --- /dev/null +++ b/stacklets/docs/bot/diary_room.py @@ -0,0 +1,344 @@ +"""The archivist in the memories room: the diary's face in the chat. + +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. + +The diary itself belongs to the memory stacklet. This module only talks +to the family and calls `stack memory diary` through the same entry +point the curator uses, so the compile, the cards and the vault writes +stay in one place. The curator compiles the diary every night whether +or not the archivist exists; the archivist's daily job compiles again +(cached, so cheap) and announces what is not announced yet. + +Notices are `m.notice`, which Element's default push rules keep silent: +nothing here wakes a phone. + +The daily job is deliberately simple: a check once a minute against a +local time of day and the date of the last run, which also catches up +after a night the machine slept through. A framework mechanism for jobs +like this one is planned; until then it lives here. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from loguru import logger + +from stack.links import go_topic, public +from reply_presenter import _fact_lines +from text_utils import strip_reply_fallback + +ENTRYPOINT = "/stacklets/memory/bot/cli_entrypoint.py" + +CARD_FILED = "diary.filed" +CARD_RECLASSIFIED = "diary.reclassified" + +# When the daily job runs, local time. +DEFAULT_JOB_AT = "07:30" +# How long one compile may take. The first one over a large room +# transcribes every recording in it. +JOB_TIMEOUT_S = 3 * 3600 +CORRECT_TIMEOUT_S = 600 +# On the first run, the room may hold years of memories. Only the recent +# 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. +EXPLAIN_TIMES = 1 + + +# ── Pure parts ──────────────────────────────────────────────────────── + + +# After a failed run, the job waits this long before trying again: the +# usual cause is the AI server being away, and a compile every minute +# until it is back helps nobody. +RETRY_AFTER = timedelta(minutes=30) + + +def household_now(now: datetime | None = None) -> datetime: + """The time where the family lives. + + The bot runner's clock is UTC; the household's timezone comes from + `TIMEZONE`, as it does for the diary compile. Without one, UTC. + """ + now = now or datetime.now(timezone.utc) + try: + zone = ZoneInfo(os.environ.get("TIMEZONE", "").strip() or "UTC") + except (ZoneInfoNotFoundError, ValueError): + zone = ZoneInfo("UTC") + return now.astimezone(zone) + + + +def job_due(at: str, last_run: str, now: datetime) -> bool: + """Whether the daily job should run now. + + `at` is "HH:MM" local time, `last_run` the local date of the last run + ("" if never), `now` the local time. Due once a day, at or after `at`, + so a machine that slept through the hour runs the job when it wakes. + """ + try: + hour, minute = (int(x) for x in at.split(":", 1)) + except ValueError: + return False + if last_run == now.date().isoformat(): + return False + return (now.hour, now.minute) >= (hour, minute) + + +def read_report(stdout: str) -> dict | None: + """The report `stack memory diary --json` prints on its last line.""" + for line in reversed(stdout.strip().splitlines()): + line = line.strip() + if line.startswith("{"): + try: + report = json.loads(line) + except json.JSONDecodeError: + return None + return report if isinstance(report, dict) else None + return None + + +def to_announce(report: dict, *, since_ms: int) -> tuple[list[dict], int]: + """The cards to post, and how many unposted cards are older than that. + + A card is posted once, when its thread holds no card notice yet. On + the first run `since_ms` holds back the room's backlog: those cards + are counted, not posted one by one. + """ + fresh, older = [], 0 + for card in report.get("cards") or []: + if card.get("posted"): + continue + if int(card.get("at") or 0) >= since_ms: + fresh.append(card) + else: + older += 1 + return fresh, older + + +def card_text(card: dict, t, *, corrected: bool = False, hint: bool = False) -> str: + """The notice that shows a card in its thread. + + Shaped like the archivist's other filing replies (`reply_presenter`): + the title, one row of topics and people, the summary, the facts. `t` + is the archivist's translator, bound to the household language. + """ + head = "diary_card_corrected" if corrected else "diary_card" + lines = [t(head, title=card.get("title", ""), date=card.get("date_label") + or card.get("date", ""))] + about = [*card.get("tags", []), *card.get("persons", [])] + if about: + lines += ["", " " + " | ".join(about)] + if card.get("summary"): + lines += ["", f" {card['summary']}"] + lines += _fact_lines(card.get("facts", [])) + if hint: + lines += ["", f" {t('diary_card_hint')}"] + 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) + return f"{text} [{t('diary_open')}]({link})" if link else text + + +def envelope(card: dict, kind: str, *, actor: str) -> dict: + """The famstack event the card notice carries: the item it files.""" + return { + "source": "docs", + "type": kind, + "summary": card.get("title", ""), + "data": {k: card.get(k) for k in ( + "entry_id", "path", "root", "date", "title", "persons", "tags")}, + "actor": actor, + "ts": datetime.now(timezone.utc).isoformat(), + } + + +# ── The room ────────────────────────────────────────────────────────── + + +class DiaryRoom: + """The archivist's behaviour in the memories room. + + Holds a reference to the bot for sending, reacting and reading + threads; everything the diary knows comes from the memory CLI. + """ + + def __init__(self, bot, *, alias: str, job_at: str, state_path: Path): + self.bot = bot + self.alias = alias + self.job_at = job_at + self.state_path = Path(state_path) + self._running = asyncio.Lock() + + def is_room(self, ctx) -> bool: + return bool(self.alias) and ctx.alias == self.alias + + # ── State ── + + def _state(self) -> dict: + try: + data = json.loads(self.state_path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except (OSError, json.JSONDecodeError): + return {} + + def _save(self, state: dict) -> None: + try: + self.state_path.parent.mkdir(parents=True, exist_ok=True) + self.state_path.write_text(json.dumps(state, indent=2), encoding="utf-8") + except OSError as e: + logger.warning("[archivist] could not save diary state: {}", e) + + def _t(self, key: str, **kwargs) -> str: + return self.bot.t(key, **kwargs) + + # ── Routing ── + + async def on_text(self, room, event) -> None: + """A message in the memories room, voice already decoded to text. + + Only a reply in one of our card threads is ours: it corrects the + card. Everything else is the family's and gets no answer. + """ + if self.bot.get_thread_root(event) is None: + return + if not await self.bot._thread_is_ours(room.room_id, event): + return + anchor = await self.bot._correction_anchor(room.room_id, event) + if anchor is None: + return + _, found = anchor + if not str(found.get("type", "")).startswith("diary."): + return + words = strip_reply_fallback(getattr(event, "body", "") or "").strip() + if words: + await self.correct(room.room_id, event, found, words) + + # ── Correcting ── + + async def correct(self, room_id: str, event, found: dict, words: str) -> None: + data = found.get("data") or {} + entry_id, root = data.get("entry_id"), data.get("root") + if not entry_id: + return + await self.bot._react(room_id, event.event_id, "👀") + by = event.sender.split(":")[0].lstrip("@") + rc, out, err = await _diary_cli( + "correct", entry_id, "--by", by, "--event", event.event_id, + "--text", words, timeout=CORRECT_TIMEOUT_S) + card = read_report(out) if rc == 0 else None + if card is None: + logger.warning("[archivist] diary correction failed rc={}: {}", rc, + err.strip().splitlines()[-1:] or "") + await self.bot._answer(room_id, self._t("diary_correct_failed"), + event.event_id, msgtype="m.notice") + return + await self.bot._send( + room_id, card_text(card, self._t, corrected=True), + reply_to=event.event_id, thread_root_event_id=root or card.get("root"), + metadata={self.bot.FAMSTACK_EVENT_KEY: envelope( + card, CARD_RECLASSIFIED, actor=self.bot.user_id)}, + msgtype="m.notice") + await self.bot._react(room_id, event.event_id, "✅") + + # ── The daily job ── + + def may_run(self, now: datetime) -> bool: + """False while a failed run is less than `RETRY_AFTER` ago.""" + failed = self._state().get("failed_at", "") + try: + return not failed or now - datetime.fromisoformat(failed) >= RETRY_AFTER + except ValueError: + return True + + async def loop(self) -> None: + """Check once a minute whether the daily job is due, and run it. + + Waits before the first check: the loop starts with the bot, and + the bot has to finish its first sync before it can post. + """ + while True: + await asyncio.sleep(60) + try: + now = household_now() + if (job_due(self.job_at, self._state().get("last_run", ""), now) + and self.may_run(now)): + await self.run_job(now) + except Exception as e: # noqa: BLE001 - the loop must survive a bad day + logger.exception("[archivist] diary job failed: {}", e) + + async def run_job(self, now: datetime | None = None) -> dict | None: + """Compile the diary, post the cards not posted yet, and the summary.""" + now = now or household_now() + async with self._running: + state = self._state() + if "since_ms" not in state: + since = now - timedelta(days=FIRST_RUN_LOOKBACK_DAYS) + state["since_ms"] = int(since.timestamp() * 1000) + rc, out, err = await _diary_cli("--json", timeout=JOB_TIMEOUT_S) + report = read_report(out) if rc == 0 else None + if report is None: + logger.warning("[archivist] diary job: compile failed rc={}: {}", rc, + err.strip().splitlines()[-1:] or "") + state["failed_at"] = now.isoformat() + self._save(state) + return None + state.pop("failed_at", None) + room_id = report.get("room_id", "") + fresh, older = to_announce(report, since_ms=int(state["since_ms"])) + explain = int(state.get("explained", 0)) < EXPLAIN_TIMES + for card in fresh: + await self.bot._send( + room_id, card_text(card, self._t, hint=explain), + reply_to=card["root"], thread_root_event_id=card["root"], + metadata={self.bot.FAMSTACK_EVENT_KEY: envelope( + card, CARD_FILED, actor=self.bot.user_id)}, + msgtype="m.notice") + added = len(fresh) + older + if 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 + state["last_run"] = now.date().isoformat() + self._save(state) + logger.info("[archivist] diary job: {} card(s) posted, {} counted", len(fresh), older) + return report + + +async def _diary_cli(*args: str, timeout: float) -> tuple[int, str, str]: + """Run `stack memory diary` through its entry point, as the curator does.""" + try: + proc = await asyncio.create_subprocess_exec( + sys.executable, ENTRYPOINT, "diary", *args, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + env=os.environ.copy()) + except Exception as e: # noqa: BLE001 + return 1, "", f"could not start: {e}" + try: + out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except TimeoutError: + proc.kill() + return 1, "", f"timed out after {timeout}s" + return proc.returncode or 0, out.decode(errors="replace"), err.decode(errors="replace") diff --git a/stacklets/docs/bot/messages/archivist.yml b/stacklets/docs/bot/messages/archivist.yml index 32d1a710..292fc976 100644 --- a/stacklets/docs/bot/messages/archivist.yml +++ b/stacklets/docs/bot/messages/archivist.yml @@ -216,6 +216,17 @@ en: **Pro tip:** create a `Thema: <name>` room (e.g. `Thema: Camping`) and invite me — everything you drop there will be organised under that topic automatically. + # Diary, in the memories room (diary_room.py) + 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_open: "Open the diary" + diary_correct_failed: "I could not apply that just now. It is kept in this thread; reply again later to retry." + de: # Startup startup: "\u270D\uFE0F Der Archivar ist zurück. Gebt mir eure Dokumente." @@ -412,3 +423,14 @@ de: **help** tippen für diese Nachricht. **Profi-Tipp:** Erstellt einen Raum mit dem Präfix `Thema:` (z.B. `Thema: Camping`) und ladet mich ein — alles dort wird automatisch unter diesem Thema organisiert. + + # Tagebuch, im Erinnerungen-Raum (diary_room.py) + 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_open: "Tagebuch öffnen" + 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 new file mode 100644 index 00000000..6e8386e0 --- /dev/null +++ b/tests/unit/stacklets/test_archivist_diary.py @@ -0,0 +1,333 @@ +"""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 +thread corrects the card. Anything else the family posts there is theirs +and gets no answer: no filing, no search, no welcome, no reaction. + +Routing is driven from the outside with Matrix shapes taken from the +spec (the same fixtures as `test_archivist_corrections.py`). The daily +job calls `stack memory diary` in a subprocess; here that boundary is +stood in by a report in the shape the CLI prints, and the rig runs the +real one. +""" + +from __future__ import annotations + +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path +from types import SimpleNamespace + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent +sys.path.insert(0, str(_REPO_ROOT / "lib")) +sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "core" / "bot-runner")) +sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) + +import diary_room # noqa: E402 +from archivist import ArchivistBot, _t # noqa: E402 +from test_archivist_corrections import FakeMatrix, _message, _thread_relation # noqa: E402 + +BOT_ID = "@archivist-bot:server" +MARGE = "@marge:server" +ROOM_ID = "!memories:server" + + +def _t_en(key, **kw): + return _t("en", key, **kw) + + +def _card(entry_id="e1", *, posted=False, at=1_800_000_000_000, **kw): + card = {"entry_id": entry_id, "path": f"family/diary/entries/2026/09/2026-09-20-{entry_id}.md", + "root": f"${entry_id}", "room_id": ROOM_ID, "date": "2026-09-20", + "date_label": "Sunday, 20 September 2026", "date_basis": "spoken", + "title": "Sandcastle at the lake", "summary": "Lisa and Bart built a sandcastle.", + "facts": ["Place: lake"], "persons": ["Lisa", "Bart"], "tags": ["Family Life"], + "state": "new", "posted": posted, "at": at} + card.update(kw) + return card + + +# ── When the job runs ───────────────────────────────────────────────── + + +class TestTheDailyJob: + + def _at(self, hour, minute=0, day=24): + return datetime(2026, 9, day, hour, minute).astimezone() + + def test_not_before_its_time(self): + assert not diary_room.job_due("07:30", "2026-09-23", self._at(7, 29)) + + def test_at_its_time(self): + assert diary_room.job_due("07:30", "2026-09-23", self._at(7, 30)) + + def test_once_a_day(self): + assert not diary_room.job_due("07:30", "2026-09-24", self._at(20)) + + def test_a_mac_that_slept_through_the_morning_catches_up_when_it_wakes(self): + assert diary_room.job_due("07:30", "2026-09-22", self._at(14)) + + def test_a_malformed_time_never_runs(self): + assert not diary_room.job_due("half past seven", "", self._at(14)) + + def test_the_time_is_the_households_not_the_containers(self, monkeypatch): + """The bot runner's clock is UTC. 07:30 means 07:30 where the family + lives, and a day is their day.""" + monkeypatch.setenv("TIMEZONE", "Europe/Berlin") + now = diary_room.household_now(datetime(2026, 9, 24, 5, 30, tzinfo=timezone.utc)) + assert (now.hour, now.minute) == (7, 30) + assert now.date().isoformat() == "2026-09-24" + late = diary_room.household_now(datetime(2026, 9, 24, 22, 30, tzinfo=timezone.utc)) + assert late.date().isoformat() == "2026-09-25" + + def test_without_a_household_timezone_it_uses_utc(self, monkeypatch): + monkeypatch.delenv("TIMEZONE", raising=False) + now = diary_room.household_now(datetime(2026, 9, 24, 5, 30, tzinfo=timezone.utc)) + assert now.hour == 5 + + +# ── What gets announced ─────────────────────────────────────────────── + + +class TestWhatIsAnnounced: + + def test_the_report_is_the_last_line_the_cli_prints(self): + out = 'published family/diary/2026/09.md\n{"cards": [], "new": 0}\n' + assert diary_room.read_report(out) == {"cards": [], "new": 0} + assert diary_room.read_report("no report here") is None + + def test_a_card_whose_thread_has_its_notice_is_not_posted_again(self): + fresh, older = diary_room.to_announce( + {"cards": [_card("a", posted=True), _card("b")]}, since_ms=0) + assert [c["entry_id"] for c in fresh] == ["b"] and older == 0 + + def test_on_the_first_run_the_backlog_is_counted_not_posted(self): + """A room with years of memories would otherwise get hundreds of + threads in one morning.""" + fresh, older = diary_room.to_announce( + {"cards": [_card("old", at=1_000), _card("new", at=5_000)]}, since_ms=2_000) + assert [c["entry_id"] for c in fresh] == ["new"] and older == 1 + + def test_the_card_reads_like_the_archivists_other_filings(self): + """The same shape as a document's filing reply: title, one row of + topics and people, the summary, the facts. No hashtags: a topic + with a space in it breaks one.""" + text = diary_room.card_text(_card(), _t_en) + lines = [ln.strip() for ln in text.splitlines() if ln.strip()] + + assert lines[0] == "📔 **Sandcastle at the lake** · Sunday, 20 September 2026" + assert lines[1] == "Family Life | Lisa | Bart" + assert lines[2] == "Lisa and Bart built a sandcastle." + assert lines[3] == "- Place: lake" + assert "#" not in text + + def test_a_card_shows_at_most_five_facts(self): + text = diary_room.card_text(_card(facts=[f"Fact: {i}" for i in range(8)]), _t_en) + assert "Fact: 4" in text and "Fact: 5" not in text + + 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) + + 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." + + +# ── The room ────────────────────────────────────────────────────────── + + +def _memories_room(): + return SimpleNamespace(room_id=ROOM_ID, canonical_alias="#memories:server", name=None, + users={uid: object() for uid in (BOT_ID, MARGE, "@homer:server")}) + + +@pytest.fixture +def bot(tmp_path): + bot = ArchivistBot(homeserver="http://homeserver", user_id=BOT_ID, password="x", + session_dir=tmp_path) + bot._client = FakeMatrix() + bot.sent: list[tuple] = [] + bot.routed: list[tuple] = [] + + async def _send(room_id, text, reply_to=None, **kw): + bot.sent.append((room_id, text, reply_to, kw)) + + async def _react(room_id, event_id, emoji): + bot.sent.append(("react", event_id, emoji)) + + async def _record(*a, **kw): + bot.routed.append(a) + + bot._send = _send + bot._react = _react + bot._handle_search = _record + bot._handle_text_capture = _record + bot._handle_reply_reprocess = _record + bot._handle_reply_capture_reprocess = _record + # A family could switch the room to react to everything. The memories + # room must stay quiet anyway, so the tests grant it. + bot._room_mode_allows_react = lambda _ctx: _granted() + return bot + + +async def _granted(): + return True + + +def _card_thread(bot): + """Marge's memo and the archivist's card notice in its thread.""" + client = bot._client + client.add(_message("$memo", MARGE, "voice-message.ogg")) + client.add(_message("$card", BOT_ID, "📔 Sandcastle", + content={"msgtype": "m.notice", **_thread_relation("$memo")}, + envelope=diary_room.envelope(_card(root="$memo"), + diary_room.CARD_FILED, actor=BOT_ID)), + thread_root="$memo") + + +class TestTheMemoriesRoomIsTheFamilys: + + @pytest.mark.asyncio + async def test_a_memo_with_a_link_is_not_filed(self, bot): + """Elsewhere a link is saved as a bookmark. Here it is part of a + memory, and the diary keeps it with the rest.""" + memo = _message("$m", MARGE, "Photos from the zoo: https://photos.example.org/zoo") + await bot._on_text(_memories_room(), memo) + assert bot.sent == [] and bot.routed == [] + + @pytest.mark.asyncio + async def test_a_photo_is_not_filed(self, bot): + event = _message("$p", MARGE, "IMG_1.jpg", content={ + "msgtype": "m.image", "url": "mxc://server/abc"}) + await bot._on_file(_memories_room(), event) + assert bot.sent == [] and bot.routed == [] + + @pytest.mark.asyncio + async def test_no_welcome_is_posted(self, bot): + room = _memories_room() + await bot._send_room_welcome_if_needed(room, bot._room_context(room)) + assert bot.sent == [] + + def test_a_voice_memo_is_left_to_the_diary(self, bot): + """Decoding it would show the archivist typing under every memo.""" + memo = _message("$v", MARGE, "voice-message.ogg", content={"msgtype": "m.audio"}) + in_thread = _message("$w", MARGE, "voice-message.ogg", + content={"msgtype": "m.audio", **_thread_relation("$memo")}) + assert bot._wants_voice(_memories_room(), memo) is False + assert bot._wants_voice(_memories_room(), in_thread) is True + + @pytest.mark.asyncio + async def test_a_reply_in_a_card_thread_corrects_the_card(self, bot): + _card_thread(bot) + seen = [] + + async def correct(room_id, event, found, words): + seen.append((room_id, event.event_id, found["data"]["entry_id"], words)) + + bot._diary.correct = correct + event = _message("$fix", MARGE, "That was the 20th, and Bart built the tower.", + content=_thread_relation("$memo", falls_back_to="$card")) + await bot._on_text(_memories_room(), event) + + assert seen == [(ROOM_ID, "$fix", "e1", "That was the 20th, and Bart built the tower.")] + assert bot.routed == [] + + @pytest.mark.asyncio + async def test_a_thread_without_our_card_is_the_familys_conversation(self, bot): + bot._client.add(_message("$memo", MARGE, "voice-message.ogg")) + seen = [] + + async def correct(*a): + seen.append(a) + + bot._diary.correct = correct + event = _message("$chat", "@homer:server", "So proud of her!", + content=_thread_relation("$memo")) + await bot._on_text(_memories_room(), event) + assert seen == [] and bot.sent == [] + + +class TestTheDailyJobPostsWhatIsNew: + + @pytest.mark.asyncio + async def test_the_how_to_is_shown_once_then_only_the_count(self, bot, monkeypatch): + """The first announcement introduces the feature and how to correct + a card. After that the count is enough.""" + import json as _json + days = iter([ + {"room_id": ROOM_ID, "cards": [_card("a", at=2_000_000_000_000)]}, + {"room_id": ROOM_ID, "cards": [_card("b", at=2_000_000_000_000)]}, + ]) + + async def cli(*args, timeout): + return 0, _json.dumps(next(days)) + "\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)) + + first_card, first_summary, second_card, second_summary = [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): + report = {"room_id": ROOM_ID, "new": 2, "kept_as_edited": [], + "cards": [_card("a", at=2_000_000_000_000), + _card("b", at=2_000_000_000_000), + _card("c", posted=True)]} + + async def cli(*args, timeout): + import json + return 0, json.dumps(report) + "\n", "" + + monkeypatch.setattr(diary_room, "_diary_cli", cli) + bot.link_base_url = "http://h/go" + await bot._diary.run_job(datetime(2026, 9, 24, 7, 30, tzinfo=timezone.utc)) + + threads = [(s[2], s[3]["thread_root_event_id"], s[3]["msgtype"], + s[3]["metadata"]["dev.famstack.event"]["type"]) for s in bot.sent[:2]] + assert threads == [("$a", "$a", "m.notice", "diary.filed"), + ("$b", "$b", "m.notice", "diary.filed")] + room_id, summary, reply_to, kw = bot.sent[2] + assert summary.startswith("❤️ 2 new memories are in the family diary.") + assert reply_to is None and kw["msgtype"] == "m.notice" + assert len(bot.sent) == 3 + + @pytest.mark.asyncio + async def test_a_failed_run_waits_before_it_tries_again(self, bot, monkeypatch): + """The AI server being down at 07:30 must not start a compile every + minute until it is back.""" + calls = [] + + async def cli(*args, timeout): + calls.append(args) + return 1, "", "LLM unreachable" + + monkeypatch.setattr(diary_room, "_diary_cli", cli) + start = datetime(2026, 9, 24, 7, 30, tzinfo=timezone.utc) + await bot._diary.run_job(start) + assert bot._diary.may_run(start + timedelta(minutes=10)) is False + assert bot._diary.may_run(start + timedelta(minutes=31)) is True + assert bot.sent == [] and len(calls) == 1 + + @pytest.mark.asyncio + async def test_a_day_with_nothing_new_is_silent(self, bot, monkeypatch): + async def cli(*args, timeout): + return 0, '{"room_id": "%s", "cards": [], "new": 0}\n' % ROOM_ID, "" + + monkeypatch.setattr(diary_room, "_diary_cli", cli) + await bot._diary.run_job(datetime(2026, 9, 24, 7, 30, tzinfo=timezone.utc)) + assert bot.sent == [] diff --git a/tests/unit/stacklets/test_archivist_source.py b/tests/unit/stacklets/test_archivist_source.py index 614d0081..7eb4361d 100644 --- a/tests/unit/stacklets/test_archivist_source.py +++ b/tests/unit/stacklets/test_archivist_source.py @@ -300,7 +300,7 @@ async def rec_binary(**kwargs): bot._handle_binary_capture = rec_binary bot._scan_sessions = set() - bot._room_context = lambda room: SimpleNamespace(room_id=room.room_id) + bot._room_context = lambda room: SimpleNamespace(room_id=room.room_id, alias=None) bot._send_room_welcome_if_needed = lambda *_a, **_k: _none() bot._is_bot_mentioned = lambda _e: False bot._should_react = lambda *_a, **_k: _true() From 9cb0482f1e6ca7ec46fb29e59f5c326d7b19b53f Mon Sep 17 00:00:00 2001 From: Arthur <516104+arthware@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:14:46 +0200 Subject: [PATCH 11/17] feat(diary): head each diary entry with its card's title and summary The diary pages showed each entry under its sender's name, so what a card says about an entry, and any correction made to it, never reached the page the family reads. Each entry now opens with its card's title, then who recorded it and what it is, then the summary as a short narrative line, then the words as recorded, all of them. A message spoken to one person keeps no summary and is shown whole, as before. --- stacklets/memory/bot/diary.py | 26 +++++--- stacklets/memory/bot/diary_card.py | 1 + tests/unit/stacklets/test_memory_diary.py | 9 ++- .../unit/stacklets/test_memory_diary_card.py | 61 ++++++++++++++++++- 4 files changed, 86 insertions(+), 11 deletions(-) diff --git a/stacklets/memory/bot/diary.py b/stacklets/memory/bot/diary.py index 65e7addb..4e370d40 100644 --- a/stacklets/memory/bot/diary.py +++ b/stacklets/memory/bot/diary.py @@ -289,6 +289,9 @@ class Entry: # then shows the body in full. gist: str = "" moments: list[str] = field(default_factory=list) + # The card's title, when the entry comes from a card. Heads the entry + # on the page. + title: str = "" # ── What this household says ────────────────────────────────────────── @@ -931,15 +934,19 @@ def _entry_block(entry: Entry, *, room_id: str, # not title-cased, so a group reads as a group. A message whose # addressee resolves to its own sender is a misread, not a dedication. to = (entry.addressee or "").strip() - if to and to.lower() != entry.sender.lower(): - heading = f"### {who} — {_L['for']} {to}" - else: - heading = f"### {who}" + addressed = bool(to) and to.lower() != entry.sender.lower() # How the date was derived is our concern, not the reader's, and it # would repeat under every entry on every page. Where it matters, # because we could not derive one, the callout below says so. - lines = [heading, f"*{_kind_label(entry)}*", ""] + if entry.title: + # From a card: its title heads the entry, and who recorded it + # and what it is follow on one line. + by = f"{who}, {_L['for']} {to}" if addressed else who + lines = [f"### {entry.title}", f"*{by} · {_kind_label(entry)}*", ""] + else: + heading = f"### {who} — {_L['for']} {to}" if addressed else f"### {who}" + lines = [heading, f"*{_kind_label(entry)}*", ""] if entry.confidence == "uncertain": lines += [ @@ -948,11 +955,16 @@ def _entry_block(entry: Entry, *, room_id: str, "", ] + # The summary opens the entry as narrative, above the family's words + # and never in their place. A message spoken to one person gets none: + # it is posted whole, with nothing generated in front of it. + if entry.gist and not addressed: + lines += [entry.gist, ""] + if entry.body.strip() and _distills(entry): - # The distilled view for long recordings: one narrative line, + # The distilled view for long recordings: the summary above, # verified quotes, and the full transcript in a folded block. # verify_moments() guarantees each quote is an exact excerpt. - lines += [entry.gist, ""] for moment in entry.moments: lines += [f"> [!quote] {moment}", ""] lines += [f"> [!note]- {_L['full_transcript']}"] diff --git a/stacklets/memory/bot/diary_card.py b/stacklets/memory/bot/diary_card.py index abe2a9b8..08e8f387 100644 --- a/stacklets/memory/bot/diary_card.py +++ b/stacklets/memory/bot/diary_card.py @@ -270,6 +270,7 @@ def to_entry(card: Card) -> "diary.Entry": comments=list(card.replies), gist=card.summary, moments=diary.verify_moments(card.body, card.quotes), + title=card.title, ) diff --git a/tests/unit/stacklets/test_memory_diary.py b/tests/unit/stacklets/test_memory_diary.py index a6cd40c5..1d872ef9 100644 --- a/tests/unit/stacklets/test_memory_diary.py +++ b/tests/unit/stacklets/test_memory_diary.py @@ -676,14 +676,17 @@ def test_a_long_entry_renders_distilled_with_verified_quotes(self): # The body appears only inside the folded block, quoted. assert "\nwort0." not in page - def test_a_short_entry_stays_verbatim_even_with_a_gist(self): + def test_a_short_entry_keeps_every_word_under_its_summary(self): + """The summary is narrative above the words, never in their place: + a short entry is shown whole, not folded behind it.""" page = diary.render_month([diary.Entry( on=date(2026, 9, 14), confidence="sent", basis="b", kind="voice", sender="marge", body="Kurz und wichtig.", - gist="A gist that must not replace the words.")]) + gist="A summary that must not replace the words.")]) assert "Kurz und wichtig." in page - assert "A gist that must not replace the words." not in page + assert "[!note]-" not in page + assert page.index("A summary that must not") < page.index("Kurz und wichtig.") class TestTheKeptFileOnThePage: diff --git a/tests/unit/stacklets/test_memory_diary_card.py b/tests/unit/stacklets/test_memory_diary_card.py index b78cbe16..af7c1b9d 100644 --- a/tests/unit/stacklets/test_memory_diary_card.py +++ b/tests/unit/stacklets/test_memory_diary_card.py @@ -88,8 +88,10 @@ def test_the_diary_pages_from_cards_are_the_pages_from_entries(self): direct = diary.pages_for( [replace(e, gist="", moments=[]) for e in entries], room_id=ROOM, media=media) + # What the card adds on purpose (its title and summary) is set + # aside: this pins that nothing of the entry itself is lost. from_cards = diary.pages_for( - [replace(diary_card.to_entry(c), gist="", moments=[]) for c in cards], + [replace(diary_card.to_entry(c), gist="", moments=[], title="") for c in cards], room_id=ROOM, media=diary_card.media_of(cards)) assert from_cards == direct @@ -445,3 +447,60 @@ def test_the_model_offers_a_date_only_as_an_iso_day(self): assert _read(date="17 March").date == "" assert _read(date="2026-03-17").date == "2026-03-17" + +# ── The card on the diary page ──────────────────────────────────────── + + +def _page_for(card) -> str: + return diary.render_month([diary_card.to_entry(card)], room_id=ROOM) + + +class TestTheDiaryPageShowsTheCard: + """Each entry on the page opens with its card: the title, who recorded + it and what it is, and the summary as a short narrative line. Then + the family's words, every one of them. A correction to the card is + therefore a correction to the page.""" + + def _note(self, **kw): + entry = diary.Entry(on=date(2026, 9, 20), confidence="sent", basis="", + kind="voice", sender="marge", body="We went to the lake today.", + event_ids=["$lake"], duration_ms=7000) + for k, v in kw.items(): + setattr(entry, k, v) + return entry + + def test_the_title_heads_the_entry_and_says_who_and_what(self): + card = diary_card.to_card(self._note(), _extraction(title="A day at the lake"), + room_id=ROOM, media={}) + page = _page_for(card) + + assert "### A day at the lake" in page + assert "*Marge · Voice note, 0:07*" in page + + def test_the_summary_opens_the_entry_and_every_word_follows(self): + card = diary_card.to_card(self._note(), _extraction(summary="Marge tells of the lake."), + room_id=ROOM, media={}) + page = _page_for(card) + + assert page.index("Marge tells of the lake.") < page.index("We went to the lake today.") + + def test_a_correction_shows_on_the_page(self): + card = diary_card.to_card(self._note(), _extraction(summary="Bart built the tower."), + room_id=ROOM, media={}) + fixed = diary_card.correct(card, _extraction(summary="Lisa built the tower.", + title="Lisa's tower"), event_id="$fix") + page = _page_for(fixed) + + assert "### Lisa's tower" in page and "Lisa built the tower." in page + assert "Bart built the tower." not in page + + def test_a_letter_to_one_person_keeps_its_title_and_no_summary(self): + """A message spoken to one person is posted whole, as agreed for + letters: nothing generated stands between the reader and it.""" + card = diary_card.to_card(self._note(addressee="Lisa"), + _extraction(title="For Lisa", summary="Marge talks to Lisa."), + room_id=ROOM, media={}) + page = _page_for(card) + + assert "### For Lisa" in page and "*Marge, for Lisa · Voice note, 0:07*" in page + assert "Marge talks to Lisa." not in page From cd12e72a42137f79b42f878c142340d0bf621c15 Mon Sep 17 00:00:00 2001 From: Arthur <516104+arthware@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:30:31 +0200 Subject: [PATCH 12/17] fix(diary): keep only facts worth looking up on a diary card Asked for "Label: value" facts, the model labelled them by kind ("Action", "Event", "Date", "Person") and repeated the summary, the date and the people as a form. A fact is now labelled by who or what it is about ("Maggie: first tooth", "Place: Springfield Lake"), milestones come first, and an entry may have none. Facts labelled only by kind are dropped. --- stacklets/memory/bot/cli/diary.py | 20 +++++++++++++------ stacklets/memory/bot/diary_card.py | 16 ++++++++++++++- .../unit/stacklets/test_memory_diary_card.py | 17 +++++++++++++--- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/stacklets/memory/bot/cli/diary.py b/stacklets/memory/bot/cli/diary.py index 30c5fdb4..6b37f861 100644 --- a/stacklets/memory/bot/cli/diary.py +++ b/stacklets/memory/bot/cli/diary.py @@ -846,10 +846,15 @@ async def _summarise(entries, llm) -> str: Not wanted: "Anna und ein Kind berichten über einen Ausflug, ein Picknick, einen verlorenen Schuh und das Wetter." -"facts": up to six short facts from the entry, in {language}, each - written as "Label: value" and each anchored on a name, a date, a - number or a place. A sentence without such an anchor is summary, not - a fact. An empty list when the entry states none. +"facts": what someone would want to look up about this entry later, in + {language}, each as "Label: value". The label is who or what the fact + is about: a family member's name ("Maggie: first tooth"), a place + ("Place: Springfield Lake"), a thing ("Swimming badge: 25 metres"), + never the kind of fact ("Action", "Event", "Detail", "Date", "Person"). + Milestones first: a first step, a first word, a badge, a birthday, a + new school. Leave out what the summary already says in a sentence and + the day the entry happened. At most five. An empty list when the entry + holds nothing worth looking up, which is often. "persons": the family members the entry is about or spoken to, using the names from the family list above. Only names from that list. @@ -1451,8 +1456,11 @@ def _report(cards, plan, tree, failed, *, bucket: str, room_id: str, Reply with a JSON object with these keys, all in {language} except the names of people and topics: "title", "description", "summary" (one to three short sentences), -"facts" (each "Label: value", anchored on a name, a date, a number or a -place), "persons" (names from the family list only), "topics" (topics +"facts" (each "Label: value", labelled by who or what it is about, such +as a family member's name or "Place", never by the kind of fact such as +"Action", "Event", "Date" or "Person"; milestones first; at most five; +none for what the summary already says), "persons" (names from the +family list only), "topics" (topics from the vocabulary only), "quotes" (passages copied word for word from the entry's words; keep the card's quotes unless the correction touches them), and "date": the day the entry happened as YYYY-MM-DD when the diff --git a/stacklets/memory/bot/diary_card.py b/stacklets/memory/bot/diary_card.py index 08e8f387..a3cecb7a 100644 --- a/stacklets/memory/bot/diary_card.py +++ b/stacklets/memory/bot/diary_card.py @@ -80,6 +80,19 @@ def _strings(value) -> list[str]: return [" ".join(v.split()) for v in value if isinstance(v, str) and v.strip()] +# Labels that say what kind of thing a fact is rather than whom or what it +# is about. Models reach for them when asked for "Label: value", and the +# result restates the summary, the date or the people as a form. +_KIND_LABELS = {"action", "actions", "activity", "detail", "details", "event", + "events", "date", "year", "time", "speaker", "recipient", + "person", "persons", "people", "note"} + + +def _kind_only(fact: str) -> bool: + label, sep, _ = fact.partition(":") + return bool(sep) and label.strip().lower() in _KIND_LABELS + + def _iso_day(value) -> str: """`value` if it is a YYYY-MM-DD day, else "".""" if not isinstance(value, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value.strip()): @@ -132,7 +145,8 @@ def text(key: str) -> str: persons=persons, tags=[*topics, *(f"Person: {p}" for p in persons)], summary=text("summary"), - facts=[f for f in (_LIST_MARKER.sub("", f) for f in _strings(raw.get("facts"))) if f], + facts=[f for f in (_LIST_MARKER.sub("", f) for f in _strings(raw.get("facts"))) + if f and not _kind_only(f)], quotes=_strings(raw.get("quotes"))[:_MAX_QUOTES], model=model, date=_iso_day(raw.get("date")), diff --git a/tests/unit/stacklets/test_memory_diary_card.py b/tests/unit/stacklets/test_memory_diary_card.py index af7c1b9d..2fd28b5c 100644 --- a/tests/unit/stacklets/test_memory_diary_card.py +++ b/tests/unit/stacklets/test_memory_diary_card.py @@ -324,9 +324,20 @@ def test_the_words_come_through_trimmed_and_facts_stay_facts(self): def test_a_fact_is_the_fact_not_the_list_marker_the_model_copied(self): """Shown a card's facts as a list, a model hands them back with the markers on, and the callout would render "- - Date".""" - assert _read(facts=["- Date: 2026-09-20", "* Place: lake", "• Who: Bart", - "- - Action: Lisa built the tower"]).facts == [ - "Date: 2026-09-20", "Place: lake", "Who: Bart", "Action: Lisa built the tower"] + assert _read(facts=["- Lisa: built the tower", "* Place: lake", "• Bart: dug the moat", + "- - Maggie: first tooth"]).facts == [ + "Lisa: built the tower", "Place: lake", "Bart: dug the moat", "Maggie: first tooth"] + + def test_a_fact_says_who_or_what_it_is_about_not_what_kind_it_is(self): + """"Action: Maggie knocked it over" repeats the summary as a form. + A fact names who or what it is about; the card's date, people and + summary already carry the rest.""" + read = _read(facts=["Action: Maggie knocked it over", "Date: 2026-03-17", + "Person: Maggie", "Event: first tooth", "Speaker: Marge", + "Maggie: first tooth", "Place: Springfield Lake", + "Swimming badge: 25 metres"]) + assert read.facts == ["Maggie: first tooth", "Place: Springfield Lake", + "Swimming badge: 25 metres"] def test_an_answer_that_is_not_an_object_reads_as_nothing(self): empty = diary_card.extraction_from(None, ontology=SEED_ONTOLOGY, language="en", From 8a60de12cdb69b4d38dedb6950a7fe9854e66f30 Mon Sep 17 00:00:00 2001 From: Arthur <516104+arthware@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:58:55 +0200 Subject: [PATCH 13/17] docs: describe the family diary in the user guide The user guide still called the diary an upcoming update. It now says how the Memories Room works: saying the date and whom a memo is for, the nightly reading and the morning cards, correcting a card by replying in its thread, and what the diary pages show. --- docs/user-guide.md | 59 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index cb60916a..056d5af8 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -6,6 +6,7 @@ What that gets you: - **A wikipedia of your family.** Documents, notes, links and voice memos become a curated wiki of your family life. Searchable, browsable, owned by you. - **Paperwork that files itself.** Photograph a letter, send it to chat, done. OCR'd, tagged, findable forever. +- **Your family's memories, kept.** Your kids' voices, the funny things they said, bedtime stories in their own words. Record them in the chat and famstack turns them into a family diary you read back month by month. Your personal family story at your fingertips. - **Answers instead of folder hunting.** Ask "when does the car insurance renew?" and get the answer, with sources. - **Never miss a thought.** Voice memos become clean, filed, searchable notes. - **Nothing leaves the house.** No cloud, no subscription, nobody else reading your family's life. It all runs on your own Mac. @@ -14,19 +15,24 @@ This guide is for everyone in the family: what to type, what comes back, and wha Setting the server up, or keeping it running? That is the [Admin Guide](admin-guide.md). +> [!TIP] +> **Try the Memories Room.** Record a voice message for your kids tonight, and tomorrow morning it is in your family diary. After four months of recording memos and our kids' voices, it has already become one of our most valuable artifacts. [How it works](#the-memories-room) + ## How do I...? | You want to | Go to | |---|---| +| Record the kids, keep the stories | [The Memories Room](#the-memories-room) | +| Read the family diary | [The family diary](#the-family-diary) | +| Fix a name or a date in a memory | [Put a memory right](#put-a-memory-right) | | File a letter, receipt or contract | [File documents](#file-documents) | | Find a document again | [Ask questions](#ask-questions) | | Save a link, note or PDF for later | [Capture rooms](#save-anything-capture-rooms) | | Collect everything about one project | [Topic rooms](#topic-rooms) | | Get email into the family chat | [Email](#email) | | Capture a thought without typing | [Voice memos](#voice-memos) | -| Fix a wrong tag or title | [Correct the bot](#correct-the-bot) | +| Fix a wrong tag or title | [Correct bot mistakes](#correct-bot-mistakes) | | Browse what the family knows | [The family wiki](#the-family-wiki) | -| Record the kids, keep the stories | [The Memories Room](#the-memories-room) | --- @@ -142,7 +148,7 @@ Driving home and something crosses your mind: hold the mic button, talk, done. T --- -## Correct the bot +## Correct bot mistakes The archivist gets things wrong sometimes. Wrong family member, too generic a tag, a title that misses the point. You fix it by replying. @@ -157,6 +163,8 @@ Anything you write in the filing's thread counts as a correction, so you can als This works for captures too: reply to a bookmark or note confirmation and it gets re-filed with your hint. +Memories work the same way, in the thread under each memory: see [Put a memory right](#put-a-memory-right). + --- ## Ask questions @@ -182,7 +190,7 @@ If the first pass isn't enough, it tells you it's looking deeper and reads the a Everything the archivist files becomes more than a pile of documents: the memory stacklet renders it as a wiki of your family life. A home page, a page per family member, topic pages, a page per correspondent (the insurance company, the school, the doctor's office). -Open it at `http://<mac-ip>:42070` (or `wiki.<your-domain>` if the admin set up a domain). It updates itself as things get filed. +Open it at `http://<mac-ip>:42070` (or `memory.<your-domain>` if the admin set up a domain). It updates itself as things get filed. Every page has an edit link: edits happen in Forgejo (the family's private git server), so every change is tracked and nothing is ever lost. If you use Obsidian, you can clone the vault and browse it there; it's all plain Markdown. @@ -190,11 +198,48 @@ Every page has an edit link: edits happen in Forgejo (the family's private git s ## The Memories Room -One of the most valuable things you can do with famstack has nothing to do with documents. +> Needs the `memory` stacklet. Voice messages need the `ai` stacklet; the cards in the room need the archivist (`docs`). + +One of the most valuable things you can do with famstack has nothing to do with documents. If you try one thing, make it this room. + +The Memories Room keeps what photos can't: your kids' voices, the funny things they said, bedtime stories in their own words. We record a voice diary once or twice a week at the dinner table: what was funny, what was special, what the kids want to tell their future selves. Holiday diaries, first days at school, a birthday message. We wish we had started with our first son. + +famstack does more than keep them. Every night it transcribes what you recorded, works out the date and who it was for, and writes it into a family diary you can read back month by month, with every recording one tap away. You don't type, tag or file anything. This is the feature that keeps gaining value. + +A few habits help: + +- **Say the date.** Start a recording with "Today is the third of March" and famstack files the memory on that day, even if your phone uploads it a week later. Without a date, the day you sent it counts. +- **Say who it is for.** Start with "Hi Bart, ..." and famstack files the memory as a message to Bart. The diary shows it whole, as a letter. +- **Reply to add to a memory.** The diary keeps your reply, or a caption under a photo, with the memory. + +The archivist does not reply to anything you post there. + +### What happens overnight + +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: + +> **archivist**: ❤️ 3 new memories in the family diary. [Open the diary] + +Both are quiet messages, so no phone buzzes. A memory you post today gets its card tomorrow morning. + +### Put a memory right + +The AI mishears names and gets things wrong. Correct it in the memory's thread, the way you correct a document: open the thread under the memory, and reply to its card, typed or as a voice message. + +> **archivist**: 📔 **Sandcastle at the lake** · Sunday, 20 September 2026 ... +> **you** (in the thread): It was Lisa who built the tower, not Bart. And it was the 19th. + +The archivist answers with 👀, puts the corrected card in the thread, and confirms with ✅. You can correct the title, the date, who it is about, the summary and the facts. The recorded words themselves stay as they were. + +famstack saves your correction in the family's archive under your name, and the card is yours from then on: the nightly run never changes it back. To correct it again, reply in the same thread. + +### The family diary -The Memories Room is a place to record your family's life. Voice messages, photos, text. We record a voice diary once or twice a week at the dinner table: what was funny, what was special, what the kids want to tell their future selves. Holiday diaries, first days at school, bedtime stories in their own words. +Open it from the note's link, or from the family wiki's home page (**Family Diary**). You read it month by month: a short opening for each month, then every memory under its day, with its title, who recorded it, a short summary, the words as they were said, the photos, and the recordings to play. For a long recording you see a few lines worth keeping, and the full transcript is one click away. A message spoken to one person appears whole. -Start collecting these. They become valuable just as they are. Voice transcription already works; an upcoming update will weave the Memories Room into the family wiki as a chronicle: "Remember? One year ago...", "Lisa's third birthday...". Everything stays on your Mac. Your memories are just yours. +Transcription and reading run on your household's own AI, so nothing leaves your home. Your memories are just yours. Start now. You will wish you had started earlier. From 945a82a87baa797c09a0abe722d3364cc798a0d4 Mon Sep 17 00:00:00 2001 From: Arthur <516104+arthware@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:36:41 +0200 Subject: [PATCH 14/17] docs: describe the family diary in the README and admin guide The README and the admin guide knew the memory stacklet only as the family wiki. The README now lists the family diary among what famstack does today and on the roadmap. The admin guide says how the diary runs: the nightly intake, the morning cards and when they post, corrections, where the cards live, and the commands to run it by hand. --- README.md | 3 +++ docs/admin-guide.md | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e3e8ebd5..869bd539 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ The reference implementation runs on a Mac Studio M1 in our living room at Lake > [!TIP] > **New in 0.3: your family server gets a memory.** > The `memory` stacklet curates a wiki of your family life out of the documents, notes, and voice memos you drop in the chat. Browsable, searchable, Obsidian-compatible, on your own Mac. Ask a question in the chat and get an answer with sources. +> **And a family diary.** famstack turns the voice messages and photos you post in the Memories room into a diary you read back month by month, in your own words. Each memory gets a card in the chat; reply to it to fix a name or a date. > It's in daily use on our own server, first tagged as [`v0.3.0-beta.1`](https://github.com/famstack-dev/famstack/releases/tag/v0.3.0-beta.1). [Read the introduction](https://famstack.dev/blog/famstack-0-3-beta/). ## What you get today @@ -45,6 +46,7 @@ The reference implementation runs on a Mac Studio M1 in our living room at Lake - **Photo library and mobile backup** that replaces iCloud Photos for the whole family (Immich). - **Document archive with OCR** that you photograph from your phone and the local AI files for you (Paperless-ngx). - **Your family wiki**: an Obsidian-compatible second brain, generated from the documents, notes, and voice memos you file (memory). +- **Your family diary**: voice messages, photos and notes from the Memories room, read back month by month in your own words. Correct any memory by replying to its card in the chat (memory). - **Local AI engine** on Apple Metal GPU: voice transcription, text-to-speech, document classification (oMLX + Whisper + Piper). - **A bot runtime in chat** that automates the small stuff: filing receipts, transcribing voice memos, status reports. - **Email into the family brain**: point a mailbox at a chat room and new mail (with its attachments) lands there for the archivist to file, newsletters and marketing filtered out (IMAP, read-only, private). @@ -205,6 +207,7 @@ Document filing, photo backup, family chat with voice memos, and the family memo | Bot runtime (archivist-bot, scribe-bot, stacker-bot) | :white_check_mark: Live | | Private Git server (Forgejo) | :white_check_mark: Live | | Family wiki generated from your documents (memory) | :construction: 0.3-beta | +| Family diary from the Memories room (memory) | :construction: 0.3-beta | | Document Q&A with citations, in chat | :construction: 0.3-beta | | Voice memos become searchable notes | :construction: 0.3-beta | | Topic rooms: the room name is the filing system | :construction: 0.3-beta | diff --git a/docs/admin-guide.md b/docs/admin-guide.md index 763ad101..91c5fb51 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -206,7 +206,7 @@ You will see two rooms: - `#Family Chat`: where the family talks. Photos, voice memos, life. - `#Server Room`: where the server talks back. Status, alerts, install confirmations. -- `#Memories`: a place to store your memories, voice diaries, stories, funny moments. +- `#Memories`: a place to store your memories, voice diaries, stories, funny moments. With the `memory` stacklet it becomes the family diary (see [Memory](#memory-memory-optional)). ### 5. Connect your phones @@ -545,6 +545,8 @@ 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. `./stack memory wiki` stays available as the manual trigger, 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). + | | | |---|---| | Wiki port | `42070` (domain mode: `memory.<your-domain>`; `wiki.<your-domain>` redirects there) | @@ -559,6 +561,9 @@ Useful commands: ./stack memory lookup <text> # which topic or doctype does a term resolve to? ./stack memory correspondents # inspect the correspondent layer ./stack memory pull # fast-forward the local vault after Forgejo edits +./stack memory diary # take in the Memories room now instead of tonight +./stack memory diary --dry-run # show what would be written, write nothing +./stack memory prompt # the vocabulary the models are given, per prompt ``` --- From b8ccb0866f24a8fd66f89450629ba875d3a40743 Mon Sep 17 00:00:00 2001 From: Arthur <516104+arthware@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:36:41 +0200 Subject: [PATCH 15/17] feat(messages): mention the family diary in the Memories welcome The welcome in the Memories room described a collection of recordings. It now says that the memories become a family diary to read back, and that nothing leaves the home. --- stacklets/messages/cli/setup.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/stacklets/messages/cli/setup.py b/stacklets/messages/cli/setup.py index 826ca5d2..bd1ff4f2 100644 --- a/stacklets/messages/cli/setup.py +++ b/stacklets/messages/cli/setup.py @@ -311,7 +311,8 @@ def _post_welcome_messages(bot, room_ids, server_name, config=None, "Record voice messages with your kids, capture what made today special, " "snap a photo at the zoo, keep a holiday diary. " "Let the kids talk to their future selves.\n\n" - "Everything stays on your Mac. Voice messages are transcribed and searchable.\n\n" + "Voice messages are transcribed, and your memories become a family " + "diary you can read back. Nothing leaves your home.\n\n" "We made it a habit to press record once or twice a week at the dinner table " "and capture a couple of moments. What was funny, what was special, " "what the kids want to tell their future selves.\n\n" @@ -322,7 +323,8 @@ def _post_welcome_messages(bot, room_ids, server_name, config=None, "<p>Record voice messages with your kids, capture what made today special, " "snap a photo at the zoo, keep a holiday diary. " "Let the kids talk to their future selves.</p>" - "<p>Everything stays on your Mac. Voice messages are transcribed and searchable.</p>" + "<p>Voice messages are transcribed, and your memories become a " + "<b>family diary</b> you can read back. Nothing leaves your home.</p>" "<p>We made it a habit to press record once or twice a week at the dinner table " "and capture a couple of moments. What was funny, what was special, " "what the kids want to tell their future selves.</p>" From 6fbcace1ecc19877e1b2f037557ca8ba9b9809c7 Mon Sep 17 00:00:00 2001 From: Arthur <516104+arthware@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:57:25 +0200 Subject: [PATCH 16/17] feat(diary): mention the morning cards on an empty diary The page a family sees before anything is recorded now says that a card appears under each message in the morning, and that replying to it fixes a name, a date or a fact. --- stacklets/memory/bot/diary.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/stacklets/memory/bot/diary.py b/stacklets/memory/bot/diary.py index 4e370d40..111b2f52 100644 --- a/stacklets/memory/bot/diary.py +++ b/stacklets/memory/bot/diary.py @@ -102,6 +102,8 @@ - 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. +- Each morning a card appears in the thread under your message. Reply to + it to fix a name, a date or a fact. There is no wrong way to use it. Press record.""", "across": "across", @@ -166,6 +168,8 @@ 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. +- Jeden Morgen erscheint im Thread unter eurer Nachricht eine Karte. + Antwortet darauf, um einen Namen, ein Datum oder eine Angabe zu korrigieren. Es gibt kein falsches Vorgehen. Dr\u00fcckt auf Aufnahme.""", "across": "in", From 7b4ae8db45b79408c0f854e5cc730098030b84c1 Mon Sep 17 00:00:00 2001 From: Arthur <516104+arthware@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:57:25 +0200 Subject: [PATCH 17/17] docs: record the diary card decisions ADR-011 gains an update: diary entries are records in the vault, the diary pages are projections of them, and a corrected card is the family's. The content pattern note records what was built and where it differs from the proposal. --- docs/adr/adr-011-vault-brain-projection.md | 15 +++++++++++++++ docs/design/brain/content-pattern.md | 19 ++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/adr/adr-011-vault-brain-projection.md b/docs/adr/adr-011-vault-brain-projection.md index 0e899136..d2552dac 100644 --- a/docs/adr/adr-011-vault-brain-projection.md +++ b/docs/adr/adr-011-vault-brain-projection.md @@ -103,3 +103,18 @@ Cost: a page filed seconds ago is findable after the next mirror tick, not at once. Read-your-writes is tested for todos only (`tests/e2e/test_demo_rig_e2e.py`), so no stated invariant changes. +## Update 2026-09-24: diary entries are records + +A diary entry is a thing that happened, so it is now a record in the +vault: one card per entry of `type: diary` under +`<bucket>/diary/entries/`, written by the diary compile through the +Forgejo API. The month, year and index pages stay projections in the +brain and are rebuilt from the cards alone. The wiki does not publish +`diary/entries/`, while search and the agent read the cards through the +brain like any other record. + +A card the family corrected (by replying in its thread in the memories +room) or edited by hand is theirs: the compile never overwrites it, and +the vault commit that changed it names who did. The diary is therefore +the one chat filing not rebuilt by replaying its thread (see +`docs/agent/dev.md`, "Reprocessing replays the source"). diff --git a/docs/design/brain/content-pattern.md b/docs/design/brain/content-pattern.md index b4c181cc..0f01ec7e 100644 --- a/docs/design/brain/content-pattern.md +++ b/docs/design/brain/content-pattern.md @@ -1,6 +1,23 @@ # One content pattern for the brain -Status: proposed, 2026-09-19. Nothing is built yet. +Status: built for the diary, 2026-09-24 (famstack #132). Decisions taken +in building it, where they differ from the proposal below: + +- Entry unit: the message group (one recording, photo or note with its + joined fragments and replies). Longer occasions ("Roadtrip 2026") are + a later layer above the cards, not bigger cards. +- One compiler, changed in place, not two compared side by side: with + the entry unit unchanged, a module test pins that the pages built from + cards lose nothing of the entries. +- Cards live at `<bucket>/diary/entries/YYYY/MM/YYYY-MM-DD-<id>.md`, out + of the wiki's way; `type: diary` (vault-format.md). +- Tags are the ontology's topic names and `Person: <name>`, as on + documents, not `Topic:<name>`. +- The archivist, not a memory bot, posts the cards (once a day) and takes + corrections in their threads, reusing its correction handling. +- A correction is applied once to the card in the vault and recorded in + the vault commit under the corrector's name; the corrected card is the + family's and is never recompiled. The thread is not replayed into it. Related: [open-knowledge-format.md](open-knowledge-format.md), [vault-format.md](vault-format.md), [diary-journal.md](diary-journal.md), ADR-011 (vault as database, brain as projection).