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
25 changes: 13 additions & 12 deletions stacklets/memory/bot/cli/wiki.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,14 @@
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import diary # noqa: E402
import home_page # noqa: E402
import open_items # noqa: E402
import person_page # noqa: E402
import topic_page # noqa: E402

# One module owns what a todo line is; `stack memory topic` reads
# through it the same way, by bare name from this folder.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from stack.lists import LISTS_DIR, ListError, read_list # noqa: E402
from stack.lists import LISTS_DIR, FamilyList, ListError, read_list # noqa: E402

HELP = "Regenerate the family wiki's home and member pages"

Expand Down Expand Up @@ -568,6 +569,7 @@ async def _generate_member(
parts, name=display, first_name=first_name, entries=entries,
lately=lately, docs=docs,
links={n: _reference(entries[n - 1])[1] for n in docs}, lang=lang,
open_items=open_items.person_items(_family_lists(shared_bucket), slug, lang),
)

page = _PERSON_PAGE_STAMP + "\n\n" + _with_references(page, entries, page_dir=slug, lang=lang)
Expand Down Expand Up @@ -632,9 +634,10 @@ async def _generate_topic(
# what was filed come from disk and the index, as they are.
page_dir = f"{bucket_prefix}/{topic_slug}"
sources = _topic_sources(entries, cross_refs, topic_slug, people or {})
todos = _open_todos(page_dir)
lists = _family_lists(bucket_prefix)
prompt = topic_page.prompt(
display=display, scope=scope, lang=lang, todos=todos,
display=display, scope=scope, lang=lang,
todos=open_items.topic_texts(lists, topic_slug),
evidence_text=topic_page.evidence(
sources, {n: e.get("summary") or "" for n, e in enumerate(entries + cross_refs, 1)}),
)
Expand All @@ -647,7 +650,8 @@ async def _generate_topic(
except topic_page.AnswerError as e:
_err(f"{page_dir}: the model's answer is unusable ({e}); the page stays as it was")
return 1
page = topic_page.render(answer, display=display, sources=sources, todos=todos, lang=lang)
page = topic_page.render(answer, display=display, sources=sources, lang=lang,
open_items=open_items.topic_items(lists, topic_slug, lang))

page = _with_references(page, entries + cross_refs, page_dir=page_dir, lang=lang)

Expand Down Expand Up @@ -697,19 +701,16 @@ def _family_names(people: dict[str, Person]) -> list[str]:
for p in people.values()]


def _open_todos(page_dir: str) -> list[str]:
"""The open items of the lists that name the topic at `page_dir`, in list order."""
bucket, _, topic = page_dir.rpartition("/")
items: list[str] = []
def _family_lists(bucket: str) -> list[tuple[str, FamilyList]]:
"""Every list in `bucket` with its vault path; a file that does not read is skipped."""
lists = []
for file in sorted((_brain_dir() / bucket / LISTS_DIR).glob("*.md")):
path = f"{bucket}/{LISTS_DIR}/{file.name}"
try:
lst = read_list(file.read_text(encoding="utf-8"), path=path)
lists.append((path, read_list(file.read_text(encoding="utf-8"), path=path)))
except (OSError, ListError):
continue
if topic in lst.topics:
items += [i.text for i in lst.open_items]
return items
return lists


def _member_preamble(slug: str, display: str, synonyms: list[str]) -> str:
Expand Down
69 changes: 69 additions & 0 deletions stacklets/memory/bot/open_items.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""The open items of the family's lists, as a topic page and a person page show them.

A list is a file of its own (`<bucket>/lists/<slug>.md`, format in
`stack.lists`) that names its topics and links its items to people. The
wiki shows them where the family already looks: a topic's page lists the
open items of every list naming the topic, under the list's name; a
person's page lists the open items linked to them, from every list,
soonest due first.

The items are copied as they are, never through the model, and a done
item is never shown: the page says what is still to do. A due day reads
`📅 16 Oct 2026`, the marker the list files use, in the household's
date format.

lists = [("family/lists/trip.md", read_list(text, path="family/lists/trip.md"))]
topic_items(lists, "trip", "en")
person_items(lists, "homer", "en")
"""

from __future__ import annotations

from page_template import format_date
from stack.lists import FamilyList, Item

Lists = list[tuple[str, FamilyList]]


def topic_items(lists: Lists, topic: str, lang: str) -> str:
"""The open items of the lists naming `topic`, each list under its linked name."""
groups = []
for path, lst in lists:
if topic not in lst.topics or not lst.open_items:
continue
lines = [_line(item, lang, _people(item)) for item in lst.open_items]
groups.append("\n".join([f"**{_link(path, lst)}**", *lines]))
return "\n\n".join(groups)


def person_items(lists: Lists, person: str, lang: str) -> str:
"""The open items linked to `person`, soonest due first, each naming its list."""
theirs = [(item, path, lst) for path, lst in lists for item in lst.open_items
if person in item.people]
# Undated items come after every dated one, in the order of their lists.
theirs.sort(key=lambda found: (not found[0].due, found[0].due))
return "\n".join(f"{_line(item, lang)} · {_link(path, lst)}"
for item, path, lst in theirs)


def topic_texts(lists: Lists, topic: str) -> list[str]:
"""The open items' texts for `topic`, for a prompt that only needs to know them."""
return [item.text for _, lst in lists if topic in lst.topics for item in lst.open_items]


def _line(item: Item, lang: str, names: list[str] | None = None) -> str:
"""`- [ ] Book the lodge · Homer · 📅 16 Oct 2026`, with the `names` given."""
bits = list(names or [])
if item.due:
bits.append(f"📅 {format_date(item.due, lang)}")
return " · ".join([f"- [ ] {item.text}", *bits])


def _people(item: Item) -> list[str]:
"""The names of the people an item is linked to, as the list writes them."""
return [link.label for link in item.links if link.person]


def _link(path: str, lst: FamilyList) -> str:
# Absolute from the vault root, as every wiki link (see `_relative_link`).
return f"[{lst.title}](/{path})"
7 changes: 5 additions & 2 deletions stacklets/memory/bot/person_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,14 @@ def read_answer(raw: str, *, lately: list[int]) -> Parts:
# ── The page ─────────────────────────────────────────────────────────────

def render(parts: Parts, *, name: str, first_name: str, entries: list[dict],
lately: list[int], docs: list[int], links: dict[int, str], lang: str) -> str:
lately: list[int], docs: list[int], links: dict[int, str], lang: str,
open_items: str = "") -> str:
"""The page body, from the template for `lang`.

`links` maps a citation number to the published path of its source,
for the Documents list. Entries the model wrote no line for fall
back to their title, so Lately never loses an entry.
back to their title, so Lately never loses an entry. `open_items`
are the list items linked to the person, already written out.
"""
lang = language(lang)
lately_lines = [
Expand All @@ -157,6 +159,7 @@ def render(parts: Parts, *, name: str, first_name: str, entries: list[dict],
"more": more,
"lately": "\n".join(lately_lines),
"documents": "\n".join(doc_lines),
"todos": open_items,
}
return fill(template("person", lang), slots)

Expand Down
4 changes: 4 additions & 0 deletions stacklets/memory/bot/templates/person.de.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ keine Dokumente hat, bekommt keine Überschrift „Dokumente“.
tagline eine warme Zeile, vom Modell geschrieben
about ein kurzer Absatz, vom Modell geschrieben
facts geboren, Schule oder Beruf, Arzt, Auto: Bezeichnung und Wert, vom Modell
todos die offenen Listenpunkte für sie, nächster Termin zuerst, vom Compiler übernommen
loves Aufzählung, vom Modell geschrieben
more ein Abschnitt, den das Modell mit eigener Überschrift ergänzen kann
lately die neuesten Einträge über die Person, vom Compiler datiert
Expand All @@ -24,6 +25,9 @@ Dieser Kommentar wird nicht veröffentlicht.
## Auf einen Blick
{{facts}}

## Noch zu tun
{{todos}}

## Was {{first_name}} liebt
{{loves}}

Expand Down
4 changes: 4 additions & 0 deletions stacklets/memory/bot/templates/person.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ documents has no Documents heading.
tagline one warm line, written by the model
about a short paragraph, written by the model
facts born, school or job, doctor, car: label and value, from the model
todos the open list items linked to them, soonest due first, copied by the compiler
loves bullets, written by the model
more a section the model may add, with its own heading
lately the latest entries naming them, dated by the compiler
Expand All @@ -24,6 +25,9 @@ This comment is not published.
## At a glance
{{facts}}

## Still to do
{{todos}}

## What {{first_name}} loves
{{loves}}

Expand Down
3 changes: 2 additions & 1 deletion stacklets/memory/bot/templates/topic.de.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ Absatz mit leerem Platzhalter fällt ganz weg, samt Überschrift.
summary eine Zeile: wofür das Thema der Familie dient, vom Modell
facts die wichtigsten Fakten als Bezeichnung und Wert, vom Modell
state was entschieden, erledigt oder geändert wurde, und von wem, vom Modell
todos die offenen Punkte der Aufgabenliste, wie die Familie sie führt
todos die offenen Punkte der Listen zum Thema, unter dem Namen der Liste,
vom Compiler übernommen
notes, bookmarks, documents
alles, was unter dem Thema abgelegt ist, das Neueste zuerst, vom Compiler
elsewhere Einträge an anderer Stelle, die das Thema nennen, vom Compiler
Expand Down
3 changes: 2 additions & 1 deletion stacklets/memory/bot/templates/topic.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ slot is empty is left out whole, heading included.
summary one line: what the topic is for the family, from the model
facts the key facts as label and value, from the model
state what has been decided, done or changed, and by whom, from the model
todos the open items of the topic's todo list, as the family keeps it
todos the open items of the lists naming the topic, under each list's name,
copied by the compiler
notes, bookmarks, documents
everything filed under the topic, newest first, by the compiler
elsewhere pages filed elsewhere that mention the topic, by the compiler
Expand Down
6 changes: 3 additions & 3 deletions stacklets/memory/bot/topic_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ class Source:


def render(answer: Answer, *, display: str, sources: list[Source],
todos: list[str], lang: str) -> str:
"""The topic page body. `todos` are the open items, in the list's order."""
open_items: str, lang: str) -> str:
"""The topic page body. `open_items` are the topic's lists, already written out."""
def listed(kind: str) -> str:
rows = sorted((s for s in sources if s.kind == kind),
key=lambda s: s.date, reverse=True)
Expand All @@ -94,7 +94,7 @@ def listed(kind: str) -> str:
"summary": answer.summary,
"facts": "\n".join(fact(label, value, c) for label, value, c in answer.facts),
"state": "\n".join(f"- {sentence(t)}{c}" for t, c in answer.state),
"todos": "\n".join(f"- [ ] {item}" for item in todos),
"todos": open_items,
"notes": listed("note"),
"bookmarks": listed("bookmark"),
"documents": listed("document"),
Expand Down
36 changes: 36 additions & 0 deletions tests/unit/stacklets/test_memory_person_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,42 @@ def test_documents_lists_only_what_was_filed_as_a_document(self, brain):
"(/family/documents/2025/01/2025-01-18-birth-certificate-p47.md) · 18 Jan 2025")


