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
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:0021: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

View File

@@ -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)

View File

@@ -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:

View File

@@ -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(

View File

@@ -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(