provozni zaloha
This commit is contained in:
@@ -7,8 +7,9 @@ description: >
|
||||
|
||||
# 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.
|
||||
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.
|
||||
|
||||
## Backend
|
||||
|
||||
@@ -26,25 +27,57 @@ Tags are the **first token** right after the trigger — comma-separated, no spa
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Tags are created automatically on first use — no registration needed.
|
||||
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.
|
||||
|
||||
## Write protocol
|
||||
|
||||
1. Extract inline tags from the first token (see Tag protocol above).
|
||||
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. Echo: `Noted [#1]: <content> [#tag1 #tag2]` (tags omitted if none).
|
||||
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.
|
||||
|
||||
No dedup. No MEMORY.md lookup. Blind append.
|
||||
|
||||
## Tag management
|
||||
|
||||
Tags are created and listed explicitly — never as a side effect of adding a note.
|
||||
|
||||
Trigger (create): `/note tag add X`, "create tag X", "register tag X".
|
||||
|
||||
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.
|
||||
|
||||
Trigger (list): `/note tags`, "what tags are there?", "list tags".
|
||||
|
||||
1. Run: `uv run skills/note/scripts/note.py tag-list`
|
||||
2. Echo output. Empty → "No tags."
|
||||
|
||||
Tags are referenced by name everywhere (no display ID). There is no tag deletion.
|
||||
|
||||
## List protocol
|
||||
|
||||
Trigger: `/note list`, `show notes`, `what notes do you have?`
|
||||
@@ -55,7 +88,35 @@ Trigger: `/note list`, `show notes`, `what notes do you have?`
|
||||
`--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.
|
||||
among active notes, newest first. Renumbers after every deletion. Never change,
|
||||
renumber, or drop it.
|
||||
|
||||
### URLs in a note
|
||||
|
||||
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.
|
||||
|
||||
## Show protocol
|
||||
|
||||
Trigger: `/note show <id>`, `show note N`, `read note N`, `what does note N say`.
|
||||
|
||||
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.
|
||||
|
||||
The block contains every stored field: display ID, internal DB id, creation
|
||||
timestamp, tags, and full untruncated content.
|
||||
|
||||
## Delete protocol
|
||||
|
||||
@@ -74,6 +135,8 @@ becomes #3). Always run `list` first if unsure of current IDs.
|
||||
|
||||
- `/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.
|
||||
|
||||
|
||||
0
skills/note/notes.db
Normal file
0
skills/note/notes.db
Normal file
118
skills/note/scripts/note.py
Normal file → Executable file
118
skills/note/scripts/note.py
Normal file → Executable file
@@ -22,6 +22,10 @@ DB_PATH = Path(__file__).resolve().parent.parent.parent.parent / "db" / "note.sq
|
||||
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 (
|
||||
@@ -31,6 +35,10 @@ CREATE TABLE IF NOT EXISTS notes (
|
||||
created_at TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
name TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
@@ -47,6 +55,23 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
||||
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
|
||||
@@ -76,6 +101,23 @@ def _tags_display(tags_json: str) -> str:
|
||||
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]
|
||||
@@ -101,6 +143,11 @@ def cmd_add(args: argparse.Namespace) -> int:
|
||||
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),
|
||||
@@ -144,7 +191,8 @@ def cmd_list(args: argparse.Namespace) -> int:
|
||||
print("No notes.")
|
||||
return 0
|
||||
for row in rows:
|
||||
print(f"{id_to_display[row['id']]}. {row['content']}{_tags_display(row['tags'])}")
|
||||
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
|
||||
|
||||
|
||||
@@ -169,6 +217,60 @@ def cmd_delete(args: argparse.Namespace) -> int:
|
||||
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)
|
||||
@@ -182,17 +284,31 @@ def _main() -> int:
|
||||
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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user