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 ─────────────────────────────────────────────────────────