From 8078dc137fcc7a312635688e8bc3afe8f1f3cfb2 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 3 Aug 2026 09:16:35 +0200 Subject: [PATCH] feat(memory): let Stacky file things into the vault, not just read it Stacky could search everything the family knows and add nothing to it. Told "merk dir, dass Bart eine Erdnussallergie hat", it had nowhere to put that: capture had exactly one door, and you had to be the archivist watching a Matrix room to use it. `stack memory capture` is that door, for the three shapes a family actually drops into a room: stack memory capture "Bart has a peanut allergy" --by homer stack memory capture "https://example.com/tent" --by homer stack memory capture --file receipt.jpg --by homer It runs the archivist's own pipeline, so what lands here is classified, tagged, summarised, mirrored and attributed exactly like a note pasted into a room. `--bucket family/camping` files it under a shared topic. The command noun sits in memory's namespace because memory owns the vault. The handler still lives under docs/bot because the capture pipeline does, and moving that pipeline is its own piece of work; when it moves, this travels with it and the dispatcher changes one path. A host `--file` is read on the host and piped to the container, which cannot see the caller's disk. Prose citing a link stays a note rather than being replaced by the fetched page. --- lib/stack/bot_runner.py | 70 ++++++ stacklets/core/famstack-api.py | 4 + stacklets/docs/bot/cli/capture.py | 280 +++++++++++++++++++++ stacklets/docs/bot/cli_entrypoint.py | 9 +- stacklets/docs/cli/_common.py | 58 +---- stacklets/memory/cli/capture.py | 74 ++++++ tests/stacklets/test_memory_capture_cli.py | 168 +++++++++++++ 7 files changed, 611 insertions(+), 52 deletions(-) create mode 100644 lib/stack/bot_runner.py create mode 100644 stacklets/docs/bot/cli/capture.py create mode 100644 stacklets/memory/cli/capture.py create mode 100644 tests/stacklets/test_memory_capture_cli.py 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