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

33
skills/article/SKILL.md Normal file
View File

@@ -0,0 +1,33 @@
# Zpracování článků
Uživatel vloží celý text článku a ty nad ním proveď souhrn.
## Co mě zajímá
AI, ML, programování, design aplikací, programovací techniky, Claude Code (workflows, MCP, agenti, skills, ...).
## Výstup
Česky. Odborné termíny v originále.
Začni hlavičkou:
Originální název
(novy radek)
Český překlad
Pak **shrnutí** — pár vět až jeden odstavec, hlavní teze článku.
Dál **rozbor**: 13 odstavce plynulé prózy, každý 24 věty. Žádné interní nadpisky uvnitř rozboru. Délka odpovídá hutnosti článku, ne jeho délce — řídký nebo marketingový článek dostane kratší rozbor, ne delší ve snaze vypadat důkladně. Co konkrétně tvrdí, na čem to staví, kde to skřípe.
Zakonči:
- **Verdikt:** 12 věty, stojí to za přečtení a komu. Neopakuje obsah rozboru — pokud se to už objevilo výš, vyber jen jedno místo.
- **Číst celé:** ANO / NE / ČÁSTEČNĚ (které části).
## Jak hodnotit
Poctivě, ne diplomaticky. Slabý článek je slabý i z prioritní oblasti. Ptej se: říká něco nového? je tam analýza nebo jen dohady, opírá se o data/zkušenost, nebo jen tvrdí? Je hutný, nebo by stačil odstavec? Délka výstupu odpovídá hodnotě článku — 11minutový marketingový text s jádrem na odstavec dostane rozbor na odstavec. Pokud jsou některé techniky, tooly nebo postupy vhodné pro mě osobně (Claude Code apod.), zmiň to krátce — větou v rozboru nebo ve verdiktu, ne samostatnou sekcí.
## Čemu se vyhnout
Prázdných frází bez důvodu. Doslovných citací delších než pár slov — parafrázuj. Opakování shrnutí v dalších odstavcích. Pseudostruktury (interní nadpisky, oddělené bloky "co skřípe", "pro tebe") uvnitř rozboru.

43
skills/bash/SKILL.md Normal file
View File

@@ -0,0 +1,43 @@
---
name: bash
description: >
Bash / shell script conventions and tooling.
Use for anything involving shell scripts.
---
# Bash Script Conventions
## Shebang and Strict Mode
- `#!/usr/bin/env bash` for portability.
- `set -euo pipefail` on the line after shebang (separated by a blank line).
- Hooks that check exit codes intentionally may omit `set -e`.
## Functions
- Declare local variables with `local`; never leak into global scope.
- Use `readonly` for values that must not change.
- Return data via stdout; capture with `$(fn)`. Do not use global variables for return values.
## Variables and Conditionals
- Always double-quote expansions and command substitutions: `"$var"`, `"${var}"`, `"$(cmd)"`, `"$@"`.
- Use `${var:-default}` for defaults, `${var:?error msg}` for required values.
- Use arrays for lists of values — do not split strings with IFS.
- Use `[[ ]]` instead of `[ ]`.
- Check command existence with `command -v cmd &> /dev/null`, not `which`.
## Output and Exit Codes
- Diagnostic/error messages go to stderr: `echo "error: ..." >&2`.
- Hook scripts use exit 0 (pass) and exit 2 (block). Do not use exit 1.
## Files and Paths
- Resolve script directory: `script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"`.
- Temporary files: `tmp=$(mktemp)` with cleanup via `trap 'rm -f "$tmp"' SIGINT SIGTERM ERR EXIT`.
## ShellCheck
- All scripts must pass `shellcheck`.
- To suppress a check: `# shellcheck disable=SCxxxx` with a comment explaining why.

91
skills/bookmark/SKILL.md Normal file
View File

@@ -0,0 +1,91 @@
---
name: bookmark
description: Manage a personal reading list. Use when the user wants to save, list, mark as read, or remove article URLs for later reading. Triggers on "bookmark", "save URL", "read later", "reading list", "bookmarks".
---
# Bookmark
Manage a personal reading list stored in SQLite (`db/bookmark.sqlite`).
## Commands
All commands run via:
```bash
/home/nanobot/.local/bin/uv run /home/nanobot/.nanobot/workspace/skills/bookmark/scripts/bookmark.py <command> [args]
```
### Add a bookmark
```bash
bookmark.py add <url> "<description>" [--tags tag1,tag2]
```
- `url` — the article URL
- `description` — short human-readable description (required)
- `--tags` — optional comma-separated tags
Example:
```bash
bookmark.py add "https://example.com/rust-async" "Async Rust patterns" --tags rust,async
```
### List unread bookmarks
```bash
bookmark.py list [--tag <tag>]
```
Shows ID, URL, tags, description, and date added for each unread bookmark. Use `--tag` to filter.
### Mark as read
```bash
bookmark.py read <id>
```
Marks bookmark as read (stores `read_at` timestamp). Does **not** delete — entry stays in DB.
### Unmark (mark as unread again)
```bash
bookmark.py unread <id>
```
### Show bookmark details
```bash
bookmark.py show <id>
```
Shows full URL, description, tags, status (read/unread), and dates. Does **not** change any state.
### List read bookmarks (history)
```bash
bookmark.py history
```
Shows all bookmarks marked as read, with both `added` and `read` dates.
## Output formatting
When presenting bookmark lists or details to the user, **always use markdown links** so URLs are clickable in WebUI and Telegram:
```
#3 [hackaday.com](https://hackaday.com/2026/06/02/linux-fu-taming-strace/) [linux, strace] — lepší strace
```
Format: `#<id> [<domain>](<url>) [<tags>] — <description>`
- Domain is clickable, pointing to the full URL
- Tags in brackets, comma-separated
- Description after em-dash
- **Never** strip URLs from the output or replace them with plain-text summaries
## Workflow
1. User shares a URL → `add` with description and optional tags
2. User wants to see what to read → `list`
3. User wants to see details of a bookmark → `show <id>`
4. User finishes an article → `read <id>`
5. User wants to revisit → `unread <id>` or `history`

View File

