nanobot: 2026-09-11 15:17:08

This commit is contained in:
lachtan
2026-09-11 15:17:08 +02:00
parent eae589cb91
commit fb37267ff1
6 changed files with 170 additions and 84 deletions

View File

@@ -34,6 +34,10 @@ Never use `/tmp/`, hardcoded absolute paths, or in-memory databases for persiste
The exec safety guard blocks commands without an explicit workspace path (e.g. `lua -e '...'`, `which`). Write scripts to files inside the workspace (e.g. `tmp/script.lua`) and run them with `working_dir` set to the workspace root. The exec safety guard blocks commands without an explicit workspace path (e.g. `lua -e '...'`, `which`). Write scripts to files inside the workspace (e.g. `tmp/script.lua`) and run them with `working_dir` set to the workspace root.
**Never put prose or user-supplied text into a command string** — not as an argument, not in a heredoc, not through a pipe. The guard scans the raw command string and has no shell parser, so quoting does not help. Any `X:` where `X` is an ASCII letter not preceded by another ASCII letter parses as a Windows drive path and blocks the whole command: Czech `Cíl:`, `Závěr:`, `směr:` all trip it (the diacritic before the letter defeats the guard's ASCII-only lookbehind), and so does `date '+%H:%M:%S'`. A literal `../` anywhere in the command — even inside prose — trips the traversal guard too.
Instead: `write_file` the text to `tmp/`, then pass the **path** (`--file tmp/x.md`, or `< tmp/x.md`). A path in the command is safe, and file tools are not subject to this guard. Skill CLIs that take text follow this — see `skills/project` and `skills/note`.
## python — use uv ## python — use uv
For all Python code use `uv`, never `python` / `python3` / `pip` / `poetry` / `conda` directly. Details in `skills/python/SKILL.md`. For all Python code use `uv`, never `python` / `python3` / `pip` / `poetry` / `conda` directly. Details in `skills/python/SKILL.md`.

View File

@@ -84,8 +84,8 @@
"originMetadata": {} "originMetadata": {}
}, },
"state": { "state": {
"nextRunAtMs": 1789119381693, "nextRunAtMs": 1789133781832,
"lastRunAtMs": 1789112181683, "lastRunAtMs": 1789126581821,
"lastStatus": "ok", "lastStatus": "ok",
"lastError": null, "lastError": null,
"runHistory": [ "runHistory": [
@@ -154,11 +154,23 @@
"status": "ok", "status": "ok",
"durationMs": 10, "durationMs": 10,
"error": null "error": null
},
{
"runAtMs": 1789119381802,
"status": "ok",
"durationMs": 17,
"error": null
},
{
"runAtMs": 1789126581821,
"status": "ok",
"durationMs": 11,
"error": null
} }
] ]
}, },
"createdAtMs": 1789032980434, "createdAtMs": 1789032980434,
"updatedAtMs": 1789112181693, "updatedAtMs": 1789126581832,
"deleteAfterRun": false "deleteAfterRun": false
}, },
{ {
@@ -185,77 +197,11 @@
"originMetadata": {} "originMetadata": {}
}, },
"state": { "state": {
"nextRunAtMs": 1789113981697, "nextRunAtMs": 1789133782309,
"lastRunAtMs": 1789112181696, "lastRunAtMs": 1789131982309,
"lastStatus": "ok", "lastStatus": "ok",
"lastError": null, "lastError": null,
"runHistory": [ "runHistory": [
{
"runAtMs": 1789077981273,
"status": "ok",
"durationMs": 0,
"error": null
},
{
"runAtMs": 1789079781275,
"status": "ok",
"durationMs": 0,
"error": null
},
{
"runAtMs": 1789081581276,
"status": "ok",
"durationMs": 1,
"error": null
},
{
"runAtMs": 1789083381371,
"status": "ok",
"durationMs": 0,
"error": null
},
{
"runAtMs": 1789085181373,
"status": "ok",
"durationMs": 1,
"error": null
},
{
"runAtMs": 1789086981376,
"status": "ok",
"durationMs": 0,
"error": null
},
{
"runAtMs": 1789088781378,
"status": "ok",
"durationMs": 1,
"error": null
},
{
"runAtMs": 1789090581388,
"status": "ok",
"durationMs": 0,
"error": null
},
{
"runAtMs": 1789092381490,
"status": "ok",
"durationMs": 0,
"error": null
},
{
"runAtMs": 1789094181491,
"status": "ok",
"durationMs": 0,
"error": null
},
{
"runAtMs": 1789095981494,
"status": "ok",
"durationMs": 0,
"error": null
},
{ {
"runAtMs": 1789097781494, "runAtMs": 1789097781494,
"status": "ok", "status": "ok",
@@ -309,11 +255,77 @@
"status": "ok", "status": "ok",
"durationMs": 1, "durationMs": 1,
"error": null "error": null
},
{
"runAtMs": 1789113981699,
"status": "ok",
"durationMs": 0,
"error": null
},
{
"runAtMs": 1789115781701,
"status": "ok",
"durationMs": 7,
"error": null
},
{
"runAtMs": 1789117581711,
"status": "ok",
"durationMs": 0,
"error": null
},
{
"runAtMs": 1789119381819,
"status": "ok",
"durationMs": 0,
"error": null
},
{
"runAtMs": 1789121181821,
"status": "ok",
"durationMs": 0,
"error": null
},
{
"runAtMs": 1789122981965,
"status": "ok",
"durationMs": 1,
"error": null
},
{
"runAtMs": 1789124781968,
"status": "ok",
"durationMs": 1,
"error": null
},
{
"runAtMs": 1789126582256,
"status": "ok",
"durationMs": 0,
"error": null
},
{
"runAtMs": 1789128382257,
"status": "ok",
"durationMs": 1,
"error": null
},
{
"runAtMs": 1789130182259,
"status": "ok",
"durationMs": 0,
"error": null
},
{
"runAtMs": 1789131982309,
"status": "ok",
"durationMs": 0,
"error": null
} }
] ]
}, },
"createdAtMs": 1789032980437, "createdAtMs": 1789032980437,
"updatedAtMs": 1789112181697, "updatedAtMs": 1789131982309,
"deleteAfterRun": false "deleteAfterRun": false
} }
] ]

