From edf823fc72c02a26ef6e712b4873dc62c6267966 Mon Sep 17 00:00:00 2001 From: lachtan Date: Wed, 10 Jun 2026 08:35:31 +0200 Subject: [PATCH] claude cisteni skillu remind --- skills/remind/SKILL.md | 18 ++- skills/remind/scripts/remind_edit.py | 182 +++++++++++++++--------- skills/remind/scripts/remind_send.py | 30 ++-- skills/remind/tests/test_remind_edit.py | 77 ++++++++++ skills/remind/tests/test_remind_send.py | 42 ++++++ 5 files changed, 261 insertions(+), 88 deletions(-) diff --git a/skills/remind/SKILL.md b/skills/remind/SKILL.md index 68d5b2f..3cec655 100644 --- a/skills/remind/SKILL.md +++ b/skills/remind/SKILL.md @@ -7,6 +7,9 @@ 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. + ## Commands ### Create @@ -35,7 +38,7 @@ Call `remind_edit.py list` → JSON with all active reminders. /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. Ambiguous matches are rejected. +Call `remind_edit.py edit --keyword `. Keyword matches case-insensitively against reminder text. `--replace-schedules` requires at least one new `--cron`/`--at`/`--random-*`. ### Enable / Disable ``` @@ -49,6 +52,16 @@ Call `remind_edit.py edit --keyword `. Keyword matches case-insensitive ``` Soft delete. Hard delete only via direct DB access. +### 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. + +### 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. + ## Scripts | Action | Command | @@ -59,10 +72,11 @@ Soft delete. Hard delete only via direct DB access. | 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` ## Environment - `REMIND_DB` — override SQLite path (used in tests). -- `python3` required; `python` unavailable. +- Scripts run via `uv run` (PEP 723 headers declare their dependencies). diff --git a/skills/remind/scripts/remind_edit.py b/skills/remind/scripts/remind_edit.py index 561cc63..210f8ac 100755 --- a/skills/remind/scripts/remind_edit.py +++ b/skills/remind/scripts/remind_edit.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # /// script # requires-python = ">=3.11" -# dependencies = ["croniter", "pyyaml"] +# dependencies = ["croniter"] # /// """Deterministic CRUD for reminders backed by SQLite. @@ -15,8 +15,9 @@ import argparse import json import os import sys -from datetime import datetime, timezone +from datetime import date, datetime, timezone from pathlib import Path +from zoneinfo import ZoneInfo from croniter import croniter from db import get_db, init_db, log_operation @@ -25,6 +26,7 @@ from random_times import compute_fire_times WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent DEFAULT_DB_PATH = WORKSPACE / "db" / "reminders.sqlite" DB_PATH = Path(os.environ.get("REMIND_DB", str(DEFAULT_DB_PATH))) +PRAGUE = ZoneInfo("Europe/Prague") def _now() -> str: @@ -51,7 +53,7 @@ def _build_random(args: argparse.Namespace) -> dict | None: raise ValueError("random schedule needs --random-times-per-day and --random-window") cfg = {key: value for key, value in fields.items() if value is not None} - compute_fire_times(__import__("datetime").date(2000, 1, 1), "validation", cfg) + compute_fire_times(date(2000, 1, 1), "validation", cfg) return cfg @@ -136,6 +138,44 @@ def _find_by_keyword(conn, keyword: str) -> list[dict]: return [dict(r) for r in rows] +def _resolve_one(conn, args: argparse.Namespace) -> dict | None: + """Resolve exactly one active reminder by --id (exact) or --keyword (substring). + + Prints a JSON error to stderr and returns None when no/ambiguous match. Ambiguous + matches include each id so the caller can retry with --id. + """ + rid = getattr(args, "id", None) + if rid is not None: + row = conn.execute( + "SELECT id, text, enabled, timezone, created_at, updated_at, deleted_at " + "FROM reminders WHERE id = ? AND deleted_at IS NULL", + (rid,), + ).fetchone() + if row is None: + print(json.dumps({"error": "no match", "id": rid}), file=sys.stderr) + return None + return dict(row) + + keyword = (args.keyword or "").strip().lower() + if not keyword: + print(json.dumps({"error": "provide --id or --keyword"}), file=sys.stderr) + return None + matches = _find_by_keyword(conn, keyword) + if len(matches) == 0: + print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr) + return None + if len(matches) > 1: + print( + json.dumps( + {"error": "ambiguous", "matches": [{"id": m["id"], "text": m["text"]} for m in matches]}, + ensure_ascii=False, + ), + file=sys.stderr, + ) + return None + return matches[0] + + def cmd_list(_args: argparse.Namespace) -> int: _ensure_db() conn = get_db(DB_PATH) @@ -238,30 +278,18 @@ def cmd_add(args: argparse.Namespace) -> int: def cmd_remove(args: argparse.Namespace) -> int: - keyword = (args.keyword or "").strip().lower() - if not keyword: - print(json.dumps({"error": "keyword must not be empty"}), file=sys.stderr) - return 1 - _ensure_db() conn = get_db(DB_PATH) try: - matches = _find_by_keyword(conn, keyword) - if len(matches) == 0: - print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr) - return 1 - if len(matches) > 1: - print( - json.dumps({"error": "ambiguous", "matches": [{"text": m["text"]} for m in matches]}, ensure_ascii=False), - file=sys.stderr, - ) + target = _resolve_one(conn, args) + if target is None: return 1 - rid = matches[0]["id"] + rid = target["id"] conn.execute("BEGIN") conn.execute("UPDATE reminders SET deleted_at = ?, updated_at = ? WHERE id = ?", (_now(), _now(), rid)) conn.execute("COMMIT") - log_operation("REMOVE", rid, f'text="{matches[0]["text"]}"') + log_operation("REMOVE", rid, f'text="{target["text"]}"') reminder = _fetch_reminder(conn, rid) print(json.dumps({"removed": reminder}, ensure_ascii=False)) return 0 @@ -274,26 +302,18 @@ def cmd_remove(args: argparse.Namespace) -> int: def cmd_edit(args: argparse.Namespace) -> int: - keyword = (args.keyword or "").strip().lower() - if not keyword: - print(json.dumps({"error": "keyword must not be empty"}), file=sys.stderr) + if args.replace_schedules and not (args.at or args.cron or args.random_times_per_day or args.random_window): + print(json.dumps({"error": "--replace-schedules requires at least one --cron/--at/--random-* option"}), file=sys.stderr) return 1 _ensure_db() conn = get_db(DB_PATH) try: - matches = _find_by_keyword(conn, keyword) - if len(matches) == 0: - print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr) - return 1 - if len(matches) > 1: - print( - json.dumps({"error": "ambiguous", "matches": [{"text": m["text"]} for m in matches]}, ensure_ascii=False), - file=sys.stderr, - ) + target = _resolve_one(conn, args) + if target is None: return 1 - rid = matches[0]["id"] + rid = target["id"] conn.execute("BEGIN") now = _now() @@ -326,26 +346,14 @@ def cmd_edit(args: argparse.Namespace) -> int: def cmd_enable(args: argparse.Namespace) -> int: - keyword = (args.keyword or "").strip().lower() - if not keyword: - print(json.dumps({"error": "keyword must not be empty"}), file=sys.stderr) - return 1 - _ensure_db() conn = get_db(DB_PATH) try: - matches = _find_by_keyword(conn, keyword) - if len(matches) == 0: - print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr) - return 1 - if len(matches) > 1: - print( - json.dumps({"error": "ambiguous", "matches": [{"text": m["text"]} for m in matches]}, ensure_ascii=False), - file=sys.stderr, - ) + target = _resolve_one(conn, args) + if target is None: return 1 - rid = matches[0]["id"] + rid = target["id"] conn.execute("UPDATE reminders SET enabled = 1, updated_at = ? WHERE id = ?", (_now(), rid)) log_operation("ENABLE", rid, None) reminder = _fetch_reminder(conn, rid) @@ -359,26 +367,14 @@ def cmd_enable(args: argparse.Namespace) -> int: def cmd_disable(args: argparse.Namespace) -> int: - keyword = (args.keyword or "").strip().lower() - if not keyword: - print(json.dumps({"error": "keyword must not be empty"}), file=sys.stderr) - return 1 - _ensure_db() conn = get_db(DB_PATH) try: - matches = _find_by_keyword(conn, keyword) - if len(matches) == 0: - print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr) - return 1 - if len(matches) > 1: - print( - json.dumps({"error": "ambiguous", "matches": [{"text": m["text"]} for m in matches]}, ensure_ascii=False), - file=sys.stderr, - ) + target = _resolve_one(conn, args) + if target is None: return 1 - rid = matches[0]["id"] + rid = target["id"] conn.execute("UPDATE reminders SET enabled = 0, updated_at = ? WHERE id = ?", (_now(), rid)) log_operation("DISABLE", rid, None) reminder = _fetch_reminder(conn, rid) @@ -391,6 +387,44 @@ def cmd_disable(args: argparse.Namespace) -> int: conn.close() +def cmd_delivered(args: argparse.Namespace) -> int: + """List reminders actually delivered to the user, newest last. + + Answers 'what reminders arrived today?'. fire_time/delivered_at are stored in + Prague local time, so no conversion is needed. Defaults to today (Prague). + """ + _ensure_db() + conn = get_db(DB_PATH) + try: + since = (args.since or "").strip() + if since: + try: + date.fromisoformat(since) + except ValueError as exc: + print(json.dumps({"error": f"invalid --since date: {exc}"}), file=sys.stderr) + return 1 + where, params = "f.fire_time >= ?", (since,) + else: + today = datetime.now(PRAGUE).date().isoformat() + where, params = "substr(f.fire_time, 1, 10) = ?", (today,) + + rows = conn.execute( + f""" + SELECT f.delivered_at, r.text + FROM reminder_fires f + JOIN reminders r ON r.id = f.reminder_id + WHERE f.status = 'delivered' AND {where} + ORDER BY f.delivered_at + """, + params, + ).fetchall() + for row in rows: + print(f"{row['delivered_at']} {row['text']}") + return 0 + finally: + conn.close() + + def main() -> None: parser = argparse.ArgumentParser(description="CRUD for reminders (SQLite backed)") sub = parser.add_subparsers(dest="command", required=True) @@ -407,11 +441,13 @@ def main() -> None: add_p.add_argument("--random-from", dest="random_from", metavar="YYYY-MM-DD", help="Random schedule: start date") add_p.add_argument("--random-until", dest="random_until", metavar="YYYY-MM-DD", help="Random schedule: end date") - remove_p = sub.add_parser("remove", help="Remove a reminder by keyword (soft delete)") - remove_p.add_argument("--keyword", required=True, help="Substring to match against reminder text") + remove_p = sub.add_parser("remove", help="Remove a reminder by keyword or id (soft delete)") + remove_p.add_argument("--keyword", help="Substring to match against reminder text") + remove_p.add_argument("--id", type=int, help="Exact reminder id (disambiguates duplicate texts)") - edit_p = sub.add_parser("edit", help="Edit a reminder by keyword") - edit_p.add_argument("--keyword", required=True, help="Substring to match against reminder text") + edit_p = sub.add_parser("edit", help="Edit a reminder by keyword or id") + edit_p.add_argument("--keyword", help="Substring to match against reminder text") + edit_p.add_argument("--id", type=int, help="Exact reminder id (disambiguates duplicate texts)") edit_p.add_argument("--text", help="New reminder text") edit_p.add_argument("--replace-schedules", action="store_true", help="Replace all schedules with new ones") edit_p.add_argument("--cron", action="append", metavar="EXPR", help="Cron expression (repeatable)") @@ -422,11 +458,16 @@ def main() -> None: edit_p.add_argument("--random-from", dest="random_from", metavar="YYYY-MM-DD") edit_p.add_argument("--random-until", dest="random_until", metavar="YYYY-MM-DD") - enable_p = sub.add_parser("enable", help="Enable a reminder by keyword") - enable_p.add_argument("--keyword", required=True) + enable_p = sub.add_parser("enable", help="Enable a reminder by keyword or id") + enable_p.add_argument("--keyword") + enable_p.add_argument("--id", type=int, help="Exact reminder id") - disable_p = sub.add_parser("disable", help="Disable a reminder by keyword") - disable_p.add_argument("--keyword", required=True) + disable_p = sub.add_parser("disable", help="Disable a reminder by keyword or id") + disable_p.add_argument("--keyword") + disable_p.add_argument("--id", type=int, help="Exact reminder id") + + delivered_p = sub.add_parser("delivered", help="List reminders delivered to the user (default: today)") + delivered_p.add_argument("--since", metavar="YYYY-MM-DD", help="List deliveries on/after this date instead of today") args = parser.parse_args() dispatch = { @@ -436,6 +477,7 @@ def main() -> None: "edit": cmd_edit, "enable": cmd_enable, "disable": cmd_disable, + "delivered": cmd_delivered, } return dispatch[args.command](args) diff --git a/skills/remind/scripts/remind_send.py b/skills/remind/scripts/remind_send.py index 5291bb3..2ff8e25 100644 --- a/skills/remind/scripts/remind_send.py +++ b/skills/remind/scripts/remind_send.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # /// script # requires-python = ">=3.11" -# dependencies = ["croniter", "pyyaml"] +# dependencies = ["croniter"] # /// """Deterministic reminder sender backed by SQLite. @@ -21,7 +21,6 @@ from datetime import date, datetime, timedelta from pathlib import Path from zoneinfo import ZoneInfo -import yaml from croniter import croniter from db import get_db, init_db, log_operation from random_times import compute_fire_times @@ -32,19 +31,23 @@ DB_PATH = Path(os.environ.get("REMIND_DB", str(DEFAULT_DB_PATH))) CONFIG = Path.home() / ".nanobot" / "config.json" TZ = ZoneInfo("Europe/Prague") -CHAT_ID = "8826147089" TOLERANCE_SECONDS = 60 +FALLBACK_CHAT_ID = "8826147089" -def _telegram_token() -> str: +def _telegram_config() -> tuple[str, str]: + """Return (bot token, chat id). Chat id reads channels.telegram.allowFrom[0], with a constant fallback.""" data = json.loads(CONFIG.read_text(encoding="utf-8")) - return data["channels"]["telegram"]["token"] + telegram = data["channels"]["telegram"] + allow_from = telegram.get("allowFrom") or [] + chat_id = str(allow_from[0]) if allow_from else FALLBACK_CHAT_ID + return telegram["token"], chat_id def _send_telegram(text: str) -> None: - token = _telegram_token() + token, chat_id = _telegram_config() url = f"https://api.telegram.org/bot{token}/sendMessage" - payload = urllib.parse.urlencode({"chat_id": CHAT_ID, "text": text}).encode() + payload = urllib.parse.urlencode({"chat_id": chat_id, "text": text}).encode() req = urllib.request.Request(url, data=payload, method="POST") with urllib.request.urlopen(req, timeout=15) as resp: resp.read() @@ -75,7 +78,7 @@ def _due_at(conn, now: datetime) -> list[dict]: """, (since, until), ).fetchall() - return [dict(r) for r in rows] + return [{**dict(r), "schedule_type": "at"} for r in rows] def _due_cron(conn, now: datetime) -> list[dict]: @@ -107,6 +110,7 @@ def _due_cron(conn, now: datetime) -> list[dict]: "text": row["text"], "schedule_id": row["schedule_id"], "fire_time": fire_iso, + "schedule_type": "cron", }) return due @@ -156,6 +160,7 @@ def _due_random(conn, now: datetime) -> list[dict]: "text": row["text"], "schedule_id": row["schedule_id"], "fire_time": fire_iso, + "schedule_type": "random", }) return due @@ -189,14 +194,7 @@ def main() -> None: rid = fire["id"] sid = fire["schedule_id"] ft = fire["fire_time"] - # Determine schedule_type from which query produced it - # We can infer: if 'schedule_id' came from schedule_at, it's 'at' - # But we don't have that info here. Let's look it up. - st = conn.execute( - "SELECT 'at' FROM schedule_at WHERE id = ? UNION ALL SELECT 'cron' FROM schedule_cron WHERE id = ? UNION ALL SELECT 'random' FROM schedule_random WHERE id = ?", - (sid, sid, sid), - ).fetchone() - schedule_type = st[0] if st else "unknown" + schedule_type = fire["schedule_type"] try: _send_telegram(f"⏰ Reminder: {text}") diff --git a/skills/remind/tests/test_remind_edit.py b/skills/remind/tests/test_remind_edit.py index 9727143..90d9d18 100644 --- a/skills/remind/tests/test_remind_edit.py +++ b/skills/remind/tests/test_remind_edit.py @@ -180,3 +180,80 @@ def test_add_random_validation(tmp_path, capsys): captured = capsys.readouterr() assert ret == 1 assert "window" in captured.err.lower() or "gap" in captured.err.lower() + + +def test_remove_by_id_disambiguates_duplicates(tmp_path, capsys): + db_path = tmp_path / "test.sqlite" + init_db(db_path) + + _run(db_path, ["add", "--text", "drink water", "--cron", "0 9 * * *"]) + _run(db_path, ["add", "--text", "drink water", "--cron", "0 10 * * *"]) + capsys.readouterr() + + ret = _run(db_path, ["remove", "--keyword", "drink water"]) + captured = capsys.readouterr() + assert ret == 1 + assert "ambiguous" in captured.err + + ret = _run(db_path, ["remove", "--id", "1"]) + captured = capsys.readouterr() + assert ret == 0 + assert json.loads(captured.out)["removed"]["id"] == 1 + + # the second duplicate is still there + ret = _run(db_path, ["list"]) + captured = capsys.readouterr() + assert "drink water" in captured.out + + +def test_resolve_requires_id_or_keyword(tmp_path, capsys): + db_path = tmp_path / "test.sqlite" + init_db(db_path) + + ret = _run(db_path, ["remove"]) + captured = capsys.readouterr() + assert ret == 1 + assert "id or --keyword" in captured.err + + +def test_edit_replace_schedules_requires_new_schedule(tmp_path, capsys): + db_path = tmp_path / "test.sqlite" + init_db(db_path) + + _run(db_path, ["add", "--text", "task", "--cron", "0 9 * * *"]) + capsys.readouterr() + ret = _run(db_path, ["edit", "--keyword", "task", "--replace-schedules"]) + captured = capsys.readouterr() + assert ret == 1 + assert "replace-schedules" in captured.err + + +def test_delivered_lists_deliveries(tmp_path, capsys): + db_path = tmp_path / "test.sqlite" + init_db(db_path) + conn = get_db(db_path) + try: + conn.execute( + "INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES ('took pills', 1, 'Europe/Prague', 'now', 'now')" + ) + rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + conn.execute( + "INSERT INTO reminder_fires (reminder_id, schedule_id, schedule_type, fire_time, delivered_at, status) " + "VALUES (?, 1, 'cron', '2026-06-01T09:00:00', '2026-06-01T09:00:01', 'delivered')", + (rid,), + ) + conn.execute( + "INSERT INTO reminder_fires (reminder_id, schedule_id, schedule_type, fire_time, delivered_at, status, error_message) " + "VALUES (?, 1, 'cron', '2026-06-01T10:00:00', NULL, 'failed', 'boom')", + (rid,), + ) + finally: + conn.close() + + ret = _run(db_path, ["delivered", "--since", "2026-06-01"]) + captured = capsys.readouterr() + assert ret == 0 + assert "took pills" in captured.out + assert "2026-06-01T09:00:01" in captured.out + # failed fire is not reported as delivered + assert captured.out.count("took pills") == 1 diff --git a/skills/remind/tests/test_remind_send.py b/skills/remind/tests/test_remind_send.py index 88e82db..1658475 100644 --- a/skills/remind/tests/test_remind_send.py +++ b/skills/remind/tests/test_remind_send.py @@ -196,3 +196,45 @@ def test_delivery_failure_logged(tmp_path, capsys): assert "network down" in row["error_message"] finally: conn.close() + + +def test_schedule_type_correct_despite_id_collision(tmp_path): + """A cron fire must record schedule_type='cron' even when schedule_cron.id collides + with a schedule_at.id (each schedule table has its own AUTOINCREMENT sequence).""" + db_path = tmp_path / "test.sqlite" + init_db(db_path) + conn = get_db(db_path) + try: + conn.execute( + "INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES ('at one', 1, 'Europe/Prague', 'now', 'now')" + ) + rid_at = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + conn.execute( + "INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)", (rid_at, "2030-01-01T00:00:00") + ) + conn.execute( + "INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES ('cron one', 1, 'Europe/Prague', 'now', 'now')" + ) + rid_cron = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + conn.execute( + "INSERT INTO schedule_cron (reminder_id, cron_expr) VALUES (?, ?)", (rid_cron, "0 10 * * *") + ) + # schedule_at.id and schedule_cron.id both equal 1 here — the collision the fix guards against. + assert conn.execute("SELECT id FROM schedule_at").fetchone()["id"] == 1 + assert conn.execute("SELECT id FROM schedule_cron").fetchone()["id"] == 1 + finally: + conn.close() + + now = datetime(2026, 6, 10, 10, 0, 0) + with patch.object(remind_send, "_send_telegram", return_value=None): + _run_send(db_path, now) + + conn = get_db(db_path) + try: + row = conn.execute( + "SELECT schedule_type, status FROM reminder_fires WHERE reminder_id = ?", (rid_cron,) + ).fetchone() + assert row["schedule_type"] == "cron" + assert row["status"] == "delivered" + finally: + conn.close()