maubot-plugins/tracker
Padreug b7a096a77a feat(tracker): Phase 1 plugin — Matrix + SQLite, rules-only
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>
2026-05-24 15:48:21 +02:00
..
classify.py feat(tracker): Phase 1 plugin — Matrix + SQLite, rules-only 2026-05-24 15:48:21 +02:00
maubot.yaml feat(tracker): Phase 1 plugin — Matrix + SQLite, rules-only 2026-05-24 15:48:21 +02:00
README.md feat(tracker): Phase 1 plugin — Matrix + SQLite, rules-only 2026-05-24 15:48:21 +02:00
scheduler.py feat(tracker): Phase 1 plugin — Matrix + SQLite, rules-only 2026-05-24 15:48:21 +02:00
tracker.py feat(tracker): Phase 1 plugin — Matrix + SQLite, rules-only 2026-05-24 15:48:21 +02:00

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.replace event 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 upstream dateparser for richer parsing later.
  • !done accepts 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_id column 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:

  1. Multi-line freeform input. Per ~/dev/CLAUDE.md, @command.new silently drops !task\n<body> because maubot's parser only treats space as the command/args delimiter. Passive regex with re.DOTALL catches every form.
  2. 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.