Zalohovani vsech podstatnych souboru
This commit is contained in:
33
skills/article/SKILL.md
Normal file
33
skills/article/SKILL.md
Normal 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**: 1–3 odstavce plynulé prózy, každý 2–4 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:** 1–2 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
43
skills/bash/SKILL.md
Normal 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
91
skills/bookmark/SKILL.md
Normal 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`
|
||||
226
skills/bookmark/scripts/bookmark.py
Normal file
226
skills/bookmark/scripts/bookmark.py
Normal 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()
|
||||
89
skills/deep-research/SKILL.md
Normal file
89
skills/deep-research/SKILL.md
Normal 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 **3–6 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 2–4 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í
|
||||
2–4 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
108
skills/detach/SKILL.md
Normal 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**: 3–5 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._
|
||||
78
skills/detach/architecture.md
Normal file
78
skills/detach/architecture.md
Normal 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
|
||||
BIN
skills/detach/scripts/__pycache__/create-task.cpython-314.pyc
Normal file
BIN
skills/detach/scripts/__pycache__/create-task.cpython-314.pyc
Normal file
Binary file not shown.
BIN
skills/detach/scripts/__pycache__/tasks-daemon.cpython-312.pyc
Normal file
BIN
skills/detach/scripts/__pycache__/tasks-daemon.cpython-312.pyc
Normal file
Binary file not shown.
BIN
skills/detach/scripts/__pycache__/tasks-daemon.cpython-314.pyc
Normal file
BIN
skills/detach/scripts/__pycache__/tasks-daemon.cpython-314.pyc
Normal file
Binary file not shown.
BIN
skills/detach/scripts/__pycache__/tasks_common.cpython-313.pyc
Normal file
BIN
skills/detach/scripts/__pycache__/tasks_common.cpython-313.pyc
Normal file
Binary file not shown.
BIN
skills/detach/scripts/__pycache__/tasks_common.cpython-314.pyc
Normal file
BIN
skills/detach/scripts/__pycache__/tasks_common.cpython-314.pyc
Normal file
Binary file not shown.
53
skills/detach/scripts/archive-tasks.py
Normal file
53
skills/detach/scripts/archive-tasks.py
Normal 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())
|
||||
83
skills/detach/scripts/create-task.py
Executable file
83
skills/detach/scripts/create-task.py
Executable 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())
|
||||
41
skills/detach/scripts/list-tasks.py
Executable file
41
skills/detach/scripts/list-tasks.py
Executable 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()
|
||||
61
skills/detach/scripts/read-task.py
Executable file
61
skills/detach/scripts/read-task.py
Executable 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()
|
||||
170
skills/detach/scripts/tasks-daemon.py
Executable file
170
skills/detach/scripts/tasks-daemon.py
Executable 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())
|
||||
241
skills/detach/scripts/tasks_common.py
Normal file
241
skills/detach/scripts/tasks_common.py
Normal 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"
|
||||
)
|
||||
9
skills/detach/systemd/tasks-daemon.path
Normal file
9
skills/detach/systemd/tasks-daemon.path
Normal 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
|
||||
18
skills/detach/systemd/tasks-daemon.service
Normal file
18
skills/detach/systemd/tasks-daemon.service
Normal 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
|
||||
Binary file not shown.
Binary file not shown.
5
skills/detach/tests/conftest.py
Normal file
5
skills/detach/tests/conftest.py
Normal 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"))
|
||||
490
skills/detach/tests/test_tasks_common.py
Normal file
490
skills/detach/tests/test_tasks_common.py
Normal 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
10
skills/grill-me/SKILL.md
Normal 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
77
skills/keep/SKILL.md
Normal 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 5–15 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
88
skills/note/SKILL.md
Normal 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
200
skills/note/scripts/note.py
Normal 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
96
skills/plan/SKILL.md
Normal 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 1–3.** 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
88
skills/project/SKILL.md
Normal 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.
|
||||
184
skills/project/scripts/project.py
Normal file
184
skills/project/scripts/project.py
Normal 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
64
skills/python/SKILL.md
Normal 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 (0–3)
|
||||
- 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 2–3 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`
|
||||
693
skills/remind/IMPROVEMENTS_REPORT.md
Normal file
693
skills/remind/IMPROVEMENTS_REPORT.md
Normal file
@@ -0,0 +1,693 @@
|
||||
# /remind Skill — Codebase Analysis & Improvement Report
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
The /remind skill consists of three scripts (`remind_edit.py`, `remind_send.py`, `random_times.py`) plus tests. The `random_times.py` module is well-structured and tested. The two main scripts (`remind_edit.py`, `remind_send.py`) suffer from:
|
||||
|
||||
- Manual YAML string construction instead of proper serialization
|
||||
- No tests at all
|
||||
- Missing core features (list, edit, deduplication, dry-run)
|
||||
- Race conditions and data-loss risks
|
||||
- One-time reminders firing repeatedly within the same minute
|
||||
|
||||
This report identifies 20+ concrete improvements with code examples.
|
||||
|
||||
---
|
||||
|
||||
## 2. Critical Issues
|
||||
|
||||
### 2.1 One-time `at` reminders fire repeatedly (BUG)
|
||||
|
||||
`remind_send.py` uses a 60-second window:
|
||||
|
||||
```python
|
||||
def should_fire(candidate: datetime, now: datetime) -> bool:
|
||||
return abs((now - candidate).total_seconds()) < 60
|
||||
```
|
||||
|
||||
With a 1-minute cron, an `at: "2026-06-02T09:20:00"` reminder fires at 09:20:00 **and** 09:20:01..09:20:59 if the cron job happens to run multiple times or with slight delay. The log shows this:
|
||||
|
||||
```
|
||||
2026-06-02T09:20:01 cedule proti kouření ve výtahu
|
||||
```
|
||||
|
||||
Only one line, but if the cron ran twice in the same minute, it would duplicate.
|
||||
|
||||
**Fix:** Track fired one-time reminders in a state file, or narrow the window to `<= 30` and ensure the cron runs at :00.
|
||||
|
||||
```python
|
||||
# Better: stateful deduplication for one-time reminders
|
||||
FIRED_STATE_PATH = Path(__file__).parent.parent.parent / "db" / "remind_fired.sqlite"
|
||||
|
||||
# Or simpler: narrow window + minute-level dedup via log check
|
||||
```
|
||||
|
||||
### 2.2 Non-atomic YAML writes = data loss risk
|
||||
|
||||
`remind_edit.py` writes directly to `reminder.yaml`:
|
||||
|
||||
```python
|
||||
with open(REMINDER_FILE, "w") as f:
|
||||
yaml.dump(data, f)
|
||||
```
|
||||
|
||||
If the process crashes mid-write, the file is truncated/corrupted.
|
||||
|
||||
**Fix:** Atomic write via temp file + rename:
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
def atomic_write(path: Path, data: dict, yaml: YAML) -> None:
|
||||
tmp = path.with_suffix(".tmp")
|
||||
with open(tmp, "w") as f:
|
||||
yaml.dump(data, f)
|
||||
os.replace(tmp, path)
|
||||
```
|
||||
|
||||
### 2.3 Concurrent edit + send = race condition
|
||||
|
||||
`remind_send.py` reads `reminder.yaml` every minute. `remind_edit.py` writes to it. No file locking means the reader could get a partially-written file.
|
||||
|
||||
**Fix:** Use `filelock` (already available via uv) or atomic writes (above) + read retry.
|
||||
|
||||
### 2.4 `remind_edit.py` has no `list` command (advertised but missing)
|
||||
|
||||
`SKILL.md` documents `list` and `remove` commands, but `remind_edit.py` only implements `add` and `remove`. There is no `list`.
|
||||
|
||||
**Fix:** Add `list` to `main()`:
|
||||
|
||||
```python
|
||||
elif command == "list":
|
||||
for i, r in enumerate(data.get("reminders", []), 1):
|
||||
print(f"{i}. {r.get('text', '(no text)')}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Code Quality — Shorten & Improve
|
||||
|
||||
### 3.1 Remove custom `LiteralScalarString` (redundant)
|
||||
|
||||
`remind_edit.py` defines:
|
||||
|
||||
```python
|
||||
class LiteralScalarString(str):
|
||||
__slots__ = ()
|
||||
```
|
||||
|
||||
ruamel.yaml already provides `ruamel.yaml.scalarstring.LiteralScalarString`. The custom class is unnecessary and confusing.
|
||||
|
||||
**Fix:**
|
||||
|
||||
```python
|
||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||
```
|
||||
|
||||
### 3.2 `format_reminder` manually builds YAML (fragile)
|
||||
|
||||
Current code concatenates strings to produce YAML:
|
||||
|
||||
```python
|
||||
def format_reminder(text, schedule):
|
||||
lines = [f"- text: {text}"]
|
||||
for key, value in schedule.items():
|
||||
if isinstance(value, list):
|
||||
lines.append(f" {key}:")
|
||||
for item in value:
|
||||
lines.append(f" - {item}")
|
||||
else:
|
||||
lines.append(f" {key}: {value}")
|
||||
return "\n".join(lines)
|
||||
```
|
||||
|
||||
This breaks on special characters (quotes, colons, newlines in text), doesn't handle indentation consistently, and duplicates YAML serialization logic.
|
||||
|
||||
**Fix:** Build a dict and let ruamel.yaml serialize it:
|
||||
|
||||
```python
|
||||
def build_reminder(text: str, schedule: dict) -> dict:
|
||||
reminder = {"text": LiteralScalarString(text)}
|
||||
for key, value in schedule.items():
|
||||
if key in ("at", "at_times", "cron_exprs") and isinstance(value, list):
|
||||
reminder[key] = [LiteralScalarString(v) for v in value]
|
||||
elif key in ("at", "window") and isinstance(value, str):
|
||||
reminder[key] = LiteralScalarString(value)
|
||||
else:
|
||||
reminder[key] = value
|
||||
return reminder
|
||||
```
|
||||
|
||||
Then append to `data["reminders"]` and dump the whole document.
|
||||
|
||||
### 3.3 `parse_schedule` is a long if-elif chain
|
||||
|
||||
```python
|
||||
def parse_schedule(args):
|
||||
if not args:
|
||||
return {"cron_exprs": ["0 9 * * *"]}
|
||||
elif args[0] == "at":
|
||||
...
|
||||
elif args[0] == "times":
|
||||
...
|
||||
elif args[0] == "cron":
|
||||
...
|
||||
else:
|
||||
...
|
||||
```
|
||||
|
||||
**Fix:** Dispatch table:
|
||||
|
||||
```python
|
||||
SCHEDULE_PARSERS = {
|
||||
"at": lambda args: {"at": args[1]},
|
||||
"times": lambda args: {"at_times": args[1:]},
|
||||
"cron": lambda args: {"cron_exprs": args[1:]},
|
||||
}
|
||||
|
||||
def parse_schedule(args: list[str]) -> dict:
|
||||
if not args:
|
||||
return {"cron_exprs": ["0 9 * * *"]}
|
||||
parser = SCHEDULE_PARSERS.get(args[0])
|
||||
if parser:
|
||||
return parser(args)
|
||||
# fallback: treat all args as cron expressions
|
||||
return {"cron_exprs": args}
|
||||
```
|
||||
|
||||
### 3.4 `remove_reminder` dual-match logic is confusing
|
||||
|
||||
```python
|
||||
def remove_reminder(data, text):
|
||||
reminders = data.get("reminders", [])
|
||||
for i, reminder in enumerate(reminders):
|
||||
if reminder.get("text") == text:
|
||||
del reminders[i]
|
||||
return True
|
||||
for i, reminder in enumerate(reminders):
|
||||
if text.lower() in reminder.get("text", "").lower():
|
||||
del reminders[i]
|
||||
return True
|
||||
return False
|
||||
```
|
||||
|
||||
This silently falls back to substring match, which could delete the wrong reminder.
|
||||
|
||||
**Fix:** Be explicit. Support exact match and `--grep` flag:
|
||||
|
||||
```python
|
||||
def remove_reminder(data: dict, text: str, grep: bool = False) -> bool:
|
||||
reminders = data.get("reminders", [])
|
||||
for i, reminder in enumerate(reminders):
|
||||
reminder_text = reminder.get("text", "")
|
||||
if (not grep and reminder_text == text) or (grep and text.lower() in reminder_text.lower()):
|
||||
del reminders[i]
|
||||
return True
|
||||
return False
|
||||
```
|
||||
|
||||
### 3.5 `main()` in `remind_edit.py` is a big if-elif
|
||||
|
||||
**Fix:** Same dispatch pattern:
|
||||
|
||||
```python
|
||||
COMMANDS = {
|
||||
"add": cmd_add,
|
||||
"remove": cmd_remove,
|
||||
"list": cmd_list,
|
||||
}
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
if not args:
|
||||
print("Usage: ...")
|
||||
sys.exit(1)
|
||||
cmd = COMMANDS.get(args[0])
|
||||
if not cmd:
|
||||
print(f"Unknown command: {args[0]}")
|
||||
sys.exit(1)
|
||||
cmd(args[1:])
|
||||
```
|
||||
|
||||
### 3.6 `remind_send.py` `should_fire` window too wide
|
||||
|
||||
With 1-minute cron granularity, a 60-second window allows double-firing if there's any jitter. Use 30 seconds:
|
||||
|
||||
```python
|
||||
def should_fire(candidate: datetime, now: datetime, window_sec: int = 30) -> bool:
|
||||
delta = (now - candidate).total_seconds()
|
||||
return 0 <= delta < window_sec
|
||||
```
|
||||
|
||||
This also ensures we only fire **after** the scheduled time, not before (which `abs()` allowed).
|
||||
|
||||
### 3.7 `remind_send.py` catches bare `Exception`
|
||||
|
||||
```python
|
||||
except Exception as e:
|
||||
print(f"Error sending reminder: {e}", file=sys.stderr)
|
||||
```
|
||||
|
||||
**Fix:** Catch specific exceptions (`telegram.error.TelegramError`, `NetworkError`).
|
||||
|
||||
### 3.8 `sys.path.insert` hacks in both scripts
|
||||
|
||||
Both scripts do:
|
||||
|
||||
```python
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
```
|
||||
|
||||
This is a code smell. Since these are run via `uv run`, they should either:
|
||||
- Be part of a proper Python package with `__init__.py`
|
||||
- Or use `PYTHONPATH` in the cron job
|
||||
- Or import via relative imports if refactored into a package
|
||||
|
||||
**Fix:** Add a `pyproject.toml` in `skills/remind/` declaring the scripts directory as part of the package, or set `PYTHONPATH` in the cron:
|
||||
|
||||
```cron
|
||||
* * * * * PYTHONPATH=/home/nanobot/.nanobot/workspace/skills/remind/scripts uv run /home/nanobot/.nanobot/workspace/skills/remind/scripts/remind_send.py
|
||||
```
|
||||
|
||||
Then use normal imports: `from random_times import compute_fire_times`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Missing Functionality
|
||||
|
||||
### 4.1 No `list` command in `remind_edit.py`
|
||||
|
||||
Users cannot view reminders without `cat reminder.yaml`.
|
||||
|
||||
### 4.2 No `edit` command
|
||||
|
||||
To change a reminder, users must remove and re-add. An `edit` command would be useful:
|
||||
|
||||
```python
|
||||
def edit_reminder(data: dict, old_text: str, new_text: str, new_schedule: dict | None = None) -> bool:
|
||||
for reminder in data.get("reminders", []):
|
||||
if reminder.get("text") == old_text:
|
||||
reminder["text"] = new_text
|
||||
if new_schedule:
|
||||
# Remove old schedule keys, add new ones
|
||||
for key in list(reminder.keys()):
|
||||
if key != "text":
|
||||
del reminder[key]
|
||||
reminder.update(new_schedule)
|
||||
return True
|
||||
return False
|
||||
```
|
||||
|
||||
### 4.3 No deduplication / "fired" tracking for one-time reminders
|
||||
|
||||
`at` and `at_times` reminders should fire exactly once. Currently they rely on the 60s window and cron granularity.
|
||||
|
||||
**Fix:** SQLite state tracking:
|
||||
|
||||
```python
|
||||
# db/remind_state.sqlite
|
||||
# table fired (text TEXT, fired_at TEXT PRIMARY KEY)
|
||||
```
|
||||
|
||||
Or simpler: append a `fired:` list to each reminder in `reminder.yaml` (but this modifies user data). Better: separate state file.
|
||||
|
||||
### 4.4 No dry-run mode in `remind_send.py`
|
||||
|
||||
Users cannot preview what would fire without actually sending Telegram messages.
|
||||
|
||||
**Fix:** Add `--dry-run` flag:
|
||||
|
||||
```python
|
||||
if dry_run:
|
||||
print(f"[DRY-RUN] Would fire: {text} at {now}")
|
||||
else:
|
||||
fire_reminder(text)
|
||||
```
|
||||
|
||||
### 4.5 No way to see today's schedule
|
||||
|
||||
Users can't ask "what reminders do I have today?"
|
||||
|
||||
**Fix:** Add a `today` or `schedule` command to `remind_edit.py` that computes and prints all fire times for the current day.
|
||||
|
||||
### 4.6 No support for disabling reminders
|
||||
|
||||
Users must delete reminders to stop them. A `disabled: true` flag would be useful.
|
||||
|
||||
### 4.7 No validation before write
|
||||
|
||||
`remind_edit.py` doesn't validate that the produced YAML is loadable by `remind_send.py`. A malformed entry could break the cron job silently.
|
||||
|
||||
**Fix:** After building the reminder dict, run it through `random_times.compute_fire_times` (if it has `random`) or `croniter` (if it has `cron_exprs`) to validate:
|
||||
|
||||
```python
|
||||
def validate_reminder(reminder: dict) -> None:
|
||||
if "random" in reminder:
|
||||
compute_fire_times(date.today(), reminder["text"], reminder["random"])
|
||||
if "cron_exprs" in reminder:
|
||||
for expr in reminder["cron_exprs"]:
|
||||
croniter(expr)
|
||||
```
|
||||
|
||||
### 4.8 No backup before edit
|
||||
|
||||
**Fix:** Keep last N backups:
|
||||
|
||||
```python
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
|
||||
def backup_reminders(path: Path) -> None:
|
||||
backup = path.with_suffix(f".yaml.{datetime.now():%Y%m%d%H%M%S}.bak")
|
||||
shutil.copy2(path, backup)
|
||||
```
|
||||
|
||||
### 4.9 `random_times.py` lacks step syntax in days parser
|
||||
|
||||
Cron supports `*/2`, `1-5/2`. `_parse_days` doesn't handle this.
|
||||
|
||||
**Fix:**
|
||||
|
||||
```python
|
||||
def _parse_days(spec: object) -> set[int]:
|
||||
text = str(spec).strip()
|
||||
if text == "*":
|
||||
return set(range(7))
|
||||
result: set[int] = set()
|
||||
for part in text.split(","):
|
||||
part = part.strip()
|
||||
step = 1
|
||||
if "/" in part:
|
||||
part, step_str = part.split("/", 1)
|
||||
step = int(step_str)
|
||||
if "-" in part:
|
||||
low_str, high_str = part.split("-", 1)
|
||||
low, high = int(low_str), int(high_str)
|
||||
result.update(_normalize_dow(d) for d in range(low, high + 1, step))
|
||||
else:
|
||||
result.add(_normalize_dow(int(part)))
|
||||
return result
|
||||
```
|
||||
|
||||
### 4.10 No `__main__` guard in `random_times.py`
|
||||
|
||||
Not critical since it's a library, but good practice.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing Gaps
|
||||
|
||||
| Component | Tests? | Coverage |
|
||||
|-----------|--------|----------|
|
||||
| `random_times.py` | Yes | Good (determinism, gaps, filters, errors) |
|
||||
| `remind_edit.py` | **No** | Zero |
|
||||
| `remind_send.py` | **No** | Zero |
|
||||
|
||||
### 5.1 Tests needed for `remind_edit.py`
|
||||
|
||||
- `parse_schedule` with all input variants
|
||||
- `build_reminder` / `format_reminder` roundtrip
|
||||
- `remove_reminder` exact vs substring
|
||||
- YAML dump/load roundtrip preserves formatting
|
||||
- Atomic write doesn't corrupt file
|
||||
|
||||
### 5.2 Tests needed for `remind_send.py`
|
||||
|
||||
- `should_fire` boundary conditions
|
||||
- `fire_reminder` with mocked Telegram bot
|
||||
- `main` with mocked `reminder.yaml` and mocked bot
|
||||
- One-time reminder deduplication
|
||||
- Random reminder integration with `random_times`
|
||||
|
||||
### 5.3 Test infrastructure
|
||||
|
||||
`conftest.py` only adds `sys.path`. It should also provide fixtures:
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def sample_yaml(tmp_path):
|
||||
path = tmp_path / "reminder.yaml"
|
||||
path.write_text("reminders:\n- text: test\n at: 2026-06-01T10:00:00\n")
|
||||
return path
|
||||
|
||||
@pytest.fixture
|
||||
def mock_bot(monkeypatch):
|
||||
class FakeBot:
|
||||
def send_message(self, chat_id, text):
|
||||
self.last_call = (chat_id, text)
|
||||
bot = FakeBot()
|
||||
monkeypatch.setattr("remind_send.Bot", lambda token: bot)
|
||||
return bot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Architecture Improvements
|
||||
|
||||
### 6.1 Consolidate into a single CLI
|
||||
|
||||
The user has considered consolidating remind into a single script. Current split:
|
||||
- `remind_edit.py` = user-facing CLI
|
||||
- `remind_send.py` = cron daemon
|
||||
- `random_times.py` = shared library
|
||||
|
||||
This split is actually reasonable. But `remind_edit.py` and `remind_send.py` share no code. Consider extracting common YAML I/O:
|
||||
|
||||
```python
|
||||
# remind_common.py
|
||||
from pathlib import Path
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
REMINDER_FILE = Path(__file__).parent.parent.parent / "reminder.yaml"
|
||||
|
||||
def load_reminders() -> dict:
|
||||
yaml = YAML()
|
||||
yaml.preserve_quotes = True
|
||||
with open(REMINDER_FILE) as f:
|
||||
return yaml.load(f) or {"reminders": []}
|
||||
|
||||
def save_reminders(data: dict) -> None:
|
||||
yaml = YAML()
|
||||
yaml.default_flow_style = False
|
||||
yaml.indent(mapping=2, sequence=4, offset=2)
|
||||
atomic_write(REMINDER_FILE, data, yaml)
|
||||
```
|
||||
|
||||
### 6.2 Use SQLite for state (not YAML)
|
||||
|
||||
The user is evaluating SQLite vs YAML for remind data storage. Current YAML approach:
|
||||
- **Pros:** Human-readable, easy to edit by hand, version-control friendly
|
||||
- **Cons:** No schema validation, race conditions, no querying, append-only log is separate
|
||||
|
||||
**Recommendation:** Keep YAML for the reminder definitions (human-editable), but use SQLite for runtime state (fired tracking, history query):
|
||||
|
||||
```python
|
||||
# db/remind_state.sqlite
|
||||
CREATE TABLE fired (
|
||||
id INTEGER PRIMARY KEY,
|
||||
text TEXT NOT NULL,
|
||||
scheduled_at TEXT NOT NULL,
|
||||
fired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_scheduled ON fired(scheduled_at);
|
||||
```
|
||||
|
||||
This gives:
|
||||
- Exact-once firing for one-time reminders
|
||||
- Queryable history ("when did X last fire?")
|
||||
- No modification to `reminder.yaml`
|
||||
|
||||
### 6.3 Refactor `remind_send.py` into a class
|
||||
|
||||
Current procedural style makes testing hard. A class-based design:
|
||||
|
||||
```python
|
||||
class ReminderEngine:
|
||||
def __init__(self, yaml_path: Path, bot: Bot | None = None, dry_run: bool = False):
|
||||
self.yaml_path = yaml_path
|
||||
self.bot = bot
|
||||
self.dry_run = dry_run
|
||||
self.now = datetime.now(TIMEZONE)
|
||||
|
||||
def load(self) -> list[dict]:
|
||||
...
|
||||
|
||||
def should_fire(self, candidate: datetime) -> bool:
|
||||
...
|
||||
|
||||
def fire(self, text: str) -> None:
|
||||
...
|
||||
|
||||
def run(self) -> list[str]:
|
||||
fired = []
|
||||
for reminder in self.load():
|
||||
for candidate in self.candidates(reminder):
|
||||
if self.should_fire(candidate) and not self.already_fired(reminder, candidate):
|
||||
self.fire(reminder["text"])
|
||||
fired.append(reminder["text"])
|
||||
return fired
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Specific Code Examples
|
||||
|
||||
### 7.1 Atomic write for `remind_edit.py`
|
||||
|
||||
```python
|
||||
import os
|
||||
from pathlib import Path
|
||||
from tempfile import mkstemp
|
||||
|
||||
def atomic_write_yaml(path: Path, data: dict, yaml: YAML) -> None:
|
||||
fd, tmp = mkstemp(dir=path.parent, suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
yaml.dump(data, f)
|
||||
os.replace(tmp, path)
|
||||
except Exception:
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
```
|
||||
|
||||
### 7.2 Proper `LiteralScalarString` usage
|
||||
|
||||
```python
|
||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||
|
||||
def build_reminder(text: str, schedule: dict) -> dict:
|
||||
reminder = {"text": LiteralScalarString(text)}
|
||||
for key, value in schedule.items():
|
||||
if isinstance(value, list):
|
||||
reminder[key] = [LiteralScalarString(v) for v in value]
|
||||
elif isinstance(value, str):
|
||||
reminder[key] = LiteralScalarString(value)
|
||||
else:
|
||||
reminder[key] = value
|
||||
return reminder
|
||||
```
|
||||
|
||||
### 7.3 Deduplication for one-time reminders
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
|
||||
STATE_DB = Path(__file__).parent.parent.parent / "db" / "remind_state.sqlite"
|
||||
|
||||
def ensure_state_db() -> None:
|
||||
STATE_DB.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(STATE_DB)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS fired (
|
||||
text TEXT NOT NULL,
|
||||
scheduled_at TEXT NOT NULL,
|
||||
fired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (text, scheduled_at)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def already_fired(text: str, scheduled_at: datetime) -> bool:
|
||||
conn = sqlite3.connect(STATE_DB)
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM fired WHERE text = ? AND scheduled_at = ?",
|
||||
(text, scheduled_at.isoformat())
|
||||
).fetchone()
|
||||
conn.close()
|
||||
return row is not None
|
||||
|
||||
def record_fired(text: str, scheduled_at: datetime) -> None:
|
||||
conn = sqlite3.connect(STATE_DB)
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO fired (text, scheduled_at) VALUES (?, ?)",
|
||||
(text, scheduled_at.isoformat())
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
```
|
||||
|
||||
### 7.4 Narrowed `should_fire` + dedup
|
||||
|
||||
```python
|
||||
def should_fire(candidate: datetime, now: datetime, window_sec: int = 30) -> bool:
|
||||
delta = (now - candidate).total_seconds()
|
||||
return 0 <= delta < window_sec
|
||||
|
||||
# In main loop for one-time reminders:
|
||||
if "at" in reminder:
|
||||
candidate = parse_at(reminder["at"])
|
||||
if should_fire(candidate, now) and not already_fired(text, candidate):
|
||||
fire_reminder(text)
|
||||
record_fired(text, candidate)
|
||||
```
|
||||
|
||||
### 7.5 `remind_edit.py` with dispatch table
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from ruamel.yaml import YAML
|
||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||
|
||||
from random_times import compute_fire_times
|
||||
from croniter import croniter
|
||||
|
||||
REMINDER_FILE = Path(__file__).parent.parent.parent / "reminder.yaml"
|
||||
|
||||
# --- commands ---
|
||||
|
||||
def cmd_add(args: list[str]) -> None:
|
||||
text = " ".join(args)
|
||||
schedule = parse_schedule([]) # default cron
|
||||
add_reminder(text, schedule)
|
||||
|
||||
def cmd_remove(args: list[str]) -> None:
|
||||
text = " ".join(args)
|
||||
remove_reminder(text)
|
||||
|
||||
def cmd_list(_args: list[str]) -> None:
|
||||
data = load_reminders()
|
||||
for i, r in enumerate(data.get("reminders", []), 1):
|
||||
print(f"{i}. {r.get('text', '(no text)')}")
|
||||
|
||||
COMMANDS = {
|
||||
"add": cmd_add,
|
||||
"remove": cmd_remove,
|
||||
"list": cmd_list,
|
||||
}
|
||||
|
||||
def main() -> None:
|
||||
args = sys.argv[1:]
|
||||
if not args or args[0] not in COMMANDS:
|
||||
print(f"Usage: {sys.argv[0]} [{'|'.join(COMMANDS)}] ...")
|
||||
sys.exit(1)
|
||||
COMMANDS[args[0]](args[1:])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Prioritized Action Plan
|
||||
|
||||
| Priority | Task | Effort | Impact |
|
||||
|----------|------|--------|--------|
|
||||
| **P0** | Fix one-time reminder double-firing (narrow window + dedup) | Small | High — prevents spam |
|
||||
| **P0** | Add atomic writes to `remind_edit.py` | Small | High — prevents data loss |
|
||||
| **P1** | Add `list` command to `remind_edit.py` | Small | Medium — advertised feature |
|
||||
| **P1** | Replace custom `LiteralScalarString` with ruamel's | Tiny | Low — code cleanliness |
|
||||
| **P1** | Replace manual YAML string building with dict+dump | Medium | High — robustness |
|
||||
| **P1** | Add validation before write | Small | Medium — catches errors early |
|
||||
| **P2** | Add tests for `remind_edit.py` and `remind_send.py` | Medium | High — enables refactoring |
|
||||
| **P2** | Extract common YAML I/O to `remind_common.py` | Small | Medium — DRY |
|
||||
| **P2** | Add `--dry-run` to `remind_send.py` | Small | Medium — safer testing |
|
||||
| **P3** | Add SQLite state tracking for fired reminders | Medium | Medium — exact-once, queryable history |
|
||||
| **P3** | Add `edit` command | Small | Low — convenience |
|
||||
| **P3** | Add `disabled` flag | Small | Low — convenience |
|
||||
| **P3** | Support cron step syntax in `_parse_days` | Small | Low — completeness |
|
||||
|
||||
---
|
||||
|
||||
## 9. Summary
|
||||
|
||||
The `random_times.py` module is solid. The main pain points are in `remind_edit.py` (manual YAML construction, no atomic writes, missing commands) and `remind_send.py` (double-firing risk, no deduplication, no tests). The highest-impact fixes are: (1) atomic YAML writes, (2) one-time reminder deduplication, and (3) replacing manual YAML string building with proper serialization. Adding tests for the two untested scripts is essential before any major refactoring.
|
||||
71
skills/remind/SKILL.md
Normal file
71
skills/remind/SKILL.md
Normal file
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: remind
|
||||
description: >-
|
||||
Create recurring reminders for tasks. Use when the user wants to set up a
|
||||
reminder for something they need to do regularly, or when they mention tasks
|
||||
they keep forgetting. Also handles listing and removing reminders. Triggers on
|
||||
words like "remind", "reminder".
|
||||
---
|
||||
|
||||
# Remind
|
||||
|
||||
Create, list, and manage recurring reminders for tasks.
|
||||
|
||||
## CRUD Script
|
||||
|
||||
All mutations to `reminder.yaml` go through `scripts/remind_edit.py` (paths in this skill are relative to the skill directory).
|
||||
|
||||
Run via: `uv run scripts/remind_edit.py <subcommand>`
|
||||
|
||||
Subcommands:
|
||||
|
||||
- **`list`** — prints JSON `{"reminders": [...]}`.
|
||||
- **`add --text "..." --cron "EXPR" [--cron "EXPR"]`** — add recurring reminder; validates cron syntax.
|
||||
- **`add --text "..." --at "ISO_DATETIME" [--at "ISO_DATETIME"]`** — add one-time reminder(s); `--at` is repeatable.
|
||||
- **`add --text "..." --at "ISO" --cron "EXPR"`** — combine one-time and recurring times in one entry.
|
||||
- **`add --text "..." --random-times-per-day N --random-window "HH:MM-HH:MM" [--random-days "1-5"] [--random-from "YYYY-MM-DD"] [--random-until "YYYY-MM-DD"]`** — random but deterministic times: fires `N` times per day at random moments inside the window. Use when the user wants something a few times a day without a fixed clock time (e.g. "remind me to drink water a few times during the day"). `--random-days` is a cron day-of-week filter; `--random-from` / `--random-until` bound the active period. Minimum gap between fires is a fixed constant in `scripts/random_times.py`. Combinable with `--at` / `--cron`.
|
||||
- **`remove --keyword "..."`** — removes by case-insensitive substring match. Returns error JSON if 0 or >1 matches.
|
||||
|
||||
All outputs are JSON. Errors go to stderr with non-zero exit code.
|
||||
|
||||
## Create Workflow
|
||||
|
||||
1. **Identify the task** — What does the user want to be reminded about? If unclear, ask.
|
||||
2. **Check for duplicates** — Run `remind_edit.py list` and compare existing reminder texts against the new one. If a similar reminder already exists:
|
||||
- Show the user the existing reminder
|
||||
- Ask whether they really want a duplicate, or want to modify the existing one
|
||||
- Only proceed if the user explicitly confirms
|
||||
3. **Determine frequency** — Ask how often the reminder should fire. Suggest common options:
|
||||
- Every N minutes/hours/days
|
||||
- Specific time of day (e.g. "every weekday at 9am")
|
||||
- Specific day of week/month
|
||||
- One-time at a specific datetime
|
||||
- A few times a day at random moments (use the `--random-*` flags)
|
||||
4. **Create the cron expression(s) or `at` field** — Map user input to cron syntax for recurring reminders, or ISO datetime for one-time reminders.
|
||||
5. **Add via script** — Run a single `add` call combining all times (see CRUD Script for the exact flags). **Never call `add` multiple times for the same task** — put all times into one call.
|
||||
6. **Confirm** — Show the user what was created (text, schedule).
|
||||
|
||||
## List Workflow
|
||||
|
||||
1. Run `uv run remind_edit.py list` and parse the JSON output.
|
||||
2. Present all reminders in a table with columns: number, task, schedule.
|
||||
3. Convert each schedule to human-readable text **in the user's language** (e.g. "every day at 9:00", "every Tuesday at 9:00"). For a `random` block, describe it like "5× a day at random between 9:00–21:00, Mon–Fri" (include `days`/`from`/`until` only if present).
|
||||
4. If `reminders` is empty, say so.
|
||||
|
||||
## Remove / Done Workflow
|
||||
|
||||
1. Run `uv run remind_edit.py remove --keyword "..."`.
|
||||
2. If exit code is non-zero, read the error JSON:
|
||||
- `"no match"` → tell the user no reminder matches the keyword.
|
||||
- `"ambiguous"` → show the matches and ask the user to be more specific.
|
||||
3. If success, confirm what was removed.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Respond to the user in their own language** (e.g. Czech) — this skill is written in English, but user-facing messages adapt to the user's language.
|
||||
- **Never edit `reminder.yaml` directly** — no `edit_file`, `write_file`, or any direct write. All mutations go exclusively through `scripts/remind_edit.py`.
|
||||
- **Read via the `list` subcommand** — never read the YAML file directly; always `remind_edit.py list`.
|
||||
- Always confirm the reminder text and frequency with the user before creating.
|
||||
- When listing, always show a human-readable schedule.
|
||||
- Completed or removed reminders are deleted from `reminder.yaml` entirely — no `done` field, no `status` field.
|
||||
- Timezone is always `Europe/Prague` unless the user explicitly requests otherwise.
|
||||
39
skills/remind/reminder.example.yaml
Normal file
39
skills/remind/reminder.example.yaml
Normal file
@@ -0,0 +1,39 @@
|
||||
# Example reminder.yaml — shows every schedule type at a glance.
|
||||
# This is documentation only; the live file is managed via scripts/remind_edit.py.
|
||||
# Timezone is always Europe/Prague.
|
||||
|
||||
reminders:
|
||||
# One-time reminder.
|
||||
- text: "call the dentist"
|
||||
at: "2026-06-10T10:00:00"
|
||||
|
||||
# Several one-time reminders in one entry.
|
||||
- text: "order shoes"
|
||||
at_times:
|
||||
- "2026-06-01T10:00:00"
|
||||
- "2026-06-01T11:00:00"
|
||||
|
||||
# Recurring via cron expressions.
|
||||
- text: "pay the membership fee"
|
||||
cron_exprs:
|
||||
- "0 9 * * *"
|
||||
- "0 14 * * *"
|
||||
|
||||
# Random but deterministic times: N fires per day inside a window, spaced at
|
||||
# least MIN_GAP_MIN apart (constant in scripts/random_times.py). The times are
|
||||
# derived from (date, text), so they are stable for a given day yet vary daily.
|
||||
- text: "drink water / stretch"
|
||||
random:
|
||||
times_per_day: 5 # required: how many fires per day (int >= 1)
|
||||
window: "09:00-21:00" # required: daily time window HH:MM-HH:MM (start < end)
|
||||
days: "1-5" # optional: cron day-of-week filter (0/7=Sun, 1=Mon..6=Sat); default every day
|
||||
from: "2026-06-01" # optional: start date, inclusive; omitted = active immediately
|
||||
until: "2026-12-31" # optional: end date, inclusive; omitted = no end
|
||||
|
||||
# Fields can be combined freely in one entry.
|
||||
- text: "water the plants"
|
||||
cron_exprs:
|
||||
- "0 19 * * *"
|
||||
random:
|
||||
times_per_day: 2
|
||||
window: "08:00-12:00"
|
||||
BIN
skills/remind/scripts/__pycache__/random_times.cpython-313.pyc
Normal file
BIN
skills/remind/scripts/__pycache__/random_times.cpython-313.pyc
Normal file
Binary file not shown.
122
skills/remind/scripts/random_times.py
Normal file
122
skills/remind/scripts/random_times.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Deterministic random fire-time computation for reminders.
|
||||
|
||||
Shared by remind_send.py (runtime) and remind_edit.py (validation). Stdlib only,
|
||||
so it imports cleanly regardless of the caller's uv/PEP 723 environment.
|
||||
|
||||
A reminder's `random` block produces `times_per_day` fire times inside a daily
|
||||
`window`, spaced at least MIN_GAP_MIN apart. The times are random but fully
|
||||
determined by (date, text): any script computing them for the same day gets the
|
||||
same result, so no state needs to be persisted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from datetime import date, datetime, time
|
||||
|
||||
MIN_GAP_MIN = 15 # minimum gap between fire times in minutes; tune here
|
||||
|
||||
|
||||
def compute_fire_times(target_date: date, text: str, cfg: dict) -> list[datetime]:
|
||||
"""Deterministic fire times for one day.
|
||||
|
||||
Returns [] when the day falls outside the days/from/until filters. Raises
|
||||
ValueError on a malformed config (bad window, days, dates, or when the
|
||||
requested count cannot fit the window with MIN_GAP_MIN spacing) — these are
|
||||
structural and validated before any date filter, so the same call validates
|
||||
a config regardless of the date passed in.
|
||||
"""
|
||||
count = _parse_count(cfg.get("times_per_day"))
|
||||
start, end = _parse_window(cfg.get("window"))
|
||||
day_set = _parse_days(cfg["days"]) if cfg.get("days") is not None else None
|
||||
from_date = _parse_date(cfg["from"]) if cfg.get("from") is not None else None
|
||||
until_date = _parse_date(cfg["until"]) if cfg.get("until") is not None else None
|
||||
|
||||
total = end - start
|
||||
required = (count - 1) * MIN_GAP_MIN
|
||||
if required > total:
|
||||
raise ValueError(
|
||||
f"{count} times with a {MIN_GAP_MIN}-min gap need {required} min, "
|
||||
f"but the window is only {total} min wide"
|
||||
)
|
||||
|
||||
if from_date is not None and target_date < from_date:
|
||||
return []
|
||||
if until_date is not None and target_date > until_date:
|
||||
return []
|
||||
if day_set is not None and _cron_weekday(target_date) not in day_set:
|
||||
return []
|
||||
|
||||
slack = total - required
|
||||
rnd = random.Random(f"{target_date.isoformat()}|{text}")
|
||||
offsets = sorted(rnd.randint(0, slack) for _ in range(count))
|
||||
minutes = [start + offset + index * MIN_GAP_MIN for index, offset in enumerate(offsets)]
|
||||
return [datetime.combine(target_date, _minute_to_time(m)) for m in minutes]
|
||||
|
||||
|
||||
def _parse_count(raw: object) -> int:
|
||||
if not isinstance(raw, int) or isinstance(raw, bool) or raw < 1:
|
||||
raise ValueError(f"times_per_day must be an int >= 1, got {raw!r}")
|
||||
return raw
|
||||
|
||||
|
||||
def _parse_window(raw: object) -> tuple[int, int]:
|
||||
if not isinstance(raw, str) or "-" not in raw:
|
||||
raise ValueError(f"window must be 'HH:MM-HH:MM', got {raw!r}")
|
||||
start_str, end_str = raw.split("-", 1)
|
||||
start = _hhmm_to_minutes(start_str.strip())
|
||||
end = _hhmm_to_minutes(end_str.strip())
|
||||
if start >= end:
|
||||
raise ValueError(f"window start must be before end: {raw!r}")
|
||||
return start, end
|
||||
|
||||
|
||||
def _hhmm_to_minutes(value: str) -> int:
|
||||
parts = value.split(":")
|
||||
if len(parts) != 2:
|
||||
raise ValueError(f"invalid time {value!r}, expected HH:MM")
|
||||
hours, minutes = int(parts[0]), int(parts[1])
|
||||
if not (0 <= hours < 24 and 0 <= minutes < 60):
|
||||
raise ValueError(f"time out of range: {value!r}")
|
||||
return hours * 60 + minutes
|
||||
|
||||
|
||||
def _minute_to_time(total_minutes: int) -> time:
|
||||
return time(total_minutes // 60, total_minutes % 60)
|
||||
|
||||
|
||||
def _parse_date(raw: object) -> date:
|
||||
if isinstance(raw, datetime):
|
||||
return raw.date()
|
||||
if isinstance(raw, date):
|
||||
return raw
|
||||
return date.fromisoformat(str(raw))
|
||||
|
||||
|
||||
def _parse_days(spec: object) -> set[int]:
|
||||
"""Parse a cron day-of-week spec into a set of cron weekdays (0/7=Sun, 1=Mon..6=Sat)."""
|
||||
text = str(spec).strip()
|
||||
if text == "*":
|
||||
return set(range(7))
|
||||
result: set[int] = set()
|
||||
for part in text.split(","):
|
||||
part = part.strip()
|
||||
if "-" in part:
|
||||
low_str, high_str = part.split("-", 1)
|
||||
low, high = int(low_str), int(high_str)
|
||||
result.update(_normalize_dow(day) for day in range(low, high + 1))
|
||||
else:
|
||||
result.add(_normalize_dow(int(part)))
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_dow(value: int) -> int:
|
||||
"""cron allows 7 for Sunday; normalize it to 0."""
|
||||
if not 0 <= value <= 7:
|
||||
raise ValueError(f"day-of-week out of range (0-7): {value}")
|
||||
return 0 if value == 7 else value
|
||||
|
||||
|
||||
def _cron_weekday(target: date) -> int:
|
||||
"""Map Python weekday (Mon=0..Sun=6) to cron weekday (Sun=0, Mon=1..Sat=6)."""
|
||||
return (target.weekday() + 1) % 7
|
||||
171
skills/remind/scripts/remind_edit.py
Executable file
171
skills/remind/scripts/remind_edit.py
Executable file
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["croniter", "pyyaml"]
|
||||
# ///
|
||||
"""Deterministic CRUD for reminder.yaml.
|
||||
|
||||
CLI tool for LLM skills to create, list, and remove reminders atomically.
|
||||
Never edits reminder.yaml directly — always writes to a .tmp file and renames.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from croniter import croniter
|
||||
from random_times import compute_fire_times
|
||||
|
||||
WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent # .../workspace
|
||||
REMINDER_YAML = WORKSPACE / "reminder.yaml"
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
if not REMINDER_YAML.exists():
|
||||
return {"reminders": []}
|
||||
data = yaml.safe_load(REMINDER_YAML.read_text(encoding="utf-8")) or {}
|
||||
if "reminders" not in data:
|
||||
data["reminders"] = []
|
||||
return data
|
||||
|
||||
|
||||
def _save(data: dict) -> None:
|
||||
tmp = REMINDER_YAML.with_suffix(".yaml.tmp")
|
||||
tmp.write_text(
|
||||
yaml.safe_dump(data, allow_unicode=True, sort_keys=False, default_flow_style=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(tmp, REMINDER_YAML)
|
||||
|
||||
|
||||
def cmd_list(_args: argparse.Namespace) -> int:
|
||||
data = _load()
|
||||
print(json.dumps({"reminders": data["reminders"]}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_add(args: argparse.Namespace) -> int:
|
||||
text = (args.text or "").strip()
|
||||
if not text:
|
||||
print(json.dumps({"error": "text must not be empty"}), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
random_cfg = _build_random(args)
|
||||
except ValueError as exc:
|
||||
print(json.dumps({"error": str(exc)}), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not args.at and not args.cron and not random_cfg:
|
||||
print(json.dumps({"error": "provide --cron, --at, or --random-* options"}), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
item: dict = {"text": text}
|
||||
|
||||
if args.at:
|
||||
for at_str in args.at:
|
||||
try:
|
||||
datetime.fromisoformat(at_str)
|
||||
except ValueError as exc:
|
||||
print(json.dumps({"error": f"invalid --at datetime: {exc}"}), file=sys.stderr)
|
||||
return 1
|
||||
if len(args.at) == 1:
|
||||
item["at"] = args.at[0]
|
||||
else:
|
||||
item["at_times"] = args.at
|
||||
|
||||
if args.cron:
|
||||
for expr in args.cron:
|
||||
if not croniter.is_valid(expr):
|
||||
print(json.dumps({"error": f"invalid cron expression: {expr!r}"}), file=sys.stderr)
|
||||
return 1
|
||||
item["cron_exprs"] = args.cron
|
||||
|
||||
if random_cfg:
|
||||
item["random"] = random_cfg
|
||||
|
||||
data = _load()
|
||||
data["reminders"].append(item)
|
||||
_save(data)
|
||||
print(json.dumps({"added": item}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def _build_random(args: argparse.Namespace) -> dict | None:
|
||||
"""Assemble and validate the random schedule block, or None if no --random-* flag given."""
|
||||
fields = {
|
||||
"times_per_day": args.random_times_per_day,
|
||||
"window": args.random_window,
|
||||
"days": args.random_days,
|
||||
"from": args.random_from,
|
||||
"until": args.random_until,
|
||||
}
|
||||
if all(value is None for value in fields.values()):
|
||||
return None
|
||||
if fields["times_per_day"] is None or fields["window"] is None:
|
||||
raise ValueError("random schedule needs --random-times-per-day and --random-window")
|
||||
|
||||
cfg = {key: value for key, value in fields.items() if value is not None}
|
||||
compute_fire_times(date(2000, 1, 1), "validation", cfg) # raises ValueError on a bad config
|
||||
return cfg
|
||||
|
||||
|
||||
def cmd_remove(args: argparse.Namespace) -> int:
|
||||
keyword = (args.keyword or "").strip().lower()
|
||||
if not keyword:
|
||||
print(json.dumps({"error": "keyword must not be empty"}), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
data = _load()
|
||||
matches = [r for r in data["reminders"] if keyword in (r.get("text") or "").lower()]
|
||||
|
||||
if len(matches) == 0:
|
||||
print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if len(matches) > 1:
|
||||
print(
|
||||
json.dumps({"error": "ambiguous", "matches": [{"text": m["text"]} for m in matches]}, ensure_ascii=False),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
removed = matches[0]
|
||||
data["reminders"] = [r for r in data["reminders"] if r is not removed]
|
||||
_save(data)
|
||||
print(json.dumps({"removed": removed}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="CRUD for reminder.yaml")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
sub.add_parser("list", help="List all reminders as JSON")
|
||||
|
||||
add_p = sub.add_parser("add", help="Add a new reminder")
|
||||
add_p.add_argument("--text", required=True, help="Reminder text")
|
||||
add_p.add_argument("--cron", action="append", metavar="EXPR", help="Cron expression (repeatable)")
|
||||
add_p.add_argument("--at", action="append", metavar="ISO_DATETIME", help="One-time datetime ISO 8601 (repeatable, combinable with --cron)")
|
||||
add_p.add_argument("--random-times-per-day", type=int, dest="random_times_per_day", metavar="N", help="Random schedule: fires per day")
|
||||
add_p.add_argument("--random-window", dest="random_window", metavar="HH:MM-HH:MM", help="Random schedule: daily time window")
|
||||
add_p.add_argument("--random-days", dest="random_days", metavar="DOW", help="Random schedule: cron day-of-week filter, e.g. '1-5' (optional)")
|
||||
add_p.add_argument("--random-from", dest="random_from", metavar="YYYY-MM-DD", help="Random schedule: start date, inclusive (optional)")
|
||||
add_p.add_argument("--random-until", dest="random_until", metavar="YYYY-MM-DD", help="Random schedule: end date, inclusive (optional)")
|
||||
|
||||
remove_p = sub.add_parser("remove", help="Remove a reminder by keyword")
|
||||
remove_p.add_argument("--keyword", required=True, help="Substring to match against reminder text")
|
||||
|
||||
args = parser.parse_args()
|
||||
dispatch = {"list": cmd_list, "add": cmd_add, "remove": cmd_remove}
|
||||
sys.exit(dispatch[args.command](args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
150
skills/remind/scripts/remind_send.py
Normal file
150
skills/remind/scripts/remind_send.py
Normal file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["croniter", "pyyaml"]
|
||||
# ///
|
||||
"""Deterministic reminder sender.
|
||||
|
||||
Runs every minute from the nanobot user crontab (NOT through the agent).
|
||||
Reads reminder.yaml, finds reminders due this minute, sends each directly to
|
||||
Telegram via the Bot API, appends the delivery to reminder.log, and dedups via
|
||||
.reminder_state.json so each scheduled fire is delivered exactly once.
|
||||
|
||||
No LLM and no nanobot process involved on purpose -- see knowledge.md/history
|
||||
for why the previous agent-driven cron job spammed empty-output messages.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import yaml
|
||||
from croniter import croniter
|
||||
from random_times import compute_fire_times
|
||||
|
||||
WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent # .../workspace
|
||||
REMINDER_YAML = WORKSPACE / "reminder.yaml"
|
||||
STATE_FILE = WORKSPACE / ".reminder_state.json"
|
||||
LOG_DIR = WORKSPACE / "log"
|
||||
LOG_FILE = LOG_DIR / "reminder.log"
|
||||
CONFIG = Path.home() / ".nanobot" / "config.json"
|
||||
|
||||
TZ = ZoneInfo("Europe/Prague")
|
||||
CHAT_ID = "8826147089" # Telegram user id (Martin); same target the old cron job used
|
||||
|
||||
|
||||
def _telegram_token() -> str:
|
||||
data = json.loads(CONFIG.read_text(encoding="utf-8"))
|
||||
return data["channels"]["telegram"]["token"]
|
||||
|
||||
|
||||
def _send_telegram(text: str) -> None:
|
||||
token = _telegram_token()
|
||||
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||
payload = urllib.parse.urlencode({"chat_id": CHAT_ID, "text": text}).encode()
|
||||
req = urllib.request.Request(url, data=payload, method="POST")
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
resp.read()
|
||||
|
||||
|
||||
def _load_state() -> dict:
|
||||
if STATE_FILE.exists():
|
||||
try:
|
||||
data = json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def _key(text: str) -> str:
|
||||
return hashlib.sha1(text.encode("utf-8")).hexdigest()[:8]
|
||||
|
||||
|
||||
def _due_fire(item: dict, now: datetime) -> datetime | None:
|
||||
"""Most recent scheduled fire-time within the last 60s, or None."""
|
||||
fire: datetime | None = None
|
||||
|
||||
at_str = item.get("at")
|
||||
if at_str:
|
||||
at_time = datetime.fromisoformat(at_str).replace(tzinfo=None)
|
||||
if 0 <= (now - at_time).total_seconds() < 60:
|
||||
fire = at_time
|
||||
|
||||
for at_str in item.get("at_times", []):
|
||||
at_time = datetime.fromisoformat(at_str).replace(tzinfo=None)
|
||||
if 0 <= (now - at_time).total_seconds() < 60 and (fire is None or at_time > fire):
|
||||
fire = at_time
|
||||
|
||||
for expr in item.get("cron_exprs", []):
|
||||
prev = croniter(expr, now).get_prev(datetime)
|
||||
if 0 <= (now - prev).total_seconds() < 60 and (fire is None or prev > fire):
|
||||
fire = prev
|
||||
|
||||
random_cfg = item.get("random")
|
||||
if random_cfg:
|
||||
try:
|
||||
for ft in compute_fire_times(now.date(), (item.get("text") or "").strip(), random_cfg):
|
||||
if 0 <= (now - ft).total_seconds() < 60 and (fire is None or ft > fire):
|
||||
fire = ft
|
||||
except ValueError as exc: # malformed config: skip this reminder, keep others working
|
||||
print(f"remind_send: bad random config for {item.get('text')!r}: {exc}", file=sys.stderr)
|
||||
|
||||
return fire
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not REMINDER_YAML.exists():
|
||||
return
|
||||
|
||||
data = yaml.safe_load(REMINDER_YAML.read_text(encoding="utf-8")) or {}
|
||||
now = datetime.now(TZ).replace(tzinfo=None)
|
||||
|
||||
state = _load_state()
|
||||
fresh: dict[str, str] = {}
|
||||
|
||||
for item in data.get("reminders", []):
|
||||
text = (item.get("text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
key = _key(text)
|
||||
last = state.get(key)
|
||||
|
||||
fire = _due_fire(item, now)
|
||||
if fire is None:
|
||||
if last: # preserve dedup info for reminders not due this minute
|
||||
fresh[key] = last
|
||||
continue
|
||||
|
||||
fire_iso = fire.isoformat()
|
||||
if last == fire_iso: # this exact fire was already delivered
|
||||
fresh[key] = last
|
||||
continue
|
||||
|
||||
try:
|
||||
_send_telegram(f"⏰ Reminder: {text}")
|
||||
except Exception as e: # leave state untouched so next run retries
|
||||
print(f"remind_send: delivery failed for {text!r}: {e}", file=sys.stderr)
|
||||
if last:
|
||||
fresh[key] = last
|
||||
continue
|
||||
|
||||
ts = datetime.now(TZ).replace(tzinfo=None).isoformat(timespec="seconds")
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with LOG_FILE.open("a", encoding="utf-8") as f:
|
||||
f.write(f"{ts} {text}\n")
|
||||
fresh[key] = fire_iso
|
||||
|
||||
if fresh != state:
|
||||
STATE_FILE.write_text(json.dumps(fresh, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
Binary file not shown.
5
skills/remind/tests/conftest.py
Normal file
5
skills/remind/tests/conftest.py
Normal file
@@ -0,0 +1,5 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# random_times.py lives in the sibling scripts/ directory.
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||
96
skills/remind/tests/test_random_times.py
Normal file
96
skills/remind/tests/test_random_times.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from random_times import MIN_GAP_MIN, compute_fire_times
|
||||
|
||||
# Window 09:00-21:00 = minutes 540..1260 -> 720 min wide.
|
||||
WINDOW = "09:00-21:00"
|
||||
WINDOW_START = datetime(2026, 3, 21, 9, 0)
|
||||
WINDOW_END = datetime(2026, 3, 21, 21, 0)
|
||||
|
||||
|
||||
def cfg(**overrides) -> dict:
|
||||
base = {"times_per_day": 5, "window": WINDOW}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_deterministic():
|
||||
day = date(2026, 3, 21)
|
||||
assert compute_fire_times(day, "drink water", cfg()) == compute_fire_times(day, "drink water", cfg())
|
||||
|
||||
|
||||
def test_differs_per_text():
|
||||
day = date(2026, 3, 21)
|
||||
assert compute_fire_times(day, "drink water", cfg()) != compute_fire_times(day, "stretch", cfg())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [1, 2, 5, 10])
|
||||
def test_count_matches_times_per_day(count):
|
||||
times = compute_fire_times(date(2026, 3, 21), "x", cfg(times_per_day=count))
|
||||
assert len(times) == count
|
||||
|
||||
|
||||
def test_min_gap_respected():
|
||||
times = compute_fire_times(date(2026, 3, 21), "x", cfg(times_per_day=8))
|
||||
for earlier, later in zip(times, times[1:]):
|
||||
assert (later - earlier).total_seconds() >= MIN_GAP_MIN * 60
|
||||
|
||||
|
||||
def test_within_window():
|
||||
for fire in compute_fire_times(date(2026, 3, 21), "x", cfg()):
|
||||
assert WINDOW_START <= fire <= WINDOW_END
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"day,expected",
|
||||
[
|
||||
(date(2026, 3, 23), True), # Monday
|
||||
(date(2026, 3, 27), True), # Friday
|
||||
(date(2026, 3, 28), False), # Saturday
|
||||
(date(2026, 3, 29), False), # Sunday
|
||||
],
|
||||
)
|
||||
def test_days_weekday_filter(day, expected):
|
||||
times = compute_fire_times(day, "x", cfg(days="1-5"))
|
||||
assert bool(times) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"spec,day,expected",
|
||||
[
|
||||
("*", date(2026, 3, 28), True), # Saturday allowed by wildcard
|
||||
("0", date(2026, 3, 29), True), # Sunday as 0
|
||||
("7", date(2026, 3, 29), True), # Sunday as 7
|
||||
("1,3,5", date(2026, 3, 25), True), # Wednesday
|
||||
("1,3,5", date(2026, 3, 24), False), # Tuesday
|
||||
],
|
||||
)
|
||||
def test_days_parser(spec, day, expected):
|
||||
times = compute_fire_times(day, "x", cfg(days=spec))
|
||||
assert bool(times) == expected
|
||||
|
||||
|
||||
def test_from_until_filter():
|
||||
bounded = cfg(**{"from": "2026-06-01", "until": "2026-06-30"})
|
||||
assert compute_fire_times(date(2026, 5, 31), "x", bounded) == []
|
||||
assert compute_fire_times(date(2026, 7, 1), "x", bounded) == []
|
||||
assert len(compute_fire_times(date(2026, 6, 15), "x", bounded)) == 5
|
||||
|
||||
|
||||
def test_infeasible_count_raises():
|
||||
# 50 times * 15-min gap = 735 min required > 720 min window.
|
||||
with pytest.raises(ValueError):
|
||||
compute_fire_times(date(2026, 3, 21), "x", cfg(times_per_day=50))
|
||||
|
||||
|
||||
def test_bad_window_raises():
|
||||
with pytest.raises(ValueError):
|
||||
compute_fire_times(date(2026, 3, 21), "x", cfg(window="21:00-09:00"))
|
||||
|
||||
|
||||
def test_config_validated_before_date_filter():
|
||||
# Out-of-range date still surfaces a structural error rather than returning [].
|
||||
with pytest.raises(ValueError):
|
||||
compute_fire_times(date(2000, 1, 1), "x", cfg(times_per_day=50, until="1999-01-01"))
|
||||
Reference in New Issue
Block a user