diff --git a/skills/remind/SKILL.md b/skills/remind/SKILL.md index 249a199..03c5e51 100644 --- a/skills/remind/SKILL.md +++ b/skills/remind/SKILL.md @@ -7,83 +7,55 @@ description: > # /remind -Reminders are stored in SQLite (`db/reminders.sqlite`) and delivered by the nanobot -user crontab, directly to Telegram, outside the agent. Reply to the user in their own language. +Reminders are stored in SQLite (`db/reminders.sqlite`) and delivered by `remind_send.py`, +which runs every minute from the nanobot user crontab, directly to Telegram, outside the agent. +Reply to the user in their own language. -## Commands +## Natural language → command mapping -### Create -``` -/remind every day at 9:00 -/remind every weekday at 9:30 -/remind at 2026-06-15T18:00 -/remind randomly 2 times between 08:00 and 20:00 -/remind once daily at random time between 8:00 and 21:00 +| User says | Command | +|-----------|---------| +| "every day at 9" / "every weekday at 9:30" | `add --cron "0 9 * * *"` | +| "on 2026-06-15 at 18:00" / "once at …" | `add --at "2026-06-15T18:00:00"` | +| "randomly 2× between 08:00 and 20:00" | `add --random-times-per-day 2 --random-window 08:00-20:00` | +| "what reminders arrived today / since when" | `delivered [--since YYYY-MM-DD]` | +| list all reminders | `list` | + +For the full flag reference of any command, run: + +```sh +uv run skills/remind/scripts/remind_cli.py --help +uv run skills/remind/scripts/remind_cli.py --help ``` -Parse natural language, then call `remind_edit.py add` with flags: -- `--text "..."` -- `--cron "0 9 * * *"` (repeatable) -- `--at "2026-06-15T18:00:00"` (repeatable) -- `--random-times-per-day N --random-window HH:MM-HH:MM [--random-days DOW] [--random-from YYYY-MM-DD] [--random-until YYYY-MM-DD]` +## Behavioral contract + +**`list`** returns readable text. Each reminder: -### List ``` -/remind list -``` -Call `remind_edit.py list`. Returns a readable text listing — each reminder is prefixed with -`#` (that id is what `--id` selects), followed by indented schedule lines: -``` -#3 drink water [enabled] +# text [enabled|disabled] cron: 0 9 * * * -#7 take meds [disabled] at: 2026-06-15T18:00:00 random: 2× daily 09:00–21:00 (1-5) from 2026-06-01 ``` + An empty store prints `(no active reminders)`. -### Edit -``` -/remind edit --text "new text" -/remind edit --replace-schedules --cron "0 10 * * *" -``` -Call `remind_edit.py edit --keyword `. Keyword matches case-insensitively against reminder text. `--replace-schedules` requires at least one new `--cron`/`--at`/`--random-*`. +**Mutations** (`add`, `edit`, `remove`, `enable`, `disable`) return JSON: `{"added": …}`, `{"edited": …}`, etc. Errors go to stderr with a non-zero exit code. -### Enable / Disable -``` -/remind disable -/remind enable -``` +**Selecting a reminder:** `edit`, `remove`, `enable`, `disable` accept `--keyword` (case-insensitive substring) or `--id` (exact). An ambiguous keyword match returns `{"error": "ambiguous", "matches": […]}` — retry with `--id `. Run `list` to see ids. -### Remove -``` -/remind remove -``` -Soft delete. Hard delete only via direct DB access. +**`delivered`** reads the `reminder_fires` table (delivered rows only, Prague local time). Defaults to today; `--since YYYY-MM-DD` widens the window. The agent never sees deliveries happen — this is the only window into them. -### Selecting by keyword or id -`edit`, `remove`, `enable`, `disable` accept either `--keyword` (case-insensitive substring) or `--id` (exact). A keyword matching two or more reminders returns `ambiguous` with each id — retry with `--id ` to disambiguate duplicate texts. Run `list` to see ids. +**`remove`** is a soft delete. -### Delivered (history) -``` -/remind delivered -/remind delivered --since 2026-06-01 -``` -Answers "what reminders arrived today / since when?". Calls `remind_edit.py delivered`, which reads the `reminder_fires` table (delivered rows only, Prague local time). The agent never sees deliveries happen, so this is the only window into them. +## Editing reminders -## Scripts +**To fix or change wording:** use `edit --id --text "…"` (get the id from `list`), +or `edit --keyword --text "…"`. +**NEVER remove + re-add a reminder just to change its text** — that loses the delivery history and changes the id. -| Action | Command | -|--------|---------| -| list | `uv run skills/remind/scripts/remind_edit.py list` | -| add | `uv run skills/remind/scripts/remind_edit.py add --text "..." ...` | -| edit | `uv run skills/remind/scripts/remind_edit.py edit --keyword ...` | -| remove | `uv run skills/remind/scripts/remind_edit.py remove --keyword ` | -| enable | `uv run skills/remind/scripts/remind_edit.py enable --keyword ` | -| disable | `uv run skills/remind/scripts/remind_edit.py disable --keyword ` | -| delivered | `uv run skills/remind/scripts/remind_edit.py delivered [--since YYYY-MM-DD]` | - -Sender runs every minute from crontab: `uv run skills/remind/scripts/remind_send.py` +Use `--replace-schedules` (with at least one new `--cron`/`--at`/`--random-*`) only when you need to change the *schedule*, not the text. ## Environment diff --git a/skills/remind/scripts/random_times.py b/skills/remind/scripts/random_times.py index 8ad9290..3351763 100644 --- a/skills/remind/scripts/random_times.py +++ b/skills/remind/scripts/random_times.py @@ -1,6 +1,6 @@ """Deterministic random fire-time computation for reminders. -Shared by remind_send.py (runtime) and remind_edit.py (validation). Stdlib only, +Shared by remind_send.py (runtime) and remind_cli.py (validation). Stdlib only, so it imports cleanly regardless of the caller's uv/PEP 723 environment. A reminder's `random` block produces `times_per_day` fire times inside a daily @@ -81,6 +81,11 @@ def _hhmm_to_minutes(value: str) -> int: return hours * 60 + minutes +def minutes_to_hhmm(total: int) -> str: + """Format a minute offset (e.g. 570) as HH:MM (e.g. '09:30').""" + return f"{total // 60:02d}:{total % 60:02d}" + + def _minute_to_time(total_minutes: int) -> time: return time(total_minutes // 60, total_minutes % 60) diff --git a/skills/remind/scripts/remind_edit.py b/skills/remind/scripts/remind_cli.py similarity index 98% rename from skills/remind/scripts/remind_edit.py rename to skills/remind/scripts/remind_cli.py index f7dc03b..845ab5a 100755 --- a/skills/remind/scripts/remind_edit.py +++ b/skills/remind/scripts/remind_cli.py @@ -21,7 +21,7 @@ from zoneinfo import ZoneInfo from croniter import croniter from db import get_db, init_db, log_operation -from random_times import compute_fire_times, parse_window +from random_times import compute_fire_times, minutes_to_hhmm, parse_window WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent DEFAULT_DB_PATH = WORKSPACE / "db" / "reminders.sqlite" @@ -200,7 +200,7 @@ def _schedule_lines(conn, reminder_id: int) -> list[str]: (reminder_id,), ) for r in random_rows: - window = f"{_minutes_to_hhmm(r['window_start'])}–{_minutes_to_hhmm(r['window_end'])}" + window = f"{minutes_to_hhmm(r['window_start'])}–{minutes_to_hhmm(r['window_end'])}" parts = [f"random: {r['times_per_day']}× daily {window}"] if r["days_filter"]: parts.append(f"({r['days_filter']})") @@ -212,10 +212,6 @@ def _schedule_lines(conn, reminder_id: int) -> list[str]: return lines -def _minutes_to_hhmm(total: int) -> str: - return f"{total // 60:02d}:{total % 60:02d}" - - def cmd_add(args: argparse.Namespace) -> int: text = (args.text or "").strip() if not text: diff --git a/skills/remind/scripts/remind_send.py b/skills/remind/scripts/remind_send.py index 462667f..bf8a5df 100644 --- a/skills/remind/scripts/remind_send.py +++ b/skills/remind/scripts/remind_send.py @@ -23,7 +23,7 @@ from zoneinfo import ZoneInfo from croniter import croniter from db import get_db, init_db, log_operation -from random_times import compute_fire_times +from random_times import compute_fire_times, minutes_to_hhmm WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent DEFAULT_DB_PATH = WORKSPACE / "db" / "reminders.sqlite" @@ -129,7 +129,7 @@ def _due_random(conn, now: datetime) -> list[dict]: for row in rows: cfg = { "times_per_day": row["times_per_day"], - "window": f"{_minutes_to_hhmm(row['window_start'])}-{_minutes_to_hhmm(row['window_end'])}", + "window": f"{minutes_to_hhmm(row['window_start'])}-{minutes_to_hhmm(row['window_end'])}", } if row["days_filter"]: cfg["days"] = row["days_filter"] @@ -164,10 +164,6 @@ def _due_random(conn, now: datetime) -> list[dict]: return due -def _minutes_to_hhmm(total: int) -> str: - return f"{total // 60:02d}:{total % 60:02d}" - - def _record_fire(conn, reminder_id: int, schedule_id: int, schedule_type: str, fire_time: str, status: str, error: str | None = None) -> None: now = _now_prague().isoformat(timespec="seconds") conn.execute( diff --git a/skills/remind/tests/test_remind_edit.py b/skills/remind/tests/test_remind_cli.py similarity index 91% rename from skills/remind/tests/test_remind_edit.py rename to skills/remind/tests/test_remind_cli.py index e25ff18..ec9eccf 100644 --- a/skills/remind/tests/test_remind_edit.py +++ b/skills/remind/tests/test_remind_cli.py @@ -7,19 +7,19 @@ SCRIPTS = Path(__file__).parent.parent / "scripts" sys.path.insert(0, str(SCRIPTS)) from db import get_db, init_db -import remind_edit +import remind_cli def _run(db_path, argv): - """Run remind_edit main with a temporary DB path.""" - original_db_path = remind_edit.DB_PATH + """Run remind_cli main with a temporary DB path.""" + original_db_path = remind_cli.DB_PATH try: - remind_edit.DB_PATH = db_path + remind_cli.DB_PATH = db_path # Patch the module-level DB_PATH used by functions - sys.argv = ["remind_edit.py"] + argv - return remind_edit.main() + sys.argv = ["remind_cli.py"] + argv + return remind_cli.main() finally: - remind_edit.DB_PATH = original_db_path + remind_cli.DB_PATH = original_db_path def test_add_list(tmp_path, capsys): @@ -112,6 +112,20 @@ def test_edit_text(tmp_path, capsys): assert data["edited"]["text"] == "new text" +def test_edit_text_by_id(tmp_path, capsys): + db_path = tmp_path / "test.sqlite" + init_db(db_path) + + _run(db_path, ["add", "--text", "original text", "--cron", "0 9 * * *"]) + capsys.readouterr() # clear setup output + ret = _run(db_path, ["edit", "--id", "1", "--text", "renamed"]) + captured = capsys.readouterr() + assert ret == 0 + data = json.loads(captured.out) + assert data["edited"]["text"] == "renamed" + assert len(data["edited"]["cron"]) == 1 + + def test_edit_replace_schedules(tmp_path, capsys): db_path = tmp_path / "test.sqlite" init_db(db_path) @@ -259,7 +273,7 @@ def test_delivered_lists_deliveries(tmp_path, capsys): def test_delivered_defaults_to_today(tmp_path, capsys): db_path = tmp_path / "test.sqlite" init_db(db_path) - today = datetime.now(remind_edit.PRAGUE).date().isoformat() + today = datetime.now(remind_cli.PRAGUE).date().isoformat() conn = get_db(db_path) try: conn.execute(