Sibling to journal/, implements the Community Organizer spec (docs/community-organizer-spec.md) over maubot: !add <text> freeform inbox capture; rules classify !task <text> [#tag…] explicit task !sidequest [#tag…] optional / passion-project item !remind in <N>(s|m|h|d) <text> chat-side timed reminder !done <id> close task or sidequest !list [type] query open items !setup add/remove per-room shortcut config One @command.passive dispatcher routes universal verbs to handlers and unknown verbs through the per-room shortcut table. Avoids the multi-line @command.new footgun (per ~/dev/CLAUDE.md) and lets shortcuts coexist with universal verbs without decorator priority games. Rules classifier (classify.py) is intentionally conservative — only buckets on clear shapes (buy keywords, past-tense markers, todo intent, remind prefixes); ambiguous capture lands in `!list inbox`. LLM fallback is Phase 2b on a dedicated inference node. Reminders (scheduler.py) replay from DB on bot start so restarts don't lose pending timers; missed-while-down stay open for query. `nostr_event_id` column reserved for Phase 2a — Matrix-local only for now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| classify.py | ||
| maubot.yaml | ||
| README.md | ||
| scheduler.py | ||
| tracker.py | ||
tracker
Community-organizer bot for Matrix. Implements the Community Organizer spec over maubot — capture/list/close tasks, sidequests, reminders, and freeform inbox entries scoped per room.
Sibling to journal/ (which owns !journal); the two
work side-by-side on the same maubot instance if you want both.
Quickstart
!add door handles from Laura # freeform, classifier sorts it
!task fix the south fence #urgent # explicit task
!sidequest #priority:3 build a chicken-tractor
!remind in 30m check the eggs # reminder; fires in chat at due time
!list # open items in this room
!list sidequest # only sidequests
!done 17 # close item 17
!setup # show room shortcuts
!setup add buy task #buy # define a !buy shortcut for this room
Multi-line input works either inline or after a newline:
!task
- fix the alpaca fence
- patch the duck pond liner
- replace the gate latch
Commands
| Command | Purpose |
|---|---|
!add <text> |
Freeform capture. Rules classifier sorts into a kind (task/journal/remind/etc.) or leaves it in the inbox as unclassified for later sorting. |
!task <text> [#tag…] |
Direct task capture. Tags become NIP-52 t tags. |
!sidequest <text> [#tag…] |
Optional / passion-project item. Same lifecycle as a task but listed separately so the day-to-day list stays clean. |
!remind in <N>(s|m|h|d) <text> |
Schedule a chat-side reminder. Fires by posting in the room at the due time. |
!done <id> |
Close a task or sidequest by id. |
!list [task|sidequest|remind|inbox|all] |
List open items in this room (last 20). Default is everything except journals. |
!setup |
Show current room shortcuts. |
!setup add <verb> <kind> [#tag…] |
Add a per-room shortcut (e.g. !setup add buy task #buy). |
!setup remove <verb> |
Drop a shortcut. |
Universal verbs are hardcoded; per-room shortcuts are stored in the
plugin DB and apply only to the room they're defined in. A bakery
co-op can define !harvest / !market in their rooms; Ariège can
define !buy / !steward / !chores in theirs. Same plugin code.
Priority
Items can carry a priority tag (#priority:1 through #priority:5)
per the spec's 5-level scale: urgent / crucial / important / future /
frequent-ongoing. The bot surfaces the label in !list output;
renderers (eink panel, etc.) decide how to use it for sorting and
visual emphasis.
The most ergonomic capture is via room shortcuts:
!setup add urgent task #priority:1
!setup add chores task #priority:5
Then !urgent the alpaca fence is down records a priority-1 task.
Classification
!add runs through a tiny rules engine (classify.py):
| Pattern | Bucket |
|---|---|
remind me to… / don't forget… / remember to… |
remind (no auto-time; you'll need to re-capture with !remind in <N>… to set a fire-time) |
buy / purchase / order / pick up / grab / get more |
task #buy |
past-tense verbs (did, finished, cleaned, watered, fed, …) |
journal |
need to / should / have to / must / todo |
task |
| anything else | unclassified (sits in !list inbox until sorted) |
This is intentionally conservative — better to leave something unclassified than to mis-bucket it. The Phase 2b plan is an LLM fallback on a dedicated inference node that handles the long tail.
Storage
One SQLite DB per maubot instance, schema managed by
mautrix.util.async_db.UpgradeTable. Two tables:
-- The items log
CREATE TABLE items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL, -- task | sidequest | journal | remind | unclassified
status TEXT NOT NULL, -- open | done | canceled
tags TEXT NOT NULL DEFAULT '', -- comma-separated; e.g. "buy,priority:2"
room TEXT NOT NULL,
user TEXT NOT NULL, -- originating MXID
ts BIGINT NOT NULL, -- captured at (ms since epoch)
due_at BIGINT, -- for reminders/timed tasks
body TEXT NOT NULL,
classification_source TEXT, -- explicit | shortcut | rules | llm | manual
nostr_event_id TEXT -- populated in Phase 2 (Nostr bridge)
);
-- Per-room shortcut config + (future) publish posture
CREATE TABLE community_config (
room TEXT PRIMARY KEY,
config_json TEXT NOT NULL
);
Wipe via the maubot UI's per-instance Database tab:
DELETE FROM items;
DELETE FROM community_config;
DELETE FROM sqlite_sequence WHERE name = 'items';
Reminders survive restarts
Pending reminders are reloaded from the DB when the bot starts —
nothing in flight when maubot restarts, but anything scheduled for
the future re-arms. Reminders whose due time elapsed while the bot
was down stay open (they aren't auto-fired late); query with
!list remind to find them.
Known quirks
- Edited messages don't re-trigger the bot. Matrix sends edits
as a separate
m.replaceevent that maubot doesn't pass to handlers. Type a fresh message instead of editing. - Time parsing is minimal in v1. Only
in <N>(s|m|h|d)works. No "tomorrow 9am", no calendar dates. Use Phase 2b's LLM layer or upstreamdateparserfor richer parsing later. !doneaccepts only the id. Closing by partial text match (e.g.!done flypaper) isn't implemented in v1.- Phase 1 is Matrix-local. Nothing publishes to Nostr yet — the
nostr_event_idcolumn exists but is always NULL until Phase 2a lands the bridge. - Universal verbs win over shortcuts. You can't define a shortcut
named
task,add, etc. — those are protocol-level.
Build + iterate
cd ~/dev/maubot-plugins/tracker
# bump version in maubot.yaml so the UI surfaces the new build
zip -j ../tracker.mbp maubot.yaml *.py
Upload via maubot UI → Plugins → click existing → upload new .mbp.
Hit Save on the affected instance after upload — toggling
Enabled alone doesn't persist (easy facepalm). Create a fresh bot
client (e.g. @trackerbot:ariege.io) or attach to an existing one;
invite to the rooms you want it active in.
See ~/dev/CLAUDE.md "Maubot plugin development" for the multi-line
command footgun and other plugin-wide gotchas.
Architecture notes
This plugin uses @command.passive with a single dispatch regex
(^!(\w+)…) rather than per-verb @command.new decorators. Two
reasons:
- Multi-line freeform input. Per
~/dev/CLAUDE.md,@command.newsilently drops!task\n<body>because maubot's parser only treats space as the command/args delimiter. Passive regex withre.DOTALLcatches every form. - Per-room shortcuts. A single dispatch handler can route universal verbs to fixed handlers AND check the room's shortcut table for unknown verbs, all in one place. Cleaner than declaring N decorators and N matchers.