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>
This commit is contained in:
Padreug 2026-05-24 15:48:21 +02:00
commit b7a096a77a
6 changed files with 693 additions and 0 deletions

392
tracker/tracker.py Normal file
View file

@ -0,0 +1,392 @@
import json
import re
import time
from datetime import datetime, timezone
from maubot import MessageEvent, Plugin
from maubot.handlers import command
from mautrix.util.async_db import Connection, UpgradeTable
from classify import classify
from scheduler import RemindScheduler
upgrade_table = UpgradeTable()
@upgrade_table.register(description="Initial schema")
async def upgrade_v1(conn: Connection) -> None:
await conn.execute(
"""
CREATE TABLE items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
status TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '',
room TEXT NOT NULL,
user TEXT NOT NULL,
ts BIGINT NOT NULL,
due_at BIGINT,
body TEXT NOT NULL,
classification_source TEXT,
nostr_event_id TEXT
)
"""
)
await conn.execute(
"CREATE INDEX items_room_kind_status_ts"
" ON items (room, kind, status, ts DESC)"
)
await conn.execute(
"CREATE INDEX items_due_at ON items (due_at) WHERE due_at IS NOT NULL"
)
await conn.execute(
"""
CREATE TABLE community_config (
room TEXT PRIMARY KEY,
config_json TEXT NOT NULL
)
"""
)
_CMD_RE = re.compile(r"^!(\w+)(?:[ \t\r\n]+(.*))?$", re.DOTALL)
_TAG_RE = re.compile(r"#(\S+)")
_RELATIVE_TIME_RE = re.compile(r"^in\s+(\d+)\s*([smhd])\b", re.IGNORECASE)
UNIVERSAL_VERBS = {"add", "task", "sidequest", "remind", "done", "list", "setup"}
DEFAULT_CONFIG = {
"verbs": {},
"publish": "matrix-only",
}
PRIORITY_LABELS = {
"1": "urgent",
"2": "crucial",
"3": "important",
"4": "future",
"5": "frequent/ongoing",
}
_USAGE = (
"**Tracker commands:**\n"
"- `!add <text>` — freeform inbox capture (auto-classified)\n"
"- `!task <text> [#tag…]` — record a task\n"
"- `!sidequest <text> [#tag…]` — record an optional / passion-project item\n"
"- `!remind in <N>(s|m|h|d) <text>` — fire a reminder later\n"
"- `!done <id>` — close a task or sidequest\n"
"- `!list [task|sidequest|remind|inbox|all]` — list recent items in this room\n"
"- `!setup` — show or configure room shortcuts\n"
"\nSee `docs/community-organizer-spec.md` for the protocol shape."
)
def _parse_tags(body: str) -> tuple[list[str], str]:
"""Pull #tag tokens out of body; return (tags, cleaned_body)."""
tags = _TAG_RE.findall(body)
cleaned = _TAG_RE.sub("", body).strip()
cleaned = re.sub(r"\s+", " ", cleaned)
return tags, cleaned
def _parse_relative_time(s: str) -> tuple[int | None, str]:
"""Returns (due_at_ms, remainder). (None, s) if unparseable."""
m = _RELATIVE_TIME_RE.match(s)
if not m:
return None, s
n = int(m.group(1))
unit = m.group(2).lower()
mult = {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
due_at_s = time.time() + n * mult
rest = s[m.end():].strip()
return int(due_at_s * 1000), rest
def _format_age(ts_ms: int) -> str:
delta = time.time() - ts_ms / 1000
if delta < 60:
return "just now"
if delta < 3600:
return f"{int(delta / 60)}m ago"
if delta < 86400:
return f"{int(delta / 3600)}h ago"
return f"{int(delta / 86400)}d ago"
def _priority_label(tags: list[str]) -> str | None:
for t in tags:
if t.startswith("priority:"):
return PRIORITY_LABELS.get(t.split(":", 1)[1])
return None
class TrackerBot(Plugin):
scheduler: RemindScheduler
@classmethod
def get_db_upgrade_table(cls) -> UpgradeTable:
return upgrade_table
async def start(self) -> None:
await super().start()
self.scheduler = RemindScheduler(self)
await self.scheduler.start()
async def stop(self) -> None:
if hasattr(self, "scheduler"):
await self.scheduler.stop()
await super().stop()
async def _get_config(self, room: str) -> dict:
row = await self.database.fetchrow(
"SELECT config_json FROM community_config WHERE room = $1", room
)
if row:
return json.loads(row["config_json"])
return json.loads(json.dumps(DEFAULT_CONFIG))
async def _set_config(self, room: str, cfg: dict) -> None:
await self.database.execute(
"INSERT INTO community_config (room, config_json) VALUES ($1, $2)"
" ON CONFLICT (room) DO UPDATE SET config_json = excluded.config_json",
room,
json.dumps(cfg),
)
async def _record(
self,
evt: MessageEvent,
kind: str,
body: str,
tags: list[str],
status: str = "open",
due_at: int | None = None,
source: str = "explicit",
) -> int:
tags_str = ",".join(tags)
item_id = await self.database.fetchval(
"INSERT INTO items"
" (kind, status, tags, room, user, ts, due_at, body, classification_source)"
" VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id",
kind,
status,
tags_str,
evt.room_id,
evt.sender,
evt.timestamp,
due_at,
body,
source,
)
return item_id
@command.passive(regex=_CMD_RE)
async def dispatch(self, evt: MessageEvent, match) -> None:
verb = match[1].lower()
body = (match[2] or "").strip()
if verb in UNIVERSAL_VERBS:
handler = getattr(self, f"_handle_{verb}")
await handler(evt, body)
return
cfg = await self._get_config(evt.room_id)
shortcut = cfg.get("verbs", {}).get(verb)
if shortcut:
await self._handle_shortcut(evt, body, shortcut, verb)
return
async def _handle_add(self, evt: MessageEvent, body: str) -> None:
if not body:
await evt.reply(_USAGE)
return
explicit_tags, cleaned = _parse_tags(body)
kind, auto_tags = classify(cleaned)
if kind is None:
await self._record(evt, "unclassified", cleaned, explicit_tags, "open", None, "rules")
await evt.reply(
"📥 Logged to inbox (unclassified).\n"
"Reply with `!sort <kind>` on this thread to bucket, or use `!list inbox`."
)
return
tags = list(dict.fromkeys(auto_tags + explicit_tags))
await self._record(evt, kind, cleaned, tags, "open", None, "rules")
tag_display = (" #" + " #".join(tags)) if tags else ""
await evt.reply(f"📥 Logged as `{kind}`{tag_display}. (auto-classified)")
async def _handle_task(self, evt: MessageEvent, body: str) -> None:
await self._task_like(evt, body, "task", [], "explicit", "✅ Task")
async def _handle_sidequest(self, evt: MessageEvent, body: str) -> None:
await self._task_like(evt, body, "sidequest", [], "explicit", "🎯 Sidequest")
async def _handle_shortcut(
self, evt: MessageEvent, body: str, shortcut: dict, verb: str
) -> None:
kind = shortcut.get("kind", "task")
base_tags = list(shortcut.get("tags", []))
emoji = "🎯" if kind == "sidequest" else ""
await self._task_like(evt, body, kind, base_tags, "shortcut", f"{emoji} {kind.capitalize()} (#{verb})")
async def _task_like(
self,
evt: MessageEvent,
body: str,
kind: str,
base_tags: list[str],
source: str,
label: str,
) -> None:
if not body:
await evt.reply(f"Usage: `!{kind} <text> [#tag…]`")
return
explicit_tags, cleaned = _parse_tags(body)
tags = list(dict.fromkeys(base_tags + explicit_tags))
item_id = await self._record(evt, kind, cleaned, tags, "open", None, source)
prio = _priority_label(tags)
prio_display = f" *(priority: {prio})*" if prio else ""
tag_display = (" #" + " #".join(t for t in tags if not t.startswith("priority:"))) if tags else ""
await evt.reply(f"{label}{tag_display}{prio_display} — id `{item_id}`")
async def _handle_remind(self, evt: MessageEvent, body: str) -> None:
if not body:
await evt.reply(
"Usage: `!remind in <N>(s|m|h|d) <text>`\n"
"Example: `!remind in 30m check the eggs`"
)
return
due_at_ms, rest = _parse_relative_time(body)
if due_at_ms is None or not rest:
await evt.reply(
"Couldn't parse the time. Use `in <N>(s|m|h|d) <text>` "
"(e.g. `in 2h water the chickens`)."
)
return
explicit_tags, cleaned = _parse_tags(rest)
item_id = await self._record(
evt, "remind", cleaned, explicit_tags, "open", due_at_ms, "explicit"
)
self.scheduler.schedule(item_id, due_at_ms, evt.room_id, cleaned)
when = datetime.fromtimestamp(due_at_ms / 1000, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
await evt.reply(f"⏰ Reminder set for {when} — id `{item_id}`")
async def _handle_done(self, evt: MessageEvent, body: str) -> None:
if not body:
await evt.reply("Usage: `!done <id>` — close a task or sidequest.")
return
try:
item_id = int(body.strip().split()[0])
except (ValueError, IndexError):
await evt.reply("That doesn't look like an id. Try `!list` to find one.")
return
row = await self.database.fetchrow(
"SELECT id, kind, body, status FROM items"
" WHERE id = $1 AND room = $2",
item_id,
evt.room_id,
)
if not row:
await evt.reply(f"No item `{item_id}` in this room.")
return
if row["kind"] == "journal":
await evt.reply("Journal entries don't get closed — they're append-only.")
return
if row["status"] == "done":
await evt.reply(f"`{item_id}` is already done.")
return
await self.database.execute(
"UPDATE items SET status = 'done' WHERE id = $1", item_id
)
await evt.reply(f"✅ Closed `{item_id}`: {row['body']}")
async def _handle_list(self, evt: MessageEvent, body: str) -> None:
body = body.strip()
filter_clause = "kind != 'journal' AND status = 'open'"
title = "Open items"
if body in ("task", "tasks"):
filter_clause = "kind = 'task' AND status = 'open'"
title = "Open tasks"
elif body in ("sidequest", "sidequests"):
filter_clause = "kind = 'sidequest' AND status = 'open'"
title = "Open sidequests"
elif body in ("remind", "reminders"):
filter_clause = "kind = 'remind' AND status = 'open'"
title = "Pending reminders"
elif body == "inbox":
filter_clause = "kind = 'unclassified' AND status = 'open'"
title = "Inbox (unclassified)"
elif body == "all":
filter_clause = "status = 'open'"
title = "Everything open"
rows = await self.database.fetch(
f"SELECT id, kind, tags, body, ts, due_at FROM items"
f" WHERE room = $1 AND {filter_clause}"
f" ORDER BY ts DESC LIMIT 20",
evt.room_id,
)
if not rows:
await evt.reply(f"{title}: nothing here.")
return
lines = [f"**{title} ({len(rows)}):**"]
for r in rows:
tags = [t for t in (r["tags"] or "").split(",") if t]
prio = _priority_label(tags)
display_tags = [t for t in tags if not t.startswith("priority:")]
tag_str = (" #" + " #".join(display_tags)) if display_tags else ""
prio_str = f" *[{prio}]*" if prio else ""
kind_str = "" if r["kind"] == "task" else f" _({r['kind']})_"
lines.append(
f"- `{r['id']}`{kind_str}{prio_str}{tag_str}: {r['body']} "
f"_({_format_age(r['ts'])})_"
)
await evt.reply("\n".join(lines))
async def _handle_setup(self, evt: MessageEvent, body: str) -> None:
cfg = await self._get_config(evt.room_id)
body = body.strip()
if not body or body == "show":
verbs = cfg.get("verbs", {})
if not verbs:
await evt.reply(
"No room shortcuts configured.\n"
"Add one with: `!setup add <verb> <kind> [#tag…]`\n"
"Example: `!setup add buy task #buy`"
)
return
lines = ["**Room shortcuts:**"]
for v, spec in verbs.items():
tags = spec.get("tags", [])
tag_str = " ".join(f"#{t}" for t in tags)
lines.append(f"- `!{v}` → `!{spec.get('kind', 'task')} {tag_str}`")
await evt.reply("\n".join(lines))
return
parts = body.split()
if parts[0] == "add" and len(parts) >= 3:
verb, kind = parts[1].lstrip("!"), parts[2]
if verb in UNIVERSAL_VERBS:
await evt.reply(f"`{verb}` is a universal verb — pick a different name.")
return
if kind not in ("task", "sidequest", "remind"):
await evt.reply(f"Kind must be task, sidequest, or remind (got `{kind}`).")
return
tags = [t.lstrip("#") for t in parts[3:] if t.startswith("#")]
cfg.setdefault("verbs", {})[verb] = {"kind": kind, "tags": tags}
await self._set_config(evt.room_id, cfg)
tag_display = " ".join(f"#{t}" for t in tags)
await evt.reply(f"✅ Added: `!{verb}` → `!{kind} {tag_display}`")
return
if parts[0] == "remove" and len(parts) >= 2:
verb = parts[1].lstrip("!")
if verb in cfg.get("verbs", {}):
del cfg["verbs"][verb]
await self._set_config(evt.room_id, cfg)
await evt.reply(f"🗑 Removed shortcut `!{verb}`.")
else:
await evt.reply(f"No shortcut `!{verb}` configured.")
return
await evt.reply(
"Usage:\n"
"- `!setup` or `!setup show` — list shortcuts\n"
"- `!setup add <verb> <kind> [#tag…]` — add a shortcut\n"
"- `!setup remove <verb>` — drop a shortcut"
)