From 8e66d6b92a793c277a07bac9a8bf4f2bf3c8b4c9 Mon Sep 17 00:00:00 2001 From: lachtan Date: Wed, 22 Jul 2026 12:32:02 +0200 Subject: [PATCH] Update projektu --- skills/bookmark/SKILL.md | 86 +++- skills/bookmark/scripts/bookmark.py | 108 ++++- skills/bookmark/scripts/html_to_markdown.py | 43 ++ skills/bookmark/tests/test_bookmark.py | 180 ++++++++ skills/compact-memory/SKILL.md | 2 + .../scripts/compact_memory_auto.py | 95 ++++ skills/flight-search/SKILL.md | 163 +++++++ skills/flight-search/scripts/flight_search.py | 416 ++++++++++++++++++ skills/keep/SKILL.md | 10 +- skills/note/SKILL.md | 243 +++++----- skills/note/notes.db | 0 skills/note/scripts/note.py | 316 ------------- skills/note/scripts/note_capture.py | 111 +++++ skills/note/scripts/note_compile.py | 216 +++++++++ skills/note/tests/test_note_capture.py | 98 +++++ skills/note/tests/test_note_compile.py | 117 +++++ skills/ponytail/SKILL.md | 111 +++++ skills/remind/SKILL.md | 9 + skills/remind/scripts/remind_cli.py | 35 ++ skills/remind/scripts/store.py | 21 + skills/remind/tests/test_remind_cli.py | 73 ++- skills/workspace-script-workaround/SKILL.md | 45 -- 22 files changed, 1995 insertions(+), 503 deletions(-) create mode 100644 skills/bookmark/scripts/html_to_markdown.py create mode 100644 skills/bookmark/tests/test_bookmark.py create mode 100755 skills/compact-memory/scripts/compact_memory_auto.py create mode 100644 skills/flight-search/SKILL.md create mode 100644 skills/flight-search/scripts/flight_search.py delete mode 100644 skills/note/notes.db delete mode 100755 skills/note/scripts/note.py create mode 100644 skills/note/scripts/note_capture.py create mode 100644 skills/note/scripts/note_compile.py create mode 100644 skills/note/tests/test_note_capture.py create mode 100644 skills/note/tests/test_note_compile.py create mode 100644 skills/ponytail/SKILL.md delete mode 100644 skills/workspace-script-workaround/SKILL.md diff --git a/skills/bookmark/SKILL.md b/skills/bookmark/SKILL.md index b1b2638..a4db403 100644 --- a/skills/bookmark/SKILL.md +++ b/skills/bookmark/SKILL.md @@ -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 [args] ``` @@ -21,14 +24,47 @@ bookmark.py add "" [--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 ` — 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 ``, `
`, `

` 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 "" "" [--tags a,b] --content-file - + +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 "" "" [--tags a,b] --content-file - <<'ARTICLE' +

+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 ` and `show ` take the display ID from **`list`** (the unread set). +- `read `, `show `, `content `, and `delete ` take the display ID from **`list`** (the unread set). - `unread ` 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 bookmark.py show ``` -`` is the number from `list`. Shows full URL, description, tags, status, and dates. Does **not** change any state. +`` 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 +``` + +`` 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 +``` + +`` 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: `# []() [] — ` +Format: `# []() — []` - 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 ` - **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 ` (from `list`) -4. User finishes an article → `read ` (from `list`) -5. User wants to revisit → `unread ` (from `history`) or `history` \ No newline at end of file +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 ` (from `list`) +5. User wants to read an archived article → `content ` (from `list`) +6. User finishes an article → `read ` (from `list`) +7. User wants to revisit → `unread ` (from `history`) or `history` +8. User wants to remove one (e.g. accidental duplicate) → confirm, then `delete ` (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 diff --git a/skills/bookmark/scripts/bookmark.py b/skills/bookmark/scripts/bookmark.py index b4a3068..feb9d42 100644 --- a/skills/bookmark/scripts/bookmark.py +++ b/skills/bookmark/scripts/bookmark.py @@ -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() diff --git a/skills/bookmark/scripts/html_to_markdown.py b/skills/bookmark/scripts/html_to_markdown.py new file mode 100644 index 0000000..cbd0d67 --- /dev/null +++ b/skills/bookmark/scripts/html_to_markdown.py @@ -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 "" "" --content-file - + + 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()) diff --git a/skills/bookmark/tests/test_bookmark.py b/skills/bookmark/tests/test_bookmark.py new file mode 100644 index 0000000..8e19c43 --- /dev/null +++ b/skills/bookmark/tests/test_bookmark.py @@ -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 = """EFI Boot +
+ +
+

Understanding EFI Boot

+

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.

+

How it works

+

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.

+
+
Copyright 2026 Example Inc. Share Tweet Subscribe to our newsletter.
+""" + + +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 diff --git a/skills/compact-memory/SKILL.md b/skills/compact-memory/SKILL.md index a7e249f..9761b5c 100644 --- a/skills/compact-memory/SKILL.md +++ b/skills/compact-memory/SKILL.md @@ -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. diff --git a/skills/compact-memory/scripts/compact_memory_auto.py b/skills/compact-memory/scripts/compact_memory_auto.py new file mode 100755 index 0000000..2676db4 --- /dev/null +++ b/skills/compact-memory/scripts/compact_memory_auto.py @@ -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()) diff --git a/skills/flight-search/SKILL.md b/skills/flight-search/SKILL.md new file mode 100644 index 0000000..b89f8f9 --- /dev/null +++ b/skills/flight-search/SKILL.md @@ -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 \ + --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 --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 +``` + +## 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 ` | Top N výsledků seřazených podle ceny | +| `list-searches` | Seznam všech hledání | +| `delete-search ` | 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) \ No newline at end of file diff --git a/skills/flight-search/scripts/flight_search.py b/skills/flight-search/scripts/flight_search.py new file mode 100644 index 0000000..5a4631b --- /dev/null +++ b/skills/flight-search/scripts/flight_search.py @@ -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() \ No newline at end of file diff --git a/skills/keep/SKILL.md b/skills/keep/SKILL.md index f198003..155fc6b 100644 --- a/skills/keep/SKILL.md +++ b/skills/keep/SKILL.md @@ -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 diff --git a/skills/note/SKILL.md b/skills/note/SKILL.md index 1e7a76b..4907b11 100644 --- a/skills/note/SKILL.md +++ b/skills/note/SKILL.md @@ -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/