# Přechod /remind skillu z YAML na SQLite ## Kontext Současný /remind skill ukládá reminder data do `reminder.yaml` a dedup state do `.reminder_state.json`. YAML má známé problémy: žádné transakce, celý soubor se načítá do paměti, dedup je hrubý (hash textu), chybí audit trail pro CRUD operace. Cílem je přejít na SQLite jako primární storage s textovým logem pro audit. ## Postup ### 1. Schéma a inicializace databáze **Cesta k DB:** `workspace/db/reminders.sqlite` (podle pravidel v AGENTS.md). Vytvořit `skills/remind/scripts/db.py`: - Funkce `init_db(path)` — spustí CREATE TABLE/INDEX z schématu níže - Funkce `get_db(path)` — vrací connection s WAL mode a foreign keys - Funkce `log_operation(operation, reminder_id, details)` — append do `workspace/log/reminder.log` Schéma: ```sql PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS reminders ( id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL CHECK(text <> ''), enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)), timezone TEXT NOT NULL DEFAULT 'Europe/Prague', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT ); CREATE TABLE IF NOT EXISTS schedule_at ( id INTEGER PRIMARY KEY AUTOINCREMENT, reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE, at_datetime TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS schedule_cron ( id INTEGER PRIMARY KEY AUTOINCREMENT, reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE, cron_expr TEXT NOT NULL CHECK(cron_expr <> '') ); CREATE TABLE IF NOT EXISTS schedule_random ( id INTEGER PRIMARY KEY AUTOINCREMENT, reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE, times_per_day INTEGER NOT NULL CHECK(times_per_day >= 1), window_start INTEGER NOT NULL CHECK(window_start >= 0 AND window_start < 1440), window_end INTEGER NOT NULL CHECK(window_end > 0 AND window_end <= 1440), days_filter TEXT, from_date TEXT, until_date TEXT, CHECK(window_start < window_end) ); CREATE TABLE IF NOT EXISTS reminder_fires ( id INTEGER PRIMARY KEY AUTOINCREMENT, reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE, schedule_id INTEGER NOT NULL, schedule_type TEXT NOT NULL CHECK(schedule_type IN ('at', 'cron', 'random')), fire_time TEXT NOT NULL, delivered_at TEXT, status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'delivered', 'failed')), error_message TEXT ); CREATE INDEX IF NOT EXISTS idx_reminders_text ON reminders(text); CREATE INDEX IF NOT EXISTS idx_at_datetime ON schedule_at(reminder_id, at_datetime); CREATE INDEX IF NOT EXISTS idx_cron_expr ON schedule_cron(reminder_id, cron_expr); CREATE INDEX IF NOT EXISTS idx_random_dates ON schedule_random(from_date, until_date); CREATE INDEX IF NOT EXISTS idx_fire_lookup ON reminder_fires( reminder_id, schedule_type, schedule_id, fire_time, status ); ``` ### 2. Přepsat `remind_edit.py` Změnit backend z YAML na SQLite, zachovat CLI rozhraní. Všechny operace v jedné SQLite transakci. **Subcommandy:** - `list` — SQL JOIN místo `yaml.safe_load`. Výstup: JSON pole reminderů se scheduly. - `add` — `INSERT INTO reminders` → získat `lastrowid` → `INSERT INTO schedule_*` podle parametrů. Podporuje kombinaci `at`, `cron`, `random` v jednom reminderu. - `remove --keyword` — `SELECT id FROM reminders WHERE text LIKE '%keyword%'` → `UPDATE deleted_at = now` (soft delete). Pokud keyword matchne více, vypsat seznam a vyžádat potvrzení. - `edit --keyword` — `UPDATE reminders.text` nebo přidání/odebrání schedulů. Pokud keyword matchne více, vypsat seznam a vyžádat potvrzení. - `enable --keyword` / `disable --keyword` — `UPDATE reminders SET enabled = 0/1`. **Log formát do `workspace/log/reminder.log`:** ``` 2026-06-10T06:40:00 [ADD] id=42 text="..." at=2026-06-11T09:00:00 2026-06-10T06:41:00 [REMOVE] id=42 text="..." 2026-06-10T06:42:00 [EDIT] id=42 text="..." 2026-06-10T06:43:00 [ENABLE] id=42 2026-06-10T06:44:00 [DISABLE] id=42 ``` ### 3. Přepsat `remind_send.py` Změnit z YAML+JSON state na SQLite. Cesta k DB přes argument nebo default `workspace/db/reminders.sqlite`. **Algoritmus:** 1. Query pro všechny due fires (at + cron + random) v jednom SELECT s UNION ALL. 2. Pro každý fire: zkontrolovat `reminder_fires` — pokud existuje řádek se stejným `reminder_id + schedule_id + schedule_type + fire_time` a `status = 'delivered'`, přeskočit. 3. Odeslat Telegram notifikaci. 4. `INSERT INTO reminder_fires (... status='delivered', delivered_at=now)`. 5. Při chybě odeslání: `INSERT INTO reminder_fires (... status='failed', error_message=e)` — příští běh retry. 6. Log do `reminder.log`: `2026-06-10T09:00:00 [DELIVER] id=42 text="..."` **Cron job** — příkaz zůstává stejný (`uv run skills/remind/scripts/remind_send.py`), skript si sám najde DB. **Query pattern (at):** ```sql SELECT r.id, r.text, 'at' AS schedule_type, sa.id AS schedule_id, sa.at_datetime AS fire_time FROM reminders r JOIN schedule_at sa ON sa.reminder_id = r.id WHERE r.enabled = 1 AND r.deleted_at IS NULL AND sa.at_datetime > datetime('now', '-60 seconds') AND sa.at_datetime <= datetime('now') AND NOT EXISTS ( SELECT 1 FROM reminder_fires rf WHERE rf.reminder_id = r.id AND rf.schedule_id = sa.id AND rf.schedule_type = 'at' AND rf.fire_time = sa.at_datetime AND rf.status = 'delivered' ) ``` **Query pattern (cron):** Načíst všechny aktivní cron expr, v Pythonu přes `croniter` vypočítat poslední fire time, porovnat s `now - 60s`. **Query pattern (random):** Načíst všechny aktivní random scheduly, v Pythonu přes `compute_fire_times(date.today(), ...)` vypočítat fire times, porovnat s `now - 60s`. ### 4. Migrační skript Vytvořit `scripts/migrate_yaml_to_sqlite.py`: - Načte `reminder.yaml` - `BEGIN TRANSACTION` - Pro každý reminder: `INSERT INTO reminders` → získat `lastrowid` - Podle polí `at` / `at_times` / `cron_exprs` / `random` vložit do příslušných schedule tabulek - `COMMIT` - Přejmenovat `reminder.yaml` → `reminder.yaml.bak` ### 5. Testy - `tests/test_db.py` — inicializace schématu, INSERT/SELECT/UPDATE/DELETE, foreign keys, constraints - `tests/test_remind_edit.py` — add, remove, list, edit, enable/disable s `:memory:` databází - `tests/test_remind_send.py` — due detection, dedup, retry failed, log output - Aktualizovat `tests/test_random_times.py` — stále platný, random_times.py se nemění ### 6. Aktualizovat SKILL.md a dokumentaci - Popsat nové DB storage místo YAML - Popsat `reminder.log` formát - Aktualizovat `reminder.example.yaml` na SQL příklady nebo odstranit ## Ověření 1. Spustit `pytest skills/remind/tests/` — všechny testy procházejí 2. Spustit migrační skript — `reminder.yaml` se převede, `.bak` vznikne 3. Ručně přidat reminder přes `remind_edit.py add` — ověřit v DB přes `sqlite3` 4. Spustit `remind_send.py` — ověřit delivery a log 5. Ověřit dedup: spustit send dvakrát ve stejné minutě — druhý běh nesmí poslat duplikát 6. Ověřit `remind_edit.py list` — zobrazí všechny aktivní reminder 7. Ověřit `remind_edit.py remove --keyword` — soft delete, záznam zůstane v DB s `deleted_at`