From 09f82b268385a5080be587a03af4956e196a5733 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 12 Oct 2026 09:45:31 +0200 Subject: [PATCH 01/17] feat(memory): add a file format for household lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groceries, a store, the things to do for a trip: every list becomes one file, `/lists/.md`, with a title, a description, the topics it belongs to and its sections in walking order. Every item is one checkbox line with its fields at the end: - a relative link to someone in `people/` assigns it, several links assign it to several people; - any other link into the vault names the record it came from; - `πŸ“…` is the day it is due and `βœ…` the day it was ticked, the fields of the Obsidian Tasks plugin. `stack.lists` reads and writes the format. It adds an item at the end of its section and creates a missing heading in the order of `sections`; every other line, including notes the family wrote in Obsidian or Forgejo, stays as it is. The commands, the archivist and the wiki move onto it next. Refs: #194 --- docs/design/brain/vault-format.md | 62 ++++- lib/stack/frontmatter.py | 11 +- lib/stack/lists.py | 359 +++++++++++++++++++++++++++++ tests/unit/framework/test_lists.py | 189 +++++++++++++++ 4 files changed, 619 insertions(+), 2 deletions(-) create mode 100644 lib/stack/lists.py create mode 100644 tests/unit/framework/test_lists.py diff --git a/docs/design/brain/vault-format.md b/docs/design/brain/vault-format.md index 23053100..1b95d2dd 100644 --- a/docs/design/brain/vault-format.md +++ b/docs/design/brain/vault-format.md @@ -84,7 +84,8 @@ cheap). **State documents** (the family's current truth; mutable through the CLI or by hand; never carry `generated`; see Β§7): -- `todos` β€” a topic's todo list. +- `todos` β€” a topic's todo list (moves to `list`, see Β§7). +- `list`: a family list, such as groceries, a store or things to do (Β§5, Β§7). - `person` (declared) β€” someone the family knows, one file each under `/people/`. @@ -263,6 +264,61 @@ presence. ### `index` (generated) `type: index`, `generated: true`, `title` optional. Body is navigation. +### `list` +The family's file for one list, `/lists/.md`. The file +name is the slug. A state document (Β§7), changed by hand or with +`stack memory list`, never generated. + +| Field | | Notes | +|---|---|---| +| `type` | R | `list` | +| `title` | R | what the family calls the list. | +| `description` | O | what goes on it ("Was wir im Supermarkt brauchen"); the agent picks a list by it. | +| `topics` | O | list; the topics the list belongs to, by slug. | +| `sections` | O | list; the sections in the order the family walks through the store. | + +``` +--- +type: list +title: Einkaufsliste +description: Was wir im Supermarkt brauchen +topics: + - einkaeufe-besorgungen +sections: + - GemΓΌse & Obst + - Milchprodukte & Eier +--- + +- [ ] Batterien + +## GemΓΌse & Obst +- [ ] Γ„pfel + +## Milchprodukte & Eier +- [ ] Joghurt [Marge](../people/marge.md) πŸ“… 2026-10-14 +- [x] Milch βœ… 2026-10-10 +``` + +The body holds one item per checkbox line. A section is a `##` +heading, kept in the order of `sections`; one the list does not name +comes after them, and an item without a section sits above the first +heading. Anything else in the body (a note under a heading, an indented +sub-item) belongs to the family and is left as it is. + +An item is what the family wrote, then its fields, in this order, at +the end of the line: + +- a relative link to a person in `/people/` assigns the item to + them; several links, several people, and one tick closes it for all; +- any other relative link into the vault names the record the item came + from; +- `πŸ“… YYYY-MM-DD` is the day it is due, `βœ… YYYY-MM-DD` the day it was + ticked: the Obsidian Tasks plugin's fields, so its queries read them. + +Only the trailing run of these counts. A link inside the sentence, a +web link and any other field stay part of the text. The same line is +used wherever an item appears, also in a record's summary. + ## 6. The `generated` invariant (from B3 / ADR-011) - A generated projection page MUST carry `generated: true`. @@ -274,6 +330,10 @@ presence. ## 7. State documents +`/lists/.md` is a state document (Β§5, `list`): a tick is +information, so a list is never regenerated, and a command changes only +the lines it is about. Each topic's `todos.md` below moves to a list. + `todos.md` is a state document, not a generated page: it is mutable current truth (a tick is information), lives in memory source, and never carries `generated`. It is a markdown checklist; frontmatter is optional diff --git a/lib/stack/frontmatter.py b/lib/stack/frontmatter.py index 7f466fbc..04b307fa 100644 --- a/lib/stack/frontmatter.py +++ b/lib/stack/frontmatter.py @@ -32,8 +32,12 @@ class FrontmatterError(ValueError): # Projections: generated wiki pages PROJECTION_TYPES = {"person", "correspondent", "topic", "index"} +# State documents: the family's current truth, changed by hand or by a +# command, never generated (Β§7) +STATE_TYPES = {"list"} + # All valid types -TYPES = RECORD_TYPES | PROJECTION_TYPES +TYPES = RECORD_TYPES | PROJECTION_TYPES | STATE_TYPES # ── Per-type field definitions (Β§5) ─────────────────────────────────── @@ -84,6 +88,11 @@ class TypeSchema: required={"type", "generated"}, list_fields=set(), ), + "list": TypeSchema( + required={"type", "title"}, + list_fields={"topics", "sections"}, + scalar_fields={"title", "description"}, + ), } diff --git a/lib/stack/lists.py b/lib/stack/lists.py new file mode 100644 index 00000000..a6b863e7 --- /dev/null +++ b/lib/stack/lists.py @@ -0,0 +1,359 @@ +"""The family's lists: one Markdown file each, one checkbox per item. + +A list lives at `/lists/.md`. The file name is the slug; +the frontmatter says what the list is and, for a store, the order of +its sections, which is the order the family walks through the store: + + --- + type: list + title: Einkaufsliste + description: Was wir im Supermarkt brauchen + topics: + - einkaeufe-besorgungen + sections: + - GemΓΌse & Obst + - Milchprodukte & Eier + --- + + ## GemΓΌse & Obst + - [ ] Γ„pfel + + ## Milchprodukte & Eier + - [ ] Joghurt [Marge](../people/marge.md) πŸ“… 2026-10-14 + +Every item, on a list or in a record's summary, is the same line: a +checkbox, what the family wrote, then the fields at the end. A link to +someone in `/people/` assigns the item to them, any other link +into the vault names the record the item came from, `πŸ“…` is the day it +is due and `βœ…` the day it was ticked. Those two are the Obsidian Tasks +plugin's fields, so its queries work on these files as they are. Links +are relative Markdown links, the one syntax Obsidian, Forgejo and the +wiki all follow (vault-format.md Β§1). + +Only the trailing run of links and dates are fields. A link inside the +sentence ("Call [Homer](...) about the car") stays part of the text, +and an unknown field (another Tasks emoji) ends the run, so nothing the +family wrote is reinterpreted or dropped. + +Edits are line edits. A family member may write anything else into a +list file in Obsidian or Forgejo, a note under a heading or a sub-item, +and inserting an item touches only the lines it adds. + +Pure functions, no I/O: the memory stacklet reads and commits the +files. Lives in the framework because the readers span stacklets: the +archivist files action items, the memory commands change lists, the +wiki renders them. +""" + +from __future__ import annotations + +import posixpath +import re +from dataclasses import dataclass, field, replace + +from .frontmatter import FrontmatterError, dump, parse, validate + +LISTS_DIR = "lists" + +DUE = "πŸ“…" +DONE = "βœ…" + +_ISO_DAY = r"\d{4}-\d{2}-\d{2}" + +# A checkbox line, open or done, at any indentation: Obsidian keeps +# sub-items as indented lines. Group 3 is the status character, " " for +# open and "x" for done; Obsidian Tasks has more (`-` cancelled, `/` in +# progress), which are kept as they are. +_ITEM = re.compile(r"^(\s*)([-*+]) \[(.)\] (.*?)\s*$") + +# The trailing fields, matched one at a time from the end of the line. +_TRAILING_LINK = re.compile(r"\s*\[([^\]]+)\]\(([^)\s]+)\)$") +_TRAILING_DUE = re.compile(rf"\s*{DUE}\s*({_ISO_DAY})$") +_TRAILING_DONE = re.compile(rf"\s*{DONE}\s*({_ISO_DAY})$") + +_HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$") + +# `/people/.md`: a declared person (stack.people). +_PERSON_PATH = re.compile(r"^[^/]+/people/([^/]+)\.md$") + + +class ListError(ValueError): + """A file that is not a list, or whose frontmatter does not parse.""" + + +# ── Items ──────────────────────────────────────────────────────────────── + +@dataclass(frozen=True) +class Link: + """A link in an item's fields: what it reads as, and the vault path it names. + + `target` is relative to the vault root (`family/people/homer.md`), + so an item keeps its links when it moves to another file. + """ + + label: str + target: str + + +@dataclass(frozen=True) +class Item: + """One checkbox line. + + >>> item = parse_item("- [ ] Joghurt [Marge](../people/marge.md) πŸ“… 2026-10-14", + ... path="family/lists/einkaufsliste.md") + >>> item.text, item.people, item.due + ('Joghurt', ('marge',), '2026-10-14') + + `section` is the heading the item sits under ("" above the first + one) and `line` its line number in the body it was read from; both + are set by `read_list`, not by `parse_item`. + """ + + text: str + done: bool = False + links: tuple[Link, ...] = () + due: str = "" + done_on: str = "" + section: str = "" + status: str = " " + indent: str = "" + line: int = -1 + + @property + def people(self) -> tuple[str, ...]: + """The ids of the people the item is assigned to, in the order written.""" + return tuple(m[1] for link in self.links if (m := _PERSON_PATH.match(link.target))) + + @property + def sources(self) -> tuple[str, ...]: + """The vault paths of the records the item came from.""" + return tuple(link.target for link in self.links if not _PERSON_PATH.match(link.target)) + + +def person_link(person_id: str, label: str, *, bucket: str) -> Link: + """The link that assigns an item to a declared person.""" + return Link(label, f"{bucket}/people/{person_id}.md") + + +def parse_item(line: str, *, path: str) -> Item | None: + """The item on a checkbox line, or None for any other line. + + `path` is the vault path of the file the line is in, so a relative + link resolves to the vault path it names. + """ + m = _ITEM.match(line) + if not m: + return None + indent, status, rest = m[1], m[3], m[4] + links: list[Link] = [] + due = done_on = "" + while True: + if (f := _TRAILING_DUE.search(rest)) and not due: + due = f[1] + elif (f := _TRAILING_DONE.search(rest)) and not done_on: + done_on = f[1] + elif (f := _TRAILING_LINK.search(rest)) and (target := _vault_target(f[2], path)): + links.insert(0, Link(f[1], target)) + else: + break + rest = rest[:f.start()] + return Item(text=rest.strip(), done=status in "xX", links=tuple(links), + due=due, done_on=done_on, status=status, indent=indent) + + +def format_item(item: Item, *, path: str) -> str: + """The checkbox line for an item in the file at vault path `path`.""" + status = item.status if item.status not in " xX" else ("x" if item.done else " ") + parts = [f"{item.indent}- [{status}] {item.text}"] + parts += [f"[{link.label}]({_relative(link.target, path)})" for link in item.links] + if item.due: + parts.append(f"{DUE} {item.due}") + if item.done_on: + parts.append(f"{DONE} {item.done_on}") + return " ".join(parts) + + +def _vault_target(href: str, path: str) -> str: + """The vault path a link names, or "" for a link out of the vault. + + A link to a web page (an online shop) stays part of the item's text. + `/family/...` is the wiki's way of writing a vault path and is read + the same as a relative one. + """ + if "://" in href or href.startswith(("mailto:", "#")) or not href.endswith(".md"): + return "" + joined = href.lstrip("/") if href.startswith("/") \ + else posixpath.join(posixpath.dirname(path), href) + target = posixpath.normpath(joined) + return "" if target.startswith("..") else target + + +def _relative(target: str, path: str) -> str: + return posixpath.relpath(target, posixpath.dirname(path) or ".") + + +# ── Lists ──────────────────────────────────────────────────────────────── + +@dataclass +class FamilyList: + """What a list file says: its frontmatter and its items, in file order.""" + + slug: str + title: str + description: str = "" + topics: list[str] = field(default_factory=list) + sections: list[str] = field(default_factory=list) + items: list[Item] = field(default_factory=list) + + @property + def open_items(self) -> list[Item]: + return [i for i in self.items if not i.done] + + +def list_path(slug: str, *, bucket: str) -> str: + """The vault path of the list `slug` in `bucket`.""" + return f"{bucket}/{LISTS_DIR}/{slug}.md" + + +def read_list(text: str, *, path: str) -> FamilyList: + """The list in a file's text, or `ListError` when it is not one.""" + try: + fm = parse(text) + except FrontmatterError as e: + raise ListError(f"{path}: frontmatter does not parse: {e}") from e + if fm.get("type") != "list": + raise ListError(f"{path}: not a list (type: {fm.get('type') or 'missing'})") + errors = validate(fm) + if errors: + raise ListError(f"{path}: {'; '.join(errors)}") + + items: list[Item] = [] + section = "" + for n, line in enumerate(_body(text).splitlines()): + if h := _HEADING.match(line): + section = h[2] if len(h[1]) > 1 else "" + elif item := parse_item(line, path=path): + items.append(replace(item, section=section, line=n)) + return FamilyList( + slug=posixpath.splitext(posixpath.basename(path))[0], + title=str(fm.get("title") or ""), + description=str(fm.get("description") or ""), + topics=list(fm.get("topics") or []), + sections=list(fm.get("sections") or []), + items=items, + ) + + +def new_list(title: str, *, description: str = "", topics: list[str] | None = None, + sections: list[str] | None = None) -> str: + """The text of a new, empty list file.""" + fm = {"type": "list", "title": title, "description": description, + "topics": list(topics or []), "sections": list(sections or [])} + return f"---\n{dump(fm)}\n---\n" + + +# ── Adding an item ─────────────────────────────────────────────────────── + +def insert_item(text: str, item: Item, *, path: str) -> str: + """The list text with `item` added at the end of its section. + + A section that has no heading yet gets one, placed in the order the + list's `sections` give; a section the list does not name goes after + all the others. An item without a section goes above the first + heading, where nothing has to be named to find it. Every other line + stays as it is. + """ + head, body = _split(text) + lines = body.splitlines() + line = format_item(item, path=path) + sections = list(parse(text).get("sections") or []) + + if not item.section: + first = next((n for n, ln in enumerate(lines) if _HEADING.match(ln)), len(lines)) + at = _after_last_content(lines, 0, first) + return _join(head, lines[:at] + [line] + _gap_before(lines, at)) + + start = _heading_line(lines, item.section) + if start is not None: + end = _section_end(lines, start) + at = _after_last_content(lines, start + 1, end) + return _join(head, lines[:at] + [line] + lines[at:]) + + at = _new_heading_position(lines, item.section, sections) + block = [f"## {item.section}", line] + before = [""] if at > 0 and lines[at - 1].strip() else [] + after = [""] if at < len(lines) else [] + return _join(head, lines[:at] + before + block + after + lines[at:]) + + +def _heading_line(lines: list[str], section: str) -> int | None: + want = section.casefold() + for n, ln in enumerate(lines): + h = _HEADING.match(ln) + if h and len(h[1]) > 1 and h[2].casefold() == want: + return n + return None + + +def _section_end(lines: list[str], start: int) -> int: + """The line after the section that starts at heading `start`.""" + for n in range(start + 1, len(lines)): + if _HEADING.match(lines[n]): + return n + return len(lines) + + +def _after_last_content(lines: list[str], start: int, end: int) -> int: + """The position after the last non-blank line in `lines[start:end]`.""" + at = end + while at > start and not lines[at - 1].strip(): + at -= 1 + return at + + +def _gap_before(lines: list[str], at: int) -> list[str]: + """The lines from `at` on, with one blank line before a heading that follows.""" + rest = lines[at:] + if rest and _HEADING.match(rest[0]): + return [""] + rest + return rest + + +def _new_heading_position(lines: list[str], section: str, order: list[str]) -> int: + """Where a new heading for `section` goes: before the next section in `order`.""" + later = [s.casefold() for s in _after(order, section)] + for n, ln in enumerate(lines): + h = _HEADING.match(ln) + if h and len(h[1]) > 1 and h[2].casefold() in later: + return n + return len(lines) + + +def _after(order: list[str], section: str) -> list[str]: + """The sections that come after `section` in `order`; [] if it is not there.""" + folded = [s.casefold() for s in order] + if section.casefold() not in folded: + return [] + return order[folded.index(section.casefold()) + 1:] + + +# ── File text ──────────────────────────────────────────────────────────── + +def _split(text: str) -> tuple[str, str]: + """(frontmatter block with its delimiters and blank line, body).""" + if not text.startswith("---\n"): + return "", text + end = text.find("\n---\n", 4) + if end < 0: + return "", text + head = text[:end + len("\n---\n")] + return head, text[len(head):].lstrip("\n") + + +def _body(text: str) -> str: + return _split(text)[1] + + +def _join(head: str, lines: list[str]) -> str: + body = "\n".join(lines).strip("\n") + return f"{head}\n{body}\n" if head else f"{body}\n" diff --git a/tests/unit/framework/test_lists.py b/tests/unit/framework/test_lists.py new file mode 100644 index 00000000..3e8f14e7 --- /dev/null +++ b/tests/unit/framework/test_lists.py @@ -0,0 +1,189 @@ +"""The family's lists: one file each, one checkbox line per item. + +A list is read by Obsidian, edited in Forgejo, changed by the memory +commands and the agent, and rendered by the wiki, so the format is the +contract (docs/design/brain/vault-format.md, `list`). These tests pin: + +- An item's fields are the ones Obsidian's Tasks plugin reads: `πŸ“…` due, + `βœ…` done, at the end of the line. +- People and source records are relative Markdown links, which + Obsidian, Forgejo and the wiki all follow, and they survive a move to + another file. +- Nothing the family wrote is reinterpreted: a link inside the sentence, + a web link, an unknown field or a note under a heading stays as it is. +- A store list keeps its sections in walking order, whatever order the + items arrive in. +""" + +from __future__ import annotations + +import pytest + +from stack.lists import ( + Item, + ListError, + format_item, + insert_item, + list_path, + new_list, + parse_item, + person_link, + read_list, +) + +GROCERIES = list_path("einkaufsliste", bucket="family") +STORE = ["GemΓΌse & Obst", "Brot & Backwaren", "Milchprodukte & Eier", "Drogerie"] + + +def _groceries() -> str: + return new_list("Einkaufsliste", description="Was wir im Supermarkt brauchen", + topics=["einkaeufe-besorgungen"], sections=STORE) + + +def _add(text: str, name: str, section: str = "", **fields) -> str: + return insert_item(text, Item(name, section=section, **fields), path=GROCERIES) + + +def _body(text: str) -> str: + return text.split("\n---\n", 1)[1] + + +class TestAnItemLine: + def test_due_and_done_dates_are_the_obsidian_tasks_fields(self): + item = parse_item("- [x] Pack sunscreen πŸ“… 2026-10-12 βœ… 2026-10-10", path=GROCERIES) + assert (item.text, item.done, item.due, item.done_on) == \ + ("Pack sunscreen", True, "2026-10-12", "2026-10-10") + + def test_people_are_relative_links_to_their_person_files(self): + item = parse_item("- [ ] Sign the field trip form [Homer](../people/homer.md) " + "[Marge](../people/marge.md) πŸ“… 2026-10-20", path=GROCERIES) + assert item.people == ("homer", "marge") + assert item.text == "Sign the field trip form" + + def test_a_link_to_a_record_names_where_the_item_came_from(self): + item = parse_item("- [ ] Pay the hospital invoice " + "[Rechnung](../documents/2026/09/2026-09-30-hospital-invoice-p63.md)", + path=GROCERIES) + assert item.sources == ("family/documents/2026/09/2026-09-30-hospital-invoice-p63.md",) + assert item.people == () + + def test_written_back_it_reads_the_same(self): + line = ("- [ ] Book the lodge [Homer](../people/homer.md) " + "[Buchung](../documents/2026/10/lodge-p70.md) πŸ“… 2026-10-14") + assert format_item(parse_item(line, path=GROCERIES), path=GROCERIES) == line + + def test_a_new_item_is_written_with_its_fields_at_the_end(self): + item = Item("Book the lodge", links=(person_link("homer", "Homer", bucket="family"),), + due="2026-10-14") + assert format_item(item, path=GROCERIES) == \ + "- [ ] Book the lodge [Homer](../people/homer.md) πŸ“… 2026-10-14" + + def test_its_links_follow_it_to_another_file(self): + """An archived item still opens the same person file.""" + item = parse_item("- [x] Milch [Marge](../people/marge.md) βœ… 2026-10-10", path=GROCERIES) + archive = "family/lists/archive/einkaufsliste/2026-10.md" + assert format_item(item, path=archive) == \ + "- [x] Milch [Marge](../../../people/marge.md) βœ… 2026-10-10" + + def test_any_other_line_is_not_an_item(self): + assert parse_item("## Milchprodukte & Eier", path=GROCERIES) is None + assert parse_item("- Milch, without a checkbox", path=GROCERIES) is None + + +class TestWhatTheFamilyWroteStays: + def test_a_link_inside_the_sentence_is_text_not_an_assignment(self): + line = "- [ ] Call [Homer](../people/homer.md) about the car" + item = parse_item(line, path=GROCERIES) + assert item.people == () and item.text == "Call [Homer](../people/homer.md) about the car" + assert format_item(item, path=GROCERIES) == line + + def test_a_web_link_stays_in_the_text(self): + """Things to order online often carry the shop's link.""" + line = "- [ ] Druckerpatronen [Shop](https://example.com/ink)" + item = parse_item(line, path=GROCERIES) + assert item.links == () and format_item(item, path=GROCERIES) == line + + def test_an_unknown_tasks_field_is_kept(self): + line = "- [ ] SteuererklΓ€rung [Homer](../people/homer.md) ⏫" + item = parse_item(line, path=GROCERIES) + assert format_item(item, path=GROCERIES) == line + + def test_another_tasks_status_is_kept(self): + line = "- [-] Kino-Gutschein" + item = parse_item(line, path=GROCERIES) + assert not item.done and format_item(item, path=GROCERIES) == line + + def test_a_note_under_a_heading_survives_an_insert(self): + text = _add(_groceries(), "Γ„pfel", "GemΓΌse & Obst") + text = text.replace("- [ ] Γ„pfel\n", "- [ ] Γ„pfel\nNur die vom Bauern!\n") + text = _add(text, "Birnen", "GemΓΌse & Obst") + assert "- [ ] Γ„pfel\nNur die vom Bauern!\n- [ ] Birnen\n" in text + + +class TestAList: + def test_reads_its_title_description_topics_and_sections(self): + lst = read_list(_groceries(), path=GROCERIES) + assert (lst.slug, lst.title, lst.description) == \ + ("einkaufsliste", "Einkaufsliste", "Was wir im Supermarkt brauchen") + assert lst.topics == ["einkaeufe-besorgungen"] and lst.sections == STORE + + def test_each_item_knows_its_section(self): + text = _add(_add(_groceries(), "Joghurt", "Milchprodukte & Eier"), "Batterien") + lst = read_list(text, path=GROCERIES) + assert [(i.text, i.section) for i in lst.items] == \ + [("Batterien", ""), ("Joghurt", "Milchprodukte & Eier")] + + def test_open_items_leave_out_the_ticked_ones(self): + text = _add(_add(_groceries(), "Milch", "Milchprodukte & Eier", done=True), "Eier", + "Milchprodukte & Eier") + assert [i.text for i in read_list(text, path=GROCERIES).open_items] == ["Eier"] + + def test_a_list_needs_only_a_title(self): + text = new_list("Garage aufrΓ€umen") + assert text == "---\ntype: list\ntitle: Garage aufrΓ€umen\n---\n" + assert read_list(text, path="family/lists/garage.md").items == [] + + def test_a_file_that_is_not_a_list_is_refused(self): + with pytest.raises(ListError, match="not a list"): + read_list("---\ntype: note\ntitle: Einkauf\ntimestamp: x\n---\n", + path="family/lists/einkauf.md") + + +class TestTheWalkingOrder: + """Sections stay in the order of `sections`, whatever order items arrive in.""" + + def test_sections_appear_in_store_order(self): + text = _groceries() + for name, section in [("Zahnpasta", "Drogerie"), ("Joghurt", "Milchprodukte & Eier"), + ("Γ„pfel", "GemΓΌse & Obst"), ("Brezeln", "Brot & Backwaren")]: + text = _add(text, name, section) + assert _body(text) == ( + "\n## GemΓΌse & Obst\n- [ ] Γ„pfel\n" + "\n## Brot & Backwaren\n- [ ] Brezeln\n" + "\n## Milchprodukte & Eier\n- [ ] Joghurt\n" + "\n## Drogerie\n- [ ] Zahnpasta\n") + + def test_an_item_joins_the_end_of_its_section(self): + text = _add(_add(_groceries(), "Joghurt", "Milchprodukte & Eier"), "Zahnpasta", "Drogerie") + text = _add(text, "Eier", "milchprodukte & eier") + assert "## Milchprodukte & Eier\n- [ ] Joghurt\n- [ ] Eier\n\n## Drogerie" in text + + def test_an_item_without_a_section_goes_above_the_first_heading(self): + text = _add(_add(_groceries(), "Joghurt", "Milchprodukte & Eier"), "Batterien") + assert _body(text) == "\n- [ ] Batterien\n\n## Milchprodukte & Eier\n- [ ] Joghurt\n" + + def test_a_section_the_list_does_not_name_goes_last(self): + text = _add(_add(_groceries(), "Zahnpasta", "Drogerie"), "Kerzen", "Deko") + assert _body(text).endswith("## Drogerie\n- [ ] Zahnpasta\n\n## Deko\n- [ ] Kerzen\n") + + def test_a_list_without_sections_keeps_items_in_the_order_they_came(self): + text = new_list("Garage aufrΓ€umen") + for name in ["Werkbank leeren", "FahrrΓ€der aufhΓ€ngen"]: + text = insert_item(text, Item(name), path="family/lists/garage.md") + assert _body(text) == "\n- [ ] Werkbank leeren\n- [ ] FahrrΓ€der aufhΓ€ngen\n" + + +def test_link_targets_are_vault_paths(): + """A link outside the vault is not a field: it could never be resolved.""" + item = parse_item("- [ ] Milch [x](../../../etc/passwd.md)", path=GROCERIES) + assert item.links == () From 6e78f9af0d186735cd67c042e7cb958d884ba494 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 12 Oct 2026 10:05:03 +0200 Subject: [PATCH 02/17] feat(memory): add commands to show and change lists `stack memory list` works on every list in `/lists/`, the same verbs for groceries, a store or the todos for a trip: - `show` names every list with its open items counted, or shows one list's items under their sections; `--all` adds the ticked ones. - `create` starts a list with a title, a description, its topics and its sections in walking order. - `add` puts one or more items into a section, with `--for` for the people it is assigned to and `--due` for a day. - `tick`, `untick` and `remove` change items by name; a tick records the day. - `move` takes an item to another list, keeping its people and dates. - `reset` reopens every ticked item, for a list used again and again. The caller names the item, never the line, and the rest of the file stays as it is. A name that fits several items, a section the list does not have, a person the family never declared or a list that does not exist is refused with the choices there are. An item bought and needed again is on the list twice under one name; a tick then means the open one, an untick the ticked one. Every change commits to Forgejo as the person given with `--as`, which is required, and every answer and commit names the list it changed: `chore(lists): homer ticked off Milch on Einkaufsliste`. `stack memory write` refuses a rewrite that drops or reopens an item on these files, as it already does for `todos.md`. Refs: #194 --- lib/stack/lists.py | 60 ++- stacklets/memory/cli/list.py | 449 ++++++++++++++++++ stacklets/memory/cli/write.py | 12 +- tests/unit/stacklets/test_memory_list_cli.py | 254 ++++++++++ tests/unit/stacklets/test_memory_write_cli.py | 7 + 5 files changed, 776 insertions(+), 6 deletions(-) create mode 100644 stacklets/memory/cli/list.py create mode 100644 tests/unit/stacklets/test_memory_list_cli.py diff --git a/lib/stack/lists.py b/lib/stack/lists.py index a6b863e7..a45824d1 100644 --- a/lib/stack/lists.py +++ b/lib/stack/lists.py @@ -52,6 +52,7 @@ from dataclasses import dataclass, field, replace from .frontmatter import FrontmatterError, dump, parse, validate +from .people import person_path LISTS_DIR = "lists" @@ -94,6 +95,12 @@ class Link: label: str target: str + @property + def person(self) -> str: + """The id of the person the link names, or "" for a record.""" + m = _PERSON_PATH.match(self.target) + return m[1] if m else "" + @dataclass(frozen=True) class Item: @@ -122,17 +129,17 @@ class Item: @property def people(self) -> tuple[str, ...]: """The ids of the people the item is assigned to, in the order written.""" - return tuple(m[1] for link in self.links if (m := _PERSON_PATH.match(link.target))) + return tuple(link.person for link in self.links if link.person) @property def sources(self) -> tuple[str, ...]: """The vault paths of the records the item came from.""" - return tuple(link.target for link in self.links if not _PERSON_PATH.match(link.target)) + return tuple(link.target for link in self.links if not link.person) def person_link(person_id: str, label: str, *, bucket: str) -> Link: """The link that assigns an item to a declared person.""" - return Link(label, f"{bucket}/people/{person_id}.md") + return Link(label, person_path(person_id, bucket)) def parse_item(line: str, *, path: str) -> Item | None: @@ -337,6 +344,43 @@ def _after(order: list[str], section: str) -> list[str]: return order[folded.index(section.casefold()) + 1:] +# ── Changing an item ───────────────────────────────────────────────────── + +def find(lst: FamilyList, name: str) -> list[Item]: + """The items `name` means: the one with exactly that text, else every one containing it. + + Case does not count. Several matches are the caller's to resolve, + by asking for more of the item's words; a guess would tick the + wrong thing. + """ + wanted = name.strip().casefold() + exact = [i for i in lst.items if i.text.casefold() == wanted] + return exact or [i for i in lst.items if wanted and wanted in i.text.casefold()] + + +def set_done(text: str, item: Item, *, done: bool, today: str, path: str) -> str: + """The list text with `item` ticked on `today`, or opened again.""" + changed = replace(item, done=done, status="x" if done else " ", + done_on=today if done else "") + + def edit(lines: list[str]) -> None: + lines[item.line] = format_item(changed, path=path) + return _edit_body(text, edit) + + +def remove_item(text: str, item: Item) -> str: + """The list text without `item`'s line, and without a heading it leaves empty.""" + def edit(lines: list[str]) -> None: + del lines[item.line] + start = next((n for n in range(item.line - 1, -1, -1) if _HEADING.match(lines[n])), None) + if start is None: + return + end = _section_end(lines, start) + if not any(ln.strip() for ln in lines[start + 1:end]): + del lines[start:end] + return _edit_body(text, edit) + + # ── File text ──────────────────────────────────────────────────────────── def _split(text: str) -> tuple[str, str]: @@ -354,6 +398,16 @@ def _body(text: str) -> str: return _split(text)[1] +def _edit_body(text: str, edit) -> str: + """`text` with its body lines changed in place by `edit`.""" + head, body = _split(text) + lines = body.splitlines() + edit(lines) + return _join(head, lines) + + def _join(head: str, lines: list[str]) -> str: body = "\n".join(lines).strip("\n") + if not body: + return head return f"{head}\n{body}\n" if head else f"{body}\n" diff --git a/stacklets/memory/cli/list.py b/stacklets/memory/cli/list.py new file mode 100644 index 00000000..31c62d8d --- /dev/null +++ b/stacklets/memory/cli/list.py @@ -0,0 +1,449 @@ +"""stack memory list: show and change the family's lists. + +Every list is a file, `/lists/.md` (the format is +`stack.lists`). Groceries, the market, things to order, the todos for +a trip: one kind of list, one set of verbs. + +The verbs are in `USAGE`, which a bare `stack memory list` prints. + +The caller names the item, never the line: the item is matched and the +rest of the file is left as it is, so a tick cannot lose other state. +A name that matches several items, a section the list does not have, +or a person the family has not declared is refused with the valid +choices, so the caller can pick one instead of guessing. + +Reads come off the vault on disk; writes commit to Forgejo as the +person named by `--as`, through `update_memory`, against the current +file there. +""" + +from __future__ import annotations + +HELP = "Show and change the family's lists" + +import argparse +import re +import sys +from dataclasses import replace +from datetime import date +from pathlib import Path +from typing import Callable + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from lib import ( # noqa: E402 + DEFAULT_SHARED_BUCKET, + refresh_vault_if_stale, + update_memory, + vault_path_for, +) + +from stack.links import go_page, public # noqa: E402 +from stack.lists import ( # noqa: E402 + LISTS_DIR, + FamilyList, + Item, + ListError, + find, + insert_item, + list_path, + new_list, + person_link, + read_list, + remove_item, + set_done, +) +from stack.people import load_people, resolve # noqa: E402 + +USAGE = """\ + stack memory list show every list, with its open items counted + stack memory list show einkaufsliste [--all] the open items (--all: the ticked ones too) + stack memory list create einkaufsliste --title Einkaufsliste \\ + --description "Was wir im Supermarkt brauchen" --topic einkaeufe-besorgungen \\ + --section "GemΓΌse & Obst" --section "Milchprodukte & Eier" --as marge + stack memory list add einkaufsliste Joghurt Eier --section "Milchprodukte & Eier" --as marge + stack memory list add schule "Sign the field trip form" --for homer --for marge \\ + --due 2026-10-20 --as marge + stack memory list tick|untick|remove einkaufsliste Joghurt --as homer + stack memory list move einkaufsliste Eier --to markt --as marge + stack memory list reset packliste --as marge reopen every ticked item +""" + +VERBS = ("show", "create", "add", "tick", "untick", "remove", "move", "reset") + +# A commit subject's length, as git and Forgejo show it without wrapping. +_SUBJECT_MAX = 72 + +_SLUG = re.compile(r"^[a-z0-9][a-z0-9-]*$") +_ISO_DAY = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +class Refusal(ValueError): + """An answer that tells the caller what to do instead.""" + + +# ── Arguments ──────────────────────────────────────────────────────────── + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="stack memory list", add_help=False) + verbs = parser.add_subparsers(dest="verb") + + show = verbs.add_parser("show", add_help=False) + show.add_argument("slug", nargs="?") + show.add_argument("--all", action="store_true") + + create = verbs.add_parser("create", add_help=False) + create.add_argument("slug") + create.add_argument("--title", required=True) + create.add_argument("--description", default="") + create.add_argument("--topic", action="append", default=[]) + create.add_argument("--section", action="append", default=[]) + + add = verbs.add_parser("add", add_help=False) + add.add_argument("slug") + add.add_argument("items", nargs="+") + add.add_argument("--section", default="") + add.add_argument("--for", dest="people", action="append", default=[]) + add.add_argument("--due", default="") + + for verb in ("tick", "untick", "remove"): + one = verbs.add_parser(verb, add_help=False) + one.add_argument("slug") + one.add_argument("items", nargs="+") + + move = verbs.add_parser("move", add_help=False) + move.add_argument("slug") + move.add_argument("items", nargs="+") + move.add_argument("--to", required=True) + + reset = verbs.add_parser("reset", add_help=False) + reset.add_argument("slug") + + for sub in (create, add, move, reset, *[verbs.choices[v] for v in ("tick", "untick", "remove")]): + sub.add_argument("--as", dest="actor", default="") + return parser + + +def run(args, stacklet, config): + argv = list(args or []) + if not argv or argv[0] not in VERBS: + print(USAGE) + return {"verbs": list(VERBS)} if not argv else \ + {"error": f"unknown verb {argv[0]!r}; use one of: {', '.join(VERBS)}"} + try: + ns = _parser().parse_args(argv) + except SystemExit: + return {"error": f"usage: see `stack memory list` for the {argv[0]} arguments"} + + vault = _vault(config) + if vault is None: + return {"error": "no vault found; is the memory stacklet installed?"} + try: + if ns.verb == "show": + return show(vault, ns.slug, show_all=ns.all) + if not ns.actor.strip(): + # Every change is a commit with a person's name on it; one + # without a name cannot be told apart from anybody else's. + raise Refusal(f"say who {ns.verb}s: add --as , the family member it is for") + actor = _actor(ns.actor) + if ns.verb == "create": + return create(vault, config, ns, actor) + if ns.verb == "move": + return move(vault, config, ns.slug, ns.items, ns.to, actor) + return change(vault, config, ns, actor) + except Refusal as e: + return {"error": str(e)} + + +# ── Reading ────────────────────────────────────────────────────────────── + +def show(vault: Path, slug: str | None, *, show_all: bool) -> dict: + """Print every list, or one list's items under their sections.""" + refresh_vault_if_stale(vault) + if not slug: + lists = _all_lists(vault) + if not lists: + print("No lists yet. Start one with `stack memory list create`.") + for lst in lists: + about = f" ({lst.description})" if lst.description else "" + print(f"{lst.slug}: {lst.title}, {len(lst.open_items)} open{about}") + return {"lists": [{"slug": x.slug, "title": x.title, "description": x.description, + "topics": x.topics, "open": len(x.open_items)} for x in lists]} + + path, lst = _find_list(vault, slug) + items = lst.items if show_all else lst.open_items + done = len(lst.items) - len(lst.open_items) + print(f"{lst.title} ({path}): {len(lst.open_items)} open, {done} done") + section = "" + for item in items: + if item.section != section: + section = item.section + print(f"## {section}") + print(_describe(item)) + return {"slug": lst.slug, "path": path, "title": lst.title, + "items": [_as_dict(i) for i in items]} + + +def _describe(item: Item) -> str: + """An item as a person reads it: the box, the text, who and when.""" + extra = [link.label for link in item.links if link.person] + if item.due: + extra.append(f"due {item.due}") + if item.done_on: + extra.append(f"ticked {item.done_on}") + tail = f" ({', '.join(extra)})" if extra else "" + return f"- [{'x' if item.done else ' '}] {item.text}{tail}" + + +def _as_dict(item: Item) -> dict: + return {"text": item.text, "done": item.done, "section": item.section, + "people": list(item.people), "sources": list(item.sources), + "due": item.due, "done_on": item.done_on} + + +def _all_lists(vault: Path) -> list[FamilyList]: + found = [] + for file in sorted(vault.glob(f"*/{LISTS_DIR}/*.md")): + try: + found.append(read_list(file.read_text(encoding="utf-8"), path=_rel(vault, file))) + except (ListError, OSError, UnicodeDecodeError): + continue + return found + + +def _find_list(vault: Path, slug: str) -> tuple[str, FamilyList]: + """The vault path and contents of the list `slug`, or a refusal naming the lists.""" + files = sorted(vault.glob(f"*/{LISTS_DIR}/{slug}.md")) + if not files: + known = ", ".join(x.slug for x in _all_lists(vault)) or "(none yet)" + raise Refusal(f"no list {slug!r}. Lists: {known}") + if len(files) > 1: + raise Refusal(f"{slug!r} is a list in several buckets: " + + ", ".join(_rel(vault, f) for f in files)) + path = _rel(vault, files[0]) + try: + return path, read_list(files[0].read_text(encoding="utf-8"), path=path) + except ListError as e: + raise Refusal(str(e)) from e + + +# ── Writing ────────────────────────────────────────────────────────────── + +def create(vault: Path, config, ns, actor: str) -> dict: + """Start a new, empty list in the shared bucket.""" + slug = ns.slug.strip() + if not _SLUG.match(slug): + raise Refusal(f"{slug!r} is not a slug: lowercase letters, digits and dashes") + if list(vault.glob(f"*/{LISTS_DIR}/{slug}.md")): + raise Refusal(f"the list {slug!r} already exists") + path = list_path(slug, bucket=_shared_bucket(config)) + text = new_list(ns.title.strip(), description=ns.description.strip(), + topics=ns.topic, sections=ns.section) + + def _create(prior: str) -> str: + if (prior or "").strip(): + raise ValueError(f"the list {slug!r} already exists") + return text + return _commit(config, path, _create, actor, f"started the list {ns.title.strip()}") + + +def change(vault: Path, config, ns, actor: str) -> dict: + """add, tick, untick, remove or reset on one list, in one commit.""" + path, lst = _find_list(vault, ns.slug) + people = _people(vault, config, getattr(ns, "people", [])) + section = _section(lst, getattr(ns, "section", "")) + due = getattr(ns, "due", "") + if due and not _ISO_DAY.match(due): + raise Refusal(f"--due {due!r} is not a date like 2026-10-14") + today = date.today().isoformat() + bucket = path.split("/", 1)[0] + outcome: dict = {} + + def _apply(prior: str) -> str: + current = read_list(prior, path=path) + if ns.verb == "add": + links = tuple(person_link(p.id, p.first_name, bucket=bucket) for p in people) + new, said = _add(prior, current, ns.items, section, links, due, path) + elif ns.verb == "reset": + new, said = _reset(prior, current, today, path) + else: + new, said = _each(prior, current.title, ns.verb, ns.items, today, path) + outcome["said"] = said + return new + + return _commit(config, path, _apply, actor, lambda: outcome.get("said", "changed the list")) + + +def move(vault: Path, config, slug: str, names: list[str], to: str, actor: str) -> dict: + """Move items to another list: added there first, so a failure never loses one.""" + path, lst = _find_list(vault, slug) + target_path, target = _find_list(vault, to) + # A section the other list does not have would be refused there, so the + # item goes above its first heading instead. + kept = {s.casefold() for s in target.sections} + items = [i if not target.sections or i.section.casefold() in kept else replace(i, section="") + for i in (_one(lst, name) for name in names)] + + def _add_all(prior: str) -> str: + for item in items: + if not find(read_list(prior, path=target_path), item.text): + prior = insert_item(prior, item, path=target_path) + return prior + said = f"moved {'; '.join(i.text for i in items)} from {lst.title} to {target.title}" + added = _commit(config, target_path, _add_all, actor, said, quiet=True) + if "error" in added: + return added + + def _remove_all(prior: str) -> str: + for item in items: + current = read_list(prior, path=path) + match = [i for i in current.items if i.text == item.text] + if match: + prior = remove_item(prior, match[0]) + return prior + return _commit(config, path, _remove_all, actor, said) + + +def _add(text: str, lst: FamilyList, names: list[str], section: str, + links: tuple, due: str, path: str) -> tuple[str, str]: + added, already = [], [] + for name in names: + name = " ".join(name.split()) + if any(i.text.casefold() == name.casefold() and not i.done for i in lst.items): + already.append(name) + continue + text = insert_item(text, Item(name, links=links, due=due, section=section), path=path) + lst = read_list(text, path=path) + added.append(name) + said = [f"added {'; '.join(added)}"] if added else [] + said += [f"{name!r} is already on the list" for name in already] + return text, "; ".join(said) + + +def _each(text: str, title: str, verb: str, names: list[str], today: str, + path: str) -> tuple[str, str]: + done, unchanged = [], [] + for name in names: + item = _one(read_list(text, path=path), name, verb=verb) + if verb == "remove": + text = remove_item(text, item) + elif item.done == (verb == "tick"): + unchanged.append(f"{item.text!r} is already {'ticked' if item.done else 'open'}") + continue + else: + text = set_done(text, item, done=verb == "tick", today=today, path=path) + done.append(item.text) + verb_said = {"tick": "ticked off {} on", "untick": "reopened {} on", + "remove": "removed {} from"}[verb] + said = [f"{verb_said.format('; '.join(done))} {title}"] if done else [] + return text, "; ".join(said + unchanged) + + +def _reset(text: str, lst: FamilyList, today: str, path: str) -> tuple[str, str]: + ticked = [i for i in lst.items if i.done] + for item in ticked: + text = set_done(text, item, done=False, today=today, path=path) + if not ticked: + return text, f"nothing is ticked on {lst.title}; the list is already open" + return text, f"reopened {'; '.join(i.text for i in ticked)} on {lst.title}" + + +def _one(lst: FamilyList, name: str, *, verb: str = "") -> Item: + """The single item `name` means, or a refusal that names the candidates. + + The same item can be on a list twice, ticked once and open again + ("we need yoghurt again"). Its name cannot tell the two apart, so a + tick means the open one, an untick the ticked one, and anything + else the open one. + """ + matches = find(lst, name) + if len(matches) > 1 and len({i.text.casefold() for i in matches}) == 1: + wanted_done = verb == "untick" + matches = [i for i in matches if i.done == wanted_done][:1] or matches[:1] + if not matches: + open_items = "; ".join(i.text for i in lst.open_items) or "(none)" + raise Refusal(f"no item matching {name!r} on {lst.title}. Open items: {open_items}") + if len(matches) > 1: + raise Refusal(f"{name!r} matches several items: " + f"{'; '.join(i.text for i in matches)}. Name the item more exactly.") + return matches[0] + + +def _section(lst: FamilyList, section: str) -> str: + """The list's own spelling of `section`, or a refusal naming its sections.""" + section = " ".join((section or "").split()) + if not section or not lst.sections: + return section + for known in lst.sections: + if known.casefold() == section.casefold(): + return known + raise Refusal(f"{lst.title} has no section {section!r}. Sections: {'; '.join(lst.sections)}") + + +def _people(vault: Path, config, names: list[str]) -> list: + if not names: + return [] + known = load_people(vault, _shared_bucket(config)) or [] + found = [] + for name in names: + person = resolve(name, known) + if person is None: + names_known = ", ".join(p.first_name for p in known) or "(none declared)" + raise Refusal(f"nobody called {name!r} in the family. People: {names_known}") + found.append(person) + return found + + +def _commit(config, path: str, transform, actor: str, + said: str | Callable[[], str], *, quiet: bool = False) -> dict: + """Commit `transform` of `path` as `actor` and print what happened. + + `said` may be a function, for an edit that only knows what it did once + the transform has met the current file. `quiet` commits without + printing, for the first of the two commits a move makes. + """ + def _said() -> str: + return said() if callable(said) else said + result = update_memory(config, path, transform, actor=actor, + message=lambda before, after: _commit_message(actor, path, _said())) + if "error" in result or quiet: + return result + if not result.get("committed"): + print(_said() or "no change") + return {"ok": True, "committed": False, "path": path, "summary": _said()} + lag = "" if result.get("mirrored") else "\n The wiki and the vault mount catch up shortly." + home_url = (config or {}).get("home_url", "") + url = public(go_page(path), f"{home_url}/go" if home_url else "") + link = f"\n {url}" if url else "" + print(f"{_said()} (by {actor}){link}{lag}") + return {"ok": True, "committed": True, "path": path, "by": actor, "summary": _said()} + + +def _commit_message(actor: str, path: str, said: str) -> str: + """`chore(lists): marge added Joghurt to Einkaufsliste`; a long one moves to the body.""" + line = f"chore(lists): {actor} {said}" + if len(line) <= _SUBJECT_MAX: + return line + return f"chore(lists): {actor} changed {path}\n\n{said}" + + +# ── Small things ───────────────────────────────────────────────────────── + +def _vault(config) -> Path | None: + data_dir = (config or {}).get("data_dir") + vault = vault_path_for(Path(data_dir)) if data_dir else None + return vault if vault and vault.exists() else None + + +def _shared_bucket(config) -> str: + stack_cfg = (config or {}).get("stack") or {} + return (stack_cfg.get("core") or {}).get("shared_bucket") or DEFAULT_SHARED_BUCKET + + +def _actor(value: str) -> str: + """`@homer:example.org` or `homer` as the name a commit is authored by.""" + return (value or "").strip().split(":")[0].lstrip("@") or "someone" + + +def _rel(vault: Path, file: Path) -> str: + return file.relative_to(vault).as_posix() diff --git a/stacklets/memory/cli/write.py b/stacklets/memory/cli/write.py index fcbde641..6eac4b59 100644 --- a/stacklets/memory/cli/write.py +++ b/stacklets/memory/cli/write.py @@ -64,6 +64,7 @@ from stack.links import go_page, public # noqa: E402 from stack.people import PEOPLE_DIR, PersonError, read_person # noqa: E402 from stack.list_doc import diff # noqa: E402 +from stack.lists import LISTS_DIR # noqa: E402 from stack.page_patch import apply_edits # noqa: E402 # Where the agent leaves a page it wants written. Its data dir is bind-mounted @@ -174,7 +175,7 @@ def _replace(prior: str) -> str: # 2026-09-15: rewrites drop [x] marks, and prompt rules do not # reliably repair them). Targeted edits go through --patch or # the list-edit verb, which stay allowed. - if not as_patch and repo_path.endswith("todos.md"): + if not as_patch and _is_list(repo_path): change = diff(seen["before"], seen["after"]) lost = list(change.reopened) + list(change.removed) if lost: @@ -204,7 +205,7 @@ def _replace(prior: str) -> str: return result before, after = seen.get("before", ""), seen.get("after", "") - change = diff(before, after) if repo_path.endswith("todos.md") else None + change = diff(before, after) if _is_list(repo_path) else None told = describe(before, after, repo_path) if preview: @@ -239,6 +240,11 @@ def _replace(prior: str) -> str: } +def _is_list(repo_path: str) -> bool: + """A list page: a topic's `todos.md`, or a file under `/lists/`.""" + return repo_path.endswith("todos.md") or f"/{LISTS_DIR}/" in repo_path + + def describe(before: str, after: str, repo_path: str) -> str: """One line saying what this edit did to this page. @@ -247,7 +253,7 @@ def describe(before: str, after: str, repo_path: str) -> str: general answer rather than a fabricated one: how much text went each way. Vague beats wrong in a commit subject somebody will read back later. """ - if repo_path.endswith("todos.md"): + if _is_list(repo_path): return diff(before, after).summary() plus, minus = _line_delta(before, after) if not (plus or minus): diff --git a/tests/unit/stacklets/test_memory_list_cli.py b/tests/unit/stacklets/test_memory_list_cli.py new file mode 100644 index 00000000..255990ca --- /dev/null +++ b/tests/unit/stacklets/test_memory_list_cli.py @@ -0,0 +1,254 @@ +"""`stack memory list`: the family's lists, changed one item at a time. + +The caller names an item, never a line. The command finds it, changes +that line and nothing else, and answers with what happened, because a +whole-page rewrite was measured to drop ticks (2026-09-15). Whatever +the caller cannot know is refused with the choices it has: an item +that matches several, a section the store list does not have, a person +the family never declared, a list that does not exist. + +The `update_memory` seam is substituted, because the real one talks to +Forgejo. The stand-in keeps what the command depends on: the transform +gets the current file, a `ValueError` becomes an error envelope, an +unchanged file commits nothing, and a commit reaches the vault on disk +the way the real write-through pull does. +""" + +from __future__ import annotations + +import importlib.util +import sys +from datetime import date +from pathlib import Path + +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" / "memory")) + + +def _load(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +list_cli = _load("memory_cli_list", _REPO_ROOT / "stacklets" / "memory" / "cli" / "list.py") + +TODAY = date.today().isoformat() +GROCERIES = "family/lists/einkaufsliste.md" +STORE = ["--section", "GemΓΌse & Obst", "--section", "Milchprodukte & Eier"] +PEOPLE = {"homer": "Homer Simpson", "marge": "Marge Simpson"} + + +@pytest.fixture +def store(monkeypatch, tmp_path): + """Forgejo as the command sees it: files, commits, and the vault on disk.""" + vault = tmp_path / "memory" / "vault" + for pid, name in PEOPLE.items(): + person = vault / "family" / "people" / f"{pid}.md" + person.parent.mkdir(parents=True, exist_ok=True) + person.write_text(f"---\ntype: person\ntitle: {name}\naccount: {pid}\n---\n") + + class _Store: + def __init__(self): + self.commits: list[tuple[str, str, str]] = [] + + def text(self, path: str) -> str: + file = vault / path + return file.read_text(encoding="utf-8") if file.exists() else "" + + def update_memory(self, config, repo_path, transform, *, actor, message): + before = self.text(repo_path) + try: + after = transform(before) + except ValueError as e: + return {"error": str(e)} + if after == before: + return {"ok": True, "committed": False} + (vault / repo_path).parent.mkdir(parents=True, exist_ok=True) + (vault / repo_path).write_text(after, encoding="utf-8") + self.commits.append((repo_path, actor, message(before, after))) + return {"ok": True, "committed": True, "path": repo_path, "mirrored": True} + + fake = _Store() + monkeypatch.setattr(list_cli, "update_memory", fake.update_memory) + return fake + + +@pytest.fixture +def run(store, tmp_path): + def _go(*argv): + return list_cli.run(list(argv), None, {"data_dir": str(tmp_path)}) + return _go + + +@pytest.fixture +def groceries(run): + run("create", "einkaufsliste", "--title", "Einkaufsliste", + "--description", "Was wir im Supermarkt brauchen", *STORE, "--as", "marge") + return GROCERIES + + +class TestStartingAList: + def test_a_new_list_is_a_file_in_the_lists_folder(self, run, store, groceries): + assert store.text(groceries).startswith("---\ntype: list\ntitle: Einkaufsliste\n") + assert store.commits[-1][:2] == (groceries, "marge") + + def test_show_names_every_list_with_what_is_open(self, run, groceries): + run("add", "einkaufsliste", "Γ„pfel", "--as", "marge") + result = run("show") + assert result["lists"] == [{"slug": "einkaufsliste", "title": "Einkaufsliste", + "description": "Was wir im Supermarkt brauchen", + "topics": [], "open": 1}] + + def test_a_list_that_exists_is_not_started_again(self, run, groceries): + result = run("create", "einkaufsliste", "--title", "Noch eine", "--as", "homer") + assert "already exists" in result["error"] + + def test_a_slug_is_lowercase_words_and_dashes(self, run): + assert "not a slug" in run("create", "Einkauf Liste", "--title", "x", "--as", "marge")["error"] + + def test_a_change_without_a_person_is_refused(self, run, store, groceries): + commits = len(store.commits) + result = run("add", "einkaufsliste", "Milch") + assert result["error"] == "say who adds: add --as , the family member it is for" + assert len(store.commits) == commits + + +class TestAddingItems: + def test_items_land_in_their_section_in_walking_order(self, run, store, groceries): + run("add", "einkaufsliste", "Joghurt", "--section", "milchprodukte & eier", "--as", "marge") + run("add", "einkaufsliste", "Γ„pfel", "--section", "GemΓΌse & Obst", "--as", "marge") + body = store.text(groceries).split("---\n", 2)[2] + assert body == "\n## GemΓΌse & Obst\n- [ ] Γ„pfel\n\n## Milchprodukte & Eier\n- [ ] Joghurt\n" + + def test_a_section_the_list_does_not_have_is_refused_with_its_sections(self, run, store, + groceries): + result = run("add", "einkaufsliste", "Joghurt", "--section", "Dairy", "--as", "marge") + assert result["error"] == ("Einkaufsliste has no section 'Dairy'. " + "Sections: GemΓΌse & Obst; Milchprodukte & Eier") + assert "Joghurt" not in store.text(groceries) + + def test_several_items_are_one_commit(self, run, store, groceries): + commits = len(store.commits) + result = run("add", "einkaufsliste", "Milch", "Eier", "--as", "marge") + assert result["summary"] == "added Milch; Eier" + assert len(store.commits) == commits + 1 + + def test_an_item_already_open_is_not_added_twice(self, run, store, groceries): + run("add", "einkaufsliste", "Milch", "--as", "marge") + commits = len(store.commits) + result = run("add", "einkaufsliste", "milch", "--as", "homer") + assert result["committed"] is False and "already on the list" in result["summary"] + assert len(store.commits) == commits + + def test_people_and_a_due_date_are_written_as_fields(self, run, store, groceries): + run("add", "einkaufsliste", "Kuchen fΓΌr die Schule", "--for", "Marge", "--for", "homer", + "--due", "2026-10-20", "--as", "marge") + assert ("- [ ] Kuchen fΓΌr die Schule [Marge](../people/marge.md) " + "[Homer](../people/homer.md) πŸ“… 2026-10-20") in store.text(groceries) + + def test_someone_the_family_never_declared_is_refused(self, run, groceries): + result = run("add", "einkaufsliste", "Bier", "--for", "Barney", "--as", "homer") + assert result["error"] == "nobody called 'Barney' in the family. People: Homer, Marge" + + def test_a_due_date_is_a_day(self, run, groceries): + result = run("add", "einkaufsliste", "Milch", "--due", "Friday", "--as", "marge") + assert "not a date" in result["error"] + + +class TestTickingOff: + @pytest.fixture + def stocked(self, run, groceries): + run("add", "einkaufsliste", "Hafermilch", "Milch", "Haferflocken", "--as", "marge") + return groceries + + def test_a_tick_records_the_day(self, run, store, stocked): + result = run("tick", "einkaufsliste", "Milch", "--as", "@homer:example.org") + assert f"- [x] Milch {chr(0x2705)} {TODAY}" in store.text(stocked) + assert result["summary"] == "ticked off Milch on Einkaufsliste" + assert store.commits[-1][1:] == ("homer", "chore(lists): homer ticked off Milch on Einkaufsliste") + + def test_a_reopened_item_loses_its_tick_day(self, run, store, stocked): + run("tick", "einkaufsliste", "Milch", "--as", "homer") + run("untick", "einkaufsliste", "Milch", "--as", "homer") + assert "- [ ] Milch\n" in store.text(stocked) + + def test_ticking_twice_changes_nothing_and_says_so(self, run, store, stocked): + run("tick", "einkaufsliste", "Milch", "--as", "homer") + commits = len(store.commits) + result = run("tick", "einkaufsliste", "Milch", "--as", "homer") + assert result["summary"] == "'Milch' is already ticked" and len(store.commits) == commits + + def test_a_name_that_fits_several_items_names_them_all(self, run, store, stocked): + before = store.text(stocked) + result = run("tick", "einkaufsliste", "hafer", "--as", "homer") + assert result["error"] == ("'hafer' matches several items: Hafermilch; Haferflocken. " + "Name the item more exactly.") + assert store.text(stocked) == before + + def test_a_name_that_fits_nothing_lists_what_is_open(self, run, stocked): + result = run("tick", "einkaufsliste", "Kaffee", "--as", "homer") + assert result["error"] == ("no item matching 'Kaffee' on Einkaufsliste. " + "Open items: Hafermilch; Milch; Haferflocken") + + def test_an_item_bought_and_needed_again_is_still_named_by_its_name(self, run, store, + stocked): + run("tick", "einkaufsliste", "Milch", "--as", "homer") + run("add", "einkaufsliste", "Milch", "--as", "marge") + assert run("tick", "einkaufsliste", "Milch", "--as", "homer")["committed"] is True + assert store.text(stocked).count("- [x] Milch") == 2 + assert run("untick", "einkaufsliste", "Milch", "--as", "homer")["committed"] is True + + def test_reset_reopens_everything_ticked(self, run, store, stocked): + run("tick", "einkaufsliste", "Milch", "Hafermilch", "--as", "homer") + result = run("reset", "einkaufsliste", "--as", "marge") + assert result["summary"] == "reopened Hafermilch; Milch on Einkaufsliste" + assert "- [x]" not in store.text(stocked) + + +class TestRemovingAndMoving: + def test_removing_the_last_item_of_a_section_removes_its_heading(self, run, store, groceries): + run("add", "einkaufsliste", "Γ„pfel", "--section", "GemΓΌse & Obst", "--as", "marge") + run("add", "einkaufsliste", "Joghurt", "--section", "Milchprodukte & Eier", "--as", "marge") + run("remove", "einkaufsliste", "Γ„pfel", "--as", "marge") + assert store.text(groceries).split("---\n", 2)[2] == \ + "\n## Milchprodukte & Eier\n- [ ] Joghurt\n" + + def test_a_moved_item_keeps_its_people_and_leaves_the_old_list(self, run, store, groceries, + capsys): + run("create", "markt", "--title", "Wochenmarkt", "--as", "marge") + run("add", "einkaufsliste", "Eier", "--section", "Milchprodukte & Eier", "--for", "homer", + "--as", "marge") + capsys.readouterr() + result = run("move", "einkaufsliste", "Eier", "--to", "markt", "--as", "marge") + assert "error" not in result + assert capsys.readouterr().out.splitlines()[0] == \ + "moved Eier from Einkaufsliste to Wochenmarkt (by marge)", "one move, one line" + assert "- [ ] Eier [Homer](../people/homer.md)" in store.text("family/lists/markt.md") + assert store.text(groceries).endswith(" - Milchprodukte & Eier\n---\n"), \ + "the old list is empty again, with no heading left behind" + + def test_a_list_that_does_not_exist_is_refused_with_the_lists(self, run, groceries): + result = run("move", "einkaufsliste", "Eier", "--to", "baumarkt", "--as", "marge") + assert result["error"] == "no list 'baumarkt'. Lists: einkaufsliste" + + +class TestShowingAList: + def test_items_appear_under_their_sections_with_who_and_when(self, run, groceries, capsys): + run("add", "einkaufsliste", "Joghurt", "--section", "Milchprodukte & Eier", + "--for", "marge", "--due", "2026-10-14", "--as", "marge") + capsys.readouterr() + run("show", "einkaufsliste") + assert capsys.readouterr().out.splitlines()[1:] == [ + "## Milchprodukte & Eier", + "- [ ] Joghurt (Marge, due 2026-10-14)", + ] + + def test_without_a_verb_it_prints_the_verbs(self, run, capsys): + assert run()["verbs"][0] == "show" + assert "stack memory list add" in capsys.readouterr().out diff --git a/tests/unit/stacklets/test_memory_write_cli.py b/tests/unit/stacklets/test_memory_write_cli.py index 5637dfdb..94884102 100644 --- a/tests/unit/stacklets/test_memory_write_cli.py +++ b/tests/unit/stacklets/test_memory_write_cli.py @@ -344,3 +344,10 @@ def test_a_person_file_in_the_format_is_written(store, tmp_path): " - Margaret\n---\n", path="family/people/maggie.md", tmp=tmp_path) assert result["committed"] is True + + +def test_a_list_file_is_guarded_like_a_todo_page(store, tmp_path): + """Lists under `lists/` hold ticks too, so a rewrite may not drop one.""" + result = _run(store, "# Camping\n\n- [ ] Wetter checken\n", + path="family/lists/camping.md", tmp=tmp_path) + assert "KΓΌhlbox mitbringen" in result["error"] From db4a2e087e598204fed204143e031276a3c8f4dc Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 12 Oct 2026 10:13:01 +0200 Subject: [PATCH 03/17] feat(memory): keep each topic's todos as a list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A topic's todos lived in `//todos.md`, apart from every other list and in a format of their own. They become lists: - The next `stack up memory` moves each `todos.md` to `/lists/.md` in one commit, keeping every line and tick and turning the old due date into a `πŸ“…` field. A topic that already has a list gains only the items it is missing. - The archivist puts the action items of a document or note on its topic's list, linked back to the record, and starts the list when the topic has none, with a description in the household language ("Was fΓΌr Utility noch zu tun ist"). A record's items go on the list once: a refile adds none, so what the family ticked, reworded or removed stays as they left it, and when a refile renames the record its items link to the new path. - A record's summary lists its action items without checkboxes: the list holds whether they are done, and Obsidian no longer counts the record's copy as a second open task. - Topic pages show the open items of every list that names the topic, and the agent's brief names those lists and how to read one. - `/go/topic//todo` links already posted in chat open the topic's list; `/go/list/` opens any list by name. - `lists` is no longer taken for a topic name. Refs: #194 --- docs/design/brain/vault-format.md | 12 +- lib/stack/briefing.py | 30 ++-- lib/stack/links.py | 21 ++- lib/stack/lists.py | 57 ++++++++ stacklets/agent/runtime/brief.py | 32 +++-- stacklets/core/famstack-api.py | 3 + stacklets/core/tools-server/resolver.py | 22 ++- stacklets/core/tools-server/server.py | 6 + stacklets/docs/bot/archivist.py | 6 +- stacklets/docs/bot/git_mirror.py | 136 ++++++++++-------- stacklets/memory/bot/cli/wiki.py | 22 +-- stacklets/memory/hooks/on_start_ready.py | 22 +++ stacklets/memory/lib.py | 90 ++++++++++++ tests/e2e/test_demo_rig_e2e.py | 9 +- tests/unit/framework/test_lists.py | 12 ++ .../unit/stacklets/test_agent_brief_lists.py | 38 +++++ .../unit/stacklets/test_core_link_resolver.py | 24 +++- tests/unit/stacklets/test_git_mirror.py | 68 ++++++--- .../stacklets/test_memory_todos_to_lists.py | 133 +++++++++++++++++ .../unit/stacklets/test_memory_topic_page.py | 6 +- tests/unit/stacklets/test_vault_entry.py | 24 ++-- 21 files changed, 613 insertions(+), 160 deletions(-) create mode 100644 tests/unit/stacklets/test_agent_brief_lists.py create mode 100644 tests/unit/stacklets/test_memory_todos_to_lists.py diff --git a/docs/design/brain/vault-format.md b/docs/design/brain/vault-format.md index 1b95d2dd..5e8c254b 100644 --- a/docs/design/brain/vault-format.md +++ b/docs/design/brain/vault-format.md @@ -84,7 +84,6 @@ cheap). **State documents** (the family's current truth; mutable through the CLI or by hand; never carry `generated`; see Β§7): -- `todos` β€” a topic's todo list (moves to `list`, see Β§7). - `list`: a family list, such as groceries, a store or things to do (Β§5, Β§7). - `person` (declared) β€” someone the family knows, one file each under `/people/`. @@ -332,13 +331,12 @@ used wherever an item appears, also in a record's summary. `/lists/.md` is a state document (Β§5, `list`): a tick is information, so a list is never regenerated, and a command changes only -the lines it is about. Each topic's `todos.md` below moves to a list. +the lines it is about. -`todos.md` is a state document, not a generated page: it is mutable -current truth (a tick is information), lives in memory source, and never -carries `generated`. It is a markdown checklist; frontmatter is optional -and, if present, uses `type: todos`. State documents get read-your-writes -through the CLI (ADR-011); they are not regenerated. +Before lists, each topic kept its todos in `//todos.md`. +`stack up memory` moves every one of them to `/lists/.md`, +in one commit by the memory bot, keeping each line and tick and turning +the old due date into `πŸ“…`. Nothing writes `todos.md` any more. `/people/.md` is a declared person (Β§5): the family's own facts about someone, starting with their names. On every diff --git a/lib/stack/briefing.py b/lib/stack/briefing.py index 277fab7e..a3a7c37e 100644 --- a/lib/stack/briefing.py +++ b/lib/stack/briefing.py @@ -11,6 +11,8 @@ from __future__ import annotations +from .lists import DUE, action_item + def render_briefing( *, @@ -83,28 +85,18 @@ def action_item_lines(items: list) -> list[str]: 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. + """An action item as a line of the summary: `- action πŸ“… YYYY-MM-DD`. - Returns: - Formatted checkbox line, or None when the item is empty/invalid. + The same text and due date the item gets on the topic's list + (`stack.lists.action_item`), without the checkbox: the list holds + whether it is done, and a box here would never be ticked. Obsidian + reads only checkbox lines as tasks, so this copy is not counted + twice. None for an empty or invalid item. """ - 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: + item = action_item(ai) + if item is None: 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}" + return f"- {item.text} {DUE} {item.due}" if item.due else f"- {item.text}" def read_briefing(text: str) -> tuple[str, list[str]]: diff --git a/lib/stack/links.py b/lib/stack/links.py index 170e9f3d..fbb0605d 100644 --- a/lib/stack/links.py +++ b/lib/stack/links.py @@ -38,7 +38,7 @@ # or a person, so the resolver never has to guess. They come in two # families, and which one a new kind belongs to is the whole design: # -# entities, addressed by NAME β€” `topic`, `person` +# entities, addressed by NAME β€” `topic`, `person`, `list` # A name a person could type, for something that has an identity # beyond any one file. A trailing leaf (`todo`) selects a # sub-page instead of the overview. @@ -108,6 +108,15 @@ def go_person(slug: str, leaf: str | None = None) -> str: return _entity_path("person", slug, leaf) +def go_list(slug: str) -> str: + """`/list/` β€” one of the family's lists, by its name. + + >>> go_list("einkaufsliste") + '/list/einkaufsliste' + """ + return _entity_path("list", slug, None) + + def go_page(repo_path: str) -> str: """Logical link for a vault page path, or "" when no stable route exists. @@ -116,8 +125,8 @@ def go_page(repo_path: str) -> str: resolver route get a link; anything else returns "" rather than a path-keyed link that breaks on the next rename. - >>> go_page("family/camping/todos.md") - '/topic/family/camping/todo' + >>> go_page("family/lists/einkaufsliste.md") + '/list/einkaufsliste' >>> go_page("family/camping/about.md") '/topic/family/camping' >>> go_page("homer/about.md") @@ -128,9 +137,9 @@ def go_page(repo_path: str) -> str: parts = [p for p in repo_path.strip("/").split("/") if p] if len(parts) < 2: return "" - if parts[-1] == "todos.md": - leaf = "todo" - elif parts[-1] == "about.md": + if parts[:2] == ["family", "lists"] and len(parts) == 3 and parts[2].endswith(".md"): + return go_list(parts[2].removesuffix(".md")) + if parts[-1] == "about.md": leaf = None else: return "" diff --git a/lib/stack/lists.py b/lib/stack/lists.py index a45824d1..5cfdc3db 100644 --- a/lib/stack/lists.py +++ b/lib/stack/lists.py @@ -142,6 +142,29 @@ def person_link(person_id: str, label: str, *, bucket: str) -> Link: return Link(label, person_path(person_id, bucket)) +def action_item(ai, *, source: Link | None = None) -> Item | None: + """An action item the classifier extracted, as a list item; None for an empty one. + + The classifier answers `{"action": ..., "due": ...}` or a plain + string. A due date that is not a day ("next week") stays in the text, + because `πŸ“…` takes only a day. `source` links the record it came from. + """ + if isinstance(ai, str): + action, due = ai.strip(), "" + elif isinstance(ai, dict): + action = str(ai.get("action") or "").strip() + due = str(ai.get("due") or "").strip() + else: + return None + if not action: + return None + if due.lower() in ("null", "none", "n/a"): + due = "" + if due and not re.fullmatch(_ISO_DAY, due): + action, due = f"{action} ({due})", "" + return Item(" ".join(action.split()), due=due, links=(source,) if source else ()) + + def parse_item(line: str, *, path: str) -> Item | None: """The item on a checkbox line, or None for any other line. @@ -217,6 +240,23 @@ def open_items(self) -> list[Item]: return [i for i in self.items if not i.done] +_TOPIC_LIST_DESCRIPTION = { + "de": "Was fΓΌr {title} noch zu tun ist", + "en": "What is still to do for {title}", +} + + +def topic_list_description(title: str, language: str) -> str: + """The description of a list started for a topic, in the household language. + + A list the archivist or the move from `todos.md` starts has no + description a person wrote, and a list picked by its name alone + gets items that belong elsewhere. + """ + code = (language or "").strip().lower()[:2] + return _TOPIC_LIST_DESCRIPTION.get(code, _TOPIC_LIST_DESCRIPTION["en"]).format(title=title) + + def list_path(slug: str, *, bucket: str) -> str: """The vault path of the list `slug` in `bucket`.""" return f"{bucket}/{LISTS_DIR}/{slug}.md" @@ -381,6 +421,23 @@ def edit(lines: list[str]) -> None: return _edit_body(text, edit) +def relink(text: str, old: str, new: str, *, path: str) -> str: + """The list text with every link to vault path `old` pointing at `new`. + + A record is renamed when a refile gives it a better title; the items + it put on a list keep linking to it. + """ + for item in read_list(text, path=path).items: + if old in (link.target for link in item.links): + moved = replace(item, links=tuple(replace(link, target=new) if link.target == old + else link for link in item.links)) + + def edit(lines: list[str], at: int = item.line, line: str = format_item(moved, path=path)) -> None: + lines[at] = line + text = _edit_body(text, edit) + return text + + # ── File text ──────────────────────────────────────────────────────────── def _split(text: str) -> tuple[str, str]: diff --git a/stacklets/agent/runtime/brief.py b/stacklets/agent/runtime/brief.py index 416a7e42..37d54ef7 100644 --- a/stacklets/agent/runtime/brief.py +++ b/stacklets/agent/runtime/brief.py @@ -54,11 +54,22 @@ def _lead(path: Path, maxwords: int = 32) -> str: return " ".join(words[:maxwords]) + ("..." if len(words) > maxwords else "") -def _open_todos(path: Path) -> list[str]: - if not path.exists(): - return [] - return [ln.split("]", 1)[1].strip() for ln in path.read_text(errors="ignore").splitlines() - if ln.strip().startswith("- [ ]")] +def _lists_with_open_items(vault: Path, topic: str) -> list[str]: + """Slugs of the lists that name `topic` and have an open item. + + Read off the list files (`family/lists/.md`) without the + framework, which this container does not carry: the `topics:` block + of the frontmatter and the open checkboxes are all it needs. + """ + found = [] + for path in sorted((vault / "family" / "lists").glob("*.md")): + text = path.read_text(errors="ignore") + head, _, body = text.partition("\n---\n") + topics = re.search(r"^topics:\n((?: - .*\n?)+)", head, re.M) + named = [ln.strip()[2:].strip().strip('"') for ln in topics[1].splitlines()] if topics else [] + if topic in named and re.search(r"^\s*[-*+] \[ \] ", body, re.M): + found.append(path.stem) + return found def _recent(vault: Path, topic: str, n: int = 3) -> list[str]: @@ -146,12 +157,13 @@ def brief_lines(msg, workspace) -> list[str]: # the todo list was). Consistent with the todos pointer just below. lines.append(f"This room's topic is '{topic}'; run " f"`stack memory topic {topic}` for its overview.") - if _open_todos(vault / "family" / topic / "todos.md"): + lists = _lists_with_open_items(vault, topic) + if lists: # Presence, not the data, and not even the count: a number is still - # recitable and still goes stale. Just that todos exist and how to - # fetch the live list when someone actually asks. - lines.append(f"This topic has open todos; run " - f"`stack memory topic {topic} todo` to see them.") + # recitable and still goes stale. Just which lists have open items + # and how to fetch one when someone actually asks. + lines.append(f"This topic's lists with open items: {', '.join(lists)}; run " + f"`stack memory list show ` to see one.") recent = _recent(vault, topic) if recent: lines.append("Recently: " + "; ".join(recent)) diff --git a/stacklets/core/famstack-api.py b/stacklets/core/famstack-api.py index fe231130..c239b1de 100644 --- a/stacklets/core/famstack-api.py +++ b/stacklets/core/famstack-api.py @@ -55,6 +55,9 @@ # One item on a list, by name. The store matches the item and keeps # the rest of the page intact, so a tick cannot lose other state. ["memory", "list-edit"], + # The family's lists: which exist, what is open on one, and the same + # item verbs `list_edit` uses. + ["memory", "list"], # What changed, when, and who did it. The vault is a git repo and has # always known this; nothing read it back until now. ["memory", "history"], diff --git a/stacklets/core/tools-server/resolver.py b/stacklets/core/tools-server/resolver.py index 49207caf..516f90fe 100644 --- a/stacklets/core/tools-server/resolver.py +++ b/stacklets/core/tools-server/resolver.py @@ -41,15 +41,27 @@ def resolve_topic_target(segments: list[str], *, shared_bucket: str) -> str | No A lone segment is a shared topic under `shared_bucket` (`/topic/camping` β†’ `family/camping/about`); a `/` pair is a personal topic, taken as a literal vault path (`/topic/homer/gravel` β†’ - `homer/gravel/about`). A `todo`/`todos` leaf selects the `todos` page. + `homer/gravel/about`). A `todo`/`todos` leaf selects the topic's list, + `family/lists/camping`. Deeper than two scope segments is not a valid topic path β€” None, so the HTTP layer 404s rather than pointing at a page that 404s anyway. """ scope, page = _split_leaf(segments) if not scope or len(scope) > 2: return None - bucket_path = f"{shared_bucket}/{scope[0]}" if len(scope) == 1 else "/".join(scope) - return f"{bucket_path}/{page}" + bucket = shared_bucket if len(scope) == 1 else scope[0] + if page == "todos": + # A topic's todos moved to the list named after it; links posted to + # chat before the move still open it. + return f"{bucket}/lists/{scope[-1]}" + return f"{bucket}/{scope[-1]}/{page}" + + +def resolve_list_target(segments: list[str], *, shared_bucket: str) -> str | None: + """Map `/list/` to the family's list of that name (`family/lists/`).""" + if len(segments) != 1 or not segments[0]: + return None + return f"{shared_bucket}/lists/{segments[0]}" def resolve_person_target(segments: list[str]) -> str | None: @@ -71,7 +83,7 @@ def build_redirect( ) -> str | None: """Full redirect URL for a `///` request, or None. - `kind` is `docs`, `topic`, `person`, or `capture`; `rest` is the remaining + `kind` is `docs`, `topic`, `person`, `list`, or `capture`; `rest` is the remaining path segments. `docs_base`/`wiki_base` are the public base URLs of Paperless and the wiki β€” already mode-correct, computed once by the HTTP layer from env, so this stays a pure string join. None (β†’ 404) for an unknown kind, a @@ -95,6 +107,8 @@ def build_redirect( target = resolve_topic_target(rest, shared_bucket=shared_bucket) elif kind == "person": target = resolve_person_target(rest) + elif kind == "list": + target = resolve_list_target(rest, shared_bucket=shared_bucket) else: return None if target is None: diff --git a/stacklets/core/tools-server/server.py b/stacklets/core/tools-server/server.py index 3a9731bb..ab382521 100644 --- a/stacklets/core/tools-server/server.py +++ b/stacklets/core/tools-server/server.py @@ -189,6 +189,12 @@ async def go_person(name: str): return _go("person", [s for s in name.split("/") if s]) +@app.get(f"/{LINK_PREFIX}/list/{{slug}}", summary="Resolve a list link") +async def go_list(slug: str): + """Redirect a stable list link to the list's wiki page.""" + return _go("list", [slug]) + + @app.get(f"/{LINK_PREFIX}/capture/{{capture_id:path}}", summary="Resolve a capture link") async def go_capture(capture_id: str): diff --git a/stacklets/docs/bot/archivist.py b/stacklets/docs/bot/archivist.py index e4ba8ea3..d9624fac 100644 --- a/stacklets/docs/bot/archivist.py +++ b/stacklets/docs/bot/archivist.py @@ -742,8 +742,8 @@ def _reserved_topic_slugs(self) -> set[str]: slug only has to avoid collisions with within-bucket reserved directories: the capture-type folders the mirror writes (`notes`, `bookmarks`, `documents`), the correspondent index - used in the shared bucket, the `_unfiled` rescue folder, and - the derived `about` page. + used in the shared bucket, the family's `lists`, the `_unfiled` + rescue folder, and the derived `about` page. Top-level vault names (`family`, `homer`, `marge`, `meta`, `wiki`, `archive`) no longer need to appear here -- the new @@ -753,7 +753,7 @@ def _reserved_topic_slugs(self) -> set[str]: return { "notes", "bookmarks", "documents", - "correspondents", "_unfiled", "about", + "correspondents", "lists", "_unfiled", "about", } async def _read_topic_state(self, room_id: str) -> dict | None: diff --git a/stacklets/docs/bot/git_mirror.py b/stacklets/docs/bot/git_mirror.py index 6a3b2775..87aca485 100644 --- a/stacklets/docs/bot/git_mirror.py +++ b/stacklets/docs/bot/git_mirror.py @@ -46,7 +46,6 @@ import json import os import secrets -import sys from dataclasses import dataclass, field from pathlib import Path @@ -54,9 +53,6 @@ from stack.forgejo import ForgejoClient, ForgejoError -sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "memory" / "bot" / "cli")) -from todo_list import update_todo_doc # noqa: E402 - from vault_entry import ( slug, capture_hash, @@ -70,7 +66,18 @@ render_email_message_section, fold_email_message, ) -from stack.briefing import format_action_item # noqa: E402 +from stack.lists import ( # noqa: E402 + LISTS_DIR, + Link, + ListError, + action_item, + insert_item, + list_path, + new_list, + read_list, + relink, + topic_list_description, +) # The shared family knowledge vault. The memory stacklet creates and @@ -499,66 +506,76 @@ def _commit_message( lines.append(f"Model: {model}") return "\n".join(lines) - 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) - if not line: - continue - texts.append(line.removeprefix("- [ ] ").strip()) - return texts - - def _todo_doc_path_for_topic(self, topic: str) -> tuple[str, str] | None: - topic = (topic or "").strip() - if not topic: - return None - topic_slug = self._slug(topic) - title = topic.replace("-", " ").title() - return f"{self.shared_bucket}/{topic_slug}/todos.md", title - - def _todo_doc_path_for_capture(self, target_path: str) -> tuple[str, str] | None: + def _topic_of_capture(self, target_path: str) -> str: + """The topic slug a capture was filed under, or "" outside a shared topic.""" parts = target_path.split("/") if len(parts) < 3 or parts[0] != self.shared_bucket: - return None + return "" topic_slug = parts[1] - if topic_slug in {"documents", "notes", "bookmarks", "emails", "_unfiled"}: - return None - return f"{self.shared_bucket}/{topic_slug}/todos.md", topic_slug.replace("-", " ").title() + if topic_slug in {"documents", "notes", "bookmarks", "emails", "_unfiled", LISTS_DIR}: + return "" + return topic_slug - async def _fold_action_items_into_todos( + async def _fold_action_items_into_list( self, client: ForgejoClient, *, - target_path: str, - title: str, + topic: str, + record_path: str, + record_title: str, action_items: list | None, message: str, author_name: str, author_email: str, + previous_path: str = "", ) -> bool: - """Merge extracted action items into a source `todos.md` document.""" - items = self._action_item_texts(action_items) - if not items: + """Put a record's action items on its topic's list, `lists/.md`. + + The list is started when the topic has none. Each item links the + record it came from. A record's items go on the list once: when + the list already has items from it, a refile adds none, because + the model words them differently each time and the family may + have ticked, reworded or removed them since. When a refile renamed + the record (`previous_path`), those items are linked to its new + path instead. An item whose text is already on the list is left + alone too. + """ + source = Link(record_title, record_path) if record_path else None + items = [i for ai in action_items or [] if (i := action_item(ai, source=source))] + if not (topic and items): return False + path = list_path(topic, bucket=self.shared_bucket) try: existing = await asyncio.to_thread( - client.get_file, self.repo_owner, REPO_NAME, target_path, + client.get_file, self.repo_owner, REPO_NAME, path, ) - prior = (existing.get("content") if existing else None) or None - content = update_todo_doc(prior, title, items) + prior = (existing or {}).get("content") or "" + title = topic.replace("-", " ").title() + content = prior or new_list( + title, topics=[topic], + description=topic_list_description(title, os.environ.get("LANGUAGE", ""))) + if previous_path and previous_path != record_path: + content = relink(content, previous_path, record_path, path=path) + already = {s for i in read_list(content, path=path).items for s in i.sources} + for item in items if record_path not in already else []: + on_list = read_list(content, path=path).items + if not any(i.text.casefold() == item.text.casefold() for i in on_list): + content = insert_item(content, item, path=path) if content == prior: return False await asyncio.to_thread( client.put_file, - self.repo_owner, REPO_NAME, target_path, + self.repo_owner, REPO_NAME, path, content=content, message=message, sha=existing["sha"] if existing else None, author_name=author_name, author_email=author_email, ) return True + except ListError as e: + logger.warning("[git-mirror] {} is not a list, action items not added: {}", path, e) + return False except ForgejoError as e: - logger.warning("[git-mirror] Todo fold failed for {}: {}", target_path, e) + logger.warning("[git-mirror] Action items not added to {}: {}", path, e) return False async def _fold_document_todos( @@ -566,23 +583,24 @@ async def _fold_document_todos( client: ForgejoClient, *, classification: dict, + record_path: str, + record_title: str, + previous_path: str = "", ) -> None: topics = classification.get("topics") or classification.get("topic") or [] if isinstance(topics, str): topics = [topics] for topic in topics: - if not isinstance(topic, str): - continue - target = self._todo_doc_path_for_topic(topic) - if target is None: + if not isinstance(topic, str) or not topic.strip(): continue - path, title = target - await self._fold_action_items_into_todos( + await self._fold_action_items_into_list( client, - target_path=path, - title=title, + topic=self._slug(topic), + record_path=record_path, + record_title=record_title, + previous_path=previous_path, action_items=classification.get("action_items") or [], - message=f"chore(todos): archivist added action items to {self._slug(topic)}", + message=f"chore(lists): archivist added action items to {self._slug(topic)}", author_name=BOT_USERNAME, author_email=BOT_EMAIL, ) @@ -595,17 +613,18 @@ async def _fold_capture_todos( classification: dict, author_name: str, author_email: str, + previous_path: str = "", ) -> None: - target = self._todo_doc_path_for_capture(target_path) - if target is None: - return - path, title = target - await self._fold_action_items_into_todos( + topic = self._topic_of_capture(target_path) + await self._fold_action_items_into_list( client, - target_path=path, - title=title, + topic=topic, + record_path=target_path, + previous_path=previous_path, + record_title=str(classification.get("title") or "").strip() + or Path(target_path).stem, action_items=classification.get("action_items") or [], - message=f"chore(todos): {author_name} added action items to {title.lower()}", + message=f"chore(lists): {author_name} added action items to {topic}", author_name=author_name, author_email=author_email, ) @@ -749,7 +768,9 @@ async def publish( self._cache[paperless_id] = target_path self._save_cache() - await self._fold_document_todos(client, classification=classification) + await self._fold_document_todos(client, classification=classification, + record_path=target_path, record_title=title, + previous_path=existing_path or "") logger.info("[git-mirror] {} #{} β†’ {}", verb, paperless_id, target_path) return True @@ -1028,6 +1049,7 @@ async def publish_capture( classification=classification, author_name=author_name, author_email=author_email, + previous_path=existing_path or "", ) return target_path diff --git a/stacklets/memory/bot/cli/wiki.py b/stacklets/memory/bot/cli/wiki.py index 339de613..0054c346 100644 --- a/stacklets/memory/bot/cli/wiki.py +++ b/stacklets/memory/bot/cli/wiki.py @@ -92,7 +92,7 @@ # 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 todo_list import read_todos # noqa: E402 +from stack.lists import LISTS_DIR, ListError, read_list # noqa: E402 HELP = "Regenerate the family wiki's home and member pages" @@ -130,7 +130,7 @@ # folder), not a topic. The topic-discovery walker skips these names. _RESERVED_BUCKET_SUBDIRS = { "notes", "bookmarks", "documents", - "correspondents", "people", "_unfiled", + "correspondents", "people", "_unfiled", "lists", } # Subdir names a topic folder must contain at least one of to be @@ -698,12 +698,18 @@ def _family_names(people: dict[str, Person]) -> list[str]: def _open_todos(page_dir: str) -> list[str]: - """The open items of a topic's `todos.md`, as the family keeps them.""" - try: - doc = (_brain_dir() / page_dir / "todos.md").read_text(encoding="utf-8") - except (OSError, RuntimeError): - return [] - return read_todos(doc)[0] + """The open items of the lists that name the topic at `page_dir`, in list order.""" + bucket, _, topic = page_dir.rpartition("/") + items: list[str] = [] + 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) + except (OSError, ListError): + continue + if topic in lst.topics: + items += [i.text for i in lst.open_items] + return items def _member_preamble(slug: str, display: str, synonyms: list[str]) -> str: diff --git a/stacklets/memory/hooks/on_start_ready.py b/stacklets/memory/hooks/on_start_ready.py index b9d5b7f3..29a290c2 100644 --- a/stacklets/memory/hooks/on_start_ready.py +++ b/stacklets/memory/hooks/on_start_ready.py @@ -27,6 +27,7 @@ ensure_brain_projection_admin, ensure_people, ensure_vault_cloned, + move_todos_to_lists, host_code_url, point_remote_at, pull_vault, @@ -62,6 +63,26 @@ def _ensure_people(ctx, code_url: str, token: str) -> None: ctx.step(f"Memory: added {', '.join(added)} to {bucket}/people/") +def _move_todos_to_lists(ctx, code_url: str, token: str) -> None: + """Move each topic's old `todos.md` to `lists/.md`, once. + + Like the person files, a failure is reported and never blocks + startup; the old files stay where they were until the next start. + """ + if not (code_url and token): + return + bucket = ctx.env.get("SHARED_BUCKET") or DEFAULT_SHARED_BUCKET + try: + moved = move_todos_to_lists( + ForgejoClient(url=code_url, token=token), shared_bucket=bucket, + language=ctx.env.get("LANGUAGE", "")) + except (ForgejoError, OSError, ValueError) as e: + ctx.warn(f"Memory: could not move the todo lists to {bucket}/lists/: {e}") + return + if moved: + ctx.step(f"Memory: moved the todo lists of {', '.join(moved)} to {bucket}/lists/") + + def run(ctx): vault = vault_path_for(ctx.stack.data) brain = brain_path_for(ctx.stack.data) @@ -115,6 +136,7 @@ def remote_for(tok: str) -> str: ctx.step(f"{reason} and it could not be replaced") _ensure_people(ctx, code_url, token) + _move_todos_to_lists(ctx, code_url, token) # If the vault never got cloned (install hook ran before code # stacklet was reachable, for example), try once more here. This diff --git a/stacklets/memory/lib.py b/stacklets/memory/lib.py index 6fa38155..f29d748d 100644 --- a/stacklets/memory/lib.py +++ b/stacklets/memory/lib.py @@ -2394,3 +2394,93 @@ def install_memory_to_forgejo_admin( "write_token": admin_token, "cloned_brain": cloned_brain, } + + +# ─── Topic todo lists move to lists/ ───────────────────────────────────── +# +# Until lists became one kind of file, each topic kept its todos in +# `//todos.md`: a title heading and checkbox lines, with a +# due date written after a dash. They now live with every other list in +# `/lists/.md` (`stack.lists`). The move runs on every +# `stack up memory` and finds nothing once it is done. + +# The old due date followed the item after a long dash (U+2014). +_OLD_DUE = re.compile(r"^(.*?)\s+\u2014\s+(\d{4}-\d{2}-\d{2})$") +_TITLE_LINE = re.compile(r"^#\s+(.+?)\s*$") + + +def todos_as_list(text: str, topic: str, *, shared_bucket: str = DEFAULT_SHARED_BUCKET, + language: str = "") -> str: + """A topic's old `todos.md` as a list file, every tick and line kept. + + The title heading becomes the list's title, the due dates become + `πŸ“…` fields, and every other line stays as the family wrote it. + """ + from dataclasses import replace + + from stack.lists import format_item, list_path, new_list, parse_item, topic_list_description + + path = list_path(topic, bucket=shared_bucket) + body = text.split("\n---\n", 1)[1] if text.startswith("---\n") and "\n---\n" in text else text + title, titled = topic.replace("-", " ").title(), False + lines: list[str] = [] + for line in body.splitlines(): + if not (titled or lines) and (heading := _TITLE_LINE.match(line)): + title, titled = heading[1], True + continue + item = parse_item(line, path=path) + if item and not item.due and (old := _OLD_DUE.match(item.text)): + line = format_item(replace(item, text=old[1], due=old[2]), path=path) + if line.strip() or lines: + lines.append(line) + head = new_list(title, topics=[topic], + description=topic_list_description(title, language)) + rest = "\n".join(lines).strip("\n") + return f"{head}\n{rest}\n" if rest else head + + +def move_todos_to_lists(client: ForgejoClient, *, + shared_bucket: str = DEFAULT_SHARED_BUCKET, + language: str = "") -> list[str]: + """Move every topic's `todos.md` to `lists/.md`; return the topics moved. + + One commit for all of them, by the memory bot, so the history reads + as one step. A topic that already has a list keeps it, and the old + file's items that are not on it yet are added. + """ + from stack.forgejo import FileChange + from stack.lists import LISTS_DIR, insert_item, list_path, read_list + + changes: list[FileChange] = [] + moved: list[str] = [] + for entry in client.list_dir(REPO_OWNER, REPO_NAME, shared_bucket): + topic = Path(str(entry.get("path", ""))).name + if entry.get("type") != "dir" or topic in (LISTS_DIR, "people"): + continue + old_path = f"{shared_bucket}/{topic}/todos.md" + old = client.get_file(REPO_OWNER, REPO_NAME, old_path) + if not old: + continue + new_path = list_path(topic, bucket=shared_bucket) + converted = todos_as_list(old.get("content") or "", topic, shared_bucket=shared_bucket, + language=language) + current = client.get_file(REPO_OWNER, REPO_NAME, new_path) + if current: + content = current.get("content") or "" + for item in read_list(converted, path=new_path).items: + if not any(i.text.casefold() == item.text.casefold() + for i in read_list(content, path=new_path).items): + content = insert_item(content, item, path=new_path) + changes.append(FileChange("update", new_path, content, sha=current.get("sha"))) + else: + changes.append(FileChange("create", new_path, converted)) + changes.append(FileChange("delete", old_path, sha=old.get("sha"))) + moved.append(topic) + if changes: + client.change_files( + REPO_OWNER, REPO_NAME, changes, + message="lists: move each topic's todo list to lists/", + author_name=BOT_USERNAME, author_email=BOT_EMAIL, + ) + return moved + diff --git a/tests/e2e/test_demo_rig_e2e.py b/tests/e2e/test_demo_rig_e2e.py index 9ea7dfbc..c9a913a8 100644 --- a/tests/e2e/test_demo_rig_e2e.py +++ b/tests/e2e/test_demo_rig_e2e.py @@ -389,11 +389,12 @@ async def test_demo_rig_memory_todos_stay_mutable_and_capture_items_project( # nothing unless the writer is plainly keeping a list, and a note that # read like a test ticket was filed as information about half the time. # The model words the item itself, so the check is on the one phrase it - # keeps; todos.md is under this run's own topic, which makes it unique. + # keeps; the topic's list is named after this run's own topic, which + # makes it unique. captured_item = "pick up the dry cleaning" captured_phrase = "dry cleaning" token = f"demo-rig-capture-todo-{scope.uid}" - path = f"{MEMORY_OWNER}/{topic}/todos.md" + path = f"{MEMORY_OWNER}/lists/{topic}.md" about_path = f"{MEMORY_OWNER}/{topic}/about.md" bot_mxid = f"@archivist-bot:{server_name}" marge_creds = demo_matrix["marge"] @@ -501,7 +502,7 @@ async def test_demo_rig_memory_todos_stay_mutable_and_capture_items_project( ), ) - bdd.then("The capture action item is folded into memory todos.md") + bdd.then("The capture action item lands on the topic's list") memory_todos = await _wait_for_repo_file_containing( demo_code, MEMORY_REPO, path, captured_phrase, timeout=120, ) @@ -509,7 +510,7 @@ async def test_demo_rig_memory_todos_stay_mutable_and_capture_items_project( # `stack memory sync` triggers the curator to mirror source AND # rebuild the wiki now, bypassing its 180s batching debounce. - # One call makes both the source (todos.md) and the generated + # One call makes both the source (the list) and the generated # page (about.md) current in LLM-time β€” the rig's fast path. bdd.and_("An explicit sync makes source and wiki current now") synced = _run_stack("memory", "sync") diff --git a/tests/unit/framework/test_lists.py b/tests/unit/framework/test_lists.py index 3e8f14e7..d04a07f4 100644 --- a/tests/unit/framework/test_lists.py +++ b/tests/unit/framework/test_lists.py @@ -21,6 +21,7 @@ from stack.lists import ( Item, + Link, ListError, format_item, insert_item, @@ -29,6 +30,7 @@ parse_item, person_link, read_list, + relink, ) GROCERIES = list_path("einkaufsliste", bucket="family") @@ -187,3 +189,13 @@ def test_link_targets_are_vault_paths(): """A link outside the vault is not a field: it could never be resolved.""" item = parse_item("- [ ] Milch [x](../../../etc/passwd.md)", path=GROCERIES) assert item.links == () + + +def test_a_renamed_record_keeps_its_items_linked(): + old, new = "family/documents/2026/10/a-p1.md", "family/documents/2026/10/b-p1.md" + text = insert_item(_groceries(), Item("Pay", links=(Link("Bill", old),), due="2026-10-30"), + path=GROCERIES) + moved = relink(text, old, new, path=GROCERIES) + assert read_list(moved, path=GROCERIES).items[0].sources == (new,) + assert "πŸ“… 2026-10-30" in moved + diff --git a/tests/unit/stacklets/test_agent_brief_lists.py b/tests/unit/stacklets/test_agent_brief_lists.py new file mode 100644 index 00000000..d2aebc9b --- /dev/null +++ b/tests/unit/stacklets/test_agent_brief_lists.py @@ -0,0 +1,38 @@ +"""The agent's brief names the topic's lists that have something open. + +A pointer, not the items: the brief goes stale within the turn, so it +says which lists to fetch and how, and the agent reads the live list +when someone asks. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from types import SimpleNamespace + +_REPO_ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "agent" / "runtime")) + +from brief import brief_lines # noqa: E402 + +PACKING = "---\ntype: list\ntitle: Packliste\ntopics:\n - camping\n---\n\n- [ ] Zelt\n" +DONE = "---\ntype: list\ntitle: Erledigt\ntopics:\n - camping\n---\n\n- [x] Gas\n" +GROCERIES = "---\ntype: list\ntitle: Einkauf\n---\n\n- [ ] Milch\n" + + +def _brief(tmp_path) -> list[str]: + vault = tmp_path / "vault" + (vault / "family" / "camping").mkdir(parents=True) + (vault / "family" / "camping" / "about.md").write_text("# Camping\n") + lists = vault / "family" / "lists" + lists.mkdir() + for slug, text in (("packliste", PACKING), ("erledigt", DONE), ("einkauf", GROCERIES)): + (lists / f"{slug}.md").write_text(text) + msg = SimpleNamespace(metadata={"room": "Camping"}, sender_id="") + return brief_lines(msg, tmp_path) + + +def test_names_only_the_topics_lists_with_open_items(tmp_path): + assert ("This topic's lists with open items: packliste; run " + "`stack memory list show ` to see one.") in _brief(tmp_path) diff --git a/tests/unit/stacklets/test_core_link_resolver.py b/tests/unit/stacklets/test_core_link_resolver.py index a70c8406..99be7e22 100644 --- a/tests/unit/stacklets/test_core_link_resolver.py +++ b/tests/unit/stacklets/test_core_link_resolver.py @@ -42,16 +42,16 @@ def test_personal_topic(self): assert _topic("homer", "gravel") == "homer/gravel/about" def test_topic_todos(self): - assert _topic("camping", "todo") == "family/camping/todos" + assert _topic("camping", "todo") == "family/lists/camping" def test_topic_todos_plural(self): - assert _topic("camping", "todos") == "family/camping/todos" + assert _topic("camping", "todos") == "family/lists/camping" def test_personal_topic_todos(self): - assert _topic("homer", "gravel", "todos") == "homer/gravel/todos" + assert _topic("homer", "gravel", "todos") == "homer/lists/gravel" def test_todo_leaf_is_case_insensitive(self): - assert _topic("camping", "TODO") == "family/camping/todos" + assert _topic("camping", "TODO") == "family/lists/camping" def test_empty(self): assert _topic() is None @@ -113,7 +113,7 @@ def test_topic_shared(self): assert _b("topic", "camping") == "https://wiki.home.tld/family/camping/about" def test_topic_todos(self): - assert _b("topic", "camping", "todo") == "https://wiki.home.tld/family/camping/todos" + assert _b("topic", "camping", "todo") == "https://wiki.home.tld/family/lists/camping" def test_person_todos(self): assert _b("person", "homer", "todo") == "https://wiki.home.tld/homer/todos" @@ -127,7 +127,7 @@ def test_port_mode_base_no_trailing_slash(self): assert build_redirect( "topic", ["camping", "todo"], docs_base="http://10.0.0.5:42000", wiki_base="http://10.0.0.5:42070", shared_bucket=_SHARED, - ) == "http://10.0.0.5:42070/family/camping/todos" + ) == "http://10.0.0.5:42070/family/lists/camping" # ── Round trip: what emitters build lands where it used to ────────────── @@ -178,7 +178,7 @@ def test_topic_todo_link_is_unchanged(self): # f"{base}/topic/{scope}/todo" β€” same path, same destination. assert go_topic("family/camping", "todo") == "/topic/family/camping/todo" assert _click(go_topic("family/camping", "todo")) == \ - f"{_WIKI_BASE}/family/camping/todos" + f"{_WIKI_BASE}/family/lists/camping" def test_person_link_resolves_to_the_member_page(self): assert _click(go_person("homer")) == f"{_WIKI_BASE}/homer/about" @@ -188,3 +188,13 @@ def test_public_form_is_what_goes_into_the_message(self): # the emitted link is base + logical path, no separator surprises. assert public(go_docs(247), "https://home.example.org/go") == \ "https://home.example.org/go/docs/247" + + +class TestListResolution: + def test_a_list_opens_by_its_name(self): + assert build_redirect("list", ["einkaufsliste"], docs_base="", wiki_base="https://wiki.home.tld", + shared_bucket="family") == "https://wiki.home.tld/family/lists/einkaufsliste" + + def test_a_list_link_has_one_name(self): + assert build_redirect("list", ["a", "b"], docs_base="", wiki_base="https://wiki.home.tld", + shared_bucket="family") is None diff --git a/tests/unit/stacklets/test_git_mirror.py b/tests/unit/stacklets/test_git_mirror.py index c85603e5..3d2c4a64 100644 --- a/tests/unit/stacklets/test_git_mirror.py +++ b/tests/unit/stacklets/test_git_mirror.py @@ -324,8 +324,8 @@ def test_full_briefing_in_rendered_output(self, mirror): assert "> - Total: EUR 340.00" in out assert "> - Policy: KH-2026-987" in out assert "> **Action items**" in out - assert "> - [ ] Confirm SEPA balance β€” 2026-03-14" in out - assert "> - [ ] File for tax" in out + assert "> - Confirm SEPA balance πŸ“… 2026-03-14" in out + assert "> - File for tax" in out def test_briefing_appears_before_body(self, mirror): out = mirror._render( @@ -368,7 +368,7 @@ def test_string_action_item_renders_as_task(self, mirror): summary=None, facts=None, action_items=["Just a string item"], ) - assert "> - [ ] Just a string item" in out + assert "> - Just a string item" in out def test_null_string_due_is_treated_as_no_due(self, mirror): # LLMs sometimes return the string "null" instead of true null. @@ -380,7 +380,7 @@ def test_null_string_due_is_treated_as_no_due(self, mirror): action_items=[{"action": "Pay bill", "due": "null"}], ) # No trailing "β€” null" - assert "> - [ ] Pay bill" in out + assert "> - Pay bill" in out assert "null" not in out.split("**Action items**")[1] def test_skips_blank_facts_and_empty_actions(self, mirror): @@ -394,7 +394,7 @@ def test_skips_blank_facts_and_empty_actions(self, mirror): ) # Only the real fact + real action survive. assert "> - Real fact" in out - assert "> - [ ] Real action" in out + assert "> - Real action" in out # No empty bullets / checkboxes. assert "- \n" not in out assert "- [ ] \n" not in out @@ -451,11 +451,11 @@ def put_file( }) -class TestTodoStateDocumentFold: - """Action items are folded into source `todos.md` by the filing writer.""" +class TestActionItemsOnTheTopicsList: + """A record's action items land on its topic's list, linked back to the record.""" @pytest.mark.asyncio - async def test_document_topics_receive_action_items(self, mirror): + async def test_a_document_starts_the_topics_list(self, mirror): client = _FakeTodoClient() await mirror._fold_document_todos( client, @@ -466,39 +466,65 @@ async def test_document_topics_receive_action_items(self, mirror): "pack stove", ], }, + record_path="family/documents/2026/03/2026-03-02-campsite-p12.md", + record_title="Campsite booking", ) - written = client.files["family/camping/todos.md"]["content"] - assert "# Camping" in written - assert "- [ ] book pitch" in written - assert "- [ ] pack stove" in written + written = client.files["family/lists/camping.md"]["content"] + assert written.startswith("---\ntype: list\ntitle: Camping\n" + "description: What is still to do for Camping\n" + "topics:\n - camping\n") + assert ("- [ ] book pitch [Campsite booking]" + "(../documents/2026/03/2026-03-02-campsite-p12.md) πŸ“… 2026-04-01") in written + assert "- [ ] pack stove [Campsite booking]" in written assert client.puts[0]["author_name"] == BOT_USERNAME @pytest.mark.asyncio - async def test_capture_in_shared_topic_updates_that_topic_todos(self, mirror): + async def test_a_ticked_item_is_never_reopened_by_a_refiling(self, mirror): client = _FakeTodoClient({ - "family/camping/todos.md": { - "content": "# Camping\n\n- [x] book pitch\n", + "family/lists/camping.md": { + "content": "---\ntype: list\ntitle: Camping\n---\n\n- [x] book pitch βœ… 2026-03-03\n", "sha": "old-sha", }, }) await mirror._fold_capture_todos( client, target_path="family/camping/notes/2026/07/packing-a1b2c3.md", - classification={"action_items": ["pack stove", "book pitch"]}, + classification={"title": "Packing", "action_items": ["pack stove", "Book pitch"]}, author_name="homer", author_email="homer@simpson", ) - written = client.files["family/camping/todos.md"]["content"] - assert "- [x] book pitch" in written - assert "- [ ] pack stove" in written - assert written.count("book pitch") == 1 + written = client.files["family/lists/camping.md"]["content"] + assert "- [x] book pitch βœ… 2026-03-03" in written + assert "- [ ] pack stove [Packing](../camping/notes/2026/07/packing-a1b2c3.md)" in written + assert written.lower().count("book pitch") == 1 assert client.puts[0]["sha"] == "old-sha" assert client.puts[0]["author_name"] == "homer" @pytest.mark.asyncio - async def test_personal_capture_bucket_does_not_create_personal_todos(self, mirror): + async def test_a_refiled_record_adds_nothing_and_its_items_follow_a_rename(self, mirror): + """The model words a refile's items differently; the list keeps the family's.""" + old = "family/documents/2026/10/2026-10-09-permission-slip-p72.md" + new = "family/documents/2026/10/2026-10-09-school-notice-p72.md" + client = _FakeTodoClient() + await mirror._fold_document_todos( + client, classification={"topics": ["Education"], + "action_items": [{"action": "Sign the slip", "due": "2026-10-23"}]}, + record_path=old, record_title="Permission slip") + await mirror._fold_document_todos( + client, classification={"topics": ["Education"], + "action_items": [{"action": "Sign and return the slip", + "due": "2026-10-23"}]}, + record_path=new, record_title="School notice", previous_path=old) + + written = client.files["family/lists/education.md"]["content"] + assert "Sign and return" not in written + assert ("- [ ] Sign the slip [Permission slip]" + "(../documents/2026/10/2026-10-09-school-notice-p72.md) πŸ“… 2026-10-23") in written + + @pytest.mark.asyncio + async def test_a_personal_capture_adds_to_no_list(self, mirror): client = _FakeTodoClient() await mirror._fold_capture_todos( client, diff --git a/tests/unit/stacklets/test_memory_todos_to_lists.py b/tests/unit/stacklets/test_memory_todos_to_lists.py new file mode 100644 index 00000000..8d2be93d --- /dev/null +++ b/tests/unit/stacklets/test_memory_todos_to_lists.py @@ -0,0 +1,133 @@ +"""Each topic's old `todos.md` moves to `lists/.md` on `stack up memory`. + +Families have been ticking items in these files for months, so the +move is held to what a family would check afterwards: every item is +there, every tick is kept, the due dates still say the same day, and +a second start finds nothing left to move. Forgejo is a real HTTP +server here (`pytest-httpserver`), answering the calls the move makes. +""" + +from __future__ import annotations + +import base64 +import json +import sys +from pathlib import Path + +_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" / "memory")) + +from lib import REPO_NAME, REPO_OWNER, move_todos_to_lists, todos_as_list # noqa: E402 +from stack.forgejo import ForgejoClient # noqa: E402 +from stack.lists import read_list # noqa: E402 + +CONTENTS = f"/api/v1/repos/{REPO_OWNER}/{REPO_NAME}/contents" +DASH = "β€”" + +CAMPING_TODOS = ( + "# Itchy Scratchy Land\n\n" + "- [ ] Book the hotel before the prices jump\n" + f"- [ ] Pay the deposit {DASH} 2026-10-30\n" + "- [x] Pack sunscreen for Bart\n" +) + + +def _file(text: str, sha: str) -> dict: + return {"type": "file", "content": base64.b64encode(text.encode()).decode(), "sha": sha} + + +def _commit(httpserver) -> dict: + post = next(r for r, _ in httpserver.log if r.method == "POST") + return json.loads(post.data) + + +def _files(commit: dict) -> dict[str, tuple[str, str]]: + """Path to (operation, content) for every file in the commit.""" + return {f["path"]: (f["operation"], + base64.b64decode(f["content"]).decode() if f.get("content") else "") + for f in commit["files"]} + + +def _client(httpserver) -> ForgejoClient: + return ForgejoClient(url=httpserver.url_for(""), token="bottoken") + + +class TestAnOldTodoList: + def test_keeps_its_title_its_items_and_its_ticks(self): + lst = read_list(todos_as_list(CAMPING_TODOS, "itchy-scratchy-land"), + path="family/lists/itchy-scratchy-land.md") + assert lst.title == "Itchy Scratchy Land" and lst.topics == ["itchy-scratchy-land"] + assert [(i.text, i.done, i.due) for i in lst.items] == [ + ("Book the hotel before the prices jump", False, ""), + ("Pay the deposit", False, "2026-10-30"), + ("Pack sunscreen for Bart", True, ""), + ] + + def test_writes_its_due_dates_as_list_fields(self): + assert "- [ ] Pay the deposit πŸ“… 2026-10-30\n" in todos_as_list(CAMPING_TODOS, "camping") + + def test_says_what_it_is_for_in_the_household_language(self): + lst = read_list(todos_as_list(CAMPING_TODOS, "camping", language="de"), + path="family/lists/camping.md") + assert lst.description == "Was fΓΌr Itchy Scratchy Land noch zu tun ist" + + def test_without_a_title_heading_takes_the_topics_name(self): + lst = read_list(todos_as_list("- [ ] Ask the vet\n", "pet"), path="family/lists/pet.md") + assert lst.title == "Pet" and [i.text for i in lst.items] == ["Ask the vet"] + + +class TestTheMoveOnStart: + def test_moves_every_topics_list_in_one_commit(self, httpserver): + httpserver.expect_request(f"{CONTENTS}/family", method="GET").respond_with_json([ + {"type": "dir", "path": "family/itchy-scratchy-land"}, + {"type": "dir", "path": "family/people"}, + {"type": "dir", "path": "family/finance"}, + ]) + httpserver.expect_request(f"{CONTENTS}/family/itchy-scratchy-land/todos.md", + method="GET").respond_with_json(_file(CAMPING_TODOS, "old-sha")) + httpserver.expect_request(f"{CONTENTS}/family/finance/todos.md", + method="GET").respond_with_data("", status=404) + httpserver.expect_request(f"{CONTENTS}/family/lists/itchy-scratchy-land.md", + method="GET").respond_with_data("", status=404) + httpserver.expect_request(CONTENTS, method="POST").respond_with_json({}, status=201) + + assert move_todos_to_lists(_client(httpserver)) == ["itchy-scratchy-land"] + + commit = _commit(httpserver) + files = _files(commit) + assert files["family/itchy-scratchy-land/todos.md"][0] == "delete" + operation, content = files["family/lists/itchy-scratchy-land.md"] + assert operation == "create" and "- [x] Pack sunscreen for Bart" in content + deleted = next(f for f in commit["files"] if f["operation"] == "delete") + assert deleted["sha"] == "old-sha", "a todo list edited meanwhile is not deleted" + + def test_a_topic_with_a_list_already_gains_only_the_missing_items(self, httpserver): + existing = ("---\ntype: list\ntitle: Itchy Scratchy Land\n---\n\n" + "- [x] Pay the deposit βœ… 2026-10-01\n") + httpserver.expect_request(f"{CONTENTS}/family", method="GET").respond_with_json( + [{"type": "dir", "path": "family/itchy-scratchy-land"}]) + httpserver.expect_request(f"{CONTENTS}/family/itchy-scratchy-land/todos.md", + method="GET").respond_with_json(_file(CAMPING_TODOS, "old-sha")) + httpserver.expect_request(f"{CONTENTS}/family/lists/itchy-scratchy-land.md", + method="GET").respond_with_json(_file(existing, "list-sha")) + httpserver.expect_request(CONTENTS, method="POST").respond_with_json({}, status=201) + + move_todos_to_lists(_client(httpserver)) + + content = _files(_commit(httpserver))["family/lists/itchy-scratchy-land.md"][1] + items = read_list(content, path="family/lists/itchy-scratchy-land.md").items + assert [(i.text, i.done) for i in items] == [ + ("Pay the deposit", True), + ("Book the hotel before the prices jump", False), + ("Pack sunscreen for Bart", True), + ] + + def test_a_second_start_finds_nothing_to_move(self, httpserver): + httpserver.expect_request(f"{CONTENTS}/family", method="GET").respond_with_json( + [{"type": "dir", "path": "family/lists"}, {"type": "dir", "path": "family/camping"}]) + httpserver.expect_request(f"{CONTENTS}/family/camping/todos.md", + method="GET").respond_with_data("", status=404) + + assert move_todos_to_lists(_client(httpserver)) == [] + assert not [r for r, _ in httpserver.log if r.method == "POST"] diff --git a/tests/unit/stacklets/test_memory_topic_page.py b/tests/unit/stacklets/test_memory_topic_page.py index 9445651c..d01222d7 100644 --- a/tests/unit/stacklets/test_memory_topic_page.py +++ b/tests/unit/stacklets/test_memory_topic_page.py @@ -44,7 +44,8 @@ "date": "2026-03-15", "filed_by": "", "summary": "Covers trips abroad.", "topics": ["trip"], "tags": []} -TODOS = "# Trip\n\n- [ ] Pack sunscreen\n- [x] Book the lodge\n- [ ] Find a dog sitter\n" +TODOS = ("---\ntype: list\ntitle: Trip\ntopics:\n - trip\n---\n\n" + "- [ ] Pack sunscreen\n- [x] Book the lodge\n- [ ] Find a dog sitter\n") ANSWER = { "title": "The Big Trip", @@ -68,7 +69,8 @@ async def complete(self, role, prompt, **kwargs) -> str: def brain(tmp_path, monkeypatch): monkeypatch.setenv("BRAIN_REPO_DIR", str(tmp_path)) (tmp_path / "family" / "trip").mkdir(parents=True) - (tmp_path / "family" / "trip" / "todos.md").write_text(TODOS, encoding="utf-8") + (tmp_path / "family" / "lists").mkdir(parents=True) + (tmp_path / "family" / "lists" / "trip.md").write_text(TODOS, encoding="utf-8") return tmp_path diff --git a/tests/unit/stacklets/test_vault_entry.py b/tests/unit/stacklets/test_vault_entry.py index 8d8fc4c0..d26430f9 100644 --- a/tests/unit/stacklets/test_vault_entry.py +++ b/tests/unit/stacklets/test_vault_entry.py @@ -414,7 +414,7 @@ def test_full_document_render(self): assert "Kfz-Versicherung renewal" in content assert "**Facts**" in content assert "**Action items**" in content - assert "- [ ] Pay invoice β€” 2025-04-01" in content + assert "- Pay invoice πŸ“… 2025-04-01" in content assert "[Show Document](http://paperless:8000/documents/42/details)" in content assert "Vehicle registration insurance renewal" in content @@ -501,7 +501,7 @@ def test_action_item_as_string(self): action_items=["Pay the invoice"], source_link=None, ) - assert "- [ ] Pay the invoice" in content + assert "- Pay the invoice" in content def test_action_item_with_null_due(self): fm = document_frontmatter( @@ -521,7 +521,7 @@ def test_action_item_with_null_due(self): action_items=[{"action": "Pay", "due": "null"}], source_link=None, ) - assert "- [ ] Pay" in content + assert "- Pay" in content assert "β€” null" not in content @@ -595,9 +595,9 @@ def test_note_render(self): assert "> Action: review expenses." in content def test_note_renders_action_items(self): - """A note that opted into extraction surfaces its action items as - `- [ ]` task lines in the briefing callout β€” what the curator later - aggregates into todos.md.""" + """A note that opted into extraction lists its action items in the + briefing callout, without checkboxes: the topic's list holds whether + they are done.""" fm = capture_frontmatter( title="Ausflug", captured_at="2026-06-26", kind="note", source_uri=None, persons=["Marge"], tags=["ausflug"], model=None, @@ -609,12 +609,12 @@ def test_note_renders_action_items(self): summary="Ausflugscheckliste.", facts=[], action_items=["Tickets buchen", "Snacks einpacken"], ) - assert "- [ ] Tickets buchen" in content - assert "- [ ] Snacks einpacken" in content + assert "- Tickets buchen" in content + assert "- Snacks einpacken" in content def test_note_without_action_items_has_no_task_lines(self): - """An idle note (or any bookmark) carries no action items β†’ no - checkboxes manufactured.""" + """An idle note (or any bookmark) carries no action items, so none + are manufactured.""" fm = capture_frontmatter( title="SchΓΆner Tag", captured_at="2026-06-26", kind="note", source_uri=None, persons=[], tags=[], model=None, @@ -625,7 +625,7 @@ def test_note_without_action_items_has_no_task_lines(self): captured_at="2026-06-26", source_uri=None, persons=[], summary="Ein schΓΆner Tag.", facts=[], action_items=[], ) - assert "- [ ]" not in content + assert "Action items" not in content def test_note_without_body(self): fm = capture_frontmatter( @@ -748,7 +748,7 @@ def test_marker_heading_briefing_and_body(self): assert "## 2026-06-21 Β· office@springfield-school.example" in section # Per-message briefing callout + action item checkbox. assert "> [!summary]" in section - assert "- [ ] Return form β€” 2026-06-26" in section + assert "- Return form πŸ“… 2026-06-26" in section # Verbatim body in a collapsible quote callout. assert "> [!quote]- Message" in section assert "> Bitte das Formular zurΓΌcksenden." in section From 5c23f12280e70a0fbed3f2087468f5b74b4f5b9f Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 12 Oct 2026 10:20:20 +0200 Subject: [PATCH 04/17] feat(agent): change list items through the list commands The agent's `list_edit` tool now names a list by its slug and runs `stack memory list`, so it reaches every list, not only a topic's `todos.md`: - `add` can put an item in one of the list's sections, assign it to family members and give it a due day; `move` takes items to another list. - The skill tells the agent where lists live, to use only a list's own sections, to assign people or dates only when someone says so, and to retry once with the choices a refusal names. - A room's greeting mentions what is open on the topic's lists. Refs: #194 --- stacklets/agent/runtime/join_greeting.py | 6 +- stacklets/agent/runtime/list_tool.py | 73 +++++++++++-------- stacklets/agent/runtime/sitecustomize.py | 2 +- .../workspace/skills/family-memory/SKILL.md | 14 ++-- .../stacklets/test_agent_join_greeting.py | 4 +- .../unit/stacklets/test_agent_vault_tools.py | 12 ++- 6 files changed, 69 insertions(+), 42 deletions(-) diff --git a/stacklets/agent/runtime/join_greeting.py b/stacklets/agent/runtime/join_greeting.py index ef919038..cc666f8d 100644 --- a/stacklets/agent/runtime/join_greeting.py +++ b/stacklets/agent/runtime/join_greeting.py @@ -59,15 +59,15 @@ _TOPIC_GREETING = ( "Introduce yourself in one short line, then say what this room's " "topic '{topic}' is about, in your own words, from its page in the " - "vault. If it has open todos, say how many and name one or two, then " - "say you can add items, tick them off, or change them." + "vault. If its lists have open items, say how many and name one or " + "two, then say you can add items, tick them off, or change them." ) _PLAIN_GREETING = ( "This room has no topic page, so do not describe one and do not go " "looking for it. Introduce yourself in one short line and say " "plainly what you can do: answer questions from the family's own " - "notes and documents, and keep topic todo lists." + "notes and documents, and keep the family's lists." ) diff --git a/stacklets/agent/runtime/list_tool.py b/stacklets/agent/runtime/list_tool.py index 34583cf4..3656250a 100644 --- a/stacklets/agent/runtime/list_tool.py +++ b/stacklets/agent/runtime/list_tool.py @@ -24,30 +24,39 @@ @tool_parameters( tool_parameters_schema( - page=StringSchema( - "The list page, such as vault/family/groceries/todos.md.", + list=StringSchema( + "The list's name, such as einkaufsliste. `stack memory list " + "show` names every list with its description.", min_length=1, ), op=StringSchema( - "Item operation, or bulk: clear-done removes all ticked " - "items, reset reopens them.", - enum=("add", "tick", "untick", "remove", "clear-done", "reset"), + "Item operation: add, tick, untick, remove, or move to another " + "list. reset reopens every ticked item.", + enum=("add", "tick", "untick", "remove", "move", "reset"), ), items=ArraySchema( StringSchema("One item's text, as it appears on the list."), description="The item(s) for this op, one or more, in the " - "family's words. Omit for bulk ops (clear-done, " - "reset).", + "family's words. Omit for reset.", nullable=True, ), section=StringSchema( - "Optional section heading for add, such as Dairy.", + "For add: one of the list's own sections, such as " + "Milchprodukte & Eier.", nullable=True, ), + people=ArraySchema( + StringSchema("A family member's name."), + description="For add: who the item is for, only when someone " + "says so.", + nullable=True, + ), + due=StringSchema("For add: the day it is due, YYYY-MM-DD.", nullable=True), + to=StringSchema("For move: the list the items go to.", nullable=True), ) ) class ListEditTool(Tool): - """Change one item on a family list.""" + """Change items on a family list.""" _scopes = {"core"} @@ -58,10 +67,10 @@ def name(self) -> str: @property def description(self) -> str: return ( - "Add, tick, untick, or remove ONE item on a family list page. " - "The store matches the item and answers with what actually " - "changed. Use this for every item change; use write_file only " - "to restructure a whole list." + "Add, tick, untick, remove or move items on a family list, by " + "name. The store matches each item and answers with what " + "actually changed. Use this for every item change; use " + "write_file only to restructure a whole list." ) @property @@ -70,28 +79,33 @@ def read_only(self) -> bool: async def execute( self, - page: str, + list: str, op: str, - items: list[str] | None = None, + items: "list[str] | None" = None, section: str | None = None, + people: "list[str] | None" = None, + due: str | None = None, + to: str | None = None, ) -> str: - target = page.strip() - for marker in ("workspace/vault/", "vault/"): - if marker in target: - target = target.split(marker, 1)[1] - break try: import brief actor = getattr(brief, "speaking_with", "") or "someone" except Exception: actor = "someone" - names = [i for i in (items or []) if i and i.strip()] - args = ["stack", "memory", "list-edit", target, "--op", op, "--by", actor] - for name in names or [""]: - args.extend(["--item", name]) - if section: - args.extend(["--section", section]) + args = ["stack", "memory", "list", op, list.strip()] + if op != "reset": + args += [i for i in (items or []) if i and i.strip()] + if op == "add": + if section: + args += ["--section", section] + for person in people or []: + args += ["--for", person] + if due: + args += ["--due", due] + if op == "move" and to: + args += ["--to", to] + args += ["--as", actor] proc = await asyncio.create_subprocess_exec( *args, @@ -101,11 +115,12 @@ async def execute( stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=60) out = stdout.decode(errors="replace").strip() err = stderr.decode(errors="replace").strip() - # Exit 1 carries an instructive answer (ambiguous item, unknown - # item with candidates). The model reads it and retries once. + # Exit 1 carries an instructive answer (ambiguous item, a section or + # list that does not exist, with the choices). The model reads it + # and retries once. if proc.returncode not in (0, 1): return f"Error: list edit failed with exit {proc.returncode}: {err or out}" - return out or "(no answer from the list store)" + return out or err or "(no answer from the list store)" def install() -> None: diff --git a/stacklets/agent/runtime/sitecustomize.py b/stacklets/agent/runtime/sitecustomize.py index 2539c7dd..c8c2832f 100644 --- a/stacklets/agent/runtime/sitecustomize.py +++ b/stacklets/agent/runtime/sitecustomize.py @@ -174,7 +174,7 @@ def _build_messages_logged(self, *args, **kwargs): ("person_tool", "memory_person tool"), ("history_tool", "memory_history tool"), ("vault_write", "write_file on a vault page -> stack memory write"), - ("list_tool", "list_edit item tool -> stack memory list-edit"), + ("list_tool", "list_edit item tool -> stack memory list"), ("tool_trim", "unused-tool trim (AGENT_TOOL_TRIM=0 to disable)"), ("compact_tools", "vault tools in nanobot's microcompact set"), ("thread_session", "thread-scoped sessions (AGENT_THREAD_SESSIONS=0 to disable)"), diff --git a/stacklets/agent/workspace/skills/family-memory/SKILL.md b/stacklets/agent/workspace/skills/family-memory/SKILL.md index 7da2d6e3..d337a555 100644 --- a/stacklets/agent/workspace/skills/family-memory/SKILL.md +++ b/stacklets/agent/workspace/skills/family-memory/SKILL.md @@ -20,7 +20,8 @@ LOOKUP: PATHS: person -> vault//about.md - topic -> vault/family//about.md ; items in .../todos.md + topic -> vault/family//about.md + list -> vault/family/lists/.md ; which lists exist -> `stack memory list show` search prints vault-relative paths -> read as vault/ SOURCES (answer used a vault page, searched or read): @@ -29,11 +30,14 @@ SOURCES (answer used a vault page, searched or read): max 3 sources; nothing from vault -> no Sources line LISTS: - item op add|tick|untick|remove -> list_edit, items=[one or more] + item op add|tick|untick|remove -> list_edit(list, op, items=[one or more]) "we did that" -> tick, never remove - "clear the list|bought all" -> list_edit op=clear-done # removes ticked - "start the week|fresh list" -> list_edit op=reset # reopens all - answer "ambiguous|no item" -> pick from named candidates, retry once + "start the week|fresh list" -> list_edit op=reset # reopens all + belongs on another list -> op=move, to= + add to a list with sections -> section = one of the list's own + "for " | "by " -> add with people=[name] | due=YYYY-MM-DD; never guess either + answer "ambiguous|no item|no section|no list|nobody called" + -> pick from the named choices, retry once restructure ONLY -> read_file then write_file(complete page) keep: frontmatter verbatim at column one, every [x], their order, their words write answer reports unintended unticked|REMOVED -> restore now, then say so diff --git a/tests/unit/stacklets/test_agent_join_greeting.py b/tests/unit/stacklets/test_agent_join_greeting.py index f74eb64d..025b2d9a 100644 --- a/tests/unit/stacklets/test_agent_join_greeting.py +++ b/tests/unit/stacklets/test_agent_join_greeting.py @@ -26,11 +26,11 @@ def test_the_greeting_names_that_topic(self): prompt = greeting_prompt("camping") assert "camping" in prompt - def test_it_is_asked_for_the_todo_state(self): + def test_it_is_asked_for_what_is_open_on_its_lists(self): """The count and an item or two are the useful part of a topic greeting; without them it is just an introduction.""" prompt = greeting_prompt("camping").lower() - assert "todo" in prompt + assert "lists have open items" in prompt class TestARoomWithoutATopic: diff --git a/tests/unit/stacklets/test_agent_vault_tools.py b/tests/unit/stacklets/test_agent_vault_tools.py index c4cd430a..9961d876 100644 --- a/tests/unit/stacklets/test_agent_vault_tools.py +++ b/tests/unit/stacklets/test_agent_vault_tools.py @@ -36,7 +36,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent MEMORY_DIR = REPO_ROOT / "stacklets" / "memory" -TOOL_MODULES = ("memory_tool", "person_tool", "history_tool", "sitecustomize") +TOOL_MODULES = ("memory_tool", "person_tool", "history_tool", "list_tool", "sitecustomize") # ── loading the real components under test ─────────────────────────── @@ -72,7 +72,7 @@ def memory_cli(): return { name: _load_from_path(f"memory_cli_{name}", MEMORY_DIR / "cli" / f"{name}.py") - for name in ("search", "person") + for name in ("search", "person", "list") } @@ -88,6 +88,7 @@ def vault_tools(monkeypatch, nanobot_stub): "memory_search": importlib.import_module("memory_tool").MemorySearchTool, "memory_person": importlib.import_module("person_tool").MemoryPersonTool, "memory_history": importlib.import_module("history_tool").MemoryHistoryTool, + "list_edit": importlib.import_module("list_tool").ListEditTool, } yield tools for name in TOOL_MODULES: @@ -126,6 +127,7 @@ async def _fake_exec(*args, **_kwargs): @pytest.mark.parametrize("tool_name,kwargs", [ ("memory_search", {"query": "Homer"}), ("memory_person", {"name": "homer"}), + ("list_edit", {"list": "einkaufsliste", "op": "tick", "items": ["Milch"]}), ]) def test_the_command_a_tool_runs_is_allowed_by_the_api(api, vault_tools, tool_name, kwargs): @@ -166,6 +168,12 @@ def test_the_allowlist_stays_read_only(api): {"query": "Homer", "limit": 10, "person": "homer", "tag": "Insurance", "scope": "family"}), ("memory_person", "person", {"name": "homer"}), + ("list_edit", "list", + {"list": "einkaufsliste", "op": "add", "items": ["Joghurt", "Eier"], + "section": "Milchprodukte & Eier", "people": ["Marge"], "due": "2026-10-14"}), + ("list_edit", "list", + {"list": "einkaufsliste", "op": "move", "items": ["Eier"], "to": "markt"}), + ("list_edit", "list", {"list": "packliste", "op": "reset", "items": ["ignored"]}), ]) def test_the_flags_a_tool_sends_are_flags_the_cli_accepts( memory_cli, vault_tools, tool_name, command, kwargs): From 18a2e7f03d3ac77badeb113414ca582eb8aac716 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 12 Oct 2026 10:20:20 +0200 Subject: [PATCH 05/17] feat(memory): remove the todo commands that lists replace `stack memory list` covers what they did, for every list: - `stack memory topic todo` and its `add`, `strike` and `unstrike` verbs are gone; `stack memory topic ` still shows the topic's overview, and `stack memory topic todo` says where the todos went: `stack memory list show `. - `stack memory list-edit ` is gone; the agent's tool runs `stack memory list`. - The agent's API no longer needs a rule refusing the topic todo verbs. Refs: #194 --- lib/stack/list_doc.py | 12 +- stacklets/core/famstack-api.py | 37 +-- stacklets/memory/bot/cli/todo_list.py | 160 ----------- stacklets/memory/cli/_todos.py | 258 ------------------ stacklets/memory/cli/list-edit.py | 229 ---------------- stacklets/memory/cli/person.py | 2 +- stacklets/memory/cli/topic.py | 145 +++++----- stacklets/memory/cli/write.py | 2 +- tests/e2e/test_demo_rig_e2e.py | 49 ++-- tests/unit/stacklets/test_memory_cli.py | 24 +- tests/unit/stacklets/test_memory_list_edit.py | 173 ------------ tests/unit/stacklets/test_todo_list.py | 206 -------------- 12 files changed, 115 insertions(+), 1182 deletions(-) delete mode 100644 stacklets/memory/bot/cli/todo_list.py delete mode 100644 stacklets/memory/cli/_todos.py delete mode 100644 stacklets/memory/cli/list-edit.py delete mode 100644 tests/unit/stacklets/test_memory_list_edit.py delete mode 100644 tests/unit/stacklets/test_todo_list.py diff --git a/lib/stack/list_doc.py b/lib/stack/list_doc.py index 744974ea..51487630 100644 --- a/lib/stack/list_doc.py +++ b/lib/stack/list_doc.py @@ -1,8 +1,8 @@ """A list page, and what changed between two versions of one. -A list lives in `todos.md`, and more than one thing writes it: the -curator merging extracted action items, a person editing it in Forgejo's -editor, and an agent asked to tidy it up. The interesting failure is not a +A list lives in `/lists/.md`, and more than one thing writes +it: the archivist adding extracted action items, a person editing it in +Forgejo's editor, and an agent asked to tidy it up. The interesting failure is not a malformed document. It is a quiet one: six of twenty-five items gone and a cheerful confirmation that everything went fine. @@ -28,7 +28,7 @@ SEE ALSO docs/design/brain/write-layer.md why this is the first piece - stacklets/memory/bot/cli/todo_list.py the task-line grammar it shares + lib/stack/lists.py the list format; its item line is the grammar """ from __future__ import annotations @@ -38,8 +38,8 @@ from difflib import SequenceMatcher # An Obsidian task line, open or done: group 1 is the box char, group 2 the -# task text. Same grammar `todo_list.py` reads and writes, restated here -# because this module is the one both sides will eventually share. +# task text, fields included. A coarser read of the line `stack.lists` +# writes, enough to tell what an edit added, removed or ticked. _TASK = re.compile(r"^\s*[-*]\s+\[([ xX])\]\s+(.+?)\s*$") _SECTION = re.compile(r"^\s*##\s+(.+?)\s*$") diff --git a/stacklets/core/famstack-api.py b/stacklets/core/famstack-api.py index c239b1de..70a91392 100644 --- a/stacklets/core/famstack-api.py +++ b/stacklets/core/famstack-api.py @@ -52,11 +52,9 @@ # ... and the way it changes one. A page is handed over whole and the # reply names what the edit did, so a rewrite that drops items says so. ["memory", "write"], - # One item on a list, by name. The store matches the item and keeps - # the rest of the page intact, so a tick cannot lose other state. - ["memory", "list-edit"], - # The family's lists: which exist, what is open on one, and the same - # item verbs `list_edit` uses. + # The family's lists: which exist, what is open on one, and one item + # changed by name. The store matches the item and keeps the rest of + # the file intact, so a tick cannot lose other state. ["memory", "list"], # What changed, when, and who did it. The vault is a git repo and has # always known this; nothing read it back until now. @@ -64,33 +62,6 @@ ["docs", "show"], ] -# Reads under an allowed prefix that are actually writes. `memory topic -# todo` is a read the agent needs, but `todo add|strike` under the same prefix -# is a second way to change a list, and given both the model picks the per-item -# verb even for a structural edit -- asked to split a list in two it ticked off -# two unrelated items and described a split that never happened. People and -# scripts keep these verbs on the host CLI, where a deterministic non-LLM path -# is worth having; the agent gets one way, and the refusal says which. -DOMAIN_DENY = [ - (("memory", "topic"), ("add", "strike", "unstrike")), -] -DENY_HINT = ( - "error: the agent does not change lists item by item. Read the page with " - "read_file on vault/family//todos.md, then write_file the complete " - "new contents to the same path. Ticking off is '- [x]'; a second list is a " - "'## ' heading.\n" -) - - -def _is_denied_write(args): - """True when an allowed prefix is being used for a write the agent owns - through the page-rewrite path instead.""" - return any( - tuple(args[:len(prefix)]) == prefix and any(v in args for v in verbs) - for prefix, verbs in DOMAIN_DENY - ) - - def handle_request(data): """Process a single JSON command by calling the stack CLI.""" try: @@ -195,8 +166,6 @@ def handle_plaintext(line): return _with_exit( f"error: '{' '.join(args[:2])}' is not allowed. Allowed: {allowed}\n", 126, ) - if _is_denied_write(args): - return _with_exit(DENY_HINT, 126) try: r = subprocess.run( [str(STACK_BIN), *args], capture_output=True, text=True, diff --git a/stacklets/memory/bot/cli/todo_list.py b/stacklets/memory/bot/cli/todo_list.py deleted file mode 100644 index e98eedaf..00000000 --- a/stacklets/memory/bot/cli/todo_list.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Extracting `action_items` from list-notes, rendering them as todos. - -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 `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 -announces it ("Liste Bus Erweiterungen:", "Todo:", "Einkaufsliste:"). We -deliberately do NOT mine arbitrary notes: the capture classifier leaves -action_items out on purpose, because a pasted Reddit thread must not manufacture -a household todo (see tests/unit/stacklets/test_capture_prompt.py). So detection here -is a narrow, deterministic signal β€” an explicit marker β€” not an LLM guess. High -precision, zero false positives, no model call. - -The list lives as a source `todos.md` in the vault (Obsidian task lines), so the -wiki renders it and a family member ticks items off in Forgejo's editor. -""" - -from __future__ import annotations - -import re - -# First-line markers that announce "this note is a list". German + English, -# the two household languages we ship. Matched case-insensitively, with or -# without a trailing colon, as the whole head or its leading word. -_LIST_MARKERS = ( - "liste", "todo", "to-do", "to do", "todos", - "aufgaben", "einkaufsliste", "einkauf", "checkliste", "checklist", "list", -) - -# An Obsidian task line, open or done: group 1 is the box char (" " open, -# "x"/"X" done), group 2 the task text. -_TASK_RE = re.compile(r"^\s*[-*]\s+\[([ xX])\]\s+(.+?)\s*$") - - -def detect_list(body: str) -> tuple[str, list[str]] | None: - """Return ``(title, action_items)`` when `body` is an announced list. - - The signal is deliberate: the first non-empty line is a list marker - (optionally `:`-terminated) and at least one item follows. The action_items - are the remaining non-empty lines, verbatim. Anything without the marker - returns None β€” we never infer a list from prose. - """ - lines = [ln.strip() for ln in body.splitlines() if ln.strip()] - if len(lines) < 2: - return None - first = lines[0] - head = first[:-1].strip() if first.endswith(":") else first - if not _is_marker(head): - return None - return head, lines[1:] - - -def _is_marker(head: str) -> bool: - h = head.lower() - return any(h == m or h.startswith(m + " ") for m in _LIST_MARKERS) - - -def render_todo_doc(title: str, action_items: list[str]) -> str: - """A fresh `todos.md`: a title heading plus one todo line per action item.""" - lines = [f"# {title}", ""] - lines += [f"- [ ] {ai}" for ai in action_items] - return "\n".join(lines) + "\n" - - -def add_items(existing: str, action_items: list[str]) -> str: - """Append action items not already present to an existing `todos.md`. - - Matches on task text across both open and done lines, so a re-sent item - neither doubles up nor resurrects something already ticked off. Existing - lines (including `- [x]` done items) are left untouched. - """ - seen = {m.group(2).strip() - for ln in existing.splitlines() - if (m := _TASK_RE.match(ln))} - fresh = [ai for ai in action_items if ai.strip() and ai.strip() not in seen] - if not fresh: - return existing - body = existing.rstrip("\n") - body += "\n" + "\n".join(f"- [ ] {ai.strip()}" for ai in fresh) - return body + "\n" - - -def update_todo_doc(existing: str | None, title: str, - action_items: list[str]) -> str: - """Create the doc if missing, otherwise append the new action items.""" - if not existing: - return render_todo_doc(title, action_items) - return add_items(existing, action_items) - - -def read_todos(doc: str) -> tuple[list[str], list[str]]: - """Split a rendered `todos.md` into ``(open, done)`` task texts, file order. - - The read side of the surface: `- [ ]` lines are open, `- [x]`/`- [X]` done; - the title and blank lines are ignored. Kept beside the writers so one module - owns what a todo line is -- the CLI list command reads through here. - """ - open_items: list[str] = [] - done_items: list[str] = [] - for line in doc.splitlines(): - m = _TASK_RE.match(line) - if not m: - continue - bucket = open_items if m.group(1) == " " else done_items - bucket.append(m.group(2).strip()) - return open_items, done_items - - -def _norm(text: str) -> str: - """Whitespace- and case-normalised task text for matching.""" - return " ".join(text.split()).lower() - - -def set_todo_done(doc: str, item: str, *, done: bool) -> tuple[str, str]: - """Flip the task matching `item` to done (`[x]`) or open (`[ ]`). - - The write counterpart to `read_todos`, kept in the module that owns the - task-line grammar. You identify the task by the **start** of its text, not - the whole line -- "buy sunscreen" finds "buy sunscreen for Bart" -- because - the striker (a family member in chat, or the agent on their behalf) rarely - quotes it exactly. An exact text wins over a longer sibling ("milk" over - "milk and eggs"). Returns `(new_doc, matched_text)` with the exact task - struck, so the caller can echo precisely what happened. - - Raises ValueError when nothing starts with the string, or when it starts - more than one task -- the message then lists the matches so the caller can - ask for a string that identifies just one. The single "same text open and - already done" case is not ambiguous: it resolves to the copy whose state - would actually change. - """ - needle = _norm(item) - lines = doc.splitlines(keepends=True) - tasks = [(i, m.group(2).strip(), m.group(1) != " ") - for i, line in enumerate(lines) - if (m := _TASK_RE.match(line))] - - hits = [t for t in tasks if _norm(t[1]).startswith(needle)] - if not hits: - raise ValueError(f"no todo matching {item!r}") - if exact := [t for t in hits if _norm(t[1]) == needle]: - hits = exact - - if len(hits) > 1: - # A string that also lands on an already-done copy resolves to the one - # whose state would flip; otherwise it is genuinely ambiguous. - changeable = [t for t in hits if t[2] != done] - if len(changeable) == 1: - hits = changeable - else: - opts = "\n ".join(dict.fromkeys(t[1] for t in hits)) - raise ValueError( - "more than one match; the string must match only one item:\n " + opts) - - idx, matched, _ = hits[0] - box = "x" if done else " " - lines[idx] = re.sub(r"\[[ xX]\]", f"[{box}]", lines[idx], count=1) - return "".join(lines), matched diff --git a/stacklets/memory/cli/_todos.py b/stacklets/memory/cli/_todos.py deleted file mode 100644 index 2fe8a484..00000000 --- a/stacklets/memory/cli/_todos.py +++ /dev/null @@ -1,258 +0,0 @@ -"""Host-native reader for a scope's derived todos β€” used by `stack memory topic`. - -`stack memory topic todo` mirrors the `/go/topic//todo` link: the -CLI noun and the URL noun are the same first-class entity. It reads the -`todos.md` the curator writes straight off the vault on disk, so listing needs -neither the LLM nor the bot-runner β€” it works even when the model is down. The -`_`-prefix keeps this off the command list; `topic.py` routes to it. - -The curator only writes topic-scope todos today (`//todos.md`); -personal-scope todos (`/todos.md`) are deferred, so a `person` scope -just reports no list until they land. -""" - -from __future__ import annotations - -import re -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from lib import ( # noqa: E402 - DEFAULT_SHARED_BUCKET, - brain_path_for, - refresh_vault_if_stale, - update_memory, - vault_path_for, -) - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "bot" / "cli")) -from todo_list import read_todos, set_todo_done, update_todo_doc # noqa: E402 - - -def _vault(config) -> Path | None: - data_dir = config.get("data_dir") if config else None - return vault_path_for(Path(data_dir)) if data_dir else None - - -def _brain(config) -> Path | None: - data_dir = config.get("data_dir") if config else None - return brain_path_for(Path(data_dir)) if data_dir else None - - -def _known_scopes(vault: Path) -> list[str]: - """Every scope slug that currently has a todos.md, deduped and sorted.""" - return sorted({p.parent.name for p in vault.glob("*/*/todos.md")}) - - -def _known_topics(brain: Path) -> list[str]: - """Every topic slug that currently has an about.md in brain.""" - return sorted({ - p.parent.name for p in brain.glob("*/*/about.md") - if p.parent.parent != brain - }) - - -def list_todos(scope: str, *, show_all: bool, config) -> dict: - """Print a scope's open todos (with `--all`, the done ones too). - - Returns the framework's `{"ok": ...}` / `{"error": ...}` envelope; the - structured `lists` payload always carries both open and done items so an - agent reading the JSON sees the full state regardless of `show_all`. - """ - vault = _vault(config) - if vault is None or not vault.exists(): - return {"error": "no vault found β€” is the memory stacklet installed?"} - - # Pull if the remote moved, so the list reflects edits made anywhere else - # (Forgejo web, another family member, the curator), not just this host's - # own writes. Same cheap ls-remote check `stack memory search` runs; a - # host-side, best-effort pull that never makes a read worse. - refresh_vault_if_stale(vault) - - scope = (scope or "").strip() - if not scope: - known = _known_scopes(vault) - hint = (" topics with todos: " + ", ".join(known)) if known else \ - " no topic has a todo list yet" - return {"error": f"usage: stack memory topic todo\n{hint}"} - - # A topic slug can occur under more than one bucket (a shared - # `family/camping` and a personal `bart/camping`); each is its own list. - matches = sorted(vault.glob(f"*/{scope}/todos.md")) - if not matches: - known = _known_scopes(vault) - hint = (" topics with todos: " + ", ".join(known)) if known else "" - return {"error": f"no todo list for topic {scope!r}\n{hint}".rstrip()} - - lists = [] - for i, path in enumerate(matches): - open_items, done_items = read_todos(path.read_text(encoding="utf-8")) - bucket = path.parent.parent.name # //todos.md - if i: - print() - print(f"{bucket}/{scope} β€” {len(open_items)} open, {len(done_items)} done") - for item in open_items: - print(f"- [ ] {item}") - if show_all: - for item in done_items: - print(f"- [x] {item}") - lists.append({ - "bucket": bucket, "scope": scope, - "open": open_items, "done": done_items, - }) - - return {"ok": True, "lists": lists} - - -def strike_todo(scope: str, item: str, *, done: bool, actor: str, config) -> dict: - """Strike (`done=True`) or unstrike (`done=False`) a scope's todo. - - Resolves the scope's `todos.md`, then hands the edit + commit to - `update_memory`, which writes canonically to Forgejo attributed to `actor` - and refreshes the local clone. The toggle itself lives in `set_todo_done` - (`todo_list.py`), so the read and write sides share one notion of a task - line. Prints a human line (what the agent relays) and returns the envelope. - """ - vault = _vault(config) - if vault is None or not vault.exists(): - return {"error": "no vault found β€” is the memory stacklet installed?"} - - scope = (scope or "").strip() - item = (item or "").strip() - # The agent may pass a full mxid (@homer:simpson); the commit and the message - # should read as the person's handle. - actor = (actor or "someone").strip().split(":")[0].lstrip("@") or "someone" - if not (scope and item): - return {"error": 'usage: stack memory topic todo strike "" --by '} - - matches = sorted(vault.glob(f"*/{scope}/todos.md")) - if not matches: - known = _known_scopes(vault) - hint = (" topics with todos: " + ", ".join(known)) if known else "" - return {"error": f"no todo list for topic {scope!r}\n{hint}".rstrip()} - - # A slug can live under several buckets (family/camping, bart/camping); - # narrow to the one that actually holds this item so we edit the right list. - if len(matches) > 1: - needle = item.lower() - narrowed = [p for p in matches - if needle in p.read_text(encoding="utf-8").lower()] - if len(narrowed) > 1: - buckets = ", ".join(p.parent.parent.name for p in narrowed) - return {"error": f"{item!r} is in several lists ({buckets}); name the bucket"} - if not narrowed: - return {"error": f"no todo matching {item!r} in {scope!r}"} - matches = narrowed - - path = matches[0] - bucket = path.parent.parent.name - repo_path = f"{bucket}/{scope}/todos.md" - - # Capture the exact task the canonical edit matched, so we echo precisely - # what changed even when the caller passed only a substring. - captured: dict[str, str] = {} - - def _tx(doc: str) -> str: - new_doc, matched = set_todo_done(doc, item, done=done) - captured["matched"] = matched - return new_doc - - verb = "ticked off" if done else "reopened" - message = f'chore(todos): {actor} {verb} "{item}" in {scope}' - result = update_memory(config, repo_path, _tx, actor=actor, message=message) - if "error" in result: - return result - - matched = captured.get("matched", item) - if not result.get("committed"): - state = "already done" if done else "already open" - print(f'"{matched}" was {state} in {bucket}/{scope} β€” nothing to do') - return {"ok": True, "committed": False, "matched": matched, "scope": scope} - - print(f'{"Struck" if done else "Reopened"}: {matched} ({bucket}/{scope}, by {actor})') - return {"ok": True, "committed": True, "matched": matched, - "scope": scope, "bucket": bucket, "by": actor} - - -def add_todo(scope: str, item: str, *, actor: str, config) -> dict: - """Add a new todo to a scope's list, creating the list if it is missing. - - The write counterpart to the archivist's action-item extraction: the agent - can put an item straight onto a topic's `todos.md` when asked, reusing the - same `update_todo_doc` merge the curator uses (a duplicate is a no-op; a - done item is never resurrected). Commits to Forgejo as `actor`. - """ - vault = _vault(config) - if vault is None or not vault.exists(): - return {"error": "no vault found β€” is the memory stacklet installed?"} - - scope = (scope or "").strip() - item = (item or "").strip() - actor = (actor or "someone").strip().split(":")[0].lstrip("@") or "someone" - if not (scope and item): - return {"error": 'usage: stack memory topic todo add "" --by '} - - # Add to the scope's existing list; if it has none, start one in the shared - # bucket (auto-extend: no new-topic ceremony just to jot something down). - matches = sorted(vault.glob(f"*/{scope}/todos.md")) - if len(matches) > 1: - buckets = ", ".join(p.parent.parent.name for p in matches) - return {"error": f"{scope!r} has lists in several buckets ({buckets}); name the bucket"} - bucket = matches[0].parent.parent.name if matches else DEFAULT_SHARED_BUCKET - repo_path = f"{bucket}/{scope}/todos.md" - title = scope.replace("-", " ").title() - - result = update_memory( - config, repo_path, - lambda doc: update_todo_doc(doc or None, title, [item]), - actor=actor, - message=f'chore(todos): {actor} added "{item}" to {scope}', - ) - if "error" in result: - return result - if not result.get("committed"): - print(f'"{item}" is already on the {bucket}/{scope} list; nothing added') - return {"ok": True, "committed": False, "scope": scope} - - print(f'Added: {item} ({bucket}/{scope}, by {actor})') - return {"ok": True, "committed": True, "item": item, - "scope": scope, "bucket": bucket, "by": actor} - - -# Strip the machine bits so the about reads as prose: the frontmatter block, the -# generated-section markers the wiki writes, and citation markers like [8]. -_FRONTMATTER = re.compile(r"^---\n.*?\n---\n", re.DOTALL) -_GEN_MARKER = re.compile(r"\n?") -_CITE = re.compile(r"\[\d+(?:,\s*\d+)*\]") - - -def show_topic(scope: str, *, config) -> dict: - """Print a topic's brain about page -- its curated overview. - - Bare `stack memory topic ` (no `todo`) gives the topic's summary, so - the agent can answer "what is this / summarise it" without guessing the vault - path. Strips frontmatter, the generated-section markers, and citation markers - for a clean read; the raw page is `brain///about.md`, while - `topic todo` continues to read source state from memory. - """ - brain = _brain(config) - if brain is None or not brain.exists(): - return {"error": "no brain projection found β€” is the memory stacklet installed?"} - scope = (scope or "").strip() - if not scope: - return {"error": "usage: stack memory topic "} - - refresh_vault_if_stale(brain) - matches = sorted(brain.glob(f"*/{scope}/about.md")) - if not matches: - known = _known_topics(brain) - hint = (" topics: " + ", ".join(known)) if known else "" - return {"error": f"no page for topic {scope!r}\n{hint}".rstrip()} - - text = matches[0].read_text(encoding="utf-8") - text = _FRONTMATTER.sub("", text) - text = _GEN_MARKER.sub("", text) - text = _CITE.sub("", text) - print(text.strip()) - return {"ok": True, "scope": scope, "bucket": matches[0].parent.parent.name} diff --git a/stacklets/memory/cli/list-edit.py b/stacklets/memory/cli/list-edit.py deleted file mode 100644 index 583dd497..00000000 --- a/stacklets/memory/cli/list-edit.py +++ /dev/null @@ -1,229 +0,0 @@ -"""stack memory list-edit β€” change one item on a list page. - -The item verb for lists. `stack memory write` hands a page over whole. -That is right for a restructure and wrong for one tick: a whole-page -write can lose state the caller never meant to touch (measured -2026-09-15 in the agent lab: rewrites drop `[x]` marks, and prompt -rules do not reliably repair them). Here the caller only names the -item. Matching, state preservation, and the commit happen at the -store, so an item change cannot damage the rest of the page. - -Matching is exact first, then substring, both case-insensitive. An -ambiguous name is an answer, not a guess: the reply names the -candidates and the caller retries with more of the item's own words. - -Exit codes: 0 = changed, or an honest no-op ("already ticked"); -1 = instructive refusal (unknown page, no match, ambiguous match); -2 = usage error. -""" - -HELP = "Change one item on a family list page" - -import argparse -import re -import sys -from pathlib import Path - -# Sibling-import pattern used by the other memory CLI plugins: plugins -# run on the host's stdlib-only python3, so we manipulate sys.path -# instead of relying on a package install. -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from lib import update_memory # noqa: E402 - -from stack.links import go_page, public # noqa: E402 -from write import _commit_message # noqa: E402 - -_BOX = re.compile(r"^(\s*)- \[([ xX])\]\s*(.+?)\s*$") - -_OPS = ("add", "tick", "untick", "remove", "clear-done", "reset") - - -def apply_list_edit(text: str, op: str, item: str, - section: str | None = None) -> tuple[str, str, str]: - """Apply one item operation, or one bulk operation, to a page's text. - - Pure transform, so the behavior is testable without the store. - Returns (new_text, sentence, kind). kind is one of: - changed the page text changed; sentence says what happened - noop nothing to do; sentence says why ("already ticked") - refuse the operation cannot proceed; sentence instructs the caller - """ - lines = text.splitlines() - boxes = [(i, m.group(2).lower() == "x", m.group(3)) - for i, ln in enumerate(lines) if (m := _BOX.match(ln))] - - # Bulk operations: the everyday list flows. "We bought everything" - # is clear-done; a recurring list starts the week with reset. Both - # name every affected item, so the reply stays checkable. - if op == "clear-done": - done = [(i, t) for i, d, t in boxes if d] - if not done: - return text, "nothing is ticked; the list is already clear", "noop" - for i, _ in reversed(done): - del lines[i] - names = "; ".join(t for _, t in done) - return ("\n".join(lines) + "\n", - f"REMOVED {len(done)}: {names}", "changed") - if op == "reset": - done = [(i, t) for i, d, t in boxes if d] - if not done: - return text, "nothing is ticked; the list is already open", "noop" - for i, _ in done: - lines[i] = re.sub(r"- \[[xX]\]", "- [ ]", lines[i], count=1) - names = "; ".join(t for _, t in done) - return ("\n".join(lines) + "\n", - f"reopened {len(done)}: {names}", "changed") - - if op == "add": - if any(item.casefold() == t.casefold() for _, _, t in boxes): - return text, f"'{item}' is already on the list", "noop" - insert_at = len(lines) - if section: - heads = [i for i, ln in enumerate(lines) - if ln.lstrip().startswith("#") - and ln.lstrip("# ").strip().casefold() == section.casefold()] - if heads: - insert_at = heads[0] + 1 - while (insert_at < len(lines) - and not lines[insert_at].lstrip().startswith("#")): - insert_at += 1 - # Keep the blank line that separates sections below the item. - while insert_at > heads[0] + 1 and not lines[insert_at - 1].strip(): - insert_at -= 1 - lines.insert(insert_at, f"- [ ] {item}") - return "\n".join(lines) + "\n", f"added 1: {item}", "changed" - - matches = [b for b in boxes if item.casefold() == b[2].casefold()] - if not matches: - matches = [b for b in boxes if item.casefold() in b[2].casefold()] - if not matches: - open_items = "; ".join(t for _, done, t in boxes if not done) - return text, (f"no item matching '{item}'. " - f"Open items: {open_items or '(none)'}"), "refuse" - if len(matches) > 1: - names = "; ".join(t for _, _, t in matches) - return text, (f"'{item}' is ambiguous, it matches: {names}. " - "Name the item more exactly."), "refuse" - - idx, done, name = matches[0] - if op == "tick": - if done: - return text, f"'{name}' is already ticked", "noop" - lines[idx] = lines[idx].replace("- [ ]", "- [x]", 1) - sentence = f"ticked off 1: {name}" - elif op == "untick": - if not done: - return text, f"'{name}' is already open", "noop" - lines[idx] = re.sub(r"- \[[xX]\]", "- [ ]", lines[idx], count=1) - sentence = f"reopened 1: {name}" - else: # remove - del lines[idx] - sentence = f"REMOVED 1: {name}" - return "\n".join(lines) + "\n", sentence, "changed" - - -_VERB = {"add": "added", "tick": "ticked off", "untick": "reopened", - "remove": "REMOVED"} - - -def apply_list_edits(text: str, op: str, items: list[str], - section: str | None = None) -> tuple[str, str, str]: - """Apply one op to several items, in one page write. - - Each item runs against the page as the previous item left it, so a - batch is exactly the same result as the calls run in order, at one - commit. The sentence groups the items that changed and appends the - ones that did not (already ticked, no match), so nothing is hidden. - kind is "changed" if any item changed, else "noop" if all were - no-ops, else "refuse". - """ - if len(items) == 1: - return apply_list_edit(text, op, items[0], section) - changed: list[str] = [] - notes: list[str] = [] - kinds: set[str] = set() - cur = text - for it in items: - cur, sentence, kind = apply_list_edit(cur, op, it, section) - kinds.add(kind) - if kind == "changed": - changed.append(sentence.split(": ", 1)[1]) - else: - notes.append(sentence) - parts: list[str] = [] - if changed: - parts.append(f"{_VERB[op]} {len(changed)}: {'; '.join(changed)}") - parts.extend(notes) - sentence = "; ".join(parts) - kind = "changed" if changed else ("refuse" if "refuse" in kinds else "noop") - return cur, sentence, kind - - -def run(args, stacklet, config): - parser = argparse.ArgumentParser(prog="stack memory list-edit", - add_help=False) - parser.add_argument("page") - parser.add_argument("--op", required=True, choices=list(_OPS)) - parser.add_argument("--item", action="append", default=[]) - parser.add_argument("--section", default=None) - parser.add_argument("--by", default="someone") - try: - ns = parser.parse_args(list(args or [])) - except SystemExit: - print(parser.format_usage().strip()) - sys.exit(2) - - repo_path = ns.page.strip().removeprefix("vault/").lstrip("/") - if not repo_path.endswith(".md"): - print(f"{repo_path!r} is not a page (expected a .md path)") - sys.exit(2) - actor = ns.by.strip().split(":")[0].lstrip("@") or "someone" - items = [i.strip() for i in ns.item if i.strip()] - if not items and ns.op not in ("clear-done", "reset"): - print("--item must name the item") - sys.exit(2) - - # The transform runs against the canonical page Forgejo hands back, - # not a local clone, so the match cannot lose a race with another - # writer. Refusals and no-ops return the page unchanged; an - # unchanged page does not commit. - outcome: dict = {} - - def _apply(prior: str) -> str: - if not (prior or "").strip(): - outcome["sentence"], outcome["kind"] = f"no such page: {repo_path}", "refuse" - return prior or "" - if ns.op in ("clear-done", "reset"): - new, sentence, kind = apply_list_edit(prior, ns.op, "", ns.section) - else: - new, sentence, kind = apply_list_edits(prior, ns.op, items, ns.section) - outcome["sentence"], outcome["kind"] = sentence, kind - return new - - result = update_memory( - config, repo_path, _apply, actor=actor, - message=lambda before, after: _commit_message( - actor, repo_path, outcome.get("sentence", "list edit")), - ) - if "error" in result: - return {"error": f"could not edit {repo_path}: {result['error']}"} - - sentence, kind = outcome.get("sentence", ""), outcome.get("kind", "") - if kind == "refuse": - print(sentence) - sys.exit(1) - if kind == "noop" or not result.get("committed"): - print(sentence or "no change") - return {"ok": True, "committed": False, "path": repo_path} - - # Same phrasing as `memory write`: the mirror lag is a delay, never - # a doubt, or the model re-runs the edit and duplicates it. The link - # line names the touched page in the wiki, for the reply's Sources. - lag = "" if result.get("mirrored") else "\n The wiki and the vault mount catch up shortly." - home_url = (config or {}).get("home_url", "") - url = public(go_page(repo_path), f"{home_url}/go" if home_url else "") - link = f"\n {url}" if url else "" - print(f"{sentence} (by {actor}){link}{lag}") - return {"ok": True, "committed": True, "path": repo_path, - "by": actor, "summary": sentence} diff --git a/stacklets/memory/cli/person.py b/stacklets/memory/cli/person.py index c3590f09..a613ef18 100644 --- a/stacklets/memory/cli/person.py +++ b/stacklets/memory/cli/person.py @@ -58,7 +58,7 @@ def _roots(config, override: str | None) -> list[Path]: Reading source alone therefore answered "no profile" for every member who had one, which is the state this command shipped in. Source is still checked first: a hand-curated page beats rebuildable - output. `_todos.py` consults both roots for the same reason. + output. """ if override: return [Path(override)] diff --git a/stacklets/memory/cli/topic.py b/stacklets/memory/cli/topic.py index 82bd680d..291ef2d7 100644 --- a/stacklets/memory/cli/topic.py +++ b/stacklets/memory/cli/topic.py @@ -1,88 +1,87 @@ -"""stack memory topic todo β€” a topic's todos, first-class like its link. +"""stack memory topic : a topic's overview, its wiki page. -Topics are first-class entities: the CLI noun and the `/go/topic/` link -share one model (`docs`, `topic`, `person`), so `stack memory topic camping -todo` mirrors `/go/topic/camping/todo`. The todos read is host-native β€” straight -off the vault on disk, no LLM, no bot-runner β€” so it works even when the model -is down. +Topics are first-class entities: the CLI noun and the `/go/topic/` +link share one model, so `stack memory topic camping` mirrors +`/go/topic/camping`. The read is host-native, straight off the brain +projection on disk, so it works even when the model is down. -Examples: + stack memory topic camping the topic's overview, as prose + stack memory topic the topics that have a page - stack memory topic itchy-scratchy-land todo - family/itchy-scratchy-land β€” 3 open, 1 done - - [ ] pick up the wristbands - - [ ] charge the camera - - [ ] pack a change of clothes for Maggie - - stack memory topic itchy-scratchy-land todo --all # include done items - stack memory topic # topics that have a list - -Todos are also mutable, attributed to the person who acted: - - stack memory topic itchy-scratchy-land todo add "book the campsite" --by homer - stack memory topic itchy-scratchy-land todo strike "charge the camera" --by homer - stack memory topic itchy-scratchy-land todo unstrike "charge the camera" --by homer - -`add` appends a new item (starting the list if the topic has none), `strike`/ -`unstrike` toggle a task's box; each commits to Forgejo (the vault's store) as -that person via `update_memory`, while the read stays host-native. `todo` is the -only resource today; the noun leaves room for more (notes, about) without -reshaping the command. +A topic's lists are read and changed with `stack memory list`. """ from __future__ import annotations +import re import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent)) -import _todos # noqa: E402 +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from lib import brain_path_for, refresh_vault_if_stale # noqa: E402 -HELP = "Inspect a topic (e.g. its todos)" +HELP = "Show a topic's overview" def run(args, stacklet, config): - argv = args or [] - by, argv = _extract_opt(argv, "--by") - show_all = "--all" in argv - positionals = [a for a in argv if not a.startswith("-")] - - # Grammar: ` todo [strike|unstrike ]`. The `todo` keyword - # splits the scope name from the verb+item; bare `topic` lists the topics - # that have a list. - if "todo" not in positionals: - if not positionals: - return _todos.list_todos("", show_all=show_all, config=config) - # `topic ` (no verb) shows the topic's about page, its overview. - return _todos.show_topic(" ".join(positionals).strip(), config=config) - - ti = positionals.index("todo") - scope = " ".join(positionals[:ti]).strip() - after = positionals[ti + 1:] - if not after: - return _todos.list_todos(scope, show_all=show_all, config=config) - - verb, item = after[0], " ".join(after[1:]).strip() - if verb in ("strike", "unstrike"): - return _todos.strike_todo(scope, item, done=(verb == "strike"), - actor=by or "someone", config=config) - if verb == "add": - return _todos.add_todo(scope, item, actor=by or "someone", config=config) - return {"error": f"unknown todo verb {verb!r}; use add, strike or unstrike"} - - -def _extract_opt(argv, flag): - """Pull `--flag value` out of argv, returning (value, remaining_argv). - - Needed because `--by ` carries a value, so the plain "non-dash - token is a positional" rule would otherwise treat the person as scope text. + words = [a for a in (args or []) if not a.startswith("-")] + if len(words) > 1 and words[1] in ("todo", "todos"): + return {"error": f"a topic's todos are a list now: " + f"stack memory list show {words[0]}"} + scope = " ".join(words).strip() + brain = _brain(config) + if brain is None or not brain.exists(): + return {"error": "no brain projection found; is the memory stacklet installed?"} + if not scope: + topics = _known_topics(brain) + print("\n".join(topics) if topics else "No topic pages yet.") + return {"topics": topics} + return show_topic(scope, config=config) + + +def _brain(config) -> Path | None: + data_dir = config.get("data_dir") if config else None + return brain_path_for(Path(data_dir)) if data_dir else None + + +def _known_topics(brain: Path) -> list[str]: + """Every topic slug that currently has an about.md in brain.""" + return sorted({ + p.parent.name for p in brain.glob("*/*/about.md") + if p.parent.parent != brain + }) + + +# Strip the machine bits so the about reads as prose: the frontmatter block, the +# generated-section markers the wiki writes, and citation markers like [8]. +_FRONTMATTER = re.compile(r"^---\n.*?\n---\n", re.DOTALL) +_GEN_MARKER = re.compile(r"\n?") +_CITE = re.compile(r"\[\d+(?:,\s*\d+)*\]") + + +def show_topic(scope: str, *, config) -> dict: + """Print a topic's brain about page -- its curated overview. + + `stack memory topic ` gives the topic's summary, so the agent can + answer "what is this / summarise it" without guessing the vault + path. Strips frontmatter, the generated-section markers, and citation markers + for a clean read; the raw page is `brain///about.md`. """ - out, value, i = [], None, 0 - while i < len(argv): - if argv[i] == flag and i + 1 < len(argv): - value = argv[i + 1] - i += 2 - continue - out.append(argv[i]) - i += 1 - return value, out + brain = _brain(config) + if brain is None or not brain.exists(): + return {"error": "no brain projection found; is the memory stacklet installed?"} + scope = (scope or "").strip() + + refresh_vault_if_stale(brain) + matches = sorted(brain.glob(f"*/{scope}/about.md")) + if not matches: + known = _known_topics(brain) + hint = (" topics: " + ", ".join(known)) if known else "" + return {"error": f"no page for topic {scope!r}\n{hint}".rstrip()} + + text = matches[0].read_text(encoding="utf-8") + text = _FRONTMATTER.sub("", text) + text = _GEN_MARKER.sub("", text) + text = _CITE.sub("", text) + print(text.strip()) + return {"ok": True, "scope": scope, "bucket": matches[0].parent.parent.name} diff --git a/stacklets/memory/cli/write.py b/stacklets/memory/cli/write.py index 6eac4b59..40ecfb21 100644 --- a/stacklets/memory/cli/write.py +++ b/stacklets/memory/cli/write.py @@ -174,7 +174,7 @@ def _replace(prior: str) -> str: # never legitimately reopens or removes an item (measured # 2026-09-15: rewrites drop [x] marks, and prompt rules do not # reliably repair them). Targeted edits go through --patch or - # the list-edit verb, which stay allowed. + # `stack memory list`, which stay allowed. if not as_patch and _is_list(repo_path): change = diff(seen["before"], seen["after"]) lost = list(change.reopened) + list(change.removed) diff --git a/tests/e2e/test_demo_rig_e2e.py b/tests/e2e/test_demo_rig_e2e.py index c9a913a8..579d26ca 100644 --- a/tests/e2e/test_demo_rig_e2e.py +++ b/tests/e2e/test_demo_rig_e2e.py @@ -381,7 +381,7 @@ async def test_demo_rig_memory_todos_stay_mutable_and_capture_items_project( server_name, scope, ): - """Topic todos read/write from memory and capture action items project.""" + """A topic's list reads and writes through memory, and capture action items land on it.""" topic = f"demo-rig-todo-{scope.uid}" item = f"verify B2 todo memory write {scope.uid}" fresh_item = f"verify B4 read-your-writes todo {scope.uid}" @@ -404,40 +404,41 @@ async def test_demo_rig_memory_todos_stay_mutable_and_capture_items_project( marge.user_id = marge_creds.user_id try: - bdd.given("A unique demo-rig topic with no todo list") + bdd.given("A unique demo-rig topic with no list") assert demo_code.get_file(MEMORY_OWNER, MEMORY_REPO, path) is None - bdd.when("Homer adds a todo through the live stack CLI") - add = _run_stack( - "memory", "topic", topic, "todo", "add", item, "--by", "homer", + bdd.when("Homer starts the topic's list and adds an item through the CLI") + create = _run_stack( + "memory", "list", "create", topic, "--title", topic, "--topic", topic, + "--as", "homer", ) + assert create.returncode == 0, create.stderr or create.stdout + add = _run_stack("memory", "list", "add", topic, item, "--as", "homer") assert add.returncode == 0, add.stderr or add.stdout - assert "Added:" in add.stdout + assert "added" in add.stdout - bdd.then("family/memory contains the todo document") + bdd.then("family/memory contains the list with the item") f = demo_code.get_file(MEMORY_OWNER, MEMORY_REPO, path) - assert f, f"missing {path} after todo add" + assert f, f"missing {path} after list add" assert f"- [ ] {item}" in f["content"] - bdd.and_("The host-native todo reader lists the open item") - listed = _run_stack("memory", "topic", topic, "todo") + bdd.and_("The host-native reader lists the open item") + listed = _run_stack("memory", "list", "show", topic) assert listed.returncode == 0, listed.stderr or listed.stdout assert f"- [ ] {item}" in listed.stdout - bdd.when("Homer strikes the todo through the live stack CLI") - struck = _run_stack( - "memory", "topic", topic, "todo", "strike", item, "--by", "homer", - ) - assert struck.returncode == 0, struck.stderr or struck.stdout - assert "Struck:" in struck.stdout + bdd.when("Homer ticks the item off through the CLI") + ticked = _run_stack("memory", "list", "tick", topic, item, "--as", "homer") + assert ticked.returncode == 0, ticked.stderr or ticked.stdout + assert "ticked off" in ticked.stdout - bdd.then("The canonical memory todo is checked off") + bdd.then("The canonical list has the item ticked") f = demo_code.get_file(MEMORY_OWNER, MEMORY_REPO, path) - assert f, f"missing {path} after todo strike" + assert f, f"missing {path} after tick" assert f"- [x] {item}" in f["content"] - bdd.and_("The reader can include done items") - listed_all = _run_stack("memory", "topic", topic, "todo", "--all") + bdd.and_("The reader can include ticked items") + listed_all = _run_stack("memory", "list", "show", topic, "--all") assert listed_all.returncode == 0, listed_all.stderr or listed_all.stdout assert f"- [x] {item}" in listed_all.stdout @@ -535,12 +536,10 @@ async def test_demo_rig_memory_todos_stay_mutable_and_capture_items_project( assert "begin: generated" not in shown.stdout assert "generated: true" not in shown.stdout - bdd.and_("Topic todo still reflects an immediate memory write") - add_fresh = _run_stack( - "memory", "topic", topic, "todo", "add", fresh_item, "--by", "homer", - ) + bdd.and_("The list still reflects an immediate memory write") + add_fresh = _run_stack("memory", "list", "add", topic, fresh_item, "--as", "homer") assert add_fresh.returncode == 0, add_fresh.stderr or add_fresh.stdout - listed_fresh = _run_stack("memory", "topic", topic, "todo") + listed_fresh = _run_stack("memory", "list", "show", topic) assert listed_fresh.returncode == 0, listed_fresh.stderr or listed_fresh.stdout assert f"- [ ] {fresh_item}" in listed_fresh.stdout finally: diff --git a/tests/unit/stacklets/test_memory_cli.py b/tests/unit/stacklets/test_memory_cli.py index 85880a18..0f085dd9 100644 --- a/tests/unit/stacklets/test_memory_cli.py +++ b/tests/unit/stacklets/test_memory_cli.py @@ -22,7 +22,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "stacklets" / "memory" / "cli")) -import _todos # noqa: E402 +import topic as topic_cli # noqa: E402 class TestCheck: @@ -121,10 +121,10 @@ def test_shows_every_vocabulary_block_and_which_prompts_read_it(self, stack_cli) class TestTopicReaderRouting: - """Bare topic reads brain; todo reads stay on source memory.""" + """A topic's overview is read from the brain projection.""" def test_bare_topic_reads_about_page_from_brain(self, tmp_path, capsys, monkeypatch): - monkeypatch.setattr(_todos, "refresh_vault_if_stale", lambda path: "up_to_date") + monkeypatch.setattr(topic_cli, "refresh_vault_if_stale", lambda path: "up_to_date") vault = tmp_path / "memory" / "vault" / "family" / "camping" brain = tmp_path / "memory" / "brain" / "family" / "camping" vault.mkdir(parents=True) @@ -141,7 +141,7 @@ def test_bare_topic_reads_about_page_from_brain(self, tmp_path, capsys, monkeypa encoding="utf-8", ) - result = _todos.show_topic("camping", config={"data_dir": str(tmp_path)}) + result = topic_cli.show_topic("camping", config={"data_dir": str(tmp_path)}) assert result == {"ok": True, "scope": "camping", "bucket": "family"} out = capsys.readouterr().out @@ -149,16 +149,8 @@ def test_bare_topic_reads_about_page_from_brain(self, tmp_path, capsys, monkeypa assert "[1]" not in out assert "source memory should not be read" not in out - def test_todo_reader_still_reads_memory_source(self, tmp_path, capsys, monkeypatch): - monkeypatch.setattr(_todos, "refresh_vault_if_stale", lambda path: "up_to_date") - todos = tmp_path / "memory" / "vault" / "family" / "camping" / "todos.md" - todos.parent.mkdir(parents=True) - todos.write_text("# Camping\n\n- [ ] Pack the stove\n", encoding="utf-8") + def test_the_old_todo_verb_points_at_the_topics_list(self, tmp_path): + """`topic todo` was the way to a topic's todos; it says where they went.""" + result = topic_cli.run(["camping", "todo"], None, {"data_dir": str(tmp_path)}) + assert result == {"error": "a topic's todos are a list now: stack memory list show camping"} - result = _todos.list_todos( - "camping", show_all=False, config={"data_dir": str(tmp_path)}, - ) - - assert result["ok"] is True - assert result["lists"][0]["open"] == ["Pack the stove"] - assert "- [ ] Pack the stove" in capsys.readouterr().out diff --git a/tests/unit/stacklets/test_memory_list_edit.py b/tests/unit/stacklets/test_memory_list_edit.py deleted file mode 100644 index 9fa4507a..00000000 --- a/tests/unit/stacklets/test_memory_list_edit.py +++ /dev/null @@ -1,173 +0,0 @@ -"""`stack memory list-edit` β€” one item changes, nothing else moves. - -The verb exists because whole-page rewrites lose state the caller never -meant to touch: in the agent lab, a category restructure dropped an item's -`[x]` mark, and prompt rules did not reliably repair it. The contract under -test: the caller only names an item; the store finds it, changes exactly -that line, and answers with what happened. Ambiguity and misses are -instructive refusals, never guesses, because a wrong guess strikes a -family member's item silently. - -These tests drive the pure transform through its public shape -(text in, text out, sentence, kind) β€” the same calls the CLI makes. -""" - -from __future__ import annotations - -import importlib.util -import sys -from pathlib import Path - -_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" / "memory")) -sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory" / "cli")) - - -def _load(name: str, path: Path): - spec = importlib.util.spec_from_file_location(name, path) - module = importlib.util.module_from_spec(spec) - sys.modules[name] = module - spec.loader.exec_module(module) - return module - - -list_edit = _load( - "memory_cli_list_edit", - _REPO_ROOT / "stacklets" / "memory" / "cli" / "list-edit.py", -) - -PAGE = """--- -type: note -date: 2026-09-14 ---- - -# Shopping list - -## Dairy - -- [ ] oat milk -- [x] coffee beans - -## Household - -- [ ] dish soap -""" - - -def test_tick_by_partial_name_changes_only_that_line(): - new, sentence, kind = list_edit.apply_list_edit(PAGE, "tick", "oat") - assert kind == "changed" - assert sentence == "ticked off 1: oat milk" - assert "- [x] oat milk" in new - # Everything else is untouched, byte for byte. - assert new.replace("- [x] oat milk", "- [ ] oat milk") == PAGE - - -def test_ambiguous_name_is_refused_and_names_the_candidates(): - page = PAGE + "- [ ] oat cookies\n" - new, sentence, kind = list_edit.apply_list_edit(page, "tick", "oat") - assert kind == "refuse" - assert new == page - assert "oat milk" in sentence and "oat cookies" in sentence - - -def test_exact_match_wins_over_substring(): - page = PAGE + "- [ ] milk\n" - new, sentence, kind = list_edit.apply_list_edit(page, "tick", "milk") - assert kind == "changed" - assert sentence == "ticked off 1: milk" - assert "- [ ] oat milk" in new - - -def test_unknown_item_is_refused_and_lists_what_is_open(): - new, sentence, kind = list_edit.apply_list_edit(PAGE, "tick", "bananas") - assert kind == "refuse" - assert new == PAGE - assert "oat milk" in sentence and "dish soap" in sentence - # Done items are not offered as candidates for a tick. - assert "coffee beans" not in sentence - - -def test_add_lands_in_the_named_section(): - new, sentence, kind = list_edit.apply_list_edit( - PAGE, "add", "butter", section="Dairy") - assert kind == "changed" - assert sentence == "added 1: butter" - dairy = new.split("## Dairy")[1].split("## Household")[0] - assert "- [ ] butter" in dairy - - -def test_add_duplicate_is_an_honest_noop(): - new, sentence, kind = list_edit.apply_list_edit(PAGE, "add", "Oat Milk") - assert kind == "noop" - assert new == PAGE - assert "already on the list" in sentence - - -def test_remove_names_what_it_destroyed(): - new, sentence, kind = list_edit.apply_list_edit(PAGE, "remove", "dish soap") - assert kind == "changed" - assert sentence == "REMOVED 1: dish soap" - assert "dish soap" not in new - - -def test_clear_done_removes_only_ticked_items_and_names_them(): - page = PAGE.replace("- [ ] dish soap", "- [x] dish soap") - new, sentence, kind = list_edit.apply_list_edit(page, "clear-done", "") - assert kind == "changed" - assert sentence == "REMOVED 2: coffee beans; dish soap" - assert "coffee beans" not in new and "dish soap" not in new - assert "- [ ] oat milk" in new - - -def test_clear_done_on_a_clean_list_is_a_noop(): - page = PAGE.replace("- [x] coffee beans", "- [ ] coffee beans") - new, sentence, kind = list_edit.apply_list_edit(page, "clear-done", "") - assert kind == "noop" - assert new == page - - -def test_reset_reopens_every_ticked_item(): - new, sentence, kind = list_edit.apply_list_edit(PAGE, "reset", "") - assert kind == "changed" - assert sentence == "reopened 1: coffee beans" - assert "- [x]" not in new - - -def test_untick_reports_a_reopening(): - new, sentence, kind = list_edit.apply_list_edit(PAGE, "untick", "coffee") - assert kind == "changed" - assert sentence == "reopened 1: coffee beans" - assert "- [ ] coffee beans" in new - - -def test_batch_add_is_one_change_naming_every_item(): - new, sentence, kind = list_edit.apply_list_edits( - PAGE, "add", ["butter", "flour"]) - assert kind == "changed" - assert sentence == "added 2: butter; flour" - assert "- [ ] butter" in new and "- [ ] flour" in new - - -def test_batch_tick_across_the_page(): - new, sentence, kind = list_edit.apply_list_edits( - PAGE, "tick", ["oat milk", "dish soap"]) - assert kind == "changed" - assert sentence == "ticked off 2: oat milk; dish soap" - assert "- [x] oat milk" in new and "- [x] dish soap" in new - - -def test_batch_reports_changed_and_misses_together(): - new, sentence, kind = list_edit.apply_list_edits( - PAGE, "tick", ["oat milk", "bananas"]) - assert kind == "changed" - assert "ticked off 1: oat milk" in sentence - assert "no item matching 'bananas'" in sentence - assert "- [x] oat milk" in new - - -def test_batch_of_one_matches_the_single_form(): - a = list_edit.apply_list_edits(PAGE, "tick", ["oat milk"]) - b = list_edit.apply_list_edit(PAGE, "tick", "oat milk") - assert a == b diff --git a/tests/unit/stacklets/test_todo_list.py b/tests/unit/stacklets/test_todo_list.py deleted file mode 100644 index d348a5b3..00000000 --- a/tests/unit/stacklets/test_todo_list.py +++ /dev/null @@ -1,206 +0,0 @@ -"""Detecting explicitly-written todo lists, and maintaining `todos.md`. - -Detection is deliberately narrow: a note becomes a list only when its first -line *announces* it ("Liste …:", "Todo:"). Prose never becomes a list β€” that is -the guard against manufacturing household todos, the same stance the capture -classifier takes by leaving `action_items` out (see test_capture_prompt.py). -Zero LLM, so there is no over-eager model to misfire. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent - / "stacklets" / "memory" / "bot" / "cli")) - -import pytest # noqa: E402 - -from todo_list import ( # noqa: E402 - add_items, - detect_list, - read_todos, - render_todo_doc, - set_todo_done, - update_todo_doc, -) - - -class TestDetectList: - - def test_german_marker_with_colon(self): - body = ("Liste Bus Erweiterungen:\n" - "Fenster Tasche\nWΓ€nde fΓΌr die Markise\nKochlΓΆffel") - title, items = detect_list(body) - assert title == "Liste Bus Erweiterungen" - assert items == ["Fenster Tasche", "WΓ€nde fΓΌr die Markise", "KochlΓΆffel"] - - def test_todo_marker(self): - title, items = detect_list("Todo:\nbuy milk\nbook tickets") - assert title == "Todo" - assert items == ["buy milk", "book tickets"] - - def test_marker_without_colon(self): - title, items = detect_list("Einkaufsliste\nMilch\nBrot") - assert title == "Einkaufsliste" - assert items == ["Milch", "Brot"] - - def test_blank_lines_ignored(self): - _, items = detect_list("Todo:\n\nbuy milk\n\n\nbook tickets\n") - assert items == ["buy milk", "book tickets"] - - # ── the false-positive guards ── - - def test_prose_is_not_a_list(self): - # The Reddit-thread / manufacture-a-todo case. - assert detect_list( - "We had a great time camping this weekend. Bart loved it.") is None - - def test_status_message_is_not_a_list(self): - assert detect_list( - "Fenstertasche ist bestellt, Thema 1 kann abgehakt werden") is None - - def test_question_is_not_a_list(self): - assert detect_list("Welche Themen sind noch auf der Liste?") is None - - def test_marker_but_no_items(self): - assert detect_list("Todo:") is None - - def test_empty(self): - assert detect_list("") is None - - -class TestRenderTodoDoc: - - def test_fresh_doc_is_obsidian_tasks(self): - doc = render_todo_doc("Bus Erweiterungen", ["Fenster Tasche", "KochlΓΆffel"]) - assert doc.startswith("# Bus Erweiterungen\n") - assert "- [ ] Fenster Tasche" in doc - assert "- [ ] KochlΓΆffel" in doc - - -class TestAddItems: - - def test_appends_new_items(self): - existing = "# Bus\n\n- [ ] Fenster Tasche\n" - out = add_items(existing, ["KochlΓΆffel", "Dachbox"]) - assert "- [ ] KochlΓΆffel" in out - assert "- [ ] Dachbox" in out - assert out.count("Fenster Tasche") == 1 - - def test_skips_duplicate_open_items(self): - existing = "# Bus\n\n- [ ] Fenster Tasche\n" - assert add_items(existing, ["Fenster Tasche"]).count("Fenster Tasche") == 1 - - def test_preserves_done_items(self): - existing = "# Bus\n\n- [x] Fenster Tasche\n- [ ] Dachbox\n" - out = add_items(existing, ["KochlΓΆffel"]) - assert "- [x] Fenster Tasche" in out - assert "- [ ] KochlΓΆffel" in out - - def test_does_not_resurrect_a_done_item(self): - # Re-sending an item she already ticked off must not re-open it. - existing = "# Bus\n\n- [x] Fenster Tasche\n" - out = add_items(existing, ["Fenster Tasche"]) - assert "- [ ] Fenster Tasche" not in out - assert out.count("Fenster Tasche") == 1 - - -class TestUpdateTodoDoc: - - def test_creates_when_missing(self): - out = update_todo_doc(None, "Bus", ["Fenster Tasche"]) - assert out.startswith("# Bus\n") - assert "- [ ] Fenster Tasche" in out - - def test_appends_when_present(self): - existing = "# Bus\n\n- [ ] Fenster Tasche\n" - out = update_todo_doc(existing, "Bus", ["KochlΓΆffel"]) - assert "- [ ] Fenster Tasche" in out - assert "- [ ] KochlΓΆffel" in out - - -class TestReadTodos: - """The read side the CLI list command goes through: a rendered - `todos.md` split into open and done task texts, ignoring the title - and blank lines.""" - - def test_splits_open_and_done(self): - doc = ("# Bus\n\n" - "- [ ] Fenster Tasche\n" - "- [x] KochlΓΆffel\n" - "- [X] Markise\n") - open_items, done_items = read_todos(doc) - assert open_items == ["Fenster Tasche"] - assert done_items == ["KochlΓΆffel", "Markise"] - - def test_ignores_title_and_prose(self): - doc = "# Einkauf\n\nSome note.\n- [ ] Milch\n" - assert read_todos(doc) == (["Milch"], []) - - def test_empty_doc(self): - assert read_todos("# Bus\n") == ([], []) - - -class TestSetTodoDone: - DOC = ( - "# Itchy & Scratchy Land\n\n" - "- [ ] pick up the wristbands\n" - "- [ ] buy sunscreen for Bart\n" - "- [x] charge the camera\n" - ) - - def test_strike_open_item(self): - new, matched = set_todo_done(self.DOC, "buy sunscreen for Bart", done=True) - assert matched == "buy sunscreen for Bart" - assert "- [x] buy sunscreen for Bart\n" in new - # every other line is left exactly as it was - assert "- [ ] pick up the wristbands\n" in new - assert "- [x] charge the camera\n" in new - - def test_unstrike_done_item(self): - new, matched = set_todo_done(self.DOC, "charge the camera", done=False) - assert matched == "charge the camera" - assert "- [ ] charge the camera\n" in new - - def test_prefix_match(self): - new, matched = set_todo_done(self.DOC, "buy sunscreen", done=True) - assert matched == "buy sunscreen for Bart" - assert "- [x] buy sunscreen for Bart\n" in new - - def test_mid_word_string_is_not_a_prefix(self): - # "sunscreen" is inside a task but does not start it -> no match - with pytest.raises(ValueError, match="no todo matching"): - set_todo_done(self.DOC, "sunscreen", done=True) - - def test_exact_wins_over_longer_sibling(self): - doc = "# L\n- [ ] milk\n- [ ] milk and eggs\n" - new, matched = set_todo_done(doc, "milk", done=True) - assert matched == "milk" - assert new == "# L\n- [x] milk\n- [ ] milk and eggs\n" - - def test_prefers_the_state_changing_task(self): - # same text open and done: striking targets the open one, not the done - doc = "# L\n- [ ] milk\n- [x] milk\n" - new, matched = set_todo_done(doc, "milk", done=True) - assert matched == "milk" - assert new == "# L\n- [x] milk\n- [x] milk\n" - - def test_no_match_raises(self): - with pytest.raises(ValueError, match="no todo matching"): - set_todo_done(self.DOC, "walk the dog", done=True) - - def test_ambiguous_prefix_lists_the_matches(self): - doc = "# L\n- [ ] call the dentist\n- [ ] call the vet\n" - with pytest.raises(ValueError) as ei: - set_todo_done(doc, "call", done=True) - msg = str(ei.value) - assert "more than one match" in msg - assert "call the dentist" in msg and "call the vet" in msg - - def test_striking_a_done_item_is_a_noop(self): - # already `[x]`: the doc comes back unchanged, so edit_file makes no commit - new, matched = set_todo_done(self.DOC, "charge the camera", done=True) - assert new == self.DOC - assert matched == "charge the camera" From d84ade22c943020a8d2bb961ecea65f9b1b61b02 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 12 Oct 2026 11:20:53 +0200 Subject: [PATCH 06/17] feat(memory): archive ticked list items the night after A list should show what is still to do or to buy. A ticked item now stays on its list for the rest of the day ("did we get eggs?") and moves to the list's archive on the nightly sweep: - The archive is one file per list and month, `/lists/archive//.md`, in the same format, with each item's section, people and dates kept. - A tick made by hand in Obsidian or Forgejo, without a date, gets the day's date first and leaves the night after. - A list created with `--recurring` (a packing list, the weekly chores) keeps its ticks until `stack memory list reset`. - Everything moves in one commit by the memory bot, against the version of each file it read, so a tick made meanwhile is never lost. Refs: #194 --- docs/design/brain/vault-format.md | 11 +- lib/stack/frontmatter.py | 2 +- lib/stack/lists.py | 65 ++++++++- .../workspace/skills/family-memory/SKILL.md | 1 + stacklets/memory/bot/cli/lists.py | 123 ++++++++++++++++++ stacklets/memory/bot/cli_entrypoint.py | 8 +- stacklets/memory/bot/curator.py | 11 +- stacklets/memory/cli/list.py | 5 +- tests/unit/framework/test_lists.py | 64 +++++++++ .../stacklets/test_memory_list_archive.py | 79 +++++++++++ 10 files changed, 362 insertions(+), 7 deletions(-) create mode 100644 stacklets/memory/bot/cli/lists.py create mode 100644 tests/unit/stacklets/test_memory_list_archive.py diff --git a/docs/design/brain/vault-format.md b/docs/design/brain/vault-format.md index 5e8c254b..2f4fc250 100644 --- a/docs/design/brain/vault-format.md +++ b/docs/design/brain/vault-format.md @@ -275,6 +275,7 @@ name is the slug. A state document (Β§7), changed by hand or with | `description` | O | what goes on it ("Was wir im Supermarkt brauchen"); the agent picks a list by it. | | `topics` | O | list; the topics the list belongs to, by slug. | | `sections` | O | list; the sections in the order the family walks through the store. | +| `recurring` | O | `true` for a list used again and again (a packing list, the weekly chores): its ticked items stay until it is reset. | ``` --- @@ -316,7 +317,15 @@ the end of the line: Only the trailing run of these counts. A link inside the sentence, a web link and any other field stay part of the text. The same line is -used wherever an item appears, also in a record's summary. +used wherever an item appears; a record's summary writes it without the +checkbox, because the list holds whether it is done. + +A ticked item stays on its list for the rest of the day. The night after, +the curator moves it to the list's archive, +`/lists/archive//.md` for the month it was ticked: +a list file of its own (`title: Einkaufsliste, 2026-10`, no topics), with +the line and its section as they were. A tick without a date gets the +day's date first. A `recurring` list is left alone. ## 6. The `generated` invariant (from B3 / ADR-011) diff --git a/lib/stack/frontmatter.py b/lib/stack/frontmatter.py index 04b307fa..ae0a21a6 100644 --- a/lib/stack/frontmatter.py +++ b/lib/stack/frontmatter.py @@ -91,7 +91,7 @@ class TypeSchema: "list": TypeSchema( required={"type", "title"}, list_fields={"topics", "sections"}, - scalar_fields={"title", "description"}, + scalar_fields={"title", "description", "recurring"}, ), } diff --git a/lib/stack/lists.py b/lib/stack/lists.py index 5cfdc3db..3da1fb86 100644 --- a/lib/stack/lists.py +++ b/lib/stack/lists.py @@ -55,6 +55,7 @@ from .people import person_path LISTS_DIR = "lists" +ARCHIVE_DIR = "archive" DUE = "πŸ“…" DONE = "βœ…" @@ -234,6 +235,7 @@ class FamilyList: topics: list[str] = field(default_factory=list) sections: list[str] = field(default_factory=list) items: list[Item] = field(default_factory=list) + recurring: bool = False @property def open_items(self) -> list[Item]: @@ -288,14 +290,22 @@ def read_list(text: str, *, path: str) -> FamilyList: topics=list(fm.get("topics") or []), sections=list(fm.get("sections") or []), items=items, + recurring=fm.get("recurring") is True, ) def new_list(title: str, *, description: str = "", topics: list[str] | None = None, - sections: list[str] | None = None) -> str: - """The text of a new, empty list file.""" + sections: list[str] | None = None, recurring: bool = False) -> str: + """The text of a new, empty list file. + + A recurring list (a packing list, the weekly chores) keeps its ticked + items, so it can be reopened whole; every other list hands them to + its archive the night after. + """ fm = {"type": "list", "title": title, "description": description, "topics": list(topics or []), "sections": list(sections or [])} + if recurring: + fm["recurring"] = True return f"---\n{dump(fm)}\n---\n" @@ -438,6 +448,57 @@ def edit(lines: list[str], at: int = item.line, line: str = format_item(moved, p return text +# ── The archive ────────────────────────────────────────────────────────── +# +# A ticked item stays on its list for the rest of the day ("did we get +# eggs?") and moves to the list's archive the night after, one file per +# month: `/lists/archive//.md`. The list then holds +# only what is still open. The archive keeps the line as it was, section +# included, and is where the household's known items come from. + +def archive_path(slug: str, month: str, *, bucket: str) -> str: + """The archive file for the items of list `slug` ticked in `month` (`YYYY-MM`).""" + return f"{bucket}/{LISTS_DIR}/{ARCHIVE_DIR}/{slug}/{month}.md" + + +def stamp_ticks(text: str, *, path: str, today: str) -> str: + """The list text with today's date on every ticked item that has none. + + An item ticked by hand in Obsidian or Forgejo carries no `βœ…` date; + stamping it on the first night gives it the day after on the list, + like any other tick. + """ + for item in read_list(text, path=path).items: + if item.done and not item.done_on: + text = set_done(text, item, done=True, today=today, path=path) + return text + + +def archive_ticked(text: str, archives: dict[str, str], *, path: str, today: str, + bucket: str) -> tuple[str, dict[str, str], list[Item]]: + """Move the items ticked before `today` from a list into its archive. + + `archives` maps an archive path to its current text ("" or missing + for a month with no file yet). Returns the list text, the archive + texts that changed, and the items moved. An item ticked today stays, + and a recurring list keeps every tick until it is reset. + """ + lst = read_list(text, path=path) + if lst.recurring: + return text, {}, [] + moving = [i for i in lst.items if i.done and i.done_on and i.done_on < today] + changed: dict[str, str] = {} + for item in moving: + target = archive_path(lst.slug, item.done_on[:7], bucket=bucket) + current = changed.get(target) or archives.get(target) or \ + new_list(f"{lst.title}, {item.done_on[:7]}", sections=lst.sections) + changed[target] = insert_item(current, item, path=target) + # Last line first, so removing one item never moves the lines of the next. + for item in sorted(moving, key=lambda i: i.line, reverse=True): + text = remove_item(text, item) + return text, changed, moving + + # ── File text ──────────────────────────────────────────────────────────── def _split(text: str) -> tuple[str, str]: diff --git a/stacklets/agent/workspace/skills/family-memory/SKILL.md b/stacklets/agent/workspace/skills/family-memory/SKILL.md index d337a555..1e9d5d8e 100644 --- a/stacklets/agent/workspace/skills/family-memory/SKILL.md +++ b/stacklets/agent/workspace/skills/family-memory/SKILL.md @@ -32,6 +32,7 @@ SOURCES (answer used a vault page, searched or read): LISTS: item op add|tick|untick|remove -> list_edit(list, op, items=[one or more]) "we did that" -> tick, never remove + "bought all|clear the list" -> tick what they got; ticked items leave the list overnight "start the week|fresh list" -> list_edit op=reset # reopens all belongs on another list -> op=move, to= add to a list with sections -> section = one of the list's own diff --git a/stacklets/memory/bot/cli/lists.py b/stacklets/memory/bot/cli/lists.py new file mode 100644 index 00000000..b9537e86 --- /dev/null +++ b/stacklets/memory/bot/cli/lists.py @@ -0,0 +1,123 @@ +"""Archive the ticked items of every list, on the nightly sweep. + +An item ticked today stays on its list for the rest of the day, so +"did we get eggs?" still has an answer in the store. The night after, +it moves to the list's archive, `/lists/archive//.md`, +and the list holds only what is still open. Ticks made by hand without +a date get today's date first, and leave the night after. + +Everything moves in one commit by the memory bot, written to Forgejo +against the hash each file was read at, so a tick made while this runs +fails the commit instead of being lost; the next night tries again. + + lists [--dry-run] + +No model is involved; the command lives with the curator's other +nightly work because that is where the night is kept. +""" + +from __future__ import annotations + +import os +import sys +from datetime import datetime, timezone +from pathlib import Path +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +sys.path.insert(0, "/app") # stack.forgejo, stack.lists +# `memory.lib`, for the repository's name and the bot's identity. +sys.path.append("/stacklets") +sys.path.append(str(Path(__file__).resolve().parents[3])) + +from stack.forgejo import FileChange, ForgejoClient, ForgejoError # noqa: E402 +from stack.lists import ( # noqa: E402 + LISTS_DIR, + ListError, + archive_path, + archive_ticked, + read_list, + stamp_ticks, +) + +from memory.lib import BOT_EMAIL, BOT_USERNAME, REPO_NAME, REPO_OWNER # noqa: E402 + + +def _err(msg: str) -> None: + print(msg, file=sys.stderr) + + +async def run(llm, argv: list[str]) -> int: + client = _client() + if client is None: + _err("no Forgejo address or admin credentials here; nothing archived") + return 1 + bucket = os.environ.get("SHARED_BUCKET") or "family" + try: + changes, moved = archive_lists(client, bucket=bucket, today=_today()) + if changes and "--dry-run" not in argv: + client.change_files(REPO_OWNER, REPO_NAME, changes, + message=f"lists: archive {moved} ticked item(s)", + author_name=BOT_USERNAME, author_email=BOT_EMAIL) + except (ForgejoError, OSError) as e: + _err(f"lists not archived: {e}") + return 1 + print(f"archived {moved} ticked item(s)" if moved else "nothing to archive") + return 0 + + +def archive_lists(client: ForgejoClient, *, bucket: str, + today: str) -> tuple[list[FileChange], int]: + """The file changes that archive every list's ticked items, and how many moved. + + A file that is not a list is reported and left alone. + """ + changes: list[FileChange] = [] + moved = 0 + for entry in client.list_dir(REPO_OWNER, REPO_NAME, f"{bucket}/{LISTS_DIR}"): + path = str(entry.get("path", "")) + if entry.get("type") != "file" or not path.endswith(".md"): + continue + current = client.get_file(REPO_OWNER, REPO_NAME, path) or {} + text = current.get("content") or "" + try: + stamped = stamp_ticks(text, path=path, today=today) + months = {i.done_on[:7] for i in read_list(stamped, path=path).items + if i.done and i.done_on and i.done_on < today} + except ListError as e: + _err(f" {e}; left alone") + continue + slug = Path(path).stem + archives, shas = {}, {} + for month in sorted(months): + target = archive_path(slug, month, bucket=bucket) + if found := client.get_file(REPO_OWNER, REPO_NAME, target): + archives[target], shas[target] = found.get("content") or "", found.get("sha", "") + after, written, items = archive_ticked(stamped, archives, path=path, + today=today, bucket=bucket) + if after != text: + changes.append(FileChange("update", path, after, sha=current.get("sha", ""))) + for target, content in written.items(): + changes.append(FileChange("update" if target in shas else "create", + target, content, sha=shas.get(target, ""))) + if items: + print(f" {slug}: {'; '.join(i.text for i in items)}") + moved += len(items) + return changes, moved + + +def _client() -> ForgejoClient | None: + 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 _today() -> str: + """Today in the household's clock, so midnight is the family's midnight.""" + try: + zone = ZoneInfo(os.environ.get("TIMEZONE", "").strip() or "UTC") + except (ZoneInfoNotFoundError, ValueError): + zone = timezone.utc + return datetime.now(zone).date().isoformat() diff --git a/stacklets/memory/bot/cli_entrypoint.py b/stacklets/memory/bot/cli_entrypoint.py index 9db5ebc5..0fcdc36e 100644 --- a/stacklets/memory/bot/cli_entrypoint.py +++ b/stacklets/memory/bot/cli_entrypoint.py @@ -30,6 +30,11 @@ new costs anything. `--force` ignores those and reads everything again, for when the model has improved. + lists [--dry-run] + Move the items ticked before today from every list into its + archive, a file per month under `lists/archive//`. The + curator runs it on the nightly sweep. No model involved. + wiki [--home] [--member ]... [--topic ]... [--dry-run] Regenerate the family wiki's entry pages. Apply by default; `--dry-run` previews to stdout. Bare invocation regenerates @@ -50,12 +55,13 @@ from stack.ai.client import LLM, LLMUnavailableError -from cli import answer, diary, rewrite, wiki +from cli import answer, diary, lists, rewrite, wiki _HANDLERS = { "answer": answer.run, "diary": diary.run, + "lists": lists.run, "rewrite": rewrite.run, "wiki": wiki.run, } diff --git a/stacklets/memory/bot/curator.py b/stacklets/memory/bot/curator.py index 2aa97224..fd18dace 100644 --- a/stacklets/memory/bot/curator.py +++ b/stacklets/memory/bot/curator.py @@ -793,6 +793,11 @@ async def compile_diary(state_dir: Path | None = None) -> bool: return await _run_command("diary", [], "compiling the diary", state_dir) +async def archive_lists(state_dir: Path | None = None) -> bool: + """Move yesterday's ticked list items into each list's archive.""" + return await _run_command("lists", [], "archiving ticked list items", state_dir) + + async def rebuild(selection: list[str], state_dir: Path | None = None) -> bool: """One wiki generation pass via the CLI entrypoint β€” the same code path `stack memory wiki update` execs, in a subprocess so a wedged @@ -1028,7 +1033,11 @@ async def mirror_reconcile() -> bool: _progress(state_dir, "reconciling the source vault with the brain") if await mirror_reconcile(): mirror_sha = _write(mirror_file, head) - # Diary first: it writes pages into the same working tree, + # Lists first: the night's archive commit then reaches brain + # with the next mirror, and the pages built below read lists + # that hold only what is open. + await archive_lists(state_dir) + # Diary next: it writes pages into the same working tree, # and the commit below should carry both nights' work. await compile_diary(state_dir) rebuilt = await rebuild([], state_dir) diff --git a/stacklets/memory/cli/list.py b/stacklets/memory/cli/list.py index 31c62d8d..387f36be 100644 --- a/stacklets/memory/cli/list.py +++ b/stacklets/memory/cli/list.py @@ -66,6 +66,8 @@ --due 2026-10-20 --as marge stack memory list tick|untick|remove einkaufsliste Joghurt --as homer stack memory list move einkaufsliste Eier --to markt --as marge + stack memory list create packliste --title Packliste --recurring --as marge + ticked items stay until a reset stack memory list reset packliste --as marge reopen every ticked item """ @@ -98,6 +100,7 @@ def _parser() -> argparse.ArgumentParser: create.add_argument("--description", default="") create.add_argument("--topic", action="append", default=[]) create.add_argument("--section", action="append", default=[]) + create.add_argument("--recurring", action="store_true") add = verbs.add_parser("add", add_help=False) add.add_argument("slug") @@ -238,7 +241,7 @@ def create(vault: Path, config, ns, actor: str) -> dict: raise Refusal(f"the list {slug!r} already exists") path = list_path(slug, bucket=_shared_bucket(config)) text = new_list(ns.title.strip(), description=ns.description.strip(), - topics=ns.topic, sections=ns.section) + topics=ns.topic, sections=ns.section, recurring=ns.recurring) def _create(prior: str) -> str: if (prior or "").strip(): diff --git a/tests/unit/framework/test_lists.py b/tests/unit/framework/test_lists.py index d04a07f4..3ebe8dbd 100644 --- a/tests/unit/framework/test_lists.py +++ b/tests/unit/framework/test_lists.py @@ -22,6 +22,9 @@ from stack.lists import ( Item, Link, + archive_path, + archive_ticked, + stamp_ticks, ListError, format_item, insert_item, @@ -199,3 +202,64 @@ def test_a_renamed_record_keeps_its_items_linked(): assert read_list(moved, path=GROCERIES).items[0].sources == (new,) assert "πŸ“… 2026-10-30" in moved + +class TestTheArchive: + """Ticked items leave the list the night after, into a file per month.""" + + TODAY = "2026-10-12" + + def _shopped(self) -> str: + text = _groceries() + for name, section, done_on in [("Γ„pfel", "GemΓΌse & Obst", "2026-10-11"), + ("Birnen", "GemΓΌse & Obst", ""), + ("Joghurt", "Milchprodukte & Eier", "2026-09-30"), + ("Eier", "Milchprodukte & Eier", "2026-10-12")]: + text = _add(text, name, section, done=bool(done_on), done_on=done_on) + return text + + def test_the_list_keeps_what_is_open_and_what_was_ticked_today(self): + text, _, moved = archive_ticked(self._shopped(), {}, path=GROCERIES, + today=self.TODAY, bucket="family") + assert [i.text for i in moved] == ["Γ„pfel", "Joghurt"] + assert [(i.text, i.done) for i in read_list(text, path=GROCERIES).items] == \ + [("Birnen", False), ("Eier", True)] + + def test_each_item_goes_to_the_month_it_was_ticked_in_its_section(self): + _, archives, _ = archive_ticked(self._shopped(), {}, path=GROCERIES, + today=self.TODAY, bucket="family") + october = archive_path("einkaufsliste", "2026-10", bucket="family") + assert sorted(archives) == [archive_path("einkaufsliste", "2026-09", bucket="family"), + october] + lst = read_list(archives[october], path=october) + assert lst.title == "Einkaufsliste, 2026-10" and lst.topics == [] + assert [(i.text, i.section, i.done_on) for i in lst.items] == \ + [("Γ„pfel", "GemΓΌse & Obst", "2026-10-11")] + + def test_an_emptied_section_loses_its_heading(self): + text, _, _ = archive_ticked(self._shopped(), {}, path=GROCERIES, + today="2026-10-13", bucket="family") + assert "Milchprodukte" not in text.split("---\n", 2)[2] + + def test_a_month_already_archived_gains_the_new_items(self): + october = archive_path("einkaufsliste", "2026-10", bucket="family") + earlier = insert_item(new_list("Einkaufsliste, 2026-10", sections=STORE), + Item("Milch", done=True, done_on="2026-10-02", + section="Milchprodukte & Eier"), path=october) + _, archives, _ = archive_ticked(self._shopped(), {october: earlier}, path=GROCERIES, + today=self.TODAY, bucket="family") + assert [i.text for i in read_list(archives[october], path=october).items] == \ + ["Γ„pfel", "Milch"] + + def test_a_recurring_list_keeps_its_ticks_for_the_next_reset(self): + packing = new_list("Packliste", recurring=True) + "\n- [x] Zelt βœ… 2026-10-01\n" + path = "family/lists/packliste.md" + assert read_list(packing, path=path).recurring + assert archive_ticked(packing, {}, path=path, today=self.TODAY, + bucket="family") == (packing, {}, []) + + def test_a_tick_made_by_hand_gets_a_date_first(self): + text = _groceries().rstrip("\n") + "\n\n- [x] Batterien\n" + stamped = stamp_ticks(text, path=GROCERIES, today=self.TODAY) + assert "- [x] Batterien βœ… 2026-10-12" in stamped + assert archive_ticked(stamped, {}, path=GROCERIES, today=self.TODAY, + bucket="family")[2] == [] diff --git a/tests/unit/stacklets/test_memory_list_archive.py b/tests/unit/stacklets/test_memory_list_archive.py new file mode 100644 index 00000000..37cad5cf --- /dev/null +++ b/tests/unit/stacklets/test_memory_list_archive.py @@ -0,0 +1,79 @@ +"""The nightly archive: yesterday's ticks leave every list, into one commit. + +What a family sees the morning after a shop: the list holds only what +they did not get, and what they got is in the month's archive. The +command is driven against a real HTTP server standing in for Forgejo +(`pytest-httpserver`), answering the calls it makes. +""" + +from __future__ import annotations + +import base64 +import importlib.util +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(_REPO_ROOT / "lib")) +sys.path.insert(0, str(_REPO_ROOT / "stacklets")) + +from stack.forgejo import ForgejoClient # noqa: E402 +from stack.lists import read_list # noqa: E402 + + +def _load(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +nightly = _load("memory_bot_cli_lists", + _REPO_ROOT / "stacklets" / "memory" / "bot" / "cli" / "lists.py") + +CONTENTS = f"/api/v1/repos/{nightly.REPO_OWNER}/{nightly.REPO_NAME}/contents" +LIST = ( + "---\ntype: list\ntitle: Einkaufsliste\nsections:\n - Obst\n - Milch\n---\n\n" + "## Obst\n- [x] Γ„pfel βœ… 2026-10-11\n- [ ] Birnen\n\n" + "## Milch\n- [x] Joghurt\n" +) + + +def _file(text: str, sha: str) -> dict: + return {"type": "file", "content": base64.b64encode(text.encode()).decode(), "sha": sha} + + +def test_the_morning_after_the_list_holds_what_is_open(httpserver): + httpserver.expect_request(f"{CONTENTS}/family/lists", method="GET").respond_with_json([ + {"type": "file", "path": "family/lists/einkaufsliste.md"}, + {"type": "dir", "path": "family/lists/archive"}, + ]) + httpserver.expect_request(f"{CONTENTS}/family/lists/einkaufsliste.md", + method="GET").respond_with_json(_file(LIST, "list-sha")) + httpserver.expect_request(f"{CONTENTS}/family/lists/archive/einkaufsliste/2026-10.md", + method="GET").respond_with_data("", status=404) + client = ForgejoClient(url=httpserver.url_for(""), token="bottoken") + + changes, moved = nightly.archive_lists(client, bucket="family", today="2026-10-12") + + assert moved == 1, "Joghurt was ticked by hand today and stays until tomorrow" + by_path = {c.path: c for c in changes} + kept = read_list(by_path["family/lists/einkaufsliste.md"].content, + path="family/lists/einkaufsliste.md") + assert [(i.text, i.done, i.done_on) for i in kept.items] == \ + [("Birnen", False, ""), ("Joghurt", True, "2026-10-12")] + assert by_path["family/lists/einkaufsliste.md"].sha == "list-sha" + archive = by_path["family/lists/archive/einkaufsliste/2026-10.md"] + assert archive.operation == "create" + assert "## Obst\n- [x] Γ„pfel βœ… 2026-10-11" in archive.content + + +def test_a_quiet_night_changes_nothing(httpserver): + httpserver.expect_request(f"{CONTENTS}/family/lists", method="GET").respond_with_json( + [{"type": "file", "path": "family/lists/packliste.md"}]) + httpserver.expect_request(f"{CONTENTS}/family/lists/packliste.md", method="GET") \ + .respond_with_json(_file("---\ntype: list\ntitle: Packliste\n---\n\n- [ ] Zelt\n", "s")) + client = ForgejoClient(url=httpserver.url_for(""), token="bottoken") + + assert nightly.archive_lists(client, bucket="family", today="2026-10-12") == ([], 0) From 5557b689352d2057e9cd1574307379a8aeaa1f23 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 12 Oct 2026 11:22:28 +0200 Subject: [PATCH 07/17] feat(memory): put a known item on the list it was last bought from Saying "we need eggs" should not require naming a store. `stack memory list add` now takes the list as optional: - An item the household had before, open on a list or in an archive, goes to the list it is open on, otherwise to the one it was last ticked on, in the section it had there and spelled as before. Items known on different lists go to each, one commit per list. - `--topic` keeps the choice inside one topic's lists, such as the stores of a shopping topic. - An item no list has had is refused with every list and its description, so the caller picks one. A list that is named always wins. The agent's `list_edit` tool may leave the list out when adding and pass the room's topic instead. Refs: #194 --- docs/design/brain/vault-format.md | 4 +- lib/stack/lists.py | 28 +++++ stacklets/agent/runtime/list_tool.py | 20 ++- .../workspace/skills/family-memory/SKILL.md | 1 + stacklets/memory/cli/list.py | 118 +++++++++++++++--- tests/unit/framework/test_lists.py | 26 ++++ .../unit/stacklets/test_agent_vault_tools.py | 2 + tests/unit/stacklets/test_memory_list_cli.py | 52 +++++++- 8 files changed, 226 insertions(+), 25 deletions(-) diff --git a/docs/design/brain/vault-format.md b/docs/design/brain/vault-format.md index 2f4fc250..7428a5c0 100644 --- a/docs/design/brain/vault-format.md +++ b/docs/design/brain/vault-format.md @@ -325,7 +325,9 @@ the curator moves it to the list's archive, `/lists/archive//.md` for the month it was ticked: a list file of its own (`title: Einkaufsliste, 2026-10`, no topics), with the line and its section as they were. A tick without a date gets the -day's date first. A `recurring` list is left alone. +day's date first. A `recurring` list is left alone. The archive is where +`stack memory list add` learns where an item was last bought, its +section and its spelling. ## 6. The `generated` invariant (from B3 / ADR-011) diff --git a/lib/stack/lists.py b/lib/stack/lists.py index 3da1fb86..e35d4623 100644 --- a/lib/stack/lists.py +++ b/lib/stack/lists.py @@ -499,6 +499,34 @@ def archive_ticked(text: str, archives: dict[str, str], *, path: str, today: str return text, changed, moving +# ── Known items ────────────────────────────────────────────────────────── + +@dataclass(frozen=True) +class Known: + """Where an item was last seen: the list, its section and its spelling.""" + + slug: str + section: str + text: str + + +def known_items(lists: list[tuple[str, FamilyList]]) -> dict[str, Known]: + """Every item the household has had on these lists, by its text in any case. + + `lists` pairs a list's slug with a list or one of its archive files. + An item open on a list now is where it lives; otherwise the most + recent tick wins, so an item bought somewhere else lately follows. + """ + seen: dict[str, tuple[str, Known]] = {} + for slug, lst in lists: + for item in lst.items: + rank = "9999-99-99" if not item.done else (item.done_on or "0000-00-00") + key = item.text.casefold() + if key not in seen or rank > seen[key][0]: + seen[key] = (rank, Known(slug, item.section, item.text)) + return {key: known for key, (_, known) in seen.items()} + + # ── File text ──────────────────────────────────────────────────────────── def _split(text: str) -> tuple[str, str]: diff --git a/stacklets/agent/runtime/list_tool.py b/stacklets/agent/runtime/list_tool.py index 3656250a..d24bb54d 100644 --- a/stacklets/agent/runtime/list_tool.py +++ b/stacklets/agent/runtime/list_tool.py @@ -26,8 +26,15 @@ tool_parameters_schema( list=StringSchema( "The list's name, such as einkaufsliste. `stack memory list " - "show` names every list with its description.", - min_length=1, + "show` names every list with its description. For add it may " + "be left out: an item the family had before goes to the list " + "it was last bought from.", + nullable=True, + ), + topic=StringSchema( + "For add without a list: the room's topic, so only its lists " + "are considered.", + nullable=True, ), op=StringSchema( "Item operation: add, tick, untick, remove, or move to another " @@ -79,8 +86,9 @@ def read_only(self) -> bool: async def execute( self, - list: str, op: str, + list: str | None = None, + topic: str | None = None, items: "list[str] | None" = None, section: str | None = None, people: "list[str] | None" = None, @@ -93,10 +101,14 @@ async def execute( except Exception: actor = "someone" - args = ["stack", "memory", "list", op, list.strip()] + args = ["stack", "memory", "list", op] + if list and list.strip(): + args.append(list.strip()) if op != "reset": args += [i for i in (items or []) if i and i.strip()] if op == "add": + if topic and not list: + args += ["--topic", topic] if section: args += ["--section", section] for person in people or []: diff --git a/stacklets/agent/workspace/skills/family-memory/SKILL.md b/stacklets/agent/workspace/skills/family-memory/SKILL.md index 1e9d5d8e..017b2ceb 100644 --- a/stacklets/agent/workspace/skills/family-memory/SKILL.md +++ b/stacklets/agent/workspace/skills/family-memory/SKILL.md @@ -31,6 +31,7 @@ SOURCES (answer used a vault page, searched or read): LISTS: item op add|tick|untick|remove -> list_edit(list, op, items=[one or more]) + add, list not said -> leave list out (topic room: topic=); known items find their list "we did that" -> tick, never remove "bought all|clear the list" -> tick what they got; ticked items leave the list overnight "start the week|fresh list" -> list_edit op=reset # reopens all diff --git a/stacklets/memory/cli/list.py b/stacklets/memory/cli/list.py index 387f36be..428b7f79 100644 --- a/stacklets/memory/cli/list.py +++ b/stacklets/memory/cli/list.py @@ -40,12 +40,14 @@ from stack.links import go_page, public # noqa: E402 from stack.lists import ( # noqa: E402 + ARCHIVE_DIR, LISTS_DIR, FamilyList, Item, ListError, find, insert_item, + known_items, list_path, new_list, person_link, @@ -62,6 +64,8 @@ --description "Was wir im Supermarkt brauchen" --topic einkaeufe-besorgungen \\ --section "GemΓΌse & Obst" --section "Milchprodukte & Eier" --as marge stack memory list add einkaufsliste Joghurt Eier --section "Milchprodukte & Eier" --as marge + stack memory list add Joghurt Eier --topic einkaeufe-besorgungen --as marge + each where it was last bought stack memory list add schule "Sign the field trip form" --for homer --for marge \\ --due 2026-10-20 --as marge stack memory list tick|untick|remove einkaufsliste Joghurt --as homer @@ -103,8 +107,10 @@ def _parser() -> argparse.ArgumentParser: create.add_argument("--recurring", action="store_true") add = verbs.add_parser("add", add_help=False) - add.add_argument("slug") + # The list is optional: `add Joghurt` puts a known item where it was + # last bought. A first word naming a list is the list. add.add_argument("items", nargs="+") + add.add_argument("--topic", default="") add.add_argument("--section", default="") add.add_argument("--for", dest="people", action="append", default=[]) add.add_argument("--due", default="") @@ -153,6 +159,8 @@ def run(args, stacklet, config): return create(vault, config, ns, actor) if ns.verb == "move": return move(vault, config, ns.slug, ns.items, ns.to, actor) + if ns.verb == "add": + return add(vault, config, ns, actor) return change(vault, config, ns, actor) except Refusal as e: return {"error": str(e)} @@ -205,15 +213,36 @@ def _as_dict(item: Item) -> dict: def _all_lists(vault: Path) -> list[FamilyList]: + return [lst for _, lst in _all_lists_at(vault)] + + +def _all_lists_at(vault: Path) -> list[tuple[str, FamilyList]]: + """Every list with its vault path; a file that is not a list is skipped.""" found = [] for file in sorted(vault.glob(f"*/{LISTS_DIR}/*.md")): + path = _rel(vault, file) try: - found.append(read_list(file.read_text(encoding="utf-8"), path=_rel(vault, file))) + found.append((path, read_list(file.read_text(encoding="utf-8"), path=path))) except (ListError, OSError, UnicodeDecodeError): continue return found +def _history(vault: Path, lists: list[tuple[str, FamilyList]]) -> list[tuple[str, FamilyList]]: + """These lists and every archived month of them, each paired with its list's slug.""" + pairs = [(lst.slug, lst) for _, lst in lists] + for path, lst in lists: + bucket = path.split("/", 1)[0] + folder = vault / bucket / LISTS_DIR / ARCHIVE_DIR / lst.slug + for file in sorted(folder.glob("*.md")): + try: + pairs.append((lst.slug, read_list(file.read_text(encoding="utf-8"), + path=_rel(vault, file)))) + except (ListError, OSError, UnicodeDecodeError): + continue + return pairs + + def _find_list(vault: Path, slug: str) -> tuple[str, FamilyList]: """The vault path and contents of the list `slug`, or a refusal naming the lists.""" files = sorted(vault.glob(f"*/{LISTS_DIR}/{slug}.md")) @@ -250,24 +279,78 @@ def _create(prior: str) -> str: return _commit(config, path, _create, actor, f"started the list {ns.title.strip()}") -def change(vault: Path, config, ns, actor: str) -> dict: - """add, tick, untick, remove or reset on one list, in one commit.""" - path, lst = _find_list(vault, ns.slug) - people = _people(vault, config, getattr(ns, "people", [])) - section = _section(lst, getattr(ns, "section", "")) - due = getattr(ns, "due", "") +def add(vault: Path, config, ns, actor: str) -> dict: + """Add items, each to its list: the one named, or where it was last bought. + + An item the household had before keeps its spelling and its section. + One commit per list. + """ + lists = _all_lists_at(vault) + names = [" ".join(n.split()) for n in ns.items if n.strip()] + slug = names.pop(0) if names and any(lst.slug == names[0] for _, lst in lists) else "" + if not names: + raise Refusal("name at least one item to add") + if slug: + candidates = [(p, lst) for p, lst in lists if lst.slug == slug] + else: + candidates = [(p, lst) for p, lst in lists if not ns.topic or ns.topic in lst.topics] + known = known_items(_history(vault, candidates)) + by_slug = {lst.slug: (p, lst) for p, lst in candidates} + + plan: dict[str, list[tuple[str, str]]] = {} + for name in names: + seen = known.get(name.casefold()) + target = slug or (seen.slug if seen else "") + if target not in by_slug: + raise Refusal(_which_list(name, candidates, ns.topic)) + lst = by_slug[target][1] + here = seen if seen and seen.slug == target else None + section = _section(lst, ns.section) if ns.section else \ + (here.section if here and (not lst.sections or here.section in lst.sections) else "") + plan.setdefault(target, []).append((here.text if here else name, section)) + + due = ns.due if due and not _ISO_DAY.match(due): raise Refusal(f"--due {due!r} is not a date like 2026-10-14") - today = date.today().isoformat() + people = _people(vault, config, ns.people) + results = [_add_to(config, *by_slug[target], entries, people, due, actor) + for target, entries in plan.items()] + if len(results) == 1: + return results[0] + errors = [r["error"] for r in results if "error" in r] + return {"error": "; ".join(errors)} if errors else {"ok": True, "lists": results} + + +def _add_to(config, path: str, lst: FamilyList, entries: list[tuple[str, str]], + people: list, due: str, actor: str) -> dict: bucket = path.split("/", 1)[0] + links = tuple(person_link(p.id, p.first_name, bucket=bucket) for p in people) + outcome: dict = {} + + def _apply(prior: str) -> str: + new, outcome["said"] = _add(prior, read_list(prior, path=path), entries, links, due, path) + return new + return _commit(config, path, _apply, actor, lambda: outcome.get("said", "added")) + + +def _which_list(name: str, candidates: list[tuple[str, FamilyList]], topic: str) -> str: + """The refusal for an item no list has had: every list it could go on.""" + if not candidates: + return f"no list names the topic {topic!r}. Start one with `stack memory list create`." + lists = "; ".join(f"{lst.slug} ({lst.description})" if lst.description else lst.slug + for _, lst in candidates) + return f"no list has had {name!r} before; name the list. Lists: {lists}" + + +def change(vault: Path, config, ns, actor: str) -> dict: + """tick, untick, remove or reset on one list, in one commit.""" + path, lst = _find_list(vault, ns.slug) + today = date.today().isoformat() outcome: dict = {} def _apply(prior: str) -> str: current = read_list(prior, path=path) - if ns.verb == "add": - links = tuple(person_link(p.id, p.first_name, bucket=bucket) for p in people) - new, said = _add(prior, current, ns.items, section, links, due, path) - elif ns.verb == "reset": + if ns.verb == "reset": new, said = _reset(prior, current, today, path) else: new, said = _each(prior, current.title, ns.verb, ns.items, today, path) @@ -307,19 +390,18 @@ def _remove_all(prior: str) -> str: return _commit(config, path, _remove_all, actor, said) -def _add(text: str, lst: FamilyList, names: list[str], section: str, +def _add(text: str, lst: FamilyList, entries: list[tuple[str, str]], links: tuple, due: str, path: str) -> tuple[str, str]: added, already = [], [] - for name in names: - name = " ".join(name.split()) + for name, section in entries: if any(i.text.casefold() == name.casefold() and not i.done for i in lst.items): already.append(name) continue text = insert_item(text, Item(name, links=links, due=due, section=section), path=path) lst = read_list(text, path=path) added.append(name) - said = [f"added {'; '.join(added)}"] if added else [] - said += [f"{name!r} is already on the list" for name in already] + said = [f"added {'; '.join(added)} to {lst.title}"] if added else [] + said += [f"{name!r} is already on {lst.title}" for name in already] return text, "; ".join(said) diff --git a/tests/unit/framework/test_lists.py b/tests/unit/framework/test_lists.py index 3ebe8dbd..a72faee3 100644 --- a/tests/unit/framework/test_lists.py +++ b/tests/unit/framework/test_lists.py @@ -22,8 +22,10 @@ from stack.lists import ( Item, Link, + Known, archive_path, archive_ticked, + known_items, stamp_ticks, ListError, format_item, @@ -263,3 +265,27 @@ def test_a_tick_made_by_hand_gets_a_date_first(self): assert "- [x] Batterien βœ… 2026-10-12" in stamped assert archive_ticked(stamped, {}, path=GROCERIES, today=self.TODAY, bucket="family")[2] == [] + + +class TestKnownItems: + def _archive(self, slug, *items) -> tuple[str, object]: + path = archive_path(slug, "2026-10", bucket="family") + text = new_list(f"{slug}, 2026-10") + for name, section, day in items: + text = insert_item(text, Item(name, done=True, done_on=day, section=section), + path=path) + return slug, read_list(text, path=path) + + def test_an_item_is_known_by_its_last_list_section_and_spelling(self): + known = known_items([ + self._archive("markt", ("Eier", "Eier & KΓ€se", "2026-10-03")), + self._archive("einkaufsliste", ("Eier", "Milchprodukte & Eier", "2026-10-10")), + ]) + assert known["eier"] == Known("einkaufsliste", "Milchprodukte & Eier", "Eier") + + def test_an_item_open_on_a_list_lives_there(self): + current = read_list(_add(_groceries(), "Eier", "Milchprodukte & Eier"), path=GROCERIES) + known = known_items([("einkaufsliste", current), + self._archive("markt", ("Eier", "Eier & KΓ€se", "2026-10-11"))]) + assert known["eier"].slug == "einkaufsliste" + diff --git a/tests/unit/stacklets/test_agent_vault_tools.py b/tests/unit/stacklets/test_agent_vault_tools.py index 9961d876..d243e48c 100644 --- a/tests/unit/stacklets/test_agent_vault_tools.py +++ b/tests/unit/stacklets/test_agent_vault_tools.py @@ -174,6 +174,8 @@ def test_the_allowlist_stays_read_only(api): ("list_edit", "list", {"list": "einkaufsliste", "op": "move", "items": ["Eier"], "to": "markt"}), ("list_edit", "list", {"list": "packliste", "op": "reset", "items": ["ignored"]}), + ("list_edit", "list", + {"op": "add", "items": ["Joghurt"], "topic": "einkaeufe-besorgungen"}), ]) def test_the_flags_a_tool_sends_are_flags_the_cli_accepts( memory_cli, vault_tools, tool_name, command, kwargs): diff --git a/tests/unit/stacklets/test_memory_list_cli.py b/tests/unit/stacklets/test_memory_list_cli.py index 255990ca..a893cc0b 100644 --- a/tests/unit/stacklets/test_memory_list_cli.py +++ b/tests/unit/stacklets/test_memory_list_cli.py @@ -75,6 +75,7 @@ def update_memory(self, config, repo_path, transform, *, actor, message): return {"ok": True, "committed": True, "path": repo_path, "mirrored": True} fake = _Store() + fake.vault = vault monkeypatch.setattr(list_cli, "update_memory", fake.update_memory) return fake @@ -136,14 +137,15 @@ def test_a_section_the_list_does_not_have_is_refused_with_its_sections(self, run def test_several_items_are_one_commit(self, run, store, groceries): commits = len(store.commits) result = run("add", "einkaufsliste", "Milch", "Eier", "--as", "marge") - assert result["summary"] == "added Milch; Eier" + assert result["summary"] == "added Milch; Eier to Einkaufsliste" assert len(store.commits) == commits + 1 def test_an_item_already_open_is_not_added_twice(self, run, store, groceries): run("add", "einkaufsliste", "Milch", "--as", "marge") commits = len(store.commits) result = run("add", "einkaufsliste", "milch", "--as", "homer") - assert result["committed"] is False and "already on the list" in result["summary"] + assert result["committed"] is False + assert result["summary"] == "'Milch' is already on Einkaufsliste" assert len(store.commits) == commits def test_people_and_a_due_date_are_written_as_fields(self, run, store, groceries): @@ -161,6 +163,52 @@ def test_a_due_date_is_a_day(self, run, groceries): assert "not a date" in result["error"] +class TestKnownItems: + """An item the household had before goes where it was last bought.""" + + @pytest.fixture + def shops(self, run, store, groceries): + run("create", "markt", "--title", "Wochenmarkt", "--description", "Samstags: GemΓΌse, KΓ€se", + "--section", "KΓ€se", "--as", "marge") + archive = "family/lists/archive/einkaufsliste/2026-10.md" + (store.vault / archive).parent.mkdir(parents=True) + (store.vault / archive).write_text( + "---\ntype: list\ntitle: Einkaufsliste, 2026-10\n---\n\n" + "## Milchprodukte & Eier\n- [x] Joghurt βœ… 2026-10-03\n- [x] Eier βœ… 2026-10-03\n") + markt = "family/lists/archive/markt/2026-10.md" + (store.vault / markt).parent.mkdir(parents=True) + (store.vault / markt).write_text( + "---\ntype: list\ntitle: Wochenmarkt, 2026-10\n---\n\n- [x] Eier βœ… 2026-10-10\n") + + def test_a_known_item_lands_on_its_list_in_its_section_and_spelling(self, run, store, shops): + result = run("add", "joghurt", "--as", "marge") + assert result["summary"] == "added Joghurt to Einkaufsliste" + assert "## Milchprodukte & Eier\n- [ ] Joghurt\n" in store.text(GROCERIES) + + def test_the_latest_purchase_decides_the_list(self, run, store, shops): + run("add", "Eier", "--as", "marge") + assert "- [ ] Eier" in store.text("family/lists/markt.md") + + def test_items_known_on_different_lists_go_to_each(self, run, store, shops): + result = run("add", "Joghurt", "Eier", "--as", "marge") + assert [r["summary"] for r in result["lists"]] == \ + ["added Joghurt to Einkaufsliste", "added Eier to Wochenmarkt"] + + def test_a_named_list_wins_over_where_it_was_bought(self, run, store, shops): + run("add", "einkaufsliste", "Eier", "--as", "marge") + assert "## Milchprodukte & Eier\n- [ ] Eier" in store.text(GROCERIES) + + def test_an_unknown_item_without_a_list_names_every_list(self, run, shops): + result = run("add", "Kerzen", "--as", "marge") + assert result["error"] == ( + "no list has had 'Kerzen' before; name the list. Lists: einkaufsliste " + "(Was wir im Supermarkt brauchen); markt (Samstags: GemΓΌse, KΓ€se)") + + def test_a_topic_narrows_the_lists(self, run, shops): + result = run("add", "Kerzen", "--topic", "garten", "--as", "marge") + assert "no list names the topic 'garten'" in result["error"] + + class TestTickingOff: @pytest.fixture def stocked(self, run, groceries): From d9a097b02734d0c7ba359bdb648572707e6fc818 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 12 Oct 2026 12:35:09 +0200 Subject: [PATCH 08/17] feat(agent): let the agent start a list Asked for "Schrauben vom Baumarkt" with no list for that store, the agent put the items on the closest-sounding list. `list_edit` now has a `create` operation with a title, a description of what goes on the list (`about`), the room's topic and a store's sections, and the skill tells the agent to start a list when none fits. The `topic` parameter says plainly that it takes the room's topic, never a list's name. `stack memory list create` takes the slug as optional and makes it from the title the way a topic's folder is (`Baumarkt` makes `baumarkt`), because a family names a list, not a slug. The test stand-in for nanobot takes the keywords the real schema builder takes, so a tool parameter that collides with one of them (`description` did) fails in the unit lane, not on the agent. Refs: #194 --- stacklets/agent/runtime/list_tool.py | 85 ++++++++++++++----- .../workspace/skills/family-memory/SKILL.md | 1 + stacklets/memory/cli/list.py | 7 +- tests/unit/stacklets/conftest.py | 12 ++- .../unit/stacklets/test_agent_vault_tools.py | 6 ++ tests/unit/stacklets/test_memory_list_cli.py | 4 + 6 files changed, 90 insertions(+), 25 deletions(-) diff --git a/stacklets/agent/runtime/list_tool.py b/stacklets/agent/runtime/list_tool.py index d24bb54d..d1aa1e26 100644 --- a/stacklets/agent/runtime/list_tool.py +++ b/stacklets/agent/runtime/list_tool.py @@ -32,14 +32,17 @@ nullable=True, ), topic=StringSchema( - "For add without a list: the room's topic, so only its lists " - "are considered.", + "The room's topic as the brief names it, never a list's name. " + "For add without a list, only that topic's lists are " + "considered; for create, the new list belongs to it.", nullable=True, ), op=StringSchema( "Item operation: add, tick, untick, remove, or move to another " - "list. reset reopens every ticked item.", - enum=("add", "tick", "untick", "remove", "move", "reset"), + "list. reset reopens every ticked item. create starts a new " + "list when none fits, such as a store the family has no list " + "for yet.", + enum=("add", "tick", "untick", "remove", "move", "reset", "create"), ), items=ArraySchema( StringSchema("One item's text, as it appears on the list."), @@ -60,6 +63,19 @@ ), due=StringSchema("For add: the day it is due, YYYY-MM-DD.", nullable=True), to=StringSchema("For move: the list the items go to.", nullable=True), + title=StringSchema("For create: the list's name as the family says it, " + "such as Baumarkt.", nullable=True), + # Not `description`: the schema builder takes that keyword for the + # tool's own description, and a parameter by that name breaks every + # request the agent makes. + about=StringSchema("For create: what goes on the list, so the next " + "item finds it.", nullable=True), + sections=ArraySchema( + StringSchema("A section, such as Obst & GemΓΌse."), + description="For create: a store's sections in the order the family " + "walks through it. Leave out for a list without sections.", + nullable=True, + ), ) ) class ListEditTool(Tool): @@ -75,7 +91,7 @@ def name(self) -> str: def description(self) -> str: return ( "Add, tick, untick, remove or move items on a family list, by " - "name. The store matches each item and answers with what " + "name, or start a new list. The store matches each item and answers with what " "actually changed. Use this for every item change; use " "write_file only to restructure a whole list." ) @@ -94,6 +110,9 @@ async def execute( people: "list[str] | None" = None, due: str | None = None, to: str | None = None, + title: str | None = None, + about: str | None = None, + sections: "list[str] | None" = None, ) -> str: try: import brief @@ -101,22 +120,10 @@ async def execute( except Exception: actor = "someone" - args = ["stack", "memory", "list", op] - if list and list.strip(): - args.append(list.strip()) - if op != "reset": - args += [i for i in (items or []) if i and i.strip()] - if op == "add": - if topic and not list: - args += ["--topic", topic] - if section: - args += ["--section", section] - for person in people or []: - args += ["--for", person] - if due: - args += ["--due", due] - if op == "move" and to: - args += ["--to", to] + if op == "create": + args = _create_args(list, title, about, topic, sections) + else: + args = _item_args(op, list, items, topic, section, people, due, to) args += ["--as", actor] proc = await asyncio.create_subprocess_exec( @@ -135,6 +142,42 @@ async def execute( return out or err or "(no answer from the list store)" +def _item_args(op, slug, items, topic, section, people, due, to) -> "list[str]": + """`stack memory list [] ... [flags]` for an item operation.""" + args = ["stack", "memory", "list", op] + if slug and slug.strip(): + args.append(slug.strip()) + if op != "reset": + args += [i for i in (items or []) if i and i.strip()] + if op == "add": + if topic and not slug: + args += ["--topic", topic] + if section: + args += ["--section", section] + for person in people or []: + args += ["--for", person] + if due: + args += ["--due", due] + if op == "move" and to: + args += ["--to", to] + return args + + +def _create_args(slug, title, about, topic, sections) -> "list[str]": + """`stack memory list create [] --title ...`; the store derives a missing slug.""" + args = ["stack", "memory", "list", "create"] + if slug and slug.strip(): + args.append(slug.strip()) + args += ["--title", (title or slug or "").strip()] + if about: + args += ["--description", about] + if topic: + args += ["--topic", topic] + for section in sections or []: + args += ["--section", section] + return args + + def install() -> None: """Append ListEditTool to nanobot discovery, same pattern as memory_tool.""" from nanobot.agent.tools.loader import ToolLoader diff --git a/stacklets/agent/workspace/skills/family-memory/SKILL.md b/stacklets/agent/workspace/skills/family-memory/SKILL.md index 017b2ceb..9b4d81c1 100644 --- a/stacklets/agent/workspace/skills/family-memory/SKILL.md +++ b/stacklets/agent/workspace/skills/family-memory/SKILL.md @@ -36,6 +36,7 @@ LISTS: "bought all|clear the list" -> tick what they got; ticked items leave the list overnight "start the week|fresh list" -> list_edit op=reset # reopens all belongs on another list -> op=move, to= + no list fits (a new store) -> op=create list= title about topic sections, then add add to a list with sections -> section = one of the list's own "for " | "by " -> add with people=[name] | due=YYYY-MM-DD; never guess either answer "ambiguous|no item|no section|no list|nobody called" diff --git a/stacklets/memory/cli/list.py b/stacklets/memory/cli/list.py index 428b7f79..e31ad6ad 100644 --- a/stacklets/memory/cli/list.py +++ b/stacklets/memory/cli/list.py @@ -56,6 +56,7 @@ set_done, ) from stack.people import load_people, resolve # noqa: E402 +from stack.vault import slug as vault_slug # noqa: E402 USAGE = """\ stack memory list show every list, with its open items counted @@ -99,7 +100,9 @@ def _parser() -> argparse.ArgumentParser: show.add_argument("--all", action="store_true") create = verbs.add_parser("create", add_help=False) - create.add_argument("slug") + # The slug is optional: a family names a list, and `Baumarkt` makes + # `baumarkt` the way a topic's name makes its folder. + create.add_argument("slug", nargs="?", default="") create.add_argument("--title", required=True) create.add_argument("--description", default="") create.add_argument("--topic", action="append", default=[]) @@ -263,7 +266,7 @@ def _find_list(vault: Path, slug: str) -> tuple[str, FamilyList]: def create(vault: Path, config, ns, actor: str) -> dict: """Start a new, empty list in the shared bucket.""" - slug = ns.slug.strip() + slug = ns.slug.strip() or vault_slug(ns.title) if not _SLUG.match(slug): raise Refusal(f"{slug!r} is not a slug: lowercase letters, digits and dashes") if list(vault.glob(f"*/{LISTS_DIR}/{slug}.md")): diff --git a/tests/unit/stacklets/conftest.py b/tests/unit/stacklets/conftest.py index da4df961..23c9a818 100644 --- a/tests/unit/stacklets/conftest.py +++ b/tests/unit/stacklets/conftest.py @@ -62,8 +62,16 @@ class Tool: def tool_parameters(schema): return lambda cls: cls - def tool_parameters_schema(**kwargs): - return dict(kwargs) + # nanobot's own signature: `required`, `description` and + # `additional_properties` are the schema's, everything else is a + # parameter. A tool parameter named after one of them breaks + # every request on the real agent, so it fails here too. + def tool_parameters_schema(*, required=None, description="", + additional_properties=False, **properties): + if not isinstance(description, str): + raise TypeError("a tool parameter is named `description`, which " + "the schema builder takes as its own") + return dict(properties) class _Schema: def __init__(self, *args, **kwargs): diff --git a/tests/unit/stacklets/test_agent_vault_tools.py b/tests/unit/stacklets/test_agent_vault_tools.py index d243e48c..bf9c879a 100644 --- a/tests/unit/stacklets/test_agent_vault_tools.py +++ b/tests/unit/stacklets/test_agent_vault_tools.py @@ -176,6 +176,12 @@ def test_the_allowlist_stays_read_only(api): ("list_edit", "list", {"list": "packliste", "op": "reset", "items": ["ignored"]}), ("list_edit", "list", {"op": "add", "items": ["Joghurt"], "topic": "einkaeufe-besorgungen"}), + ("list_edit", "list", + {"op": "create", "title": "Baumarkt", "items": ["Schrauben"]}), + ("list_edit", "list", + {"list": "baumarkt", "op": "create", "title": "Baumarkt", + "about": "Werkzeug und Material", "topic": "einkaeufe-besorgungen", + "sections": ["Schrauben", "Farbe"], "items": ["ignored"]}), ]) def test_the_flags_a_tool_sends_are_flags_the_cli_accepts( memory_cli, vault_tools, tool_name, command, kwargs): diff --git a/tests/unit/stacklets/test_memory_list_cli.py b/tests/unit/stacklets/test_memory_list_cli.py index a893cc0b..e74e94fe 100644 --- a/tests/unit/stacklets/test_memory_list_cli.py +++ b/tests/unit/stacklets/test_memory_list_cli.py @@ -110,6 +110,10 @@ def test_a_list_that_exists_is_not_started_again(self, run, groceries): result = run("create", "einkaufsliste", "--title", "Noch eine", "--as", "homer") assert "already exists" in result["error"] + def test_a_list_named_by_its_title_alone_gets_a_slug_from_it(self, run, store): + result = run("create", "--title", "Garage aufrΓ€umen", "--as", "homer") + assert result["path"] == "family/lists/garage-aufraumen.md" + def test_a_slug_is_lowercase_words_and_dashes(self, run): assert "not a slug" in run("create", "Einkauf Liste", "--title", "x", "--as", "marge")["error"] From 8b423999d7a08c4a03cd62d4d77a4e4672ecf167 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 12 Oct 2026 13:20:14 +0200 Subject: [PATCH 09/17] fix(archivist): take todos only from a note in the writer's own words Every note was asked for action items: a typed reminder, a voice message, but also text copied from somewhere else, whose author's suggestions ("try this framework") then landed on the family's list. The archivist now decides before the model is asked. A voice message counts as the speaker's own words; a typed message counts when it reads like one: a single paragraph, no link, at most 300 characters. Anything else yields no todos, and a link, a file, an email or a refile never does. A long note with a task in it is left to the agent: "put it on the list". The prompt for what passes is the one before due dates were added: on the household's model, asking for a due date as well merged a shopping list into one item or dropped it, so a deadline stays in the item's words ("Take out the trash by Friday"). The archivist also shows the model the topic's list from `lists/` again; it still read the old `todos.md`. Refs: #194 --- stacklets/docs/bot/capture_pipeline.py | 42 +++++++++----- stacklets/docs/bot/own_words.py | 56 +++++++++++++++++++ stacklets/docs/bot/pipeline.py | 21 ++++--- tests/e2e/eval/test_capture_todos_eval.py | 36 ++++++++++++ tests/unit/stacklets/test_capture_pipeline.py | 52 +++++++++++++++++ tests/unit/stacklets/test_own_words.py | 37 ++++++++++++ 6 files changed, 220 insertions(+), 24 deletions(-) create mode 100644 stacklets/docs/bot/own_words.py create mode 100644 tests/e2e/eval/test_capture_todos_eval.py create mode 100644 tests/unit/stacklets/test_own_words.py diff --git a/stacklets/docs/bot/capture_pipeline.py b/stacklets/docs/bot/capture_pipeline.py index 839afb84..f1aee053 100644 --- a/stacklets/docs/bot/capture_pipeline.py +++ b/stacklets/docs/bot/capture_pipeline.py @@ -28,6 +28,7 @@ from extractors import SourceContent, email_to_source from matching import build_capture_event from notifier import Notifier +from own_words import in_own_words from pdf_analysis import ( DEFAULT_VISION_MAX_PDF_PAGES, has_ocr_text_layer, @@ -43,6 +44,7 @@ LLMUnavailableError, ) from stack import resolve_model +from stack.lists import list_path try: from pypdf import PdfReader # type: ignore @@ -260,9 +262,10 @@ async def capture_text( through; see ``capture_url``. ``transcribed`` marks a body that whisper produced rather than a - keyboard. It changes nothing about how the note is filed β€” only - whether the reply quotes the words back, which is the sender's - one chance to catch a mishearing. + keyboard. It decides whether the reply quotes the words back, the + sender's one chance to catch a mishearing, and counts the note as + the speaker's own words, the only notes that become todos (see + `own_words`). """ source = await self._text_extractor.extract(text) if source is None: @@ -276,6 +279,7 @@ async def capture_text( actor=sender_mxid, capture_id=capture_id, seed_topics=seed_topics, bucket=bucket, transcribed=transcribed, write_in=write_in, + extract_action_items=in_own_words(text, spoken=transcribed), ) async def capture_email( @@ -631,9 +635,15 @@ async def _publish( transcribed: bool = False, kept_media: dict | None = None, write_in: str | None = None, + extract_action_items: bool = False, ) -> CaptureOutcome: """Shared tail: classify, mirror, record tags, return the outcome. + ``extract_action_items`` asks the classifier for the todos in the + source. Only a note in the writer's own words does (`capture_text`); + links, files, emails and refiles never do, because their text is + somebody else's, or the model's own summary of it. + ``existing_path`` + ``captured_at`` are the reprocess inputs -- when re-classifying an already-filed capture we keep its original capture date and hand the prior path to publish_capture @@ -653,14 +663,7 @@ async def _publish( localpart = sender_mxid.split(":")[0].lstrip("@") sender_name = localpart.capitalize() entity_slug = bucket or localpart.lower() - # A topic room's capture merges into that topic's list, so show the - # classifier the list it is about to add to. Extraction in isolation - # cannot tell a repeat from a new item, which is how re-posting one - # list six times grew it instead of leaving it alone. - current_list = "" - if kind == "note" and "/" in entity_slug: - current_list = await self._mirror.read_capture( - f"{entity_slug}/todos.md") or "" + current_list = await self._current_list(entity_slug) if extract_action_items else "" classification = await self._classify( source, sender_name, images=images, user_hint=user_hint, @@ -668,10 +671,7 @@ async def _publish( default_person=default_person, current_list=current_list, write_in=write_in, - # Todo extraction is opt-in per source kind: human-typed notes - # only. Bookmarks (saved URLs/snippets) stay out β€” that's the - # guard against a pasted thread manufacturing a household todo. - extract_action_items=(kind == "note"), + extract_action_items=extract_action_items, ) # Topic-room seed: prepend caller-guaranteed tags to whatever @@ -776,6 +776,18 @@ async def _publish( scope=entity_slug, ) + async def _current_list(self, entity_slug: str) -> str: + """The list a topic room's note adds to, for the classifier to see. + + Extraction in isolation cannot tell a repeat from a new item, which + is how re-posting one list six times grew it instead of leaving it + alone. Only a topic room (`/`) has a list to show. + """ + bucket, _, topic = entity_slug.partition("/") + if not topic: + return "" + return await self._mirror.read_capture(list_path(topic, bucket=bucket)) or "" + async def _family_names(self) -> list[str]: """The family's first names: the person files, else the Person tags.""" people = self._vault.people() if self._vault is not None else None diff --git a/stacklets/docs/bot/own_words.py b/stacklets/docs/bot/own_words.py new file mode 100644 index 00000000..144bcddb --- /dev/null +++ b/stacklets/docs/bot/own_words.py @@ -0,0 +1,56 @@ +"""Whether a note is in the writer's own words, the only notes that become todos. + +A note is anything a family member sends that is not a link or a file: +a reminder they typed, something they said into the phone, or text they +copied from somewhere else. Only the first two are things the family +means to do. Copied text carries the plans and advice of whoever wrote +it ("try this framework", "book early"), and a model asked to find the +tasks in it finds them, so a pasted article used to put its author's +suggestions on the family's list. + +So the decision is made here, in code, before the model sees the note: + +- A voice message is the speaker's own words. +- A typed message is, when it reads like one: a single paragraph, no + link, short enough to have been typed into a chat. +- Anything longer, or with a link in it, is treated as copied text and + yields no todos at all, whatever the model would make of it. + +The cost is a long, hand-typed note with a task buried in it. That task +is missed, and one sentence to the agent ("put it on the list") fixes +it. The other way round, noise on the list, makes the family stop +trusting the list. What passes this gate still meets the prompt's own +rule: only what the writer says they or the family must do. + + >>> in_own_words("wir sollten bis Freitag den MΓΌll rausbringen", spoken=False) + True + >>> in_own_words("Check this out: https://example.com/framework", spoken=False) + False + +Pure functions, no I/O. +""" + +from __future__ import annotations + +import re + +# A chat message somebody typed fits in a few lines. Copied text that +# short still has to pass the prompt's rule, so the limit only needs to +# keep articles, threads and documents out. +MAX_TYPED_CHARS = 300 + +_LINK = re.compile(r"https?://|www\.", re.IGNORECASE) +_PARAGRAPH_BREAK = re.compile(r"\n\s*\n") + + +def in_own_words(text: str, *, spoken: bool) -> bool: + """True for a voice message, or for a typed message that reads like one.""" + if spoken: + return True + body = (text or "").strip() + return ( + bool(body) + and len(body) <= MAX_TYPED_CHARS + and not _PARAGRAPH_BREAK.search(body) + and not _LINK.search(body) + ) diff --git a/stacklets/docs/bot/pipeline.py b/stacklets/docs/bot/pipeline.py index f807cf0b..a73c498f 100644 --- a/stacklets/docs/bot/pipeline.py +++ b/stacklets/docs/bot/pipeline.py @@ -784,11 +784,11 @@ async def classify_capture( captures are bookmarks/notes, not archives, and the user reads the summary later to remember what this was about. - ``extract_action_items`` opts a *note* into todo extraction - (the caller sets it only for human-authored notes, never - bookmarks). Off, the field is absent β€” a pasted snippet can't - manufacture a todo. On, it's hard-defaulted to [] so a note - with nothing to do stays empty. + ``extract_action_items`` opts a note into todo extraction. The + caller sets it only for a note in the writer's own words + (`own_words`); off, the field is absent, so copied text cannot + put its author's suggestions on the family's list. On, it + defaults to [] so a note with nothing to do stays empty. Existing tags are fed as a vocabulary hint so the LLM reuses what's already in the system ("LLMs" not "llm", "Apple @@ -1305,10 +1305,13 @@ def _build_capture_prompt( f'("this Friday", "next week", "the 14th to 16th") into concrete dates.\n' if today else "" ) - # Action items are off by default β€” the snippet-becomes-todo guard. The - # caller opts in only for human-authored notes (never bookmarks), and even - # then the field is hard-defaulted to [] so the model isn't pushed to - # manufacture a task from a note that has none. + # Action items are asked for only when the caller decided the note is in + # the writer's own words (`own_words`): copied text carries its author's + # suggestions, and a model asked for tasks finds them. Even then the + # field defaults to [] so a note with nothing to do stays empty. The + # items stay plain text: asked for a due date as well, the model merged + # a shopping list into one item or dropped it, so a deadline stays in + # the item's words ("Take out the trash by Friday"). action_items_field = ( ',\n "action_items": ["ONLY if this note records something to DO β€” a ' "task, errand, reminder, or a list the writer keeps (shopping, packing, " diff --git a/tests/e2e/eval/test_capture_todos_eval.py b/tests/e2e/eval/test_capture_todos_eval.py new file mode 100644 index 00000000..108d9786 --- /dev/null +++ b/tests/e2e/eval/test_capture_todos_eval.py @@ -0,0 +1,36 @@ +"""Which short notes become todos, asked of the real model. + +A note reaches this question only when it is in the writer's own words +(`own_words`): a voice message, or a short typed message without a link. +What is left for the model is whether the writer records something to +do or keeps a list. A short pasted tip passes the gate, so these cases +pin that the model keeps it as information, and that a list still +yields one item per thing. + +Opt-in like the rest of the eval: it needs `AI_DEFAULT_MODEL` and a +reachable AI endpoint. +""" + +from __future__ import annotations + +import pytest + +# (note, how many items it should yield) +CASES = { + "a reminder with a deadline": ("wir sollten bis Freitag den MΓΌll rausbringen", 1), + "a reminder": ("Nicht vergessen: Lisa zum Zahnarzt bringen", 1), + "a list the writer keeps": ("Einkaufsliste: Milch, Eier, Brot", 3), + "a list said in passing": ("Wir brauchen noch Milch, Eier und Brot.", 3), + "a pasted tip": ("Check out Bun, a fast all-in-one JavaScript runtime.", 0), + "a piece of advice": ("Tipp: Tomaten nie im KΓΌhlschrank lagern, sonst verlieren sie Aroma.", 0), + "something that happened": ("Der Elternabend war gut, Frau Krabappel hat viel erzΓ€hlt.", 0), +} + + +@pytest.mark.parametrize("case", CASES) +async def test_only_what_the_writer_commits_to_becomes_a_todo(ai_classifier, case): + note, count = CASES[case] + answer = await ai_classifier.classify_capture( + text=note, person_names=["Homer", "Marge", "Lisa"], extract_action_items=True) + items = answer.get("action_items") or [] + assert len(items) == count, f"{case}: {items}" diff --git a/tests/unit/stacklets/test_capture_pipeline.py b/tests/unit/stacklets/test_capture_pipeline.py index c84e064e..da37d9bd 100644 --- a/tests/unit/stacklets/test_capture_pipeline.py +++ b/tests/unit/stacklets/test_capture_pipeline.py @@ -73,6 +73,7 @@ async def classify_capture(self, *, text, person_names, existing_tags, # thirteen items to twenty-seven. self.saw_current_list = current_list self.saw_write_in = write_in + self.saw_extract_action_items = extract_action_items if self._raises: raise self._raises return self._payload @@ -1180,3 +1181,54 @@ async def test_no_choice_leaves_it_to_the_household(self): text="a note", sender_mxid="@homer:server", ) assert classifier.saw_write_in is None + + +class TestOnlyTheWritersOwnWordsBecomeTodos: + """The classifier is asked for todos only for a note in the writer's own words. + + Copied text carries its author's suggestions; asked for tasks, the model + finds them. `own_words` holds the rule; these pin that every way into + the vault applies it. + """ + + @pytest.mark.asyncio + async def test_a_short_typed_note_is_asked_for_todos(self): + classifier = FakeClassifier() + await _pipeline(classifier=classifier).capture_text( + text="wir sollten bis Freitag den MΓΌll rausbringen", sender_mxid="@marge:s") + assert classifier.saw_extract_action_items is True + + @pytest.mark.asyncio + async def test_a_voice_message_is_asked_for_todos(self): + classifier = FakeClassifier() + await _pipeline(classifier=classifier).capture_text( + text="Nicht vergessen, " + "die FahrrΓ€der aufpumpen. " * 20, + sender_mxid="@marge:s", transcribed=True) + assert classifier.saw_extract_action_items is True + + @pytest.mark.asyncio + async def test_a_pasted_article_is_not(self): + classifier = FakeClassifier() + article = "Why we moved to Bun\n\nTry it on your next project. " * 5 + await _pipeline(classifier=classifier).capture_text( + text=article, sender_mxid="@homer:s") + assert classifier.saw_extract_action_items is False + + @pytest.mark.asyncio + async def test_a_link_is_not(self): + classifier = FakeClassifier() + await _pipeline(classifier=classifier).capture_url( + url="https://example.com/framework", sender_mxid="@homer:s", + notifier=FakeNotifier()) + assert classifier.saw_extract_action_items is False + + @pytest.mark.asyncio + async def test_a_topic_rooms_note_is_shown_the_topics_list(self): + """Seeing the list keeps a re-posted list from growing in new wordings.""" + mirror = FakeMirror() + mirror._stored = {"family/lists/camping.md": "- [ ] KΓΌhlbox\n"} + classifier = FakeClassifier() + await _pipeline(mirror=mirror, classifier=classifier).capture_text( + text="KΓΌhlbox mitnehmen", sender_mxid="@marge:s", bucket="family/camping") + assert classifier.saw_current_list == "- [ ] KΓΌhlbox\n" + diff --git a/tests/unit/stacklets/test_own_words.py b/tests/unit/stacklets/test_own_words.py new file mode 100644 index 00000000..bd460873 --- /dev/null +++ b/tests/unit/stacklets/test_own_words.py @@ -0,0 +1,37 @@ +"""Only a note in the writer's own words becomes todos. + +Copied text carries the suggestions of whoever wrote it, and a model +asked for tasks finds them: a pasted article put "try this framework" +on a family's list. The gate keeps that text away from the question. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "stacklets" / "docs" / "bot")) + +from own_words import MAX_TYPED_CHARS, in_own_words # noqa: E402 + + +class TestWhatCountsAsTheWritersOwnWords: + def test_a_short_typed_reminder(self): + assert in_own_words("wir sollten bis Freitag den MΓΌll rausbringen", spoken=False) + + def test_a_voice_message_of_any_length(self): + assert in_own_words("Also, ich hab mir ΓΌberlegt … " * 40, spoken=True) + + +class TestWhatCountsAsCopiedText: + def test_a_paste_longer_than_a_chat_message(self): + assert not in_own_words("x" * (MAX_TYPED_CHARS + 1), spoken=False) + + def test_text_with_a_link(self): + assert not in_own_words("Try Bun, it is fast: https://bun.sh", spoken=False) + + def test_several_paragraphs(self): + assert not in_own_words("Why we moved to Bun\n\nWe tried it for a week.", spoken=False) + + def test_nothing_at_all(self): + assert not in_own_words(" ", spoken=False) From 159d95c39054edbef32ca9bc2f05e523761cff91 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 12 Oct 2026 14:24:38 +0200 Subject: [PATCH 10/17] feat(memory): change a list's title, topics or walking order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A list could only be described when it was created. `stack memory list set ` now changes what is given and keeps the rest: - `--title` and `--description`; - `--topic`, repeatable, the topics the list belongs to, which is how a list joins a topic room after the fact (`--topic ""` for none); - `--section`, repeatable, the new walking order: the list's sections move with their items into that order, and a section it does not name goes after them. The agent's `list_edit` tool has the same operation, so "die Einkaufsliste gehΓΆrt in diesen Raum" can be done in chat. Refs: #194 --- lib/stack/lists.py | 43 +++++++++++++++++++ stacklets/agent/runtime/list_tool.py | 30 +++++++------ .../workspace/skills/family-memory/SKILL.md | 4 +- stacklets/memory/cli/list.py | 40 ++++++++++++++++- tests/unit/framework/test_lists.py | 32 ++++++++++++++ .../unit/stacklets/test_agent_vault_tools.py | 4 ++ tests/unit/stacklets/test_memory_list_cli.py | 24 +++++++++++ 7 files changed, 162 insertions(+), 15 deletions(-) diff --git a/lib/stack/lists.py b/lib/stack/lists.py index e35d4623..a5b4f8a8 100644 --- a/lib/stack/lists.py +++ b/lib/stack/lists.py @@ -309,6 +309,49 @@ def new_list(title: str, *, description: str = "", topics: list[str] | None = No return f"---\n{dump(fm)}\n---\n" +# ── Changing a list ────────────────────────────────────────────────────── + +def set_details(text: str, *, title: str | None = None, description: str | None = None, + topics: list[str] | None = None, sections: list[str] | None = None) -> str: + """The list text with its frontmatter changed; `None` leaves a field as it is. + + New `sections` are the new walking order: the body's section blocks + are put in that order, a block the order does not name after them. + Everything inside a block stays as it is. + """ + fm = parse(text) + for key, value in (("title", title), ("description", description), + ("topics", topics), ("sections", sections)): + if value is not None: + fm[key] = value + lines = _body(text).splitlines() + if sections is not None: + lines = _in_walking_order(lines, sections) + return _join(f"---\n{dump(fm)}\n---\n", lines) + + +def _in_walking_order(lines: list[str], order: list[str]) -> list[str]: + """Body lines with their `##` blocks sorted by `order`, the preamble first.""" + starts = [n for n, ln in enumerate(lines) if (h := _HEADING.match(ln)) and len(h[1]) > 1] + if not starts: + return lines + preamble = lines[:starts[0]] + blocks = [lines[a:b] for a, b in zip(starts, [*starts[1:], len(lines)])] + rank = {name.casefold(): n for n, name in enumerate(order)} + + def position(block: list[str]) -> int: + return rank.get(_HEADING.match(block[0])[2].casefold(), len(rank)) + out = _trimmed(preamble) + for block in sorted(blocks, key=position): + out += ([""] if out else []) + _trimmed(block) + return out + + +def _trimmed(lines: list[str]) -> list[str]: + """`lines` without the blank lines at their end.""" + return lines[:_after_last_content(lines, 0, len(lines))] + + # ── Adding an item ─────────────────────────────────────────────────────── def insert_item(text: str, item: Item, *, path: str) -> str: diff --git a/stacklets/agent/runtime/list_tool.py b/stacklets/agent/runtime/list_tool.py index d1aa1e26..fc9f5cdb 100644 --- a/stacklets/agent/runtime/list_tool.py +++ b/stacklets/agent/runtime/list_tool.py @@ -34,15 +34,16 @@ topic=StringSchema( "The room's topic as the brief names it, never a list's name. " "For add without a list, only that topic's lists are " - "considered; for create, the new list belongs to it.", + "considered; for create or set, the list belongs to it.", nullable=True, ), op=StringSchema( "Item operation: add, tick, untick, remove, or move to another " "list. reset reopens every ticked item. create starts a new " "list when none fits, such as a store the family has no list " - "for yet.", - enum=("add", "tick", "untick", "remove", "move", "reset", "create"), + "for yet. set changes a list's title, description, topic or " + "sections, such as linking it to this room's topic.", + enum=("add", "tick", "untick", "remove", "move", "reset", "create", "set"), ), items=ArraySchema( StringSchema("One item's text, as it appears on the list."), @@ -63,16 +64,16 @@ ), due=StringSchema("For add: the day it is due, YYYY-MM-DD.", nullable=True), to=StringSchema("For move: the list the items go to.", nullable=True), - title=StringSchema("For create: the list's name as the family says it, " + title=StringSchema("For create or set: the list's name as the family says it, " "such as Baumarkt.", nullable=True), # Not `description`: the schema builder takes that keyword for the # tool's own description, and a parameter by that name breaks every # request the agent makes. - about=StringSchema("For create: what goes on the list, so the next " + about=StringSchema("For create or set: what goes on the list, so the next " "item finds it.", nullable=True), sections=ArraySchema( StringSchema("A section, such as Obst & GemΓΌse."), - description="For create: a store's sections in the order the family " + description="For create or set: a store's sections in the order the family " "walks through it. Leave out for a list without sections.", nullable=True, ), @@ -120,8 +121,8 @@ async def execute( except Exception: actor = "someone" - if op == "create": - args = _create_args(list, title, about, topic, sections) + if op in ("create", "set"): + args = _details_args(op, list, title, about, topic, sections) else: args = _item_args(op, list, items, topic, section, people, due, to) args += ["--as", actor] @@ -163,12 +164,17 @@ def _item_args(op, slug, items, topic, section, people, due, to) -> "list[str]": return args -def _create_args(slug, title, about, topic, sections) -> "list[str]": - """`stack memory list create [] --title ...`; the store derives a missing slug.""" - args = ["stack", "memory", "list", "create"] +def _details_args(op, slug, title, about, topic, sections) -> "list[str]": + """`stack memory list create|set [] --title ...`: a list's own details. + + `create` makes a missing slug from the title; `set` changes only what is + given, so linking a list to this room is `set` with just the topic. + """ + args = ["stack", "memory", "list", op] if slug and slug.strip(): args.append(slug.strip()) - args += ["--title", (title or slug or "").strip()] + if title or op == "create": + args += ["--title", (title or slug or "").strip()] if about: args += ["--description", about] if topic: diff --git a/stacklets/agent/workspace/skills/family-memory/SKILL.md b/stacklets/agent/workspace/skills/family-memory/SKILL.md index 9b4d81c1..7edf8656 100644 --- a/stacklets/agent/workspace/skills/family-memory/SKILL.md +++ b/stacklets/agent/workspace/skills/family-memory/SKILL.md @@ -37,11 +37,13 @@ LISTS: "start the week|fresh list" -> list_edit op=reset # reopens all belongs on another list -> op=move, to= no list fits (a new store) -> op=create list= title about topic sections, then add + "belongs here|rename|new order|new sections" -> op=set list= with only what changes add to a list with sections -> section = one of the list's own "for " | "by " -> add with people=[name] | due=YYYY-MM-DD; never guess either answer "ambiguous|no item|no section|no list|nobody called" -> pick from the named choices, retry once - restructure ONLY -> read_file then write_file(complete page) + restructure set cannot do (merge two lists, split a section) + -> read_file then write_file(complete page) keep: frontmatter verbatim at column one, every [x], their order, their words write answer reports unintended unticked|REMOVED -> restore now, then say so edit answer = the truth -> relay it in one line diff --git a/stacklets/memory/cli/list.py b/stacklets/memory/cli/list.py index e31ad6ad..bd080cde 100644 --- a/stacklets/memory/cli/list.py +++ b/stacklets/memory/cli/list.py @@ -53,6 +53,7 @@ person_link, read_list, remove_item, + set_details, set_done, ) from stack.people import load_people, resolve # noqa: E402 @@ -74,9 +75,12 @@ stack memory list create packliste --title Packliste --recurring --as marge ticked items stay until a reset stack memory list reset packliste --as marge reopen every ticked item + stack memory list set einkaufsliste --topic einkauf --as marge + the list's title, description, topics + or walking order (--section, in order) """ -VERBS = ("show", "create", "add", "tick", "untick", "remove", "move", "reset") +VERBS = ("show", "create", "add", "tick", "untick", "remove", "move", "reset", "set") # A commit subject's length, as git and Forgejo show it without wrapping. _SUBJECT_MAX = 72 @@ -128,10 +132,20 @@ def _parser() -> argparse.ArgumentParser: move.add_argument("items", nargs="+") move.add_argument("--to", required=True) + # `set` changes what is given and keeps the rest. `--topic` and + # `--section` replace the whole list of them: sections are a walking + # order, which only means something complete. + details = verbs.add_parser("set", add_help=False) + details.add_argument("slug") + details.add_argument("--title") + details.add_argument("--description") + details.add_argument("--topic", action="append") + details.add_argument("--section", action="append") + reset = verbs.add_parser("reset", add_help=False) reset.add_argument("slug") - for sub in (create, add, move, reset, *[verbs.choices[v] for v in ("tick", "untick", "remove")]): + for sub in (create, add, move, reset, details, *[verbs.choices[v] for v in ("tick", "untick", "remove")]): sub.add_argument("--as", dest="actor", default="") return parser @@ -160,6 +174,8 @@ def run(args, stacklet, config): actor = _actor(ns.actor) if ns.verb == "create": return create(vault, config, ns, actor) + if ns.verb == "set": + return change_details(vault, config, ns, actor) if ns.verb == "move": return move(vault, config, ns.slug, ns.items, ns.to, actor) if ns.verb == "add": @@ -264,6 +280,26 @@ def _find_list(vault: Path, slug: str) -> tuple[str, FamilyList]: # ── Writing ────────────────────────────────────────────────────────────── +def change_details(vault: Path, config, ns, actor: str) -> dict: + """Change a list's title, description, topics or walking order.""" + path, lst = _find_list(vault, ns.slug) + given = {"title": ns.title, "description": ns.description, + "topics": _named(ns.topic), "sections": _named(ns.section)} + changed = [name for name, value in given.items() if value is not None] + if not changed: + raise Refusal("name what to change: --title, --description, --topic or --section") + + def _apply(prior: str) -> str: + return set_details(prior, **given) + return _commit(config, path, _apply, actor, + f"changed the {', '.join(changed)} of {lst.title}") + + +def _named(values: list[str] | None) -> list[str] | None: + """The non-empty values given, so `--topic ""` leaves a list in no topic.""" + return None if values is None else [v.strip() for v in values if v.strip()] + + def create(vault: Path, config, ns, actor: str) -> dict: """Start a new, empty list in the shared bucket.""" slug = ns.slug.strip() or vault_slug(ns.title) diff --git a/tests/unit/framework/test_lists.py b/tests/unit/framework/test_lists.py index a72faee3..812be31e 100644 --- a/tests/unit/framework/test_lists.py +++ b/tests/unit/framework/test_lists.py @@ -36,6 +36,7 @@ person_link, read_list, relink, + set_details, ) GROCERIES = list_path("einkaufsliste", bucket="family") @@ -289,3 +290,34 @@ def test_an_item_open_on_a_list_lives_there(self): self._archive("markt", ("Eier", "Eier & KΓ€se", "2026-10-11"))]) assert known["eier"].slug == "einkaufsliste" + +class TestChangingAList: + """A list's details change after the fact; its items never move or change.""" + + def test_a_list_joins_a_topic_room(self): + text = set_details(_add(_groceries(), "Milch", "Milchprodukte & Eier"), topics=["einkauf"]) + lst = read_list(text, path=GROCERIES) + assert lst.topics == ["einkauf"] and [i.text for i in lst.items] == ["Milch"] + + def test_title_and_description_change_alone(self): + lst = read_list(set_details(_groceries(), title="Supermarkt", description="Rewe"), + path=GROCERIES) + assert (lst.title, lst.description, lst.sections) == ("Supermarkt", "Rewe", STORE) + + def test_no_topics_leaves_none(self): + assert read_list(set_details(_groceries(), topics=[]), path=GROCERIES).topics == [] + + def test_a_new_walking_order_moves_the_sections_with_their_items(self): + text = _groceries() + for name, section in [("Batterien", ""), ("Γ„pfel", "GemΓΌse & Obst"), + ("Joghurt", "Milchprodukte & Eier"), ("Kerzen", "Deko")]: + text = _add(text, name, section) + text = text.replace("- [ ] Joghurt\n", "- [ ] Joghurt\nDer griechische!\n") + moved = set_details(text, sections=["Milchprodukte & Eier", "GemΓΌse & Obst"]) + assert _body(moved) == ( + "\n- [ ] Batterien\n" + "\n## Milchprodukte & Eier\n- [ ] Joghurt\nDer griechische!\n" + "\n## GemΓΌse & Obst\n- [ ] Γ„pfel\n" + "\n## Deko\n- [ ] Kerzen\n") + assert read_list(moved, path=GROCERIES).sections == ["Milchprodukte & Eier", "GemΓΌse & Obst"] + diff --git a/tests/unit/stacklets/test_agent_vault_tools.py b/tests/unit/stacklets/test_agent_vault_tools.py index bf9c879a..e9b0e4b8 100644 --- a/tests/unit/stacklets/test_agent_vault_tools.py +++ b/tests/unit/stacklets/test_agent_vault_tools.py @@ -178,6 +178,10 @@ def test_the_allowlist_stays_read_only(api): {"op": "add", "items": ["Joghurt"], "topic": "einkaeufe-besorgungen"}), ("list_edit", "list", {"op": "create", "title": "Baumarkt", "items": ["Schrauben"]}), + ("list_edit", "list", + {"list": "einkaufsliste", "op": "set", "topic": "einkauf"}), + ("list_edit", "list", + {"list": "einkaufsliste", "op": "set", "sections": ["Obst", "Milch"]}), ("list_edit", "list", {"list": "baumarkt", "op": "create", "title": "Baumarkt", "about": "Werkzeug und Material", "topic": "einkaeufe-besorgungen", diff --git a/tests/unit/stacklets/test_memory_list_cli.py b/tests/unit/stacklets/test_memory_list_cli.py index e74e94fe..034ceeef 100644 --- a/tests/unit/stacklets/test_memory_list_cli.py +++ b/tests/unit/stacklets/test_memory_list_cli.py @@ -290,6 +290,30 @@ def test_a_list_that_does_not_exist_is_refused_with_the_lists(self, run, groceri assert result["error"] == "no list 'baumarkt'. Lists: einkaufsliste" +class TestChangingAList: + def test_a_list_joins_a_topic_room_after_the_fact(self, run, store, groceries): + run("add", "einkaufsliste", "Milch", "--as", "marge") + result = run("set", "einkaufsliste", "--topic", "einkauf", "--as", "marge") + assert result["summary"] == "changed the topics of Einkaufsliste" + assert "topics:\n - einkauf\n" in store.text(groceries) + assert "- [ ] Milch" in store.text(groceries) + + def test_an_empty_topic_leaves_it_in_none(self, run, store, groceries): + run("set", "einkaufsliste", "--topic", "", "--as", "marge") + assert "topics:" not in store.text(groceries) + + def test_a_new_walking_order_reorders_the_sections(self, run, store, groceries): + run("add", "einkaufsliste", "Γ„pfel", "--section", "GemΓΌse & Obst", "--as", "marge") + run("add", "einkaufsliste", "Milch", "--section", "Milchprodukte & Eier", "--as", "marge") + run("set", "einkaufsliste", "--section", "Milchprodukte & Eier", "--section", "GemΓΌse & Obst", + "--as", "marge") + body = store.text(groceries).split("---\n", 2)[2] + assert body.index("Milchprodukte") < body.index("GemΓΌse") + + def test_nothing_to_change_is_refused(self, run, groceries): + assert run("set", "einkaufsliste", "--as", "marge")["error"].startswith("name what to change") + + class TestShowingAList: def test_items_appear_under_their_sections_with_who_and_when(self, run, groceries, capsys): run("add", "einkaufsliste", "Joghurt", "--section", "Milchprodukte & Eier", From 10704949171c4a4e651d7979a25075ca0ba74338 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 12 Oct 2026 14:24:38 +0200 Subject: [PATCH 11/17] fix(agent): recognize a German topic room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent took a room's topic from its name, but only knew the English "Topic:" prefix and dropped every non-ASCII letter, so a room called "Thema: Einkauf" became `thema-einkauf` and matched nothing. It also needed the topic's wiki page, which a room kept only for lists does not have. The agent now derives the topic the way the archivist binds it (both prefixes, "Γ€" folded to "a"), held to the archivist's answers by a test, and knows a topic that has a page or a list naming it. The brief offers the topic's overview only when there is one. Refs: #194 --- stacklets/agent/runtime/brief.py | 89 +++++++++++++------ .../unit/stacklets/test_agent_brief_lists.py | 54 ++++++++++- 2 files changed, 113 insertions(+), 30 deletions(-) diff --git a/stacklets/agent/runtime/brief.py b/stacklets/agent/runtime/brief.py index 37d54ef7..a3b144fe 100644 --- a/stacklets/agent/runtime/brief.py +++ b/stacklets/agent/runtime/brief.py @@ -22,6 +22,7 @@ import re import subprocess +import unicodedata from pathlib import Path # Strip footnote/citation markers the curator leaves in generated pages, e.g. @@ -55,20 +56,24 @@ def _lead(path: Path, maxwords: int = 32) -> str: def _lists_with_open_items(vault: Path, topic: str) -> list[str]: - """Slugs of the lists that name `topic` and have an open item. + """Slugs of the lists that name `topic` and have an open item.""" + return [path.stem for path in _lists_naming(vault, topic) + if re.search(r"^\s*[-*+] \[ \] ", path.read_text(errors="ignore"), re.M)] - Read off the list files (`family/lists/.md`) without the - framework, which this container does not carry: the `topics:` block - of the frontmatter and the open checkboxes are all it needs. + +def _lists_naming(vault: Path, topic: str) -> list[Path]: + """The list files (`family/lists/.md`) whose `topics:` name `topic`. + + Read without the framework, which this container does not carry: the + `topics:` block of the frontmatter is all it needs. """ found = [] for path in sorted((vault / "family" / "lists").glob("*.md")): - text = path.read_text(errors="ignore") - head, _, body = text.partition("\n---\n") - topics = re.search(r"^topics:\n((?: - .*\n?)+)", head, re.M) - named = [ln.strip()[2:].strip().strip('"') for ln in topics[1].splitlines()] if topics else [] - if topic in named and re.search(r"^\s*[-*+] \[ \] ", body, re.M): - found.append(path.stem) + head = path.read_text(errors="ignore").partition("\n---\n")[0] + block = re.search(r"^topics:\n((?: - .*\n?)+)", head, re.M) + named = [ln.strip()[2:].strip().strip('"') for ln in block[1].splitlines()] if block else [] + if topic in named: + found.append(path) return found @@ -85,19 +90,50 @@ def _recent(vault: Path, topic: str, n: int = 3) -> list[str]: return [s for s in out if "docs(memory): refresh" not in s][:n] +# The archivist binds a room named "Thema: " or "Topic: " to the +# topic it derives from the name (stacklets/docs/bot/topic_rooms.py). This +# container cannot import that module, so the rule is restated here, and a +# unit test holds the two to the same answers. A room renamed after it was +# bound keeps its first slug, which a name cannot tell; such a room is missed. +_TOPIC_ROOM = re.compile(r"^\s*(?:thema|topic)\s*:\s*(?P\S.*?)\s*$", re.IGNORECASE) +_MAX_SLUG_CHARS = 40 + + +def topic_slug(display: str) -> str: + """The slug the archivist gives a topic room named `display`: `EinkΓ€ufe` β†’ `einkaufe`.""" + folded = unicodedata.normalize("NFKD", display) + folded = "".join(ch for ch in folded if not unicodedata.combining(ch)).lower() + folded = re.sub(r"[^a-z0-9]+", "-", folded).strip("-") + if len(folded) > _MAX_SLUG_CHARS: + folded = folded[:_MAX_SLUG_CHARS] + if (cut := folded.rfind("-")) > 0: + folded = folded[:cut] + folded = folded.strip("-") + return folded + + def _slug_candidates(raw: str) -> list[str]: - """Map a Matrix room label to a vault topic slug, best-effort. + """The topic slugs a room label could stand for, the archivist's first. + + Besides `Thema: …` and `Topic: …`, a label can be a room's alias + (`#topic-camping`) or a bot-runner room's name (`Camping Room`). + """ + label = str(raw).strip() + candidates = [] + if room := _TOPIC_ROOM.match(label): + candidates.append(topic_slug(room["body"])) + alias = re.sub(r"^topic[-\s]+", "", label.lstrip("#").strip(), flags=re.I) + candidates.append(topic_slug(re.sub(r"\s+Room$", "", alias, flags=re.I))) + return [c for c in dict.fromkeys(candidates) if c] + + +def _topic_known(vault: Path, slug: str) -> bool: + """A topic the vault knows: it has a page, or a list belongs to it. - Topic rooms are named 'Topic: ' with alias '#topic-<slug>'; bot-runner - rooms are '<Name> Room'. Strip those markers, then kebab-case. (Titles with - non-ASCII characters won't round-trip cleanly here - the canonical alias is - the robust source; using it is a follow-up.) + A room kept only for lists, such as a shopping room, has no page until + something is filed there, and is still the place its lists belong. """ - s = str(raw).strip().lstrip("#").strip() - s = re.sub(r"^topic[:\-\s]+", "", s, flags=re.I) # "Topic: X" or "topic-x" - s = re.sub(r"\s+Room$", "", s, flags=re.I) - base = re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-") - return [base] if base else [] + return (vault / "family" / slug / "about.md").exists() or bool(_lists_naming(vault, slug)) def topic_for_room_label(raw: str, vault: Path) -> str: @@ -109,10 +145,7 @@ def topic_for_room_label(raw: str, vault: Path) -> str: guessing wrong produces a bot that promises to describe a topic that does not exist. """ - for cand in _slug_candidates(raw): - if (vault / "family" / cand / "about.md").exists(): - return cand - return "" + return next((c for c in _slug_candidates(raw) if _topic_known(vault, c)), "") def _topic_slug(msg, vault: Path) -> str: @@ -155,8 +188,9 @@ def brief_lines(msg, workspace) -> list[str]: # Pointers, not data: name the topic and how to fetch its overview, so the # model reads the real page instead of reciting a stale snippet (the trap # the todo list was). Consistent with the todos pointer just below. - lines.append(f"This room's topic is '{topic}'; run " - f"`stack memory topic {topic}` for its overview.") + overview = (vault / "family" / topic / "about.md").exists() + lines.append(f"This room's topic is '{topic}'" + + (f"; run `stack memory topic {topic}` for its overview." if overview else ".")) lists = _lists_with_open_items(vault, topic) if lists: # Presence, not the data, and not even the count: a number is still @@ -167,6 +201,7 @@ def brief_lines(msg, workspace) -> list[str]: recent = _recent(vault, topic) if recent: lines.append("Recently: " + "; ".join(recent)) - lines.append(f"(Full topic: vault/family/{topic}/)") + if overview: + lines.append(f"(Full topic: vault/family/{topic}/)") return lines diff --git a/tests/unit/stacklets/test_agent_brief_lists.py b/tests/unit/stacklets/test_agent_brief_lists.py index d2aebc9b..fd6baf7d 100644 --- a/tests/unit/stacklets/test_agent_brief_lists.py +++ b/tests/unit/stacklets/test_agent_brief_lists.py @@ -1,8 +1,9 @@ -"""The agent's brief names the topic's lists that have something open. +"""What the agent's brief says about the room's topic and its lists. A pointer, not the items: the brief goes stale within the turn, so it says which lists to fetch and how, and the agent reads the live list -when someone asks. +when someone asks. Which topic a room is follows the archivist's rule +for topic rooms, `Thema: Einkauf` or `Topic: Camping`. """ from __future__ import annotations @@ -11,10 +12,14 @@ from pathlib import Path from types import SimpleNamespace +import pytest + _REPO_ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "agent" / "runtime")) +sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) -from brief import brief_lines # noqa: E402 +from brief import brief_lines, topic_for_room_label # noqa: E402 +from topic_rooms import parse_topic_name # noqa: E402 PACKING = "---\ntype: list\ntitle: Packliste\ntopics:\n - camping\n---\n\n- [ ] Zelt\n" DONE = "---\ntype: list\ntitle: Erledigt\ntopics:\n - camping\n---\n\n- [x] Gas\n" @@ -36,3 +41,46 @@ def _brief(tmp_path) -> list[str]: def test_names_only_the_topics_lists_with_open_items(tmp_path): assert ("This topic's lists with open items: packliste; run " "`stack memory list show <list>` to see one.") in _brief(tmp_path) + + +# ── Which topic a room is ──────────────────────────────────────────────── + +ROOM_NAMES = ["Thema: Einkauf", "Thema: EinkΓ€ufe & Besorgungen", "Topic: Itchy & Scratchy Land", + "thema:CafΓ© MΓΌller", "Topic: " + "Ein sehr langer Name fΓΌr ein Thema " * 3] + + +def _vault_with_list(tmp_path, topic: str) -> Path: + lists = tmp_path / "family" / "lists" + lists.mkdir(parents=True) + (lists / "einkaufsliste.md").write_text( + f"---\ntype: list\ntitle: Einkaufsliste\ntopics:\n - {topic}\n---\n\n- [ ] Milch\n") + return tmp_path + + +def test_a_german_topic_room_is_the_topic_the_archivist_bound_it_to(tmp_path): + assert topic_for_room_label("Thema: Einkauf", _vault_with_list(tmp_path, "einkauf")) == "einkauf" + + +def test_a_room_kept_only_for_lists_is_known_by_its_lists(tmp_path): + """No page yet, because nothing was filed there; its list makes it a topic.""" + vault = _vault_with_list(tmp_path, "einkaufe-besorgungen") + assert topic_for_room_label("Thema: EinkΓ€ufe & Besorgungen", vault) == "einkaufe-besorgungen" + + +def test_a_room_no_topic_knows_is_none(tmp_path): + assert topic_for_room_label("Thema: Garten", _vault_with_list(tmp_path, "einkauf")) == "" + + +@pytest.mark.parametrize("name", ROOM_NAMES) +def test_the_agent_and_the_archivist_derive_the_same_topic(tmp_path, name): + """The agent cannot import the archivist's rule, so it restates it; this holds them together.""" + slug = parse_topic_name(name).slug + assert topic_for_room_label(name, _vault_with_list(tmp_path, slug)) == slug + + +def test_the_brief_offers_no_overview_a_topic_does_not_have(tmp_path): + vault = tmp_path / "vault" + _vault_with_list(vault, "einkauf") + lines = brief_lines(SimpleNamespace(metadata={"room": "Thema: Einkauf"}, sender_id=""), tmp_path) + assert lines[0] == "This room's topic is 'einkauf'." + assert "einkaufsliste" in lines[1] From bc7056beee8fe588e533b1e452a7b281baa10e98 Mon Sep 17 00:00:00 2001 From: Test <test@famstack.local> Date: Mon, 12 Oct 2026 14:52:24 +0200 Subject: [PATCH 12/17] feat(memory): tick an item without naming its list "Ich habe den Erlaubniszettel unterschrieben" says nothing about the Education list the archivist put the item on, and `tick` needed the list. `tick`, `untick` and `remove` now take it as optional, as `add` does: without one, each item is looked up on every list. - An item found on one list is changed there, and the answer names it. - Items on different lists are each changed on theirs. - An item open on one list and ticked on another is the one the verb would change. - An item still on several lists is refused with each list and its matches, so the caller names the list. The agent's `list_edit` tool leaves the list out when nobody said which. Refs: #194 --- stacklets/agent/runtime/list_tool.py | 7 +- .../workspace/skills/family-memory/SKILL.md | 2 +- stacklets/memory/cli/list.py | 84 +++++++++++++++---- .../unit/stacklets/test_agent_vault_tools.py | 1 + tests/unit/stacklets/test_memory_list_cli.py | 35 ++++++++ 5 files changed, 110 insertions(+), 19 deletions(-) diff --git a/stacklets/agent/runtime/list_tool.py b/stacklets/agent/runtime/list_tool.py index fc9f5cdb..f0f40269 100644 --- a/stacklets/agent/runtime/list_tool.py +++ b/stacklets/agent/runtime/list_tool.py @@ -26,9 +26,10 @@ tool_parameters_schema( list=StringSchema( "The list's name, such as einkaufsliste. `stack memory list " - "show` names every list with its description. For add it may " - "be left out: an item the family had before goes to the list " - "it was last bought from.", + "show` names every list with its description. Leave it out " + "when nobody said which list: add puts a known item where it " + "was last bought, and tick, untick or remove find the item on " + "whichever list it is.", nullable=True, ), topic=StringSchema( diff --git a/stacklets/agent/workspace/skills/family-memory/SKILL.md b/stacklets/agent/workspace/skills/family-memory/SKILL.md index 7edf8656..092fde1c 100644 --- a/stacklets/agent/workspace/skills/family-memory/SKILL.md +++ b/stacklets/agent/workspace/skills/family-memory/SKILL.md @@ -32,7 +32,7 @@ SOURCES (answer used a vault page, searched or read): LISTS: item op add|tick|untick|remove -> list_edit(list, op, items=[one or more]) add, list not said -> leave list out (topic room: topic=<topic>); known items find their list - "we did that" -> tick, never remove + "we did that" -> tick, never remove; list not said -> leave list out "bought all|clear the list" -> tick what they got; ticked items leave the list overnight "start the week|fresh list" -> list_edit op=reset # reopens all belongs on another list -> op=move, to=<list> diff --git a/stacklets/memory/cli/list.py b/stacklets/memory/cli/list.py index bd080cde..6ffe4693 100644 --- a/stacklets/memory/cli/list.py +++ b/stacklets/memory/cli/list.py @@ -71,6 +71,7 @@ stack memory list add schule "Sign the field trip form" --for homer --for marge \\ --due 2026-10-20 --as marge stack memory list tick|untick|remove einkaufsliste Joghurt --as homer + stack memory list tick "Erlaubniszettel" --as marge on whichever list it is stack memory list move einkaufsliste Eier --to markt --as marge stack memory list create packliste --title Packliste --recurring --as marge ticked items stay until a reset @@ -122,9 +123,10 @@ def _parser() -> argparse.ArgumentParser: add.add_argument("--for", dest="people", action="append", default=[]) add.add_argument("--due", default="") + # As for `add`, the list is optional: a first word naming a list is + # the list, and without one the item is looked up on every list. for verb in ("tick", "untick", "remove"): one = verbs.add_parser(verb, add_help=False) - one.add_argument("slug") one.add_argument("items", nargs="+") move = verbs.add_parser("move", add_help=False) @@ -325,10 +327,7 @@ def add(vault: Path, config, ns, actor: str) -> dict: One commit per list. """ lists = _all_lists_at(vault) - names = [" ".join(n.split()) for n in ns.items if n.strip()] - slug = names.pop(0) if names and any(lst.slug == names[0] for _, lst in lists) else "" - if not names: - raise Refusal("name at least one item to add") + slug, names = _list_and_items(lists, ns.items) if slug: candidates = [(p, lst) for p, lst in lists if lst.slug == slug] else: @@ -352,12 +351,8 @@ def add(vault: Path, config, ns, actor: str) -> dict: if due and not _ISO_DAY.match(due): raise Refusal(f"--due {due!r} is not a date like 2026-10-14") people = _people(vault, config, ns.people) - results = [_add_to(config, *by_slug[target], entries, people, due, actor) - for target, entries in plan.items()] - if len(results) == 1: - return results[0] - errors = [r["error"] for r in results if "error" in r] - return {"error": "; ".join(errors)} if errors else {"ok": True, "lists": results} + return _combined([_add_to(config, *by_slug[target], entries, people, due, actor) + for target, entries in plan.items()]) def _add_to(config, path: str, lst: FamilyList, entries: list[tuple[str, str]], @@ -382,23 +377,82 @@ def _which_list(name: str, candidates: list[tuple[str, FamilyList]], topic: str) def change(vault: Path, config, ns, actor: str) -> dict: - """tick, untick, remove or reset on one list, in one commit.""" - path, lst = _find_list(vault, ns.slug) + """tick, untick, remove or reset; one commit per list it touches. + + Without a list, each item is looked up on every list, so "I signed the + permission slip" ticks it on whichever list the archivist put it. + """ + if ns.verb == "reset": + return _change_list(config, *_find_list(vault, ns.slug), "reset", [], actor) + lists = _all_lists_at(vault) + slug, names = _list_and_items(lists, ns.items) + if slug: + return _change_list(config, *_find_list(vault, slug), ns.verb, names, actor) + plan: dict[str, tuple[FamilyList, list[str]]] = {} + for name in names: + path, lst = _locate(lists, name, ns.verb) + plan.setdefault(path, (lst, []))[1].append(name) + return _combined([_change_list(config, path, lst, ns.verb, found, actor) + for path, (lst, found) in plan.items()]) + + +def _change_list(config, path: str, lst: FamilyList, verb: str, names: list[str], + actor: str) -> dict: + """One verb on one list, in one commit.""" today = date.today().isoformat() outcome: dict = {} def _apply(prior: str) -> str: current = read_list(prior, path=path) - if ns.verb == "reset": + if verb == "reset": new, said = _reset(prior, current, today, path) else: - new, said = _each(prior, current.title, ns.verb, ns.items, today, path) + new, said = _each(prior, current.title, verb, names, today, path) outcome["said"] = said return new return _commit(config, path, _apply, actor, lambda: outcome.get("said", "changed the list")) +def _locate(lists: list[tuple[str, FamilyList]], name: str, verb: str) -> tuple[str, FamilyList]: + """The list holding the item `name` means, when the caller named none. + + An item open on one list and ticked on another is the one the verb + would change; anything still on several lists is refused with each + list and its matches, so the caller names the list. + """ + found = [(path, lst) for path, lst in lists if find(lst, name)] + if len(found) > 1: + wanted_done = verb == "untick" + changeable = [(path, lst) for path, lst in found + if any(i.done == wanted_done for i in find(lst, name))] + found = changeable if len(changeable) == 1 else found + if not found: + raise Refusal(f"no item matching {name!r} on any list") + if len(found) > 1: + where = "; ".join(f"{lst.slug}: {', '.join(i.text for i in find(lst, name))}" + for _, lst in found) + raise Refusal(f"{name!r} is on several lists ({where}). Name the list.") + return found[0] + + +def _list_and_items(lists: list[tuple[str, FamilyList]], words: list[str]) -> tuple[str, list[str]]: + """The list a command names first, if any, and the items after it.""" + names = [" ".join(w.split()) for w in words if w.strip()] + slug = names.pop(0) if names and any(lst.slug == names[0] for _, lst in lists) else "" + if not names: + raise Refusal("name at least one item") + return slug, names + + +def _combined(results: list[dict]) -> dict: + """One answer for changes to several lists: each list's, or every error.""" + if len(results) == 1: + return results[0] + errors = [r["error"] for r in results if "error" in r] + return {"error": "; ".join(errors)} if errors else {"ok": True, "lists": results} + + def move(vault: Path, config, slug: str, names: list[str], to: str, actor: str) -> dict: """Move items to another list: added there first, so a failure never loses one.""" path, lst = _find_list(vault, slug) diff --git a/tests/unit/stacklets/test_agent_vault_tools.py b/tests/unit/stacklets/test_agent_vault_tools.py index e9b0e4b8..87b574ba 100644 --- a/tests/unit/stacklets/test_agent_vault_tools.py +++ b/tests/unit/stacklets/test_agent_vault_tools.py @@ -178,6 +178,7 @@ def test_the_allowlist_stays_read_only(api): {"op": "add", "items": ["Joghurt"], "topic": "einkaeufe-besorgungen"}), ("list_edit", "list", {"op": "create", "title": "Baumarkt", "items": ["Schrauben"]}), + ("list_edit", "list", {"op": "tick", "items": ["Erlaubniszettel"]}), ("list_edit", "list", {"list": "einkaufsliste", "op": "set", "topic": "einkauf"}), ("list_edit", "list", diff --git a/tests/unit/stacklets/test_memory_list_cli.py b/tests/unit/stacklets/test_memory_list_cli.py index 034ceeef..89d987a3 100644 --- a/tests/unit/stacklets/test_memory_list_cli.py +++ b/tests/unit/stacklets/test_memory_list_cli.py @@ -263,6 +263,41 @@ def test_reset_reopens_everything_ticked(self, run, store, stocked): assert "- [x]" not in store.text(stocked) +class TestTickingWithoutNamingTheList: + """"I signed the permission slip" ticks it on whichever list the archivist put it.""" + + @pytest.fixture + def school(self, run, groceries): + run("create", "education", "--title", "Education", "--as", "marge") + run("add", "education", "Sign the permission slip", "Pay the trip fee", "--as", "marge") + run("add", "einkaufsliste", "Milch", "--as", "marge") + + def test_an_item_is_found_on_its_list(self, run, store, school): + result = run("tick", "permission slip", "--as", "marge") + assert result["summary"] == "ticked off Sign the permission slip on Education" + assert "- [x] Sign the permission slip" in store.text("family/lists/education.md") + + def test_items_on_different_lists_are_each_ticked_there(self, run, school): + result = run("tick", "trip fee", "Milch", "--as", "homer") + assert [r["summary"] for r in result["lists"]] == [ + "ticked off Pay the trip fee on Education", "ticked off Milch on Einkaufsliste"] + + def test_the_open_one_is_ticked_when_another_list_has_it_ticked(self, run, store, school): + run("add", "education", "Milch", "--as", "marge") + run("tick", "education", "Milch", "--as", "marge") + run("tick", "Milch", "--as", "homer") + assert "- [x] Milch" in store.text(GROCERIES) + + def test_an_item_open_on_two_lists_is_refused_with_both(self, run, school): + run("add", "education", "Milch", "--as", "marge") + result = run("tick", "Milch", "--as", "homer") + assert result["error"] == ("'Milch' is on several lists (education: Milch; " + "einkaufsliste: Milch). Name the list.") + + def test_an_item_on_no_list_is_refused(self, run, school): + assert run("tick", "Kaffee", "--as", "homer")["error"] == "no item matching 'Kaffee' on any list" + + class TestRemovingAndMoving: def test_removing_the_last_item_of_a_section_removes_its_heading(self, run, store, groceries): run("add", "einkaufsliste", "Γ„pfel", "--section", "GemΓΌse & Obst", "--as", "marge") From 212405c182132d7f752309bb1c8f5d0d54b44402 Mon Sep 17 00:00:00 2001 From: Test <test@famstack.local> Date: Mon, 12 Oct 2026 15:06:43 +0200 Subject: [PATCH 13/17] fix(memory): refuse adding or ticking items by writing a list page The agent added groceries by rewriting the list page instead of calling `list_edit`, which skips what the list commands do: Joghurt landed on the list twice, coffee under bread, and a following tick caught only one of the two. `stack memory write`, whole page or patch, now refuses a change to a list page that adds or ticks off items and points to `list_edit`. Writing a list page stays the way to restructure it: rewording an item, moving it under another heading, renaming a section. Refs: #194 --- stacklets/memory/cli/write.py | 45 ++++++++++++------- tests/unit/stacklets/test_memory_write_cli.py | 42 +++++++++++------ 2 files changed, 58 insertions(+), 29 deletions(-) diff --git a/stacklets/memory/cli/write.py b/stacklets/memory/cli/write.py index 40ecfb21..47735d19 100644 --- a/stacklets/memory/cli/write.py +++ b/stacklets/memory/cli/write.py @@ -170,21 +170,8 @@ def _replace(prior: str) -> str: # becomes the error message the caller relays to the model. _check_frontmatter(seen["before"], seen["after"], repo_path) _check_person(seen["after"], repo_path) - # A whole-page rewrite of a list is a restructure. A restructure - # never legitimately reopens or removes an item (measured - # 2026-09-15: rewrites drop [x] marks, and prompt rules do not - # reliably repair them). Targeted edits go through --patch or - # `stack memory list`, which stay allowed. - if not as_patch and _is_list(repo_path): - change = diff(seen["before"], seen["after"]) - lost = list(change.reopened) + list(change.removed) - if lost: - raise ValueError( - f"this rewrite reopens or removes items: " - f"{'; '.join(lost)}. A restructure keeps every item " - "and every [x]. Resend the full page with them " - "unchanged. To reopen or remove an item on purpose, " - "use list_edit.") + if _is_list(repo_path): + _check_list_write(diff(seen["before"], seen["after"]), as_patch=as_patch) # A preview still wants the *current* page to compare against, so it # takes the same trip and then hands back what was already there: # an unchanged file is a no-op, and a no-op does not commit. @@ -240,6 +227,34 @@ def _replace(prior: str) -> str: } +def _check_list_write(change, *, as_patch: bool) -> None: + """Refuse a write to a list page that does what only `list_edit` should. + + Writing a list page is for restructuring it: rewording an item, moving + it under another heading, renaming a section. Adding or ticking off an + item through it skips what the list commands do (the duplicate check, + the item's section, the day of the tick), and an agent that took this + route put Joghurt on a list twice and its coffee under bread. A whole- + page rewrite also never legitimately reopens or removes an item + (measured 2026-09-15: rewrites drop [x] marks, and prompt rules do not + reliably repair them); a patch may, on purpose. + """ + items = [i.text for i in change.added] + list(change.struck) + if items: + raise ValueError( + f"this write adds or ticks off items: {'; '.join(items)}. Item " + "changes go through list_edit (add, tick), which keeps their " + "sections and finds duplicates. Write a list page only to " + "restructure it.") + lost = list(change.reopened) + list(change.removed) + if lost and not as_patch: + raise ValueError( + f"this rewrite reopens or removes items: {'; '.join(lost)}. A " + "restructure keeps every item and every [x]. Resend the full " + "page with them unchanged. To reopen or remove an item on " + "purpose, use list_edit.") + + def _is_list(repo_path: str) -> bool: """A list page: a topic's `todos.md`, or a file under `<bucket>/lists/`.""" return repo_path.endswith("todos.md") or f"/{LISTS_DIR}/" in repo_path diff --git a/tests/unit/stacklets/test_memory_write_cli.py b/tests/unit/stacklets/test_memory_write_cli.py index 94884102..9d6e1cb5 100644 --- a/tests/unit/stacklets/test_memory_write_cli.py +++ b/tests/unit/stacklets/test_memory_write_cli.py @@ -112,12 +112,12 @@ def _run(store, buffer_text, *flags, path="family/camping/todos.md", by="marge", def test_a_rewrite_replaces_the_page_and_says_what_it_did(store, tmp_path): """The receipt is the point: an opaque rewrite becomes a reviewable one.""" - result = _run(store, PAGE.replace("- [ ] Wetter", "- [x] Wetter"), tmp=tmp_path) + result = _run(store, PAGE.replace("Wetter checken", "Wetterbericht checken"), tmp=tmp_path) assert result["committed"] is True - assert "ticked off" in result["summary"] - assert "Wetter checken" in result["summary"] - assert store.page.count("- [x] Wetter checken") == 1 + assert "reworded" in result["summary"] + assert "Wetterbericht checken" in result["summary"] + assert store.page.count("- [ ] Wetterbericht checken") == 1 def test_a_rewrite_that_drops_an_item_is_refused(store, tmp_path): @@ -144,12 +144,12 @@ def _edits(*pairs): def test_a_patch_changes_only_what_it_names(store, tmp_path): """Everything the patch did not mention comes back untouched.""" - result = _run(store, _edits(("- [ ] Wetter checken", "- [x] Wetter checken")), + result = _run(store, _edits(("- [ ] Wetter checken", "- [ ] Wetterbericht checken")), "--patch", tmp=tmp_path) assert result["committed"] is True - assert store.page == PAGE.replace("- [ ] Wetter checken", "- [x] Wetter checken") - assert "ticked off" in result["summary"] + assert store.page == PAGE.replace("- [ ] Wetter checken", "- [ ] Wetterbericht checken") + assert "reworded" in result["summary"] def test_a_patch_is_matched_against_the_page_as_it_is_now(store, tmp_path): @@ -199,12 +199,12 @@ def test_a_preview_reports_the_change_without_making_it(store, tmp_path): A dropped flag turns "show me what this would do" into a change to the family's list. """ - result = _run(store, _edits(("- [ ] Wetter checken", "- [x] Wetter checken")), + result = _run(store, _edits(("- [ ] Wetter checken", "- [ ] Wetterbericht checken")), "--patch", "--dry-run", tmp=tmp_path) assert result["preview"] is True assert result["committed"] is False - assert "ticked off" in result["summary"], "a preview still says what it would do" + assert "reworded" in result["summary"], "a preview still says what it would do" assert store.page == PAGE, "the page must be untouched" assert store.commits == [] @@ -230,12 +230,10 @@ def test_the_commit_says_what_the_edit_did(store, tmp_path): anyone actually asks. What changed is already known at the moment of writing, so it costs nothing to record it where it lasts. """ - _run(store, _edits(("- [ ] Wetter checken", "- [x] Wetter checken")), + _run(store, _edits(("- [ ] Wetter checken", "- [ ] Wetterbericht checken")), "--patch", tmp=tmp_path) - assert store.last_subject == ( - "chore(memory): marge ticked off 1: Wetter checken in camping" - ) + assert "reworded 1: Wetter checken -> Wetterbericht checken" in store.last_subject def test_the_subject_names_the_topic_not_the_path(store, tmp_path): @@ -315,7 +313,7 @@ def test_a_write_the_readers_already_show_reports_plainly(store, tmp_path, capsy def test_a_write_the_readers_have_not_caught_up_with_is_still_done(store, tmp_path, capsys): store.mirrored = False - result = _run(store, PAGE.replace("- [ ] Wetter", "- [x] Wetter"), tmp=tmp_path) + result = _run(store, PAGE.replace("Wetter checken", "Wetterbericht checken"), tmp=tmp_path) assert result["committed"] is True, "the commit landed; only the view lags" assert result["mirrored"] is False @@ -351,3 +349,19 @@ def test_a_list_file_is_guarded_like_a_todo_page(store, tmp_path): result = _run(store, "# Camping\n\n- [ ] Wetter checken\n", path="family/lists/camping.md", tmp=tmp_path) assert "KΓΌhlbox mitbringen" in result["error"] + + +def test_adding_or_ticking_items_through_a_page_write_is_refused(store, tmp_path): + """Item changes go through list_edit, which keeps sections and finds duplicates. + + An agent that rewrote the page to add groceries put one item on the list + twice and another under the wrong section. + """ + added = _run(store, PAGE + "- [ ] Joghurt\n", tmp=tmp_path) + ticked = _run(store, _edits(("- [ ] Wetter checken", "- [x] Wetter checken")), + "--patch", tmp=tmp_path) + + assert "Joghurt" in added["error"] and "list_edit" in added["error"] + assert "Wetter checken" in ticked["error"] + assert store.commits == [] + From fe1d8784cb690a0db470d223ce8bb471f3c40dd2 Mon Sep 17 00:00:00 2001 From: Test <test@famstack.local> Date: Mon, 12 Oct 2026 15:07:25 +0200 Subject: [PATCH 14/17] fix(agent): show lists in chat without link markup or old ticks In a test with the family's phrasing the agent read list files to show them, so link markup and every old tick reached the chat in a format that changed from answer to answer; it removed an item the family said they would buy elsewhere instead of moving it; and with two ticket items on a list it ticked one without asking. The skill now shows lists through `stack memory list show`, reads "ich hole X lieber auf dem Markt" as a move, asks when two items fit, and names the list and the item when it confirms a change. Refs: #194 --- stacklets/agent/workspace/skills/family-memory/SKILL.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/stacklets/agent/workspace/skills/family-memory/SKILL.md b/stacklets/agent/workspace/skills/family-memory/SKILL.md index 092fde1c..f58d6840 100644 --- a/stacklets/agent/workspace/skills/family-memory/SKILL.md +++ b/stacklets/agent/workspace/skills/family-memory/SKILL.md @@ -30,23 +30,26 @@ SOURCES (answer used a vault page, searched or read): max 3 sources; nothing from vault -> no Sources line LISTS: + "what is on X|which lists" -> exec `stack memory list show [<list>]`; never read_file to show a list item op add|tick|untick|remove -> list_edit(list, op, items=[one or more]) add, list not said -> leave list out (topic room: topic=<topic>); known items find their list "we did that" -> tick, never remove; list not said -> leave list out "bought all|clear the list" -> tick what they got; ticked items leave the list overnight "start the week|fresh list" -> list_edit op=reset # reopens all - belongs on another list -> op=move, to=<list> + belongs on another list -> op=move, to=<list>, never remove + "hole X lieber auf dem Markt" | "kaufe X woanders" | "X gibt es bei Y" -> move no list fits (a new store) -> op=create list=<slug> title about topic sections, then add "belongs here|rename|new order|new sections" -> op=set list=<slug> with only what changes add to a list with sections -> section = one of the list's own "for <name>" | "by <day>" -> add with people=[name] | due=YYYY-MM-DD; never guess either + two items fit what was said -> ask which one, never pick answer "ambiguous|no item|no section|no list|nobody called" -> pick from the named choices, retry once restructure set cannot do (merge two lists, split a section) -> read_file then write_file(complete page) keep: frontmatter verbatim at column one, every [x], their order, their words write answer reports unintended unticked|REMOVED -> restore now, then say so - edit answer = the truth -> relay it in one line + edit answer = the truth -> relay it in one line, naming the list and the item edit answer prints a link line -> that page goes in Sources no tool call + answer read -> nothing happened, whatever I believe edits commit as the person I reply to From 2df021ba69abb0422ddc6a6e5793160307654fb2 Mon Sep 17 00:00:00 2001 From: Test <test@famstack.local> Date: Mon, 12 Oct 2026 15:07:25 +0200 Subject: [PATCH 15/17] fix(memory): count the items in a long list change's commit subject A change whose sentence did not fit a commit subject was recorded as "marge changed family/lists/einkaufsliste.md". The subject now counts the items and names the list ("marge ticked off 3 items on Einkaufsliste"), with the full sentence in the body. Refs: #194 --- stacklets/memory/cli/list.py | 30 ++++++++++++++++++-- tests/unit/stacklets/test_memory_list_cli.py | 9 ++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/stacklets/memory/cli/list.py b/stacklets/memory/cli/list.py index 6ffe4693..fe0a5c08 100644 --- a/stacklets/memory/cli/list.py +++ b/stacklets/memory/cli/list.py @@ -598,11 +598,37 @@ def _said() -> str: def _commit_message(actor: str, path: str, said: str) -> str: - """`chore(lists): marge added Joghurt to Einkaufsliste`; a long one moves to the body.""" + """`chore(lists): marge added Joghurt to Einkaufsliste`, short enough for a subject. + + When the sentence is too long, the subject counts the items instead of + naming them and the whole sentence moves to the body, so the history + still says what changed and to which list. + """ line = f"chore(lists): {actor} {said}" if len(line) <= _SUBJECT_MAX: return line - return f"chore(lists): {actor} changed {path}\n\n{said}" + return f"chore(lists): {actor} {_counted(said, path)}\n\n{said}" + + +# "moved A; B from Einkaufsliste to Wochenmarkt", "ticked off A; B on Einkaufsliste" +_MOVED = re.compile(r"^moved (?P<items>.+) from (?P<a>.+) to (?P<b>[^;]+)$") +_CHANGED = re.compile(r"^(?P<verb>added|ticked off|reopened|removed) (?P<items>.+) " + r"(?P<prep>to|on|from) (?P<list>[^;]+)$") + + +def _counted(said: str, path: str) -> str: + """The first clause of an answer with its items counted: `ticked off 3 items on Einkaufsliste`.""" + clause = said.split("; '")[0] + if m := _MOVED.match(clause): + return f"moved {_items(m['items'])} from {m['a']} to {m['b']}" + if m := _CHANGED.match(clause): + return f"{m['verb']} {_items(m['items'])} {m['prep']} {m['list']}" + return f"changed {path}" + + +def _items(names: str) -> str: + count = len(names.split("; ")) + return "1 item" if count == 1 else f"{count} items" # ── Small things ───────────────────────────────────────────────────────── diff --git a/tests/unit/stacklets/test_memory_list_cli.py b/tests/unit/stacklets/test_memory_list_cli.py index 89d987a3..e46bc4ef 100644 --- a/tests/unit/stacklets/test_memory_list_cli.py +++ b/tests/unit/stacklets/test_memory_list_cli.py @@ -325,6 +325,15 @@ def test_a_list_that_does_not_exist_is_refused_with_the_lists(self, run, groceri assert result["error"] == "no list 'baumarkt'. Lists: einkaufsliste" +class TestTheHistory: + def test_a_long_change_counts_its_items_in_the_subject(self, run, store, groceries): + names = ["Hafermilch ohne Zucker", "Vollkornbrot vom BΓ€cker", "Bio-Joghurt Natur"] + run("add", "einkaufsliste", *names, "--as", "marge") + subject, _, body = store.commits[-1][2].partition("\n\n") + assert subject == "chore(lists): marge added 3 items to Einkaufsliste" + assert body == f"added {'; '.join(names)} to Einkaufsliste" + + class TestChangingAList: def test_a_list_joins_a_topic_room_after_the_fact(self, run, store, groceries): run("add", "einkaufsliste", "Milch", "--as", "marge") From d6fbaa1e162f0b77a1a943e6c48858a2ab1e6317 Mon Sep 17 00:00:00 2001 From: Test <test@famstack.local> Date: Mon, 12 Oct 2026 15:08:05 +0200 Subject: [PATCH 16/17] fix(archivist): title a topic's new list with the topic's name A list the archivist started for a topic was titled from the topic's slug, so "Haus & Garten" became "Haus Garten". It now takes the topic's name as the document was classified with it. Refs: #194 --- stacklets/docs/bot/git_mirror.py | 8 ++++++-- tests/unit/stacklets/test_git_mirror.py | 9 +++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/stacklets/docs/bot/git_mirror.py b/stacklets/docs/bot/git_mirror.py index 87aca485..2bc03224 100644 --- a/stacklets/docs/bot/git_mirror.py +++ b/stacklets/docs/bot/git_mirror.py @@ -521,6 +521,7 @@ async def _fold_action_items_into_list( client: ForgejoClient, *, topic: str, + topic_name: str = "", record_path: str, record_title: str, action_items: list | None, @@ -531,7 +532,9 @@ async def _fold_action_items_into_list( ) -> bool: """Put a record's action items on its topic's list, `lists/<topic>.md`. - The list is started when the topic has none. Each item links the + The list is started when the topic has none, titled with the + topic's name as classified (`topic_name`, "Haus & Garten"), else + with its slug made readable. Each item links the record it came from. A record's items go on the list once: when the list already has items from it, a refile adds none, because the model words them differently each time and the family may @@ -550,7 +553,7 @@ async def _fold_action_items_into_list( client.get_file, self.repo_owner, REPO_NAME, path, ) prior = (existing or {}).get("content") or "" - title = topic.replace("-", " ").title() + title = topic_name or topic.replace("-", " ").title() content = prior or new_list( title, topics=[topic], description=topic_list_description(title, os.environ.get("LANGUAGE", ""))) @@ -596,6 +599,7 @@ async def _fold_document_todos( await self._fold_action_items_into_list( client, topic=self._slug(topic), + topic_name=topic.strip(), record_path=record_path, record_title=record_title, previous_path=previous_path, diff --git a/tests/unit/stacklets/test_git_mirror.py b/tests/unit/stacklets/test_git_mirror.py index 3d2c4a64..c4a1f224 100644 --- a/tests/unit/stacklets/test_git_mirror.py +++ b/tests/unit/stacklets/test_git_mirror.py @@ -523,6 +523,15 @@ async def test_a_refiled_record_adds_nothing_and_its_items_follow_a_rename(self, assert ("- [ ] Sign the slip [Permission slip]" "(../documents/2026/10/2026-10-09-school-notice-p72.md) πŸ“… 2026-10-23") in written + @pytest.mark.asyncio + async def test_a_new_list_is_titled_with_the_topics_name(self, mirror): + client = _FakeTodoClient() + await mirror._fold_document_todos( + client, classification={"topics": ["Haus & Garten"], "action_items": ["Hecke schneiden"]}, + record_path="family/documents/2026/10/hecke-p9.md", record_title="Hecke") + written = client.files["family/lists/haus-garten.md"]["content"] + assert "title: Haus & Garten\n" in written + @pytest.mark.asyncio async def test_a_personal_capture_adds_to_no_list(self, mirror): client = _FakeTodoClient() From beea04115c1c405bee139b36892ea1153c6aac9c Mon Sep 17 00:00:00 2001 From: Test <test@famstack.local> Date: Mon, 12 Oct 2026 15:08:48 +0200 Subject: [PATCH 17/17] docs(memory): explain household lists in the user guide The user guide named neither lists nor Stacky, so a family member could not learn from it that lists exist or how to keep them. A "Lists" section says how to start a list in walking order, add and tick items by chat without naming the list, move items, put a name and a day on one, use a recurring list, what letters and notes add, how a list belongs to a topic room, and what Stacky cannot do yet. Refs: #194 --- docs/user-guide.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/user-guide.md b/docs/user-guide.md index c5e7e80e..0b3604a2 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -32,6 +32,7 @@ Setting the server up, or keeping it running? That is the [Admin Guide](admin-gu | 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) | +| Keep the shopping list, or what is still to do | [Lists](#lists) | | Get email into the family chat | [Email](#email) | | Capture a thought without typing | [Voice memos](#voice-memos) | | Fix a wrong tag or title | [Correct bot mistakes](#correct-bot-mistakes) | @@ -125,6 +126,37 @@ Questions asked inside a topic room automatically search just that topic. Ask "A --- +## Lists + +> Needs the `memory` stacklet. Keeping lists by chat needs the `agent` stacklet (Stacky). + +The shopping list, the market, the camping packing list, what still has to happen for the school trip: every list the family keeps is a checklist that Stacky, the family agent, keeps for you. Talk to it in any room it is in, starting with its name. German works as well as English. + +**Start a list once, with the store in walking order.** The sections are the order you go through the shop, so the list reads top to bottom in the aisles: + +> Stacky, start a list "Supermarket" with the sections Fruit & Veg, Bread, Dairy, Frozen, Drugstore. + +Say what goes on a list when you start it ("the farmers' market on Saturday: vegetables, cheese, eggs"). That is how Stacky later knows where something new belongs. Changed your mind about the order? "Stacky, the drugstore comes first now." + +**Then just say what you need.** "Stacky, we need milk, eggs and toothpaste." Each item lands in its section. Something you have bought before goes back to the list and the section it had, so after the first week nobody has to say where. + +**Tick off as you shop.** "Stacky, I got the milk and the bread." You don't have to say which list. A ticked item stays visible for the rest of the day, so "did we get eggs?" still has an answer, and moves to the list's archive overnight. The list then shows only what is still open. + +More you can say: + +- "Stacky, I'll get the eggs at the market instead" moves them to the market list. +- "Stacky, Homer should book the tickets by Friday" puts a name and a day on an item. +- "Stacky, what's on the supermarket list?" shows what is still open. +- "Stacky, we're back from camping, reset the packing list." Start a list you use again and again (packing, the weekly chores) as a recurring list: its ticks stay until you reset it. + +**Letters and notes put their to-dos on lists too.** When a letter in **#Documents** asks you to do something ("sign the permission slip by 23 October"), the archivist puts it on the list of the letter's topic, with the day and a link back to the letter. A short note in your own words, pinned with πŸ“Œ in a [topic room](#topic-rooms) ("we should take out the trash by Friday"), does the same. Long pasted text and links never turn into to-dos: someone else's tips are not your chores. Tick these off with Stacky like anything else. + +**A list belongs to a topic.** In a topic room, Stacky uses that topic's lists. To tie an existing list to a room: "Stacky, the supermarket list belongs to this room." + +Every list is a page in the [family wiki](#the-family-wiki) and a plain Markdown checklist in the vault, so you can also tick items in Obsidian or in Forgejo's editor. Stacky does not hear voice messages or see photos yet: a screenshot of a list has to be typed in for now. + +--- + ## Email > Needs the `core` stacklet, plus `docs` and `code` to file what arrives. Connected by the admin (see the admin guide).