@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""Bookmark skill — CRUD for reading-list entries stored in SQLite."""
import argparse
import json
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
DB_PATH = (
Path(__file__).resolve().parent.parent.parent.parent / "db" / "bookmark.sqlite"
)
EMPTY_TAGS_JSON = "[]"
SCHEMA = """
CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
read_at TEXT
);
"""
def _init_db(conn: sqlite3.Connection) -> None:
conn.execute("PRAGMA journal_mode=WAL")
conn.executescript(SCHEMA)
@contextmanager
def _connect() -> sqlite3.Connection:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
_init_db(conn)
try:
yield conn
finally:
conn.close()
def _parse_tags(raw: str) -> list[str]:
"""Parse comma-separated tags into a deduplicated sorted list."""
if not raw:
return []
tags = [t.strip() for t in raw.split(",") if t.strip()]
return sorted(set(tags))
def _tags_display(tags_json: str) -> str:
tags = json.loads(tags_json)
return ", ".join(tags) if tags else ""
def _domain(url: str) -> str:
"""Extract domain from URL (strip www. prefix)."""
try:
parsed = urlparse(url)
host = parsed.hostname or ""
return host.removeprefix("www.")
except (ValueError, AttributeError):
return url
def _print_bookmark(
row: sqlite3.Row, *, show_status: bool = False, show_read_date: bool = False
) -> None:
"""Format and print a single bookmark row."""
tags = json.loads(row["tags"])
tag_str = f" [{', '.join(tags)}]" if tags else ""
print(f"#{row['id']} {_domain(row['url'])}{tag_str}")
print(f" {row['description']}")
print(f" {row['url']}")
line = f" added: {row['created_at'][:10]}"
if show_status:
status = "read" if row["read_at"] else "unread"
line += f" status: {status}"
if show_read_date and row["read_at"]:
line += f" read: {row['read_at'][:10]}"
print(line)
def cmd_add(args: argparse.Namespace) -> None:
tags = _parse_tags(args.tags)
with _connect() as conn:
now = datetime.now(timezone.utc).isoformat()
conn.execute(
"INSERT INTO bookmarks (url, description, tags, created_at) VALUES (?, ?, ?, ?)",
(args.url, args.description, json.dumps(tags, ensure_ascii=False), now),
)
conn.commit()
bid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
tag_info = f" [{', '.join(tags)}]" if tags else ""
print(f"Added bookmark #{bid}: {args.url}{tag_info}")
def cmd_list(args: argparse.Namespace) -> None:
with _connect() as conn:
if args.tag:
rows = conn.execute(
"""SELECT * FROM bookmarks
WHERE read_at IS NULL AND EXISTS (
SELECT 1 FROM json_each(tags) WHERE json_each.value = ?
)
ORDER BY created_at DESC""",
(args.tag,),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM bookmarks WHERE read_at IS NULL ORDER BY created_at DESC"
).fetchall()
if not rows:
print(
"No bookmarks." if not args.tag else f"No bookmarks with tag '{args.tag}'."
)
return
for r in rows:
_print_bookmark(r)
print()
def cmd_read(args: argparse.Namespace) -> None:
with _connect() as conn:
now = datetime.now(timezone.utc).isoformat()
cur = conn.execute(
"UPDATE bookmarks SET read_at = ? WHERE id = ? AND read_at IS NULL",
(now, args.id),
)
affected = cur.rowcount
conn.commit()
if affected == 0:
print(f"Bookmark #{args.id} not found or already marked as read.")
else:
print(f"Marked bookmark #{args.id} as read.")
def cmd_unread(args: argparse.Namespace) -> None:
with _connect() as conn:
cur = conn.execute(
"UPDATE bookmarks SET read_at = NULL WHERE id = ? AND read_at IS NOT NULL",
(args.id,),
)
affected = cur.rowcount
conn.commit()
if affected == 0:
print(f"Bookmark #{args.id} not found or not marked as read.")
else:
print(f"Unmarked bookmark #{args.id}.")
def cmd_show(args: argparse.Namespace) -> None:
with _connect() as conn:
row = conn.execute(
"SELECT * FROM bookmarks WHERE id = ?", (args.id,)
).fetchone()
if not row:
print(f"Bookmark #{args.id} not found.")
return
_print_bookmark(row, show_status=True)
def cmd_history(args: argparse.Namespace) -> None:
with _connect() as conn:
rows = conn.execute(
"SELECT * FROM bookmarks WHERE read_at IS NOT NULL ORDER BY read_at DESC"
).fetchall()
if not rows:
print("No read bookmarks.")
return
for r in rows:
_print_bookmark(r, show_read_date=True)
print()
def main() -> None:
parser = argparse.ArgumentParser(description="Bookmark CRUD")
sub = parser.add_subparsers(dest="command", required=True)
# add
p_add = sub.add_parser("add", help="Add a bookmark")
p_add.add_argument("url", help="URL to bookmark")
p_add.add_argument("description", help="Short description")
p_add.add_argument("--tags", default="", help="Comma-separated tags")
# list
p_list = sub.add_parser("list", help="List unread bookmarks")
p_list.add_argument("--tag", help="Filter by tag (exact match)")
# read (mark as read)
p_read = sub.add_parser("read", help="Mark bookmark as read")
p_read.add_argument("id", type=int, help="Bookmark ID")
# unread (unmark)
p_unread = sub.add_parser("unread", help="Unmark bookmark as read")
p_unread.add_argument("id", type=int, help="Bookmark ID")
# show (display details)
p_show = sub.add_parser("show", help="Show bookmark details")
p_show.add_argument("id", type=int, help="Bookmark ID")
# history (list read)
sub.add_parser("history", help="List read bookmarks")
dispatch = {
"add": cmd_add,
"list": cmd_list,
"read": cmd_read,
"unread": cmd_unread,
"show": cmd_show,
"history": cmd_history,
}
args = parser.parse_args()
dispatch[args.command](args)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,89 @@
---
name: deep-research
description: >
Multi-step research orchestration in the spirit of Claude/Gemini deep research.
Use when the user wants a thorough investigation, comparison, "find everything about…",
"research…", a well-sourced answer with multiple references — not a quick one-shot search.
---
# Deep Research
You orchestrate a multi-step investigation: decompose the question into
sub-questions, gather evidence from multiple sources, cross-check findings, and
synthesize a structured report with citations. Optimize for depth and
verifiability, not speed.
## Procedure
### 1. Plan (always first, visible to the user)
- Restate the question in one sentence to confirm scope.
- Decompose into **36 sub-questions** covering different axes of the topic.
- Show the plan briefly ("I'll split this into: …") and proceed — do not wait for
approval unless the request is genuinely ambiguous.
### 2. Gather
For each sub-question in sequence:
1. `web_search` — find relevant sources (DuckDuckGo, up to 8 results).
2. `web_fetch` on the 24 most promising results — read the actual page content,
not just the snippet.
3. Note: concise findings + the **URL of every source used** + confidence level.
### 3. Verify
- Cross-check findings across sub-questions and sources.
- **Flag contradictions explicitly** ("source A claims X, source B claims Y") —
do not paper over them.
- Mark claims supported by a single source as unverified.
- If an axis of the topic is under-covered, run one more `web_search` + `web_fetch`
round before synthesizing.
### 4. Synthesize (report)
Output structure:
```
## Shrnutí
24 sentences directly answering the original question.
## Zjištění
Organized by sub-question / axis. Every non-trivial claim carries an [n] citation.
## Rozpory a nejistoty
Where sources disagree, what could not be verified. (Omit the section if none.)
## Zdroje
[1] Title — URL
[2] …
```
## Progress reporting
Deep research can take several minutes. **Emit a short status message between
phases** so the user (especially on Telegram, where there is no thinking stream)
sees the task is alive. Examples:
- After step 1: `Plán: 5 podotázek — (1) … (2) … (3) …`
- After each sub-question: `[2/5] kimi-k2 benchmarks — 3 zdroje, hotovo`
- Before step 4: `Všechny podotázky pokryty, syntetizuji report.`
Keep status lines to one short sentence. No filler, no emojis. The final report
comes as a separate, full message at the end.
If a `web_fetch` fails or stalls, say so in a status line and continue — do not
abort the whole run silently.
## Rules
- **Always cite URLs.** Claims without a source must be labeled as your own
inference / estimate.
- Prefer primary and recent sources; for fast-moving topics, watch publication dates.
- Do not invent facts. If something cannot be found, say "not found" — do not guess.
- Length proportional to the question. No filler.
- **Respond in the user's language.**
## Tools used
`web_search` · `web_fetch` · `write_file` (optional: persist the report under `workspace/`).

108
skills/detach/SKILL.md Normal file
View File

@@ -0,0 +1,108 @@
---
name: detach
description: >-
Run a task in the background and notify via Telegram when done. Subactions:
detach (capture), list (pending/done), read (fetch result), archive (move done tasks out of sight).
Triggers on: "detach", "background", "fire and forget", "list tasks", "result <slug>",
"archive tasks", "archive done tasks", "archive task".
---
# Detach
Four subactions:
1. **`detach`** (default) — capture a goal, write it to `workspace/tasks/inbox/`. A daemon runs the task in an isolated `nanobot agent` session and notifies the user via Telegram when done.
2. **`list`** — list pending and completed background tasks.
3. **`read`** — fetch and present the result of a completed task.
4. **`archive`** — move completed tasks from `done/` to `archive/` to keep the list clean.
## Rules
- **Respond to the user in their own language** (auto-detect from their message) — this skill is written in English, but all user-facing messages adapt to the user's language.
- **Do not** start solving a detached task yourself. Capture it and stop.
---
## Subaction: `detach` (capture)
### When NOT to use detach
- Fast tasks (<1 min) — answer directly in chat.
- **Reminders** ("remind me in an hour") — use the builtin `cron` tool.
- **Recurring** tasks ("every day at 9") — use `cron` with `cron_expr` / `every_seconds`.
### Procedure (execute in this order, no need to wait for confirmation)
#### 1. Identify channel and chat_id
The system prompt's runtime context contains `Channel: <name>` and `Chat ID: <id>`. Read both. If `Chat ID` is missing, see Failure handling.
#### 2. Prepare slug, goal, and optional model
- **Slug**: 35 words from the goal, kebab-case (`[a-z0-9-]` only). Example: "Research Qdrant vs Weaviate" → `qdrant-vs-weaviate`.
- **Goal**: restate the goal so it is self-contained without chat history. State-oriented, bounded, with a clear deliverable.
- **Model** (optional): only when the user explicitly names a model or preset for this task ("run it on kimi", "use the m3 model", "with glm"). Pass that spoken token verbatim as `--model "<token>"` — the script fuzzy-matches it against the configured presets. If the user says nothing about a model, omit `--model` and the task runs on the agent default.
#### 3. Create the task
Run:
```bash
exec skills/detach/scripts/create-task.py \
--goal "<self-contained goal>" \
--slug "<slug>" \
--channel "<channel from runtime context>" \
--chat-id "<chat_id from runtime context>"
```
Add `--constraint "<text>"` for each extra constraint (optional). Add `--model "<token>"` only when the user explicitly chose a model (see step 2). The script creates the task, ensures queue directories exist, and atomically moves the file into `tasks/inbox/` to trigger the daemon.
#### 4. Confirm to the user
Tell the user the task was queued — include the slug and the fetch hint (`result <slug>`). Do not restate the full goal.
### Failure handling for `detach`
- **No `Chat ID` in runtime context**: tell the user "Detach needs a chat context to remember where to read back results. Want me to do this task synchronously here instead?" — and do NOT create a task file.
- **Unknown or ambiguous `--model`**: the script exits non-zero and prints the available presets. Show the user those presets and ask which one to use, or offer to queue the task on the default model. Do NOT silently fall back to the default when the user explicitly asked for a model.
- **Script exits non-zero** (other reasons): report the error output, offer synchronous execution.
---
## Subaction: `list`
Triggered by phrases like "list detached", "list tasks", "pending tasks".
### Procedure
1. `exec skills/detach/scripts/list-tasks.py`
2. Output the result **verbatim** — it is already formatted as a bullet list. Do not convert it into a table or otherwise restructure it.
---
## Subaction: `read <identifier>`
Triggered by "result <identifier>", "result of <identifier>". If no identifier is given ("what was the last result?") → use most recent.
### Procedure
1. `exec skills/detach/scripts/read-task.py <identifier>` — omit the argument if no identifier.
2. If the output lists multiple matches, ask the user to pick one by slug.
3. Show the output to the user.
---
## Subaction: `archive`
Triggered by "archive tasks", "archive done tasks", "archive task".
### Procedure
1. If the user did not specify which tasks to archive, call `exec skills/detach/scripts/list-tasks.py` and show the `done/` contents, then ask which tasks to archive (or all).
2. If the user said "archive all" or equivalent → `exec skills/detach/scripts/archive-tasks.py --all`
3. If the user named specific task(s) by slug → `exec skills/detach/scripts/archive-tasks.py --slug <slug>` (repeat `--slug` for each).
4. Show the script output to the user.
---
_For a full description of the task lifecycle, directories, and scripts, see `architecture.md` in this skill directory._

View File

@@ -0,0 +1,78 @@
# Detach skill — architecture
## Directory layout
```
~/.nanobot/workspace/tasks/
inbox/ — tasks waiting to be picked up (written atomically from new/)
running/ — task currently executing
done/ — completed tasks (success)
failed/ — completed tasks (exception or timeout)
archive/ — tasks moved out of the active view; no longer shown by list
new/ — atomic write staging: skill writes here, then renames into inbox/
```
## Task lifecycle
```
capture (skill) → inbox/ → running/ → done/ or failed/ → archive/
```
1. **Capture** — the skill calls `create-task.py`, which writes the task file into `new/` and atomically renames it into `inbox/`. This rename is the trigger for the daemon. The filename timestamp uses microsecond precision (`%Y-%m-%d_%H_%M_%S_%f`, e.g. `2026-06-07_15_00_00_123456-slug.md`), making filename collisions impossible even for simultaneous calls with the same slug. `tasks_common.py` parsers accept both the old second-precision format (`T`-joined, e.g. `2026-06-07T150000`) and the new underscore format for backward compatibility with existing task files. When `--model <token>` is given, the script fuzzy-resolves it to an exact preset against `config.json` *at capture time* (fail-fast in chat) and stores it in the `model:` frontmatter field.
2. **Daemon pickup**`tasks-daemon.py` is started by a systemd `.path` unit whenever `inbox/` is non-empty. It processes all files in one pass (Type=oneshot). Concurrency is handled by systemd: the service won't start again while the previous run is still live; the level-triggered `.path` unit re-triggers it after the run if inbox is still non-empty.
3. **Execution** — for each file in `inbox/`: move to `running/`, read frontmatter, call `Nanobot.run(goal, session_key="detach:<stem>")` with a 45-minute timeout in an isolated session. If the frontmatter carries `model: <preset>`, the daemon switches to it via `bot._loop.set_model_preset(preset)` before running (the same switch the `/model` chat command performs); otherwise the task runs on `agents.defaults.modelPreset`.
4. **Completion** — daemon appends `## Result` and a trailing metadata block (`completed`, `duration_seconds`, `status`) to the file, then moves it to `done/` (success) or `failed/` (exception or timeout).
5. **Notification** — daemon sends a Telegram message to `chat_id` from the frontmatter (or falls back to the first `allowFrom` ID for non-Telegram channels).
6. **Archive** — user explicitly calls the `archive` subaction; `archive-tasks.py` moves selected files from `done/` to `archive/`.
## File format
Each task is a single Markdown file:
```
---
created: <ISO 8601>
channel: telegram | websocket | ...
chat_id: "<id>"
slug: <kebab-case>
model: <preset> # optional; omitted → agent default
---
# Goal
<self-contained goal text>
# Constraints
- No user interaction (isolated session, no clarification questions — work with what you have).
- <optional extra constraints>
# Result
<appended by daemon after completion>
---
completed: <ISO 8601>
duration_seconds: <int>
status: done | failed
```
The daemon appends the `# Result` section and the trailing `---` block; everything before that is written by `create-task.py` at capture time.
## Scripts
| Script | Role |
|---|---|
| `create-task.py` | Capture: writes task file, ensures queue dirs, atomically moves to `inbox/`; logs `CREATE` to `detach.log` |
| `tasks-daemon.py` | Long-running one-shot systemd service; executes tasks, notifies via Telegram; logs lifecycle events to `detach.log` |
| `list-tasks.py` | List `running/`, `done/`, `failed/` (capped at 10 newest each) as a flat bullet list, one task per bullet (slug · time · age + indented goal) |
| `read-task.py` | Format and print a completed task's result |
| `archive-tasks.py` | Move tasks from `done/` to `archive/` (by slug or all); logs `ARCHIVE` to `detach.log` |
| `tasks_common.py` | Shared stdlib helpers: paths, parsers, formatters, model-preset resolution, shared `log()``~/.nanobot/workspace/log/detach.log` |
## Systemd units
Two user-level units under `~/.config/systemd/user/`:
- `tasks-daemon.service` — Type=oneshot, runs `tasks-daemon.py`
- `tasks-daemon.path` — level-triggered, watches `inbox/`, starts the service when non-empty

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from tasks_common import FILENAME_RE, TASKS, log
def find_by_slug(done: Path, slug: str) -> list[Path]:
return [f for f in done.glob("*.md") if (m := FILENAME_RE.match(f.name)) and m.group(2) == slug]
def main() -> int:
parser = argparse.ArgumentParser(description="Archive tasks from done/ to archive/")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--all", action="store_true", help="Archive all tasks in done/")
group.add_argument("--slug", action="append", dest="slugs", metavar="SLUG", help="Archive a specific task by slug (repeatable)")
args = parser.parse_args()
archive = TASKS / "archive"
archive.mkdir(exist_ok=True)
done = TASKS / "done"
if args.all:
targets = list(done.glob("*.md"))
else:
targets = []
for slug in args.slugs:
matches = find_by_slug(done, slug)
if not matches:
print(f"Not found in done/: {slug}", file=sys.stderr)
return 1
targets.extend(matches)
if not targets:
print("Nothing to archive.")
return 0
for f in targets:
f.rename(archive / f.name)
log(f"ARCHIVE {f.name}")
print(f"Archived {len(targets)} task(s).")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,83 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
import argparse
import os
import sys
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from tasks_common import (
TASKS,
build_task_content,
build_task_filename,
load_preset_names,
log,
resolve_preset,
)
def ensure_queue_dirs() -> None:
for name in ("new", "inbox", "running", "done", "failed"):
(TASKS / name).mkdir(parents=True, exist_ok=True)
def main() -> int:
parser = argparse.ArgumentParser(description="Create a detach task and drop it in inbox/")
parser.add_argument("--goal", required=True, help="Self-contained goal restatement")
parser.add_argument("--slug", required=True, help="Short kebab-case identifier")
parser.add_argument("--channel", required=True, help="Channel name (e.g. telegram, websocket)")
parser.add_argument("--chat-id", required=True, dest="chat_id", help="Chat ID string")
parser.add_argument("--constraint", action="append", default=[], dest="constraints",
help="Extra constraint bullet (repeatable)")
parser.add_argument("--model", default=None,
help="Model preset to run the task on (fuzzy-matched against config.json); "
"omit to use the agent default")
args = parser.parse_args()
model = None
if args.model:
try:
model = resolve_preset(args.model, load_preset_names())
except KeyError as e:
print(e.args[0], file=sys.stderr)
return 1
ensure_queue_dirs()
now = datetime.now().astimezone()
timestamp_str = now.strftime("%Y-%m-%d_%H_%M_%S_%f")
created_iso = now.isoformat()
filename = build_task_filename(timestamp_str, args.slug)
content = build_task_content(
created_iso=created_iso,
channel=args.channel,
chat_id=args.chat_id,
slug=args.slug,
goal=args.goal,
constraints=args.constraints,
model=model,
)
tmp_path = TASKS / "new" / filename
inbox_path = TASKS / "inbox" / filename
try:
tmp_path.write_text(content)
os.replace(tmp_path, inbox_path)
log(f"CREATE {filename} slug={args.slug}")
except Exception as e:
print(f"Error writing task: {e}", file=sys.stderr)
return 1
print(args.slug)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from tasks_common import TASKS, render_list
def list_dir(path: Path) -> list[Path]:
if not path.exists():
return []
return sorted(path.glob("*.md"), key=lambda f: f.name, reverse=True)
def main() -> None:
running = list_dir(TASKS / "running")
done_all = list_dir(TASKS / "done")
failed_all = list_dir(TASKS / "failed")
if not running and not done_all and not failed_all:
print('No detached tasks yet. Start one by saying "detach: <your goal>".')
return
sections = []
if running:
sections.append(f"## Running ({len(running)})\n\n{render_list(running, len(running))}")
if done_all:
sections.append(f"## Done ({len(done_all)})\n\n{render_list(done_all[:10], len(done_all))}")
if failed_all:
sections.append(f"## Failed ({len(failed_all)})\n\n{render_list(failed_all[:10], len(failed_all))}")
print("\n\n".join(sections))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from tasks_common import FILENAME_RE, TASKS, format_result
def completed_files() -> list[Path]:
paths = []
for d in ("done", "failed"):
p = TASKS / d
if p.exists():
paths.extend(p.glob("*.md"))
return sorted(paths, key=lambda f: f.name, reverse=True)
def find_matches(identifier: str) -> list[Path]:
if not identifier:
done = sorted((TASKS / "done").glob("*.md"), key=lambda f: f.name, reverse=True) if (TASKS / "done").exists() else []
if done:
return [done[0]]
failed = sorted((TASKS / "failed").glob("*.md"), key=lambda f: f.name, reverse=True) if (TASKS / "failed").exists() else []
return [failed[0]] if failed else []
return [f for f in completed_files() if identifier.lower() in f.name.lower()]
def main() -> None:
for d in ("done", "failed"):
(TASKS / d).mkdir(parents=True, exist_ok=True)
identifier = sys.argv[1] if len(sys.argv) > 1 else ""
matches = find_matches(identifier)
if not matches:
if identifier:
print(f"No task matches `{identifier}`. Try `list` to see what's available.")
else:
print("No completed tasks yet.")
return
if len(matches) == 1:
print(format_result(matches[0]))
return
# Multiple matches — list for user to pick
print(f"Multiple tasks match `{identifier}`:\n")
for f in matches:
m = FILENAME_RE.match(f.name)
slug = m.group(2) if m else f.stem
ts = m.group(1) if m else ""
print(f"- `{slug}` ({ts}, {f.parent.name})")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,170 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["nanobot-ai"]
# ///
"""tasks-daemon: vyprázdni ~/.nanobot/workspace/tasks/inbox/ v jednom průchodu.
Spouštěn systemd .path unitem (tasks-daemon.path) jakmile inbox není
prázdný. Souběh řeší systemd sám: Type=oneshot service se nespustí
podruhé, dokud první běh trvá; level-triggered .path ho restartne po
doběhu, pokud inbox stále není prázdný.
Partial-write race řeší skill atomickým mv z tasks/new/ → tasks/inbox/,
takže tu žádný flock není potřeba.
Pro každý *.md v inbox/:
1. mv → running/<file>.md
2. načti frontmatter (chat_id povinný, channel default telegram)
3. spusť Nanobot.run(goal, session_key=f"detach:<stem>") s 45min timeoutem;
pokud frontmatter nese `model: <preset>`, přepni na něj (jinak default)
4. append ## Result do souboru, mv → done/<file>.md (success)
nebo failed/<file>.md (exception/timeout)
5. pošli Telegram zprávu uživateli (chat_id z frontmatteru)
"""
import asyncio
import json
import shutil
import sys
import traceback
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from tasks_common import LOG, TASKS, log, parse_frontmatter
from nanobot import Nanobot
CONFIG = Path.home() / ".nanobot" / "config.json"
TIMEOUT_SECONDS = 20 * 60
def telegram_send(chat_id: str, text: str) -> None:
token = json.loads(CONFIG.read_text())["channels"]["telegram"]["token"]
url = f"https://api.telegram.org/bot{token}/sendMessage"
data = urllib.parse.urlencode({"chat_id": chat_id, "text": text}).encode()
req = urllib.request.Request(url, data=data, method="POST")
with urllib.request.urlopen(req, timeout=15) as resp:
resp.read()
def resolve_telegram_chat_id(fm: dict[str, str]) -> tuple[str, str]:
"""Return (chat_id, source) — Telegram chat ID + 'frontmatter' or 'fallback'.
Pokud task přišel z Telegramu, použij chat_id z frontmatteru (multi-user ready).
Jinak (WebUI, CLI, ...) padni na první ID z channels.telegram.allowFrom v config.json.
"""
if fm.get("channel") == "telegram":
return fm["chat_id"], "frontmatter"
cfg = json.loads(CONFIG.read_text())
return cfg["channels"]["telegram"]["allowFrom"][0], "fallback"
async def run_agent(goal: str, session_key: str, preset: str | None = None) -> str:
bot = Nanobot.from_config()
if preset:
# Same switch the `/model <preset>` chat command performs; an invalid
# preset raises KeyError, caught by process_task and routed to failed/.
bot._loop.set_model_preset(preset)
result = await bot.run(goal, session_key=session_key)
return result.content or ""
def process_task(path: Path) -> None:
try:
content = path.read_text()
except Exception as e:
log(f"FAILED {path.name} read-error: {e}")
shutil.move(path, TASKS / "failed" / path.name)
return
fm, body = parse_frontmatter(content)
if not fm or "chat_id" not in fm:
log(f"FAILED {path.name} missing-chat_id-in-frontmatter")
shutil.move(path, TASKS / "failed" / path.name)
return
notify_chat_id, notify_source = resolve_telegram_chat_id(fm)
slug = fm.get("slug", path.stem)
preset = fm.get("model")
running = TASKS / "running" / path.name
shutil.move(path, running)
log(f"START {path.name} preset={preset or 'default'}")
goal = body.strip()
session_key = f"detach:{path.stem}"
started = datetime.now().astimezone()
try:
result_text = asyncio.run(
asyncio.wait_for(run_agent(goal, session_key, preset), timeout=TIMEOUT_SECONDS)
)
status = "done"
outcome = "✅ Hotovo"
except asyncio.TimeoutError:
result_text = f"(TIMEOUT po {TIMEOUT_SECONDS // 60} min)"
status = "failed"
outcome = "⏱️ Timeout"
log(f"TIMEOUT {path.name}")
except Exception as e:
result_text = f"(EXCEPTION: {e}\n\n{traceback.format_exc()})"
status = "failed"
outcome = "❌ Selhalo"
log(f"EXCEPTION {path.name}: {e}")
completed = datetime.now().astimezone()
duration_s = int((completed - started).total_seconds())
appended = (
f"{content}\n\n# Result\n\n{result_text}\n\n"
f"---\ncompleted: {completed.isoformat()}\n"
f"duration_seconds: {duration_s}\nstatus: {status}\n"
)
running.write_text(appended)
target_dir = TASKS / status
shutil.move(running, target_dir / path.name)
# Telegram notifikace — vždy přes Telegram, chat_id buď z frontmatteru
# (Telegram session) nebo z fallback configu (WebUI / CLI / atd.).
lines = result_text.strip().splitlines()
summary_line = lines[0][:200] if lines else "(prázdný výstup)"
msg = (
f"{outcome}: `{slug}`\n\n"
f"{summary_line}\n\n"
f"V chatu si vyžádej plný report: `výsledek {slug}`"
)
try:
telegram_send(notify_chat_id, msg)
log(f"NOTIFY {path.name} chat={notify_chat_id} source={notify_source}")
except Exception as e:
log(f"NOTIFY-FAILED {path.name}: {e}")
log(f"END {path.name} status={status} duration={duration_s}s")
def main() -> int:
for d in ("new", "inbox", "running", "done", "failed"):
(TASKS / d).mkdir(parents=True, exist_ok=True)
LOG.parent.mkdir(parents=True, exist_ok=True)
inbox = TASKS / "inbox"
tasks = sorted(inbox.glob("*.md"))
if not tasks:
return 0
log(f"DRAIN start {len(tasks)} task(s)")
for path in tasks:
try:
process_task(path)
except Exception as e:
log(f"FATAL {path.name}: {e}\n{traceback.format_exc()}")
log("DRAIN end")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,241 @@
"""Shared pure stdlib helpers for detach skill scripts."""
import json
import re
from datetime import datetime
from pathlib import Path
WORKSPACE = Path.home() / ".nanobot" / "workspace"
TASKS = WORKSPACE / "tasks"
CONFIG = Path.home() / ".nanobot" / "config.json"
LOG = WORKSPACE / "log" / "detach.log"
FILENAME_RE = re.compile(
r"^(\d{4}-\d{2}-\d{2}(?:T\d{6}|_\d{2}_\d{2}_\d{2}_\d{6}))-(.+)\.md$"
)
_NO_INTERACTION_BULLET = (
"- No user interaction (isolated session, no clarification questions"
" — work with what you have)."
)
def log(msg: str) -> None:
LOG.parent.mkdir(parents=True, exist_ok=True)
with LOG.open("a") as f:
f.write(f"{datetime.now().astimezone().isoformat()} {msg}\n")
def parse_frontmatter(content: str) -> tuple[dict[str, str], str]:
"""Parse YAML-ish frontmatter delimited by --- lines.
Returns (fields, body). On no match returns ({}, original content).
"""
m = re.match(r"^---\n(.*?)\n---\n(.*)", content, re.DOTALL)
if not m:
return {}, content
fm: dict[str, str] = {}
for line in m.group(1).splitlines():
if ":" in line:
k, _, v = line.partition(":")
fm[k.strip()] = v.strip().strip('"').strip("'")
return fm, m.group(2)
def parse_kv(text: str) -> dict[str, str]:
"""Parse simple key: value lines into a dict (no quote stripping)."""
result: dict[str, str] = {}
for line in text.splitlines():
if ":" in line:
k, _, v = line.partition(":")
result[k.strip()] = v.strip()
return result
def parse_filename(name: str) -> tuple[str, str] | None:
"""Return (timestamp_str, slug) from a task filename, or None if no match."""
m = FILENAME_RE.match(name)
if not m:
return None
return m.group(1), m.group(2)
def parse_timestamp(ts_str: str) -> datetime:
"""Parse a filename timestamp in old (T-joined) or new (underscore-separated) format."""
for fmt in ("%Y-%m-%d_%H_%M_%S_%f", "%Y-%m-%dT%H%M%S"):
try:
return datetime.strptime(ts_str, fmt)
except ValueError:
continue
raise ValueError(f"unrecognized timestamp: {ts_str}")
def format_time(ts_str: str) -> str:
"""Format a filename timestamp to HH:MM."""
try:
return parse_timestamp(ts_str).strftime("%H:%M")
except ValueError:
return ts_str
def format_age(ts_str: str) -> str:
"""Return a human-readable age for a filename timestamp."""
try:
delta = datetime.now() - parse_timestamp(ts_str)
s = max(0, int(delta.total_seconds()))
if s < 60:
return f"{s}s ago"
if s < 3600:
return f"{s // 60}m ago"
if s < 86400:
return f"{s // 3600}h ago"
return f"{s // 86400}d ago"
except ValueError:
return "?"
def goal_summary(path: Path, width: int = 80) -> str:
"""Return first non-empty line of the Goal section, truncated to width."""
try:
_, body = parse_frontmatter(path.read_text())
except OSError:
return ""
goal = (extract_section(body, "Goal") or "").strip()
first = next((line for line in goal.splitlines() if line.strip()), "")
return first if len(first) <= width else first[:width - 1].rstrip() + ""
def render_list(paths: list[Path], total: int) -> str:
"""Render tasks as a flat bullet list — robust for LLM relaying (no table grammar)."""
blocks = []
for path in paths:
parsed = parse_filename(path.name)
if parsed:
ts_str, slug = parsed
head = f"- `{slug}` · {format_time(ts_str)} · {format_age(ts_str)}"
else:
head = f"- `{path.name}`"
summary = goal_summary(path)
blocks.append(f"{head}\n {summary}" if summary else head)
out = "\n".join(blocks)
if total > len(paths):
out += f"\n\n_(+ {total - len(paths)} older)_"
return out
def extract_section(text: str, name: str) -> str | None:
"""Return the text content of a markdown section by heading name, or None."""
m = re.search(rf"(?m)^#+ {re.escape(name)}\s*\n(.*?)(?=^#|\Z)", text, re.DOTALL)
return m.group(1).strip() if m else None
def format_result(path: Path) -> str:
"""Format a completed task file as a human-readable result block."""
content = path.read_text()
sep = "\n\n---\n"
main_part, _, meta_str = content.rpartition(sep)
if not main_part:
main_part = content
meta_str = ""
trailing = parse_kv(meta_str)
orig_fm, body = parse_frontmatter(main_part)
m = FILENAME_RE.match(path.name)
slug = m.group(2) if m else path.stem
goal = extract_section(body, "Goal") or body.strip()
result = extract_section(body, "Result") or "(no result)"
created = orig_fm.get("created", "")
completed = trailing.get("completed", "")
duration = trailing.get("duration_seconds", "")
status = trailing.get("status", path.parent.name)
model = orig_fm.get("model", "")
model_suffix = f" · model: `{model}`" if model else ""
if created:
meta_line = f"_Done in `{duration}`s · `{created}` → `{completed}` · status: `{status}`{model_suffix}_"
else:
meta_line = f"_Done in `{duration}`s · completed: `{completed}` · status: `{status}`{model_suffix}_"
return "\n".join([
f"**Result: `{slug}`**",
"",
goal,
"",
"---",
"",
result,
"",
"---",
meta_line,
])
def load_preset_names() -> list[str]:
"""Return the configured model preset names from config.json, sorted.
The config key may be written either camelCase (`modelPresets`) or
snake_case (`model_presets`) — nanobot accepts both, so we read both.
"""
config = json.loads(CONFIG.read_text())
presets = config.get("modelPresets") or config.get("model_presets") or {}
return sorted(presets.keys())
def resolve_preset(token: str, names: list[str]) -> str:
"""Resolve a user-typed model token to an exact preset name.
Exact match (case-insensitive) wins; otherwise a unique case-insensitive
substring match. Raises KeyError when nothing or more than one matches.
"""
token = token.strip()
exact = [n for n in names if n.lower() == token.lower()]
if exact:
return exact[0]
substring = [n for n in names if token.lower() in n.lower()]
if len(substring) == 1:
return substring[0]
available = ", ".join(names) or "(none)"
if not substring:
raise KeyError(f"model {token!r} not found. Available: {available}")
raise KeyError(f"model {token!r} is ambiguous: {', '.join(substring)}")
def build_task_filename(timestamp_str: str, slug: str) -> str:
"""Build the task filename from a formatted timestamp and slug."""
return f"{timestamp_str}-{slug}.md"
def build_task_content(
created_iso: str,
channel: str,
chat_id: str,
slug: str,
goal: str,
constraints: list[str],
model: str | None = None,
) -> str:
"""Build the full frontmatter+body content for a new task file."""
constraint_lines = [_NO_INTERACTION_BULLET] + [f"- {c}" for c in constraints]
constraints_block = "\n".join(constraint_lines)
model_line = f"model: {model}\n" if model else ""
return (
f"---\n"
f"created: {created_iso}\n"
f'channel: {channel}\n'
f'chat_id: "{chat_id}"\n'
f"slug: {slug}\n"
f"{model_line}"
f"---\n"
f"\n"
f"# Goal\n"
f"\n"
f"{goal}\n"
f"\n"
f"# Constraints\n"
f"\n"
f"{constraints_block}\n"
)

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Trigger detach daemon when tasks/inbox has files
[Path]
DirectoryNotEmpty=%h/.nanobot/workspace/tasks/inbox
Unit=tasks-daemon.service
[Install]
WantedBy=paths.target

View File

@@ -0,0 +1,18 @@
[Unit]
Description=nanobot detach tasks daemon (drain inbox)
After=nanobot.service
# Tolerate transient startup crashes without permanently latching the pipeline.
# 20 retries per 30 min, then pause + auto-resume as the window slides — no manual reset-failed.
StartLimitIntervalSec=1800
StartLimitBurst=20
[Service]
Type=oneshot
# On a crash (exit!=0) retry after a delay; clean drain (exit 0) and systemd-initiated
# stop (SIGTERM) do not restart. Gives a fixed deploy time to recover automatically.
Restart=on-failure
RestartSec=60
Environment=PATH=%h/.local/bin:/usr/bin:/bin
ExecStart=%h/.nanobot/workspace/skills/detach/scripts/tasks-daemon.py
StandardOutput=append:%h/.nanobot/workspace/log/tasks-daemon.stdout.log
StandardError=append:%h/.nanobot/workspace/log/tasks-daemon.stderr.log

View File

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

View File

@@ -0,0 +1,490 @@
"""Tests for tasks_common pure logic — no I/O beyond tmp_path, no network."""
import json
from pathlib import Path
import pytest
import tasks_common
from tasks_common import (
FILENAME_RE,
build_task_content,
build_task_filename,
extract_section,
format_age,
format_result,
format_time,
goal_summary,
load_preset_names,
parse_filename,
parse_frontmatter,
parse_kv,
parse_timestamp,
render_list,
resolve_preset,
)
# ---------------------------------------------------------------------------
# parse_frontmatter
# ---------------------------------------------------------------------------
def test_parse_frontmatter_basic():
content = "---\ncreated: 2026-01-01T10:00:00+00:00\nslug: my-task\n---\n\n# Goal\n\nDo something.\n"
fm, body = parse_frontmatter(content)
assert fm["created"] == "2026-01-01T10:00:00+00:00"
assert fm["slug"] == "my-task"
assert "# Goal" in body
def test_parse_frontmatter_quoted_chat_id():
content = '---\nchat_id: "12345"\nchannel: telegram\n---\nbody\n'
fm, body = parse_frontmatter(content)
assert fm["chat_id"] == "12345"
assert fm["channel"] == "telegram"
assert body == "body\n"
def test_parse_frontmatter_no_match():
content = "No frontmatter here."
fm, body = parse_frontmatter(content)
assert fm == {}
assert body == content
def test_parse_frontmatter_roundtrip():
original = "---\nfoo: bar\nbaz: qux\n---\nbody text\n"
fm, body = parse_frontmatter(original)
assert fm == {"foo": "bar", "baz": "qux"}
assert body == "body text\n"
# ---------------------------------------------------------------------------
# parse_kv
# ---------------------------------------------------------------------------
def test_parse_kv_basic():
text = "completed: 2026-01-02T11:00:00+00:00\nduration_seconds: 42\nstatus: done\n"
kv = parse_kv(text)
assert kv["completed"] == "2026-01-02T11:00:00+00:00"
assert kv["duration_seconds"] == "42"
assert kv["status"] == "done"
def test_parse_kv_empty():
assert parse_kv("") == {}
def test_parse_kv_no_colon_lines_ignored():
kv = parse_kv("no colon here\nkey: value\n")
assert kv == {"key": "value"}
# ---------------------------------------------------------------------------
# FILENAME_RE / parse_filename
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("name,expected", [
("2026-01-15T143022-my-task.md", ("2026-01-15T143022", "my-task")),
("2026-12-31T235959-qdrant-vs-weaviate.md", ("2026-12-31T235959", "qdrant-vs-weaviate")),
("2026-06-07_15_00_00_123456-qdrant-vs-weaviate.md", ("2026-06-07_15_00_00_123456", "qdrant-vs-weaviate")),
("2026-06-07_09_05_59_000001-deploy-api.md", ("2026-06-07_09_05_59_000001", "deploy-api")),
])
def test_filename_re_matches(name, expected):
assert parse_filename(name) == expected
@pytest.mark.parametrize("name", [
"not-a-task.md",
"2026-01-15-missing-time.md",
"2026-01-15T1430-short.md",
"2026-06-07_15_00_123456-too-few-groups.md",
])
def test_filename_re_no_match(name):
assert parse_filename(name) is None
def test_filename_re_direct_old_format():
assert FILENAME_RE.match("2026-06-02T120000-test-slug.md") is not None
def test_filename_re_direct_new_format():
assert FILENAME_RE.match("2026-06-07_15_00_00_123456-test-slug.md") is not None
# ---------------------------------------------------------------------------
# format_time
# ---------------------------------------------------------------------------
def test_format_time_old_format():
assert format_time("2026-06-02T143022") == "14:30"
def test_format_time_new_format():
assert format_time("2026-06-07_15_00_00_123456") == "15:00"
def test_format_time_invalid():
assert format_time("not-a-time") == "not-a-time"
# ---------------------------------------------------------------------------
# parse_timestamp
# ---------------------------------------------------------------------------
def test_parse_timestamp_old_format():
dt = parse_timestamp("2026-06-02T143022")
assert dt.hour == 14
assert dt.minute == 30
assert dt.second == 22
def test_parse_timestamp_new_format():
dt = parse_timestamp("2026-06-07_15_00_00_123456")
assert dt.hour == 15
assert dt.minute == 0
assert dt.microsecond == 123456
def test_parse_timestamp_invalid():
import pytest as _pytest
with _pytest.raises(ValueError):
parse_timestamp("not-a-timestamp")
# ---------------------------------------------------------------------------
# format_age
# ---------------------------------------------------------------------------
def test_format_age_invalid():
assert format_age("bad-value") == "?"
def test_format_age_future_clamps_to_zero():
# A timestamp far in the future still returns a non-negative age string.
result = format_age("2099-01-01T000000")
assert result.endswith("ago") or result == "0s ago"
# ---------------------------------------------------------------------------
# render_list / goal_summary
# ---------------------------------------------------------------------------
def _make_task_path(tmp_path: Path, name: str, goal: str = "Test goal.") -> Path:
path = tmp_path / name
path.write_text(
f'---\nslug: test\nchat_id: "1"\nchannel: telegram\n---\n\n# Goal\n\n{goal}\n'
)
return path
def test_render_list_basic(tmp_path):
paths = [
_make_task_path(tmp_path, "2026-06-01T120000-alpha.md", "Alpha goal."),
_make_task_path(tmp_path, "2026-06-02T130000-beta.md", "Beta goal."),
]
out = render_list(paths, len(paths))
assert "- `alpha`" in out
assert "- `beta`" in out
assert "|" not in out # no markdown table grammar
def test_render_list_shows_goal(tmp_path):
paths = [_make_task_path(tmp_path, "2026-06-01T120000-mytask.md", "Research Qdrant.")]
out = render_list(paths, 1)
assert "- `mytask`" in out
assert "\n Research Qdrant." in out # goal on its own indented line
def test_render_list_truncation_note(tmp_path):
paths = [_make_task_path(tmp_path, "2026-06-01T120000-alpha.md")]
out = render_list(paths, 5)
assert "(+ 4 older)" in out
def test_render_list_no_truncation_note_when_exact(tmp_path):
paths = [_make_task_path(tmp_path, "2026-06-01T120000-alpha.md")]
out = render_list(paths, 1)
assert "older" not in out
def test_render_list_unknown_filename(tmp_path):
path = tmp_path / "weird-name.md"
path.write_text("no frontmatter")
out = render_list([path], 1)
assert "- `weird-name.md`" in out
assert "" not in out # no table placeholders
def test_render_list_new_format_filename(tmp_path):
paths = [_make_task_path(tmp_path, "2026-06-07_15_00_00_123456-new-slug.md", "New task.")]
out = render_list(paths, 1)
assert "- `new-slug`" in out
assert "New task." in out
assert "15:00" in out
def test_render_list_omits_goal_line_when_empty(tmp_path):
path = tmp_path / "2026-06-01T120000-nogoal.md"
path.write_text('---\nslug: nogoal\n---\n\nNo goal section here.\n')
out = render_list([path], 1)
assert out.startswith("- `nogoal` · 12:00 · ")
assert "\n " not in out # no indented goal line
def test_goal_summary_truncates(tmp_path):
long_goal = "A" * 100
path = _make_task_path(tmp_path, "2026-06-01T120000-long.md", long_goal)
summary = goal_summary(path)
assert len(summary) <= 80
assert summary.endswith("")
def test_goal_summary_missing_file():
from pathlib import Path as _Path
assert goal_summary(_Path("/nonexistent/file.md")) == ""
# ---------------------------------------------------------------------------
# extract_section
# ---------------------------------------------------------------------------
def test_extract_section_found():
text = "# Goal\n\nDo something useful.\n\n# Constraints\n\n- bullet\n"
assert extract_section(text, "Goal") == "Do something useful."
def test_extract_section_not_found():
assert extract_section("# Goal\n\ntext\n", "Result") is None
def test_extract_section_stops_at_next_heading():
text = "# Goal\n\ngoal text\n\n# Result\n\nresult text\n"
assert extract_section(text, "Goal") == "goal text"
assert extract_section(text, "Result") == "result text"
# ---------------------------------------------------------------------------
# format_result (uses tmp_path)
# ---------------------------------------------------------------------------
def _make_task_file(tmp_path: Path, slug: str, goal: str, result_text: str) -> Path:
filename = f"2026-06-01T120000-{slug}.md"
path = tmp_path / "done" / filename
path.parent.mkdir(parents=True, exist_ok=True)
content = (
f"---\ncreated: 2026-06-01T12:00:00+02:00\nchannel: telegram\n"
f'chat_id: "99"\nslug: {slug}\n---\n\n'
f"# Goal\n\n{goal}\n\n# Result\n\n{result_text}\n\n"
f"---\ncompleted: 2026-06-01T12:05:00+02:00\nduration_seconds: 300\nstatus: done\n"
)
path.write_text(content)
return path
def test_format_result_contains_slug(tmp_path):
path = _make_task_file(tmp_path, "my-slug", "Research X.", "Found Y.")
output = format_result(path)
assert "my-slug" in output
def test_format_result_contains_goal(tmp_path):
path = _make_task_file(tmp_path, "task-one", "Research X.", "Found Y.")
output = format_result(path)
assert "Research X." in output
def test_format_result_contains_result(tmp_path):
path = _make_task_file(tmp_path, "task-two", "Research X.", "Found Y.")
output = format_result(path)
assert "Found Y." in output
def test_format_result_contains_meta(tmp_path):
path = _make_task_file(tmp_path, "task-three", "Do it.", "Done.")
output = format_result(path)
assert "300" in output # duration_seconds
assert "done" in output
# ---------------------------------------------------------------------------
# build_task_filename
# ---------------------------------------------------------------------------
def test_build_task_filename_old_format():
name = build_task_filename("2026-06-02T153045", "my-slug")
assert name == "2026-06-02T153045-my-slug.md"
assert FILENAME_RE.match(name) is not None
def test_build_task_filename_new_format():
name = build_task_filename("2026-06-07_15_00_00_123456", "my-slug")
assert name == "2026-06-07_15_00_00_123456-my-slug.md"
assert FILENAME_RE.match(name) is not None
# ---------------------------------------------------------------------------
# build_task_content
# ---------------------------------------------------------------------------
FIXED_TS = "2026-06-02T153045"
FIXED_ISO = "2026-06-02T15:30:45+02:00"
def test_build_task_content_frontmatter_fields():
content = build_task_content(
created_iso=FIXED_ISO,
channel="telegram",
chat_id="42",
slug="test-task",
goal="Do the thing.",
constraints=[],
)
fm, body = parse_frontmatter(content)
assert fm["created"] == FIXED_ISO
assert fm["channel"] == "telegram"
assert fm["chat_id"] == "42"
assert fm["slug"] == "test-task"
def test_build_task_content_goal_section():
content = build_task_content(
created_iso=FIXED_ISO,
channel="telegram",
chat_id="42",
slug="test-task",
goal="Do the thing.",
constraints=[],
)
_, body = parse_frontmatter(content)
assert extract_section(body, "Goal") == "Do the thing."
def test_build_task_content_constraints_section_has_no_interaction():
content = build_task_content(
created_iso=FIXED_ISO,
channel="telegram",
chat_id="42",
slug="test-task",
goal="Do the thing.",
constraints=[],
)
_, body = parse_frontmatter(content)
constraints = extract_section(body, "Constraints")
assert constraints is not None
assert "No user interaction" in constraints
def test_build_task_content_extra_constraints():
content = build_task_content(
created_iso=FIXED_ISO,
channel="telegram",
chat_id="42",
slug="test-task",
goal="Do the thing.",
constraints=["Max 5 minutes.", "Output must be JSON."],
)
_, body = parse_frontmatter(content)
constraints = extract_section(body, "Constraints")
assert "Max 5 minutes." in constraints
assert "Output must be JSON." in constraints
def test_build_task_content_parseable_by_daemon():
"""The content produced must be parseable by parse_frontmatter as tasks-daemon does."""
content = build_task_content(
created_iso=FIXED_ISO,
channel="websocket",
chat_id="777",
slug="daemon-check",
goal="Verify parsing.",
constraints=[],
)
fm, body = parse_frontmatter(content)
assert fm.get("chat_id") == "777"
assert fm.get("channel") == "websocket"
assert "# Goal" in body
assert "# Constraints" in body
def test_build_task_content_omits_model_by_default():
content = build_task_content(
created_iso=FIXED_ISO,
channel="telegram",
chat_id="42",
slug="test-task",
goal="Do the thing.",
constraints=[],
)
fm, _ = parse_frontmatter(content)
assert "model" not in fm
def test_build_task_content_includes_model_when_set():
content = build_task_content(
created_iso=FIXED_ISO,
channel="telegram",
chat_id="42",
slug="test-task",
goal="Do the thing.",
constraints=[],
model="kimi-k2.6-openrouter",
)
fm, _ = parse_frontmatter(content)
assert fm["model"] == "kimi-k2.6-openrouter"
# ---------------------------------------------------------------------------
# resolve_preset
# ---------------------------------------------------------------------------
PRESETS = ["glm-5.1-ollama", "kimi-k2.6-openrouter", "qwen-3.5-ollama", "qwen-3.6-plus"]
def test_resolve_preset_exact_case_insensitive():
assert resolve_preset("Kimi-K2.6-OpenRouter", PRESETS) == "kimi-k2.6-openrouter"
def test_resolve_preset_unique_substring():
assert resolve_preset("kimi", PRESETS) == "kimi-k2.6-openrouter"
assert resolve_preset("glm", PRESETS) == "glm-5.1-ollama"
def test_resolve_preset_unknown_raises_with_available():
with pytest.raises(KeyError) as exc:
resolve_preset("gpt5", PRESETS)
assert "not found" in exc.value.args[0]
assert "kimi-k2.6-openrouter" in exc.value.args[0]
def test_resolve_preset_ambiguous_raises_with_candidates():
with pytest.raises(KeyError) as exc:
resolve_preset("qwen", PRESETS)
assert "ambiguous" in exc.value.args[0]
assert "qwen-3.5-ollama" in exc.value.args[0]
assert "qwen-3.6-plus" in exc.value.args[0]
# ---------------------------------------------------------------------------
# load_preset_names (uses tmp_path + monkeypatched CONFIG)
# ---------------------------------------------------------------------------
def test_load_preset_names_reads_camelcase(tmp_path, monkeypatch):
config = tmp_path / "config.json"
config.write_text(json.dumps({"modelPresets": {"b-preset": {}, "a-preset": {}}}))
monkeypatch.setattr(tasks_common, "CONFIG", config)
assert load_preset_names() == ["a-preset", "b-preset"]
def test_load_preset_names_reads_snake_case(tmp_path, monkeypatch):
config = tmp_path / "config.json"
config.write_text(json.dumps({"model_presets": {"kimi": {}, "glm": {}}}))
monkeypatch.setattr(tasks_common, "CONFIG", config)
assert load_preset_names() == ["glm", "kimi"]
def test_load_preset_names_empty_when_absent(tmp_path, monkeypatch):
config = tmp_path / "config.json"
config.write_text(json.dumps({"channels": {}}))
monkeypatch.setattr(tasks_common, "CONFIG", config)
assert load_preset_names() == []

10
skills/grill-me/SKILL.md Normal file
View File

@@ -0,0 +1,10 @@
---
name: grill-me
description: Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions "grill me".
---
Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.
Ask the questions one at a time.
If a question can be answered by exploring the codebase, explore the codebase instead.

77
skills/keep/SKILL.md Normal file
View File

@@ -0,0 +1,77 @@
---
name: keep
description: >
Explicit immediate memory.
Use when user says "keep X", "zapamatuj si X", "ulož si X", "pamatuj si X", "/keep X".
Adds, deduplicates, and compacts entries in workspace/keep.md.
Separate from MEMORY.md / Dream pipeline.
---
# Keep
Explicit memory store. User says "keep X" → reformulate, write to
`/home/nanobot/.nanobot/workspace/keep.md`, dedup, compact when too long.
## File
`workspace/keep.md`. Flat bullet list. One entry = one line: `- <terse fact>`.
**No dates.** Date adds noise, has no value for keep/discard decisions.
## Write protocol
1. Extract the core fact. Drop filler ("you know", "important is that", "watch
out for", "remember please").
2. Rewrite as a 515 word terse fact. Telegraphic style. Dashes / parentheses
for context. **Preserve the language of the input — never translate.** Czech
input → Czech entry, English input → English entry.
- Input: "you know, Honza from marketing is allergic to peanuts"
- Entry: `Honza (marketing) — peanut allergy`
- If the entry is a decision, preference, or dead-end (not a plain fact),
append the reason inline on the same line: `<fact> — because <terse why>`.
Plain facts (allergy, deploy window, name) get no reason.
- If it is a decision/preference/dead-end but the input gives no reason, ask
the user once for the why before storing. If they supply it → append it. If
they decline or it is self-evident → store without it.
3. Read `workspace/keep.md` (create if missing).
4. Read `workspace/memory/MEMORY.md` and check if a semantically similar fact
already exists there (Dream may have already distilled it).
- If similar fact in MEMORY.md → tell the user (`"Already in MEMORY.md:
<existing fact>. Keep anyway?"`) and act on their answer. Default: skip.
5. Check duplicates: case-insensitive substring match against existing lines.
- If duplicate → ask user: replace, append as variant, or skip.
6. Append the new line.
7. If line count > 150 → run **compaction** (below) before responding.
8. Confirm: `Kept: <terse fact>`. Respond in the user's language (the model
localizes the confirmation itself).
## Compaction
Trigger: line count > 150, or user says "keep compact" / "udělej compaction".
1. Read full `keep.md`.
2. Rewrite under ~120 lines (headroom). Strategies:
- Merge duplicates and near-duplicates.
- Drop stale one-shot info (past meetings, transient states, expired notes).
- Shorten verbose entries.
3. Write the new file in one go.
4. Report: `Compaction: 151 → 117 lines`.
## Edge cases
- `/keep` with no content → ask "What should I remember?".
- Vague input ("remember this", "that thing") → ask for the concrete fact; do
not store a placeholder.
- File missing → create it on first write.
- Multi-line input → collapse newlines to spaces; one entry = one line.
## Rules
- Never store the verbatim input. Always reformulate.
- Reason (why) only for decisions / preferences / dead-ends — never for plain
facts. Always terse and inline on the same line; never a separate Why: block.
- Preserve input language; never translate.
- Do not store smalltalk or meta-commentary about memory itself.
- Keep is separate from MEMORY.md and Dream. Read MEMORY.md only for the dedup
check (step 4); never edit it or any Dream file from this skill.
- Touch `keep.md` only via this skill. Other agent paths should read it
(per USER.md reference) but not edit it.

88
skills/note/SKILL.md Normal file
View File

@@ -0,0 +1,88 @@
---
name: note
description: >
Explicit notes.
Use when user says "note X", "note it".
---
# Note
Explicit note store backed by SQLite. User says "note X" → extract tags,
reformulate content, store via `note.py add`. Delete only on explicit user request. Notes are stored to sqlite db.
## Backend
`skills/note/scripts/note.py` — CLI wrapper around `db/note.sqlite`.
Operation log: `log/note.log` (append-only, all write operations).
## Tag protocol
Tags are the **first token** right after the trigger — comma-separated, no spaces:
```
/note arch explanation of the architecture decision → tags: [arch]
/note hw,linux interesting article about kernel → tags: [hw, linux]
/note this is a note without tags → tags: []
```
Rules:
- Lowercase only; multi-word tags use `-`: `cli`, `soft-delete`, `task-queue`
- If user writes `#tag`, strip `#` before passing to the script
- If no tag is given — that is fine, use no tags; never force tags
Tags are created automatically on first use — no registration needed.
## Write protocol
1. Extract inline tags from the first token (see Tag protocol above).
2. Reformulate the remaining text into a terse fact. One concept per entry —
split if too complex; omit context that is not itself a fact. Preserve
input language; never translate. Drop filler.
- Input: "poznamenej si, glow zobrazuje markdown v terminálu #cli"
- Run: `uv run skills/note/scripts/note.py add "glow displays markdown in terminal" --tags cli`
3. Echo: `Noted [#1]: <content> [#tag1 #tag2]` (tags omitted if none).
`#1` is the display ID of the new note — use it to delete immediately if needed.
No dedup. No MEMORY.md lookup. Blind append.
## List protocol
Trigger: `/note list`, `show notes`, `what notes do you have?`
1. Run: `uv run skills/note/scripts/note.py list [--limit N] [--tag TAG [TAG ...]]`
2. Echo output. If empty → respond "No notes."
`--tag` accepts one or more tags; OR logic (notes with at least one matching tag).
The number before each note (`1.`, `2.`, …) is the **display ID** — sequential
among active notes, newest first. Renumbers after every deletion.
## Delete protocol
Trigger: `/note delete`, `delete a note`, `remove a note`.
1. If the user has not specified an ID, run `list` first to show current notes.
2. Run: `uv run skills/note/scripts/note.py delete <display-id>`
- Exit 0 → confirm deletion.
- Exit 1 → display ID out of range; respond accordingly.
3. Nothing is deleted automatically. Only this explicit protocol deletes.
Display IDs renumber after every deletion (e.g., after deleting #3, the old #4
becomes #3). Always run `list` first if unsure of current IDs.
## Edge cases
- `/note` with no content → ask "What should I note?"
- Vague input → ask for the concrete fact; do not store a placeholder.
- `/note delete` with no ID → run `list` first, then ask which display ID.
- Multi-line input → collapse to one line; one entry = one row.
## Rules
- Never store verbatim input. Always reformulate. Preserve input language.
- Do not store smalltalk or meta-commentary about the note skill itself.
- **No auto-load:** `note.sqlite` is never referenced in bootstrap files.
- **No auto-delete / no compaction.** Only explicit delete marks an entry.
- **Delete is soft** — the entry is marked with a timestamp, not removed from
the database. The operation log (`log/note.log`) is the primary audit trail.
- Separate from `/keep`, `MEMORY.md`, Dream — never cross-write or cross-read.

200
skills/note/scripts/note.py Normal file
View File

@@ -0,0 +1,200 @@
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = []
# ///
"""
note.py — backend for /note skill.
SQLite-backed note store with tags, soft-delete, and operation log.
"""
import argparse
import json
import re
import sqlite3
import sys
from collections.abc import Generator
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
DB_PATH = Path(__file__).resolve().parent.parent.parent.parent / "db" / "note.sqlite"
LOG_PATH = Path(__file__).resolve().parent.parent.parent.parent / "log" / "note.log"
_TAG_RE = re.compile(r"^[a-z][a-z0-9-]*$")
SCHEMA = """
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
deleted_at TEXT
);
"""
def _init_db(conn: sqlite3.Connection) -> None:
conn.execute("PRAGMA journal_mode=WAL")
conn.executescript(SCHEMA)
_migrate(conn)
def _migrate(conn: sqlite3.Connection) -> None:
cols = {row[1] for row in conn.execute("PRAGMA table_info(notes)")}
if "tags" not in cols:
conn.execute("ALTER TABLE notes ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'")
if "deleted_at" not in cols:
conn.execute("ALTER TABLE notes ADD COLUMN deleted_at TEXT")
conn.commit()
@contextmanager
def _connect() -> Generator[sqlite3.Connection, None, None]:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
_init_db(conn)
try:
yield conn
finally:
conn.close()
def _validate_tags(tags: list[str]) -> None:
for tag in tags:
if not _TAG_RE.match(tag):
raise ValueError(
f"Invalid tag '{tag}' — use lowercase letters, digits, hyphens only (e.g. cli, soft-delete)"
)
def _tags_display(tags_json: str) -> str:
tags = json.loads(tags_json)
if not tags:
return ""
return " [" + " ".join(f"#{t}" for t in tags) + "]"
def _log(op: str, detail: str) -> None:
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
with LOG_PATH.open("a") as f:
f.write(f"{ts} {op} {detail}\n")
def _active_ids(conn: sqlite3.Connection) -> list[int]:
rows = conn.execute(
"SELECT id FROM notes WHERE deleted_at IS NULL ORDER BY created_at DESC"
).fetchall()
return [row["id"] for row in rows]
def cmd_add(args: argparse.Namespace) -> int:
tags: list[str] = args.tags or []
try:
_validate_tags(tags)
except ValueError as exc:
print(str(exc), file=sys.stderr)
return 1
content = args.text.strip()
tags_json = json.dumps(tags)
created_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cur = conn.execute(
"INSERT INTO notes(content, tags, created_at) VALUES(?, ?, ?)",
(content, tags_json, created_at),
)
conn.commit()
nid = cur.lastrowid
tags_log = ",".join(tags)
_log("ADD", f"id={nid} tags=[{tags_log}] {content}")
print(f"Noted [#1]: {content}{_tags_display(tags_json)}")
return 0
def cmd_list(args: argparse.Namespace) -> int:
with _connect() as conn:
id_to_display = {nid: i + 1 for i, nid in enumerate(_active_ids(conn))}
if args.tag:
placeholders = ",".join("?" * len(args.tag))
rows = conn.execute(
f"""
SELECT id, content, tags FROM notes
WHERE deleted_at IS NULL
AND (
SELECT count(*) FROM json_each(notes.tags)
WHERE value IN ({placeholders})
) > 0
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""",
(*args.tag, args.limit, args.offset),
).fetchall()
else:
rows = conn.execute(
"SELECT id, content, tags FROM notes"
" WHERE deleted_at IS NULL"
" ORDER BY created_at DESC LIMIT ? OFFSET ?",
(args.limit, args.offset),
).fetchall()
tag_filter = ",".join(args.tag) if args.tag else "None"
_log("LIST", f"tag={tag_filter} returned={len(rows)}")
if not rows:
print("No notes.")
return 0
for row in rows:
print(f"{id_to_display[row['id']]}. {row['content']}{_tags_display(row['tags'])}")
return 0
def cmd_delete(args: argparse.Namespace) -> int:
display_id: int = args.id
deleted_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
ids = _active_ids(conn)
idx = display_id - 1
if idx < 0 or idx >= len(ids):
print(f"No active note with display id={display_id}.")
return 1
nid = ids[idx]
row = conn.execute(
"SELECT id, content, tags FROM notes WHERE id = ?", (nid,)
).fetchone()
conn.execute("UPDATE notes SET deleted_at = ? WHERE id = ?", (deleted_at, nid))
conn.commit()
tags_log = ",".join(json.loads(row["tags"]))
_log("DELETE", f"display_id={display_id} id={nid} tags=[{tags_log}] content={row['content']!r}")
print(f"Deleted: {row['content']}")
return 0
def _main() -> int:
parser = argparse.ArgumentParser(description="Note store")
sub = parser.add_subparsers(dest="cmd", required=True)
p_add = sub.add_parser("add", help="Add a note")
p_add.add_argument("text", help="Note content")
p_add.add_argument("--tags", nargs="+", metavar="TAG", default=[], help="Tags (lowercase, hyphens allowed)")
p_list = sub.add_parser("list", help="List active notes")
p_list.add_argument("--limit", type=int, default=50)
p_list.add_argument("--offset", type=int, default=0)
p_list.add_argument("--tag", nargs="+", metavar="TAG", help="Filter by tag (OR logic)")
p_del = sub.add_parser("delete", help="Soft-delete a note by ID")
p_del.add_argument("id", type=int, help="Note ID")
args = parser.parse_args()
if args.cmd == "add":
return cmd_add(args)
if args.cmd == "list":
return cmd_list(args)
if args.cmd == "delete":
return cmd_delete(args)
return 0
if __name__ == "__main__":
sys.exit(_main())

96
skills/plan/SKILL.md Normal file
View File

@@ -0,0 +1,96 @@
---
name: plan
description: >
Plan mode — explore read-only, write a plan to workspace/plans/, get approval,
execute only after the user explicitly says so (now or later). Mirrors Claude
Code plan mode.
Use when user says "/plan X", "plan mode", "first plan then do X".
---
# Plan
Explore the task read-only, design an approach, write it to a plan file, and
**stop for approval**. Mutate nothing until the user explicitly approves
execution — which can happen now or much later. Plan now, execute whenever.
Four phases, run linearly. Emit a short status line between phases so the user
(especially on Telegram, where there is no thinking stream) sees progress.
## 1. Explore (read-only)
1. Restate the task in one sentence to confirm scope.
2. Investigate the relevant files and state: `read_file`, `ssh … cat` / `rsync`
for server files, `--help`, the official wiki. Look for existing code,
skills, or patterns to reuse instead of proposing new ones.
3. **No mutations.** Reading only.
Default: explore **linearly, yourself**. Planning is iterative — what you find
decides where you look next — and that does not split cleanly up front.
Use `spawn` **only** when the task is large and breaks into genuinely
independent parts (e.g. "explore three separate subsystems"). Then spawn one
subagent per part and wait for their results before phase 2. `spawn` is async
(results arrive via the message bus, not inline), so reach for it only at real
divisible scale — never routinely.
## 2. Design
Design the approach: what changes, where, and how it will be verified. Reuse
what you found in phase 1. If the request is genuinely ambiguous, ask now;
otherwise proceed.
## 3. Write the plan
1. Pick a kebab-case slug from the topic.
2. Write the plan to `/home/nanobot/.nanobot/workspace/plans/<slug>.md` (create
the `plans/` directory if missing). This file write is the **only** write
allowed before approval.
3. Plan structure:
```
# <Title>
## Kontext
Why this change — the problem, what prompted it, the intended outcome.
## Postup
Numbered steps. Name the files to touch. Reference reusable code found
in phase 1 with its path.
## Ověření
How to test the change end-to-end (run it, run tests, check behavior).
```
4. Also print a short version of the plan into the chat.
## 4. Approval (replaces ExitPlanMode — over chat)
Stop and ask: `Plán uložen do workspace/plans/<slug>.md. Schvaluješ? Mám ho
vykonat teď?` Then wait. Mutate nothing on your own.
- Approved + execute now → drop the read-only discipline and execute the plan in
this conversation.
- Approved but **not now** → planning is done. The plan stays in
`workspace/plans/<slug>.md` for later; the user can run it anytime by pointing
at the file.
- Wants changes → rewrite the plan file (still read-only otherwise) and ask again.
## Edge cases
- `/plan` with no task → ask "What should I plan?".
- Tiny one-step task (typo fix, single-line change) → say a full plan is
overkill and offer to just do it; don't force the ceremony.
- User already approved earlier and now says "execute the plan" → read the plan
file and execute; no need to re-plan.
## Rules
- **Read-only through phases 13.** Do not write or edit files (except the plan
file in phase 3), run mutating commands, change config, or restart services.
- Execute only after explicit approval to execute now. Approval to "save the
plan" is not approval to run it.
- Reuse before inventing — prefer existing code, skills, and patterns found
in phase 1.
- Respond in the user's language (the model localizes status and questions
itself); keep the plan-file body and structure as above.
- Keep status lines to one short sentence. No filler, no emojis.

88
skills/project/SKILL.md Normal file
View File

@@ -0,0 +1,88 @@
---
name: project
aliases: [proj]
description: >
Project management — long-running things with notes, next-steps, and status.
Use when user mentions "project".
---
# Project
File-backed project store in `projects/`. Each project is one markdown file
with YAML frontmatter (`status`, `priority`, `created`, `slug`) and a free-form
body for notes and next-steps.
## Backend
`skills/project/scripts/project.py` — deterministic CRUD for frontmatter and
basic operations. Agent handles all body edits via `edit_file` / `apply_patch`.
## Commands
### `project add <název>` — create
1. Run: `uv run skills/project/scripts/project.py add "<název>" [--priority high|medium|low]`
2. Default priority is `medium`.
3. Echo: `Created project '<slug>' (priority: <priority>)`
### `project list` — list active
1. Run: `uv run skills/project/scripts/project.py list`
2. Echo JSON output. Format as:
```
Active projects:
- <slug> (priority: high) — <first line of body / project name>
```
3. If empty → "No active projects."
### `project show <slug>` — display
1. Run: `uv run skills/project/scripts/project.py show <slug>`
2. Echo the full markdown file.
### `project status <slug> <active|paused|done>` — change status
1. Run: `uv run skills/project/scripts/project.py status <slug> <status>`
2. Echo: `Project '<slug>' is now <status>.`
### `project next <slug> <text>` — set next step
1. Read the project file.
2. Use `edit_file` to replace the content under `## Další krok` with the new text.
3. If the section does not exist, add it before the end of the file.
4. Echo: `Next step for '<slug>' updated.`
### `project note <slug> <text>` — add a note
1. Read the project file.
2. Use `edit_file` to append a bullet under `## Poznámky`:
`- <today>: <text>`
3. If `## Poznámky` does not exist, add it after the first heading.
4. Echo: `Note added to '<slug>'.`
### `project switch <slug>` — session context
1. Run `my(action="set", key="project_context", value="<slug>")`.
2. Echo: `Switched to project '<slug>'. Next project commands without slug will use this context.`
3. If a command is missing a slug and `project_context` is set, use it automatically.
## Rules
- **Slug** = kebab-case from first 4 words of the name. Used as filename (`<slug>.md`).
- **Frontmatter** is read-only for the agent — never edit it directly in the file.
Use `project.py status` to change status.
- **Body edits** (notes, next-step, structure changes) are always done by the agent
via `edit_file` / `apply_patch`.
- **No database** — pure markdown files. Git-friendly, one commit per change.
- **Session context** (`project switch`) lives only in `my` scratchpad and is lost
on restart. Re-run `project switch` after restart if needed.
- **Priority** = `high` | `medium` | `low`. `list` sorts by priority (high first).
- **Status** = `active` | `paused` | `done`. `list` shows only `active`.
## Edge cases
- `project add` with existing slug → error, do not overwrite.
- `project show` / `project status` / `project next` / `project note` with
missing slug → "Project '<slug>' not found."
- Missing `## Poznámky` or `## Další krok` → agent creates the section.
- Empty `projects/` → `list` returns empty array.

View File

@@ -0,0 +1,184 @@
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = ["pyyaml"]
# ///
"""
project.py — backend for /project skill.
Deterministic CRUD for project markdown files with YAML frontmatter.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import date
from pathlib import Path
import yaml
WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent
PROJECTS_DIR = WORKSPACE / "projects"
# Valid statuses and priorities
STATUSES = {"active", "paused", "done"}
PRIORITIES = {"high", "medium", "low"}
def _slugify(name: str) -> str:
"""Kebab-case slug from first few words of name. Max 4 words."""
words = re.sub(r"[^a-zA-Z0-9\s]", "", name).lower().split()
words = words[:4]
return "-".join(words) if words else "project"
def _list_projects() -> list[dict]:
"""Parse frontmatter from all .md files in projects/."""
if not PROJECTS_DIR.exists():
return []
projects = []
for path in sorted(PROJECTS_DIR.glob("*.md")):
text = path.read_text(encoding="utf-8")
frontmatter, _ = _split_frontmatter(text)
if frontmatter:
meta = yaml.safe_load(frontmatter) or {}
meta["_file"] = path.name
projects.append(meta)
return projects
def _split_frontmatter(text: str) -> tuple[str | None, str]:
"""Split YAML frontmatter from body. Returns (frontmatter_yaml, body)."""
if not text.startswith("---\n"):
return None, text
end = text.find("\n---\n", 4)
if end == -1:
return None, text
return text[4:end], text[end + 5 :]
def _load_file(slug: str) -> tuple[Path, str, str | None, str]:
"""Load project file. Returns (path, full_text, frontmatter_yaml, body)."""
path = PROJECTS_DIR / f"{slug}.md"
if not path.exists():
raise FileNotFoundError(f"Project '{slug}' not found ({path.name})")
text = path.read_text(encoding="utf-8")
fm, body = _split_frontmatter(text)
return path, text, fm, body
def _write_file(path: Path, frontmatter: dict, body: str) -> None:
"""Write project file with YAML frontmatter."""
fm_yaml = yaml.safe_dump(frontmatter, allow_unicode=True, sort_keys=False, default_flow_style=False)
path.write_text(f"---\n{fm_yaml}---\n{body}", encoding="utf-8")
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
def cmd_add(args: argparse.Namespace) -> int:
name = (args.name or "").strip()
if not name:
print(json.dumps({"error": "name must not be empty"}), file=sys.stderr)
return 1
slug = _slugify(name)
PROJECTS_DIR.mkdir(parents=True, exist_ok=True)
path = PROJECTS_DIR / f"{slug}.md"
if path.exists():
print(json.dumps({"error": f"project '{slug}' already exists"}), file=sys.stderr)
return 1
priority = (args.priority or "medium").lower()
if priority not in PRIORITIES:
print(json.dumps({"error": f"invalid priority '{priority}' — use high/medium/low"}), file=sys.stderr)
return 1
frontmatter = {
"status": "active",
"priority": priority,
"created": date.today().isoformat(),
"slug": slug,
}
body = f"# {name}\n\n## Poznámky\n\n## Další krok\n\n"
_write_file(path, frontmatter, body)
print(json.dumps({"added": {"slug": slug, "name": name, "path": str(path.relative_to(WORKSPACE))}}, ensure_ascii=False))
return 0
def cmd_list(_args: argparse.Namespace) -> int:
projects = _list_projects()
active = [p for p in projects if p.get("status") == "active"]
# Sort by priority: high > medium > low
priority_order = {"high": 0, "medium": 1, "low": 2}
active.sort(key=lambda p: priority_order.get(p.get("priority", "medium"), 1))
print(json.dumps({"projects": active}, ensure_ascii=False))
return 0
def cmd_show(args: argparse.Namespace) -> int:
slug = (args.slug or "").strip()
try:
_path, text, _fm, _body = _load_file(slug)
except FileNotFoundError as exc:
print(json.dumps({"error": str(exc)}), file=sys.stderr)
return 1
print(text)
return 0
def cmd_status(args: argparse.Namespace) -> int:
slug = (args.slug or "").strip()
new_status = (args.status or "").strip().lower()
if new_status not in STATUSES:
print(json.dumps({"error": f"invalid status '{new_status}' — use active/paused/done"}), file=sys.stderr)
return 1
try:
path, text, fm_yaml, body = _load_file(slug)
except FileNotFoundError as exc:
print(json.dumps({"error": str(exc)}), file=sys.stderr)
return 1
if not fm_yaml:
print(json.dumps({"error": "no frontmatter found"}), file=sys.stderr)
return 1
frontmatter = yaml.safe_load(fm_yaml) or {}
frontmatter["status"] = new_status
_write_file(path, frontmatter, body)
print(json.dumps({"updated": {"slug": slug, "status": new_status}}, ensure_ascii=False))
return 0
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(description="Project file backend")
sub = parser.add_subparsers(dest="command", required=True)
p_add = sub.add_parser("add", help="Create a new project")
p_add.add_argument("name", help="Project name")
p_add.add_argument("--priority", default="medium", help="Priority: high/medium/low")
sub.add_parser("list", help="List active projects")
p_show = sub.add_parser("show", help="Show full project file")
p_show.add_argument("slug", help="Project slug")
p_status = sub.add_parser("status", help="Change project status")
p_status.add_argument("slug", help="Project slug")
p_status.add_argument("status", help="New status: active/paused/done")
args = parser.parse_args()
dispatch = {"add": cmd_add, "list": cmd_list, "show": cmd_show, "status": cmd_status}
return dispatch[args.command](args)
if __name__ == "__main__":
sys.exit(main())

