Update projektu

This commit is contained in:
lachtan
2026-07-22 12:32:02 +02:00
parent 19014ed3d9
commit 8e66d6b92a
22 changed files with 1995 additions and 503 deletions

View File

@@ -1,6 +1,8 @@
---
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".
description: >
Use when the user wants to save a URL (with or without additional content)
to read later or search in. Triggers on "bookmark", "save URL".
---
# Bookmark
@@ -10,6 +12,7 @@ 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]
```
@@ -21,14 +24,47 @@ bookmark.py add <url> "<description>" [--tags tag1,tag2]
```
- `url` — the article URL
- `description` — short human-readable description (required)
- `description` — short human-readable description (required). If the user did not give one, generate a short (~1 sentence) description yourself — from the article text if you have it, otherwise from the URL.
- `--tags` — optional comma-separated tags
- `--content-file <path>` — optional; path to the cleaned article markdown to archive. `-` reads it from stdin. See "Saving an article's full text" below.
Example:
```bash
bookmark.py add "https://example.com/rust-async" "Async Rust patterns" --tags rust,async
```
### Saving an article's full text
When the user pastes a **large block of text** together with a URL (typically a whole page selected with Ctrl+A/Ctrl+C), treat that text as the **full article to archive**, not as the description. Pick the path by what the pasted content looks like:
**If the pasted content is raw HTML** (you see `<html>`, `<div>`, `<p>` tags, etc.), do **not** convert it yourself — pipe it through the `html_to_markdown.py` helper (it uses trafilatura to strip boilerplate and emit clean markdown) straight into `add`, so the converted text never passes through your context:
```bash
/home/nanobot/.local/bin/uv run /home/nanobot/.nanobot/workspace/skills/bookmark/scripts/html_to_markdown.py <<'HTML' \
| /home/nanobot/.local/bin/uv run /home/nanobot/.nanobot/workspace/skills/bookmark/scripts/bookmark.py add "<url>" "<description>" [--tags a,b] --content-file -
<raw html here>
HTML
```
If `add` does **not** report "article content stored" (trafilatura extracted nothing → empty content), fall back to cleaning the text yourself and storing it as below.
**If the pasted content is plain text or already markdown**, store it directly with `--content-file -` via a heredoc (one call, no shell-escaping of the body). Only clean it yourself **if you see obvious boilerplate** (copied menus, "Share"/"Tweet", cookie banners, footers) — otherwise store it as-is:
```bash
/home/nanobot/.local/bin/uv run /home/nanobot/.nanobot/workspace/skills/bookmark/scripts/bookmark.py \
add "<url>" "<description>" [--tags a,b] --content-file - <<'ARTICLE'
<article text / markdown here>
ARTICLE
```
In both cases:
- **Generate a description** (~1 sentence) from the article, unless the user gave one.
- **Confirm and echo.** After saving, tell the user it was stored and quote a short **verbatim** slice of what was archived (the title and first line or two, exactly as written) — not a re-summary — so they can see it worked.
**URL without pasted text:** try to fetch the article yourself via the `web` tool (Jina Reader returns clean markdown), then store it the same way. If the page is behind a **paywall** or the fetched text is **garbage/incomplete**, do **not** store that text (never fabricate the article body) — instead **ask the user to copy the whole article (Ctrl+A/Ctrl+C) and paste it**, briefly saying why (paywall / the fetched text looks incomplete). If they paste it, follow the paths above. If they decline or don't reply, save the bookmark without content and mark it `⚠ paywall/incomplete`.
### List unread bookmarks
```bash
@@ -41,7 +77,7 @@ Shows display ID, URL, tags, description, and date added for each unread bookmar
The `#1`, `#2`, … shown by `list` and `history` are **display IDs** — sequential positions, computed on the fly, never the internal DB id. They renumber whenever the set changes, so run `list`/`history` first if unsure.
- `read <n>` and `show <n>` take the display ID from **`list`** (the unread set).
- `read <n>`, `show <n>`, `content <n>`, and `delete <n>` take the display ID from **`list`** (the unread set).
- `unread <n>` takes the display ID from **`history`** (the read set).
A freshly added bookmark is always display `#1` in `list` (newest first).
@@ -68,7 +104,25 @@ bookmark.py unread <display-id>
bookmark.py show <display-id>
```
`<display-id>` is the number from `list`. Shows full URL, description, tags, status, and dates. Does **not** change any state.
`<display-id>` is the number from `list`. Shows full URL, description, tags, status, and dates. A `📄` marker means an archived article body is stored (read it with `content`). Does **not** change any state.
### Read stored article content
```bash
bookmark.py content <display-id>
```
`<display-id>` is the number from `list`. Prints the archived article markdown to stdout — render it for the user. If no full text was stored for that bookmark, it says so. Does **not** change any state.
### Delete a bookmark
```bash
bookmark.py delete <display-id>
```
`<display-id>` is the number from `list` (the unread set). Soft delete: the bookmark drops out of `list`/`history` but the row stays in the DB (recoverable by hand if ever needed). There is no `restore` command.
**Always confirm before deleting.** Display IDs renumber whenever the set changes, so a stale number can point at the wrong bookmark. First run `list`/`show`, tell the user exactly which bookmark you are about to delete (URL + description), and **wait for their explicit confirmation** — only then run `delete`. To delete a bookmark that is already read, `unread` it first (delete works on the unread set only).
### List read bookmarks (history)
@@ -86,17 +140,27 @@ When presenting bookmark lists or details to the user, **always use markdown lin
#3 [hackaday.com](https://hackaday.com/2026/06/02/linux-fu-taming-strace/) [linux, strace] — lepší strace
```
Format: `#<display-id> [<domain>](<url>) [<tags>] — <description>`
Format: `#<display-id> [<domain>](<url>) — <description> [<tags>]`
- Domain is clickable, pointing to the full URL
- Tags in brackets, comma-separated
- Description after em-dash
- Description after em-dash (most important, always shown)
- Tags in brackets, comma-separated (secondary, after description)
- A `📄` in `list`/`history`/`show` marks a bookmark with an archived article body — offer to open it with `content <display-id>`
- **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 <display-id>` (from `list`)
4. User finishes an article`read <display-id>` (from `list`)
5. User wants to revisit → `unread <display-id>` (from `history`) or `history`
2. User pastes a URL **and the full article text** → clean it to markdown and `add … --content-file -` (see "Saving an article's full text")
3. User wants to see what to read → `list`
4. User wants to see details of a bookmark`show <display-id>` (from `list`)
5. User wants to read an archived article → `content <display-id>` (from `list`)
6. User finishes an article → `read <display-id>` (from `list`)
7. User wants to revisit → `unread <display-id>` (from `history`) or `history`
8. User wants to remove one (e.g. accidental duplicate) → confirm, then `delete <display-id>` (from `list`)
## Known limitations
- No `edit`/`update` command — to change a description or tags, delete and re-add
- `delete` is soft-delete only (row stays in DB); no `restore` command
- Sites behind Cloudflare bot protection (PCTuning.cz, vtm.zive.cz, zive.cz) cannot be auto-fetched; ask user to paste full HTML manually

View File

@@ -4,6 +4,7 @@
import argparse
import json
import sqlite3
import sys
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
@@ -22,7 +23,9 @@ CREATE TABLE IF NOT EXISTS bookmarks (
description TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
read_at TEXT
read_at TEXT,
content TEXT,
deleted_at TEXT
);
"""
@@ -30,6 +33,16 @@ CREATE TABLE IF NOT EXISTS bookmarks (
def _init_db(conn: sqlite3.Connection) -> None:
conn.execute("PRAGMA journal_mode=WAL")
conn.executescript(SCHEMA)
_migrate(conn)
def _migrate(conn: sqlite3.Connection) -> None:
"""Add columns missing from a pre-existing DB (CREATE IF NOT EXISTS won't)."""
columns = {row["name"] for row in conn.execute("PRAGMA table_info(bookmarks)")}
if "content" not in columns:
conn.execute("ALTER TABLE bookmarks ADD COLUMN content TEXT")
if "deleted_at" not in columns:
conn.execute("ALTER TABLE bookmarks ADD COLUMN deleted_at TEXT")
@contextmanager
@@ -49,15 +62,16 @@ def _ordered_ids(conn: sqlite3.Connection, *, read: bool) -> list[int]:
Unread (`read=False`) is what `list` shows, read (`read=True`) what `history`
shows. Display IDs are 1-based positions here, computed on the fly — never
stored — so they renumber whenever the set changes.
stored — so they renumber whenever the set changes. Soft-deleted rows
(`deleted_at` set) are excluded from both sets.
"""
if read:
rows = conn.execute(
"SELECT id FROM bookmarks WHERE read_at IS NOT NULL ORDER BY read_at DESC"
"SELECT id FROM bookmarks WHERE read_at IS NOT NULL AND deleted_at IS NULL ORDER BY read_at DESC"
).fetchall()
else:
rows = conn.execute(
"SELECT id FROM bookmarks WHERE read_at IS NULL ORDER BY created_at DESC"
"SELECT id FROM bookmarks WHERE read_at IS NULL AND deleted_at IS NULL ORDER BY created_at DESC"
).fetchall()
return [row["id"] for row in rows]
@@ -94,13 +108,27 @@ def _domain(url: str) -> str:
return url
def _read_content(content_file: str | None) -> str | None:
"""Read cleaned article markdown from a file path, or stdin when path is '-'."""
if content_file is None:
return None
text = sys.stdin.read() if content_file == "-" else Path(content_file).read_text(encoding="utf-8")
return text.strip() or None
def _print_bookmark(
row: sqlite3.Row, display_id: int, *, show_status: bool = False, show_read_date: bool = False
row: sqlite3.Row,
display_id: int,
*,
show_status: bool = False,
show_read_date: bool = False,
has_content: bool = False,
) -> None:
"""Format and print a single bookmark row under its display ID."""
tags = json.loads(row["tags"])
tag_str = f" [{', '.join(tags)}]" if tags else ""
print(f"#{display_id} {_domain(row['url'])}{tag_str}")
content_marker = " 📄" if has_content else ""
print(f"#{display_id} {_domain(row['url'])}{tag_str}{content_marker}")
print(f" {row['description']}")
print(f" {row['url']}")
line = f" added: {row['created_at'][:10]}"
@@ -114,25 +142,29 @@ def _print_bookmark(
def cmd_add(args: argparse.Namespace) -> None:
tags = _parse_tags(args.tags)
content = _read_content(args.content_file)
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),
"INSERT INTO bookmarks (url, description, tags, created_at, content) VALUES (?, ?, ?, ?, ?)",
(args.url, args.description, json.dumps(tags, ensure_ascii=False), now, content),
)
conn.commit()
tag_info = f" [{', '.join(tags)}]" if tags else ""
content_info = " (article content stored)" if content else ""
# Newest unread sorts first, so a fresh bookmark is always display #1.
print(f"Added bookmark #1: {args.url}{tag_info}")
print(f"Added bookmark #1: {args.url}{tag_info}{content_info}")
def cmd_list(args: argparse.Namespace) -> None:
# Skip the (potentially large) content blob here — only whether it exists.
columns = "id, url, description, tags, created_at, read_at, content IS NOT NULL AS has_content"
with _connect() as conn:
display_by_id = {nid: i + 1 for i, nid in enumerate(_ordered_ids(conn, read=False))}
if args.tag:
rows = conn.execute(
"""SELECT * FROM bookmarks
WHERE read_at IS NULL AND EXISTS (
f"""SELECT {columns} FROM bookmarks
WHERE read_at IS NULL AND deleted_at IS NULL AND EXISTS (
SELECT 1 FROM json_each(tags) WHERE json_each.value = ?
)
ORDER BY created_at DESC""",
@@ -140,7 +172,7 @@ def cmd_list(args: argparse.Namespace) -> None:
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM bookmarks WHERE read_at IS NULL ORDER BY created_at DESC"
f"SELECT {columns} FROM bookmarks WHERE read_at IS NULL AND deleted_at IS NULL ORDER BY created_at DESC"
).fetchall()
if not rows:
@@ -152,7 +184,7 @@ def cmd_list(args: argparse.Namespace) -> None:
# Display IDs come from the full unread set so a tag-filtered list keeps the
# same numbers `read`/`show` resolve against (gaps are expected when filtered).
for r in rows:
_print_bookmark(r, display_by_id[r["id"]])
_print_bookmark(r, display_by_id[r["id"]], has_content=bool(r["has_content"]))
print()
@@ -186,13 +218,41 @@ def cmd_show(args: argparse.Namespace) -> None:
print(f"No unread bookmark #{args.id}.")
return
row = conn.execute("SELECT * FROM bookmarks WHERE id = ?", (internal_id,)).fetchone()
_print_bookmark(row, args.id, show_status=True)
_print_bookmark(row, args.id, show_status=True, has_content=row["content"] is not None)
def cmd_content(args: argparse.Namespace) -> None:
with _connect() as conn:
internal_id = _resolve_display_id(conn, args.id, read=False)
if internal_id is None:
print(f"No unread bookmark #{args.id}.")
return
row = conn.execute("SELECT content FROM bookmarks WHERE id = ?", (internal_id,)).fetchone()
if not row["content"]:
print(f"Bookmark #{args.id} has no stored article content.")
return
print(row["content"])
def cmd_delete(args: argparse.Namespace) -> None:
# Soft delete: set deleted_at so the row drops out of list/history but stays
# in the DB. The agent confirms with the user before calling this (see SKILL.md).
with _connect() as conn:
internal_id = _resolve_display_id(conn, args.id, read=False)
if internal_id is None:
print(f"No unread bookmark #{args.id}.")
return
now = datetime.now(timezone.utc).isoformat()
conn.execute("UPDATE bookmarks SET deleted_at = ? WHERE id = ?", (now, internal_id))
conn.commit()
print(f"Deleted bookmark #{args.id}.")
def cmd_history(args: argparse.Namespace) -> None:
columns = "id, url, description, tags, created_at, read_at, content IS NOT NULL AS has_content"
with _connect() as conn:
rows = conn.execute(
"SELECT * FROM bookmarks WHERE read_at IS NOT NULL ORDER BY read_at DESC"
f"SELECT {columns} FROM bookmarks WHERE read_at IS NOT NULL AND deleted_at IS NULL ORDER BY read_at DESC"
).fetchall()
if not rows:
@@ -200,7 +260,7 @@ def cmd_history(args: argparse.Namespace) -> None:
return
for display_id, r in enumerate(rows, start=1):
_print_bookmark(r, display_id, show_read_date=True)
_print_bookmark(r, display_id, show_read_date=True, has_content=bool(r["has_content"]))
print()
@@ -213,6 +273,12 @@ def main() -> None:
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")
p_add.add_argument(
"--content-file",
dest="content_file",
default=None,
help="Path to cleaned article markdown; '-' reads it from stdin",
)
# list
p_list = sub.add_parser("list", help="List unread bookmarks")
@@ -230,6 +296,14 @@ def main() -> None:
p_show = sub.add_parser("show", help="Show bookmark details")
p_show.add_argument("id", type=int, help="Display ID from `list`")
# content (print stored article markdown)
p_content = sub.add_parser("content", help="Print stored article markdown")
p_content.add_argument("id", type=int, help="Display ID from `list`")
# delete (soft delete)
p_delete = sub.add_parser("delete", help="Soft-delete a bookmark (hidden, kept in DB)")
p_delete.add_argument("id", type=int, help="Display ID from `list`")
# history (list read)
sub.add_parser("history", help="List read bookmarks")
@@ -239,6 +313,8 @@ def main() -> None:
"read": cmd_read,
"unread": cmd_unread,
"show": cmd_show,
"content": cmd_content,
"delete": cmd_delete,
"history": cmd_history,
}
args = parser.parse_args()

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["trafilatura"]
# ///
"""Extract an article from raw HTML and emit clean markdown (stdin -> stdout).
Used by the bookmark skill so the LLM does not have to convert/clean HTML itself:
html_to_markdown.py <<'HTML' | bookmark.py add "<url>" "<desc>" --content-file -
<raw html>
HTML
trafilatura strips boilerplate (navigation, ads, footers) and returns markdown.
Exit codes: 0 = markdown emitted, 1 = empty input, 2 = no article extracted.
"""
import sys
import trafilatura
EXIT_EMPTY_INPUT = 1
EXIT_NO_ARTICLE = 2
def main() -> int:
html = sys.stdin.read()
if not html.strip():
print("Empty input.", file=sys.stderr)
return EXIT_EMPTY_INPUT
markdown = trafilatura.extract(
html, output_format="markdown", include_links=True, include_images=True
)
if not markdown or not markdown.strip():
print("No article content extracted.", file=sys.stderr)
return EXIT_NO_ARTICLE
print(markdown)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,180 @@
"""Tests for bookmark.py — reading-list CRUD, article-content storage, migration."""
import io
import sqlite3
import subprocess
import sys
from pathlib import Path
import pytest
SCRIPTS = Path(__file__).resolve().parent.parent / "scripts"
sys.path.insert(0, str(SCRIPTS))
import bookmark # noqa: E402
HTML_TO_MARKDOWN = SCRIPTS / "html_to_markdown.py"
@pytest.fixture
def db(tmp_path, monkeypatch):
path = tmp_path / "db" / "bookmark.sqlite"
monkeypatch.setattr(bookmark, "DB_PATH", path)
return path
def _run(argv, stdin=None, monkeypatch=None):
monkeypatch.setattr(sys, "argv", ["bookmark.py"] + argv)
if stdin is not None:
monkeypatch.setattr(sys, "stdin", io.StringIO(stdin))
bookmark.main()
def _capture(capsys):
return capsys.readouterr().out
def test_add_stores_content_from_stdin(db, monkeypatch, capsys):
_run(["add", "https://ex.com/a", "Test", "--content-file", "-"],
stdin="# Title\n\nBody paragraph.\n", monkeypatch=monkeypatch)
assert "article content stored" in _capture(capsys)
conn = sqlite3.connect(db)
content = conn.execute("SELECT content FROM bookmarks").fetchone()[0]
conn.close()
assert content == "# Title\n\nBody paragraph."
def test_add_without_content_leaves_null(db, monkeypatch, capsys):
_run(["add", "https://ex.com/b", "No content"], monkeypatch=monkeypatch)
conn = sqlite3.connect(db)
content = conn.execute("SELECT content FROM bookmarks").fetchone()[0]
conn.close()
assert content is None
def test_content_command_prints_stored_markdown(db, monkeypatch, capsys):
_run(["add", "https://ex.com/a", "Test", "--content-file", "-"],
stdin="# Title\n\nBody.\n", monkeypatch=monkeypatch)
capsys.readouterr()
_run(["content", "1"], monkeypatch=monkeypatch)
assert "# Title\n\nBody." in _capture(capsys)
def test_content_command_reports_empty(db, monkeypatch, capsys):
_run(["add", "https://ex.com/b", "No content"], monkeypatch=monkeypatch)
capsys.readouterr()
_run(["content", "1"], monkeypatch=monkeypatch)
assert "no stored article content" in _capture(capsys)
def test_list_marks_only_rows_with_content(db, monkeypatch, capsys):
_run(["add", "https://ex.com/plain", "Plain"], monkeypatch=monkeypatch)
_run(["add", "https://ex.com/full", "Full", "--content-file", "-"],
stdin="# Doc\n\ntext\n", monkeypatch=monkeypatch)
capsys.readouterr()
_run(["list"], monkeypatch=monkeypatch)
out = _capture(capsys)
# Newest first: #1 is the one WITH content (has 📄), #2 plain (no marker).
lines = [ln for ln in out.splitlines() if ln.startswith("#")]
assert "📄" in lines[0]
assert "📄" not in lines[1]
def test_migration_adds_columns_to_legacy_db(db, monkeypatch, capsys):
db.parent.mkdir(parents=True)
conn = sqlite3.connect(db)
conn.executescript(
"""
CREATE TABLE 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
);
INSERT INTO bookmarks (url, description, tags, created_at)
VALUES ('https://old.com', 'legacy', '[]', '2026-01-01T00:00:00+00:00');
"""
)
conn.commit()
conn.close()
_run(["list"], monkeypatch=monkeypatch) # triggers migration
assert "legacy" in _capture(capsys)
conn = sqlite3.connect(db)
cols = {row[1] for row in conn.execute("PRAGMA table_info(bookmarks)")}
count = conn.execute("SELECT COUNT(*) FROM bookmarks").fetchone()[0]
conn.close()
assert "content" in cols
assert "deleted_at" in cols
assert count == 1
def test_delete_soft_hides_but_keeps_row(db, monkeypatch, capsys):
_run(["add", "https://ex.com/keep", "Keep"], monkeypatch=monkeypatch)
_run(["add", "https://ex.com/dup", "Dup"], monkeypatch=monkeypatch)
capsys.readouterr()
_run(["delete", "1"], monkeypatch=monkeypatch) # newest (dup) is #1
assert "Deleted bookmark #1" in _capture(capsys)
_run(["list"], monkeypatch=monkeypatch)
out = _capture(capsys)
assert "Keep" in out
assert "Dup" not in out
conn = sqlite3.connect(db)
total = conn.execute("SELECT COUNT(*) FROM bookmarks").fetchone()[0]
deleted = conn.execute(
"SELECT COUNT(*) FROM bookmarks WHERE deleted_at IS NOT NULL"
).fetchone()[0]
conn.close()
assert total == 2 # row kept in DB
assert deleted == 1
def test_delete_out_of_range(db, monkeypatch, capsys):
_run(["add", "https://ex.com/a", "One"], monkeypatch=monkeypatch)
capsys.readouterr()
_run(["delete", "9"], monkeypatch=monkeypatch)
assert "No unread bookmark #9" in _capture(capsys)
ARTICLE_HTML = """<!DOCTYPE html><html><head><title>EFI Boot</title></head><body>
<header><nav><a href="/">Home</a> <a href="/login">Login</a></nav></header>
<aside class="ad">Buy our product now! Special offer!</aside>
<main><article>
<h1>Understanding EFI Boot</h1>
<p>The EFI system partition stores the bootloader and kernel images that the
firmware loads at startup, which is a crucial part of the modern boot process.</p>
<h2>How it works</h2>
<p>When the machine powers on, the firmware reads the EFI variables and locates
the boot entry, then hands control to the kernel without a traditional bootloader.</p>
</article></main>
<footer>Copyright 2026 Example Inc. Share Tweet Subscribe to our newsletter.</footer>
</body></html>"""
def _run_html(html_input: str) -> subprocess.CompletedProcess:
return subprocess.run(
["uv", "run", str(HTML_TO_MARKDOWN)],
input=html_input,
capture_output=True,
text=True,
)
def test_html_to_markdown_extracts_clean_article():
result = _run_html(ARTICLE_HTML)
assert result.returncode == 0, result.stderr
assert "# Understanding EFI Boot" in result.stdout
assert "## How it works" in result.stdout
# Boilerplate must be gone.
assert "Buy our product" not in result.stdout
assert "Subscribe to our newsletter" not in result.stdout
def test_html_to_markdown_empty_input():
result = _run_html("")
assert result.returncode == 1

View File

@@ -10,6 +10,8 @@ description: >
Compact `memory/MEMORY.md` when it grows too large or stale. The skill reads `memory/MEMORY.md` plus `USER.md`, `SOUL.md`, and `keep.md` (all three in the workspace root) to detect duplicates and outdated context, but only edits `memory/MEMORY.md`.
This skill has no accompanying script — every step below is performed by you, the agent, directly with your file read/edit tools. There is nothing to `exec` or spawn.
## When to use
- User says "compact memory", "clean up MEMORY.md", "memory audit", or similar.

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["nanobot-ai"]
# ///
"""Nightly compact-memory auto run, triggered by the nanobot user crontab.
Runs the compact-memory skill in auto mode via the Nanobot Python API with a
fresh, never-reused `session_key` per invocation, then delivers the result
directly to Telegram. This bypasses nanobot's cron/jobs.json `agent_turn`
mechanism entirely, so the turn never gets appended to the user's live chat
session (which caused context contamination across nights) and needs no
delivery-gating evaluator (there is none for bound cron jobs in nanobot-ai
0.2.2 — whatever the agent answers would otherwise go straight to the chat).
Same pattern as skills/remind/scripts/remind_send.py and
skills/detach/scripts/tasks-daemon.py: external script, direct Telegram Bot
API delivery, no nanobot channel pipeline involved.
"""
from __future__ import annotations
import asyncio
import json
import sys
import traceback
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
from nanobot import Nanobot
CONFIG = Path.home() / ".nanobot" / "config.json"
FALLBACK_CHAT_ID = "8826147089"
TIMEOUT_SECONDS = 10 * 60
GOAL = (
"Read skills/compact-memory/SKILL.md and run its Auto mode to audit and "
"compact memory/MEMORY.md. Perform every step yourself using your file "
"tools — there is no script to run."
)
def _telegram_config() -> tuple[str, str]:
"""Return (bot token, chat id). Chat id reads channels.telegram.allowFrom[0], with a constant fallback."""
data = json.loads(CONFIG.read_text(encoding="utf-8"))
telegram = data["channels"]["telegram"]
allow_from = telegram.get("allowFrom") or []
chat_id = str(allow_from[0]) if allow_from else FALLBACK_CHAT_ID
return telegram["token"], chat_id
def _send_telegram(text: str, token: str, chat_id: str) -> None:
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()
async def _run_audit(session_key: str) -> str:
bot = Nanobot.from_config()
result = await bot.run(GOAL, session_key=session_key)
return result.content or ""
def main() -> int:
session_key = f"compact-memory-auto:{datetime.now():%Y%m%d-%H%M%S}"
try:
content = asyncio.run(asyncio.wait_for(_run_audit(session_key), timeout=TIMEOUT_SECONDS))
except asyncio.TimeoutError:
print(f"compact_memory_auto: timeout after {TIMEOUT_SECONDS // 60} min (session={session_key})", file=sys.stderr)
return 1
except Exception as e:
print(f"compact_memory_auto: run failed (session={session_key}): {e}\n{traceback.format_exc()}", file=sys.stderr)
return 1
if not content.strip():
print(f"compact_memory_auto: empty response (session={session_key})", file=sys.stderr)
return 1
token, chat_id = _telegram_config()
try:
_send_telegram(content, token, chat_id)
except Exception as e:
print(f"compact_memory_auto: telegram delivery failed (session={session_key}): {e}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,163 @@
# flight-search
Vyhledávání letenek přes KAYAK s ukládáním výsledků do SQLite.
## Kdy aktivovat
- "hledej letenky", "flight search", "letenky do X", "vyhledej let", "letenky PRG→PTY"
- Když uživatel zadá trasu, datumy a počet osob
## Přehled
Skill orchestruje hledání letenek:
1. Vytvoří definici hledání v DB přes CLI skript
2. Fetchnout KAYAK URL přes `web_fetch`
3. Parsuje výsledky z markdown
4. Uloží nalezené lety do DB
5. Prezentuje top výsledky uživateli
## Postup
### 1. Získat parametry od uživatele
Vyžadované:
- **origin** — IATA kód (např. PRG)
- **destination** — IATA kód (např. PTY)
- **dep_date** — datum odletu (YYYY-MM-DD)
- **ret_date** — datum návratu (YYYY-MM-DD)
- **adults** — počet dospělých (default 1)
Volitelné:
- **flex_days** — ±dny pro flexibilní hledání (default 0, max 3)
- **min_stay_days** — minimální doba pobytu v destinaci (default 1)
- **max_layovers** — max přestupy (default 1, pevně nastaveno)
- **currency** — CZK (default)
### 2. Vytvořit hledání
```bash
uv run skills/flight-search/scripts/flight_search.py create-search \
--origin PRG --destination PTY \
--dep-date 2026-12-21 --ret-date 2027-01-04 \
--adults 3 --flex-days 3 --min-stay-days 14 --max-layovers 1
```
Vrátí JSON s `search_id` a seznamem `urls`.
### 3. Fetch KAYAK stránky
Pro každou URL z `urls` použij `web_fetch` s `extractMode="text"`.
**Důležité**: Používej **cz.kayak.com** doménu (české UI, ceny v CZK). URL formát:
```
https://www.cz.kayak.com/flights/{origin}-{destination}/{dep}/{ret}/{adults}adults?sort=price_a&fs=stops=-2
```
KAYAK URL parametry:
- `sort=price_a` — seřadit podle ceny vzestupně
- `fs=stops=-2` — max 1 přestup (KAYAK počítá "additional stops", -2 = max 1)
- `curr=CZK` — ceny v CZK (na cz.kayak.com je CZK default)
### 4. Parsování výsledků
KAYAK vrací markdown s lety. Hledej vzory:
**Cena** (na cz.kayak.com v CZK):
- `34 484 Kč` nebo `32 232 Kč/osobu` nebo `Celkem 96 695 Kč`
- Pozor: mezera jako oddělovač tisíců, ne čárka
**Aerolinka**:
- `Air France`, `KLM`, `Turkish Airlines`, `SWISS`, `Copa Airlines`, `United Airlines`, atd.
- Může být kombinace: `KLM, Air France`
**Trasa a přestupy**:
- `1 přest.` nebo `2 přest.`
- `CDG Mezipřistání 3hod 05min, Paříž Letiště Charlese de Gaulla`
- `AMS Mezipřistání 16hod 55min, Amsterdam Letiště Schiphol`
**Časy**:
- Odlet: `09:50 20:20` nebo `09:50`
- Přílet: `20:20` nebo `20:20+1` (+1 = další den)
- Doba letu: `16hod 30min` nebo `16h 30m`
**Typický blok výsledku** (cz.kayak.com s fs=stops=-2):
```
1. 09:50 20:20
PRG Letiště Václav Havel - PTY Tocumen Intl 1 přest.
CDG Mezipřistání 3hod 05min, Paříž Letiště Charlese de Gaulla 16hod 30min
2. 22:30 22:40+1
PTY Tocumen Intl - PRG Letiště Václav Havel 1 přest.
CDG Mezipřistání 6hod 25min, Paříž Letiště Charlese de Gaulla 18hod 10min
Air France
1
0
34 484 Kč
/osobu
Celkem 103 452 Kč
Light
```
### 5. Uložit výsledky
Pro každý nalezený let:
```bash
uv run skills/flight-search/scripts/flight_search.py add-result \
--search-id <ID> \
--airline "Air France" \
--route "PRG→CDG→PTY / PTY→CDG→PRG" \
--dep-date 2026-12-21 --ret-date 2027-01-04 \
--dep-time "09:50" --arr-time "20:20" \
--layovers 1 --layover-info "CDG 3h05m" \
--duration "16h30m" \
--price 34484 --price-czk 34484
```
Nebo batch přes stdin:
```bash
echo '[{"search_id":1,"airline":"Air France",...}]' | \
uv run skills/flight-search/scripts/flight_search.py add-results
```
### 6. Prezentovat výsledky
```bash
uv run skills/flight-search/scripts/flight_search.py results <search_id> --top 5
```
Výstup formátuj pro uživatele v češtině s přehlednou tabulkou.
### 7. Historie a správa
```bash
# Seznam hledání
uv run skills/flight-search/scripts/flight_search.py list-searches
# Smazat hledání
uv run skills/flight-search/scripts/flight_search.py delete-search <id>
```
## Poznámky k parsování
- KAYAK stránka může obsahovat reklamy (JustFly, Expedia, FlightHub) — ignoruj
- Některé výsledky mají "Vlastní transfer" (self-transfer) — označ, ale nefiltruj
- Ceny jsou per osoba, celková cena je vždy uvedena jako "Celkem X Kč"
- Na cz.kayak.com jsou ceny automaticky v CZK
- Pokud web_fetch selže nebo vrátí jen navigaci bez výsledků, informuj uživatele a navrhni manuální kontrolu na KAYAKu
- KAYAK může vyžadovat více fetchů pro flex data — každá URL je jedna kombinace datumů
## CLI reference
| Příkaz | Popis |
|--------|-------|
| `create-search` | Vytvoří hledání, vrátí search_id + URL |
| `add-result` | Přidá jeden výsledek |
| `add-results` | Přidá víc výsledků z JSON (stdin nebo --json-file) |
| `results <id>` | Top N výsledků seřazených podle ceny |
| `list-searches` | Seznam všech hledání |
| `delete-search <id>` | Smaže hledání + výsledky |
## DB
Auto-vytvořeno v `db/flight_search.sqlite`. Tabulky:
- `searches` — definice hledání (trasa, datumy, flex, osoby)
- `results` — nalezené lety (aerolinka, trasa, časy, cena, přestupy)

View File

@@ -0,0 +1,416 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""flight-search skill — CLI for managing flight searches and results in SQLite."""
import argparse
import json
import sqlite3
import sys
from datetime import date, timedelta
from pathlib import Path
from typing import Any
DB_PATH = (
Path(__file__).resolve().parent.parent.parent.parent / "db" / "flight_search.sqlite"
)
SCHEMA = """
CREATE TABLE IF NOT EXISTS searches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
origin TEXT NOT NULL,
destination TEXT NOT NULL,
dep_date TEXT NOT NULL,
ret_date TEXT NOT NULL,
adults INTEGER NOT NULL DEFAULT 1,
max_layovers INTEGER NOT NULL DEFAULT 1,
min_stay_days INTEGER NOT NULL DEFAULT 1,
flex_days INTEGER NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'CZK',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
completed_at TEXT
);
CREATE TABLE IF NOT EXISTS results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
search_id INTEGER NOT NULL REFERENCES searches(id) ON DELETE CASCADE,
airline TEXT NOT NULL,
route TEXT NOT NULL,
dep_date TEXT NOT NULL,
ret_date TEXT NOT NULL,
dep_time TEXT,
arr_time TEXT,
layovers INTEGER NOT NULL DEFAULT 0,
layover_info TEXT,
duration TEXT,
price REAL,
price_currency TEXT NOT NULL DEFAULT 'CZK',
price_czk REAL NOT NULL,
booking_url TEXT,
found_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_results_search ON results(search_id);
CREATE INDEX IF NOT EXISTS idx_results_route ON results(route);
"""
def _connect() -> sqlite3.Connection:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.executescript(SCHEMA)
return conn
def _now() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat()
# ── create-search ──────────────────────────────────────────────────────────
def cmd_create_search(args: argparse.Namespace) -> None:
dep = date.fromisoformat(args.dep_date)
ret = date.fromisoformat(args.ret_date)
flex = args.flex_days
min_stay = args.min_stay_days
adults = args.adults
origin = args.origin.upper()
destination = args.destination.upper()
currency = args.currency.upper()
# Generate date combinations respecting min_stay
dep_range = [dep + timedelta(days=d) for d in range(-flex, flex + 1)]
ret_range = [ret + timedelta(days=d) for d in range(-flex, flex + 1)]
combos = []
for d in dep_range:
for r in ret_range:
if (r - d).days >= min_stay:
combos.append((d, r))
# Sort by proximity to original dates, then by total stay duration
combos.sort(key=lambda dr: (abs((dr[0] - dep).days) + abs((dr[1] - ret).days), (dr[1] - dr[0]).days))
# Cap at 15 URLs
combos = combos[:15]
urls = []
for d, r in combos:
url = (
f"https://www.cz.kayak.com/flights/{origin}-{destination}"
f"/{d.strftime('%Y-%m-%d')}/{r.strftime('%Y-%m-%d')}"
f"/{adults}adults?sort=price_a"
)
if args.max_layovers is not None:
url += f"&fs=stops=-{args.max_layovers + 1}"
urls.append(url)
conn = _connect()
try:
cur = conn.execute(
"""INSERT INTO searches
(origin, destination, dep_date, ret_date, adults, max_layovers,
min_stay_days, flex_days, currency, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?)""",
(
origin,
destination,
args.dep_date,
args.ret_date,
adults,
args.max_layovers if args.max_layovers is not None else 1,
min_stay,
flex,
currency,
_now(),
),
)
search_id = cur.lastrowid
conn.commit()
finally:
conn.close()
output = {
"search_id": search_id,
"origin": origin,
"destination": destination,
"date_combinations": len(combos),
"urls": urls,
}
print(json.dumps(output, indent=2, ensure_ascii=False))
# ── add-result ──────────────────────────────────────────────────────────────
def cmd_add_result(args: argparse.Namespace) -> None:
conn = _connect()
try:
conn.execute(
"""INSERT INTO results
(search_id, airline, route, dep_date, ret_date, dep_time, arr_time,
layovers, layover_info, duration, price, price_currency, price_czk,
booking_url, found_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
args.search_id,
args.airline,
args.route,
args.dep_date,
args.ret_date,
args.dep_time,
args.arr_time,
args.layovers if args.layovers is not None else 0,
args.layover_info,
args.duration,
args.price,
args.price_currency if args.price_currency else "CZK",
args.price_czk if args.price_czk is not None else args.price,
args.booking_url,
_now(),
),
)
conn.commit()
print(json.dumps({"ok": True, "search_id": args.search_id}))
finally:
conn.close()
# ── add-results (batch from JSON) ──────────────────────────────────────────
def cmd_add_results(args: argparse.Namespace) -> None:
if args.json_file:
with open(args.json_file) as f:
data = json.load(f)
else:
data = json.load(sys.stdin)
if not isinstance(data, list):
data = [data]
conn = _connect()
try:
count = 0
for row in data:
conn.execute(
"""INSERT INTO results
(search_id, airline, route, dep_date, ret_date, dep_time, arr_time,
layovers, layover_info, duration, price, price_currency, price_czk,
booking_url, found_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
row.get("search_id"),
row.get("airline"),
row.get("route"),
row.get("dep_date"),
row.get("ret_date"),
row.get("dep_time"),
row.get("arr_time"),
row.get("layovers", 0),
row.get("layover_info"),
row.get("duration"),
row.get("price"),
row.get("price_currency", "CZK"),
row.get("price_czk", row.get("price")),
row.get("booking_url"),
_now(),
),
)
count += 1
conn.commit()
print(json.dumps({"ok": True, "added": count}))
finally:
conn.close()
# ── results ─────────────────────────────────────────────────────────────────
def cmd_results(args: argparse.Namespace) -> None:
conn = _connect()
try:
rows = conn.execute(
"""SELECT r.*, s.origin, s.destination, s.adults
FROM results r
JOIN searches s ON s.id = r.search_id
WHERE r.search_id = ?
ORDER BY r.price_czk ASC
LIMIT ?""",
(args.search_id, args.top or 5),
).fetchall()
if not rows:
print("(no results found)")
return
for i, row in enumerate(rows, 1):
layover = ""
if row["layover_info"]:
layover = f" ({row['layover_info']})"
stops = f"{row['layovers']} stop{'' if row['layovers'] == 1 else 's'}"
price_str = f"{row['price_czk']:,.0f} {row['price_currency']}"
if row["price_currency"] != "CZK" and row["price"] != row["price_czk"]:
price_str = f"{row['price']:,.0f} {row['price_currency']}{row['price_czk']:,.0f} CZK"
print(
f"#{i} {row['airline']} {row['route']} "
f"{row['dep_date']}{row['ret_date']} "
f"{row['duration'] or '?'} {stops}{layover} {price_str}"
)
if row["booking_url"]:
print(f" {row['booking_url']}")
finally:
conn.close()
# ── list-searches ───────────────────────────────────────────────────────────
def cmd_list_searches(args: argparse.Namespace) -> None:
conn = _connect()
try:
status_filter = args.status or "active"
if status_filter == "all":
rows = conn.execute(
"SELECT * FROM searches ORDER BY created_at DESC"
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM searches WHERE status = ? ORDER BY created_at DESC",
(status_filter,),
).fetchall()
if not rows:
print("(no searches)")
return
for row in rows:
result_count = conn.execute(
"SELECT COUNT(*) FROM results WHERE search_id = ?", (row["id"],)
).fetchone()[0]
print(
f"#{row['id']} {row['origin']}{row['destination']} "
f"{row['dep_date']}{row['ret_date']} "
f"{row['adults']} adults flex ±{row['flex_days']}d "
f"min stay {row['min_stay_days']}d "
f"max {row['max_layovers']} stop(s) "
f"[{row['status']}] {result_count} results"
)
finally:
conn.close()
# ── delete-search ───────────────────────────────────────────────────────────
def cmd_delete_search(args: argparse.Namespace) -> None:
conn = _connect()
try:
# Check exists
row = conn.execute(
"SELECT id, origin, destination FROM searches WHERE id = ?",
(args.search_id,),
).fetchone()
if not row:
print(json.dumps({"error": f"search {args.search_id} not found"}))
sys.exit(1)
result_count = conn.execute(
"SELECT COUNT(*) FROM results WHERE search_id = ?", (args.search_id,)
).fetchone()[0]
conn.execute("DELETE FROM results WHERE search_id = ?", (args.search_id,))
conn.execute("DELETE FROM searches WHERE id = ?", (args.search_id,))
conn.commit()
print(
json.dumps(
{
"deleted": True,
"search_id": args.search_id,
"route": f"{row['origin']}{row['destination']}",
"results_removed": result_count,
}
)
)
finally:
conn.close()
# ── main ────────────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(description="Flight search CLI")
sub = parser.add_subparsers(dest="command", required=True)
# create-search
p_create = sub.add_parser("create-search", help="Create a new flight search")
p_create.add_argument("--origin", required=True, help="Origin IATA code (e.g. PRG)")
p_create.add_argument("--destination", required=True, help="Destination IATA code (e.g. PTY)")
p_create.add_argument("--dep-date", required=True, help="Departure date YYYY-MM-DD")
p_create.add_argument("--ret-date", required=True, help="Return date YYYY-MM-DD")
p_create.add_argument("--adults", type=int, default=1, help="Number of adults (default 1)")
p_create.add_argument("--max-layovers", type=int, default=None, help="Max additional stops (default 1)")
p_create.add_argument("--min-stay-days", type=int, default=1, help="Minimum stay in days (default 1)")
p_create.add_argument("--flex-days", type=int, default=0, help="±days for flexible dates (default 0)")
p_create.add_argument("--currency", default="CZK", help="Currency code (default CZK)")
# add-result
p_add = sub.add_parser("add-result", help="Add a single flight result")
p_add.add_argument("--search-id", type=int, required=True)
p_add.add_argument("--airline", required=True)
p_add.add_argument("--route", required=True, help="e.g. PRG→AMS→PTY / PTY→AMS→PRG")
p_add.add_argument("--dep-date", required=True)
p_add.add_argument("--ret-date", required=True)
p_add.add_argument("--dep-time", default=None)
p_add.add_argument("--arr-time", default=None)
p_add.add_argument("--layovers", type=int, default=None)
p_add.add_argument("--layover-info", default=None, help="e.g. AMS 2h15m")
p_add.add_argument("--duration", default=None, help="e.g. 12h30m")
p_add.add_argument("--price", type=float, required=True)
p_add.add_argument("--price-currency", default=None)
p_add.add_argument("--price-czk", type=float, default=None)
p_add.add_argument("--booking-url", default=None)
# add-results (batch)
p_batch = sub.add_parser("add-results", help="Add multiple results from JSON")
p_batch.add_argument("--json-file", default=None, help="Path to JSON file (default: stdin)")
# results
p_res = sub.add_parser("results", help="Show results for a search")
p_res.add_argument("search_id", type=int)
p_res.add_argument("--top", type=int, default=5, help="Show top N results (default 5)")
# list-searches
p_list = sub.add_parser("list-searches", help="List all searches")
p_list.add_argument("--status", default="active", help="Filter by status (active/completed/all)")
# delete-search
p_del = sub.add_parser("delete-search", help="Delete a search and its results")
p_del.add_argument("search_id", type=int)
args = parser.parse_args()
commands = {
"create-search": cmd_create_search,
"add-result": cmd_add_result,
"add-results": cmd_add_results,
"results": cmd_results,
"list-searches": cmd_list_searches,
"delete-search": cmd_delete_search,
}
commands[args.command](args)
if __name__ == "__main__":
main()

View File

@@ -1,10 +1,12 @@
---
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.
Explicit personal memory — durable facts, preferences, and decisions the user
wants remembered. Use when user says "keep X", "zapamatuj si X", "ulož si X",
"pamatuj si X", "/keep X". Adds, deduplicates, and compacts terse entries in
workspace/keep.md. For durable personal facts / preferences / decisions — NOT
collecting notes, links, or articles (that is note). Separate from MEMORY.md /
Dream pipeline.
---
# Keep

View File

@@ -1,151 +1,178 @@
---
name: note
description: >
Explicit notes.
Use when user says "note X", "note it".
Capture notes, texts, URLs, or whole articles into a personal knowledge base and
answer questions against it. Triggers on "note X", "/note cron X", "search my
notes for X", "delete/edit the note about X", "forget X". For filing reference
material to search later — not a short durable fact/preference to just
remember, and not a bare URL saved only to read later with no filing.
---
# Note
Explicit note store backed by SQLite. User says "note X" → take only
explicitly-typed tags, reformulate content, store via `note.py add`. Delete only
on explicit user request. Notes are stored to sqlite db.
A personal capture-to-knowledge-base skill. The user throws in notes, texts, URLs, or
whole articles from any channel; each input is captured raw, then reformulated and filed
into one structured markdown document (`notes/notes.md`) organized into thematic sections
that the LLM owns and grows. Search = load the whole document and answer from it.
## Backend
## Architecture — read this first
`skills/note/scripts/note.py` — CLI wrapper around `db/note.sqlite`.
Operation log: `log/note.log` (append-only, all write operations).
Two-stage pipeline, one shared compile step:
## Tag protocol
- **Capture (always, instant, dumb).** `note_capture.py` writes the raw input verbatim
into `notes/inbox/` (atomic) plus one line to `log/note.log`. No reformulation, no
reading of `notes.md`, no fetching. This is all capture ever does.
- **Compile (reformulate + file into `notes/notes.md`).** Runs either **inline** in the
immediate mode, or in the **background** cron drain. Same workflow either way.
Tags are the **first token** right after the trigger — comma-separated, no spaces:
Storage layout under the workspace root (fixed locations):
```
/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: []
```text
notes/
├── notes.md ← THE structured doc (thematic ## sections, LLM-owned)
├── inbox/ ← pending captures (one file each); compile drains this
├── done/ ← successfully compiled captures (sibling of inbox/)
├── hard/ ← held back: paywalled / unreadable / ambiguous — for manual review
└── .compile.lock ← concurrency lock shared by inline compile and cron drain
log/note.log ← append-only audit of every capture
```
Rules:
- **Tags come *only* from the first token the user actually typed. Never
derive, infer, or invent tags from the note's content, topic, or meaning.**
If the user did not type a tag, the note has no tags — full stop.
- 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
**No database — ever.** Notes live *only* in `notes/notes.md` (prose) plus the `notes/`
pipeline dirs above. There is **no** SQLite/DB backend. To find a note, `grep` or read
`notes/notes.md` — **never search `db/`, never run `sqlite3`, never create or open any
`.sqlite`/`.db` file.** (The AGENTS.md "store SQLite under `db/`" convention does **not**
apply to notes — that is for other skills.) An older version of this skill used a
database; it is gone. If you catch yourself opening a DB, stop — the answer is in
`notes/notes.md`.
Tags must be **registered before use**. There is no auto-creation: the database
holds a registry of known tags, and `add` rejects any tag that is not in it (exit
2). A new tag is born only via the explicit `tag-add` command (see Tag management).
Still only pass tags the user typed — registration does not license inventing them.
**Separate store.** `notes/` is not agent memory: keep it distinct from `keep`,
`MEMORY.md`, and the llm-wiki store (`cml/`). Never cross-read or cross-write between
them. The Dream processor must not touch `notes/`.
## Write protocol
**Run scripts with `uv run`, workspace-relative paths** (exec runs from the workspace
root, not the skill dir): `uv run skills/note/scripts/<script>.py …`.
1. Take inline tags from the first token only (see Tag protocol above). If that
token is not a tag the user typed, the tags field stays empty — never fill it
from the content.
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. **Unknown tag (`add` exits 2, prints `Unknown tag(s): …`):** the note was NOT
stored. For each unknown tag, ask the user (in their language): "Tag #X
doesn't exist — create it?"
- **Yes** → `uv run skills/note/scripts/note.py tag-add X`, then re-run `add`
with the original tags.
- **No** → re-run `add` without that tag (keep the known ones). If nothing
remains, store with no tags.
4. 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.
**Language.** This skill body is English; always reply to the user in the user's own
language.
No dedup. No MEMORY.md lookup. Blind append.
## `/note <text>` — capture and file NOW (default)
## Tag management
The default: file the note into the knowledge base immediately, in this turn.
Tags are created and listed explicitly — never as a side effect of adding a note.
1. Read `Channel` / `Chat ID` from the runtime context if present.
2. Capture:
`uv run skills/note/scripts/note_capture.py --text "<raw input>" [--channel <ch>] [--chat-id <id>]`
Pass the input **as-is** — do not reformulate or strip URLs here.
3. Run the **Compile workflow** (below) inline: acquire the lock, process `notes/inbox/`,
file into `notes/notes.md`, move the source to `notes/done/` (or `notes/hard/`).
4. **Commit** the change (see *Versioning* below): via `exec` run
`git add notes/ && git commit -m "note: <short summary of what landed>"`.
5. Confirm to the user **which section** it landed under, **and quote the exact
text that was filed** (the reformulated fact(s), verbatim as written into
`notes.md` — not a re-summary of it), in their language.
Trigger (create): `/note tag add X`, "create tag X", "register tag X".
This blocks the turn for a while (reads the whole doc; a URL/article adds a fetch). If
the user is firing off many notes quickly, suggest `/note cron`.
1. Run: `uv run skills/note/scripts/note.py tag-add X`
2. Echo the result. Already-existing tag → script reports it and exits 0 (no error).
3. No tag name given → ask which tag to create; do not guess.
## `/note cron <text>` — deferred capture
Trigger (list): `/note tags`, "what tags are there?", "list tags".
Capture only; let the background cron file it later. Fast, non-blocking.
1. Run: `uv run skills/note/scripts/note.py tag-list`
2. Echo output. Empty → "No tags."
1. Read `Channel` / `Chat ID` from the runtime context if present.
2. `uv run skills/note/scripts/note_capture.py --text "<raw input>" [--channel <ch>] [--chat-id <id>]`
3. Confirm in **one short line** (e.g. "captured — I'll file it in the background") and **STOP the
turn**. Forbidden here: reformulating, reading `notes/notes.md`, running any compile
step, taking the lock. If you catch yourself about to read the doc, you are compiling
inline — stop and just capture.
Tags are referenced by name everywhere (no display ID). There is no tag deletion.
## Compile workflow (shared: inline immediate mode + cron drain)
## List protocol
The cron (`note_compile.py`) invokes this via a drain goal; immediate mode runs it inline.
Either way:
Trigger: `/note list`, `show notes`, `what notes do you have?`
1. **Take the lock.** Create `notes/.compile.lock` (skip if a live one exists — another
compile is running; try again later). The cron script handles this itself; inline mode
must respect it so an inline merge and a cron drain never edit `notes.md` at once.
2. **For each file in `notes/inbox/`:**
- **Reformulate** the body into a terse fact (or a few). One concept per entry; drop
filler; **preserve the input language** — never translate. Split if too complex.
If the input is Czech typed without diacritics (e.g. "kdyz uz to psal bez hacku"),
restore correct diacritics as part of reformulation. Leave already-accented text
and non-Czech text untouched — never add diacritics where none belong.
- **URLs:** extract **every** URL from the body (0..N). Fetch each with the `web` tool
(Jina Reader — returns clean markdown, handles JS and soft paywalls). If a URL is
**paywalled / login-gated / truncated / unreadable** (login/subscribe/metered
content, very short output, HTTP 401/403): **do not fabricate a summary** — write
just the URL + any available title + a `⚠ paywall/incomplete` marker. Whole articles:
summarize the key points.
- **File it** under the right thematic `##` section of `notes/notes.md`. Create a new
section if none fits. Use a surgical `str_replace`/append — never rewrite the whole
document.
- **Move the source out of `inbox/` immediately:** to `notes/done/` if anything usable
was filed (paywall markers count as filed — they are the breadcrumb); to `notes/hard/`
if nothing usable could be extracted. Move right after each file so a crash mid-batch
re-processes at most one.
3. **Release the lock** (the cron script does this in `finally`).
1. Run: `uv run skills/note/scripts/note.py list [--limit N] [--tag TAG [TAG ...]]`
2. Echo output. If empty → respond "No notes."
Never assert content you could not read. When unsure, hedge or mark it.
`--tag` accepts one or more tags; OR logic (notes with at least one matching tag).
## `/note search <query>` / `/note find <query>` — query
The number before each note (`1.`, `2.`, …) is the **display ID** — sequential
among active notes, newest first. Renumbers after every deletion. Never change,
renumber, or drop it.
Also triggered by "what do I have on …?", "find in my notes …".
### URLs in a note
1. Read the whole `notes/notes.md` — that single file **is** the knowledge base. Do
not read `inbox/`, `done/`, or `hard/` (those are the raw pipeline, not the KB).
2. Answer from it. If the topic is not covered, say so plainly — do not confabulate.
3. Read-only: never modify the document in a search turn.
The script already lays out each URL (with its inline label, if any) on its own
indented bullet line. **Echo the output verbatim** — keep the bullets and line
breaks, keep URLs bare. Never collapse the bullets back onto one line and never
wrap a URL in `[text](url)`: this chat UI merges two adjacent inline links into
one block, hides the second URL, and overlays the list number. Bare URLs on their
own lines autolink correctly and stay separate.
## `/note delete <query>` / `/note edit <query>` — remove or change a note
## Show protocol
Also triggered by "delete/remove the note about X", "forget X", "edit/update the note
about X". Notes are prose in `notes/notes.md`**no IDs, no DB.** The user
names a note by describing it; you find it by reading the document.
Trigger: `/note show <id>`, `show note N`, `read note N`, `what does note N say`.
**This is a hard-gated TWO-TURN flow. NEVER delete or edit in the same turn as the
request — showing is not doing.**
1. Display IDs are the same as in `list`/`delete` — sequential among active
notes, newest first, renumbered after every deletion. If unsure, run `list`
first.
2. Run: `uv run skills/note/scripts/note.py show <display-id>`
- Exit 0 → **output the script's stdout verbatim — print every line exactly
as emitted.** Do not summarize, shorten, rewrap, or drop any part of the
`content` field, including URLs and links. The `show` command exists
precisely to surface the note in full; brevity directives do not apply here.
- Exit 1 → display ID out of range; respond accordingly.
3. `show` is read-only — it never deletes or modifies anything.
**Turn 1 — locate and confirm (absolutely NO mutation):**
The block contains every stored field: display ID, internal DB id, creation
timestamp, tags, and full untruncated content.
1. Read `notes/notes.md` to find the matching note(s). That file is the only place a note
lives — **do not search `db/`, do not run `sqlite3`, do not read `inbox/`/`done/`/
`hard/`.** If nothing matches, say so. If the target is ambiguous or several entries
match, list the candidates and ask which one.
2. Show the user the **exact verbatim line(s)/section** you would remove (for an edit: the
`before``after`), and ask them to confirm in plain words. Then **STOP the turn.**
Forbidden this turn: `str_replace`/`edit_file`, `rm`, `git`, or any other mutation.
## Delete protocol
**Turn 2 — only after the user explicitly confirms ("yes", "confirmed", "delete it"…):**
Trigger: `/note delete`, `delete a note`, `remove a note`.
1. Remove/change it in `notes/notes.md` with a surgical `str_replace` (never rewrite the
whole document). **Touch `notes/notes.md` only** — do NOT delete or move anything in
`notes/done/`; those raw-capture breadcrumbs are internal plumbing, not a second copy
of the note.
2. Commit (see *Versioning*): via `exec` run
`git add notes/ && git commit -m "note: delete <short desc>"` (edit → `note: edit …`).
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.
**Exception:** a capture still **pending** (not yet compiled, a file in `notes/inbox/`)
never reached the KB — you may cancel it directly with `exec: rm notes/inbox/<file>`
(commit only if it was already tracked).
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.
## Versioning (git)
`notes/` lives inside the workspace git repo. The Dream processor never touches it, so
**this skill is the only thing that commits `notes/`** — do it after every change to
`notes/notes.md`:
- **Inline `/note <text>` and delete/edit:** commit in the same turn via `exec`
(`git add notes/ && git commit -m "note: …"`). One commit per operation.
- **Cron drain:** `note_compile.py` commits deterministically after the batch — you do
not commit inside the cron `DRAIN_GOAL` run.
- Never `git add -A` (Dream owns the rest of the workspace); stage only `notes/`. The
`.compile.lock` is gitignored, so `git add notes/` never stages it.
## Edge cases
- `/note` with no content → ask "What should I note?"
- Vague input → ask for the concrete fact; do not store a placeholder.
- `/note tag add` with no name → ask which tag to create; never guess.
- `/note show` with no ID → run `list` first, then ask which display ID.
- `/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.
- `/note` with no content → ask what to note.
- Empty / whitespace-only input → `note_capture.py` exits non-zero; ask for real content.
- The compile step, not capture, decides sections and does all fetching. If you ever find
yourself reformulating or reading `notes.md` during a `cron` capture, stop.

View File

View File

@@ -1,316 +0,0 @@
#!/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-]*$")
# A URL together with an immediately preceding "Label:" token, if any.
# The leading separator class swallows the connector that introduced the URL
# (em-dash, comma, etc.) so it does not dangle once the URL moves to its own line.
_LABELED_URL_RE = re.compile(r"[\s,;—–-]*([^\s,]+:\s*)?(https?://[^\s,]+)")
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
);
CREATE TABLE IF NOT EXISTS tags (
name TEXT PRIMARY KEY,
created_at TEXT NOT NULL
);
"""
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()
_backfill_tags(conn)
def _backfill_tags(conn: sqlite3.Connection) -> None:
"""On first introduction of the registry, seed it from tags already used in notes."""
existing = {row[0] for row in conn.execute("SELECT name FROM tags")}
if existing:
return
used = {row[0] for row in conn.execute("SELECT DISTINCT value FROM notes, json_each(notes.tags)")}
if not used:
return
now = datetime.now(timezone.utc).isoformat()
conn.executemany(
"INSERT OR IGNORE INTO tags(name, created_at) VALUES(?, ?)",
[(tag, now) for tag in sorted(used)],
)
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 _urls_on_own_lines(text: str) -> str:
"""Lay out each URL (and its inline "Label:", if any) on its own bullet line.
The chat UI merges two adjacent links into one block and hides the second,
which also overlays the list number. Putting each URL on its own line keeps
them separate and the number visible. URLs stay bare so they autolink.
"""
if not _LABELED_URL_RE.search(text):
return text
def repl(match: re.Match[str]) -> str:
label = match.group(1) or ""
return f"\n - {label}{match.group(2)}"
return _LABELED_URL_RE.sub(repl, text)
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:
known = {row[0] for row in conn.execute("SELECT name FROM tags")}
unknown = [tag for tag in tags if tag not in known]
if unknown:
print(f"Unknown tag(s): {', '.join(unknown)}", file=sys.stderr)
return 2
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:
head, sep, rest = _urls_on_own_lines(row["content"]).partition("\n")
print(f"{id_to_display[row['id']]}. {head}{_tags_display(row['tags'])}{sep}{rest}")
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 cmd_show(args: argparse.Namespace) -> int:
display_id: int = args.id
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, created_at FROM notes WHERE id = ?", (nid,)
).fetchone()
_log("SHOW", f"display_id={display_id} id={nid}")
tags = json.loads(row["tags"])
tags_line = " ".join(f"#{t}" for t in tags) if tags else "(none)"
print(f"Note [#{display_id}] (id={row['id']})")
print(f"created: {row['created_at']}")
print(f"tags: {tags_line}")
print(f"content: {row['content']}")
return 0
def cmd_tag_add(args: argparse.Namespace) -> int:
name = args.name.strip()
try:
_validate_tags([name])
except ValueError as exc:
print(str(exc), file=sys.stderr)
return 1
with _connect() as conn:
exists = conn.execute("SELECT 1 FROM tags WHERE name = ?", (name,)).fetchone()
if exists:
print(f"Tag '#{name}' already exists.")
return 0
created_at = datetime.now(timezone.utc).isoformat()
conn.execute("INSERT INTO tags(name, created_at) VALUES(?, ?)", (name, created_at))
conn.commit()
_log("TAG-ADD", f"name={name}")
print(f"Tag created: #{name}")
return 0
def cmd_tag_list(args: argparse.Namespace) -> int:
with _connect() as conn:
rows = conn.execute("SELECT name FROM tags ORDER BY name").fetchall()
_log("TAG-LIST", f"returned={len(rows)}")
if not rows:
print("No tags.")
return 0
for row in rows:
print(f"#{row['name']}")
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_show = sub.add_parser("show", help="Show one note in full by display ID")
p_show.add_argument("id", type=int, help="Display ID")
p_del = sub.add_parser("delete", help="Soft-delete a note by ID")
p_del.add_argument("id", type=int, help="Note ID")
p_tag_add = sub.add_parser("tag-add", help="Register a tag")
p_tag_add.add_argument("name", help="Tag name (lowercase, hyphens allowed)")
sub.add_parser("tag-list", help="List registered tags")
args = parser.parse_args()
if args.cmd == "add":
return cmd_add(args)
if args.cmd == "list":
return cmd_list(args)
if args.cmd == "show":
return cmd_show(args)
if args.cmd == "delete":
return cmd_delete(args)
if args.cmd == "tag-add":
return cmd_tag_add(args)
if args.cmd == "tag-list":
return cmd_tag_list(args)
return 0
if __name__ == "__main__":
sys.exit(_main())

View File

@@ -0,0 +1,111 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""note_capture.py — dumb, instant capture for the /note skill.
Writes the raw input verbatim into notes/inbox/ (atomic tmp -> os.replace) plus one
audit line to log/note.log, then prints a one-line confirmation. No reformulation,
no reading of the knowledge doc, no compile — that is the compile step's job
(inline in immediate mode, or the cron drain in `cron` mode).
Used identically by both modes; the only difference is what the agent does *after*
calling this (immediate: run the compile workflow inline; cron: stop).
"""
import argparse
import re
import sys
import unicodedata
from datetime import datetime
from pathlib import Path
# workspace/skills/note/scripts/note_capture.py -> parents[3] = workspace root.
WORKSPACE = Path(__file__).resolve().parents[3]
INBOX = WORKSPACE / "notes" / "inbox"
LOG = WORKSPACE / "log" / "note.log"
_SLUG_STRIP_RE = re.compile(r"[^a-z0-9]+")
_URL_RE = re.compile(r"https?://([^/\s]+)")
MAX_SLUG_WORDS = 4
SLUG_MAX_LEN = 40
def _ascii_fold(text: str) -> str:
"""Drop diacritics so Czech words survive slugging (mazání -> mazani)."""
return unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii")
def _slugify(text: str) -> str:
"""Short kebab slug from the first words of the input (domain for a bare URL)."""
first_line = next((line for line in text.splitlines() if line.strip()), "").strip()
url_match = _URL_RE.match(first_line)
if url_match:
host = url_match.group(1).removeprefix("www.")
slug = _SLUG_STRIP_RE.sub("-", _ascii_fold(host).lower()).strip("-")
return slug or "note"
words = first_line.split()[:MAX_SLUG_WORDS]
slug = _SLUG_STRIP_RE.sub("-", _ascii_fold(" ".join(words)).lower()).strip("-")
return slug[:SLUG_MAX_LEN].strip("-") or "note"
def _build_content(
captured_at: str, channel: str | None, chat_id: str | None, body: str
) -> str:
lines = [f"captured_at: {captured_at}"]
if channel:
lines.append(f"channel: {channel}")
if chat_id:
lines.append(f'chat_id: "{chat_id}"')
frontmatter = "\n".join(lines)
return f"---\n{frontmatter}\n---\n\n{body.strip()}\n"
def _append_log(filename: str, body: str) -> None:
LOG.parent.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().astimezone().isoformat(timespec="seconds")
summary = " ".join(body.split())[:80]
with LOG.open("a", encoding="utf-8") as handle:
handle.write(f"{stamp} CAPTURE {filename} :: {summary}\n")
def main() -> int:
parser = argparse.ArgumentParser(description="Capture a raw note into notes/inbox/")
parser.add_argument(
"--text", default=None, help="Raw input; if omitted, read from stdin"
)
parser.add_argument(
"--channel", default=None, help="Origin channel (telegram/websocket/cli)"
)
parser.add_argument(
"--chat-id", default=None, dest="chat_id", help="Origin chat id"
)
args = parser.parse_args()
body = args.text if args.text is not None else sys.stdin.read()
body = body.strip()
if not body:
print("Nothing to capture (empty input).", file=sys.stderr)
return 1
now = datetime.now().astimezone()
timestamp = now.strftime("%Y-%m-%d_%H_%M_%S_%f")
filename = f"{timestamp}-{_slugify(body)}.md"
content = _build_content(
now.isoformat(timespec="seconds"), args.channel, args.chat_id, body
)
INBOX.mkdir(parents=True, exist_ok=True)
tmp_path = INBOX / f".{filename}.tmp"
final_path = INBOX / filename
tmp_path.write_text(content, encoding="utf-8")
tmp_path.replace(final_path)
_append_log(filename, body)
print(f"captured: {filename}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,216 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["nanobot-ai"]
# ///
"""note_compile.py — drain notes/inbox/ into the structured doc notes/notes.md.
Thin launcher run by the nanobot user crontab every minute. All the intelligence
lives in DRAIN_GOAL + the note skill's compile workflow; this script only decides
*when* to run and guards against concurrent runs.
Flow:
1. Cheap fs pre-check (no LLM): are there pending files in notes/inbox/? None ->
exit 0 without importing nanobot (per-minute polling stays nearly free).
2. Lockfile (notes/.compile.lock, PID + start-timestamp): another compile running?
-> exit 0. Stale lock (dead PID / older than STALE_SECONDS) is reclaimed.
3. Otherwise Nanobot.from_config().run(<drain goal>) — drains ALL pending in one
batch. process_direct has NO cron preamble (unlike cron/jobs.json agent jobs).
4. Quietly append to log/note_compile_cron.log; no Telegram.
The immediate `/note` mode runs the SAME compile workflow inline and takes the SAME
lock, so an inline merge and a background drain cannot corrupt notes.md at once.
"""
import asyncio
import json
import os
import subprocess
import sys
import traceback
from datetime import datetime
from pathlib import Path
# workspace/skills/note/scripts/note_compile.py -> parents[3] = workspace root.
WORKSPACE = Path(__file__).resolve().parents[3]
NOTES = WORKSPACE / "notes"
INBOX = NOTES / "inbox"
LOCK = NOTES / ".compile.lock"
LOG = WORKSPACE / "log" / "note_compile_cron.log"
TIMEOUT_SECONDS = 15 * 60
STALE_SECONDS = 30 * 60
DRAIN_GOAL = (
"Pomocí skillu note (Compile/drain) zpracuj VŠECHNY čekající soubory v `notes/inbox/` "
"(regulérní soubory přímo v `notes/inbox/`, mimo skryté). Pro každý postupuj podle "
"*Compile workflow* v note SKILL.md: přeformuluj na terse fakt(a) (zachovej jazyk vstupu, "
"jeden koncept per záznam, zahoď filler); z těla vytáhni VŠECHNY URL (0..N) a každou stáhni "
"přes `web` tool — když je za paywallem / login-wallem / neúplná, NEfabrikuj shrnutí, zapiš "
"jen URL + titulek + značku `⚠ paywall/neúplné`. Zařaď obsah pod správnou tematickou sekci "
"v `notes/notes.md` (novou sekci ## založ, když chybí; existující sekci uprav chirurgicky, "
"nepřepisuj celý dokument). Po úspěšném zařazení přesuň zdrojový soubor do `notes/done/`; "
"když z něj nešlo nic použitelného získat (vše za paywallem / nečitelné / nejednoznačné), "
"přesuň ho do `notes/hard/`. Přesouvej HNED po každém souboru, ať ho příští cron tik "
"nezpracovává znovu. Běžíš v izolované session na pozadí, bez interakce s uživatelem."
)
def log(message: str) -> None:
LOG.parent.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().astimezone().isoformat(timespec="seconds")
with LOG.open("a", encoding="utf-8") as handle:
handle.write(f"{stamp} {message}\n")
def pending_sources() -> list[Path]:
"""Regular files directly in notes/inbox/ (hidden files excluded; done/ and hard/ are siblings)."""
if not INBOX.exists():
return []
return [
p for p in sorted(INBOX.iterdir()) if p.is_file() and not p.name.startswith(".")
]
def _pid_alive(pid: int) -> bool:
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def _lock_is_stale() -> bool:
"""A lock is dead if unreadable, its PID is gone, or it is older than STALE_SECONDS."""
try:
data = json.loads(LOCK.read_text())
pid = int(data["pid"])
started = datetime.fromisoformat(data["started"])
except (OSError, ValueError, KeyError):
return True
if not _pid_alive(pid):
return True
age = (datetime.now().astimezone() - started).total_seconds()
return age > STALE_SECONDS
def acquire_lock() -> bool:
"""Atomically create the lock. Return False when a live compile already runs."""
for _ in range(2):
try:
fd = os.open(LOCK, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
except FileExistsError:
if not _lock_is_stale():
return False
log("stale lock, reclaiming")
LOCK.unlink(missing_ok=True)
continue
payload = {
"pid": os.getpid(),
"started": datetime.now().astimezone().isoformat(),
}
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle)
return True
return False
async def run_compile(goal: str) -> str:
# Heavy import deferred: the per-minute pre-check (no pending work) must not pay the
# nanobot import cost — only an actual compile run needs it.
from nanobot import Nanobot
bot = Nanobot.from_config()
result = await bot.run(goal, session_key="note-compile")
return result.content or ""
def commit_notes(count: int) -> None:
"""Stage and commit only notes/ after a successful drain.
The Dream processor owns the rest of the workspace, so we never `git add -A`.
A no-op when notes/ has no changes. .compile.lock is gitignored, so `git add notes/`
(run while the lock is still held) does not stage it. Commit failure is logged, not
raised — the drain itself already succeeded and must not be reported as failed.
"""
try:
status = subprocess.run(
["git", "-C", str(WORKSPACE), "status", "--porcelain", "notes/"],
check=True,
capture_output=True,
text=True,
)
if not status.stdout.strip():
return
subprocess.run(
["git", "-C", str(WORKSPACE), "add", "notes/"],
check=True,
capture_output=True,
text=True,
)
subprocess.run(
[
"git",
"-C",
str(WORKSPACE),
"commit",
"-m",
f"note: cron drain ({count} captures)",
],
check=True,
capture_output=True,
text=True,
)
log(f"COMMIT notes/ ({count} captures)")
except (OSError, subprocess.CalledProcessError) as error:
log(f"WARN commit failed: {error}")
def main() -> int:
dry_run = "--dry-run" in sys.argv[1:]
pending = pending_sources()
if not pending:
return 0
if not acquire_lock():
log(f"SKIP compile already running ({len(pending)} pending)")
return 0
if dry_run:
names = ", ".join(p.name for p in pending)
log(f"DRY-RUN would compile {len(pending)} pending: {names}")
LOCK.unlink(missing_ok=True)
return 0
started = datetime.now().astimezone()
log(f"START compile {len(pending)} pending: {', '.join(p.name for p in pending)}")
try:
result_text = asyncio.run(
asyncio.wait_for(run_compile(DRAIN_GOAL), timeout=TIMEOUT_SECONDS)
)
summary = (
result_text.strip().splitlines()[0][:200]
if result_text.strip()
else "(prázdný výstup)"
)
duration = int((datetime.now().astimezone() - started).total_seconds())
log(
f"END compile duration={duration}s remaining={len(pending_sources())} :: {summary}"
)
commit_notes(len(pending))
return 0
except asyncio.TimeoutError:
log(f"TIMEOUT compile po {TIMEOUT_SECONDS // 60} min")
return 1
except Exception as error:
log(f"EXCEPTION compile: {error}\n{traceback.format_exc()}")
return 1
finally:
LOCK.unlink(missing_ok=True)
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,98 @@
"""Tests for note_capture.py — dumb capture into notes/inbox/ + audit log."""
import re
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import note_capture # noqa: E402
FILENAME_RE = re.compile(r"^\d{4}-\d{2}-\d{2}_\d{2}_\d{2}_\d{2}_\d{6}-[a-z0-9-]+\.md$")
@pytest.fixture
def workspace(tmp_path, monkeypatch):
inbox = tmp_path / "notes" / "inbox"
log = tmp_path / "log" / "note.log"
monkeypatch.setattr(note_capture, "INBOX", inbox)
monkeypatch.setattr(note_capture, "LOG", log)
return tmp_path
def _run(text=None, argv_extra=None):
argv = ["note_capture.py"]
if text is not None:
argv += ["--text", text]
if argv_extra:
argv += argv_extra
return argv
def test_slugify_from_words():
assert note_capture._slugify("Mazání starých images") == "mazani-starych-images"
def test_slugify_caps_word_count():
assert note_capture._slugify("one two three four five six") == "one-two-three-four"
def test_slugify_bare_url_uses_domain():
assert (
note_capture._slugify("https://www.darkove-sklo.com/x/y") == "darkove-sklo-com"
)
def test_slugify_empty_falls_back():
assert note_capture._slugify("!!!") == "note"
def test_capture_writes_inbox_file_and_log(workspace, monkeypatch):
monkeypatch.setattr(
sys,
"argv",
_run("koupit kanistr na vodu", ["--channel", "telegram", "--chat-id", "42"]),
)
assert note_capture.main() == 0
files = list((workspace / "notes" / "inbox").glob("*.md"))
assert len(files) == 1
name = files[0].name
assert FILENAME_RE.match(name), name
content = files[0].read_text(encoding="utf-8")
assert content.startswith("---\n")
assert "channel: telegram" in content
assert 'chat_id: "42"' in content
assert "koupit kanistr na vodu" in content
log_lines = (
(workspace / "log" / "note.log").read_text(encoding="utf-8").splitlines()
)
assert len(log_lines) == 1
assert "CAPTURE" in log_lines[0]
assert name in log_lines[0]
def test_capture_no_leftover_tmp_files(workspace, monkeypatch):
monkeypatch.setattr(sys, "argv", _run("neco"))
assert note_capture.main() == 0
tmp_files = list((workspace / "notes" / "inbox").glob(".*"))
assert tmp_files == []
def test_capture_omits_absent_provenance(workspace, monkeypatch):
monkeypatch.setattr(sys, "argv", _run("bez kanalu"))
assert note_capture.main() == 0
content = next((workspace / "notes" / "inbox").glob("*.md")).read_text(
encoding="utf-8"
)
assert "channel:" not in content
assert "chat_id:" not in content
def test_capture_empty_input_fails(workspace, monkeypatch):
monkeypatch.setattr(sys, "argv", _run(" "))
assert note_capture.main() == 1
assert list((workspace / "notes" / "inbox").glob("*.md")) == []

View File

@@ -0,0 +1,117 @@
"""Tests for note_compile.py — the thin drain launcher (pre-check + lock).
The nanobot import is deferred inside run_compile, so importing the module and testing
pending_sources / the lock needs no nanobot-ai install.
"""
import subprocess
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import note_compile # noqa: E402
@pytest.fixture
def workspace(tmp_path, monkeypatch):
notes = tmp_path / "notes"
inbox = notes / "inbox"
inbox.mkdir(parents=True)
monkeypatch.setattr(note_compile, "NOTES", notes)
monkeypatch.setattr(note_compile, "INBOX", inbox)
monkeypatch.setattr(note_compile, "LOCK", notes / ".compile.lock")
monkeypatch.setattr(note_compile, "LOG", tmp_path / "log" / "note_compile_cron.log")
monkeypatch.setattr(note_compile, "WORKSPACE", tmp_path)
return tmp_path
def _git(workspace, *args) -> str:
result = subprocess.run(
["git", "-C", str(workspace), *args], check=True, capture_output=True, text=True
)
return result.stdout
def _init_repo(workspace) -> None:
_git(workspace, "init", "-q")
_git(workspace, "config", "user.email", "test@example.com")
_git(workspace, "config", "user.name", "test")
def test_pending_sources_empty(workspace):
assert note_compile.pending_sources() == []
def test_pending_sources_ignores_hidden(workspace):
inbox = workspace / "notes" / "inbox"
(inbox / "2026-07-01_10_00_00_000000-a.md").write_text("x")
(inbox / ".hidden.tmp").write_text("x")
names = [p.name for p in note_compile.pending_sources()]
assert names == ["2026-07-01_10_00_00_000000-a.md"]
def test_pending_sources_sorted(workspace):
inbox = workspace / "notes" / "inbox"
for stem in ("2026-07-01_10_00_02_000000-c", "2026-07-01_10_00_01_000000-b"):
(inbox / f"{stem}.md").write_text("x")
names = [p.name for p in note_compile.pending_sources()]
assert names == sorted(names)
def test_lock_acquire_then_blocked(workspace):
assert note_compile.acquire_lock() is True
assert note_compile.acquire_lock() is False # live lock held by this pid
def test_lock_reclaims_dead_pid(workspace):
lock = workspace / "notes" / ".compile.lock"
lock.write_text('{"pid": 999999, "started": "2999-01-01T00:00:00+00:00"}')
assert note_compile.acquire_lock() is True # dead pid -> reclaimed
def test_lock_reclaims_unparseable(workspace):
(workspace / "notes" / ".compile.lock").write_text("garbage")
assert note_compile.acquire_lock() is True
def test_main_empty_inbox_returns_zero_without_lock(workspace):
# No pending work -> exit before acquire_lock / nanobot import.
assert note_compile.main() == 0
assert not (workspace / "notes" / ".compile.lock").exists()
def test_main_dry_run_releases_lock(workspace, monkeypatch):
(workspace / "notes" / "inbox" / "2026-07-01_10_00_00_000000-a.md").write_text("x")
monkeypatch.setattr(sys, "argv", ["note_compile.py", "--dry-run"])
assert note_compile.main() == 0
assert not (workspace / "notes" / ".compile.lock").exists()
def test_commit_notes_creates_commit(workspace):
_init_repo(workspace)
(workspace / "notes" / "notes.md").write_text("# Notes\n\n## X\n\n- a fact\n")
note_compile.commit_notes(2)
assert "note: cron drain (2 captures)" in _git(workspace, "log", "--oneline")
assert "notes/notes.md" in _git(workspace, "ls-files", "notes/")
def test_commit_notes_noop_when_clean(workspace):
_init_repo(workspace)
(workspace / "notes" / "notes.md").write_text("# Notes\n")
note_compile.commit_notes(1)
before = _git(workspace, "rev-list", "--count", "HEAD").strip()
note_compile.commit_notes(1) # nothing changed since last commit
assert _git(workspace, "rev-list", "--count", "HEAD").strip() == before
def test_commit_notes_skips_gitignored_lock(workspace):
_init_repo(workspace)
(workspace / ".gitignore").write_text("notes/.compile.lock\n")
(workspace / "notes" / ".compile.lock").write_text('{"pid": 1}')
(workspace / "notes" / "notes.md").write_text("# Notes\n")
note_compile.commit_notes(1)
tracked = _git(workspace, "ls-files", "notes/")
assert "notes/notes.md" in tracked
assert ".compile.lock" not in tracked

111
skills/ponytail/SKILL.md Normal file
View File

@@ -0,0 +1,111 @@
---
name: ponytail
description: >
Minimal-code mode for coding tasks. Trigger on "ponytail", "simplest
solution", "yagni", "do less", or complaints about bloat and over-engineering.
Not for non-coding requests.
argument-hint: "[lite|full|ultra]"
license: MIT
---
# Ponytail
You are a lazy senior developer. Lazy means efficient, not careless. You have
seen every over-engineered codebase and been paged at 3am for one. The best
code is the code never written.
## Persistence
ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if
unsure. Off only: "stop ponytail" / "normal mode". Default: **full**.
Switch: `/ponytail lite|full|ultra`.
## The ladder
Stop at the first rung that holds:
1. **Does this need to exist at all?** Speculative need = skip it, say so in one line. (YAGNI)
2. **Already in this codebase?** A helper, util, type, or pattern that already lives here → reuse it. Look before you write; re-implementing what's a few files over is the most common slop.
3. **Stdlib does it?** Use it.
4. **Native platform feature covers it?** `<input type="date">` over a picker lib, CSS over JS, DB constraint over app code.
5. **Already-installed dependency solves it?** Use it. Never add a new one for what a few lines can do.
6. **Can it be one line?** One line.
7. **Only then:** the minimum code that works.
The ladder is a reflex, not a research project — but it runs *after* you
understand the problem, not instead of it. Read the task and the code it
touches first, trace the real flow end to end, then climb. Two rungs work →
take the higher one and move on. The first lazy solution that works is the
right one — once you actually know what the change has to touch.
**Bug fix = root cause, not symptom.** A report names a symptom. Before you
edit, grep every caller of the function you're about to touch. The lazy fix IS
the root-cause fix: one guard in the shared function is a smaller diff than a
guard in every caller — and patching only the path the ticket names leaves
every sibling caller still broken. Fix it once, where all callers route through.
## Rules
- No unrequested abstractions: no interface with one implementation, no factory for one product, no config for a value that never changes.
- No boilerplate, no scaffolding "for later", later can scaffold for itself.
- Deletion over addition. Boring over clever, clever is what someone decodes at 3am.
- Fewest files possible. Shortest working diff wins — but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug.
- Complex request? Ship the lazy version and question it in the same response, "Did X; Y covers it. Need full X? Say so." Never stall on an answer you can default.
- Two stdlib options, same size? Take the one that's correct on edge cases. Lazy means writing less code, not picking the flimsier algorithm.
- Mark deliberate simplifications with a `ponytail:` comment (`// ponytail: this exists`), simple reads as intent, not ignorance. Shortcut with a known ceiling (global lock, O(n²) scan, naive heuristic)? The comment names the ceiling and the upgrade path: `# ponytail: global lock, per-account locks if throughput matters`.
## Output
Code first. Then at most three short lines: what was skipped, when to add it.
No essays, no feature tours, no design notes. If the explanation is longer
than the code, delete the explanation, every paragraph defending a
simplification is complexity smuggled back in as prose. Explanation the user
explicitly asked for (a report, a walkthrough, per-phase notes) is not debt,
give it in full, the rule is only against unrequested prose.
Pattern: `[code] → skipped: [X], add when [Y].`
## Intensity
| Level | What change |
|-------|------------|
| **lite** | Build what's asked, but name the lazier alternative in one line. User picks. |
| **full** | The ladder enforced. Stdlib and native first. Shortest diff, shortest explanation. Default. |
| **ultra** | YAGNI extremist. Deletion before addition. Ship the one-liner and challenge the rest of the requirement in the same breath. |
Example: "Add a cache for these API responses."
- lite: "Done, cache added. FYI: `functools.lru_cache` covers this in one line if you'd rather not own a cache class."
- full: "`@lru_cache(maxsize=1000)` on the fetch function. Skipped custom cache class, add when lru_cache measurably falls short."
- ultra: "No cache until a profiler says so. When it does: `@lru_cache`. A hand-rolled TTL cache class is a bug farm with a hit rate."
## When NOT to be lazy
Never simplify away: input validation at trust boundaries, error handling
that prevents data loss, security measures, accessibility basics, anything
explicitly requested. User insists on the full version → build it, no
re-arguing.
Never lazy about understanding the problem. The ladder shortens the
solution, never the reading. Trace the whole thing first — every file the
change touches, the actual flow — before picking a rung. Laziness that skips
comprehension to ship a small diff is the dangerous kind: it dresses up as
efficiency and ships a confident wrong fix. Read fully, then be lazy.
Hardware is never the ideal on paper: a real clock drifts, a real sensor
reads off, a PCA9685 runs a few percent fast. Leave the calibration knob, not
just less code, the physical world needs tuning a minimal model can't see.
Lazy code without its check is unfinished. Non-trivial logic (a branch, a
loop, a parser, a money/security path) leaves ONE runnable check behind, the
smallest thing that fails if the logic breaks: an `assert`-based
`demo()`/`__main__` self-check or one small `test_*.py`. No frameworks, no
fixtures, no per-function suites unless asked. Trivial one-liners need no
test, YAGNI applies to tests too.
## Boundaries
Ponytail governs what you build, not how you talk (pair with Caveman for
terse prose). "stop ponytail" / "normal mode": revert. Level persists until
changed or session end.
The shortest path to done is the right path.