View File

@@ -48,3 +48,6 @@ Klíčová designová rozhodnutí: (1) brief je psaný pro čtenáře-agenta se
- 2026-09-10: Self-analysis (na dotaz uživatele „proc jsi sám nevytvořil minimalistickou verzi?"): tři příčiny přepsání /handoff skillu. (1) Psal jsem pro špatného čtenáře — skill čte model, který potřebuje šablonu výstupu + constrainty, ne tréninkový dokument; defaultl jsem na dokumentační styl, protože většina mých artefaktů je pro člověka. Ukázková šablona > popis šablony. (2) Optimalizoval jsem na vypadání důkladně (Rules/Procedure jako zviditelněné uvažování) místo na funkci — uvažování patří do konverzace, ne do artefaktu. (3) Chyběl test „vyrobí tohle spolehlivě dobrý brief?", který se ověřuje použitím a ořezáváním, ne dodáním plné verze bez jediného běhu. Paradox: vlastní rule 4 skillu („be specific, not complete") jsem v samotném skillu porušil. Společný jmenovatel: rozhodnutí podle spotřebitele výstupu, ne podle vlastních zvyků. Diagnostika zrcadlová k Solarflare: tam runbook pro agenta místo člověka, tady pro člověka místo agenta. Pravidlo: před sepsáním skillu určit, kdo ho čte v momentě použití, a psát pro něj. - 2026-09-10: Self-analysis (na dotaz uživatele „proc jsi sám nevytvořil minimalistickou verzi?"): tři příčiny přepsání /handoff skillu. (1) Psal jsem pro špatného čtenáře — skill čte model, který potřebuje šablonu výstupu + constrainty, ne tréninkový dokument; defaultl jsem na dokumentační styl, protože většina mých artefaktů je pro člověka. Ukázková šablona > popis šablony. (2) Optimalizoval jsem na vypadání důkladně (Rules/Procedure jako zviditelněné uvažování) místo na funkci — uvažování patří do konverzace, ne do artefaktu. (3) Chyběl test „vyrobí tohle spolehlivě dobrý brief?", který se ověřuje použitím a ořezáváním, ne dodáním plné verze bez jediného běhu. Paradox: vlastní rule 4 skillu („be specific, not complete") jsem v samotném skillu porušil. Společný jmenovatel: rozhodnutí podle spotřebitele výstupu, ne podle vlastních zvyků. Diagnostika zrcadlová k Solarflare: tam runbook pro agenta místo člověka, tady pro člověka místo agenta. Pravidlo: před sepsáním skillu určit, kdo ho čte v momentě použití, a psát pro něj.
- 2026-09-10: Prompting recept (na dotaz uživatele „jak to příště zadat, abych dostal lepší výstup?"): pro skládání skillů/artefaktů zadat 4 věci, které mě vedly k přepsání /handoffu: (1) kdo čte výstup v momentě použití (model vs člověk), (2) ukázka/kostra cílové podoby — „chci tohle, jen to napiš pořádně", (3) constraint na délku — „nejkratší verze, která funguje", (4) požadavek na ukázku použití („ukaž výstup na reálném příkladu") — tím se spustí test místo speculativního dodání plné verze. Poznámka pro mě: i bez zadání bych měl (1) a (4) udělat sám; (2) a (3) jsou legitimní požadavky na uživatele, protože definují chuť výstupu. - 2026-09-10: Prompting recept (na dotaz uživatele „jak to příště zadat, abych dostal lepší výstup?"): pro skládání skillů/artefaktů zadat 4 věci, které mě vedly k přepsání /handoffu: (1) kdo čte výstup v momentě použití (model vs člověk), (2) ukázka/kostra cílové podoby — „chci tohle, jen to napiš pořádně", (3) constraint na délku — „nejkratší verze, která funguje", (4) požadavek na ukázku použití („ukaž výstup na reálném příkladu") — tím se spustí test místo speculativního dodání plné verze. Poznámka pro mě: i bez zadání bych měl (1) a (4) udělat sám; (2) a (3) jsou legitimní požadavky na uživatele, protože definují chuť výstupu.
- 2026-09-10: correction: Položku (1) „kdo čte výstup" z prompting receptu stahuji — u skillů plyne ze samotné definice (soubor pro model v momentě použití), není to legitimní zadání od uživatele, ale moje povinnost. Skutečná příčina: nezapátrání targetu PŘED psaním; default dokumentačního stylu vyhrál, protože čtenář nebyl první určující otázkou. Recept zredukován na 2 položky od uživatele (ukázka/kostra cílové podoby, constraint na délku) + 2 položky moje (čtenář z definice typu souboru, ukázka výstupu na reálném příkladu). - 2026-09-10: correction: Položku (1) „kdo čte výstup" z prompting receptu stahuji — u skillů plyne ze samotné definice (soubor pro model v momentě použití), není to legitimní zadání od uživatele, ale moje povinnost. Skutečná příčina: nezapátrání targetu PŘED psaním; default dokumentačního stylu vyhrál, protože čtenář nebyl první určující otázkou. Recept zredukován na 2 položky od uživatele (ukázka/kostra cílové podoby, constraint na délku) + 2 položky moje (čtenář z definice typu souboru, ukázka výstupu na reálném příkladu).
- 2026-09-11: OpenWebUI zakázáno (2026-09-12) — žere sílně paměť a občas zasekne celý server. Jako náhradu chci vyzkoušet LibreChat, konkrétně self-hosted Docker variantu dle oficiální dokumentace:
https://www.librechat.ai/docs/local/docker

