diff --git a/docs/2026-06-02-chateau-pilot-review.md b/docs/2026-06-02-chateau-pilot-review.md new file mode 100644 index 0000000..670e2a0 --- /dev/null +++ b/docs/2026-06-02-chateau-pilot-review.md @@ -0,0 +1,1161 @@ +--- +title: "Château du Faune — Tracker / Journal pilot review" +subtitle: "Findings from the first week of !journal, !goals, !tasks usage across four Signal rooms" +date: 2026-06-02 +author: aiolabs / Château du Faune +--- + +# Executive summary + +For the past two weeks, members of the Château du Faune have been +"going through the motions" of using `!journal`, `!tasks`, `!goals`, +`!reminder`, `!buy`, `!sidequest`, and related commands across four +Signal rooms — Operations, Animals, Gardens & Food, Events, and the +general castle room. Nothing was hooked up yet: the chats are mirrors +of what the maubot stack on the new Matrix homeserver will eventually +capture. The point was to *generate signal* — to find out which +verbs people actually reach for, how they phrase entries, where the +syntax gets in the way, and which features the protocol spec is +quietly missing. + +This document reads that signal. It is written for both the chateau +members (who will see their words quoted back and choices made about +how the bot will treat their phrasing) and for the +[aiolabs/maubot-plugins][repo] developers (who will turn the +recommendations into code and spec changes). The +[community-organizer spec][spec] is the contract; the +[`tracker/`][tracker] and [`journal/`][journal] plugins are the +reference implementation; the data we collected over the past two +weeks is the field test. + +**Top-line findings:** + +1. **The capture habit is sticking.** The bot doesn't exist yet, and + people are still using the syntax — sometimes correcting each + other on it. The most surprising data point is that Coco told + Helena *"`!journal` is the required format"* unprompted. Social + enforcement of a syntax we haven't actually shipped is the best + possible adoption signal. + +2. **The vocabulary the community reaches for is broader than the + spec.** The current spec lists `!task`, `!sidequest`, `!journal`, + `!remind`, `!add`, `!done`, `!list`, `!setup`. The chats show + people typing `!buy`, `!need`, `!goals`, `!stewards`, `!tasks`, + `!task` (singular), `!reminders` (plural), `!idea`, `!plans`, + `!priority` (inline), `!side quests`, and a few more. Some of + these are per-room shortcuts the spec already accommodates; + others want a small protocol-level addition. + +3. **The 5-level priority scheme came directly from this pilot.** + Coco proposed "1 urgent / 2 crucial / 3 important / 4 future / 5 + frequent/ongoing" in the operations chat on May 24. It is now + §3.3.1 of the spec, exactly as proposed. This loop — observe → + spec → implement → re-observe — is the one we want to keep + running. + +4. **The bot will need to be more grammar-tolerant than the spec + currently implies.** Multiple verbs per message, edited messages + that add content, `!`-words inside bullet points used as + priority markers, pinning as a "this is the current list" + signal, no-prefix journals — these are all behaviors the bot + doesn't catch today but should. + +5. **The community is already designing the bot's voice.** Alfred + got named on May 24 ("we trusted him with resource management, + safety, tool use, etc."). Photos got captioned, journals got + pinned, Cocopis explained what `!tags` are *to other members*. + The chateau is treating the bot as a collaborator before it + exists. We should lean into that — bot personality is now a + design surface. + +The rest of this report: + +- **§1** What we observed, by room and by pattern +- **§2** What the existing spec + plugins handle well +- **§3** Spec changes proposed +- **§4** Plugin changes proposed +- **§5** New ideas the data surfaced +- **§6** How this fits the larger omnixy / aiolabs ecosystem +- **§7** A phased plan +- **§8** Open questions for the chateau + +[repo]: https://git.atitlan.io/aiolabs/maubot-plugins +[spec]: ../docs/community-organizer-spec.md +[tracker]: ../tracker/ +[journal]: ../journal/ + +--- + +# 1 — What we observed + +## 1.1 Verbs in actual use + +A frequency-sorted list of every `!verb` we saw across the four rooms +(case-folded), grouped by what the spec or current shortcut config +covers: + +| Verb seen | Already in spec | Status | +|---|---|---| +| `!journal` | Yes (universal) | Heavy use across all four rooms | +| `!reminder` / `!reminders` | Spec verb is `!remind` | Mismatch — see §3.1 | +| `!tasks` / `!task` | Spec verb is `!task` | `!tasks` plural also appears | +| `!goals` | No | New — see §3.2 | +| `!stewards` | No (per-room shortcut candidate) | Used as a list/backlog, not a one-off task | +| `!buy` | Per-room shortcut, fits cleanly | Should ship pre-configured for cdf rooms | +| `!need` | No | Distinct from `!buy` in usage — see §3.2 | +| `!sidequest` / `!side quests` | Yes | Used; "side quests" with space appeared once | +| `!idea` / `!plans` | No | Speculative / design-phase items | +| `!priority` (inline) | No (priority is a tag) | Used as a bullet-level marker — see §1.4 | +| `!move` | Mentioned by Ulo as a future verb | Not in spec | + +And a usage example per verb so the design instinct is grounded in +actual phrasing rather than imagined phrasing: + +**`!journal` — past-tense daily log, multi-line is the dominant +form:** + +> `!journal` +> `- opened/closed, watered and fed hens, babies & pacas` +> `- put insulators on the rebars so they're ready to go for fence` +> `- mentally plotted potential hidden-in-plain-sight stable locations. I want opinions next` +> `- should scrape clean duck babes straw tmrw` +> `- should clean and dry very well sapphis wound again` + +Note the mix of past-tense ("opened/closed") and forward-looking +("should scrape clean tmrw") bullets in the same `!journal`. People +do not bucket their day cleanly. §3.4 addresses this. + +**`!goals` — a *list of current intentions*, often nested with +sub-categories:** + +> `!goals & notes next week for garden/kitchen crew` +> +> `GARDEN` +> `- documentation & journaling process` +> `- prepping & starting the Lady Godiva pumpkin patch.` +> `…` +> +> `KITCHEN` +> `- forage, harvest & food processing resource improvements (ex. cherries into vinegars…)` +> `- filling the shop up (& soon looking into certifying our kitchen/lab)` +> `…` + +`!goals` lists evolve over days — Coco's gardens `!goals` on May 27 +and June 2 are *the same list*, refined. This is the strongest +argument we found for **replaceable-event semantics** (§3.3). + +**`!reminder` — a future-pointed list, often with embedded +@-mentions and `!priority` markers inside bullets:** + +> `!goals this week/soon` +> `- attempt to secure alpaca deposit & arrival of the boys` +> `- !priority confirm if teeth & nails will also be done by the shearer 'sometime in july'` +> `- !priority diagnosis of sapphis skin condition be made by a vet in person.` +> `!reminder following shearing, immediately start sapphis skin infection protocol` +> `- fencing add in doors & handles. Start soon on setting up 2nd stable paddock` + +Look at that message carefully: it is *one* `!goals` entry, with +`!priority` and `!reminder` appearing **inside bullets as inline +markers**, not as new commands. This is the single most important +parser decision we have to make — see §4.1. + +**`!buy` — shopping list, sometimes added-to over days via edit:** + +> `!buy` +> `- roll out flypaper for stable(s)` +> `- door handles for electric (or from laura)` +> `- la fourche order, sesame & produce will be thursday, 28th` +> `- buy more pans & x2 induction adapters` + +**`!need` — semantically distinct from `!buy`. Means "stock is +low" rather than "purchase this":** + +> `!need puppy & cat food, hay & straw soon` + +Coco used both in the same week. The distinction matters because +`!need` items become `!buy` items only after someone agrees to act +on them, where `!buy` is already a decision. + +## 1.2 Cadence patterns — the recurring chore problem + +The operations chat opened with a chores list dominated by +*recurring* items with fuzzy intervals: + +> `pacas:` +> `- every ~3 days scoop manure` +> `- every ~3 days fluff bedding` +> `- every 2 wks to 1 month - change out whole bedding` +> `- daily fresh water` +> `- daily light green/yellow hay` +> `- training - max 1x/day - approach with treats in a bucket…` +> +> `hens & ducks:` +> `- am let out, pm close in` +> `- every ~3 days fresh water & food` +> `- every 1 week change out straw` +> `- spend time with the puppies every day` + +Coco flagged the problem herself: *"weird thing is tasks that are +every few days or variable timeframes?"* + +The spec partially anticipates this with `priority:5` ("frequent / +ongoing"), but `priority:5` is a marker, not a cadence. "Every 3 +days" and "daily" are different cadences and the bot should know. +RFC 5545 `RRULE` is the canonical answer; NIP-52 doesn't yet +codify recurrence (§11 of the spec lists this as open). A +lightweight stopgap is proposed in §3.5. + +## 1.3 Multi-verb messages + +Helena posted in the Gardens & Food room: + +> `!journal` +> `• bigger Team meet and on board of Marta what we did the last weeks…` +> `• weeding` +> `• getting fresh alpaca manure and distributing` +> `• collecting seeds for next year planting` +> +> `!task` +> `- research thing for rain tank` +> `- update online garden map` +> `- check on sprouting pumpkin seeds and plant when ready` + +This is one Matrix message but **two distinct entries** — a +`!journal` and a `!task` list. The current tracker dispatch regex +matches the first verb at start-of-message and stops. The plugin +will silently drop Helena's `!task` block. + +This is a real bug, not a theoretical one — Helena was the first +person who naturally typed in this shape, and the bot would have +lost the task list. §4.2 fixes it. + +Skye independently did the same thing on the same day: + +> `!goals !tasks` +> `- [ ] organise conversation about image ai making with pat and coco` +> `- [ ] Look into creation of the funding via our socials…` +> `- [ ] For flyers/posters the !goals is to make one for pats new events app for ariege…` + +`!goals !tasks` on one line is yet another shape: two verbs sharing +one body. Likely "this entry belongs to both lists" — but the spec +needs to say so explicitly. + +## 1.4 Inline `!priority` and `!reminder` markers + +Returning to Coco's May 27 `!goals`: + +> `- !priority confirm if teeth & nails will also be done by the shearer 'sometime in july'` +> `- !priority diagnosis of sapphis skin condition be made by a vet in person.` +> `!reminder following shearing, immediately start sapphis skin infection protocol` + +`!priority` and `!reminder` are appearing **inside body content** as +bullet-level annotations, not as commands. A naïve parser that +matches `!\w+` anywhere in a message would interpret these as new +commands and produce four entries from one message. + +The fix is the rule the journal plugin already enforces: a `!verb` +counts as a verb **only** if it is at the very start of the message +(or, with §4.2 in place, at the very start of a top-level block). +Everything else is body content with inline tags. + +But "inline tags" is itself a feature worth keeping: a bullet +starting with `!priority` is a natural way to mark *that bullet* +as urgent within a larger list. The bot can: + +1. Treat the inline `!priority` as a *tag on the bullet body* + (preserves the user's intent). +2. Leave the parent entry's priority alone (the user didn't priority + the *list*; they priority'd one item in it). + +A renderer can then surface that bullet as priority-1 within the +parent entry. This is a richer model than the spec currently +sketches, but it matches what users are actually doing. §3.3 +proposes the wire format. + +## 1.5 Pinning as state + +Across all four rooms, the dominant `!goals` / `!tasks` / `!stewards` +messages are **pinned to the room**. When the list evolves, the new +version is pinned and the old one is unpinned. Matrix has native +support for this (`m.room.pinned_events`). + +We propose treating "the latest pinned message matching `!goals` +from a given room" as the **current state of that group's working +list**. This gives every room a queryable "what's on our plate +right now" view without requiring users to remember an item id. + +§3.3 + §4.5 develop this. + +## 1.6 Implicit subjects — animals, people, places + +The animals room is full of entries like: + +> `- thinking of long-term solutions for pups barking and neighbors.` +> `- 45 min in the alpaca paddock with both pups, no electricity.` +> `- carefully choose feeding station(s) so they are isolated and don't compete with any other animals` +> +> *(and elsewhere)* +> +> `lets clean sapphis wound again soon?` +> `also just realized the shearer will come in july` + +The community has named entities — *Sapphi*, *Loki*, *Leia*, +*Tango*, *Morrigan*, *the alpacas*, *the pups*, *the hens*, *the +south paddock*, *the orchard*, *the coop* — that recur across many +entries. A useful query is "show me everything mentioning Sapphi +this month" or "what tasks involve the south paddock?". + +NIP-52 already supports `["p", ""]` for *people*. Animals +and places need a convention — see §3.6. + +## 1.7 Inline @-mentions = informal assignment + +> `- location & plans for stable, cont. @coco` +> `- closing the main property gate? guardian dogs sign @coco` +> `- pruning & landscaping @Thomasss**]` +> `- following a budget per person for food orders, picking up produce @Skye 🌟` + +People mention each other inline to mean "this bullet is yours". The +bot can extract these into structured `["p", …]` tags and a +renderer can surface a per-person "your bullets" view. §4.6. + +## 1.8 Cross-group routing ambiguity + +Skye said in the Events room: + +> *"organise a mini meeting to talk about ai image making etc with +> pat/coco …. I'm not sure which chat to put these social things in +> hehe"* + +Some items belong to multiple groups. The current 1-room ↔ +1-community mapping makes this expensive — you'd have to type the +same entry twice. Coco's answer (*"i think events would be the +fitting place"*) is the human workaround; the bot should help. +§5.2 proposes optional multi-room fan-out. + +## 1.9 No-prefix journal-shaped content + +Lots of messages look like journals without using any command: + +> `today plan for me:` +> `- working in other groups on animals and operations` + +> `Today's plan for me:` +> `- making bulk buckwheat zucchini burgers some for today and rest in freezer` + +> `journal` +> `started but did not complete: bathroom clean, mapping stables, fencing the internal 150m paddock` + +The chateau is explicitly standardizing on `!journal` (Coco +corrected Helena on this in real time). So we **should not** make +the bot fuzzy-match — they want explicit syntax. But the data +suggests an opt-in *"capture every non-command message in this +specific room"* mode could be valuable for very-low-friction rooms +(e.g. a private journal room per member). Default: off. §5.1. + +## 1.10 Capitalization, punctuation, language + +The same intent appears as `!journal`, `Journal:`, `journal`, `!journal:`, +`Today plan for me`, `today plan:`, sometimes mixed with French. + +The bot's regex needs to be: +- case-insensitive on the verb (`!Journal` == `!journal`) +- tolerant of a trailing colon (`!journal:` is fine) +- bilingual on body content (already true — body is opaque text) + +The current `_CMD_RE = re.compile(r"^!(\w+)(?:[ \t\r\n]+(.*))?$", re.DOTALL)` +in `tracker.py:52` *does* match `!Journal` because `\w` is +case-insensitive in unicode mode, and the dispatch already calls +`.lower()`. But it does **not** match `!journal:` because the colon +follows the verb with no whitespace. One-line fix in §4.3. + +## 1.11 Edits matter + +Two distinct edit patterns appeared: + +**A. Edit-to-append.** Coco edited a `!journal` post-hoc and +commented: *"just added that i also fed puppies 2x today"*. The +intent is "this is the same entry, with more content". + +**B. Edit-to-correct.** Coco scheduled a vet appointment for "June +5 @ 14h", then edited the same entry to "May 30 @ 15h" with the +note *"updated to May 30 @ 15h because they need the vaccination +within the month"*. + +The tracker's current known-quirks note (`tracker/README.md:133`) +says: + +> *"Edited messages don't re-trigger the bot. Matrix sends edits +> as a separate `m.replace` event that maubot doesn't pass to +> handlers. Type a fresh message instead of editing."* + +This is solvable — `m.replace` events *are* visible to maubot; the +handler just needs to be wired. §4.4. + +--- + +# 2 — What the existing spec and plugins handle well + +Before proposing changes, let's mark what's already right. The pilot +*validates* a lot of the design. + +## 2.1 The split between universal verbs and per-room shortcuts + +The spec says: every implementation must recognize the universal +verbs (`!task`, `!journal`, etc.); per-room shortcuts (`!buy`, +`!harvest`, …) expand to them. The data agrees: every room developed +its own dialect (animals uses `!journal` heavily, operations uses +`!buy` and `!stewards`, events uses `!goals`). A single plugin +serves all of them. + +We just need to **pre-configure** the cdf rooms with sensible +shortcuts so members don't have to `!setup add buy task #buy` on day +one. §4.7. + +## 2.2 The passive regex / multi-line story + +The journal plugin's switch to `@command.passive` with a regex was +right. Every multi-line entry we observed (and many one-line ones) +would have been silently dropped under `@command.new`. The CLAUDE.md +footgun is real and the plugin avoided it. + +## 2.3 The 5-level priority scheme + +`priority:1..5` with the labels urgent / crucial / important / future +/ frequent-ongoing came out of the pilot. The renderer plan is to +surface labels rather than numbers; the wire tag stays `priority:N` +for sort stability. The data didn't push back on this — Coco's +proposal landed exactly as written. + +## 2.4 Sidequests as a separate kind + +`!sidequest` (with a `🎯` emoji ack) gives a distinct surface for +"would be cool to do" items. Coco's *"`!side quests` lists which are +different from the rest of the tasks"* is exactly the use case. The +spec already excludes sidequests from default `!list` output, which +is the right default. + +## 2.5 The Matrix-only Phase 1 posture + +The tracker is `matrix-only` for v1 — no Nostr publishing yet. This +turns out to be the right call: the four-week dataset shows the +community is still finding its vocabulary. Publishing to a public +relay before the vocabulary stabilizes would make schema migrations +visible to anyone subscribed. Stay matrix-only until the verb set is +stable, then bridge. + +## 2.6 The journal plugin's append-only stance + +The spec's "`!done` on a journal entry is undefined behavior; +implementations SHOULD reject it" matches the data: nobody tried to +close a journal. They edit, they re-journal, they pin — they don't +close. Good call. + +--- + +# 3 — Spec changes proposed + +These are protocol-level additions to `docs/community-organizer-spec.md`. +Each is small. None breaks existing conformance — they extend, they +don't refactor. + +## 3.1 Plural aliases for universal verbs + +`!reminders` and `!tasks` should be accepted as equivalents of +`!remind` and `!task`. `!sidequests` likewise. This is a one-line +parser change; it costs nothing and removes a footgun (users will +naturally pluralize when a list is multi-item). + +> *Spec change:* in §3.1, add a row: +> *"Implementations MUST accept the bare plural form of each verb +> (`!tasks`, `!sidequests`, `!reminders`) as equivalent."* + +## 3.2 New universal verbs: `!goals` and `!need` + +Both verbs got real, repeated, distinct-from-existing usage. Both +deserve protocol-level status, not per-room shortcut status, because: + +- They had identical meaning across all four rooms. A bakery co-op + reading this spec should be able to use them without re-configuring. +- The shortcut mechanism is for *vocabulary the community invented* + (e.g. `!harvest`, `!brewery`). `!goals` and `!need` are not + vocabulary inventions — they're a category the spec missed. + +| New verb | Purpose | Underlying kind | +|---|---|---| +| `!goals ` | A list of current intentions for the author/group/period. Replaceable per `(author, room, period?)`. | 31922 with `["t", "goals"]` | +| `!need ` | A supply that is running low. Distinct from `!buy` (which is "I will purchase this"). Default lifecycle: open → `!buy` (converts to task) or `!done`. | 31922 with `["t", "need"]` | + +`!goals` deserves §3.3 (replaceable semantics) since the goals lists +visibly evolved over days in the pilot. + +## 3.3 Replaceable-event semantics for `!goals` (and stewards lists) + +NIP-52 kind 31922 is a *parameterized replaceable event* keyed by +`d`-tag. Today the tracker assigns a unique `d` per entry. For +`!goals` (and the per-room `!stewards` list pattern), we want the +opposite: the latest `!goals` from a given `(author, room)` pair +should *replace* the previous one. + +> *Spec change:* in §3.3, add: +> *"Implementations MAY designate certain verbs as replaceable per +> `(author, room, period?)`. When a verb is replaceable, the bot +> reuses the same `d`-tag for successive captures, so the relay +> retains only the latest version. Recommended replaceable verbs: +> `goals`, `stewards`."* + +The `period` qualifier lets `!goals this week` and `!goals next week` +coexist. Suggested encoding: a `period:` tag (e.g. `period:2026-w22`). +For v1 we keep it simple — no period qualifier, latest wins. + +The pinning UX hooks straight in: when a `!goals` message is the +latest replaceable entry from its author, the bot can ask Matrix to +pin it (and unpin the previous one). This makes the room's pinned +message a live mirror of the relay state. + +## 3.4 Forward + retrospective in a single `!journal` + +The data shows people blend "did X" and "will do Y tomorrow" in the +same `!journal`. The spec currently says journals are past-tense and +append-only. Rather than fight the user, we accept the blend. + +> *Spec change:* in §3.1, clarify: +> *"`!journal` is append-only; implementations MUST NOT close +> journals. Bodies MAY contain forward-looking statements; the +> classifier MAY surface these as candidate tasks at review time but +> MUST NOT auto-create derived task entries."* + +The "candidate task" idea is a v2 nicety: a weekly digest that pulls +"should…" bullets out of journals and offers them as `!task` +candidates. Not in v1. + +## 3.5 Recurrence tags (lightweight, pre-RRULE) + +For the chore-list problem (`every ~3 days scoop manure`, `daily +fresh water`), we propose a `frequency:` tag namespace that lives +inside the existing `["t", …]` mechanism. Renderers parse it. + +Suggested values: + +| Tag | Meaning | +|---|---| +| `frequency:daily` | Once per day | +| `frequency:weekly` | Once per week | +| `frequency:3d` | Every 3 days | +| `frequency:2w` | Every 2 weeks | +| `frequency:as-needed` | No fixed cadence; on demand | + +This is a stopgap. When NIP-52 or a sibling NIP defines proper +recurrence, we migrate. For now, the eink renderer can group +`frequency:daily` items into a "today" panel and `frequency:weekly` +into a "this week" panel without parsing RRULE. + +> *Spec change:* in §3.3, add: +> *"`frequency:` is reserved for recurrence hints. Recommended +> values: `daily`, `weekly`, `Nd` (every N days), `Nw` (every N weeks), +> `monthly`, `as-needed`. Implementations MAY define additional +> values."* + +## 3.6 Subject tags — animals and places, not just people + +NIP-52 has `["p", ]` for people but no convention for +non-human subjects. We propose a namespaced `t`-tag: + +| Tag | Meaning | +|---|---| +| `subject:animal/sapphi` | Animal named Sapphi | +| `subject:animal/loki` | Animal named Loki | +| `subject:place/south-paddock` | A named place on the property | +| `subject:object/electric-fence` | A named asset/object | + +A community can define its own subject vocabulary via `!setup`: + +``` +!setup subjects animal/sapphi animal/loki animal/leia +!setup subjects place/south-paddock place/orchard +``` + +When typing, members can reference subjects with a `@`-prefix as +shorthand: + +``` +!journal cleaned @sapphi's wound, fed @hens at noon +``` + +The bot expands `@sapphi` to `subject:animal/sapphi` tags and +preserves the original text in the body. Renderers can then offer +"recent entries about Sapphi" without the bot needing to do NLP. + +For *people* the existing `["p", ""]` already works; +inline `@person:name` (or just `@`-mentions resolved through the +Matrix room's member list) feeds straight into that. + +## 3.7 Inline `!verb` markers within a body are tags, not commands + +This is half a spec change, half a clarification: + +> *Spec change:* in §3.3, add: +> *"A `!verb` occurring at the start of a message (or, with multi-verb +> support, at the start of a top-level block) is a command. A `!verb` +> appearing inside body content is body content and MAY be extracted +> by the bot as an `inline:` tag on the parent entry. This +> preserves user intent without spawning ghost entries."* + +So Coco's `- !priority diagnosis of sapphis skin condition…` bullet +gets recorded as part of the parent `!goals` entry, *and* the parent +gets an `inline:priority` tag. A renderer can surface that bullet +with priority styling. + +## 3.8 Multi-line entries with sub-headers + +A small content-format convention so renderers can do something +sensible with Coco's `GARDEN` / `KITCHEN` sub-headers: + +> *Spec change:* in §10, add: +> *"Bodies MAY use Markdown-style headers (`#`, `##`) as in-body +> section markers. Renderers MAY surface them as visual groupings. +> The body remains a single content blob; sub-headers do not split +> the entry."* + +--- + +# 4 — Plugin changes proposed + +These are concrete edits to `tracker/`, `journal/`, and a small +amount of `wiki/` if we want cross-plugin discovery. Each maps to a +specific file path so the work is sized. + +## 4.1 Parse inline `!verb` markers as tags, not commands + +`tracker/tracker.py:184` — the `dispatch` handler matches a verb at +start-of-message. Today, anything else is unmatched and the message +is dropped (correct). We extend: after dispatching the top-level +verb, scan the body for inline `!verb` occurrences and add them as +`inline:` tags on the recorded entry. Implementation is a +single pass over the body before recording. + +This makes Coco's `- !priority …` bullets actually do something +useful at the entry level without inventing ghost entries. + +## 4.2 Split multi-verb messages + +`tracker/tracker.py:52` — the `_CMD_RE` regex anchors at +start-of-string and captures everything to end-of-string. We change +to a *two-pass* parse: + +1. Split the message at every line that starts with `!verb` (and + only such lines — bullets like `- !priority …` are NOT split + points). +2. Each split block becomes its own captured entry. + +Helena's `!journal … !task …` message becomes two entries. Skye's +`!goals !tasks` on a single line stays as one entry but gets *both* +tags applied. This requires a second regex for the multi-verb-one-line +case. + +The order matters: split first on newline-prefixed `!verb`s, then on +the single-line multi-verb case. + +## 4.3 Tolerate trailing colon and case on verb + +`tracker/tracker.py:52` — change the regex to allow an optional +trailing colon and explicit case-insensitive flag: + +```python +_CMD_RE = re.compile(r"^!(\w+):?(?:[ \t\r\n]+(.*))?$", re.DOTALL | re.IGNORECASE) +``` + +(The `.lower()` already happens in `dispatch`; the regex flag is for +unicode `\w` consistency. Trailing colon is the visible change.) + +Apply the same change to `journal/journal.py:34` for `_JOURNAL_RE`. + +## 4.4 Handle Matrix edits + +`tracker/tracker.py` — register a handler for `m.room.message` events +with `rel_type == "m.replace"`. The handler: + +1. Looks up the original event id in the items table (we need to + start recording it — schema change below). +2. If the original is a captured entry, treat the edit as a body + update on that entry. Bump a `last_edited_ts`. +3. Reply with a small ack ("✏️ Updated entry `42`"). + +Schema change: add `mx_event_id TEXT` column to `items`, indexed. +This is a real change to `upgrade_table` — add as `upgrade_v2`. + +`tracker/README.md:133` — strike the "Edited messages don't +re-trigger the bot" caveat. + +This is the single change that unlocks the most observed user +behavior. People *do* edit, and treating edits as no-ops loses +substantial information. + +## 4.5 Detect pin events; mark replaceable verbs as pinned + +`tracker/tracker.py` — subscribe to `m.room.pinned_events`. When a +captured entry's source Matrix message becomes pinned, mark the +entry as `pinned=true`. When a `!goals` entry is recorded, the bot +*itself* asks Matrix to pin the message (and unpin the previous +`!goals` from the same author). + +This makes the pinned-message UX a transparent mirror of the relay +state, without users needing to manually pin/unpin. + +(This requires the bot to have pin-event permission in the room. The +admin can grant it via Element. Document in the README.) + +## 4.6 Extract inline @-mentions as `p`-tags + +`tracker/tracker.py` — alongside the existing `_TAG_RE` extraction +for `#tag`, add an `_MENTION_RE` extraction for `@user`. Resolve +against the Matrix room's member list (best-effort; if it doesn't +resolve, store the literal string). + +The recorded entry gets `["p", ""]` tags for each resolved +mention, *and* keeps the `@user` substring in the body for human +readability. A renderer can then offer a "your bullets" view per +person. + +## 4.7 Ship sensible default shortcuts for the cdf rooms + +`tracker/tracker.py` — add a one-time bootstrap that, on first +`!setup` (or first capture) in a room whose name matches a known +chateau room, pre-populates shortcuts. Suggested defaults: + +| Room name | Shortcuts | +|---|---| +| Animals | `!buy task #buy`, `!chores task #priority:5`, `!vet task #vet`, `!need need` | +| Gardens | `!buy task #buy`, `!harvest task #harvest`, `!plant task #plant`, `!need need` | +| Operations | `!buy task #buy`, `!steward task #steward`, `!repair task #repair`, `!need need` | +| Events | `!host task #host`, `!ticket task #ticket` | + +These are starting points, not a final list. Communities edit them +via `!setup` once they're in. + +A safer way to ship this is a `default_shortcuts.yaml` config the +plugin reads, so changes don't need a code redeploy. + +## 4.8 New universal verbs implementation + +`tracker/tracker.py:56` — add to `UNIVERSAL_VERBS`: + +```python +UNIVERSAL_VERBS = { + "add", "task", "tasks", "sidequest", "sidequests", + "remind", "reminders", "done", "list", "setup", + "goals", "need", "needs", +} +``` + +(Plural aliases per §3.1.) + +Add `_handle_goals` and `_handle_need` handlers. `!goals` uses +replaceable-event semantics (§3.3) — for the matrix-local v1, this +means *upserting* on `(user, room, kind='goals')` rather than +inserting. One row per author per room. + +`!need` records a `kind='need'` item that stays open until +either `!done` (we got some) or another verb explicitly converts it +to a `!buy` task. + +## 4.9 Frequency tag parsing in `!list` + +`tracker/tracker.py:302` — when listing items, group by +`frequency:` tag so the chore-list renders as: + +``` +**Daily (3):** +- `5` fresh water for alpacas +- `7` fresh water for ducks +- `12` open & close hens + +**Every 3 days (2):** +- `19` scoop alpaca manure +- `23` fluff alpaca bedding + +**Weekly (1):** +- `4` change out hen straw +``` + +This is rendering, not protocol — the spec just stores tags. + +## 4.10 Replace the `tracker/README.md` quirk note + +After §4.4 lands, strike the edit caveat. Add a paragraph on the +new pin behavior, the new universal verbs, and the multi-verb +splitting. + +--- + +# 5 — New ideas the pilot surfaced + +## 5.1 Opt-in "private journal room" mode + +For a per-user *private* journal room (one bot account, one human +member), the prefix friction adds nothing — every message is +journal content by definition. We can add a per-room flag +`auto_journal: true` (default `false`) that records every plain +message as a `!journal` entry. This is *opt-in per room* and the +bot acks loudly the first time it activates so nobody is surprised +by it. + +For shared rooms — which is everywhere in the pilot — the default +stays explicit-prefix-only, matching Coco's "`!journal` is the +required format" enforcement. + +## 5.2 Multi-room fan-out + +Skye's "I'm not sure which chat to put these social things in" +suggests an entry can sometimes belong to multiple rooms. We +propose a `+#room` syntax: + +``` +!task +#events +#general organize image-AI brainstorm with pat and coco +``` + +This records the entry in *both* communities. The same entry has +two `a`-tags (one per NIP-72 community). Renderers in either room +see it. `!done` closes it in both. + +Implementation: parse `+#` tokens out of the body, resolve +to room ids the bot is also a member of, and insert one row per +target room with a shared `link_id`. v2 work, not v1. + +## 5.3 Photo / link / media in entries + +Helena's "checked feathered babies" entry was actually a photo + +caption. Thomas shares YouTube links. Coco shared a PDF on toxic +plants. The body field today is text; the bot can capture media +attachments by storing their `mxc://` URI in a `media` JSON column. +A renderer can fetch and display. + +Especially valuable for the eink panel — a photo of the daily chores +list, a graph of fed/watered checkmarks, the duck-pool-water-runoff +diagram. + +## 5.4 Calendar feed for timed entries + +Pat already saw the vet appointment on the castle calendar — *"nice +i already see it on the castle calendar"*. This integration should +be documented: where it lives, what feeds it. If it's manual, it +should be automated; if it's automated, it should be tested for +all `kind 31923` timed entries. + +The natural shape: an iCalendar feed at +`https://docs.ariege.io/calendar.ics` produced from the bot's +timed-entry table. Castle members subscribe in their calendar app +of choice. + +## 5.5 Daily / weekly digests + +The pilot showed the value of *summary*. Coco pins a current +`!goals`. Ulo posts meeting summaries. Helena writes "homework" +recaps. The bot can produce these on a cron: + +- **Morning digest** (per room): "Yesterday's journal entries (3), + Open tasks this week (12), Goals pinned (1)." +- **Weekly digest** (Sunday evening): "This week's completed tasks + (28), Open carry-over (15), Journal highlights (a sampling)." + +This is a content surface, not protocol. The data is already there. +Implementation = a cron handler + a markdown template + posting to +the room. Maubot has cron support via `asyncio` + `croniter`. + +## 5.6 Bot personality — Alfred + +The community named the bot Alfred (the butler). Replies should +match: a slightly formal, helpful, occasionally dry tone. + +Concretely: +- Current acks: `📥 Logged for @alice.` +- Alfred-flavored: `Very good, @alice. Logged.` + +A `personality` config field with `default | alfred | minimal` +lets other adopting communities pick. Keep `default` deadpan and +neutral. + +This is low-priority but it costs nothing and the data shows the +community will engage with the bot more if it feels like a +collaborator. "What should we name our assistant?" — *"Alfred would +be my go to"* — *"doiittt"* is a community taking ownership of the +tool before it exists. + +## 5.7 LLM fallback inside the chateau network + +The spec's classifier conformance level 2 is LLM fallback. The +chateau has an existing Ollama setup or can run one on the same +host as maubot. The fallback fires only when: + +- The user typed `!add` (explicit ambiguity), +- The rules classifier returned `unclassified`, +- The LLM is reachable. + +If any condition fails, the entry stays in the inbox until +human-sorted (level 0 fallback). The architecture stays +self-hostable. + +Smallest reasonable model for this is in the `llama3:1b` / +`phi3:mini` size range — runs comfortably on the maubot host. + +## 5.8 Wiki / docs lookup inside the tracker context + +The wiki plugin already exists. Inside a tracker reply, if an entry +mentions e.g. *"recycling"*, the bot could surface a `[?]` hint: +"Related: `docs.ariege.io/spaces/waste-recycling`". One-line link, +ignorable. + +This makes the chateau's docs site organically discoverable from +chat without anyone needing to remember URLs. Implementation: +inside the tracker's record path, fire a quick keyword query at the +wiki plugin's index and append top-1 result if confidence is high. + +--- + +# 6 — How this fits the larger omnixy / aiolabs ecosystem + +The Château pilot is one community using one piece of the stack. The +broader picture: + +## 6.1 The protocol survives the implementation + +The community-organizer spec is CC0 and references only open +standards (RFC 5545 VTODO, NIP-52, NIP-72, ActivityStreams). If the +maubot plugin disappears tomorrow, the events on the relay are still +readable by any NIP-52 client. The chateau's organizational memory +is not coupled to maubot. That's the design property we're +protecting. + +## 6.2 Nostr bridge (Phase 2) + +Today the tracker is matrix-only. When the vocabulary stabilizes +(probably 2-4 more weeks of pilot), Phase 2 bridges to Nostr: + +- Bot writes captured entries to a community Nostr relay. +- The relay can be private (auth-gated) or public per the + `publish` posture config. +- Renderers (the eink panel, a future webapp dashboard) subscribe + to the relay and surface the data. + +This is the unlock for the *inky-impression* panel in the foyer — +the data shape it consumes is exactly the events the bot will be +producing. + +## 6.3 Identity — the signer abstraction + +Today everything is signed by the bot. The spec's §7.2 / §12 +develop the per-user-signing endgame: a sidecar +`nsecbunkerd` on the same host, the bot holding only scoped NIP-46 +connection tokens. + +At chateau scale (10-20 members), this is overkill for v1. We run +v1 (trust the bot, `author` tag carries the human MXID) for as long +as the community is small and physical-trust-bounded. We migrate to +per-user signing when: + +- More than one community is using the same bot account, *or* +- An external party (visitor, woofer, neighbor) needs to write + entries without being a fully-trusted member, *or* +- LNbits #18 (the bunker integration) ships and we want a single + identity story across the stack. + +The signer abstraction is *already* in the spec, so the migration +is design-for-it-now, do-it-later. + +## 6.4 LNbits / Lightning + +Two Lightning hooks the pilot suggests: + +**Marketplace / payable buy items.** A `!buy` item could optionally +carry a price and produce a Lightning invoice via LNbits when +clicked through. "Roll-out flypaper — €12 — pay" with the invoice +generated against the chateau's funding wallet. Useful for outside +contributors who want to chip in a specific item. + +**Visa funding stream.** Coco's alpaca funding stream (€23k over +time) is exactly the kind of recurring-pledge use case LNbits has. +This is a partnership channel between the tracker and the LNbits +extension at the marketplace level, not v1. + +## 6.5 Events app + +Coco's events-app side-project is in the pilot data: +*"developing the app & biz plan for tech company of an +ariege-wide calendar of events app"*. The tracker's NIP-52 31923 +events are the right shape to feed that calendar. The webapp +consumes NIP-52 already (per the bunker spec discussion). One data +shape, two surfaces: the bot records, the events app displays. + +## 6.6 Continuwuity homeserver + +The Matrix homeserver running this is Continuwuity, deployed via +`deploy/server-deploy/modules/services/matrix.nix`. The maubot +daemon registers via the admin-room token flow (see +`~/dev/CLAUDE.md` Matrix-homeserver section). All bot accounts in +this report (`@journalbot`, future `@trackerbot`, `@wikibot`, etc.) +are regular Matrix users on that homeserver. + +The bot-as-user pattern means a chateau member can DM the bot +privately (e.g. for personal `!journal` entries the public room +shouldn't see). The current journal plugin already supports this — +just invite the bot to a 1-1 room. + +## 6.7 Wiki cross-cutting + +The wiki plugin (`!ask`, `!doc`) reads `docs.ariege.io`. The +chateau's documentation should grow alongside the tracker — when a +new community-life protocol gets agreed on at a meeting (e.g. +"who feeds the cat"), it should land in `docs.ariege.io/spaces/...` +and be `!ask`-able. The pilot showed several decisions that would +benefit from durable documentation: the chickens' opening time, the +puppy training schedule, the vet's number. + +This argues for a small `tracker → wiki` integration: when a +`!journal` entry contains a *decision* (heuristically: contains +"we decided" / "rule" / "from now on"), the bot pings the room with +"This looks like a durable decision — would you like to file it +as a docs page?". One-click → wiki page draft. + +--- + +# 7 — Phased plan + +A 5-phase rollout that keeps each step small, deployable, and +reversible. + +## Phase 0 — Verb normalization and edit handling (next 1-2 weeks) + +Small parser / dispatch changes. No schema migrations beyond +`mx_event_id`. + +- §4.1 inline `!verb` markers as tags +- §4.2 multi-verb-per-message splitter +- §4.3 trailing-colon + case +- §4.4 edit handling (schema v2: add `mx_event_id`) +- §4.8 new universal verbs (`!goals`, `!need`, plural aliases) +- §4.10 README updates + +After Phase 0, the bot won't drop any of the entries we observed. + +## Phase 1 — Replaceable goals and pinning (2-4 weeks) + +- §3.3 replaceable-event semantics in spec +- §4.5 pin/unpin behavior on `!goals` recording +- §4.7 default shortcuts for cdf rooms +- §4.9 `!list` grouping by frequency + +After Phase 1, `!goals` becomes a living list per author and the +chore-recurrence model is at least visible to renderers. + +## Phase 2 — Subjects, mentions, recurrence (4-6 weeks) + +- §3.5 `frequency:` tag in spec +- §3.6 subject tags (animals, places) +- §4.6 inline `@`-mention extraction +- §3.7, §3.8 grammar clarifications in spec +- §5.5 daily/weekly digests + +After Phase 2, the eink panel can render meaningful views without +needing protocol changes. + +## Phase 3 — Nostr bridge (6-10 weeks) + +- Wire the tracker's local SQLite to a Nostr publisher. +- Subscribe to events tagged with the bot's communities for + bidirectional sync. +- Eink panel goes live against the relay. + +## Phase 4 — Per-user signing and identity (when LNbits #18 ships) + +- Operator-IdP + sidecar bunker per spec §12. +- Chat-handle ↔ pubkey binding via LNbits. +- Bot acquires scoped NIP-46 tokens per member. +- `BotSigner` becomes the fallback only. + +--- + +# 8 — Open questions for the Château + +Things the team should weigh in on before we lock the next phase: + +1. **Verb names.** Is `!goals` the right word, or `!intentions` / + `!focus` / `!agenda`? Same for `!need` vs `!stock` vs `!low`. The + bot will accept what the community uses — but the spec name will + stick. + +2. **Default shortcuts per room.** §4.7 lists candidates. Anything + missing? Anything wrong? Specifically: does the Animals room + want `!vet` and `!med` as separate kinds, or one `!health`? + +3. **Subject vocabulary.** Which animals, places, and assets are + worth naming as subjects? Suggested first pass: **animals** — + Sapphi, Loki, Leia, Tango, Morrigan, plus group names + (alpacas, hens, ducks, pups, hatchlings). **Places** — orchard, + south-paddock, coop, garage, kitchen, lab, dependence. **Assets** — + tractor, electric-fence, van, irrigation. + +4. **Calendar integration.** Today *something* turns dated journal + entries into castle-calendar items. Who owns that pipeline? + Should the bot own it? Should we publish an `.ics` feed off the + maubot host? + +5. **Photo / media capture.** Does it matter that journal entries + capture the photos attached to them, or is text enough for v1? + (Materially affects storage and the eink panel.) + +6. **The "Alfred" persona.** Yes / no / opt-in per room? + +7. **The private-journal room mode.** Would members value a 1-1 DM + with the bot where every message is auto-journaled, or does the + explicit `!journal` everywhere feel cleaner? + +8. **LLM fallback.** Comfortable running a local model on the same + host that classifies `!add` entries? Or stay rules-only? + +9. **Cross-room fan-out.** Do members value `+#room1 +#room2` + syntax, or is "I'll just repost in the other room" fine? + +--- + +# Appendix A — Glossary + +- **Verb** — A `!`-prefixed word at the start of a message that the + bot recognizes as a command (e.g. `!journal`, `!task`). +- **Tag** — A `#`-prefixed word inside a message that gets attached + to the entry as metadata (e.g. `#buy`, `#priority:1`). +- **Subject** — A `@`-prefixed word that names an entity (animal, + place, asset) the entry is about. +- **Shortcut** — A per-room alias that expands to a universal verb + + tags (e.g. `!buy` expands to `!task #buy`). +- **Replaceable event** — A NIP-52 event keyed by a `d`-tag, where + newer events overwrite older ones with the same key. Used for + living lists like `!goals`. +- **Inbox** — Where `!add` entries land when the classifier can't + determine a kind. Cleared via `!sort` or `!reclassify`. +- **Bot** — The maubot plugin instance attached to a Matrix client + account (e.g. `@journalbot:ariege.io`). +- **Renderer** — Any output surface (eink panel, webapp dashboard, + mobile notification) that consumes captured events. + +--- + +# Appendix B — File map + +Concrete files this report's proposals would change: + +| File | Change | +|---|---| +| `docs/community-organizer-spec.md` | §3.1 plural aliases, §3.2 new verbs, §3.3 replaceable, §3.5 frequency, §3.6 subjects, §3.7 inline markers, §3.8 sub-headers | +| `tracker/tracker.py:52` | Regex updates (case-insensitive, trailing colon) | +| `tracker/tracker.py:56` | `UNIVERSAL_VERBS` additions | +| `tracker/tracker.py:184` | Dispatch — multi-verb + inline tag extraction | +| `tracker/tracker.py` | New `_handle_goals`, `_handle_need`, `_handle_edit` | +| `tracker/tracker.py` (upgrade table) | `upgrade_v2` — `mx_event_id`, `link_id`, `pinned` columns | +| `tracker/classify.py` | Optional — LLM fallback wiring | +| `tracker/README.md:133` | Strike the edit caveat, document new behaviors | +| `journal/journal.py:34` | Regex update consistent with tracker | +| `wiki/wiki.py` | Optional `!ask`-from-tracker integration | + +--- + +*End of report.* diff --git a/docs/2026-06-02-chateau-pilot-review.pdf b/docs/2026-06-02-chateau-pilot-review.pdf new file mode 100644 index 0000000..b974d22 Binary files /dev/null and b/docs/2026-06-02-chateau-pilot-review.pdf differ diff --git a/docs/adr-0001-alfred-vault-trial.md b/docs/adr-0001-alfred-vault-trial.md new file mode 100644 index 0000000..e73b814 --- /dev/null +++ b/docs/adr-0001-alfred-vault-trial.md @@ -0,0 +1,75 @@ +# ADR 0001 — Alfred: agent-runtime trial writing to a Markdown vault + +**Status:** trial, 2026-09-20 +**Owner:** padreug +**Code:** `~/Work/tries/2026-09-20-alfred-vm/` (bohm), not yet in any aiolabs repo + +## Context + +The community-organizer spec (this repo, `docs/community-organizer-spec.md`) +defines capture as NIP-52 events scoped by NIP-72 communities, produced by +the `tracker` maubot plugin. Phase 1 of `tracker` shipped rules-only; the +LLM tier (§6.1 level 2), Nostr publishing (§4) and per-user signing (§7.2) +never landed. The 2026-06-02 pilot review recorded that the community had +already named the bot "Alfred" and asked for digests, an LLM fallback and +grammar tolerance. + +On 2026-09-19 a separate "2nd brain" landed for the operator: plain-Markdown +zk vaults, one Forgejo repo per vault, `brain todos` scanning `- [ ]` lines +in `journal/` and `projects/`, `brain sync` for git. The chateau vault +(`padreug/brain-chateaudufaune`) is one of them. + +The May 2026 planning session had suggested an OpenClaw/ZeroClaw-style +agent "running on its own machine" as an alternate runtime (spec §12). + +## Decision + +Run a **trial** of that alternate runtime, sized to one community and one +vault: + +- **Runtime:** ZeroClaw (0.8.3 from nixpkgs, rebuilt with `channel-matrix`; + upstream NixOS module) in a QEMU VM on bohm. Explicitly **not** on cfaun. +- **Store:** the chateau vault — Markdown + git, `main`, same repo the + operator's laptop syncs. Alfred commits and pushes every write with a + repo-scoped write deploy key; force-push is blocked on Forgejo. +- **Inference:** the optimus box (`http://192.168.0.33:8080/v1`, llama-swap: + GLM-4.7-Flash for chat, GPT-OSS-120B for the nightly digest). +- **Behaviour:** mention-gated replies in one Matrix room; a matrix-nio + sidecar logs the whole room per day; a 22:00 Europe/Paris cron job + digests the log into `journal/YYYY-MM-DD.md` (`## Chat digest (Alfred)`) + and proposes tasks; "what needs doing" runs a deterministic port of + `brain todos` so answers match the laptop. +- **Bounds:** `workspace_only`, shell allowlist `git` + `vault-todos`, no + web/delegation tools, autonomy `full` (switch to `supervised` if it + misbehaves). Prompt injection from the room is bounded to that repo; + git history is the undo. + +## Consequences + +- **Not spec-conformant.** Alfred emits no §4 events and knows nothing of + §5 communities or §7 signing. Nothing downstream (eink renderer, relays, + third-party Nostr clients) sees its output. It shares §3.1 vocabulary + (task / journal / done / list) and the §6 rule that capture never blocks + on classification (unsure → `inbox/`). +- **Two writers, one repo.** Laptop and bot both `pull --rebase` before + push and stage explicit paths only; the bot never touches `hubs/`, + `notes/`, `README.md`, `.zk/`. Conflicts abort and ask a human. +- **Local-model quality is the unknown.** Multi-step tool loops (edit + file + git) on GLM-Flash are unproven; the deterministic todos path is + immune. +- **Dependency churn.** ZeroClaw moves fast; config keys were read from the + v0.8.3 source. The upstream v0.8.5 flake did not evaluate (Cargo hash + mismatch), hence the nixpkgs rebuild. + +## Revisit when + +- The trial holds up for a few weeks → promote to cfaun as a + `services.zeroclaw` instance in `server-deploy` (sops for env + deploy + key, `networking.hosts` for optimus) and decide whether `tracker` is + retired or bridged (a small publisher turning vault commits into §4 + events would restore conformance). +- The agent is unreliable → fall back to a deterministic maubot plugin + (`dev.aiolabs.alfred`): Python vault writers, `brain todos` port, + in-process git under an asyncio lock, model used only to parse JSON. + Sketched in the 2026-09-20 planning session; not built. +- Either way, update spec §12 "Alternate storage (trial)" and this ADR. diff --git a/docs/community-organizer-spec.md b/docs/community-organizer-spec.md index cd40f8b..675e007 100644 --- a/docs/community-organizer-spec.md +++ b/docs/community-organizer-spec.md @@ -855,6 +855,16 @@ scoping in §5. A ZeroClaw-based implementation would carry the `["client", "maubot-tracker", "..."]`; renderers ignore the difference since they filter by community `a`-tag. +### Alternate storage (trial) + +Since 2026-09-20 a ZeroClaw-based "Alfred" trial targets a plain-Markdown +zk vault synced over git (`padreug/brain-chateaudufaune`) as its store +instead of the §4 events. It is **not** spec-conformant — no NIP-52 +events, no §5 community scoping, no §7 signing — but keeps the §3.1 +vocabulary and the §6 rule that capture never blocks on classification +(unsure items go to `inbox/`). Rationale, bounds and revisit criteria in +[`adr-0001-alfred-vault-trial.md`](adr-0001-alfred-vault-trial.md). + ### Reference identity provider — operator-IdP pattern The aiolabs reference implementation runs the **operator-IdP-with- @@ -948,6 +958,9 @@ the bot signing as itself and human attribution carried in the ## Changelog +- **0.3** (2026-09-20) — §12 gains "Alternate storage (trial)": the + Alfred / ZeroClaw trial writes a Markdown git vault, not §4 events; + see ADR 0001. - **0.2** (2026-06-28) — reflect shipped state of the reference IdP + sidecar bunker. `aiolabs/lnbits#9` and `#18` are no longer in-flight; `aiolabs/nsecbunkerd` (deploys from `dev`) is live with diff --git a/journal/README.md b/journal/README.md index e7b84de..ae41088 100644 --- a/journal/README.md +++ b/journal/README.md @@ -40,10 +40,12 @@ CREATE TABLE entries ( user TEXT NOT NULL, -- @sender:domain room TEXT NOT NULL, -- !roomid:domain ts BIGINT NOT NULL, -- ms since epoch (from evt.timestamp) - text TEXT NOT NULL -- raw entry body + text TEXT NOT NULL, -- raw entry body + event_id TEXT -- source event, so edits update in place (v2) ); CREATE INDEX entries_user_ts ON entries (user, ts DESC); CREATE INDEX entries_ts ON entries (ts DESC); +CREATE INDEX entries_event_id ON entries (event_id); ``` Wipe data via the maubot UI's per-instance **Database** tab: @@ -58,11 +60,32 @@ rather keep IDs monotonic across resets.) ## Known quirks -- **Edited messages don't re-trigger the bot.** Matrix sends edits as - a separate `m.replace` event that bots don't react to. If you typed - `!journal` then edited the message to add content, the bot saw only - the empty `!journal` and won't record. Send a fresh message instead - of editing. +- **Editing a `!journal` message updates its entry** (since v0.3.0). + Fix a typo, add a line, and the stored entry changes in place — the + bot confirms with a 📝 reaction on your message rather than posting + a reply. The original send time is kept, since `ts` records when the + work was logged, not when the wording was corrected. + + This works because entries are keyed on the source event id, added in + the v2 migration. Entries recorded before v0.3.0 have `event_id IS + NULL` and so can't be matched — editing one of those is a no-op. + + Worth knowing if you write other maubot plugins: mautrix swaps an + edit's content for `m.new_content` before the handler sees it + (`mautrix/types/event/message.py:393-396`), stripping the `* ` + fallback prefix, so an edit is **indistinguishable from a new + command**. Neither `@command.passive` nor `@command.new` filters + them. A plugin that doesn't call `evt.content.get_edit()` will + silently record a duplicate on every edit — which is exactly what + this plugin did before v0.3.0. + +- **Reactions never trigger the bot.** `@command.passive` registers on + `EventType.ROOM_MESSAGE` and filters `msgtype` to `(m.text,)`, so an + `m.reaction` fails both checks. If a "Logged for" appears right after + someone reacts, the trigger was an edit landing at the same moment — + a reply to an edit event renders as "This event could not be + displayed" in Element, which makes it look unrelated to any message. + - **`!journal show ` runs the show query with that text as the user filter.** If it doesn't match any MXID, you get "No entries." Use a fully-qualified MXID like `@pat:ariege.io`. diff --git a/journal/journal.py b/journal/journal.py index 2a78fe3..63098dd 100644 --- a/journal/journal.py +++ b/journal/journal.py @@ -25,6 +25,12 @@ async def upgrade_v1(conn: Connection) -> None: await conn.execute("CREATE INDEX entries_ts ON entries (ts DESC)") +@upgrade_table.register(description="Track source event id so edits can update entries") +async def upgrade_v2(conn: Connection) -> None: + await conn.execute("ALTER TABLE entries ADD COLUMN event_id TEXT") + await conn.execute("CREATE INDEX entries_event_id ON entries (event_id)") + + # Match `!journal` followed by any whitespace (space, tab, OR newline) # and capture everything after. Maubot's @command.new parser only treats # *space* as the command/args delimiter, so `!journal\n` gets @@ -60,6 +66,18 @@ class JournalBot(Plugin): async def journal(self, evt: MessageEvent, match) -> None: rest = (match[1] or "").strip() + # An edit arrives as a *fresh* m.room.message whose content mautrix + # swaps for `m.new_content` before we see it + # (mautrix/types/event/message.py:393-396), stripping the "* " + # fallback prefix — so it is indistinguishable from a new command + # and used to record a duplicate row per edit. get_edit() returns + # the *original* event id, which is what entries are keyed on, so + # the edit updates that row in place instead. + edit_of = evt.content.get_edit() + if edit_of: + await self._apply_edit(evt, edit_of, rest) + return + if not rest: await evt.reply(_USAGE) return @@ -101,10 +119,39 @@ class JournalBot(Plugin): # Default: record the full rest (multi-line preserved) await self.database.execute( - "INSERT INTO entries (user, room, ts, text) VALUES ($1, $2, $3, $4)", + "INSERT INTO entries (user, room, ts, text, event_id)" + " VALUES ($1, $2, $3, $4, $5)", evt.sender, evt.room_id, evt.timestamp, rest, + evt.event_id, ) await evt.reply(f"📓 Logged for {evt.sender}.") + + async def _apply_edit(self, evt: MessageEvent, original_id, rest: str) -> None: + """Apply an edit of a previously recorded `!journal` message.""" + row = await self.database.fetchrow( + "SELECT id FROM entries WHERE event_id = $1", original_id + ) + if row is None: + # Edit of a message that never became an entry: a `show`/`today` + # query, or an entry recorded before v0.3.0 started tracking + # event_id. Nothing to update, and re-recording would duplicate. + return + if not rest: + # Editing the body away would blank the entry; leave it alone. + return + + # `ts` deliberately keeps the original send time — it records when + # the work was logged, not when a typo was fixed. + await self.database.execute( + "UPDATE entries SET text = $1 WHERE event_id = $2", rest, original_id + ) + try: + # Confirm on the original message rather than replying: a reply + # to an edit event renders as "This event could not be displayed" + # in Element, and a message per keystroke-save is noise. + await self.client.react(evt.room_id, original_id, "📝") + except Exception: + self.log.debug("Could not react to edited entry", exc_info=True) diff --git a/journal/maubot.yaml b/journal/maubot.yaml index 74e2ae3..7084d99 100644 --- a/journal/maubot.yaml +++ b/journal/maubot.yaml @@ -1,6 +1,6 @@ maubot: 0.1.0 id: dev.aiolabs.journal -version: 0.2.0 +version: 0.3.0 license: AGPL-3.0-or-later modules: - journal