64
skills/python/SKILL.md Normal file
View File

@@ -0,0 +1,64 @@
---
name: python
description: >
Python coding conventions, style, and tooling.
Use for anything involving Python code.
---
# Python Coding Conventions
## Tooling
- **Always use `uv`** — never bare `pip`, `python`, `venv`, or `virtualenv`.
- Run code: `uv run script.py` (or `uv run python -m module`)
- Add dependencies: `uv add <pkg>`; sync: `uv sync`
- One-off tools: `uv run --with <pkg> ...` or `uvx <tool>`
- **Format before done:** `uv run ruff format`
- **Lint before done:** `uv run ruff check --fix`
- Treat "done" as: formatted, linted clean, type hints present.
## Core Principles
- **Readability first** — code must be easily readable and understandable at a glance
- **Simplicity** — prefer the simplest solution that solves the problem; avoid unnecessary abstractions and cleverness
- **Clean Code** — meaningful names, small focused functions, single responsibility, no duplication (DRY), clear intent
## Style
- Follow PEP 8, but max line length **120 characters** (not the default 88)
## Types and Annotations
- Use Python 3.12+ built-in generics: `list[str]`, `dict[str, int]`, `tuple[int, ...]`
- Use `X | Y` instead of `Union[X, Y]`, `str | None` instead of `Optional[str]`
- Do not import from `typing` unless truly necessary (e.g., `Protocol`, `TypeVar`)
- All public functions and methods must have type hints
## Docstrings and Comments
- Add a docstring or comment only when it explains **intent** not obvious from the code or signature
- First line: short imperative summary; omit parameter/return docs if self-explanatory
- Prefer clear naming over explanatory comments; never restate what the code does
## Functions
- Break complex functions into smaller ones; one thing at one level of abstraction
- Keep the parameter count low (03)
- No boolean flag arguments — split into two well-named functions or use an enum
- Command-Query Separation: a function that returns a value must not mutate state
- Handle edge cases explicitly; prefer specific exceptions over bare `except`
## Control Flow
- Fail fast — validate inputs up front with guard clauses and early returns
- Avoid deep nesting (max 23 levels); invert conditions to return early
- Replace magic numbers and strings with named constants
## Error Handling
- Never silently swallow exceptions
- Do not unnecessarily wrap exceptions in other exception types
## Paths
- Prefer `pathlib.Path` over `os.path`

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