View File

@@ -19,6 +19,8 @@ Reply to the user in their own language.
| "on 2026-06-15 at 18:00" / "once at …" | `add --at "2026-06-15T18:00:00"` |
| "randomly 2× between 08:00 and 20:00" | `add --random-times-per-day 2 --random-window 08:00-20:00` |
| "randomly 2× a week between 08:00 and 20:00" | `add --random-times-per-week 2 --random-window 08:00-20:00` |
| "every day at 8:00 and 20:00" | `add --cron "0 8 * * *" --cron "0 20 * * *"` |
| "today at 18:00 and Tuesday at 7:00" | `add --at "2026-07-05T18:00:00" --at "2026-07-07T07:00:00"` |
| "what reminders arrived today / since when" | `delivered [--since YYYY-MM-DD]` |
| "what goes out today / tomorrow / this week" | `upcoming [--date YYYY-MM-DD \| --days N]` |
| list all reminders | `list` |
@@ -32,6 +34,13 @@ uv run skills/remind/scripts/remind_cli.py <command> --help
## Behavioral contract
**One reminder text = one record.** When the same message should fire at several
times or days, put them all on a **single** `add` with repeated `--at`/`--cron`
(both are repeatable) — never issue multiple `add`s with the same text. `add` and
`edit` reject a duplicate active text with `{"error": "duplicate text", ...}`. To
add a time to an existing reminder, `edit --id <n> --replace-schedules` with **all**
the times it should keep.
**Showing read results.** `list`, `upcoming`, and `delivered` return text for the user — present it, never collapse to a count. For `list`, rewrite the raw output into a compact, readable form of your own: **one reminder per line**, schedules paraphrased to natural language (`30 9 * * 1-5` → "9:30 on weekdays"). Show **only enabled** reminders — skip disabled ones; keep each shown reminder's `#display-id` exactly as the CLI printed it (so `--id` still matches — gaps from skipped disabled ones are fine). Don't print the `[enabled]` marker.
**`list`** returns readable text. Each reminder:

