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

@@ -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
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.
@@ -60,9 +67,10 @@ language.
The default: file the note into the knowledge base immediately, in this turn.
1. Read `Channel` / `Chat ID` from the runtime context if present.
2. Capture:
`uv run skills/note/scripts/note_capture.py --text "<raw input>" [--channel <ch>] [--chat-id <id>]`
Pass the input **as-is** — do not reformulate or strip URLs here.
2. Capture, in two steps — `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>]`
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/`,
file into `notes/notes.md`, move the source to `notes/done/` (or `notes/hard/`).
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.
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
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
@@ -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.
- 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
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:
parser = argparse.ArgumentParser(description="Capture a raw note into notes/inbox/")
parser.add_argument(
"--text", default=None, help="Raw input; if omitted, read from stdin"
"--file",
default=None,
help="Path to a file holding the raw input; if omitted, read from stdin",
)
parser.add_argument(
"--channel", default=None, help="Origin channel (telegram/websocket/cli)"
@@ -83,7 +85,16 @@ def main() -> int:
)
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()
if not body:
print("Nothing to capture (empty input).", file=sys.stderr)

View File

@@ -21,10 +21,13 @@ def workspace(tmp_path, monkeypatch):
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"]
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:
argv += argv_extra
return argv
@@ -52,7 +55,7 @@ def test_capture_writes_inbox_file_and_log(workspace, monkeypatch):
monkeypatch.setattr(
sys,
"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
@@ -76,14 +79,14 @@ def test_capture_writes_inbox_file_and_log(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
tmp_files = list((workspace / "notes" / "inbox").glob(".*"))
assert tmp_files == []
def test_capture_omits_absent_provenance(workspace, monkeypatch):
monkeypatch.setattr(sys, "argv", _run("bez kanalu"))
monkeypatch.setattr(sys, "argv", _run(workspace, "bez kanalu"))
assert note_capture.main() == 0
content = next((workspace / "notes" / "inbox").glob("*.md")).read_text(
encoding="utf-8"
@@ -93,6 +96,46 @@ def test_capture_omits_absent_provenance(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 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