From 12a6d48c8193a817b6a19e02ccedb6ee74ef6d2f Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 13 Oct 2026 17:45:02 +0200 Subject: [PATCH] fix(wiki): rebuild every page that shows a changed list Topic pages show the open items of the lists that name their topic, and person pages the items assigned to that person. A change to a family list only rebuilt the home page, so an item added or ticked in the chat reached those pages with the nightly sweep, up to a day later. The curator now reads the changed list before and after the change and also rebuilds every topic either version names and every person either version assigns an open item to. A person whose item was ticked, and a topic the list stopped naming, still get their page rebuilt. --- docs/admin-guide.md | 2 +- stacklets/memory/bot/curator.py | 44 +++++++++++++++- tests/unit/stacklets/test_memory_curator.py | 57 +++++++++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/docs/admin-guide.md b/docs/admin-guide.md index 25bc58d5..ff8973d7 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -565,7 +565,7 @@ The persistent layer of the household brain: a curated knowledge vault, rendered Setup seeds the vault with three things: the classification ontology (the topics and document types the archivist files against), household facts, and a hand-curated correspondents layer with aliases, so "Springfield Insurance" and "Springfield Ins. Co." resolve to the same page instead of becoming duplicates. -The wiki maintains itself. A curator sidecar (`stack-memory-curator`) watches the vault: when new filings settle it regenerates the pages of the family members involved plus the home page (a couple of LLM calls, a few minutes after the burst), and once a night it rebuilds everything — topic pages, cross-references, the lot — while the GPU has nothing better to do. To bring the wiki up to date now instead of waiting, `./stack memory wiki update` regenerates what the latest filings touched and `./stack memory wiki update --all` runs the whole nightly rebuild; both return when the pages are written. `--member`, `--topic` and `--home` regenerate just those pages, and `[memory]` in `stack.toml` holds the knobs (see [Configuration](#configuration)). Edits happen in Forgejo (every wiki page links to its source), so the commit log doubles as the household's learning history. +The 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); a changed list also regenerates the topic pages it belongs to and the pages of the people its items are for. Once a night it rebuilds everything — topic pages, cross-references, the lot — while the GPU has nothing better to do. To bring the wiki up to date now instead of waiting, `./stack memory wiki update` regenerates what the latest filings touched and `./stack memory wiki update --all` runs the whole nightly rebuild; both return when the pages are written. `--member`, `--topic` and `--home` regenerate just those pages, and `[memory]` in `stack.toml` holds the knobs (see [Configuration](#configuration)). Edits happen in Forgejo (every wiki page links to its source), so the commit log doubles as the household's learning history. **Who the family is.** Every person the family knows has a file of their own in the vault, `family/people/.md`: the full name, the other names the family uses, and the chat account if they have one. diff --git a/stacklets/memory/bot/curator.py b/stacklets/memory/bot/curator.py index fd18dace..33d11736 100644 --- a/stacklets/memory/bot/curator.py +++ b/stacklets/memory/bot/curator.py @@ -78,6 +78,7 @@ run_git, vault_remote_url, ) +from stack.lists import LISTS_DIR, ListError, read_list # noqa: E402 from wiki import COMMIT_PREFIX # noqa: E402 from stack.people import PEOPLE_DIR # noqa: E402 @@ -201,6 +202,7 @@ def member_selection( fm_reader, *, shared_bucket: str, + list_texts=lambda path: [], ) -> list[str]: """Map changed vault paths to an incremental `wiki` selection argv. @@ -215,7 +217,10 @@ def member_selection( included whenever anything relevant changed at all. A change to a person file adds `--members`: who the family knows changed, so every person page is rebuilt and pages for people no file declares are - retired. + retired. A changed list adds the topics it names and the people its + open items are assigned to, before and after the change + (`list_texts(path) -> [text, ...]`), since those pages show its + open items. """ members: list[str] = [] topics: list[str] = [] @@ -238,6 +243,10 @@ def member_selection( topic = _topic_slug_from_capture_path(parts, shared_bucket=shared_bucket) if topic and topic not in topics: topics.append(topic) + if _is_list_path(parts): + people, named = list_reach(list_texts(path), path) + members += [p for p in people if p not in members] + topics += [t for t in named if t not in topics] if path.endswith(".md"): persons = fm.get("persons") or fm.get("person") or [] if isinstance(persons, str): @@ -258,6 +267,32 @@ def member_selection( return argv +def _is_list_path(parts: list[str]) -> bool: + """`/lists/.md`, not a file in the lists' archive.""" + return len(parts) == 3 and parts[1] == LISTS_DIR and parts[2].endswith(".md") + + +def list_reach(texts: list[str], path: str) -> tuple[list[str], list[str]]: + """The people and the topics whose pages show the open items of a list. + + `texts` are versions of one list file; the reach is their union, so + a person whose item was ticked and a topic the list stopped naming + still have their page rebuilt. A version that is not a list adds + nothing. + """ + people: list[str] = [] + topics: list[str] = [] + for text in texts: + try: + lst = read_list(text, path=path) + except ListError: + continue + topics += [t for t in lst.topics if t not in topics] + for item in lst.open_items: + people += [p for p in item.people if p not in people] + return people, topics + + def _topic_slug_from_capture_path(parts: list[str], *, shared_bucket: str) -> str: """Return the topic slug for a capture path, or "" for non-topic paths.""" capture_dirs = {"notes", "bookmarks", "documents"} @@ -633,9 +668,13 @@ async def tracked_files(self) -> list[str]: def frontmatter_at(self, rev: str, path: str) -> dict: """Frontmatter of `path` at `rev` ({} for deleted/binary files).""" - out = self._run("show", f"{rev}:{path}") + out = self.text_at(rev, path) return _parse_frontmatter(out) if out else {} + def text_at(self, rev: str, path: str) -> str: + """The text of `path` at `rev`, "" where it does not exist.""" + return self._run("show", f"{rev}:{path}") or "" + class Brain: """The brain projection working copy — the curator owns its git. @@ -1069,6 +1108,7 @@ async def mirror_reconcile() -> bool: selection = member_selection( paths, lambda p: vault.frontmatter_at(head, p), shared_bucket=shared_bucket, + list_texts=lambda p: [vault.text_at(last, p), vault.text_at(head, p)], ) if not selection: last = _write(sha_file, head) diff --git a/tests/unit/stacklets/test_memory_curator.py b/tests/unit/stacklets/test_memory_curator.py index 773a7174..5760dddb 100644 --- a/tests/unit/stacklets/test_memory_curator.py +++ b/tests/unit/stacklets/test_memory_curator.py @@ -229,6 +229,63 @@ def test_no_duplicate_members(self): assert sel[0] == "--home" +# A topic page shows the open items of every list naming its topic, and a +# person page the open items assigned to that person. A change to a list +# has to rebuild those pages, or an item added in the chat reaches them +# only with the nightly sweep. + +CAMPING_LIST = "family/lists/camping-trip.md" + + +def _camping_list(*items: str, topics: tuple[str, ...] = ("camping",)) -> str: + topic_lines = "".join(f" - {t}\n" for t in topics) + return ("---\ntype: list\ntitle: Camping trip\ntopics:\n" + topic_lines + "---\n\n" + + "".join(f"{item}\n" for item in items)) + + +def _versions(before: str, after: str): + """list_texts stub: the list file before and after the change.""" + return lambda path: [before, after] if path == CAMPING_LIST else [] + + +class TestAListChange: + def test_an_item_for_marge_rebuilds_her_page_and_the_lists_topic(self): + before = _camping_list("- [ ] Pack the tent") + after = _camping_list("- [ ] Pack the tent", + "- [ ] Buy marshmallows [Marge](../people/marge.md)") + + sel = member_selection([CAMPING_LIST], _fm({}), shared_bucket="family", + list_texts=_versions(before, after)) + + assert sel == ["--home", "--member", "marge", "--topic", "camping"] + + def test_ticking_marges_item_still_rebuilds_her_page(self): + """The item leaves her page, so the page needs the rebuild as much.""" + before = _camping_list("- [ ] Buy marshmallows [Marge](../people/marge.md)") + after = _camping_list("- [x] Buy marshmallows [Marge](../people/marge.md) ✅ 2026-10-13") + + sel = member_selection([CAMPING_LIST], _fm({}), shared_bucket="family", + list_texts=_versions(before, after)) + + assert "--member" in sel and sel[sel.index("--member") + 1] == "marge" + + def test_a_topic_the_list_no_longer_names_is_rebuilt_too(self): + """Its page still shows the list's items until it is rebuilt.""" + before = _camping_list("- [ ] Pack the tent", topics=("camping",)) + after = _camping_list("- [ ] Pack the tent", topics=("holidays",)) + + sel = member_selection([CAMPING_LIST], _fm({}), shared_bucket="family", + list_texts=_versions(before, after)) + + assert sel == ["--home", "--topic", "camping", "--topic", "holidays"] + + def test_a_list_that_does_not_read_only_refreshes_home(self): + sel = member_selection([CAMPING_LIST], _fm({}), shared_bucket="family", + list_texts=_versions("not a list", "---\ntype: note\n---\n")) + + assert sel == ["--home"] + + # ── is_source_path ─────────────────────────────────────────────────────────