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

48
tracker/classify.py Normal file
View file

@ -0,0 +1,48 @@
"""Rules-based classifier for !add freeform capture.
Level 1 of the spec's classifier conformance (§6.1). Maps obvious
shapes to a kind+tags. Anything ambiguous returns (None, []) and the
caller records the entry as `unclassified` for later sorting.
LLM fallback is Phase 2b and lives separately.
"""
import re
_BUY_RE = re.compile(
r"\b(buy|purchase|order|pick\s*up|grab|get\s+more)\b",
re.IGNORECASE,
)
_REMIND_PREFIX_RE = re.compile(
r"\b(remind\s+me\s+to|don'?t\s+forget|remember\s+to)\b",
re.IGNORECASE,
)
_PAST_TENSE_RE = re.compile(
r"\b("
r"did|finished|completed|cleaned|watered|fed|opened|closed|"
r"planted|harvested|fixed|moved|painted|swept|mucked|brushed|"
r"installed|repaired|cooked|prepared"
r")\b",
re.IGNORECASE,
)
_TASK_INTENT_RE = re.compile(
r"\b(need\s+to|should|have\s+to|must|todo|to\s+do)\b",
re.IGNORECASE,
)
def classify(body: str) -> tuple[str | None, list[str]]:
"""Return (kind, tags). (None, []) means unclassified."""
if _REMIND_PREFIX_RE.search(body):
return ("remind", [])
if _BUY_RE.search(body):
return ("task", ["buy"])
if _PAST_TENSE_RE.search(body):
return ("journal", [])
if _TASK_INTENT_RE.search(body):
return ("task", [])
return (None, [])