maubot-plugins/tracker/README.md
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

176 lines
6.9 KiB
Markdown

# tracker
Community-organizer bot for Matrix. Implements the
[Community Organizer spec](../docs/community-organizer-spec.md) over
maubot — capture/list/close tasks, sidequests, reminders, and freeform
inbox entries scoped per room.
Sibling to [`journal/`](../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:
```sql
-- 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:
```sql
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
```sh
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.