View File

@@ -52,6 +52,13 @@ them. The Dream processor must not touch `notes/`.
**Run scripts with `uv run`, workspace-relative paths** (exec runs from the workspace **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 …`. root, not the skill dir): `uv run skills/note/scripts/<script>.py …`.
**Never pass the text on the command line.** Not as an argument, not in a heredoc, not
through a pipe — always `write_file` it to `tmp/` and pass the path. The exec safety
guard scans the raw command string and has no shell parser, so quoting does not help: a
colon right after a letter that follows a diacritic parses as a Windows drive path, and
a note like `Cíl: koupit mléko` blocks the whole command with *path outside working dir*.
Since capture takes the user's input verbatim, this would hit real notes, not edge cases.
**Language.** This skill body is English; always reply to the user in the user's own **Language.** This skill body is English; always reply to the user in the user's own
language. language.
@@ -60,9 +67,10 @@ language.
The default: file the note into the knowledge base immediately, in this turn. The default: file the note into the knowledge base immediately, in this turn.
1. Read `Channel` / `Chat ID` from the runtime context if present. 1. Read `Channel` / `Chat ID` from the runtime context if present.
2. Capture: 2. Capture, in two steps — `write_file` the raw input to `tmp/note-capture.md`, then:
`uv run skills/note/scripts/note_capture.py --text "<raw input>" [--channel <ch>] [--chat-id <id>]` `uv run skills/note/scripts/note_capture.py --file tmp/note-capture.md [--channel <ch>] [--chat-id <id>]`
Pass the input **as-is** — do not reformulate or strip URLs here. Pass the input **as-is** — do not reformulate or strip URLs here. See
*Never pass the text on the command line* below for why it goes through a file.
3. Run the **Compile workflow** (below) inline: acquire the lock, process `notes/inbox/`, 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/`). file into `notes/notes.md`, move the source to `notes/done/` (or `notes/hard/`).
4. **Commit** the change (see *Versioning* below): via `exec` run 4. **Commit** the change (see *Versioning* below): via `exec` run
@@ -79,7 +87,10 @@ the user is firing off many notes quickly, suggest `/note cron`.
Capture only; let the background cron file it later. Fast, non-blocking. Capture only; let the background cron file it later. Fast, non-blocking.
1. Read `Channel` / `Chat ID` from the runtime context if present. 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>]` 2. `write_file` the raw input to `tmp/note-capture.md`, then
`uv run skills/note/scripts/note_capture.py --file tmp/note-capture.md [--channel <ch>] [--chat-id <id>]`
(the extra write is what keeps the capture from being blocked — see below; it is still
one fast turn, so this mode stays non-blocking)
3. Confirm in **one short line** (e.g. "captured — I'll file it in the background") and **STOP the 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 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 step, taking the lock. If you catch yourself about to read the doc, you are compiling
@@ -174,5 +185,7 @@ never reached the KB — you may cancel it directly with `exec: rm notes/inbox/<
- `/note` with no content → ask what to note. - `/note` with no content → ask what to note.
- Empty / whitespace-only input → `note_capture.py` exits non-zero; ask for real content. - Empty / whitespace-only input → `note_capture.py` exits non-zero; ask for real content.
- `No such file` from capture → the `write_file` step was skipped or the path is wrong;
write `tmp/note-capture.md` first, never fall back to passing the text as an argument.
- The compile step, not capture, decides sections and does all fetching. If you ever find - 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. yourself reformulating or reading `notes.md` during a `cron` capture, stop.

View File

@@ -73,7 +73,9 @@ def _append_log(filename: str, body: str) -> None:
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description="Capture a raw note into notes/inbox/") parser = argparse.ArgumentParser(description="Capture a raw note into notes/inbox/")
parser.add_argument( parser.add_argument(
"--text", default=None, help="Raw input; if omitted, read from stdin" "--file",
default=None,
help="Path to a file holding the raw input; if omitted, read from stdin",
) )
parser.add_argument( parser.add_argument(
"--channel", default=None, help="Origin channel (telegram/websocket/cli)" "--channel", default=None, help="Origin channel (telegram/websocket/cli)"
@@ -83,7 +85,16 @@ def main() -> int:
) )
args = parser.parse_args() args = parser.parse_args()
body = args.text if args.text is not None else sys.stdin.read() # The text is never passed on the command line: the exec safety guard scans the
# raw command string and misreads prose as a filesystem path (see SKILL.md).
if args.file is None:
body = sys.stdin.read()
else:
source = Path(args.file)
if not source.is_file():
print(f"No such file: {args.file}", file=sys.stderr)
return 1
body = source.read_text(encoding="utf-8")
body = body.strip() body = body.strip()
if not body: if not body:
print("Nothing to capture (empty input).", file=sys.stderr) print("Nothing to capture (empty input).", file=sys.stderr)

View File

@@ -21,10 +21,13 @@ def workspace(tmp_path, monkeypatch):
return tmp_path return tmp_path
def _run(text=None, argv_extra=None): def _run(workspace, text=None, argv_extra=None):
"""Build argv the way the skill does: text in a file, only its path in the command."""
argv = ["note_capture.py"] argv = ["note_capture.py"]
if text is not None: if text is not None:
argv += ["--text", text] source = workspace / "note-capture.md"
source.write_text(text, encoding="utf-8")
argv += ["--file", str(source)]
if argv_extra: if argv_extra:
argv += argv_extra argv += argv_extra
return argv return argv
@@ -52,7 +55,7 @@ def test_capture_writes_inbox_file_and_log(workspace, monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
sys, sys,
"argv", "argv",
_run("koupit kanistr na vodu", ["--channel", "telegram", "--chat-id", "42"]), _run(workspace, "koupit kanistr na vodu", ["--channel", "telegram", "--chat-id", "42"]),
) )
assert note_capture.main() == 0 assert note_capture.main() == 0
@@ -76,14 +79,14 @@ def test_capture_writes_inbox_file_and_log(workspace, monkeypatch):
def test_capture_no_leftover_tmp_files(workspace, monkeypatch): def test_capture_no_leftover_tmp_files(workspace, monkeypatch):
monkeypatch.setattr(sys, "argv", _run("neco")) monkeypatch.setattr(sys, "argv", _run(workspace, "neco"))
assert note_capture.main() == 0 assert note_capture.main() == 0
tmp_files = list((workspace / "notes" / "inbox").glob(".*")) tmp_files = list((workspace / "notes" / "inbox").glob(".*"))
assert tmp_files == [] assert tmp_files == []
def test_capture_omits_absent_provenance(workspace, monkeypatch): def test_capture_omits_absent_provenance(workspace, monkeypatch):
monkeypatch.setattr(sys, "argv", _run("bez kanalu")) monkeypatch.setattr(sys, "argv", _run(workspace, "bez kanalu"))
assert note_capture.main() == 0 assert note_capture.main() == 0
content = next((workspace / "notes" / "inbox").glob("*.md")).read_text( content = next((workspace / "notes" / "inbox").glob("*.md")).read_text(
encoding="utf-8" encoding="utf-8"
@@ -93,6 +96,46 @@ def test_capture_omits_absent_provenance(workspace, monkeypatch):
def test_capture_empty_input_fails(workspace, monkeypatch): def test_capture_empty_input_fails(workspace, monkeypatch):
monkeypatch.setattr(sys, "argv", _run(" ")) monkeypatch.setattr(sys, "argv", _run(workspace, " "))
assert note_capture.main() == 1 assert note_capture.main() == 1
assert list((workspace / "notes" / "inbox").glob("*.md")) == [] assert list((workspace / "notes" / "inbox").glob("*.md")) == []
def test_capture_accepts_text_the_exec_guard_would_reject_on_a_command_line(
workspace, monkeypatch
):
"""Regression: a note like `Cíl: …` parses as a Windows drive path in the exec guard.
Capture takes the user's input verbatim, so such text must reach the script
through a file, never as an argument, a heredoc or a pipe.
"""
text = "Cíl: koupit mléko. Závěr: zítra."
monkeypatch.setattr(sys, "argv", _run(workspace, text))
assert note_capture.main() == 0
content = next((workspace / "notes" / "inbox").glob("*.md")).read_text(
encoding="utf-8"
)
assert text in content
def test_capture_rejects_missing_file(workspace, monkeypatch, capsys):
monkeypatch.setattr(
sys, "argv", ["note_capture.py", "--file", str(workspace / "chybi.md")]
)
assert note_capture.main() == 1
assert "No such file" in capsys.readouterr().err
assert list((workspace / "notes" / "inbox").glob("*.md")) == []
def test_capture_no_longer_accepts_text_on_the_command_line(monkeypatch):
"""`--text` is gone for good: prose in argv is what the exec guard blocks."""
monkeypatch.setattr(sys, "argv", ["note_capture.py", "--text", "něco"])
with pytest.raises(SystemExit) as excinfo:
note_capture.main()
assert excinfo.value.code == 2