diff --git a/docs/design/brain/vault-format.md b/docs/design/brain/vault-format.md index 23053100..7428a5c0 100644 --- a/docs/design/brain/vault-format.md +++ b/docs/design/brain/vault-format.md @@ -84,7 +84,7 @@ 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. +- `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 +263,72 @@ 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. | +| `recurring` | O | `true` for a list used again and again (a packing list, the weekly chores): its ticked items stay until it is reset. | + +``` +--- +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; 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. 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) - A generated projection page MUST carry `generated: true`. @@ -274,11 +340,14 @@ presence. ## 7. State documents -`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. +`/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. + +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/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). 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/frontmatter.py b/lib/stack/frontmatter.py index 7f466fbc..ae0a21a6 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", "recurring"}, + ), } 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/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/lib/stack/lists.py b/lib/stack/lists.py new file mode 100644 index 00000000..a5b4f8a8 --- /dev/null +++ b/lib/stack/lists.py @@ -0,0 +1,602 @@ +"""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 +from .people import person_path + +LISTS_DIR = "lists" +ARCHIVE_DIR = "archive" + +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 + + @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: + """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(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 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, 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. + + `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) + recurring: bool = False + + @property + 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" + + +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, + recurring=fm.get("recurring") is True, + ) + + +def new_list(title: str, *, description: str = "", topics: list[str] | None = None, + 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" + + +# ── 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: + """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:] + + +# ── 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) + + +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 + + +# ── 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 + + +# ── 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]: + """(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 _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/agent/runtime/brief.py b/stacklets/agent/runtime/brief.py index 416a7e42..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. @@ -54,11 +55,26 @@ 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.""" + return [path.stem for path in _lists_naming(vault, topic) + if re.search(r"^\s*[-*+] \[ \] ", path.read_text(errors="ignore"), re.M)] + + +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")): + 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 def _recent(vault: Path, topic: str, n: int = 3) -> list[str]: @@ -74,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: @@ -98,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: @@ -144,17 +188,20 @@ 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.") - if _open_todos(vault / "family" / topic / "todos.md"): + 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 - # 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 <list>` to see one.") 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/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..f0f40269 100644 --- a/stacklets/agent/runtime/list_tool.py +++ b/stacklets/agent/runtime/list_tool.py @@ -24,30 +24,64 @@ @tool_parameters( tool_parameters_schema( - page=StringSchema( - "The list page, such as vault/family/groceries/todos.md.", - min_length=1, + list=StringSchema( + "The list's name, such as einkaufsliste. `stack memory list " + "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( + "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 or set, the list belongs to it.", + nullable=True, ), 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. create starts a new " + "list when none fits, such as a store the family has no list " + "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."), 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), + 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 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 or set: a store's sections in the order the family " + "walks through it. Leave out for a list without sections.", nullable=True, ), ) ) class ListEditTool(Tool): - """Change one item on a family list.""" + """Change items on a family list.""" _scopes = {"core"} @@ -58,10 +92,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, 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." ) @property @@ -70,28 +104,29 @@ def read_only(self) -> bool: async def execute( self, - page: str, op: str, - items: list[str] | None = None, + list: str | None = None, + topic: 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, + title: str | None = None, + about: str | None = None, + sections: "list[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]) + 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] proc = await asyncio.create_subprocess_exec( *args, @@ -101,11 +136,53 @@ 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 _item_args(op, slug, items, topic, section, people, due, to) -> "list[str]": + """`stack memory list <op> [<list>] <items>... [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 _details_args(op, slug, title, about, topic, sections) -> "list[str]": + """`stack memory list create|set [<list>] --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()) + if title or op == "create": + 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: 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..f58d6840 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/<name>/about.md - topic -> vault/family/<topic>/about.md ; items in .../todos.md + topic -> vault/family/<topic>/about.md + list -> vault/family/lists/<list>.md ; which lists exist -> `stack memory list show` search prints vault-relative paths -> read as vault/<path> SOURCES (answer used a vault page, searched or read): @@ -29,15 +30,26 @@ 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] - "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 - restructure ONLY -> read_file then write_file(complete page) + "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>, 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 diff --git a/stacklets/core/famstack-api.py b/stacklets/core/famstack-api.py index fe231130..70a91392 100644 --- a/stacklets/core/famstack-api.py +++ b/stacklets/core/famstack-api.py @@ -52,42 +52,16 @@ # ... 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 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. ["memory", "history"], ["docs", "show"], ] -# Reads under an allowed prefix that are actually writes. `memory topic <slug> -# 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/<topic>/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: @@ -192,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/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 `<member>/<slug>` 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/<slug>` to the family's list of that name (`family/lists/<slug>`).""" + 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 `/<prefix>/<kind>/<rest>` 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/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 (`<bucket>/<topic>`) 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/git_mirror.py b/stacklets/docs/bot/git_mirror.py index 6a3b2775..2bc03224 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,79 @@ 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, + topic_name: 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/<topic>.md`. + + 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 + 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_name or 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 +586,25 @@ 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), + topic_name=topic.strip(), + 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 +617,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 +772,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 +1053,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/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/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, `<bucket>/lists/archive/<slug>/<YYYY-MM>.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/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/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/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/<slug>/`. The + curator runs it on the nightly sweep. No model involved. + wiki [--home] [--member <slug>]... [--topic <slug>]... [--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/_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 <name> todo` mirrors the `/go/topic/<name>/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 (`<bucket>/<topic>/todos.md`); -personal-scope todos (`<member>/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 <name> 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 # <bucket>/<scope>/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 <name> todo strike "<item>" --by <person>'} - - 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 <name> todo add "<item>" --by <person>'} - - # 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"<!--\s*(?:begin|end):\s*generated\s*-->\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 <slug>` (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/<bucket>/<slug>/about.md`, while - `topic <slug> 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 <name>"} - - 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/list.py b/stacklets/memory/cli/list.py new file mode 100644 index 00000000..fe0a5c08 --- /dev/null +++ b/stacklets/memory/cli/list.py @@ -0,0 +1,653 @@ +"""stack memory list: show and change the family's lists. + +Every list is a file, `<bucket>/lists/<slug>.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 + ARCHIVE_DIR, + LISTS_DIR, + FamilyList, + Item, + ListError, + find, + insert_item, + known_items, + list_path, + new_list, + person_link, + read_list, + remove_item, + set_details, + 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 + 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 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 + 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 + 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", "set") + +# 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) + # 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=[]) + create.add_argument("--section", action="append", default=[]) + create.add_argument("--recurring", action="store_true") + + add = verbs.add_parser("add", add_help=False) + # 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="") + + # 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("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) + + # `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, details, *[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 <person>, the family member it is for") + 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": + return add(vault, config, ns, 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]: + 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((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")) + 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 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) + 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, recurring=ns.recurring) + + 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 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) + slug, names = _list_and_items(lists, ns.items) + 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") + people = _people(vault, config, ns.people) + 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]], + 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; 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 verb == "reset": + new, said = _reset(prior, current, today, path) + else: + 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) + 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, entries: list[tuple[str, str]], + links: tuple, due: str, path: str) -> tuple[str, str]: + added, already = [], [] + 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)} to {lst.title}"] if added else [] + said += [f"{name!r} is already on {lst.title}" 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`, 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} {_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 ───────────────────────────────────────────────────────── + +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/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 <name> todo — a topic's todos, first-class like its link. +"""stack memory topic <name>: a topic's overview, its wiki page. -Topics are first-class entities: the CLI noun and the `/go/topic/<name>` 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/<name>` +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: `<name...> todo [strike|unstrike <item...>]`. 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 <slug>` (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 <person>` 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"<!--\s*(?:begin|end):\s*generated\s*-->\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 <slug>` 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/<bucket>/<slug>/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 fcbde641..47735d19 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 @@ -169,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 - # the list-edit verb, which stay allowed. - if not as_patch and repo_path.endswith("todos.md"): - 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. @@ -204,7 +192,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 +227,39 @@ 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 + + def describe(before: str, after: str, repo_path: str) -> str: """One line saying what this edit did to this page. @@ -247,7 +268,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/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/<topic>.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 +# `<bucket>/<topic>/todos.md`: a title heading and checkbox lines, with a +# due date written after a dash. They now live with every other list in +# `<bucket>/lists/<topic>.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/<topic>.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/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/e2e/test_demo_rig_e2e.py b/tests/e2e/test_demo_rig_e2e.py index 9ea7dfbc..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}" @@ -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"] @@ -403,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 @@ -501,7 +503,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 +511,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") @@ -534,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/framework/test_lists.py b/tests/unit/framework/test_lists.py new file mode 100644 index 00000000..812be31e --- /dev/null +++ b/tests/unit/framework/test_lists.py @@ -0,0 +1,323 @@ +"""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, + Link, + Known, + archive_path, + archive_ticked, + known_items, + stamp_ticks, + ListError, + format_item, + insert_item, + list_path, + new_list, + parse_item, + person_link, + read_list, + relink, + set_details, +) + +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 == () + + +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 + + +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] == [] + + +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" + + +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/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_brief_lists.py b/tests/unit/stacklets/test_agent_brief_lists.py new file mode 100644 index 00000000..fd6baf7d --- /dev/null +++ b/tests/unit/stacklets/test_agent_brief_lists.py @@ -0,0 +1,86 @@ +"""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. Which topic a room is follows the archivist's rule +for topic rooms, `Thema: Einkauf` or `Topic: Camping`. +""" + +from __future__ import annotations + +import sys +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, 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" +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 <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] 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..87b574ba 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,25 @@ 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"]}), + ("list_edit", "list", + {"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", + {"list": "einkaufsliste", "op": "set", "sections": ["Obst", "Milch"]}), + ("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_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_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..c4a1f224 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,74 @@ 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_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() await mirror._fold_capture_todos( client, 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 <slug> 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_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) 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..e46bc4ef --- /dev/null +++ b/tests/unit/stacklets/test_memory_list_cli.py @@ -0,0 +1,374 @@ +"""`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() + fake.vault = vault + 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_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"] + + 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 <person>, 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 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 + 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): + 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 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): + 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 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") + 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 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") + 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", + "--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_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_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/<topic>.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_memory_write_cli.py b/tests/unit/stacklets/test_memory_write_cli.py index 5637dfdb..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 @@ -344,3 +342,26 @@ 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"] + + +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 == [] + 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) 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" 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