Dalsi kolo vylepesni /remind

This commit is contained in:
lachtan
2026-06-10 09:54:02 +02:00
parent abdfd8c67d
commit 10801da03e
5 changed files with 63 additions and 80 deletions

View File

@@ -7,83 +7,55 @@ description: >
# /remind # /remind
Reminders are stored in SQLite (`db/reminders.sqlite`) and delivered by the nanobot Reminders are stored in SQLite (`db/reminders.sqlite`) and delivered by `remind_send.py`,
user crontab, directly to Telegram, outside the agent. Reply to the user in their own language. 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 | User says | Command |
``` |-----------|---------|
/remind <text> every day at 9:00 | "every day at 9" / "every weekday at 9:30" | `add --cron "0 9 * * *"` |
/remind <text> every weekday at 9:30 | "on 2026-06-15 at 18:00" / "once at …" | `add --at "2026-06-15T18:00:00"` |
/remind <text> at 2026-06-15T18:00 | "randomly 2× between 08:00 and 20:00" | `add --random-times-per-day 2 --random-window 08:00-20:00` |
/remind <text> randomly 2 times between 08:00 and 20:00 | "what reminders arrived today / since when" | `delivered [--since YYYY-MM-DD]` |
/remind <text> once daily at random time between 8:00 and 21:00 | 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 <command> --help
``` ```
Parse natural language, then call `remind_edit.py add` with flags: ## Behavioral contract
- `--text "..."`
- `--cron "0 9 * * *"` (repeatable) **`list`** returns readable text. Each reminder:
- `--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]`
### List
``` ```
/remind list #<id> text [enabled|disabled]
```
Call `remind_edit.py list`. Returns a readable text listing — each reminder is prefixed with
`#<id>` (that id is what `--id` selects), followed by indented schedule lines:
```
#3 drink water [enabled]
cron: 0 9 * * * cron: 0 9 * * *
#7 take meds [disabled]
at: 2026-06-15T18:00:00 at: 2026-06-15T18:00:00
random: 2× daily 09:0021:00 (1-5) from 2026-06-01 random: 2× daily 09:0021:00 (1-5) from 2026-06-01
``` ```
An empty store prints `(no active reminders)`. An empty store prints `(no active reminders)`.
### Edit **Mutations** (`add`, `edit`, `remove`, `enable`, `disable`) return JSON: `{"added": …}`, `{"edited": …}`, etc. Errors go to stderr with a non-zero exit code.
```
/remind edit <keyword> --text "new text"
/remind edit <keyword> --replace-schedules --cron "0 10 * * *"
```
Call `remind_edit.py edit --keyword <keyword>`. Keyword matches case-insensitively against reminder text. `--replace-schedules` requires at least one new `--cron`/`--at`/`--random-*`.
### Enable / Disable **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 <n>`. Run `list` to see ids.
```
/remind disable <keyword>
/remind enable <keyword>
```
### Remove **`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.
```
/remind remove <keyword>
```
Soft delete. Hard delete only via direct DB access.
### Selecting by keyword or id **`remove`** is a soft delete.
`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 <n>` to disambiguate duplicate texts. Run `list` to see ids.
### Delivered (history) ## Editing reminders
```
/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 **To fix or change wording:** use `edit --id <n> --text "…"` (get the id from `list`),
or `edit --keyword <kw> --text "…"`.
**NEVER remove + re-add a reminder just to change its text** — that loses the delivery history and changes the id.
| Action | Command | Use `--replace-schedules` (with at least one new `--cron`/`--at`/`--random-*`) only when you need to change the *schedule*, not the text.
|--------|---------|
| 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 <kw> ...` |
| remove | `uv run skills/remind/scripts/remind_edit.py remove --keyword <kw>` |
| enable | `uv run skills/remind/scripts/remind_edit.py enable --keyword <kw>` |
| disable | `uv run skills/remind/scripts/remind_edit.py disable --keyword <kw>` |
| 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 ## Environment

View File