class TestWhatIsStillToDo:
"""The list items that are Maggie's, from every list, soonest due first."""

LISTS = {
"schule": ("---\ntype: list\ntitle: Schule\n---\n\n"
"- [ ] Vaccination card [Maggie](../people/maggie.md)\n"
"- [ ] Field trip form [Maggie](../people/maggie.md) 📅 2026-10-20\n"
"- [x] Lunch money [Maggie](../people/maggie.md) 📅 2026-10-01\n"),
"einkauf": ("---\ntype: list\ntitle: Einkauf\n---\n\n"
"- [ ] Pacifier [Maggie](../people/maggie.md) 📅 2026-10-14\n"
"- [ ] Duff [Homer](../people/homer.md)\n"),
}

def _with_lists(self, brain):
(brain / "family" / "lists").mkdir(parents=True)
for slug, text in self.LISTS.items():
(brain / "family" / "lists" / f"{slug}.md").write_text(text, encoding="utf-8")

def test_open_ones_only_soonest_due_first_each_with_its_list(self, brain):
self._with_lists(brain)
page = _page(brain, ANSWER, [FIRST_WORD])
assert page.split("## Still to do\n", 1)[1].split("\n\n", 1)[0] == (
"- [ ] Pacifier · 📅 14 Oct 2026 · [Einkauf](/family/lists/einkauf.md)\n"
"- [ ] Field trip form · 📅 20 Oct 2026 · [Schule](/family/lists/schule.md)\n"
"- [ ] Vaccination card · [Schule](/family/lists/schule.md)")

