diff --git a/lib/stack/bot_runner.py b/lib/stack/bot_runner.py new file mode 100644 index 00000000..7b9f74cc --- /dev/null +++ b/lib/stack/bot_runner.py @@ -0,0 +1,70 @@ +"""The bot-runner container as the stack's tools runtime. + +The host-side `./stack` is stdlib-only by design: it starts fast and +needs no pip install before a family can use it. But some commands are +thin wrappers over pipelines that want aiohttp, loguru, yaml and a +rendered service env. Rather than break the stdlib invariant on the host +or clone those pipelines in urllib, those commands `docker exec` into +`stack-core-bot-runner`, which already has every dependency and the env +pre-rendered, and run a stacklet's `bot/cli_entrypoint.py` there. + +`stacklets/docs/cli/_common.py` established the pattern and its docstring +predicted it would generalise. It did, at `stack memory capture`, so the +mechanism lives here rather than being copied per stacklet. A caller +supplies its own entrypoint path; nothing else differs between them. +""" + +from __future__ import annotations + +import subprocess +import sys + +BOT_RUNNER_CONTAINER = "stack-core-bot-runner" + + +def bot_runner_running() -> bool: + """True when the bot-runner container is up. False if absent or stopped.""" + result = subprocess.run( + ["docker", "inspect", "-f", "{{.State.Running}}", BOT_RUNNER_CONTAINER], + capture_output=True, text=True, + ) + return result.returncode == 0 and result.stdout.strip() == "true" + + +def dispatch(entrypoint: str, command: str, *argv: str, + stdin_bytes: bytes | None = None) -> dict: + """Run `entrypoint ` inside the bot-runner. + + Returns `{"ok": True}` on success, `{"error": ...}` when the runtime + is unavailable. stdout and stderr stream straight through so the + caller sees live output; when the host is a TTY the exec gets one + too, so ANSI colors from stack.prompt render correctly. + + `stdin_bytes` feeds the container's stdin, which is how a host file + reaches a pipeline running inside a container that cannot see the + host filesystem. Piped input and an allocated TTY are mutually + exclusive, so supplying bytes drops the TTY. + """ + if not bot_runner_running(): + return {"error": f"{BOT_RUNNER_CONTAINER} is not running — bring core up first: stack up core"} + + piping = stdin_bytes is not None + tty_flags = ["-it"] if (sys.stdout.isatty() and not piping) else ["-i"] + cmd = [ + "docker", "exec", *tty_flags, + BOT_RUNNER_CONTAINER, + "python", entrypoint, command, *argv, + ] + try: + rc = (subprocess.run(cmd, input=stdin_bytes).returncode if piping + else subprocess.call(cmd)) + except FileNotFoundError: + return {"error": "docker CLI not found on this host"} + + # Pass rc through to the shell without letting the harness print a + # generic "command failed (exit N)" on top of the container's own + # stderr diagnostic. sys.exit bypasses the {"error": ...} path, so + # scripts still see the right return code without the double message. + if rc != 0: + sys.exit(rc) + return {"ok": True} diff --git a/stacklets/core/famstack-api.py b/stacklets/core/famstack-api.py index 6b5cb9ac..a17839d4 100644 --- a/stacklets/core/famstack-api.py +++ b/stacklets/core/famstack-api.py @@ -45,6 +45,10 @@ ["memory", "topic"], ["memory", "lookup"], ["memory", "correspondents"], + # The agent's only way to put something *into* the vault. It runs the + # archivist's own pipeline, so what the agent files is classified, + # attributed and mirrored exactly like a note pasted into a room. + ["memory", "capture"], ["docs", "show"], ] diff --git a/stacklets/docs/bot/cli/capture.py b/stacklets/docs/bot/cli/capture.py new file mode 100644 index 00000000..04632996 --- /dev/null +++ b/stacklets/docs/bot/cli/capture.py @@ -0,0 +1,280 @@ +"""`stack memory capture` — file a link, an image or pasted text into the vault. + +The capture pipeline used to have exactly one door: the archivist reading +a Matrix room. Everything else that might want to file something (the +agent, a script, a person at a terminal) had no way in, which is why the +agent can read the whole family vault and add nothing to it. + +This is that door, and it carries all three shapes a family actually +drops into a room: + + capture "Bart has a peanut allergy" --by homer a note + capture "https://example.com/tent" --by homer a bookmark, fetched + capture --file ~/Downloads/receipt.jpg --by homer an image or PDF + +It runs the *same* pipeline the archivist runs, so what lands here is +indistinguishable from what lands through a room: same classifier, same +tag vocabulary, same mirror, same attribution. + +WHERE THIS LIVES, AND WHY IT IS THE WRONG PLACE + Filing into the vault is a memory concern -- memory owns the vault -- + so the command noun is `stack memory capture`, dispatched from + `stacklets/memory/cli/capture.py`. The handler sits here, under docs, + only because the pipeline it calls does. When the pipeline moves to + the memory stacklet this module travels with it and the host + dispatcher changes one constant. The seam callers depend on (the + command, its arguments, its receipt) does not move. + +WHERE THIS DIVERGES FROM THE ROOM, DELIBERATELY + The archivist has a third rule: prose with a URL buried in it is + treated as a URL drop, and the surrounding words become a hint for + the classifier. That rule is guarded by `not mentioned` -- it exists + to read raw family chatter, where the link is usually the point. + Everything arriving here is deliberate, and a caller that writes a + sentence and cites a source means the sentence. So prose stays a + note, with its link preserved by TextExtractor, and only a bare URL + is fetched as a bookmark. + +THE RECEIPT IS THE POINT + The agent relays this output to the family. A receipt that reads the + same whether or not anything was filed is precisely what lets an + agent report a success it never had, so a failure here never renders + as a filing. See tests/stacklets/test_memory_capture_cli.py. +""" + +from __future__ import annotations + +import mimetypes +import sys +from dataclasses import dataclass +from pathlib import Path + +import aiohttp + +from capture_pipeline import CapturePipeline +from capture_tags import CaptureTagCache +from extractors import TextExtractor, UrlExtractor +from pipeline import Classifier, PaperlessAPI +from text_utils import is_just_url + +# `_DATA_DIR` is the bot's in-container session dir, and the same constant +# `_mirror` already resolves the bot's Forgejo creds from. Imported rather +# than restated so the CLI and the bot never disagree about where the +# archivist keeps its state. +from cli._mirror import _DATA_DIR, build_mirror_like_bot, read_bot_toml_settings +from cli._shared import err + +_FILED = ("captured", "reclassified") + + +@dataclass(frozen=True) +class CaptureSpec: + """One filing request: what to file, who is filing it, and where.""" + + text: str = "" + sender: str = "" + bucket: str | None = None + file: str | None = None + stdin_name: str | None = None + + +def capture_kind(spec: CaptureSpec) -> str: + """Which shape of capture this request is: file, link, or note. + + The same question the archivist asks of a room message, minus its + chatter-reading rule (see the module docstring). + """ + if spec.file or spec.stdin_name: + return "file" + return "link" if is_just_url(spec.text) else "note" + + +def parse_args(argv: list[str]) -> CaptureSpec: + """Read the command line a caller wrote. + + Bare words are the body. They are rejoined with spaces because the + agent reaches this command through a plaintext socket that splits on + shlex: a quoted body arrives whole, an unquoted one arrives in + pieces, and filing only the first word of a note is worse than + failing outright. + + Raises ValueError with a message the caller can act on; the command + turns that into a usage error rather than a traceback. + """ + words: list[str] = [] + sender: str | None = None + bucket: str | None = None + file: str | None = None + stdin_name: str | None = None + + i = 0 + while i < len(argv): + arg = argv[i] + if arg == "--by" and i + 1 < len(argv): + sender = argv[i + 1] + i += 2 + continue + if arg == "--bucket" and i + 1 < len(argv): + bucket = argv[i + 1] + i += 2 + continue + if arg == "--file" and i + 1 < len(argv): + file = argv[i + 1] + i += 2 + continue + if arg == "--stdin-file" and i + 1 < len(argv): + stdin_name = argv[i + 1] + i += 2 + continue + if arg.startswith("--"): + raise ValueError(f"unknown flag {arg!r}") + words.append(arg) + i += 1 + + if not sender: + raise ValueError("a capture must name its author: --by ") + text = " ".join(words).strip() + if not text and not (file or stdin_name): + raise ValueError("nothing to capture: give the text, a URL, or --file") + # The pipeline reads a binary's meaning out of the bytes themselves and + # has nowhere to put a caption, so accepting one would silently drop it. + if text and (file or stdin_name): + raise ValueError("--file takes no text alongside it; capture them separately") + + # The agent knows people as `@homer:simpson`, a person at a terminal + # types `homer`. The vault attributes both to the same human. + return CaptureSpec( + text=text, + sender=sender.split(":")[0].lstrip("@"), + bucket=bucket, + file=file, + stdin_name=stdin_name, + ) + + +def render_receipt(outcome) -> str: + """Turn a CaptureOutcome into the line the caller reads back. + + Only a genuine filing may start with "Captured:". Everything else + says plainly that nothing was filed and why, so neither a person + skimming a terminal nor an agent relaying to a room can mistake a + failure for a success. + """ + if outcome.status in _FILED: + title = (outcome.classification or {}).get("title") or "(untitled)" + lines = [f"Captured: {title}"] + if outcome.vault_path: + lines.append(f" vault: {outcome.vault_path}") + if outcome.scope: + lines.append(f" scope: {outcome.scope}") + return "\n".join(lines) + + if outcome.status == "empty": + return "Nothing captured: there was no text to file." + if outcome.status == "no_mirror": + return ("Nothing captured: the vault is not reachable " + "(is the code stacklet up?).") + + what = { + "url": "that link", + "transcription": "that voice memo", + "binary": "that file", + }.get(getattr(outcome, "failure_reason", None), "the content") + return f"Nothing captured: could not read {what}." + + +class _StderrNotifier: + """The mid-flow status port, pointed at a terminal instead of a room. + + Capture posts "fetching …" before it pulls a link. In a room that is + a chat message; here it is a progress line on stderr, so stdout stays + exactly the receipt a caller parses. + """ + + async def status(self, key: str, **kwargs) -> None: + err(f"… {key}") + + async def acknowledge(self) -> None: + """No source message to react to on a command line.""" + + +async def run(paperless: PaperlessAPI, classifier: Classifier, + argv: list[str]) -> int: + try: + spec = parse_args(argv) + except ValueError as e: + err(str(e)) + err('Usage: capture "" --by [--bucket ]') + err(' capture --file --by [--bucket ]') + return 2 + + kind = capture_kind(spec) + payload, name = b"", "" + if kind == "file": + # `--stdin-file` is how a *host* file gets here: this process runs + # in a container that cannot see the caller's disk, so the host + # dispatcher reads the bytes and pipes them in. `--file` remains + # for paths the container really can see, like anything under /data. + if spec.stdin_name: + payload, name = sys.stdin.buffer.read(), spec.stdin_name + else: + path = Path(spec.file).expanduser() + try: + payload, name = path.read_bytes(), path.name + except OSError as e: + err(f"Cannot read {path}: {e}") + return 1 + if not payload: + err(f"Cannot read {name}: no bytes arrived") + return 1 + + mirror = build_mirror_like_bot() + if mirror is None: + err("Mirror env missing — bring up `code` so CODE_URL / admin creds are set.") + return 1 + + settings = read_bot_toml_settings() + tags = CaptureTagCache(_DATA_DIR / "capture-tags.json") + tags.load() + + async with aiohttp.ClientSession() as http: + pipeline = CapturePipeline( + url_extractor=UrlExtractor(http), + text_extractor=TextExtractor(), + classifier=classifier, + mirror=mirror, + capture_tags=tags, + paperless=paperless, + bot_name="archivist-bot", + classify_max_chars=int(settings.get("classify_max_chars", 100_000)), + capture_keep_body=bool(settings.get("capture_keep_body", False)), + capture_tag_prompt_size=int(settings.get("capture_tag_prompt_size", 50)), + # No transcriber wired: an audio file fails cleanly with + # "could not read that voice memo" rather than half-filing. + ) + + if kind == "link": + outcome = await pipeline.capture_url( + url=spec.text, sender_mxid=spec.sender, + notifier=_StderrNotifier(), bucket=spec.bucket, + ) + elif kind == "file": + mime = mimetypes.guess_type(name)[0] or "application/octet-stream" + outcome = await pipeline.capture_binary( + file_data=payload, mime=mime, filename=name, + # A room capture points `source_uri` at the Matrix mxc URL + # so the entry links back to the original bytes. A local + # file has no such home, and we deliberately do not copy it + # into the vault, so the entry keeps what was read out of + # the file and names the file it came from. + source_uri=None, display_link=name, + sender_mxid=spec.sender, bucket=spec.bucket, + ) + else: + outcome = await pipeline.capture_text( + text=spec.text, sender_mxid=spec.sender, bucket=spec.bucket, + ) + + print(render_receipt(outcome)) + sys.stdout.flush() + return 0 if outcome.status in _FILED else 1 diff --git a/stacklets/docs/bot/cli_entrypoint.py b/stacklets/docs/bot/cli_entrypoint.py index bd570f80..88d28e44 100644 --- a/stacklets/docs/bot/cli_entrypoint.py +++ b/stacklets/docs/bot/cli_entrypoint.py @@ -29,6 +29,12 @@ family/memory vault (no LLM). Useful for backfilling existing docs into a freshly-installed memory vault. + capture "" --by [--bucket ] + file a note into the memory vault + through the archivist's own pipeline. + Reached as `stack memory capture`; + it lives here only while the capture + pipeline does. See cli/capture.py. tags [--types] [--used|--unused] [--owner=N] list tags or document_types tags merge [--type] [--dry] retag docs, drop source @@ -60,7 +66,7 @@ from pipeline import Classifier, PaperlessAPI -from cli import classify, mirror, reformat, reprocess, show, tags +from cli import capture, classify, mirror, reformat, reprocess, show, tags from cli._shared import err @@ -71,6 +77,7 @@ "reprocess": reprocess.run, "mirror": mirror.run, "tags": tags.run, + "capture": capture.run, } diff --git a/stacklets/docs/cli/_common.py b/stacklets/docs/cli/_common.py index d2c47257..5e6a39e6 100644 --- a/stacklets/docs/cli/_common.py +++ b/stacklets/docs/cli/_common.py @@ -3,63 +3,19 @@ The stack CLI plugin loader skips `_`-prefixed files, so this module hosts shared plumbing without registering as a command. -Design: the archivist pipeline needs aiohttp + loguru + the rendered -Paperless/AI env vars to run. The host-side `./stack` is stdlib-only by -design (fast startup, no pip install needed). Rather than cloning the -pipeline in urllib or breaking the stdlib invariant, host commands -docker-exec into the bot-runner container — it already has every dep -the archivist uses and the env is pre-rendered. - -The same pattern generalises: any stacklet CLI that needs non-stdlib -deps can grow a sibling `bot/cli_entrypoint.py` and a thin host -dispatcher here. Keeps the host wrapper minimal and reuses the -container's Python environment as the stack's "tools runtime". +The mechanism itself now lives in `stack.bot_runner`, lifted there when +`stack memory capture` became its second user. This module keeps the +docs entrypoint path and its own name so command modules read unchanged. """ from __future__ import annotations -import subprocess -import sys +from stack.bot_runner import BOT_RUNNER_CONTAINER, bot_runner_running # noqa: F401 +from stack.bot_runner import dispatch as _dispatch -BOT_RUNNER_CONTAINER = "stack-core-bot-runner" ENTRYPOINT_PATH = "/stacklets/docs/bot/cli_entrypoint.py" -def _bot_runner_running() -> bool: - """True when the bot-runner container is up. False if absent or stopped.""" - result = subprocess.run( - ["docker", "inspect", "-f", "{{.State.Running}}", BOT_RUNNER_CONTAINER], - capture_output=True, text=True, - ) - return result.returncode == 0 and result.stdout.strip() == "true" - - def dispatch(command: str, *argv: str) -> dict: - """docker exec the bot-runner's cli_entrypoint with the given args. - - Returns `{"ok": True}` on success, `{"error": ...}` on failure. stdout - and stderr stream straight through so the caller sees live output. - When the host is a TTY the exec is allocated one too, so ANSI colors - from stack.prompt render correctly. - """ - if not _bot_runner_running(): - return {"error": f"{BOT_RUNNER_CONTAINER} is not running — bring core up first: stack up core"} - - tty_flags = ["-it"] if sys.stdout.isatty() else ["-i"] - cmd = [ - "docker", "exec", *tty_flags, - BOT_RUNNER_CONTAINER, - "python", ENTRYPOINT_PATH, command, *argv, - ] - try: - rc = subprocess.call(cmd) - except FileNotFoundError: - return {"error": "docker CLI not found on this host"} - - # Pass rc through to the shell without letting the harness print a - # generic "command failed (exit N)" on top of the container's own - # stderr diagnostic. sys.exit bypasses the {"error": ...} path, so - # scripts still see the right return code without the double message. - if rc != 0: - sys.exit(rc) - return {"ok": True} + """docker exec the docs bot's cli_entrypoint with the given args.""" + return _dispatch(ENTRYPOINT_PATH, command, *argv) diff --git a/stacklets/memory/cli/capture.py b/stacklets/memory/cli/capture.py new file mode 100644 index 00000000..100c5731 --- /dev/null +++ b/stacklets/memory/cli/capture.py @@ -0,0 +1,74 @@ +"""stack memory capture — put something into the family's memory. + +Reading the vault has always had a front door (`stack memory search`, +`stack memory person`, `stack memory topic`). Writing to it had exactly +one, and you had to be the archivist watching a Matrix room to use it. +So the agent could read everything the family knows and add nothing to +it: told "merk dir, dass Bart eine Erdnussallergie hat", it had nowhere +to put that. + +This is the write door, and it is deliberately the *same* pipeline the +archivist runs, not a second way in. A note filed here is classified, +tagged, summarised, mirrored and attributed exactly as one pasted into a +room, because it is the same code doing it. + +Examples: + + stack memory capture "Bart has a peanut allergy" --by homer + Captured: Bart's peanut allergy + vault: homer/notes/2026/08/barts-peanut-allergy-3a338e.md + scope: homer + + stack memory capture "Zelt ist kaputt" --by marge --bucket family/camping + +`--by` attributes the commit, so every entry says who filed it. Without +`--bucket` the note lands in that person's own bucket; a topic path like +`family/camping` files it under the shared topic instead, the same +routing a message in a topic room gets. + +WHY THE COMMAND IS HERE AND THE CODE IS NOT + Memory owns the vault, so filing into it is a memory command. The + handler still lives at `stacklets/docs/bot/cli/capture.py` because + the capture pipeline does, and the pipeline is under docs for + historical reasons rather than good ones: it writes no Paperless + document and touches no docs resource except the person-name roster. + Moving it is its own piece of work. Until then this dispatcher points + across, and the only thing that changes afterwards is the entrypoint + path on the next line. +""" + +HELP = "File a note, link, or image into the family memory vault" + +from pathlib import Path + +from stack.bot_runner import dispatch + +# Points at the docs stacklet until the capture pipeline moves to memory. +# Nothing else in this file, and nothing in any caller, knows that. +_ENTRYPOINT = "/stacklets/docs/bot/cli_entrypoint.py" + +_USAGE = ('usage: stack memory capture "" --by [--bucket ]\n' + " stack memory capture --file --by [--bucket ]") + + +def run(args, stacklet, config): + if not args: + return {"error": _USAGE} + + # The pipeline runs in a container that cannot see the host's disk, so + # a `--file` path is resolved here and the bytes ride in on stdin. The + # name travels separately because it is what the mime guess and the + # vault entry's display link are built from. + argv, payload = list(args), None + if "--file" in argv: + i = argv.index("--file") + if i + 1 >= len(argv): + return {"error": _USAGE} + path = Path(argv[i + 1]).expanduser() + try: + payload = path.read_bytes() + except OSError as e: + return {"error": f"cannot read {path}: {e}"} + argv[i:i + 2] = ["--stdin-file", path.name] + + return dispatch(_ENTRYPOINT, "capture", *argv, stdin_bytes=payload) diff --git a/tests/stacklets/test_memory_capture_cli.py b/tests/stacklets/test_memory_capture_cli.py new file mode 100644 index 00000000..80e7b978 --- /dev/null +++ b/tests/stacklets/test_memory_capture_cli.py @@ -0,0 +1,168 @@ +"""`stack memory capture` — the seam that lets a caller file into the vault. + +Filing knowledge is a *memory* capability. Until now the only way to +reach it was to be the archivist reading a Matrix room, which left every +other caller (the agent, a script, a person at a terminal) with no way to +put anything in. This command is that way in. + +The command noun lives in memory's namespace because memory owns the +vault; the handler still sits beside the pipeline under `docs/bot` for +now, so the two move together when the pipeline goes where it belongs. +These tests pin the *seam*, not that placement: the argument grammar a +caller writes and the receipt it reads back. Both survive the move. + +Why the receipt matters enough to test: the agent relays it to the +family verbatim. A receipt that reads the same whether or not anything +was filed is exactly what lets an agent claim success it never had, so +the cases below pin that a failure never renders as a filing. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) + +from capture_pipeline import CaptureOutcome # noqa: E402 + +sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot" / "cli")) +from capture import capture_kind, parse_args, render_receipt # noqa: E402 + + +class TestTheArgumentGrammar: + """What a caller has to type, and what it is allowed to leave out.""" + + def test_the_text_is_the_argument(self): + spec = parse_args(["Bart has a peanut allergy", "--by", "homer"]) + + assert spec.text == "Bart has a peanut allergy" + assert spec.sender == "homer" + + def test_a_multi_word_body_survives_as_one_body(self): + """The plaintext socket splits on shlex, so a quoted body arrives + whole but an unquoted one arrives in pieces. Rejoining is the + difference between filing a note and filing its first word.""" + spec = parse_args(["Liste:", "Heringe", "Kuehlbox", "--by", "marge"]) + + assert spec.text == "Liste: Heringe Kuehlbox" + + def test_the_bucket_is_optional_and_means_the_sender(self): + """No bucket is the common case: a person filing their own note. + The pipeline routes it to their personal bucket from the sender.""" + assert parse_args(["a note", "--by", "homer"]).bucket is None + + def test_a_topic_bucket_routes_the_capture_to_the_topic(self): + spec = parse_args(["a note", "--by", "homer", + "--bucket", "family/camping"]) + + assert spec.bucket == "family/camping" + + def test_a_sender_may_be_given_as_a_full_mxid(self): + """The agent knows people as `@homer:simpson`; a person at a + terminal types `homer`. Both name the same human.""" + assert parse_args(["a note", "--by", "@homer:simpson"]).sender == "homer" + + def test_filing_as_nobody_is_refused(self): + """Every vault write is attributed. A capture with no author + would commit as a ghost, so it must not be expressible.""" + with pytest.raises(ValueError, match="--by"): + parse_args(["a note"]) + + def test_filing_nothing_is_refused(self): + with pytest.raises(ValueError, match="nothing to capture"): + parse_args(["--by", "homer"]) + + def test_a_file_needs_no_text_beside_it(self): + spec = parse_args(["--file", "/tmp/receipt.jpg", "--by", "homer"]) + + assert spec.file == "/tmp/receipt.jpg" + assert spec.text == "" + + def test_a_caption_on_a_file_is_refused_rather_than_dropped(self): + """The pipeline reads a binary's meaning out of the bytes and has + nowhere to put a caption. Accepting one would file the image and + silently lose the words the caller thought they were filing.""" + with pytest.raises(ValueError, match="separately"): + parse_args(["Rechnung vom Zeltladen", "--file", "/tmp/r.jpg", + "--by", "homer"]) + + +class TestWhichShapeOfCaptureThisIs: + """A link, an image and a note are three different filings.""" + + def test_a_bare_url_is_fetched_as_a_bookmark(self): + spec = parse_args(["https://example.com/tent-review", "--by", "homer"]) + + assert capture_kind(spec) == "link" + + def test_a_file_is_read_as_a_binary(self): + spec = parse_args(["--file", "/tmp/receipt.jpg", "--by", "homer"]) + + assert capture_kind(spec) == "file" + + def test_prose_citing_a_link_stays_a_note(self): + """Deliberate divergence from the archivist. + + Reading a room, a link buried in chatter usually *is* the point, + so the archivist fetches it and treats the words as framing. + A caller here wrote the sentence on purpose; fetching its source + and filing that instead would discard what they said. The link + still survives -- TextExtractor surfaces it as the note's link. + """ + spec = parse_args(["Bart has a peanut allergy, see", + "https://example.com/allergies", "--by", "homer"]) + + assert capture_kind(spec) == "note" + + +class TestTheReceipt: + """What the caller reads back, and what the agent relays.""" + + def test_a_filed_note_names_what_was_filed_and_where(self): + receipt = render_receipt(CaptureOutcome( + status="captured", + classification={"title": "Packliste für Campingausflug"}, + vault_path="family/camping/notes/2026/08/packliste-277e6e.md", + scope="family/camping", + )) + + assert "Packliste für Campingausflug" in receipt + assert "family/camping/notes/2026/08/packliste-277e6e.md" in receipt + + def test_a_filed_note_says_captured(self): + """The agent is told to relay this word rather than invent its + own. It has to actually be here to relay.""" + receipt = render_receipt(CaptureOutcome( + status="captured", classification={"title": "T"}, + vault_path="homer/notes/2026/08/t.md", scope="homer", + )) + + assert receipt.startswith("Captured:") + + @pytest.mark.parametrize("outcome,expected", [ + (CaptureOutcome(status="empty"), "nothing"), + (CaptureOutcome(status="no_mirror"), "vault"), + (CaptureOutcome(status="extract_failed", failure_reason="url"), "read"), + ]) + def test_a_failure_never_reads_as_a_filing(self, outcome, expected): + """The whole point. Whatever went wrong, the receipt must not + contain the word a caller scans for to conclude it worked.""" + receipt = render_receipt(outcome) + + assert not receipt.startswith("Captured:") + assert expected in receipt.lower() + + def test_an_untitled_capture_still_gets_a_readable_receipt(self): + """The classifier degrades to `{}` when the LLM is down, and the + capture still files. A receipt that crashed here would turn a + working capture into a failed command.""" + receipt = render_receipt(CaptureOutcome( + status="captured", classification={}, + vault_path="homer/notes/2026/08/untitled.md", scope="homer", + )) + + assert "homer/notes/2026/08/untitled.md" in receipt