Dalsi kolo vylepesni /remind
This commit is contained in:
@@ -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 <text> every day at 9:00
|
||||
/remind <text> every weekday at 9:30
|
||||
/remind <text> at 2026-06-15T18:00
|
||||
/remind <text> randomly 2 times between 08:00 and 20:00
|
||||
/remind <text> 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 <command> --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
|
||||
`#<id>` (that id is what `--id` selects), followed by indented schedule lines:
|
||||
```
|
||||
#3 drink water [enabled]
|
||||
#<id> 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 <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-*`.
|
||||
**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 <keyword>
|
||||
/remind enable <keyword>
|
||||
```
|
||||
**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.
|
||||
|
||||
### Remove
|
||||
```
|
||||
/remind remove <keyword>
|
||||
```
|
||||
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 <n>` 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 <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 |
|
||||
|--------|---------|
|
||||
| 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`
|
||||
Use `--replace-schedules` (with at least one new `--cron`/`--at`/`--random-*`) only when you need to change the *schedule*, not the text.
|
||||
|
||||
## Environment
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
Reference in New Issue
Block a user