def test_it_sits_under_the_facts_at_a_glance(self, brain):
self._with_lists(brain)
page = _page(brain, ANSWER, [FIRST_WORD], lang="de")
assert "## Noch zu tun\n- [ ] Pacifier · 📅 14. Okt. 2026" in page

def test_nothing_open_for_her_no_heading(self, brain):
page = _page(brain, ANSWER, [FIRST_WORD])
assert "## Still to do" not in page


class TestAnEmptySectionIsNotThere:
def test_no_documents_no_documents_heading(self, brain):
page = _page(brain, ANSWER, [FIRST_WORD, SQUISHEE])
Expand Down
23 changes: 19 additions & 4 deletions tests/unit/stacklets/test_memory_topic_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
Read from the outside, as the family reads the published page. What
these tests promise:

- The open items are the family's own todo list, exactly, and done
items are not shown.
- The open items are the family's own lists naming the topic, exactly,
under each list's name, and done items are not shown.
- Everything filed under the topic is listed under its kind with who
filed it and when, newest first; what was filed elsewhere has a
section of its own.
Expand Down Expand Up @@ -88,9 +88,24 @@ def _section(page: str, heading: str) -> str:


class TestWhatTheCompilerLists:
def test_the_open_todos_as_the_family_keeps_them(self, brain):
def test_the_open_items_under_their_lists_name_as_the_family_keeps_them(self, brain):
assert _section(_topic(brain, ANSWER), "Still to do") == (
"- [ ] Pack sunscreen\n- [ ] Find a dog sitter")
"**[Trip](/family/lists/trip.md)**\n- [ ] Pack sunscreen\n- [ ] Find a dog sitter")

def test_every_list_naming_the_topic_with_who_and_by_when(self, brain):
(brain / "family" / "lists" / "packing.md").write_text(
"---\ntype: list\ntitle: Packing\ntopics:\n - trip\n---\n\n"
"- [ ] Tent [Homer](../people/homer.md) 📅 2026-10-13\n", encoding="utf-8")
page = _topic(brain, ANSWER)
still = page.split("## Still to do\n", 1)[1].split("\n## ", 1)[0].strip()
assert still == (
"**[Packing](/family/lists/packing.md)**\n- [ ] Tent · Homer · 📅 13 Oct 2026\n\n"
"**[Trip](/family/lists/trip.md)**\n- [ ] Pack sunscreen\n- [ ] Find a dog sitter")

def test_the_model_reads_the_open_items_but_does_not_write_them(self, brain):
model = _Model(ANSWER)
_topic(brain, ANSWER, model=model)
assert "Pack sunscreen" in model.prompts[0] and "Book the lodge" not in model.prompts[0]

def test_notes_newest_first_with_who_and_when(self, brain):
assert _section(_topic(brain, ANSWER), "Notes") == (
Expand Down
Loading