Zalohovani vsech podstatnych souboru

This commit is contained in:
lachtan
2026-06-10 06:39:52 +02:00
parent 1e10891945
commit 67e29c8b88
69 changed files with 9115 additions and 0 deletions

View File

@@ -0,0 +1,693 @@
# /remind Skill — Codebase Analysis & Improvement Report
## 1. Executive Summary
The /remind skill consists of three scripts (`remind_edit.py`, `remind_send.py`, `random_times.py`) plus tests. The `random_times.py` module is well-structured and tested. The two main scripts (`remind_edit.py`, `remind_send.py`) suffer from:
- Manual YAML string construction instead of proper serialization
- No tests at all
- Missing core features (list, edit, deduplication, dry-run)
- Race conditions and data-loss risks
- One-time reminders firing repeatedly within the same minute
This report identifies 20+ concrete improvements with code examples.
---
## 2. Critical Issues
### 2.1 One-time `at` reminders fire repeatedly (BUG)
`remind_send.py` uses a 60-second window:
```python
def should_fire(candidate: datetime, now: datetime) -> bool:
return abs((now - candidate).total_seconds()) < 60
```
With a 1-minute cron, an `at: "2026-06-02T09:20:00"` reminder fires at 09:20:00 **and** 09:20:01..09:20:59 if the cron job happens to run multiple times or with slight delay. The log shows this:
```
2026-06-02T09:20:01 cedule proti kouření ve výtahu
```
Only one line, but if the cron ran twice in the same minute, it would duplicate.
**Fix:** Track fired one-time reminders in a state file, or narrow the window to `<= 30` and ensure the cron runs at :00.
```python
# Better: stateful deduplication for one-time reminders
FIRED_STATE_PATH = Path(__file__).parent.parent.parent / "db" / "remind_fired.sqlite"
# Or simpler: narrow window + minute-level dedup via log check
```
### 2.2 Non-atomic YAML writes = data loss risk
`remind_edit.py` writes directly to `reminder.yaml`:
```python
with open(REMINDER_FILE, "w") as f:
yaml.dump(data, f)
```
If the process crashes mid-write, the file is truncated/corrupted.
**Fix:** Atomic write via temp file + rename:
```python
import os
def atomic_write(path: Path, data: dict, yaml: YAML) -> None:
tmp = path.with_suffix(".tmp")
with open(tmp, "w") as f:
yaml.dump(data, f)
os.replace(tmp, path)
```
### 2.3 Concurrent edit + send = race condition
`remind_send.py` reads `reminder.yaml` every minute. `remind_edit.py` writes to it. No file locking means the reader could get a partially-written file.
**Fix:** Use `filelock` (already available via uv) or atomic writes (above) + read retry.
### 2.4 `remind_edit.py` has no `list` command (advertised but missing)
`SKILL.md` documents `list` and `remove` commands, but `remind_edit.py` only implements `add` and `remove`. There is no `list`.
**Fix:** Add `list` to `main()`:
```python
elif command == "list":
for i, r in enumerate(data.get("reminders", []), 1):
print(f"{i}. {r.get('text', '(no text)')}")
```
---
## 3. Code Quality — Shorten & Improve
### 3.1 Remove custom `LiteralScalarString` (redundant)
`remind_edit.py` defines:
```python
class LiteralScalarString(str):
__slots__ = ()
```
ruamel.yaml already provides `ruamel.yaml.scalarstring.LiteralScalarString`. The custom class is unnecessary and confusing.
**Fix:**
```python
from ruamel.yaml.scalarstring import LiteralScalarString
```
### 3.2 `format_reminder` manually builds YAML (fragile)
Current code concatenates strings to produce YAML:
```python
def format_reminder(text, schedule):
lines = [f"- text: {text}"]
for key, value in schedule.items():
if isinstance(value, list):
lines.append(f" {key}:")
for item in value:
lines.append(f" - {item}")
else:
lines.append(f" {key}: {value}")
return "\n".join(lines)
```
This breaks on special characters (quotes, colons, newlines in text), doesn't handle indentation consistently, and duplicates YAML serialization logic.
**Fix:** Build a dict and let ruamel.yaml serialize it:
```python
def build_reminder(text: str, schedule: dict) -> dict:
reminder = {"text": LiteralScalarString(text)}
for key, value in schedule.items():
if key in ("at", "at_times", "cron_exprs") and isinstance(value, list):
reminder[key] = [LiteralScalarString(v) for v in value]
elif key in ("at", "window") and isinstance(value, str):
reminder[key] = LiteralScalarString(value)
else:
reminder[key] = value
return reminder
```
Then append to `data["reminders"]` and dump the whole document.
### 3.3 `parse_schedule` is a long if-elif chain
```python
def parse_schedule(args):
if not args:
return {"cron_exprs": ["0 9 * * *"]}
elif args[0] == "at":
...
elif args[0] == "times":
...
elif args[0] == "cron":
...
else:
...
```
**Fix:** Dispatch table:
```python
SCHEDULE_PARSERS = {
"at": lambda args: {"at": args[1]},
"times": lambda args: {"at_times": args[1:]},
"cron": lambda args: {"cron_exprs": args[1:]},
}
def parse_schedule(args: list[str]) -> dict:
if not args:
return {"cron_exprs": ["0 9 * * *"]}
parser = SCHEDULE_PARSERS.get(args[0])
if parser:
return parser(args)
# fallback: treat all args as cron expressions
return {"cron_exprs": args}
```
### 3.4 `remove_reminder` dual-match logic is confusing
```python
def remove_reminder(data, text):
reminders = data.get("reminders", [])
for i, reminder in enumerate(reminders):
if reminder.get("text") == text:
del reminders[i]
return True
for i, reminder in enumerate(reminders):
if text.lower() in reminder.get("text", "").lower():
del reminders[i]
return True
return False
```
This silently falls back to substring match, which could delete the wrong reminder.
**Fix:** Be explicit. Support exact match and `--grep` flag:
```python
def remove_reminder(data: dict, text: str, grep: bool = False) -> bool:
reminders = data.get("reminders", [])
for i, reminder in enumerate(reminders):
reminder_text = reminder.get("text", "")
if (not grep and reminder_text == text) or (grep and text.lower() in reminder_text.lower()):
del reminders[i]
return True
return False
```
### 3.5 `main()` in `remind_edit.py` is a big if-elif
**Fix:** Same dispatch pattern:
```python
COMMANDS = {
"add": cmd_add,
"remove": cmd_remove,
"list": cmd_list,
}
def main():
args = sys.argv[1:]
if not args:
print("Usage: ...")
sys.exit(1)
cmd = COMMANDS.get(args[0])
if not cmd:
print(f"Unknown command: {args[0]}")
sys.exit(1)
cmd(args[1:])
```
### 3.6 `remind_send.py` `should_fire` window too wide
With 1-minute cron granularity, a 60-second window allows double-firing if there's any jitter. Use 30 seconds:
```python
def should_fire(candidate: datetime, now: datetime, window_sec: int = 30) -> bool:
delta = (now - candidate).total_seconds()
return 0 <= delta < window_sec
```
This also ensures we only fire **after** the scheduled time, not before (which `abs()` allowed).
### 3.7 `remind_send.py` catches bare `Exception`
```python
except Exception as e:
print(f"Error sending reminder: {e}", file=sys.stderr)
```
**Fix:** Catch specific exceptions (`telegram.error.TelegramError`, `NetworkError`).
### 3.8 `sys.path.insert` hacks in both scripts
Both scripts do:
```python
sys.path.insert(0, str(Path(__file__).parent))
```
This is a code smell. Since these are run via `uv run`, they should either:
- Be part of a proper Python package with `__init__.py`
- Or use `PYTHONPATH` in the cron job
- Or import via relative imports if refactored into a package
**Fix:** Add a `pyproject.toml` in `skills/remind/` declaring the scripts directory as part of the package, or set `PYTHONPATH` in the cron:
```cron
* * * * * PYTHONPATH=/home/nanobot/.nanobot/workspace/skills/remind/scripts uv run /home/nanobot/.nanobot/workspace/skills/remind/scripts/remind_send.py
```
Then use normal imports: `from random_times import compute_fire_times`.
---
## 4. Missing Functionality
### 4.1 No `list` command in `remind_edit.py`
Users cannot view reminders without `cat reminder.yaml`.
### 4.2 No `edit` command
To change a reminder, users must remove and re-add. An `edit` command would be useful:
```python
def edit_reminder(data: dict, old_text: str, new_text: str, new_schedule: dict | None = None) -> bool:
for reminder in data.get("reminders", []):
if reminder.get("text") == old_text:
reminder["text"] = new_text
if new_schedule:
# Remove old schedule keys, add new ones
for key in list(reminder.keys()):
if key != "text":
del reminder[key]
reminder.update(new_schedule)
return True
return False
```
### 4.3 No deduplication / "fired" tracking for one-time reminders
`at` and `at_times` reminders should fire exactly once. Currently they rely on the 60s window and cron granularity.
**Fix:** SQLite state tracking:
```python
# db/remind_state.sqlite
# table fired (text TEXT, fired_at TEXT PRIMARY KEY)
```
Or simpler: append a `fired:` list to each reminder in `reminder.yaml` (but this modifies user data). Better: separate state file.
### 4.4 No dry-run mode in `remind_send.py`
Users cannot preview what would fire without actually sending Telegram messages.
**Fix:** Add `--dry-run` flag:
```python
if dry_run:
print(f"[DRY-RUN] Would fire: {text} at {now}")
else:
fire_reminder(text)
```
### 4.5 No way to see today's schedule
Users can't ask "what reminders do I have today?"
**Fix:** Add a `today` or `schedule` command to `remind_edit.py` that computes and prints all fire times for the current day.
### 4.6 No support for disabling reminders
Users must delete reminders to stop them. A `disabled: true` flag would be useful.
### 4.7 No validation before write
`remind_edit.py` doesn't validate that the produced YAML is loadable by `remind_send.py`. A malformed entry could break the cron job silently.
**Fix:** After building the reminder dict, run it through `random_times.compute_fire_times` (if it has `random`) or `croniter` (if it has `cron_exprs`) to validate:
```python
def validate_reminder(reminder: dict) -> None:
if "random" in reminder:
compute_fire_times(date.today(), reminder["text"], reminder["random"])
if "cron_exprs" in reminder:
for expr in reminder["cron_exprs"]:
croniter(expr)
```
### 4.8 No backup before edit
**Fix:** Keep last N backups:
```python
import shutil
from datetime import datetime
def backup_reminders(path: Path) -> None:
backup = path.with_suffix(f".yaml.{datetime.now():%Y%m%d%H%M%S}.bak")
shutil.copy2(path, backup)
```
### 4.9 `random_times.py` lacks step syntax in days parser
Cron supports `*/2`, `1-5/2`. `_parse_days` doesn't handle this.
**Fix:**
```python
def _parse_days(spec: object) -> set[int]:
text = str(spec).strip()
if text == "*":
return set(range(7))
result: set[int] = set()
for part in text.split(","):
part = part.strip()
step = 1
if "/" in part:
part, step_str = part.split("/", 1)
step = int(step_str)
if "-" in part:
low_str, high_str = part.split("-", 1)
low, high = int(low_str), int(high_str)
result.update(_normalize_dow(d) for d in range(low, high + 1, step))
else:
result.add(_normalize_dow(int(part)))
return result
```
### 4.10 No `__main__` guard in `random_times.py`
Not critical since it's a library, but good practice.
---
## 5. Testing Gaps
| Component | Tests? | Coverage |
|-----------|--------|----------|
| `random_times.py` | Yes | Good (determinism, gaps, filters, errors) |
| `remind_edit.py` | **No** | Zero |
| `remind_send.py` | **No** | Zero |
### 5.1 Tests needed for `remind_edit.py`
- `parse_schedule` with all input variants
- `build_reminder` / `format_reminder` roundtrip
- `remove_reminder` exact vs substring
- YAML dump/load roundtrip preserves formatting
- Atomic write doesn't corrupt file
### 5.2 Tests needed for `remind_send.py`
- `should_fire` boundary conditions
- `fire_reminder` with mocked Telegram bot
- `main` with mocked `reminder.yaml` and mocked bot
- One-time reminder deduplication
- Random reminder integration with `random_times`
### 5.3 Test infrastructure
`conftest.py` only adds `sys.path`. It should also provide fixtures:
```python
@pytest.fixture
def sample_yaml(tmp_path):
path = tmp_path / "reminder.yaml"
path.write_text("reminders:\n- text: test\n at: 2026-06-01T10:00:00\n")
return path
@pytest.fixture
def mock_bot(monkeypatch):
class FakeBot:
def send_message(self, chat_id, text):
self.last_call = (chat_id, text)
bot = FakeBot()
monkeypatch.setattr("remind_send.Bot", lambda token: bot)
return bot
```
---
## 6. Architecture Improvements
### 6.1 Consolidate into a single CLI
The user has considered consolidating remind into a single script. Current split:
- `remind_edit.py` = user-facing CLI
- `remind_send.py` = cron daemon
- `random_times.py` = shared library
This split is actually reasonable. But `remind_edit.py` and `remind_send.py` share no code. Consider extracting common YAML I/O:
```python
# remind_common.py
from pathlib import Path
from ruamel.yaml import YAML
REMINDER_FILE = Path(__file__).parent.parent.parent / "reminder.yaml"
def load_reminders() -> dict:
yaml = YAML()
yaml.preserve_quotes = True
with open(REMINDER_FILE) as f:
return yaml.load(f) or {"reminders": []}
def save_reminders(data: dict) -> None:
yaml = YAML()
yaml.default_flow_style = False
yaml.indent(mapping=2, sequence=4, offset=2)
atomic_write(REMINDER_FILE, data, yaml)
```
### 6.2 Use SQLite for state (not YAML)
The user is evaluating SQLite vs YAML for remind data storage. Current YAML approach:
- **Pros:** Human-readable, easy to edit by hand, version-control friendly
- **Cons:** No schema validation, race conditions, no querying, append-only log is separate
**Recommendation:** Keep YAML for the reminder definitions (human-editable), but use SQLite for runtime state (fired tracking, history query):
```python
# db/remind_state.sqlite
CREATE TABLE fired (
id INTEGER PRIMARY KEY,
text TEXT NOT NULL,
scheduled_at TEXT NOT NULL,
fired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_scheduled ON fired(scheduled_at);
```
This gives:
- Exact-once firing for one-time reminders
- Queryable history ("when did X last fire?")
- No modification to `reminder.yaml`
### 6.3 Refactor `remind_send.py` into a class
Current procedural style makes testing hard. A class-based design:
```python
class ReminderEngine:
def __init__(self, yaml_path: Path, bot: Bot | None = None, dry_run: bool = False):
self.yaml_path = yaml_path
self.bot = bot
self.dry_run = dry_run
self.now = datetime.now(TIMEZONE)
def load(self) -> list[dict]:
...
def should_fire(self, candidate: datetime) -> bool:
...
def fire(self, text: str) -> None:
...
def run(self) -> list[str]:
fired = []
for reminder in self.load():
for candidate in self.candidates(reminder):
if self.should_fire(candidate) and not self.already_fired(reminder, candidate):
self.fire(reminder["text"])
fired.append(reminder["text"])
return fired
```
---
## 7. Specific Code Examples
### 7.1 Atomic write for `remind_edit.py`
```python
import os
from pathlib import Path
from tempfile import mkstemp
def atomic_write_yaml(path: Path, data: dict, yaml: YAML) -> None:
fd, tmp = mkstemp(dir=path.parent, suffix=".tmp")
try:
with os.fdopen(fd, "w") as f:
yaml.dump(data, f)
os.replace(tmp, path)
except Exception:
os.unlink(tmp)
raise
```
### 7.2 Proper `LiteralScalarString` usage
```python
from ruamel.yaml.scalarstring import LiteralScalarString
def build_reminder(text: str, schedule: dict) -> dict:
reminder = {"text": LiteralScalarString(text)}
for key, value in schedule.items():
if isinstance(value, list):
reminder[key] = [LiteralScalarString(v) for v in value]
elif isinstance(value, str):
reminder[key] = LiteralScalarString(value)
else:
reminder[key] = value
return reminder
```
### 7.3 Deduplication for one-time reminders
```python
from pathlib import Path
import sqlite3
STATE_DB = Path(__file__).parent.parent.parent / "db" / "remind_state.sqlite"
def ensure_state_db() -> None:
STATE_DB.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(STATE_DB)
conn.execute("""
CREATE TABLE IF NOT EXISTS fired (
text TEXT NOT NULL,
scheduled_at TEXT NOT NULL,
fired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (text, scheduled_at)
)
""")
conn.commit()
conn.close()
def already_fired(text: str, scheduled_at: datetime) -> bool:
conn = sqlite3.connect(STATE_DB)
row = conn.execute(
"SELECT 1 FROM fired WHERE text = ? AND scheduled_at = ?",
(text, scheduled_at.isoformat())
).fetchone()
conn.close()
return row is not None
def record_fired(text: str, scheduled_at: datetime) -> None:
conn = sqlite3.connect(STATE_DB)
conn.execute(
"INSERT OR IGNORE INTO fired (text, scheduled_at) VALUES (?, ?)",
(text, scheduled_at.isoformat())
)
conn.commit()
conn.close()
```
### 7.4 Narrowed `should_fire` + dedup
```python
def should_fire(candidate: datetime, now: datetime, window_sec: int = 30) -> bool:
delta = (now - candidate).total_seconds()
return 0 <= delta < window_sec
# In main loop for one-time reminders:
if "at" in reminder:
candidate = parse_at(reminder["at"])
if should_fire(candidate, now) and not already_fired(text, candidate):
fire_reminder(text)
record_fired(text, candidate)
```
### 7.5 `remind_edit.py` with dispatch table
```python
from pathlib import Path
import sys
from ruamel.yaml import YAML
from ruamel.yaml.scalarstring import LiteralScalarString
from random_times import compute_fire_times
from croniter import croniter
REMINDER_FILE = Path(__file__).parent.parent.parent / "reminder.yaml"
# --- commands ---
def cmd_add(args: list[str]) -> None:
text = " ".join(args)
schedule = parse_schedule([]) # default cron
add_reminder(text, schedule)
def cmd_remove(args: list[str]) -> None:
text = " ".join(args)
remove_reminder(text)
def cmd_list(_args: list[str]) -> None:
data = load_reminders()
for i, r in enumerate(data.get("reminders", []), 1):
print(f"{i}. {r.get('text', '(no text)')}")
COMMANDS = {
"add": cmd_add,
"remove": cmd_remove,
"list": cmd_list,
}
def main() -> None:
args = sys.argv[1:]
if not args or args[0] not in COMMANDS:
print(f"Usage: {sys.argv[0]} [{'|'.join(COMMANDS)}] ...")
sys.exit(1)
COMMANDS[args[0]](args[1:])
```
---
## 8. Prioritized Action Plan
| Priority | Task | Effort | Impact |
|----------|------|--------|--------|
| **P0** | Fix one-time reminder double-firing (narrow window + dedup) | Small | High — prevents spam |
| **P0** | Add atomic writes to `remind_edit.py` | Small | High — prevents data loss |
| **P1** | Add `list` command to `remind_edit.py` | Small | Medium — advertised feature |
| **P1** | Replace custom `LiteralScalarString` with ruamel's | Tiny | Low — code cleanliness |
| **P1** | Replace manual YAML string building with dict+dump | Medium | High — robustness |
| **P1** | Add validation before write | Small | Medium — catches errors early |
| **P2** | Add tests for `remind_edit.py` and `remind_send.py` | Medium | High — enables refactoring |
| **P2** | Extract common YAML I/O to `remind_common.py` | Small | Medium — DRY |
| **P2** | Add `--dry-run` to `remind_send.py` | Small | Medium — safer testing |
| **P3** | Add SQLite state tracking for fired reminders | Medium | Medium — exact-once, queryable history |
| **P3** | Add `edit` command | Small | Low — convenience |
| **P3** | Add `disabled` flag | Small | Low — convenience |
| **P3** | Support cron step syntax in `_parse_days` | Small | Low — completeness |
---
## 9. Summary
The `random_times.py` module is solid. The main pain points are in `remind_edit.py` (manual YAML construction, no atomic writes, missing commands) and `remind_send.py` (double-firing risk, no deduplication, no tests). The highest-impact fixes are: (1) atomic YAML writes, (2) one-time reminder deduplication, and (3) replacing manual YAML string building with proper serialization. Adding tests for the two untested scripts is essential before any major refactoring.

71
skills/remind/SKILL.md Normal file
View File

@@ -0,0 +1,71 @@
---
name: remind
description: >-
Create recurring reminders for tasks. Use when the user wants to set up a
reminder for something they need to do regularly, or when they mention tasks
they keep forgetting. Also handles listing and removing reminders. Triggers on
words like "remind", "reminder".
---
# Remind
Create, list, and manage recurring reminders for tasks.
## CRUD Script
All mutations to `reminder.yaml` go through `scripts/remind_edit.py` (paths in this skill are relative to the skill directory).
Run via: `uv run scripts/remind_edit.py <subcommand>`
Subcommands:
- **`list`** — prints JSON `{"reminders": [...]}`.
- **`add --text "..." --cron "EXPR" [--cron "EXPR"]`** — add recurring reminder; validates cron syntax.
- **`add --text "..." --at "ISO_DATETIME" [--at "ISO_DATETIME"]`** — add one-time reminder(s); `--at` is repeatable.
- **`add --text "..." --at "ISO" --cron "EXPR"`** — combine one-time and recurring times in one entry.
- **`add --text "..." --random-times-per-day N --random-window "HH:MM-HH:MM" [--random-days "1-5"] [--random-from "YYYY-MM-DD"] [--random-until "YYYY-MM-DD"]`** — random but deterministic times: fires `N` times per day at random moments inside the window. Use when the user wants something a few times a day without a fixed clock time (e.g. "remind me to drink water a few times during the day"). `--random-days` is a cron day-of-week filter; `--random-from` / `--random-until` bound the active period. Minimum gap between fires is a fixed constant in `scripts/random_times.py`. Combinable with `--at` / `--cron`.
- **`remove --keyword "..."`** — removes by case-insensitive substring match. Returns error JSON if 0 or >1 matches.
All outputs are JSON. Errors go to stderr with non-zero exit code.
## Create Workflow
1. **Identify the task** — What does the user want to be reminded about? If unclear, ask.
2. **Check for duplicates** — Run `remind_edit.py list` and compare existing reminder texts against the new one. If a similar reminder already exists:
- Show the user the existing reminder
- Ask whether they really want a duplicate, or want to modify the existing one
- Only proceed if the user explicitly confirms
3. **Determine frequency** — Ask how often the reminder should fire. Suggest common options:
- Every N minutes/hours/days
- Specific time of day (e.g. "every weekday at 9am")
- Specific day of week/month
- One-time at a specific datetime
- A few times a day at random moments (use the `--random-*` flags)
4. **Create the cron expression(s) or `at` field** — Map user input to cron syntax for recurring reminders, or ISO datetime for one-time reminders.
5. **Add via script** — Run a single `add` call combining all times (see CRUD Script for the exact flags). **Never call `add` multiple times for the same task** — put all times into one call.
6. **Confirm** — Show the user what was created (text, schedule).
## List Workflow
1. Run `uv run remind_edit.py list` and parse the JSON output.
2. Present all reminders in a table with columns: number, task, schedule.
3. Convert each schedule to human-readable text **in the user's language** (e.g. "every day at 9:00", "every Tuesday at 9:00"). For a `random` block, describe it like "5× a day at random between 9:0021:00, MonFri" (include `days`/`from`/`until` only if present).
4. If `reminders` is empty, say so.
## Remove / Done Workflow
1. Run `uv run remind_edit.py remove --keyword "..."`.
2. If exit code is non-zero, read the error JSON:
- `"no match"` → tell the user no reminder matches the keyword.
- `"ambiguous"` → show the matches and ask the user to be more specific.
3. If success, confirm what was removed.
## Rules
- **Respond to the user in their own language** (e.g. Czech) — this skill is written in English, but user-facing messages adapt to the user's language.
- **Never edit `reminder.yaml` directly** — no `edit_file`, `write_file`, or any direct write. All mutations go exclusively through `scripts/remind_edit.py`.
- **Read via the `list` subcommand** — never read the YAML file directly; always `remind_edit.py list`.
- Always confirm the reminder text and frequency with the user before creating.
- When listing, always show a human-readable schedule.
- Completed or removed reminders are deleted from `reminder.yaml` entirely — no `done` field, no `status` field.
- Timezone is always `Europe/Prague` unless the user explicitly requests otherwise.

View File

@@ -0,0 +1,39 @@
# Example reminder.yaml — shows every schedule type at a glance.
# This is documentation only; the live file is managed via scripts/remind_edit.py.
# Timezone is always Europe/Prague.
reminders:
# One-time reminder.
- text: "call the dentist"
at: "2026-06-10T10:00:00"
# Several one-time reminders in one entry.
- text: "order shoes"
at_times:
- "2026-06-01T10:00:00"
- "2026-06-01T11:00:00"
# Recurring via cron expressions.
- text: "pay the membership fee"
cron_exprs:
- "0 9 * * *"
- "0 14 * * *"
# Random but deterministic times: N fires per day inside a window, spaced at
# least MIN_GAP_MIN apart (constant in scripts/random_times.py). The times are
# derived from (date, text), so they are stable for a given day yet vary daily.
- text: "drink water / stretch"
random:
times_per_day: 5 # required: how many fires per day (int >= 1)
window: "09:00-21:00" # required: daily time window HH:MM-HH:MM (start < end)
days: "1-5" # optional: cron day-of-week filter (0/7=Sun, 1=Mon..6=Sat); default every day
from: "2026-06-01" # optional: start date, inclusive; omitted = active immediately
until: "2026-12-31" # optional: end date, inclusive; omitted = no end
# Fields can be combined freely in one entry.
- text: "water the plants"
cron_exprs:
- "0 19 * * *"
random:
times_per_day: 2
window: "08:00-12:00"

View File

@@ -0,0 +1,122 @@
"""Deterministic random fire-time computation for reminders.
Shared by remind_send.py (runtime) and remind_edit.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
`window`, spaced at least MIN_GAP_MIN apart. The times are random but fully
determined by (date, text): any script computing them for the same day gets the
same result, so no state needs to be persisted.
"""
from __future__ import annotations
import random
from datetime import date, datetime, time
MIN_GAP_MIN = 15 # minimum gap between fire times in minutes; tune here
def compute_fire_times(target_date: date, text: str, cfg: dict) -> list[datetime]:
"""Deterministic fire times for one day.
Returns [] when the day falls outside the days/from/until filters. Raises
ValueError on a malformed config (bad window, days, dates, or when the
requested count cannot fit the window with MIN_GAP_MIN spacing) — these are
structural and validated before any date filter, so the same call validates
a config regardless of the date passed in.
"""
count = _parse_count(cfg.get("times_per_day"))
start, end = _parse_window(cfg.get("window"))
day_set = _parse_days(cfg["days"]) if cfg.get("days") is not None else None
from_date = _parse_date(cfg["from"]) if cfg.get("from") is not None else None
until_date = _parse_date(cfg["until"]) if cfg.get("until") is not None else None
total = end - start
required = (count - 1) * MIN_GAP_MIN
if required > total:
raise ValueError(
f"{count} times with a {MIN_GAP_MIN}-min gap need {required} min, "
f"but the window is only {total} min wide"
)
if from_date is not None and target_date < from_date:
return []
if until_date is not None and target_date > until_date:
return []
if day_set is not None and _cron_weekday(target_date) not in day_set:
return []
slack = total - required
rnd = random.Random(f"{target_date.isoformat()}|{text}")
offsets = sorted(rnd.randint(0, slack) for _ in range(count))
minutes = [start + offset + index * MIN_GAP_MIN for index, offset in enumerate(offsets)]
return [datetime.combine(target_date, _minute_to_time(m)) for m in minutes]
def _parse_count(raw: object) -> int:
if not isinstance(raw, int) or isinstance(raw, bool) or raw < 1:
raise ValueError(f"times_per_day must be an int >= 1, got {raw!r}")
return raw
def _parse_window(raw: object) -> tuple[int, int]:
if not isinstance(raw, str) or "-" not in raw:
raise ValueError(f"window must be 'HH:MM-HH:MM', got {raw!r}")
start_str, end_str = raw.split("-", 1)
start = _hhmm_to_minutes(start_str.strip())
end = _hhmm_to_minutes(end_str.strip())
if start >= end:
raise ValueError(f"window start must be before end: {raw!r}")
return start, end
def _hhmm_to_minutes(value: str) -> int:
parts = value.split(":")
if len(parts) != 2:
raise ValueError(f"invalid time {value!r}, expected HH:MM")
hours, minutes = int(parts[0]), int(parts[1])
if not (0 <= hours < 24 and 0 <= minutes < 60):
raise ValueError(f"time out of range: {value!r}")
return hours * 60 + minutes
def _minute_to_time(total_minutes: int) -> time:
return time(total_minutes // 60, total_minutes % 60)
def _parse_date(raw: object) -> date:
if isinstance(raw, datetime):
return raw.date()
if isinstance(raw, date):
return raw
return date.fromisoformat(str(raw))
def _parse_days(spec: object) -> set[int]:
"""Parse a cron day-of-week spec into a set of cron weekdays (0/7=Sun, 1=Mon..6=Sat)."""
text = str(spec).strip()
if text == "*":
return set(range(7))
result: set[int] = set()
for part in text.split(","):
part = part.strip()
if "-" in part:
low_str, high_str = part.split("-", 1)
low, high = int(low_str), int(high_str)
result.update(_normalize_dow(day) for day in range(low, high + 1))
else:
result.add(_normalize_dow(int(part)))
return result
def _normalize_dow(value: int) -> int:
"""cron allows 7 for Sunday; normalize it to 0."""
if not 0 <= value <= 7:
raise ValueError(f"day-of-week out of range (0-7): {value}")
return 0 if value == 7 else value
def _cron_weekday(target: date) -> int:
"""Map Python weekday (Mon=0..Sun=6) to cron weekday (Sun=0, Mon=1..Sat=6)."""
return (target.weekday() + 1) % 7

View File

@@ -0,0 +1,171 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = ["croniter", "pyyaml"]
# ///
"""Deterministic CRUD for reminder.yaml.
CLI tool for LLM skills to create, list, and remove reminders atomically.
Never edits reminder.yaml directly — always writes to a .tmp file and renames.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import date, datetime
from pathlib import Path
import yaml
from croniter import croniter
from random_times import compute_fire_times
WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent # .../workspace
REMINDER_YAML = WORKSPACE / "reminder.yaml"
def _load() -> dict:
if not REMINDER_YAML.exists():
return {"reminders": []}
data = yaml.safe_load(REMINDER_YAML.read_text(encoding="utf-8")) or {}
if "reminders" not in data:
data["reminders"] = []
return data
def _save(data: dict) -> None:
tmp = REMINDER_YAML.with_suffix(".yaml.tmp")
tmp.write_text(
yaml.safe_dump(data, allow_unicode=True, sort_keys=False, default_flow_style=False),
encoding="utf-8",
)
os.replace(tmp, REMINDER_YAML)
def cmd_list(_args: argparse.Namespace) -> int:
data = _load()
print(json.dumps({"reminders": data["reminders"]}, ensure_ascii=False))
return 0
def cmd_add(args: argparse.Namespace) -> int:
text = (args.text or "").strip()
if not text:
print(json.dumps({"error": "text must not be empty"}), file=sys.stderr)
return 1
try:
random_cfg = _build_random(args)
except ValueError as exc:
print(json.dumps({"error": str(exc)}), file=sys.stderr)
return 1
if not args.at and not args.cron and not random_cfg:
print(json.dumps({"error": "provide --cron, --at, or --random-* options"}), file=sys.stderr)
return 1
item: dict = {"text": text}
if args.at:
for at_str in args.at:
try:
datetime.fromisoformat(at_str)
except ValueError as exc:
print(json.dumps({"error": f"invalid --at datetime: {exc}"}), file=sys.stderr)
return 1
if len(args.at) == 1:
item["at"] = args.at[0]
else:
item["at_times"] = args.at
if args.cron:
for expr in args.cron:
if not croniter.is_valid(expr):
print(json.dumps({"error": f"invalid cron expression: {expr!r}"}), file=sys.stderr)
return 1
item["cron_exprs"] = args.cron
if random_cfg:
item["random"] = random_cfg
data = _load()
data["reminders"].append(item)
_save(data)
print(json.dumps({"added": item}, ensure_ascii=False))
return 0
def _build_random(args: argparse.Namespace) -> dict | None:
"""Assemble and validate the random schedule block, or None if no --random-* flag given."""
fields = {
"times_per_day": args.random_times_per_day,
"window": args.random_window,
"days": args.random_days,
"from": args.random_from,
"until": args.random_until,
}
if all(value is None for value in fields.values()):
return None
if fields["times_per_day"] is None or fields["window"] is None:
raise ValueError("random schedule needs --random-times-per-day and --random-window")
cfg = {key: value for key, value in fields.items() if value is not None}
compute_fire_times(date(2000, 1, 1), "validation", cfg) # raises ValueError on a bad config
return cfg
def cmd_remove(args: argparse.Namespace) -> int:
keyword = (args.keyword or "").strip().lower()
if not keyword:
print(json.dumps({"error": "keyword must not be empty"}), file=sys.stderr)
return 1
data = _load()
matches = [r for r in data["reminders"] if keyword in (r.get("text") or "").lower()]
if len(matches) == 0:
print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr)
return 1
if len(matches) > 1:
print(
json.dumps({"error": "ambiguous", "matches": [{"text": m["text"]} for m in matches]}, ensure_ascii=False),
file=sys.stderr,
)
return 1
removed = matches[0]
data["reminders"] = [r for r in data["reminders"] if r is not removed]
_save(data)
print(json.dumps({"removed": removed}, ensure_ascii=False))
return 0
def main() -> None:
parser = argparse.ArgumentParser(description="CRUD for reminder.yaml")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("list", help="List all reminders as JSON")
add_p = sub.add_parser("add", help="Add a new reminder")
add_p.add_argument("--text", required=True, help="Reminder text")
add_p.add_argument("--cron", action="append", metavar="EXPR", help="Cron expression (repeatable)")
add_p.add_argument("--at", action="append", metavar="ISO_DATETIME", help="One-time datetime ISO 8601 (repeatable, combinable with --cron)")
add_p.add_argument("--random-times-per-day", type=int, dest="random_times_per_day", metavar="N", help="Random schedule: fires per day")
add_p.add_argument("--random-window", dest="random_window", metavar="HH:MM-HH:MM", help="Random schedule: daily time window")
add_p.add_argument("--random-days", dest="random_days", metavar="DOW", help="Random schedule: cron day-of-week filter, e.g. '1-5' (optional)")
add_p.add_argument("--random-from", dest="random_from", metavar="YYYY-MM-DD", help="Random schedule: start date, inclusive (optional)")
add_p.add_argument("--random-until", dest="random_until", metavar="YYYY-MM-DD", help="Random schedule: end date, inclusive (optional)")
remove_p = sub.add_parser("remove", help="Remove a reminder by keyword")
remove_p.add_argument("--keyword", required=True, help="Substring to match against reminder text")
args = parser.parse_args()
dispatch = {"list": cmd_list, "add": cmd_add, "remove": cmd_remove}
sys.exit(dispatch[args.command](args))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,150 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = ["croniter", "pyyaml"]
# ///
"""Deterministic reminder sender.
Runs every minute from the nanobot user crontab (NOT through the agent).
Reads reminder.yaml, finds reminders due this minute, sends each directly to
Telegram via the Bot API, appends the delivery to reminder.log, and dedups via
.reminder_state.json so each scheduled fire is delivered exactly once.
No LLM and no nanobot process involved on purpose -- see knowledge.md/history
for why the previous agent-driven cron job spammed empty-output messages.
"""
from __future__ import annotations
import hashlib
import json
import sys
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
import yaml
from croniter import croniter
from random_times import compute_fire_times
WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent # .../workspace
REMINDER_YAML = WORKSPACE / "reminder.yaml"
STATE_FILE = WORKSPACE / ".reminder_state.json"
LOG_DIR = WORKSPACE / "log"
LOG_FILE = LOG_DIR / "reminder.log"
CONFIG = Path.home() / ".nanobot" / "config.json"
TZ = ZoneInfo("Europe/Prague")
CHAT_ID = "8826147089" # Telegram user id (Martin); same target the old cron job used
def _telegram_token() -> str:
data = json.loads(CONFIG.read_text(encoding="utf-8"))
return data["channels"]["telegram"]["token"]
def _send_telegram(text: str) -> None:
token = _telegram_token()
url = f"https://api.telegram.org/bot{token}/sendMessage"
payload = urllib.parse.urlencode({"chat_id": CHAT_ID, "text": text}).encode()
req = urllib.request.Request(url, data=payload, method="POST")
with urllib.request.urlopen(req, timeout=15) as resp:
resp.read()
def _load_state() -> dict:
if STATE_FILE.exists():
try:
data = json.loads(STATE_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
return {}
def _key(text: str) -> str:
return hashlib.sha1(text.encode("utf-8")).hexdigest()[:8]
def _due_fire(item: dict, now: datetime) -> datetime | None:
"""Most recent scheduled fire-time within the last 60s, or None."""
fire: datetime | None = None
at_str = item.get("at")
if at_str:
at_time = datetime.fromisoformat(at_str).replace(tzinfo=None)
if 0 <= (now - at_time).total_seconds() < 60:
fire = at_time
for at_str in item.get("at_times", []):
at_time = datetime.fromisoformat(at_str).replace(tzinfo=None)
if 0 <= (now - at_time).total_seconds() < 60 and (fire is None or at_time > fire):
fire = at_time
for expr in item.get("cron_exprs", []):
prev = croniter(expr, now).get_prev(datetime)
if 0 <= (now - prev).total_seconds() < 60 and (fire is None or prev > fire):
fire = prev
random_cfg = item.get("random")
if random_cfg:
try:
for ft in compute_fire_times(now.date(), (item.get("text") or "").strip(), random_cfg):
if 0 <= (now - ft).total_seconds() < 60 and (fire is None or ft > fire):
fire = ft
except ValueError as exc: # malformed config: skip this reminder, keep others working
print(f"remind_send: bad random config for {item.get('text')!r}: {exc}", file=sys.stderr)
return fire
def main() -> None:
if not REMINDER_YAML.exists():
return
data = yaml.safe_load(REMINDER_YAML.read_text(encoding="utf-8")) or {}
now = datetime.now(TZ).replace(tzinfo=None)
state = _load_state()
fresh: dict[str, str] = {}
for item in data.get("reminders", []):
text = (item.get("text") or "").strip()
if not text:
continue
key = _key(text)
last = state.get(key)
fire = _due_fire(item, now)
if fire is None:
if last: # preserve dedup info for reminders not due this minute
fresh[key] = last
continue
fire_iso = fire.isoformat()
if last == fire_iso: # this exact fire was already delivered
fresh[key] = last
continue
try:
_send_telegram(f"⏰ Reminder: {text}")
except Exception as e: # leave state untouched so next run retries
print(f"remind_send: delivery failed for {text!r}: {e}", file=sys.stderr)
if last:
fresh[key] = last
continue
ts = datetime.now(TZ).replace(tzinfo=None).isoformat(timespec="seconds")
LOG_DIR.mkdir(parents=True, exist_ok=True)
with LOG_FILE.open("a", encoding="utf-8") as f:
f.write(f"{ts} {text}\n")
fresh[key] = fire_iso
if fresh != state:
STATE_FILE.write_text(json.dumps(fresh, indent=2, ensure_ascii=False), encoding="utf-8")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,5 @@
import sys
from pathlib import Path
# random_times.py lives in the sibling scripts/ directory.
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))

View File

@@ -0,0 +1,96 @@
from datetime import date, datetime
import pytest
from random_times import MIN_GAP_MIN, compute_fire_times
# Window 09:00-21:00 = minutes 540..1260 -> 720 min wide.
WINDOW = "09:00-21:00"
WINDOW_START = datetime(2026, 3, 21, 9, 0)
WINDOW_END = datetime(2026, 3, 21, 21, 0)
def cfg(**overrides) -> dict:
base = {"times_per_day": 5, "window": WINDOW}
base.update(overrides)
return base
def test_deterministic():
day = date(2026, 3, 21)
assert compute_fire_times(day, "drink water", cfg()) == compute_fire_times(day, "drink water", cfg())
def test_differs_per_text():
day = date(2026, 3, 21)
assert compute_fire_times(day, "drink water", cfg()) != compute_fire_times(day, "stretch", cfg())
@pytest.mark.parametrize("count", [1, 2, 5, 10])
def test_count_matches_times_per_day(count):
times = compute_fire_times(date(2026, 3, 21), "x", cfg(times_per_day=count))
assert len(times) == count
def test_min_gap_respected():
times = compute_fire_times(date(2026, 3, 21), "x", cfg(times_per_day=8))
for earlier, later in zip(times, times[1:]):
assert (later - earlier).total_seconds() >= MIN_GAP_MIN * 60
def test_within_window():
for fire in compute_fire_times(date(2026, 3, 21), "x", cfg()):
assert WINDOW_START <= fire <= WINDOW_END
@pytest.mark.parametrize(
"day,expected",
[
(date(2026, 3, 23), True), # Monday
(date(2026, 3, 27), True), # Friday
(date(2026, 3, 28), False), # Saturday
(date(2026, 3, 29), False), # Sunday
],
)
def test_days_weekday_filter(day, expected):
times = compute_fire_times(day, "x", cfg(days="1-5"))
assert bool(times) == expected
@pytest.mark.parametrize(
"spec,day,expected",
[
("*", date(2026, 3, 28), True), # Saturday allowed by wildcard
("0", date(2026, 3, 29), True), # Sunday as 0
("7", date(2026, 3, 29), True), # Sunday as 7
("1,3,5", date(2026, 3, 25), True), # Wednesday
("1,3,5", date(2026, 3, 24), False), # Tuesday
],
)
def test_days_parser(spec, day, expected):
times = compute_fire_times(day, "x", cfg(days=spec))
assert bool(times) == expected
def test_from_until_filter():
bounded = cfg(**{"from": "2026-06-01", "until": "2026-06-30"})
assert compute_fire_times(date(2026, 5, 31), "x", bounded) == []
assert compute_fire_times(date(2026, 7, 1), "x", bounded) == []
assert len(compute_fire_times(date(2026, 6, 15), "x", bounded)) == 5
def test_infeasible_count_raises():
# 50 times * 15-min gap = 735 min required > 720 min window.
with pytest.raises(ValueError):
compute_fire_times(date(2026, 3, 21), "x", cfg(times_per_day=50))
def test_bad_window_raises():
with pytest.raises(ValueError):
compute_fire_times(date(2026, 3, 21), "x", cfg(window="21:00-09:00"))
def test_config_validated_before_date_filter():
# Out-of-range date still surfaces a structural error rather than returning [].
with pytest.raises(ValueError):
compute_fire_times(date(2000, 1, 1), "x", cfg(times_per_day=50, until="1999-01-01"))