Dalsi kolo vylepesni /remind
This commit is contained in:
@@ -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:00–21:00 (1-5) from 2026-06-01
|
random: 2× daily 09:00–21: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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
Reference in New Issue
Block a user