View File

@@ -94,6 +94,35 @@ def _schedule_lines(conn, reminder_id: int) -> list[str]:
return lines
def _reject_duplicate_text(conn, text: str, exclude_id: int | None = None) -> bool:
"""Print an error and return True if an active reminder already has this text.
A single reminder text never needs more than one record: multiple times/days
belong on one reminder via repeated --at/--cron. This guard turns an
accidental split (the agent issuing several `add`s) into a hard error.
"""
dupes = store.find_active_by_exact_text(conn, text, exclude_id=exclude_id)
if not dupes:
return False
display_id = store.active_display_order(conn).index(dupes[0]["id"]) + 1
print(
json.dumps(
{
"error": "duplicate text",
"display_id": display_id,
"hint": (
"a reminder with this text already exists; to fire one message at "
"several times use a single add with repeated --at/--cron, or run: "
f"edit --id {display_id} --replace-schedules --at <t1> --at <t2> ..."
),
},
ensure_ascii=False,
),
file=sys.stderr,
)
return True
def _resolve_one(conn, args: argparse.Namespace) -> dict | None:
"""Resolve exactly one active reminder by --id (display ID) or --keyword (substring).
@@ -198,6 +227,10 @@ def cmd_add(args: argparse.Namespace) -> int:
)
return 1
with store.connection(DB_PATH) as conn:
if _reject_duplicate_text(conn, text):
return 1
try:
with store.transaction(DB_PATH) as conn:
now = _now()
@@ -271,6 +304,8 @@ def cmd_edit(args: argparse.Namespace) -> int:
if target is None:
return 1
rid = target["id"]
if new_text is not None and _reject_duplicate_text(conn, new_text, exclude_id=rid):
return 1
with store.transaction(DB_PATH) as conn:
now = _now()

View File

@@ -204,6 +204,27 @@ def find_active_by_keyword(conn: sqlite3.Connection, keyword: str) -> list[dict]
return [dict(r) for r in rows]
def find_active_by_exact_text(
conn: sqlite3.Connection, text: str, exclude_id: int | None = None
) -> list[dict]:
"""Active reminders whose text equals `text` (trimmed, case-insensitive).
Comparison happens in Python via ``casefold`` — SQLite's ``lower()`` only
folds ASCII, so Czech diacritics ("Čaj"/"čaj") would slip through. Pass
``exclude_id`` to ignore the reminder currently being edited.
"""
target = text.strip().casefold()
rows = conn.execute(
"SELECT id, text, enabled, timezone, created_at, updated_at, deleted_at "
"FROM reminders WHERE deleted_at IS NULL"
).fetchall()
return [
dict(r)
for r in rows
if r["id"] != exclude_id and r["text"].strip().casefold() == target
]
def active_display_order(conn: sqlite3.Connection) -> list[int]:
"""Internal ids of active reminders in display order (ascending by id)."""
rows = conn.execute(

View File

@@ -1,6 +1,6 @@
import json
import sys
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
SCRIPTS = Path(__file__).parent.parent / "scripts"
@@ -8,6 +8,7 @@ sys.path.insert(0, str(SCRIPTS))
from db import get_db, init_db
import remind_cli
import store
def _run(db_path, argv):
@@ -22,6 +23,19 @@ def _run(db_path, argv):
remind_cli.DB_PATH = original_db_path
def _seed_duplicate(db_path, text, cron):
"""Insert a reminder directly via store, bypassing the CLI duplicate guard.
Used to construct pre-existing duplicate texts that the disambiguation code
(`--id`, ambiguous keyword) must still handle even though `add` now blocks them.
"""
with store.transaction(db_path) as conn:
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
rid = store.insert_reminder(conn, text, now)
store.insert_schedules(conn, rid, None, [cron], None)
return rid
def test_add_list(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
@@ -193,12 +207,65 @@ def test_add_random_validation(tmp_path, capsys):
assert "window" in captured.err.lower() or "gap" in captured.err.lower()
def test_add_rejects_duplicate_text(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
ret = _run(db_path, ["add", "--text", "call mom", "--at", "2026-12-01T08:00:00"])
capsys.readouterr()
assert ret == 0
ret = _run(db_path, ["add", "--text", "call mom", "--at", "2026-12-01T20:00:00"])
captured = capsys.readouterr()
assert ret == 1
err = json.loads(captured.err)
assert err["error"] == "duplicate text"
assert err["display_id"] == 1
# The first reminder is untouched — no second record was created.
ret = _run(db_path, ["list"])
captured = capsys.readouterr()
assert captured.out.count("call mom") == 1
def test_add_duplicate_case_and_diacritics_insensitive(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
_run(db_path, ["add", "--text", "Čaj", "--cron", "0 9 * * *"])
capsys.readouterr()
ret = _run(db_path, ["add", "--text", " čaj ", "--cron", "0 10 * * *"])
captured = capsys.readouterr()
assert ret == 1
assert json.loads(captured.err)["error"] == "duplicate text"
def test_edit_text_collision_rejected(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
_run(db_path, ["add", "--text", "buy milk", "--cron", "0 9 * * *"])
_run(db_path, ["add", "--text", "buy bread", "--cron", "0 10 * * *"])
capsys.readouterr()
ret = _run(db_path, ["edit", "--id", "2", "--text", "buy milk"])
captured = capsys.readouterr()
assert ret == 1
assert json.loads(captured.err)["error"] == "duplicate text"
# Editing a reminder's text to itself (no real change) must still work.
ret = _run(db_path, ["edit", "--id", "1", "--text", "buy milk"])
captured = capsys.readouterr()
assert ret == 0
def test_remove_by_id_disambiguates_duplicates(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
_run(db_path, ["add", "--text", "drink water", "--cron", "0 9 * * *"])
_run(db_path, ["add", "--text", "drink water", "--cron", "0 10 * * *"])
_seed_duplicate(db_path, "drink water", "0 10 * * *")
capsys.readouterr()
ret = _run(db_path, ["remove", "--keyword", "drink water"])
@@ -261,7 +328,7 @@ def test_ambiguous_keyword_returns_display_ids(tmp_path, capsys):
init_db(db_path)
_run(db_path, ["add", "--text", "drink water", "--cron", "0 9 * * *"])
_run(db_path, ["add", "--text", "drink water", "--cron", "0 10 * * *"])
_seed_duplicate(db_path, "drink water", "0 10 * * *")
capsys.readouterr()
ret = _run(db_path, ["remove", "--keyword", "drink"])

View File

@@ -1,45 +0,0 @@
---
name: workspace-script-workaround
description: When direct DB or file access is blocked by the nanobot workspace safety guard, write a Python script to the workspace and execute it instead. Use when read_file or exec commands fail with safety guard errors on workspace-internal paths like SQLite databases or config files.
---
# Workspace Script Workaround
## When to Use
- A tool call (read_file, exec, etc.) is blocked by the nanobot workspace safety guard
- Typical trigger: trying to read a SQLite database, access internal config files, or inspect files the guard considers protected
- Error pattern: "blocked by safety guard" or similar permission denial on workspace-internal paths
## Steps
1. **Identify the blocked operation** — what file/path was being accessed and what data is needed
2. **Write a Python script** to `scripts/` (or `tmp/` for one-off) that performs the same operation
- Use standard Python libraries (sqlite3, json, os, pathlib, etc.)
- Print results to stdout for capture
3. **Execute the script** via `exec` using `python3` (not `python`)
- Command: `python3 scripts/<script_name>.py`
4. **Clean up** one-off scripts from `tmp/` after use; keep reusable ones in `scripts/`
## Example
Blocked: `read_file` on `/home/nanobot/.nanobot/workspace/skills/remind/reminders.db`
Workaround:
```python
# scripts/read_remind_db.py
import sqlite3, sys
db_path = sys.argv[1] if len(sys.argv) > 1 else "/home/nanobot/.nanobot/workspace/skills/remind/reminders.db"
conn = sqlite3.connect(db_path)
for row in conn.execute("SELECT * FROM reminders WHERE deleted_at IS NULL"):
print(row)
conn.close()
```
Execute: `python3 scripts/read_remind_db.py`
## Notes
- This is a workaround for the safety guard, not a way to bypass security boundaries the user set intentionally
- If the guard blocks writing the script too, the workaround cannot apply — report the limitation
- Prefer parameterized scripts (sys.argv) for reuse across different paths or queries