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:
parent
774cb44a4a
commit
b7a096a77a
6 changed files with 693 additions and 0 deletions
392
tracker/tracker.py
Normal file
392
tracker/tracker.py
Normal 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"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue