Zalohovani vsech podstatnych souboru
This commit is contained in:
693
skills/remind/IMPROVEMENTS_REPORT.md
Normal file
693
skills/remind/IMPROVEMENTS_REPORT.md
Normal 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
71
skills/remind/SKILL.md
Normal 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:00–21:00, Mon–Fri" (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.
|
||||
39
skills/remind/reminder.example.yaml
Normal file
39
skills/remind/reminder.example.yaml
Normal 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"
|
||||
BIN
skills/remind/scripts/__pycache__/random_times.cpython-313.pyc
Normal file
BIN
skills/remind/scripts/__pycache__/random_times.cpython-313.pyc
Normal file
Binary file not shown.
122
skills/remind/scripts/random_times.py
Normal file
122
skills/remind/scripts/random_times.py
Normal 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
|
||||
171
skills/remind/scripts/remind_edit.py
Executable file
171
skills/remind/scripts/remind_edit.py
Executable 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()
|
||||
150
skills/remind/scripts/remind_send.py
Normal file
150
skills/remind/scripts/remind_send.py
Normal 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()
|
||||
Binary file not shown.
Binary file not shown.
5
skills/remind/tests/conftest.py
Normal file
5
skills/remind/tests/conftest.py
Normal 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"))
|
||||
96
skills/remind/tests/test_random_times.py
Normal file
96
skills/remind/tests/test_random_times.py
Normal 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"))
|
||||
Reference in New Issue
Block a user