Zalohovani vsech podstatnych souboru
This commit is contained in:
88
skills/note/SKILL.md
Normal file
88
skills/note/SKILL.md
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
name: note
|
||||
description: >
|
||||
Explicit notes.
|
||||
Use when user says "note X", "note it".
|
||||
---
|
||||
|
||||
# Note
|
||||
|
||||
Explicit note store backed by SQLite. User says "note X" → extract tags,
|
||||
reformulate content, store via `note.py add`. Delete only on explicit user request. Notes are stored to sqlite db.
|
||||
|
||||
## Backend
|
||||
|
||||
`skills/note/scripts/note.py` — CLI wrapper around `db/note.sqlite`.
|
||||
Operation log: `log/note.log` (append-only, all write operations).
|
||||
|
||||
## Tag protocol
|
||||
|
||||
Tags are the **first token** right after the trigger — comma-separated, no spaces:
|
||||
|
||||
```
|
||||
/note arch explanation of the architecture decision → tags: [arch]
|
||||
/note hw,linux interesting article about kernel → tags: [hw, linux]
|
||||
/note this is a note without tags → tags: []
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Lowercase only; multi-word tags use `-`: `cli`, `soft-delete`, `task-queue`
|
||||
- If user writes `#tag`, strip `#` before passing to the script
|
||||
- If no tag is given — that is fine, use no tags; never force tags
|
||||
|
||||
Tags are created automatically on first use — no registration needed.
|
||||
|
||||
## Write protocol
|
||||
|
||||
1. Extract inline tags from the first token (see Tag protocol above).
|
||||
2. Reformulate the remaining text into a terse fact. One concept per entry —
|
||||
split if too complex; omit context that is not itself a fact. Preserve
|
||||
input language; never translate. Drop filler.
|
||||
- Input: "poznamenej si, glow zobrazuje markdown v terminálu #cli"
|
||||
- Run: `uv run skills/note/scripts/note.py add "glow displays markdown in terminal" --tags cli`
|
||||
3. Echo: `Noted [#1]: <content> [#tag1 #tag2]` (tags omitted if none).
|
||||
`#1` is the display ID of the new note — use it to delete immediately if needed.
|
||||
|
||||
No dedup. No MEMORY.md lookup. Blind append.
|
||||
|
||||
## List protocol
|
||||
|
||||
Trigger: `/note list`, `show notes`, `what notes do you have?`
|
||||
|
||||
1. Run: `uv run skills/note/scripts/note.py list [--limit N] [--tag TAG [TAG ...]]`
|
||||
2. Echo output. If empty → respond "No notes."
|
||||
|
||||
`--tag` accepts one or more tags; OR logic (notes with at least one matching tag).
|
||||
|
||||
The number before each note (`1.`, `2.`, …) is the **display ID** — sequential
|
||||
among active notes, newest first. Renumbers after every deletion.
|
||||
|
||||
## Delete protocol
|
||||
|
||||
Trigger: `/note delete`, `delete a note`, `remove a note`.
|
||||
|
||||
1. If the user has not specified an ID, run `list` first to show current notes.
|
||||
2. Run: `uv run skills/note/scripts/note.py delete <display-id>`
|
||||
- Exit 0 → confirm deletion.
|
||||
- Exit 1 → display ID out of range; respond accordingly.
|
||||
3. Nothing is deleted automatically. Only this explicit protocol deletes.
|
||||
|
||||
Display IDs renumber after every deletion (e.g., after deleting #3, the old #4
|
||||
becomes #3). Always run `list` first if unsure of current IDs.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- `/note` with no content → ask "What should I note?"
|
||||
- Vague input → ask for the concrete fact; do not store a placeholder.
|
||||
- `/note delete` with no ID → run `list` first, then ask which display ID.
|
||||
- Multi-line input → collapse to one line; one entry = one row.
|
||||
|
||||
## Rules
|
||||
|
||||
- Never store verbatim input. Always reformulate. Preserve input language.
|
||||
- Do not store smalltalk or meta-commentary about the note skill itself.
|
||||
- **No auto-load:** `note.sqlite` is never referenced in bootstrap files.
|
||||
- **No auto-delete / no compaction.** Only explicit delete marks an entry.
|
||||
- **Delete is soft** — the entry is marked with a timestamp, not removed from
|
||||
the database. The operation log (`log/note.log`) is the primary audit trail.
|
||||
- Separate from `/keep`, `MEMORY.md`, Dream — never cross-write or cross-read.
|
||||
200
skills/note/scripts/note.py
Normal file
200
skills/note/scripts/note.py
Normal file
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# dependencies = []
|
||||
# ///
|
||||
|
||||
"""
|
||||
note.py — backend for /note skill.
|
||||
SQLite-backed note store with tags, soft-delete, and operation log.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
DB_PATH = Path(__file__).resolve().parent.parent.parent.parent / "db" / "note.sqlite"
|
||||
LOG_PATH = Path(__file__).resolve().parent.parent.parent.parent / "log" / "note.log"
|
||||
|
||||
_TAG_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content TEXT NOT NULL,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def _init_db(conn: sqlite3.Connection) -> None:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.executescript(SCHEMA)
|
||||
_migrate(conn)
|
||||
|
||||
|
||||
def _migrate(conn: sqlite3.Connection) -> None:
|
||||
cols = {row[1] for row in conn.execute("PRAGMA table_info(notes)")}
|
||||
if "tags" not in cols:
|
||||
conn.execute("ALTER TABLE notes ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'")
|
||||
if "deleted_at" not in cols:
|
||||
conn.execute("ALTER TABLE notes ADD COLUMN deleted_at TEXT")
|
||||
conn.commit()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _connect() -> Generator[sqlite3.Connection, None, None]:
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
_init_db(conn)
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _validate_tags(tags: list[str]) -> None:
|
||||
for tag in tags:
|
||||
if not _TAG_RE.match(tag):
|
||||
raise ValueError(
|
||||
f"Invalid tag '{tag}' — use lowercase letters, digits, hyphens only (e.g. cli, soft-delete)"
|
||||
)
|
||||
|
||||
|
||||
def _tags_display(tags_json: str) -> str:
|
||||
tags = json.loads(tags_json)
|
||||
if not tags:
|
||||
return ""
|
||||
return " [" + " ".join(f"#{t}" for t in tags) + "]"
|
||||
|
||||
|
||||
def _log(op: str, detail: str) -> None:
|
||||
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
||||
with LOG_PATH.open("a") as f:
|
||||
f.write(f"{ts} {op} {detail}\n")
|
||||
|
||||
|
||||
def _active_ids(conn: sqlite3.Connection) -> list[int]:
|
||||
rows = conn.execute(
|
||||
"SELECT id FROM notes WHERE deleted_at IS NULL ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
return [row["id"] for row in rows]
|
||||
|
||||
|
||||
def cmd_add(args: argparse.Namespace) -> int:
|
||||
tags: list[str] = args.tags or []
|
||||
try:
|
||||
_validate_tags(tags)
|
||||
except ValueError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
content = args.text.strip()
|
||||
tags_json = json.dumps(tags)
|
||||
created_at = datetime.now(timezone.utc).isoformat()
|
||||
with _connect() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO notes(content, tags, created_at) VALUES(?, ?, ?)",
|
||||
(content, tags_json, created_at),
|
||||
)
|
||||
conn.commit()
|
||||
nid = cur.lastrowid
|
||||
tags_log = ",".join(tags)
|
||||
_log("ADD", f"id={nid} tags=[{tags_log}] {content}")
|
||||
print(f"Noted [#1]: {content}{_tags_display(tags_json)}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_list(args: argparse.Namespace) -> int:
|
||||
with _connect() as conn:
|
||||
id_to_display = {nid: i + 1 for i, nid in enumerate(_active_ids(conn))}
|
||||
if args.tag:
|
||||
placeholders = ",".join("?" * len(args.tag))
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT id, content, tags FROM notes
|
||||
WHERE deleted_at IS NULL
|
||||
AND (
|
||||
SELECT count(*) FROM json_each(notes.tags)
|
||||
WHERE value IN ({placeholders})
|
||||
) > 0
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(*args.tag, args.limit, args.offset),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, content, tags FROM notes"
|
||||
" WHERE deleted_at IS NULL"
|
||||
" ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
(args.limit, args.offset),
|
||||
).fetchall()
|
||||
tag_filter = ",".join(args.tag) if args.tag else "None"
|
||||
_log("LIST", f"tag={tag_filter} returned={len(rows)}")
|
||||
if not rows:
|
||||
print("No notes.")
|
||||
return 0
|
||||
for row in rows:
|
||||
print(f"{id_to_display[row['id']]}. {row['content']}{_tags_display(row['tags'])}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_delete(args: argparse.Namespace) -> int:
|
||||
display_id: int = args.id
|
||||
deleted_at = datetime.now(timezone.utc).isoformat()
|
||||
with _connect() as conn:
|
||||
ids = _active_ids(conn)
|
||||
idx = display_id - 1
|
||||
if idx < 0 or idx >= len(ids):
|
||||
print(f"No active note with display id={display_id}.")
|
||||
return 1
|
||||
nid = ids[idx]
|
||||
row = conn.execute(
|
||||
"SELECT id, content, tags FROM notes WHERE id = ?", (nid,)
|
||||
).fetchone()
|
||||
conn.execute("UPDATE notes SET deleted_at = ? WHERE id = ?", (deleted_at, nid))
|
||||
conn.commit()
|
||||
tags_log = ",".join(json.loads(row["tags"]))
|
||||
_log("DELETE", f"display_id={display_id} id={nid} tags=[{tags_log}] content={row['content']!r}")
|
||||
print(f"Deleted: {row['content']}")
|
||||
return 0
|
||||
|
||||
|
||||
def _main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Note store")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_add = sub.add_parser("add", help="Add a note")
|
||||
p_add.add_argument("text", help="Note content")
|
||||
p_add.add_argument("--tags", nargs="+", metavar="TAG", default=[], help="Tags (lowercase, hyphens allowed)")
|
||||
|
||||
p_list = sub.add_parser("list", help="List active notes")
|
||||
p_list.add_argument("--limit", type=int, default=50)
|
||||
p_list.add_argument("--offset", type=int, default=0)
|
||||
p_list.add_argument("--tag", nargs="+", metavar="TAG", help="Filter by tag (OR logic)")
|
||||
|
||||
p_del = sub.add_parser("delete", help="Soft-delete a note by ID")
|
||||
p_del.add_argument("id", type=int, help="Note ID")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.cmd == "add":
|
||||
return cmd_add(args)
|
||||
if args.cmd == "list":
|
||||
return cmd_list(args)
|
||||
if args.cmd == "delete":
|
||||
return cmd_delete(args)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_main())
|
||||
Reference in New Issue
Block a user