Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/admin-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>.md`: the full name, the other names the family uses, and the chat account if they have one.

Expand Down
44 changes: 42 additions & 2 deletions stacklets/memory/bot/curator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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] = []
Expand All @@ -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):
Expand All @@ -258,6 +267,32 @@ def member_selection(
return argv


def _is_list_path(parts: list[str]) -> bool:
"""`<bucket>/lists/<slug>.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"}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
57 changes: 57 additions & 0 deletions tests/unit/stacklets/test_memory_curator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────


Expand Down
Loading