@@ -1,6 +1,6 @@
"""Deterministic random fire-time computation for reminders. """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. 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 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 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: def _minute_to_time(total_minutes: int) -> time:
return time(total_minutes // 60, total_minutes % 60) return time(total_minutes // 60, total_minutes % 60)

View File

@@ -21,7 +21,7 @@ from zoneinfo import ZoneInfo
from croniter import croniter from croniter import croniter
from db import get_db, init_db, log_operation 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 WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent
DEFAULT_DB_PATH = WORKSPACE / "db" / "reminders.sqlite" DEFAULT_DB_PATH = WORKSPACE / "db" / "reminders.sqlite"
@@ -200,7 +200,7 @@ def _schedule_lines(conn, reminder_id: int) -> list[str]:
(reminder_id,), (reminder_id,),
) )
for r in random_rows: 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}"] parts = [f"random: {r['times_per_day']}× daily {window}"]
if r["days_filter"]: if r["days_filter"]:
parts.append(f"({r['days_filter']})") parts.append(f"({r['days_filter']})")
@@ -212,10 +212,6 @@ def _schedule_lines(conn, reminder_id: int) -> list[str]:
return lines return lines
def _minutes_to_hhmm(total: int) -> str:
return f"{total // 60:02d}:{total % 60:02d}"
def cmd_add(args: argparse.Namespace) -> int: def cmd_add(args: argparse.Namespace) -> int:
text = (args.text or "").strip() text = (args.text or "").strip()
if not text: if not text:

View File

@@ -23,7 +23,7 @@ from zoneinfo import ZoneInfo
from croniter import croniter from croniter import croniter
from db import get_db, init_db, log_operation 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 WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent
DEFAULT_DB_PATH = WORKSPACE / "db" / "reminders.sqlite" DEFAULT_DB_PATH = WORKSPACE / "db" / "reminders.sqlite"
@@ -129,7 +129,7 @@ def _due_random(conn, now: datetime) -> list[dict]:
for row in rows: for row in rows:
cfg = { cfg = {
"times_per_day": row["times_per_day"], "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"]: if row["days_filter"]:
cfg["days"] = row["days_filter"] cfg["days"] = row["days_filter"]
@@ -164,10 +164,6 @@ def _due_random(conn, now: datetime) -> list[dict]:
return due 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: 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") now = _now_prague().isoformat(timespec="seconds")
conn.execute( conn.execute(

View File

@@ -7,19 +7,19 @@ SCRIPTS = Path(__file__).parent.parent / "scripts"
sys.path.insert(0, str(SCRIPTS)) sys.path.insert(0, str(SCRIPTS))
from db import get_db, init_db from db import get_db, init_db
import remind_edit import remind_cli
def _run(db_path, argv): def _run(db_path, argv):
"""Run remind_edit main with a temporary DB path.""" """Run remind_cli main with a temporary DB path."""
original_db_path = remind_edit.DB_PATH original_db_path = remind_cli.DB_PATH
try: try:
remind_edit.DB_PATH = db_path remind_cli.DB_PATH = db_path
# Patch the module-level DB_PATH used by functions # Patch the module-level DB_PATH used by functions
sys.argv = ["remind_edit.py"] + argv sys.argv = ["remind_cli.py"] + argv
return remind_edit.main() return remind_cli.main()
finally: finally:
remind_edit.DB_PATH = original_db_path remind_cli.DB_PATH = original_db_path
def test_add_list(tmp_path, capsys): def test_add_list(tmp_path, capsys):
@@ -112,6 +112,20 @@ def test_edit_text(tmp_path, capsys):
assert data["edited"]["text"] == "new text" 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): def test_edit_replace_schedules(tmp_path, capsys):
db_path = tmp_path / "test.sqlite" db_path = tmp_path / "test.sqlite"
init_db(db_path) 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): def test_delivered_defaults_to_today(tmp_path, capsys):
db_path = tmp_path / "test.sqlite" db_path = tmp_path / "test.sqlite"
init_db(db_path) 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) conn = get_db(db_path)
try: try:
conn.execute( conn.execute(