From 501469bbe7877807f7758f90bf9ba277ee605795 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 15 Sep 2026 13:59:02 +0200 Subject: [PATCH 01/13] feat: keep the original file behind a wiki page Adds stack.media, the archive that holds the bytes a page describes: one file per event id under /media///, written once, addressed by a link that works from any page. Brain's seeded .gitignore now excludes it, so deleting a message can really delete the file. No caller yet; the capture and diary paths follow. --- lib/stack/media.py | 203 +++++++++++++++++++++++ stacklets/memory/lib.py | 3 + tests/framework/test_media_archive.py | 223 ++++++++++++++++++++++++++ 3 files changed, 429 insertions(+) create mode 100644 lib/stack/media.py create mode 100644 tests/framework/test_media_archive.py diff --git a/lib/stack/media.py b/lib/stack/media.py new file mode 100644 index 00000000..ed0f7107 --- /dev/null +++ b/lib/stack/media.py @@ -0,0 +1,203 @@ +"""Media archive - keeping the original bytes an artifact arrived as. + +A stacklet that files an artifact keeps a description of it: a page with +a summary, the metadata a reader needs, a link back to where it was +posted. The bytes themselves stay behind that link, addressed by an +identifier only the producing service understands, and nothing about +that arrangement promises they will still be there later. This module is +where a copy is kept instead. + +The archive is a tree of originals addressed by the identifier of the +event that carried them: + + ///. + +`store` returns the path that addresses the file from the root of the +rendered site, which is the form a page links or embeds. That form is +the reason the archive lives inside the published tree: it resolves at +any page depth and in any deployment mode, where an absolute URL would +be right in one mode and wrong in the other, and pages outlive both. + +Writes are idempotent and atomic. An identifier names one artifact +permanently, so a file already at its path is the file that belongs +there and is never rewritten. + +The archive is deliberately excluded from version control; see +`ensure_ignored`. Stdlib only, like the rest of `lib/stack`. +""" + +from __future__ import annotations + +import logging +import os +import re +import subprocess +import tempfile +from pathlib import Path + +log = logging.getLogger(__name__) + +# The archive's directory name, and the first segment of every link into +# it. Both are derived from here so the name is stated once. +ARCHIVE_DIR = "media" + +# What survives into a filename. Everything else collapses to a hyphen: +# identifiers come from event ids and user-supplied filenames, which +# carry sigils, separators and path punctuation a path must not inherit. +# Excluding the dot is what makes traversal impossible - `..` cannot +# survive the substitution, so no identifier can address a parent. +_UNSAFE = re.compile(r"[^A-Za-z0-9_-]+") + +# Filenames are capped well below the ~255 byte limit filesystems impose, +# leaving room for the extension and the temporary write prefix. +_MAX_STEM = 96 +_MAX_EXT = 16 + +# A conversion that has not finished by now is not going to. The caller +# degrades to what it had rather than blocking the run behind it. +_TRANSCODE_TIMEOUT_S = 300 + +_IGNORE_NOTE = ( + "# Original uploads, kept on disk only. Deleting the source event has\n" + "# to be able to delete the copy, and history would keep it anyway.\n" +) + + +def archive_root(site_root) -> Path: + """The archive directory inside a rendered site's content root.""" + return Path(site_root) / ARCHIVE_DIR + + +def safe_name(artifact_id: str) -> str: + """An identifier reduced to something that can be a filename. + + Every run of unsupported characters becomes a single hyphen, and the + result is trimmed and capped. An empty or wholly unsupported + identifier still yields a name, because dropping the artifact is a + worse outcome than filing it under a generic one. + """ + cleaned = _UNSAFE.sub("-", str(artifact_id)).strip("-") + return cleaned[:_MAX_STEM].strip("-") or "artifact" + + +def _safe_ext(ext: str) -> str: + cleaned = _UNSAFE.sub("", str(ext).lstrip(".").lower()) + return cleaned[:_MAX_EXT] or "bin" + + +def store(root, artifact_id: str, data: bytes, *, ext: str, when) -> str: + """Keep `data` in the archive and return the link that addresses it. + + `root` is the archive directory (see `archive_root`), `when` anything + with `strftime` - the date the artifact belongs to, which decides the + year and month folders. The returned link is rooted at the site, not + at `root`, so it reads the same from a page at any depth. + + An artifact already in the archive is left exactly as it is: the + identifier is the promise that the bytes are the same ones. + + Returns "" when the write fails. A caller that cannot archive an + artifact still has whatever it had before and should carry on with + it; losing the page over a failed copy helps nobody. + """ + year, month = when.strftime("%Y"), when.strftime("%m") + name = f"{safe_name(artifact_id)}.{_safe_ext(ext)}" + target = Path(root) / year / month / name + link = f"/{ARCHIVE_DIR}/{year}/{month}/{name}" + + if target.exists(): + return link + try: + target.parent.mkdir(parents=True, exist_ok=True) + _write_atomically(target, data) + except OSError as e: + log.warning("could not archive %s: %s", artifact_id, e) + return "" + return link + + +def _write_atomically(target: Path, data: bytes) -> None: + """Write via a neighbouring temporary file and rename over the target. + + The rename is what makes a reader safe: the site serves this tree + while it is being written, and a partially written file under the + final name would be served as the artifact. + """ + fd, tmp = tempfile.mkstemp(dir=str(target.parent), prefix=".tmp-") + try: + with os.fdopen(fd, "wb") as handle: + handle.write(data) + os.replace(tmp, target) + finally: + Path(tmp).unlink(missing_ok=True) + + +def transcode_audio(src, dst) -> bool: + """Convert an audio file to AAC in an MP4 container. True when done. + + The conversion exists for one reason: Safari does not decode Opus in + an Ogg container, which is what a recording made in a chat client + usually is, so an archived original alone would be silent for part of + the audience. The original is kept regardless; this is the second + copy that plays everywhere. + + False when ffmpeg is absent or the conversion fails, with nothing + left behind at `dst`. Callers fall back to the link they had. + """ + src, dst = Path(src), Path(dst) + if dst.exists(): + return True + + tmp = dst.with_name(f".tmp-{dst.name}") + try: + dst.parent.mkdir(parents=True, exist_ok=True) + done = subprocess.run( + ["ffmpeg", "-nostdin", "-loglevel", "error", "-y", + "-i", str(src), "-vn", "-c:a", "aac", "-b:a", "96k", str(tmp)], + capture_output=True, timeout=_TRANSCODE_TIMEOUT_S, check=False, + ) + except (OSError, subprocess.SubprocessError) as e: + log.warning("no audio conversion for %s: %s", src.name, e) + tmp.unlink(missing_ok=True) + return False + + if done.returncode != 0 or not tmp.exists(): + detail = done.stderr.decode(errors="replace").strip().splitlines() + log.warning("ffmpeg could not convert %s: %s", + src.name, detail[-1] if detail else f"rc={done.returncode}") + tmp.unlink(missing_ok=True) + return False + + try: + os.replace(tmp, dst) + except OSError as e: + log.warning("could not place %s: %s", dst.name, e) + tmp.unlink(missing_ok=True) + return False + return True + + +def ensure_ignored(repo_root) -> None: + """Make the repository at `repo_root` ignore the archive. + + Two reasons, and the first is the one that matters: deleting the + source event has to delete the copy, and a blob that was committed + once stays reachable in history no matter what happens to the working + tree, which would make the deletion a false promise. The second is + size - originals accumulate and every clone would carry all of them. + + Idempotent. A repository that already states the rule is left alone, + so this is safe to call on every write. + """ + gitignore = Path(repo_root) / ".gitignore" + rule = f"{ARCHIVE_DIR}/" + try: + existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else "" + if rule in (line.strip() for line in existing.splitlines()): + return + separator = "" if not existing or existing.endswith("\n") else "\n" + gitignore.write_text( + f"{existing}{separator}{_IGNORE_NOTE}{rule}\n", encoding="utf-8", + ) + except OSError as e: + log.warning("could not record the archive's ignore rule: %s", e) diff --git a/stacklets/memory/lib.py b/stacklets/memory/lib.py index a31b3e0a..2f0b1b69 100644 --- a/stacklets/memory/lib.py +++ b/stacklets/memory/lib.py @@ -100,6 +100,9 @@ ".obsidian/\n" ".trash/\n" ".DS_Store\n" + "# Archived originals (stack.media). Deleting the message that carried\n" + "# one has to delete the file, and git history would keep it anyway.\n" + "media/\n" ) BRAIN_SEED_README = ( "# Family brain (projection)\n\n" diff --git a/tests/framework/test_media_archive.py b/tests/framework/test_media_archive.py new file mode 100644 index 00000000..8d4ad666 --- /dev/null +++ b/tests/framework/test_media_archive.py @@ -0,0 +1,223 @@ +"""The media archive — what `stack.media` promises about kept originals. + +The archive exists so that the page describing an artifact is not the +only thing left of it. Everything here is about that promise holding +over time: a file written once is never rewritten, the link a page gets +back keeps working from any depth, an identifier that arrives from the +outside cannot address anything but the archive, and a repository that +holds the tree never commits it. + +Ground truth for the ignore rule is git itself: the test runs `git +check-ignore` against a real repository rather than asserting on the +text of the file, so the assertion is about the effect the rule has and +not about how it was spelled. +""" + +from __future__ import annotations + +import subprocess +from datetime import date, datetime, timezone +from pathlib import Path + +import pytest + +from stack import media + +MARCH = date(2026, 3, 14) + + +@pytest.fixture +def archive(tmp_path) -> Path: + return media.archive_root(tmp_path) + + +# ── Keeping an original ────────────────────────────────────────────────── + +class TestStore: + """An identifier names one artifact, permanently.""" + + def test_the_link_is_rooted_at_the_site_not_at_the_archive(self, archive): + """Pages sit at every depth and are read for years. A link from + the site root is the one form that resolves the same in the + rendered wiki and in a local vault client.""" + link = media.store(archive, "$abc", b"bytes", ext="png", when=MARCH) + + assert link == "/media/2026/03/abc.png" + + def test_the_bytes_land_where_the_link_says(self, archive): + link = media.store(archive, "$abc", b"bytes", ext="png", when=MARCH) + + kept = archive.parent / link.lstrip("/") + assert kept.read_bytes() == b"bytes" + + def test_an_artifact_already_kept_is_not_written_again(self, archive): + """Re-running a compile must not touch files it kept on an + earlier pass: the identifier is the promise that the bytes are + the same ones, and a rewrite is churn the wiki would rebuild + over.""" + first = media.store(archive, "$abc", b"original", ext="png", when=MARCH) + kept = archive.parent / first.lstrip("/") + before = kept.stat().st_mtime_ns + + again = media.store(archive, "$abc", b"different", ext="png", when=MARCH) + + assert again == first + assert kept.read_bytes() == b"original" + assert kept.stat().st_mtime_ns == before + + def test_the_month_comes_from_the_date_the_artifact_belongs_to(self, archive): + """Not from the clock: an artifact filed today can belong to a + month years back, and the archive follows the page.""" + link = media.store( + archive, "$old", b"x", ext="jpg", + when=datetime(2019, 11, 2, 23, 30, tzinfo=timezone.utc), + ) + + assert link == "/media/2019/11/old.jpg" + + def test_a_failed_write_reports_itself_rather_than_raising(self, tmp_path): + """A caller that cannot archive still has the link it had. The + page is worth more than the copy, so a full disk or a read-only + mount costs the copy and nothing else.""" + blocked = tmp_path / "file-where-a-directory-should-be" + blocked.write_text("not a directory") + + assert media.store(blocked, "$abc", b"x", ext="png", when=MARCH) == "" + + def test_a_partial_write_is_never_visible_under_the_final_name(self, archive): + """The site serves this tree while it is being written, so the + file appears whole or not at all. Its arrival is a rename, and + nothing is left behind next to it.""" + media.store(archive, "$abc", b"x" * 4096, ext="png", when=MARCH) + + month = archive / "2026" / "03" + assert [p.name for p in month.iterdir()] == ["abc.png"] + + +# ── Identifiers from the outside ───────────────────────────────────────── + +class TestIdentifiersAreNotPaths: + """Identifiers arrive on events from outside the stack. None of them + may address anything but the archive.""" + + @pytest.mark.parametrize("crafted", [ + "../../../etc/passwd", + "/etc/passwd", + "..", + "../../secrets", + "a/../../b", + "$evil/../../../../tmp/owned", + ]) + def test_a_crafted_identifier_stays_inside_the_archive(self, archive, crafted): + link = media.store(archive, crafted, b"x", ext="png", when=MARCH) + + written = list(p for p in archive.rglob("*") if p.is_file()) + assert len(written) == 1 + assert archive.resolve() in written[0].resolve().parents + assert ".." not in link + + def test_an_extension_cannot_carry_a_path_either(self, archive): + media.store(archive, "$abc", b"x", ext="../../png", when=MARCH) + + written = [p for p in archive.rglob("*") if p.is_file()] + assert written[0].name == "abc.png" + + def test_an_identifier_with_nothing_usable_still_files(self, archive): + """Dropping the artifact would be the worse outcome. It lands + under a generic name and the page still points at it.""" + link = media.store(archive, "///", b"x", ext="png", when=MARCH) + + assert link == "/media/2026/03/artifact.png" + + def test_two_different_identifiers_do_not_collide(self, archive): + first = media.store(archive, "$aaa", b"1", ext="png", when=MARCH) + second = media.store(archive, "$bbb", b"2", ext="png", when=MARCH) + + assert first != second + + +# ── Keeping the archive out of git ─────────────────────────────────────── + +class TestEnsureIgnored: + """A committed blob survives every later deletion. The archive holds + the only copy of bytes a deletion upstream has to be able to remove, + so the repository that carries the tree must not track it.""" + + def _repo(self, tmp_path: Path) -> Path: + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + return tmp_path + + def _ignored(self, repo: Path, rel: str) -> bool: + done = subprocess.run( + ["git", "-C", str(repo), "check-ignore", "-q", rel], check=False, + ) + return done.returncode == 0 + + def test_git_ignores_the_archive_afterwards(self, tmp_path): + repo = self._repo(tmp_path) + media.store(media.archive_root(repo), "$a", b"x", ext="png", when=MARCH) + + media.ensure_ignored(repo) + + assert self._ignored(repo, "media/2026/03/a.png") + + def test_calling_it_again_does_not_repeat_the_rule(self, tmp_path): + """It runs on every write, so a second call has to be a no-op. + A .gitignore that grows a line per upload is a diff on every + curator commit.""" + repo = self._repo(tmp_path) + + media.ensure_ignored(repo) + once = (repo / ".gitignore").read_text() + media.ensure_ignored(repo) + + assert (repo / ".gitignore").read_text() == once + + def test_rules_already_in_the_file_are_kept(self, tmp_path): + """The seeded projection repo ships its own ignore rules. Adding + one must not cost the others.""" + repo = self._repo(tmp_path) + (repo / ".gitignore").write_text(".obsidian/\n.DS_Store\n") + + media.ensure_ignored(repo) + + assert self._ignored(repo, ".obsidian/workspace.json") + assert self._ignored(repo, "media/2026/03/a.png") + + def test_a_file_without_a_trailing_newline_is_not_joined_onto(self, tmp_path): + """Appending to `.DS_Store` would produce `.DS_Storemedia/` and + silently ignore neither.""" + repo = self._repo(tmp_path) + (repo / ".gitignore").write_text(".DS_Store") + + media.ensure_ignored(repo) + + assert self._ignored(repo, ".DS_Store") + assert self._ignored(repo, "media/2026/03/a.png") + + +# ── Playable audio ─────────────────────────────────────────────────────── + +class TestTranscodeAudio: + + def test_a_missing_converter_is_reported_not_raised(self, tmp_path, monkeypatch): + """ffmpeg is a container dependency and an instance can be + mid-upgrade. The compile drops the second copy and keeps + running; it never dies on the way to a page.""" + monkeypatch.setenv("PATH", str(tmp_path / "empty")) + src = tmp_path / "voice.ogg" + src.write_bytes(b"not really audio") + + assert media.transcode_audio(src, tmp_path / "voice.m4a") is False + assert not (tmp_path / "voice.m4a").exists() + + def test_unconvertible_input_leaves_nothing_behind(self, tmp_path): + """A half-written file under the final name would be served as + the recording, and would report itself as already converted on + the next run.""" + src = tmp_path / "voice.ogg" + src.write_bytes(b"not really audio") + + assert media.transcode_audio(src, tmp_path / "voice.m4a") is False + assert list(tmp_path.glob("*.m4a")) == [] + assert list(tmp_path.glob(".tmp-*")) == [] From abf6367e5e79f461fa27fccd6b1a90a04720f8b8 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 15 Sep 2026 14:45:38 +0200 Subject: [PATCH 02/13] feat: record where an archived file came from, and make it playable The archive now writes .json beside every file it keeps: who sent it, from which room, under what name, when, and a checksum. Someone finding the tree years from now can place a file back in its context without famstack. Adds the second copies a page can show - AAC for recordings Safari would otherwise not play, WebP bounded to 1600px so a month of photographs is not a hundred-megabyte page. The original is kept either way, and the sidecar lists what was derived from it. --- lib/stack/media.py | 245 +++++++++++++++++++++++- tests/framework/test_media_archive.py | 258 ++++++++++++++++++++++++++ 2 files changed, 496 insertions(+), 7 deletions(-) diff --git a/lib/stack/media.py b/lib/stack/media.py index ed0f7107..349039c9 100644 --- a/lib/stack/media.py +++ b/lib/stack/media.py @@ -18,6 +18,17 @@ any page depth and in any deployment mode, where an absolute URL would be right in one mode and wrong in the other, and pages outlive both. +Beside each file is `.json`, written by `keep`: who sent +the artifact, from where, under what name, and when. The archive is +meant to be readable by someone who finds the tree with none of this +software left, so everything needed to place a file back in its +context is next to it rather than in a database. + +`derive` adds the second copies a page can actually show: audio a +browser will play, an image bounded to a width a page can carry. The +sidecar lists them, so a later conversion replaces its own output +instead of leaving an orphan behind. + Writes are idempotent and atomic. An identifier names one artifact permanently, so a file already at its path is the file that belongs there and is never rewritten. @@ -28,11 +39,15 @@ from __future__ import annotations +import hashlib +import json import logging +import mimetypes import os import re import subprocess import tempfile +from datetime import datetime, timezone from pathlib import Path log = logging.getLogger(__name__) @@ -57,6 +72,18 @@ # degrades to what it had rather than blocking the run behind it. _TRANSCODE_TIMEOUT_S = 300 +# How wide an image derivative may be. A page embedding a dozen +# originals downloads a dozen originals: the width a wikilink alias +# carries is a display attribute and changes nothing about what +# crosses the wire. 1600px still fills a retina-width column, which is +# where shrinking further starts costing the reader something. +MAX_IMAGE_WIDTH = 1600 + +# The forms a derivative takes, by the kind of artifact it came from. +# AAC in MP4 because Safari does not decode Opus in Ogg; WebP because +# it is the smallest of the formats every current browser renders. +_DERIVED_EXT = {"audio": "m4a", "image": "webp"} + _IGNORE_NOTE = ( "# Original uploads, kept on disk only. Deleting the source event has\n" "# to be able to delete the copy, and history would keep it anyway.\n" @@ -85,6 +112,40 @@ def _safe_ext(ext: str) -> str: return cleaned[:_MAX_EXT] or "bin" +def extension_for(filename: str, mime: str = "") -> str: + """The extension to file an artifact under. + + The name it was posted as decides, because that is what the sender + saw and what a reader of the archive will recognise. A name without + one (a paste, a client that sends none) falls back to what the + declared type implies, and then to a neutral extension. + """ + from_name = Path(str(filename or "")).suffix + if from_name.lstrip("."): + return _safe_ext(from_name) + declared = str(mime or "").split(";")[0].strip() + return _safe_ext(mimetypes.guess_extension(declared) or "" if declared else "") + + +def kind_for(mime: str) -> str: + """The kind of artifact a media type describes. + + One of image, video, audio, file. The kind is what decides whether + a page can show the thing inline and what a derivative of it would + be, so it is recorded rather than re-guessed at every reading. + """ + top = str(mime or "").split("/")[0].strip().lower() + return top if top in ("image", "video", "audio") else "file" + + +def _located(root, artifact_id: str, ext: str, when) -> "tuple[Path, str]": + """Where an artifact lives, and the link that addresses it.""" + year, month = when.strftime("%Y"), when.strftime("%m") + name = f"{safe_name(artifact_id)}.{_safe_ext(ext)}" + return (Path(root) / year / month / name, + f"/{ARCHIVE_DIR}/{year}/{month}/{name}") + + def store(root, artifact_id: str, data: bytes, *, ext: str, when) -> str: """Keep `data` in the archive and return the link that addresses it. @@ -100,11 +161,7 @@ def store(root, artifact_id: str, data: bytes, *, ext: str, when) -> str: artifact still has whatever it had before and should carry on with it; losing the page over a failed copy helps nobody. """ - year, month = when.strftime("%Y"), when.strftime("%m") - name = f"{safe_name(artifact_id)}.{_safe_ext(ext)}" - target = Path(root) / year / month / name - link = f"/{ARCHIVE_DIR}/{year}/{month}/{name}" - + target, link = _located(root, artifact_id, ext, when) if target.exists(): return link try: @@ -116,6 +173,108 @@ def store(root, artifact_id: str, data: bytes, *, ext: str, when) -> str: return link +def kept(root, artifact_id: str, *, ext: str, when) -> str: + """The link to an artifact already in the archive, or "". + + Lets a caller skip fetching bytes it would only throw away: the + archive is rebuilt from the same source on every run, and the + identifier already says whether this artifact is in it. + """ + target, link = _located(root, artifact_id, ext, when) + return link if target.exists() else "" + + +def keep(root, artifact_id: str, data: bytes, *, ext: str, when, + kind: str, source: str, mime: str = "", filename: str = "", + room_id: str = "", sender: str = "") -> str: + """Store an artifact together with the record of where it came from. + + `store` with a sidecar: the same bytes, plus `.json` + naming who sent it, from which room, under what name, and when. + Nothing else in the tree says any of that - the filename is a + scrubbed identifier and the directories are a date - so without the + sidecar the archive is a pile of anonymous files the moment it + outlives the software that wrote it. + + `source` names the pipeline that filed the artifact. `kind` is the + caller's own classification (see `kind_for`), recorded rather than + inferred here because the caller knows what it handled. + + Returns the link, or "" when the bytes could not be written. + """ + link = store(root, artifact_id, data, ext=ext, when=when) + if not link: + return "" + sidecar, _ = _located(root, artifact_id, _SIDECAR_EXT, when) + if not sidecar.exists(): + # Field order is the order a reader wants them in: identity, + # then the file, then the two timestamps, then our own notes. + _write_sidecar(sidecar, { + "event_id": artifact_id, + "room_id": room_id, + "sender": sender, + "filename": filename, + "mime": mime, + "size": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + "captured_at": when.isoformat(), + "stored_at": datetime.now(timezone.utc).isoformat(), + "kind": kind, + "source": source, + "derived": [], + }) + return link + + +# ── The record beside the file ─────────────────────────────────────────── + +_SIDECAR_EXT = "json" + + +def _read_sidecar(path: Path) -> dict: + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + return loaded if isinstance(loaded, dict) else {} + + +def _write_sidecar(path: Path, record: dict) -> None: + """Write a sidecar the same way the payload is written. + + Atomically, and never fatally: an archived file with no record + beside it is a loss worth a warning, and losing the file as well + because the record failed would be a worse one. + """ + try: + path.parent.mkdir(parents=True, exist_ok=True) + _write_atomically( + path, json.dumps(record, indent=2, ensure_ascii=False).encode("utf-8"), + ) + except OSError as e: + log.warning("could not record %s: %s", path.name, e) + + +def _record_derived(sidecar: Path, link: str, kind: str) -> None: + """Add a derivative to the sidecar's list, once. + + The list is what lets a later conversion replace its own output: + without it a second format, a better encoder or a changed width + leaves the previous file in the tree with nothing pointing at it + and nothing saying where it came from. + """ + record = _read_sidecar(sidecar) + if not record: + return + derived = record.get("derived") + if not isinstance(derived, list): + derived = [] + if any(isinstance(d, dict) and d.get("path") == link for d in derived): + return + record["derived"] = [*derived, {"path": link, "kind": kind}] + _write_sidecar(sidecar, record) + + def _write_atomically(target: Path, data: bytes) -> None: """Write via a neighbouring temporary file and rename over the target. @@ -144,6 +303,45 @@ def transcode_audio(src, dst) -> bool: False when ffmpeg is absent or the conversion fails, with nothing left behind at `dst`. Callers fall back to the link they had. """ + return _convert(src, dst, ["-vn", "-c:a", "aac", "-b:a", "96k"]) + + +def transcode_image(src, dst, *, max_width: int = MAX_IMAGE_WIDTH) -> bool: + """Convert an image to a WebP no wider than `max_width`. True when done. + + A page that embeds originals makes a reader download originals, and + twenty photographs off a phone are a hundred megabytes of them. The + display width a wikilink carries does not change that; only a + smaller file does. + + Aspect ratio is preserved and an image already narrower than the + bound keeps its dimensions - `min()` in the scale expression is + what stops an old small photo being blown up to fill the width. + + False when ffmpeg is absent or the conversion fails, with nothing + left behind at `dst`. Callers fall back to the original. + """ + return _convert(src, dst, [ + "-vf", f"scale='min({int(max_width)},iw)':-1:flags=lanczos", + # One frame: the source may be an animation, and the point of + # the derivative is a bounded still. + "-frames:v", "1", + ]) + + +def _convert(src, dst, options: "list[str]") -> bool: + """Run ffmpeg from `src` to `dst` with `options`, atomically. + + Shared by every derivative. The output is built under a temporary + name and renamed into place, because the site serves this tree + while a compile is running and a half-written file under the final + name would be both served and taken for finished work on the next + run. + + Never raises. A missing converter, a codec the build lacks and a + file ffmpeg cannot read are all the same to the caller: no + derivative, and whatever it had before. + """ src, dst = Path(src), Path(dst) if dst.exists(): return True @@ -153,11 +351,11 @@ def transcode_audio(src, dst) -> bool: dst.parent.mkdir(parents=True, exist_ok=True) done = subprocess.run( ["ffmpeg", "-nostdin", "-loglevel", "error", "-y", - "-i", str(src), "-vn", "-c:a", "aac", "-b:a", "96k", str(tmp)], + "-i", str(src), *options, str(tmp)], capture_output=True, timeout=_TRANSCODE_TIMEOUT_S, check=False, ) except (OSError, subprocess.SubprocessError) as e: - log.warning("no audio conversion for %s: %s", src.name, e) + log.warning("no conversion for %s: %s", src.name, e) tmp.unlink(missing_ok=True) return False @@ -177,6 +375,39 @@ def transcode_audio(src, dst) -> bool: return True +def derive(root, artifact_id: str, *, ext: str, when, kind: str) -> str: + """The copy of an archived artifact a page can show, or "". + + Audio becomes playable, an image becomes bounded; everything else + has no derivative and says so by returning "". The result is + recorded in the artifact's sidecar, so the tree explains its own + extra files. + + Idempotent and cheap to call again: an artifact whose derivative is + already there costs one stat. An artifact that is already in the + derived form is its own derivative and gets none, which is also + what stops a conversion writing over its own input. + + Returns "" whenever the conversion cannot be made - ffmpeg absent, + a format it will not read, the original gone. The caller shows the + original instead. + """ + target_ext = _DERIVED_EXT.get(kind, "") + if not target_ext or _safe_ext(ext) == target_ext: + return "" + src, _ = _located(root, artifact_id, ext, when) + if not src.exists(): + return "" + dst, link = _located(root, artifact_id, target_ext, when) + + convert = transcode_audio if kind == "audio" else transcode_image + if not convert(src, dst): + return "" + sidecar, _ = _located(root, artifact_id, _SIDECAR_EXT, when) + _record_derived(sidecar, link, kind) + return link + + def ensure_ignored(repo_root) -> None: """Make the repository at `repo_root` ignore the archive. diff --git a/tests/framework/test_media_archive.py b/tests/framework/test_media_archive.py index 8d4ad666..a62190ef 100644 --- a/tests/framework/test_media_archive.py +++ b/tests/framework/test_media_archive.py @@ -15,6 +15,9 @@ from __future__ import annotations +import hashlib +import json +import shutil import subprocess from datetime import date, datetime, timezone from pathlib import Path @@ -221,3 +224,258 @@ def test_unconvertible_input_leaves_nothing_behind(self, tmp_path): assert media.transcode_audio(src, tmp_path / "voice.m4a") is False assert list(tmp_path.glob("*.m4a")) == [] assert list(tmp_path.glob(".tmp-*")) == [] + + +# ── The record beside the file ─────────────────────────────────────────── + +class TestKeep: + """An archive nobody can read back is a pile of anonymous files. + `keep` writes the file and the record of where it came from, and + that record is the only thing in the tree that still knows.""" + + ORIGIN = { + "kind": "image", "source": "capture", "mime": "image/png", + "filename": "Bildschirmfoto 2026-03-14.png", + "room_id": "!kitchen:example.org", "sender": "@marge:example.org", + } + + def _record(self, archive: Path) -> dict: + return json.loads((archive / "2026" / "03" / "abc.json") + .read_text(encoding="utf-8")) + + def test_the_record_says_who_posted_it_where_and_under_what_name(self, archive): + """Everything needed to put the file back in its context, next + to the file. The name in the tree is a scrubbed identifier and + the folders are a date, so nothing else in the archive carries + any of this.""" + media.keep(archive, "$abc", b"bytes", ext="png", + when=datetime(2026, 3, 14, 9, 30, tzinfo=timezone.utc), + **self.ORIGIN) + + record = self._record(archive) + assert record["event_id"] == "$abc", "unscrubbed, or the timeline is lost" + assert record["room_id"] == "!kitchen:example.org" + assert record["sender"] == "@marge:example.org" + assert record["filename"] == "Bildschirmfoto 2026-03-14.png" + assert record["captured_at"].startswith("2026-03-14T09:30") + assert record["source"] == "capture" + + def test_the_record_can_prove_the_bytes_are_the_bytes(self, archive): + media.keep(archive, "$abc", b"bytes", ext="png", when=MARCH, **self.ORIGIN) + + record = self._record(archive) + assert record["size"] == 5 + assert record["sha256"] == hashlib.sha256(b"bytes").hexdigest() + + def test_a_second_compile_over_the_same_room_changes_nothing(self, archive): + """The diary recompiles the whole room every night. A run that + rewrote every file it had already kept would be a diff of the + entire archive, every night, forever.""" + media.keep(archive, "$abc", b"bytes", ext="png", when=MARCH, **self.ORIGIN) + month = archive / "2026" / "03" + before = {p.name: p.stat().st_mtime_ns for p in month.iterdir()} + + media.keep(archive, "$abc", b"bytes", ext="png", when=MARCH, **self.ORIGIN) + + assert {p.name: p.stat().st_mtime_ns for p in month.iterdir()} == before + + def test_bytes_that_cannot_be_written_leave_no_record_saying_they_were( + self, tmp_path): + blocked = tmp_path / "file-where-a-directory-should-be" + blocked.write_text("not a directory") + + assert media.keep(blocked, "$abc", b"x", ext="png", when=MARCH, + **self.ORIGIN) == "" + + +class TestKnowingWhatIsAlreadyThere: + + def test_an_artifact_in_the_archive_is_reported_without_its_bytes(self, archive): + """The compiler asks before it downloads. A room with years of + photographs in it would otherwise be re-fetched in full on every + run to write files that are already on disk.""" + assert media.kept(archive, "$abc", ext="png", when=MARCH) == "" + + media.store(archive, "$abc", b"bytes", ext="png", when=MARCH) + + assert media.kept(archive, "$abc", ext="png", when=MARCH) == \ + "/media/2026/03/abc.png" + + +class TestNamingAnArtifact: + """The extension is not cosmetic: the wiki turns an embed into an + audio player, a video player or an image by reading it off the + path, so getting it wrong loses the player.""" + + @pytest.mark.parametrize("filename, mime, expected", [ + ("voice-message.ogg", "audio/ogg", "ogg"), + ("IMG_4021.JPEG", "image/jpeg", "jpeg"), + ("Zeugnis Bart.pdf", "application/pdf", "pdf"), + ("", "image/png", "png"), + ("", "video/mp4", "mp4"), + ("", "", "bin"), + ("noextension", "", "bin"), + ]) + def test_the_name_it_was_posted_as_decides(self, filename, mime, expected): + assert media.extension_for(filename, mime) == expected + + @pytest.mark.parametrize("mime, expected", [ + ("image/png", "image"), ("video/quicktime", "video"), + ("audio/ogg; codecs=opus", "audio"), ("application/pdf", "file"), + ("", "file"), + ]) + def test_the_kind_follows_the_media_type(self, mime, expected): + assert media.kind_for(mime) == expected + + +# ── Second copies ──────────────────────────────────────────────────────── + +def _encoders() -> str: + if shutil.which("ffmpeg") is None: + return "" + return subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], + capture_output=True, check=False).stdout.decode() + + +needs_ffmpeg = pytest.mark.skipif( + shutil.which("ffmpeg") is None, + reason="ffmpeg is a container dependency; absent here") + +# The container image installs a full ffmpeg; a developer machine may +# carry a build without the WebP encoder, which is a property of that +# build and not of this code. The width arithmetic below is ours and is +# tested separately, through a format every build can write. +needs_webp = pytest.mark.skipif( + "webp" not in _encoders(), + reason="this ffmpeg build has no WebP encoder") + + +def _width(path: Path) -> int: + out = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=width", "-of", "csv=p=0", str(path)], + capture_output=True, check=True, + ) + return int(out.stdout.decode().strip()) + + +def _an_image(path: Path, width: int) -> bytes: + path.parent.mkdir(parents=True, exist_ok=True) + subprocess.run( + ["ffmpeg", "-nostdin", "-loglevel", "error", "-y", "-f", "lavfi", + "-i", f"testsrc=size={width}x{width // 2}:duration=1", + "-frames:v", "1", str(path)], check=True) + return path.read_bytes() + + +@needs_ffmpeg +class TestBoundingAnImage: + """Twenty originals off a phone are a hundred megabytes on one month + page. The width alias a wikilink carries is a display attribute and + changes nothing about what crosses the wire, so the file itself has + to be smaller.""" + + def test_a_large_photograph_comes_out_at_the_bound(self, tmp_path): + _an_image(tmp_path / "big.png", 3000) + + assert media.transcode_image( + tmp_path / "big.png", tmp_path / "out.png") is True + assert _width(tmp_path / "out.png") == media.MAX_IMAGE_WIDTH + + def test_a_small_photograph_is_not_blown_up_to_fill_it(self, tmp_path): + """An old 400px photo enlarged to 1600 is a bigger file showing + less. The bound is a ceiling, not a target.""" + _an_image(tmp_path / "small.png", 400) + + media.transcode_image(tmp_path / "small.png", tmp_path / "out.png") + + assert _width(tmp_path / "out.png") == 400 + + def test_the_original_survives_being_derived_from(self, tmp_path): + before = _an_image(tmp_path / "photo.png", 800) + + media.transcode_image(tmp_path / "photo.png", tmp_path / "out.png") + + assert (tmp_path / "photo.png").read_bytes() == before + + +class TestDerivatives: + """A page embeds the derivative, so the derivative is what decides + whether a reader hears a recording, and how much of a photograph + they have to download to see it.""" + + ORIGIN = {"source": "diary", "room_id": "!memories:example.org", + "sender": "@homer:example.org"} + + @needs_ffmpeg + @needs_webp + def test_a_photograph_gets_a_bounded_copy_beside_it(self, archive, tmp_path): + data = _an_image(tmp_path / "photo.png", 3000) + media.keep(archive, "$photo", data, ext="png", when=MARCH, + kind="image", mime="image/png", filename="photo.png", + **self.ORIGIN) + + link = media.derive(archive, "$photo", ext="png", when=MARCH, kind="image") + + assert link == "/media/2026/03/photo.webp" + assert _width(archive / "2026" / "03" / "photo.webp") == media.MAX_IMAGE_WIDTH + assert (archive / "2026" / "03" / "photo.png").read_bytes() == data + + def test_a_derivative_is_written_into_the_record_that_owns_it(self, archive): + """A re-encode has to be able to replace its own output. Without + the list the previous file stays in the tree with nothing + pointing at it and nothing saying where it came from.""" + media.keep(archive, "$voice", b"ogg bytes", ext="ogg", when=MARCH, + kind="audio", mime="audio/ogg", filename="voice.ogg", + **self.ORIGIN) + # As an earlier compile left it: the conversion is ffmpeg's, the + # bookkeeping is ours, and only the bookkeeping is under test. + (archive / "2026" / "03" / "voice.m4a").write_bytes(b"aac bytes") + payload = archive / "2026" / "03" / "voice.ogg" + before = payload.stat().st_mtime_ns + + link = media.derive(archive, "$voice", ext="ogg", when=MARCH, kind="audio") + media.derive(archive, "$voice", ext="ogg", when=MARCH, kind="audio") + + record = json.loads((archive / "2026" / "03" / "voice.json") + .read_text(encoding="utf-8")) + assert record["derived"] == [{"path": link, "kind": "audio"}], \ + "listed once, however often the compile runs" + assert payload.stat().st_mtime_ns == before, "the original is not rewritten" + + def test_an_artifact_already_in_the_derived_form_gets_no_second_copy(self, archive): + """Deriving an .m4a to an .m4a would name the conversion's + output as its own input.""" + media.keep(archive, "$voice", b"aac bytes", ext="m4a", when=MARCH, + kind="audio", mime="audio/mp4", filename="voice.m4a", + **self.ORIGIN) + + assert media.derive(archive, "$voice", ext="m4a", when=MARCH, + kind="audio") == "" + assert (archive / "2026" / "03" / "voice.m4a").read_bytes() == b"aac bytes" + + def test_an_artifact_that_has_no_second_form_asks_for_none(self, archive): + media.keep(archive, "$doc", b"%PDF-", ext="pdf", when=MARCH, + kind="file", mime="application/pdf", filename="form.pdf", + **self.ORIGIN) + + assert media.derive(archive, "$doc", ext="pdf", when=MARCH, + kind="file") == "" + + def test_without_a_converter_the_page_loses_the_player_not_the_entry( + self, archive, monkeypatch, tmp_path): + """ffmpeg is a container dependency and an instance can be + mid-upgrade. The compile keeps the original, skips the second + copy and publishes; it never dies on the way to a page.""" + monkeypatch.setenv("PATH", str(tmp_path / "empty")) + media.keep(archive, "$voice", b"ogg bytes", ext="ogg", when=MARCH, + kind="audio", mime="audio/ogg", filename="voice.ogg", + **self.ORIGIN) + + assert media.derive(archive, "$voice", ext="ogg", when=MARCH, + kind="audio") == "" + + month = archive / "2026" / "03" + assert sorted(p.name for p in month.iterdir()) == ["voice.json", "voice.ogg"] + record = json.loads((month / "voice.json").read_text(encoding="utf-8")) + assert record["derived"] == [] From 88d268c710b89cf46800ab993ee133e93f2b1d8d Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 15 Sep 2026 14:45:44 +0200 Subject: [PATCH 03/13] chore: install ffmpeg in the two images that compile media The diary runs in the curator container (curator.py subprocesses the compiler) and on demand in the bot-runner, and both now need to convert what they archive: an AAC copy of a voice note, a width-bounded copy of a photograph. Neither image had ffmpeg. Without it the pages fall back to the room link, which is what they do today. --- stacklets/core/bot-runner/Dockerfile | 7 +++++-- stacklets/memory/curator.Dockerfile | 9 ++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/stacklets/core/bot-runner/Dockerfile b/stacklets/core/bot-runner/Dockerfile index 6b0cf8d7..40347dc0 100644 --- a/stacklets/core/bot-runner/Dockerfile +++ b/stacklets/core/bot-runner/Dockerfile @@ -1,8 +1,11 @@ FROM python:3.12-slim -# libolm is needed by matrix-nio for E2E encryption +# libolm is needed by matrix-nio for E2E encryption. ffmpeg makes the +# derivatives the media archive publishes: an AAC copy of a voice note +# (Safari does not decode Opus in Ogg) and a width-bounded image, both +# written by `stack memory diary` and the capture path, which run here. RUN apt-get update && apt-get install -y --no-install-recommends \ - gcc libolm-dev \ + gcc libolm-dev ffmpeg \ && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/stacklets/memory/curator.Dockerfile b/stacklets/memory/curator.Dockerfile index 1940255a..97f74e80 100644 --- a/stacklets/memory/curator.Dockerfile +++ b/stacklets/memory/curator.Dockerfile @@ -9,11 +9,18 @@ # bind mount (see docker-compose.yml), same as the bot-runner, so # code changes don't need an image rebuild; tzdata makes the # WIKI_NIGHTLY local time honest inside the container. +# +# ffmpeg is the one heavy addition. The nightly diary runs in THIS +# container (curator.py subprocesses the compiler rather than exec-ing +# into the bot-runner), and the diary archives the media it publishes: +# voice notes need an AAC copy Safari can play, photographs need a +# bounded one a page can carry. Without it here the nightly run files +# originals and no page can show them. FROM python:3.12-slim RUN apt-get update && apt-get install -y --no-install-recommends \ - git tzdata \ + git tzdata ffmpeg \ && rm -rf /var/lib/apt/lists/* RUN pip install --no-cache-dir "loguru>=0.7,<1.0" "openai>=1.50,<3.0" "pyyaml>=6,<7" From 2a1eecdde6c702a3bb2291153755e6490671c4b9 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 15 Sep 2026 14:49:52 +0200 Subject: [PATCH 04/13] feat: play the recording on the diary page, not just link to it The compile now keeps every file the memories room carried and puts it on the page: a voice note plays, a photo and a video show, a document is offered as a download. The link back into the room stays. Files already kept are not fetched again, so only the first compile pays for the room's history. Anything that fails - the homeserver, the disk, a missing ffmpeg - costs that entry its file and nothing else; the page renders as it did before. --- stacklets/memory/bot/cli/diary.py | 95 +++++++++++++++++++++++++++- stacklets/memory/bot/diary.py | 45 +++++++++++-- tests/stacklets/test_memory_diary.py | 88 ++++++++++++++++++++++++++ 3 files changed, 220 insertions(+), 8 deletions(-) diff --git a/stacklets/memory/bot/cli/diary.py b/stacklets/memory/bot/cli/diary.py index a175201f..eca7a332 100644 --- a/stacklets/memory/bot/cli/diary.py +++ b/stacklets/memory/bot/cli/diary.py @@ -33,8 +33,10 @@ that fits both, so the caller picks. A dry run publishes no pages but still writes the transcript, reading -and summary caches. These are cost caches, not output: discarding them -would make a preview run as expensive as the compile that follows. +and summary caches, and still keeps the originals it reads. None of +those are output: discarding them would make a preview run as expensive +as the compile that follows, and a preview whose pages embed nothing +would not be a preview of what publishing produces. Runs inside `stack-core-bot-runner`: it has the whisper client, the LLM client, the brain working copy, and the Matrix admin credentials. The @@ -71,6 +73,7 @@ import voice # noqa: E402 from stack.ai.client import LLMError, Transcriber # noqa: E402 from stack.ai import transcripts # noqa: E402 +from stack import media # noqa: E402 from . import wiki # noqa: E402 @@ -170,6 +173,86 @@ async def _download(session, homeserver, token, mxc: str) -> bytes | None: return resp.content +# ── Keeping the originals ───────────────────────────────────────────── +# +# The room is the only copy of everything a family posts into it. The +# pages compiled below describe those recordings and photographs and +# link back to them, which is worth nothing on the day the homeserver's +# media store is not there any more. So the compile keeps the file too, +# in the archive the wiki serves, and the page embeds what it kept. + +# What `stack.media` calls each kind of message the compiler +# recognises. `text` is absent on purpose: it carries no file. +_ARCHIVE_KIND = {"voice": "audio", "image": "image", + "video": "video", "file": "file"} + + +async def _archive_media(messages, events, *, session, homeserver, token, + room_id: str) -> "dict[str, str]": + """Keep the file behind every upload; return where each one landed. + + Keyed by event id, in the site-rooted form a page embeds, which is + what `diary.pages_for` takes. The value is the copy meant to be + shown: the AAC version of a recording, the bounded version of a + photograph, the original for everything else. + + Nothing here is allowed to stop a compile. An unreachable + homeserver, a full disk, a container without ffmpeg: each costs an + entry its file and nothing more. The entry is left out of the map + and its page renders the way it did before any of this existed. + + Bytes already in the archive are not fetched again. A compile walks + the room's whole history every night, and re-downloading years of + photographs to write files that are already on disk would make + every night cost what the first one did. + """ + try: + brain = wiki._brain_dir() + except RuntimeError as e: + _err(f" {e}; keeping no originals") + return {} + media.ensure_ignored(brain) + root = media.archive_root(brain) + + raw = {ev.get("event_id"): ev for ev in events} + paths: "dict[str, str]" = {} + fetched = 0 + for msg in messages: + kind = _ARCHIVE_KIND.get(msg.kind, "") + if not kind or not msg.url: + continue + event = raw.get(msg.event_id) or {} + content = event.get("content") or {} + filename = content.get("filename") or content.get("body") or "" + mime = (content.get("info") or {}).get("mimetype") or "" + ext = media.extension_for(filename, mime) + # Filed under when it arrived rather than under the date its + # entry ends up carrying: the archive indexes events, and the + # record beside each file states that same moment. The page + # addresses the file by path, so the two need not agree. + when = datetime.fromtimestamp(msg.ts / 1000, timezone.utc) + + link = media.kept(root, msg.event_id, ext=ext, when=when) + if not link: + data = await _download(session, homeserver, token, msg.url) + if not data: + continue + link = media.keep( + root, msg.event_id, data, ext=ext, when=when, kind=kind, + source="diary", mime=mime, filename=filename, + room_id=room_id, sender=event.get("sender") or "", + ) + if not link: + continue + fetched += 1 + paths[msg.event_id] = media.derive( + root, msg.event_id, ext=ext, when=when, kind=kind) or link + + if fetched: + _err(f" kept {fetched} original(s)") + return paths + + # ── Decoding and reading ────────────────────────────────────────────── @@ -860,6 +943,11 @@ async def run(llm, argv: list[str]) -> int: bucket=bucket, dry_run=dry_run) _err(f"{len(messages)} message(s) in {room_arg}") + # Before anything is read or interpreted: the files themselves. + kept = await _archive_media( + messages, events, session=session, homeserver=homeserver, + token=token, room_id=room_id) + # Transcription first and on its own: every later step reads # words, and a recording that cannot be decoded should drop out # before the model is asked to interpret its filename. @@ -934,7 +1022,8 @@ async def run(llm, argv: list[str]) -> int: readings_cache.save() summaries_cache.save() - pages = diary.pages_for(entries, room_id=room_id, summaries=summaries) + pages = diary.pages_for(entries, room_id=room_id, summaries=summaries, + media=kept) return _publish_pages(pages, bucket=bucket, dry_run=dry_run) diff --git a/stacklets/memory/bot/diary.py b/stacklets/memory/bot/diary.py index 38460101..822c7710 100644 --- a/stacklets/memory/bot/diary.py +++ b/stacklets/memory/bot/diary.py @@ -76,6 +76,7 @@ "link_image": "See it in the room", "link_video": "Watch it in the room", "link_other": "Open in the room", + "kept_file": "The file itself", "no_entries": "No entries yet.", "entry_one": "entry", "entry_many": "entries", "month_one": "month", "month_many": "months", @@ -135,6 +136,7 @@ "link_image": "Im Chat ansehen", "link_video": "Im Chat ansehen", "link_other": "Im Chat \u00f6ffnen", + "kept_file": "Die Datei selbst", "no_entries": "Noch keine Eintr\u00e4ge.", "entry_one": "Eintrag", "entry_many": "Eintr\u00e4ge", "month_one": "Monat", "month_many": "Monaten", @@ -853,8 +855,15 @@ def _permalink(room_id: str, event_id: str) -> str: return f"https://matrix.to/#/{room_id}/{event_id}" -def _entry_block(entry: Entry, *, room_id: str) -> str: - """One entry: who, what it was, and then their words untouched.""" +def _entry_block(entry: Entry, *, room_id: str, + media: "dict[str, str] | None" = None) -> str: + """One entry: who, what it was, and then their words untouched. + + `media` maps an event id to the site path of the file that message + carried, for the entries whose file was archived. Resolving those + paths is the caller's job (see `pages_for`); this module states + where they go on the page and in what form. + """ who = entry.sender.title() # An addressee is rendered as the message names them ("Bart", "kids"), # not title-cased, so a group reads as a group. A message whose @@ -903,6 +912,21 @@ def _entry_block(entry: Entry, *, room_id: str) -> str: lines += [f"> {line}" for line in text.strip().splitlines()] lines.append("") + # The archived file, shown the way its kind is worth showing. A + # recording, a photo and a clip are the memory itself and belong on + # the page; anything else is a download and gets a link, because an + # embedded document pushes the rest of the day off the screen. The + # embed syntax is Obsidian's, which the wiki turns into the right + # player by reading the extension off the path. + for event_id in entry.event_ids: + path = (media or {}).get(event_id) + if not path: + continue + if entry.kind in ("voice", "image", "video"): + lines += [f"![[{path}]]", ""] + else: + lines += [f"[{_L['kept_file']}]({path})", ""] + if room_id and entry.event_ids: label = {"voice": _L["link_voice"], "image": _L["link_image"], "video": _L["link_video"]} @@ -982,7 +1006,8 @@ def _recorded_by(entries) -> list[str]: return seen -def render_month(entries, *, room_id: str = "", summary: str = "") -> str: +def render_month(entries, *, room_id: str = "", summary: str = "", + media: "dict[str, str] | None" = None) -> str: """A month of entries, grouped by the day they happened. `summary` is an optional paragraph recalling the month. It opens @@ -990,6 +1015,9 @@ def render_month(entries, *, room_id: str = "", summary: str = "") -> str: from the recordings. The full promise is stated once, on the diary's front page, not on every month. + `media` is the archived file for each event that has one, keyed by + event id, in the form a page addresses it by. + Entries whose date could not be recovered are still shown on the day they surfaced, under a heading that says as much. Hiding them would lose the memory to protect the timeline, which is the wrong trade for @@ -1011,7 +1039,7 @@ def render_month(entries, *, room_id: str = "", summary: str = "") -> str: # heading would cast that doubt over every other entry # filed the same day. lines += [f"## {_day_heading(entry.on)}", ""] - lines += [_entry_block(entry, room_id=room_id), ""] + lines += [_entry_block(entry, room_id=room_id, media=media), ""] return "\n".join(lines).rstrip() + "\n" @@ -1098,6 +1126,7 @@ def _and_list(names: list[str]) -> str: def pages_for(entries, *, room_id: str = "", summaries: "dict[str, str] | None" = None, + media: "dict[str, str] | None" = None, ) -> list[tuple[str, str, str]]: """Every page the diary publishes: (path, body, title). @@ -1114,6 +1143,11 @@ def pages_for(entries, *, room_id: str = "", Paths are relative to the shared bucket, which the caller prefixes -- the bucket is named in config (`family`, `office`, a surname) and this module has no business knowing which. + + `summaries` and `media` arrive the same way and for the same + reason: both are the result of work this module cannot do (a model + call; a download and a file write), handed in keyed by month and by + event id so rendering stays a pure function of its inputs. """ out = [(f"{DIARY_DIR}/about.md", render_index(entries), _L["diary_title"])] for year, in_year in sorted(_by_year(entries).items()): @@ -1124,7 +1158,8 @@ def pages_for(entries, *, room_id: str = "", out.append(( f"{DIARY_DIR}/{year}/{month}.md", render_month(in_month, room_id=room_id, - summary=(summaries or {}).get(f"{year}-{month}", "")), + summary=(summaries or {}).get(f"{year}-{month}", ""), + media=media), _month_year(in_month[0].on), )) return out diff --git a/tests/stacklets/test_memory_diary.py b/tests/stacklets/test_memory_diary.py index d6c72180..f6c85e62 100644 --- a/tests/stacklets/test_memory_diary.py +++ b/tests/stacklets/test_memory_diary.py @@ -686,6 +686,94 @@ def test_a_short_entry_stays_verbatim_even_with_a_gist(self): assert "A gist that must not replace the words." not in page +class TestTheKeptFileOnThePage: + """A page that only links the room is worth nothing once the room's + media store is gone. The compiler keeps the file and hands the + renderer the path it landed on; this is what the page does with it. + + The embed syntax is Obsidian's and the wiki turns it into a player + by reading the extension, which is why the extension in these + assertions is the load-bearing part. + """ + + ROOM = "!memories:example.org" + + def _entry(self, kind: str, event_id: str = "$one") -> diary.Entry: + return diary.Entry( + on=date(2026, 3, 14), confidence="sent", + basis="dated from when it was sent", kind=kind, sender="marge", + body="", event_ids=[event_id]) + + def test_a_recording_is_played_from_the_copy_that_plays_everywhere(self): + """Safari does not decode Opus in Ogg, which is what a chat + client records. The page embeds the AAC copy; the Ogg original + stays in the archive.""" + page = diary.render_month( + [self._entry("voice")], room_id=self.ROOM, + media={"$one": "/media/2026/03/one.m4a"}) + + assert "![[/media/2026/03/one.m4a]]" in page + + def test_a_photograph_is_shown_on_the_page(self): + page = diary.render_month( + [self._entry("image")], room_id=self.ROOM, + media={"$one": "/media/2026/03/one.webp"}) + + assert "![[/media/2026/03/one.webp]]" in page + + def test_a_document_is_offered_rather_than_embedded(self): + """An embedded PDF is a scrolling frame that pushes the rest of + the day off the screen.""" + page = diary.render_month( + [self._entry("file")], room_id=self.ROOM, + media={"$one": "/media/2026/03/one.pdf"}) + + assert "[The file itself](/media/2026/03/one.pdf)" in page + assert "![[" not in page + + def test_the_room_is_still_where_the_memory_lives(self): + """The archive is a second copy, not a replacement. Following + the entry back into the conversation it came from is the thing + a family actually does with it.""" + page = diary.render_month( + [self._entry("voice")], room_id=self.ROOM, + media={"$one": "/media/2026/03/one.m4a"}) + + assert f"[Listen in the room](https://matrix.to/#/{self.ROOM}/$one)" in page + + def test_each_half_of_a_split_recording_is_played(self): + """A memo that arrived as two uploads is one entry with two + files. Playing only the first would cut the memory in half.""" + entry = self._entry("voice") + entry.event_ids = ["$first", "$second"] + + page = diary.render_month( + [entry], room_id=self.ROOM, + media={"$first": "/media/2026/03/first.m4a", + "$second": "/media/2026/03/second.m4a"}) + + assert "![[/media/2026/03/first.m4a]]" in page + assert "![[/media/2026/03/second.m4a]]" in page + + def test_an_entry_whose_file_could_not_be_kept_reads_as_it_always_did(self): + """Every failure ends here: no homeserver, no disk, no ffmpeg. + The page has to be exactly the page famstack published before + the archive existed, or a bad night costs a family its diary.""" + entries = [self._entry("voice", "$a"), self._entry("image", "$b")] + + assert diary.render_month(entries, room_id=self.ROOM, media={}) == \ + diary.render_month(entries, room_id=self.ROOM) + + def test_only_the_events_that_were_kept_are_shown(self): + entries = [self._entry("image", "$kept"), self._entry("image", "$lost")] + + page = diary.render_month( + entries, room_id=self.ROOM, + media={"$kept": "/media/2026/03/kept.webp"}) + + assert page.count("![[") == 1 + + class TestVerifyMoments: """A quote reaches the page only when the body really contains it. The page shows the body's own text, not the model's copy.""" From efccffe79f82a78c929cdeb36f4cc39e4cdab139 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 15 Sep 2026 14:56:42 +0200 Subject: [PATCH 05/13] feat: keep the screenshot a capture entry describes An upload into a capture room is archived alongside its entry instead of living only in the homeserver's media store. The entry names the file under the name it was posted as, and shows it when showing it is useful: a picture goes on the page, a document stays a download. The bytes are kept in the archivist, which is the layer that has the room, the sender and the event the upload arrived on. Documents filed to Paperless are not archived - Paperless holds those durably and the entry already names them. Nothing here can stop a filing: without a brain to write into, the entry is the one famstack published before. --- stacklets/docs/bot/archivist.py | 74 +++++++++++++-- stacklets/docs/bot/capture_pipeline.py | 16 +++- stacklets/docs/bot/git_mirror.py | 10 ++ stacklets/docs/bot/vault_entry.py | 21 +++++ tests/stacklets/test_archivist_routing.py | 107 ++++++++++++++++++++++ tests/stacklets/test_vault_entry.py | 79 +++++++++++++++- 6 files changed, 295 insertions(+), 12 deletions(-) diff --git a/stacklets/docs/bot/archivist.py b/stacklets/docs/bot/archivist.py index 1ea91bd3..fe9718c7 100644 --- a/stacklets/docs/bot/archivist.py +++ b/stacklets/docs/bot/archivist.py @@ -62,7 +62,7 @@ PaperlessAPI, PaperlessDuplicateError, ) -from stack import resolve_model +from stack import media, resolve_model from stack.email_message import defang_links from stack.links import go_docs, go_topic, public from stack.ai.client import ( @@ -1575,6 +1575,7 @@ async def _on_file(self, room, event) -> None: reply_to=reply_to, default_person=not bot_attachment, extra_seed_topics=extra_seed_topics, + captured_ts=getattr(event, "server_timestamp", 0) or 0, ) async def _on_text(self, room, event: RoomMessageText) -> None: @@ -2244,6 +2245,56 @@ async def _handle_text_capture( ) await self._reply_for_capture(room_id, outcome, reply_to) + def _keep_original( + self, *, file_data: bytes, mime: str, filename: str, + event_id: str | None, room_id: str, sender_mxid: str, ts_ms: int, + ) -> dict | None: + """Archive the uploaded bytes; return how the entry shows them. + + ``{"name", "original", "embed"}``, or None when nothing was + kept. The archive lives inside the brain working copy, which is + the tree the wiki serves, so the entry addresses the file by a + site path that resolves at any page depth and in either + deployment mode. + + Best-effort by construction. No brain (memory not installed), + no event id, no writable disk: the capture files exactly as it + did before, pointing at the mxc URL alone. + + Documents are not archived here. They go to Paperless, which + holds the bytes durably and is named on the entry; a second + copy would be two places to keep in step and two places to + delete from. + """ + brain = os.environ.get("BRAIN_REPO_DIR", "") + if not brain or not event_id: + return None + import datetime as _dt + when = _dt.datetime.fromtimestamp( + ts_ms / 1000 if ts_ms else _dt.datetime.now(_dt.timezone.utc).timestamp(), + tz=_dt.timezone.utc, + ) + media.ensure_ignored(brain) + root = media.archive_root(brain) + kind = media.kind_for(mime) + ext = media.extension_for(filename, mime) + link = media.keep( + root, event_id, file_data, ext=ext, when=when, kind=kind, + source="capture", mime=mime, filename=filename, + room_id=room_id, sender=sender_mxid, + ) + if not link: + return None + # A screenshot is the entry and goes on the page; the bounded + # copy when there is one, because a phone photograph embedded + # at full size is a page nobody waits for. + shown = media.derive(root, event_id, ext=ext, when=when, kind=kind) + return { + "name": filename, + "original": link, + "embed": shown or (link if kind == "image" else ""), + } + async def _handle_binary_capture( self, *, room_id: str, file_data: bytes, mime: str, filename: str, source_uri: str, sender_mxid: str, @@ -2251,17 +2302,19 @@ async def _handle_binary_capture( reply_to: str | None = None, default_person: bool = True, extra_seed_topics: list[str] | None = None, + captured_ts: int = 0, ) -> None: """Capture a PDF or image as a visual bookmark. Vision-driven by default (the classifier sees the rendered - page(s) + any extractable text layer). The mirror entry - points back at the Matrix mxc URL -- the binary stays where - the homeserver put it, the wiki just summarises and links. - ``capture_id`` is the Matrix event_id of the upload, stored - on the entry as a stable correlation key so a deriver (or - the reply-to-correct path) can find this capture later - without depending on the title-derived path. + page(s) + any extractable text layer). The entry points back at + the Matrix mxc URL and carries a copy of the bytes in the media + archive, so it still has the file when the homeserver's media + store does not. ``capture_id`` is the Matrix event_id of the + upload, stored on the entry as a stable correlation key so a + deriver (or the reply-to-correct path) can find this capture + later without depending on the title-derived path. It is also + what the archived file is named after. ``default_person`` is False for bot-posted content (an email attachment): the sender is a bot, not the owner, so don't fall @@ -2289,6 +2342,11 @@ async def _handle_binary_capture( seed_topics=seed_topics or None, bucket=bucket, default_person=default_person, + kept_media=self._keep_original( + file_data=file_data, mime=mime, filename=filename, + event_id=capture_id, room_id=room_id, + sender_mxid=sender_mxid, ts_ms=captured_ts, + ), ) await self._reply_for_capture(room_id, outcome, reply_to) diff --git a/stacklets/docs/bot/capture_pipeline.py b/stacklets/docs/bot/capture_pipeline.py index d3a5b0bc..ed7f8b1a 100644 --- a/stacklets/docs/bot/capture_pipeline.py +++ b/stacklets/docs/bot/capture_pipeline.py @@ -274,6 +274,7 @@ async def capture_binary( seed_topics: list[str] | None = None, bucket: str | None = None, default_person: bool = True, + kept_media: dict | None = None, ) -> CaptureOutcome: """File a PDF or image as a bookmark. @@ -282,9 +283,15 @@ async def capture_binary( PDFs (past the per-instance vision cap) bypass vision and ride the text layer through the existing text-capture path, so the household doesn't pay vision tokens for a 60-page - research paper. ``source_uri`` is the Matrix mxc URL so the - wiki entry links back to the original binary -- we don't - re-store the bytes; Matrix already has them. + research paper. ``source_uri`` is the Matrix mxc URL, so the + entry links back to the message the upload came in on. + + ``kept_media`` is where the caller archived the bytes, when it + did. The mxc URL is a pointer into the homeserver's media store + and nothing promises that store outlives the entry; the archive + is the copy the wiki serves itself. The caller keeps it because + it is the one that has the room, the sender and the event this + upload arrived on, which is what the archive records. """ source, images, kind = self._source_from_binary( file_data=file_data, mime=mime, filename=filename, @@ -302,6 +309,7 @@ async def capture_binary( images=images, actor=sender_mxid, capture_id=capture_id, seed_topics=seed_topics, bucket=bucket, default_person=default_person, + kept_media=kept_media, ) def _cap_pdf_body(self, source: SourceContent) -> SourceContent: @@ -554,6 +562,7 @@ async def _publish( email_meta: dict | None = None, default_person: bool = True, transcribed: bool = False, + kept_media: dict | None = None, ) -> CaptureOutcome: """Shared tail: classify, mirror, record tags, return the outcome. @@ -648,6 +657,7 @@ async def _publish( existing_path=existing_path, capture_id=capture_id, submitter=sender_mxid, + kept_media=kept_media, ) # Feed topic tags (not the derived Person: X) back into the diff --git a/stacklets/docs/bot/git_mirror.py b/stacklets/docs/bot/git_mirror.py index dd6a36f9..fd95d5b8 100644 --- a/stacklets/docs/bot/git_mirror.py +++ b/stacklets/docs/bot/git_mirror.py @@ -451,6 +451,7 @@ def _render_capture( summary: str | None = None, facts: list | None = None, action_items: list | None = None, + kept_media: dict | None = None, ) -> str: """Capture mirror markdown. Delegates to ``vault_entry.render_capture``.""" return render_capture( @@ -458,6 +459,7 @@ def _render_capture( captured_at=captured_at, source_uri=source_uri, persons=persons, from_path=from_path, shared_bucket=self.shared_bucket, summary=summary, facts=facts, action_items=action_items, + kept_media=kept_media, ) def _commit_message( @@ -850,6 +852,7 @@ async def publish_capture( existing_path: str | None = None, capture_id: str | None = None, submitter: str | None = None, + kept_media: dict | None = None, ) -> str | None: """Create or update a capture entry in the mirror. @@ -867,6 +870,12 @@ async def publish_capture( the body text (re-pastes of the same text update; edits create a new file). + ``kept_media`` is the archived copy of the uploaded file, when + the caller kept one: the entry names it and, for a picture, + shows it. Absent on every capture that has no bytes of its own + (a URL, a pasted note) and on a reprocess, which re-renders + from the prior entry and never sees the original again. + ``existing_path`` is the reprocess hook: when supplied, the write deletes the old file if the new title-derived path differs (a rename) and re-uses the same identity otherwise. @@ -960,6 +969,7 @@ async def publish_capture( summary=briefing_summary, facts=briefing_facts, action_items=briefing_actions, + kept_media=kept_media, ) verb = "update" if existing else "capture" diff --git a/stacklets/docs/bot/vault_entry.py b/stacklets/docs/bot/vault_entry.py index 274fe436..ed0afd55 100644 --- a/stacklets/docs/bot/vault_entry.py +++ b/stacklets/docs/bot/vault_entry.py @@ -442,6 +442,7 @@ def render_capture( summary: str | None = None, facts: list | None = None, action_items: list | None = None, + kept_media: dict | None = None, ) -> str: """Assemble the mirror file for a capture entry (kind=note|bookmark). @@ -471,6 +472,11 @@ def render_capture( facts: List of fact strings for the briefing. action_items: Tasks the note records (notes only); [] / None for bookmarks. Rendered as `- [ ]` lines in the briefing callout. + kept_media: The archived copy of the uploaded file, as + ``{"name", "original", "embed"}``. ``original`` is where the + bytes live in the site; ``embed`` is the version worth + putting on the page and is empty for anything that should + be downloaded rather than displayed. Returns: Complete markdown string for the mirror file. @@ -496,6 +502,13 @@ def render_capture( meta_lines.append(" · ".join(line2_bits)) if source_uri: meta_lines.append(f"**Source** <{source_uri}>") + kept = kept_media or {} + if kept.get("original"): + # The bytes themselves, kept where the wiki serves them. The + # Source line above points at the chat message, which is a + # pointer into a service that need not outlive this entry. + meta_lines.append( + f"**File** [{kept.get('name') or 'original'}]({kept['original']})") parts.extend(f"> {ln}" for ln in meta_lines) parts.append("") @@ -508,6 +521,14 @@ def render_capture( parts.append(briefing) parts.append("") + # A picture is the entry; a document is a download. Embedding a PDF + # puts a scrolling frame where a summary should be, so only what + # reads at a glance goes inline and the rest stays the File link + # above. + if kept.get("embed"): + parts.append(f"![[{kept['embed']}]]") + parts.append("") + # Notes: collapsible callout around the verbatim paste. The `-` # after [!quote] tells Obsidian to default-collapse the section. # Forgejo's renderer falls back to a labeled blockquote. diff --git a/tests/stacklets/test_archivist_routing.py b/tests/stacklets/test_archivist_routing.py index 12055b4a..1d0538ba 100644 --- a/tests/stacklets/test_archivist_routing.py +++ b/tests/stacklets/test_archivist_routing.py @@ -16,7 +16,9 @@ from __future__ import annotations +import json import sys +from datetime import datetime, timezone from pathlib import Path from types import SimpleNamespace @@ -1010,3 +1012,108 @@ async def test_a_mention_still_asks_rather_than_files( event.source["content"]["m.mentions"] = {"user_ids": [BOT_ID]} await bot._on_text(self._shared_room(), event) assert [c[0] for c in calls] == ["search"] + + +class TestAnUploadKeepsItsFile: + """A capture entry pointed at an mxc URL and nothing else, which is + a pointer into the homeserver's media store and no promise that the + file is still there in ten years. An upload into a capture room now + also lands in the media archive the wiki serves, and the pipeline + is told where it went so the entry can name it. + + The bytes are kept here rather than in the pipeline because this is + the layer that has the event: the room, the sender and the moment, + which is what the archive records beside the file. + """ + + # 2026-03-14 09:30 UTC, which is the folder the file lands in. + WHEN = int(datetime(2026, 3, 14, 9, 30, tzinfo=timezone.utc).timestamp() * 1000) + + def _bot(self, tmp_path): + bot = _build_bot(tmp_path) + calls: list[dict] = [] + + class _RecordingPipeline: + async def capture_binary(self, **kwargs): + calls.append(kwargs) + return SimpleNamespace(status="captured") + + async def _binding(_room, _sender): + return None + + async def _reply(*_a, **_kw): + return None + + bot._capture = _RecordingPipeline() + bot._topic_binding = _binding + bot._reply_for_capture = _reply + bot._client = SimpleNamespace(rooms={"!r:server": _room()}) + return bot, calls + + async def _upload(self, bot, **over): + args = dict( + room_id="!r:server", file_data=b"%PDF-1.4 anmeldung", + mime="application/pdf", filename="Anmeldung Schwimmkurs.pdf", + source_uri="mxc://home.local/abcdef", sender_mxid="@marge:server", + capture_id="$upload", captured_ts=self.WHEN, + ) + args.update(over) + await bot._handle_binary_capture(**args) + + async def test_the_file_lands_where_the_wiki_will_serve_it( + self, tmp_path, monkeypatch): + brain = tmp_path / "brain" + brain.mkdir() + monkeypatch.setenv("BRAIN_REPO_DIR", str(brain)) + bot, calls = self._bot(tmp_path) + + await self._upload(bot) + + assert calls[0]["kept_media"]["original"] == "/media/2026/03/upload.pdf" + assert (brain / "media" / "2026" / "03" / "upload.pdf").read_bytes() == \ + b"%PDF-1.4 anmeldung" + + async def test_the_archive_is_never_committed(self, tmp_path, monkeypatch): + """Deleting the message has to be able to delete the file, and a + blob that was committed once stays reachable forever.""" + brain = tmp_path / "brain" + brain.mkdir() + monkeypatch.setenv("BRAIN_REPO_DIR", str(brain)) + bot, _ = self._bot(tmp_path) + + await self._upload(bot) + + assert "media/" in (brain / ".gitignore").read_text(encoding="utf-8") + + async def test_the_file_remembers_the_room_it_was_posted_in( + self, tmp_path, monkeypatch): + """The name in the archive is a scrubbed event id and the + folders are a date. Everything else about the upload is in the + record beside it or nowhere.""" + brain = tmp_path / "brain" + brain.mkdir() + monkeypatch.setenv("BRAIN_REPO_DIR", str(brain)) + bot, _ = self._bot(tmp_path) + + await self._upload(bot) + + record = json.loads( + (brain / "media" / "2026" / "03" / "upload.json") + .read_text(encoding="utf-8")) + assert record["room_id"] == "!r:server" + assert record["sender"] == "@marge:server" + assert record["filename"] == "Anmeldung Schwimmkurs.pdf" + assert record["source"] == "capture" + + async def test_a_capture_still_files_when_there_is_nowhere_to_keep_it( + self, tmp_path, monkeypatch): + """The docs stacklet runs without memory installed, so there is + no brain to write into. Filing is what the user asked for; the + copy is a bonus and never a precondition.""" + monkeypatch.delenv("BRAIN_REPO_DIR", raising=False) + bot, calls = self._bot(tmp_path) + + await self._upload(bot) + + assert calls[0]["kept_media"] is None + assert calls[0]["file_data"] == b"%PDF-1.4 anmeldung" diff --git a/tests/stacklets/test_vault_entry.py b/tests/stacklets/test_vault_entry.py index 427e8970..6ddca06c 100644 --- a/tests/stacklets/test_vault_entry.py +++ b/tests/stacklets/test_vault_entry.py @@ -651,7 +651,84 @@ def test_note_without_body(self): assert "> [!quote]" not in content # no body → no quote block -# ── Email thread folding ─────────────────────────────────────────────── +class TestCaptureKeepsTheFileItDescribes: + """A capture entry used to be a summary and an mxc URL, which is a + pointer into the homeserver's media store and no promise at all. + The bytes are archived now, and the entry says where. + + What the entry does with them depends on the file: a screenshot is + the entry and belongs on the page, a form is a download. The embed + syntax is Obsidian's, which the wiki renders by extension. + """ + + def _fm(self, title: str) -> dict: + return capture_frontmatter( + title=title, captured_at="2026-03-14", kind="bookmark", + source_uri="mxc://home.local/abcdef", persons=[], tags=[], + model=None, + ) + + def _render(self, kept: dict | None, **over) -> str: + args = dict( + from_path="marge/bookmarks/2026/03/entry.md", + shared_bucket="family", frontmatter=self._fm("Screenshot"), + body="", kind="bookmark", captured_at="2026-03-14", + source_uri="mxc://home.local/abcdef", persons=[], + summary="A screenshot of the school calendar.", facts=[], + kept_media=kept, + ) + args.update(over) + return render_capture(**args) + + def test_a_screenshot_is_shown_on_the_page(self): + content = self._render({ + "name": "Bildschirmfoto.png", + "original": "/media/2026/03/evt.png", + "embed": "/media/2026/03/evt.webp", + }) + + assert "![[/media/2026/03/evt.webp]]" in content + + def test_a_document_is_offered_rather_than_embedded(self): + """An embedded PDF is a scrolling frame where the summary should + be. The entry names the file and leaves opening it to the + reader.""" + content = self._render({ + "name": "Anmeldung Schwimmkurs.pdf", + "original": "/media/2026/03/evt.pdf", + "embed": "", + }) + + assert "**File** [Anmeldung Schwimmkurs.pdf](/media/2026/03/evt.pdf)" in content + assert "![[" not in content + + def test_the_entry_names_the_file_as_it_was_posted(self): + """The name in the archive is a scrubbed event id. The name the + sender saw only survives on the page and in the record beside + the file.""" + content = self._render({ + "name": "Bildschirmfoto.png", + "original": "/media/2026/03/evt.png", + "embed": "/media/2026/03/evt.png", + }) + + assert "**File** [Bildschirmfoto.png](/media/2026/03/evt.png)" in content + + def test_the_message_it_came_in_on_is_still_named(self): + """The archive is a second copy, not a replacement: the entry + still points back at the conversation.""" + content = self._render({ + "name": "x.png", "original": "/media/2026/03/evt.png", + "embed": "/media/2026/03/evt.png", + }) + + assert "**Source** " in content + + def test_an_entry_whose_file_was_not_kept_reads_as_it_always_did(self): + """Nothing about the archive may be a precondition for filing. + A capture with no bytes of its own (a URL, a pasted note), a + vault with no brain, a full disk: same page as before.""" + assert self._render(None) == self._render({}) class TestRenderEmailMessageSection: From be5434d4f168237843b40132ed441e155b38b3df Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 15 Sep 2026 15:00:42 +0200 Subject: [PATCH 06/13] fix: keep an upload off the event loop while it is archived Writing the file and converting it can take seconds, and every bot in the stack shares one loop, so the archivist hands that work to a thread. The diary compiler now names each file as it fetches it: the first run over a room with years in it is minutes of downloading and was silent throughout. --- lib/stack/media.py | 3 ++- stacklets/docs/bot/archivist.py | 14 +++++++++----- stacklets/memory/bot/cli/diary.py | 5 +++++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/lib/stack/media.py b/lib/stack/media.py index 349039c9..de5c46fd 100644 --- a/lib/stack/media.py +++ b/lib/stack/media.py @@ -124,7 +124,8 @@ def extension_for(filename: str, mime: str = "") -> str: if from_name.lstrip("."): return _safe_ext(from_name) declared = str(mime or "").split(";")[0].strip() - return _safe_ext(mimetypes.guess_extension(declared) or "" if declared else "") + guessed = mimetypes.guess_extension(declared) if declared else "" + return _safe_ext(guessed or "") def kind_for(mime: str) -> str: diff --git a/stacklets/docs/bot/archivist.py b/stacklets/docs/bot/archivist.py index fe9718c7..aefa2bae 100644 --- a/stacklets/docs/bot/archivist.py +++ b/stacklets/docs/bot/archivist.py @@ -2251,6 +2251,11 @@ def _keep_original( ) -> dict | None: """Archive the uploaded bytes; return how the entry shows them. + Blocking: it writes megabytes and may run a converter, so the + caller hands it to a thread. Every bot in the stack shares one + event loop, and a photograph being resized here would otherwise + be every other bot standing still. + ``{"name", "original", "embed"}``, or None when nothing was kept. The archive lives inside the brain working copy, which is the tree the wiki serves, so the entry addresses the file by a @@ -2270,10 +2275,8 @@ def _keep_original( if not brain or not event_id: return None import datetime as _dt - when = _dt.datetime.fromtimestamp( - ts_ms / 1000 if ts_ms else _dt.datetime.now(_dt.timezone.utc).timestamp(), - tz=_dt.timezone.utc, - ) + when = (_dt.datetime.fromtimestamp(ts_ms / 1000, tz=_dt.timezone.utc) + if ts_ms else _dt.datetime.now(_dt.timezone.utc)) media.ensure_ignored(brain) root = media.archive_root(brain) kind = media.kind_for(mime) @@ -2342,7 +2345,8 @@ async def _handle_binary_capture( seed_topics=seed_topics or None, bucket=bucket, default_person=default_person, - kept_media=self._keep_original( + kept_media=await asyncio.to_thread( + self._keep_original, file_data=file_data, mime=mime, filename=filename, event_id=capture_id, room_id=room_id, sender_mxid=sender_mxid, ts_ms=captured_ts, diff --git a/stacklets/memory/bot/cli/diary.py b/stacklets/memory/bot/cli/diary.py index eca7a332..326304a3 100644 --- a/stacklets/memory/bot/cli/diary.py +++ b/stacklets/memory/bot/cli/diary.py @@ -234,6 +234,11 @@ async def _archive_media(messages, events, *, session, homeserver, token, link = media.kept(root, msg.event_id, ext=ext, when=when) if not link: + # Logged before the fetch, not after. A first compile over a + # room with years of photographs in it is minutes of + # downloading, and a command that prints nothing for that + # long is indistinguishable from one that has hung. + _err(f" keeping {filename or msg.event_id}") data = await _download(session, homeserver, token, msg.url) if not data: continue From 4964878ac4b4aa3b925fd22aa0738afb550d0181 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 15 Sep 2026 15:03:41 +0200 Subject: [PATCH 07/13] fix: correcting a capture no longer drops its file A reply-to-correct re-renders the entry from the prior one and never sees the original bytes again, so the archived file would have disappeared from the page on the first correction. The reference is read back off the entry being replaced. --- stacklets/docs/bot/capture_pipeline.py | 31 +++++++++++ tests/stacklets/test_capture_pipeline.py | 71 ++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/stacklets/docs/bot/capture_pipeline.py b/stacklets/docs/bot/capture_pipeline.py index ed7f8b1a..7e6a504e 100644 --- a/stacklets/docs/bot/capture_pipeline.py +++ b/stacklets/docs/bot/capture_pipeline.py @@ -18,6 +18,7 @@ import datetime as _dt import io +import re from dataclasses import dataclass, field from loguru import logger @@ -501,6 +502,10 @@ async def reprocess( work against the model's own paraphrase rather than the raw page. Pure text corrections (Homer's "It is a Mac Studio") compose cleanly under that constraint. + + The archived file is the one thing that cannot be recovered + that way: the bytes are long gone from this path, so the + reference is read back off the entry being replaced. """ if self._mirror is None: return CaptureOutcome(status="no_mirror") @@ -545,6 +550,7 @@ async def reprocess( actor=sender_mxid, capture_id=str(capture_id) if capture_id else None, initial_classification=initial_classification, + kept_media=_kept_media_from(raw), ) async def _publish( @@ -860,6 +866,31 @@ def _parse_capture_markdown(raw: str) -> tuple[dict, str]: return (meta, summary) +# The two lines an entry uses to reach its archived file, as +# `vault_entry.render_capture` writes them. +_FILE_LINE = re.compile(r"^> \*\*File\*\* \[([^\]]*)\]\((/media/[^)]+)\)", re.M) +_EMBED_LINE = re.compile(r"^!\[\[(/media/[^\]]+)\]\]", re.M) + + +def _kept_media_from(raw: str) -> dict | None: + """The archived file an entry already names, read back off the page. + + A correction re-renders the whole entry from the prior one and + never sees the original bytes again. Without reading the reference + back, replying to fix a tag would drop the file from an entry that + had one. + """ + named = _FILE_LINE.search(raw) + if not named: + return None + shown = _EMBED_LINE.search(raw) + return { + "name": named.group(1), + "original": named.group(2), + "embed": shown.group(1) if shown else "", + } + + def _parse_yaml_frontmatter(text: str) -> dict: """Same stdlib subset memory/lib's `_parse_frontmatter` uses -- keeps the bot stdlib-only for this path so we don't pull diff --git a/tests/stacklets/test_capture_pipeline.py b/tests/stacklets/test_capture_pipeline.py index 66b759da..082ebe2f 100644 --- a/tests/stacklets/test_capture_pipeline.py +++ b/tests/stacklets/test_capture_pipeline.py @@ -18,6 +18,7 @@ sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) from capture_pipeline import CapturePipeline # noqa: E402 +from vault_entry import capture_frontmatter, render_capture # noqa: E402 def _source(*, text="article body", source_uri=None, title_hint="A Title"): @@ -1005,3 +1006,73 @@ async def test_a_typed_note_still_reads_as_pasted_text(self): sender_mxid="@homer:s", ) assert out.display_link == "(pasted text)" + + +class TestCorrectingAnEntryKeepsItsFile: + """A correction re-renders the whole entry from the prior one and + never sees the original bytes again. The archived file has to + survive that, or replying "this is about school, not work" would + quietly take the photograph off the page. + + The entry fed in here is rendered by `vault_entry`, not hand-written, + so the read and the write cannot drift apart without this failing. + """ + + PATH = "marge/bookmarks/test-capture.md" + + def _entry(self, **kept) -> str: + fm = capture_frontmatter( + title="Schulkalender", captured_at="2026-03-14", kind="bookmark", + source_uri="mxc://home.local/abc", persons=["Marge"], + tags=["schule"], model=None, capture_id="$shot:home.local", + ) + return render_capture( + frontmatter=fm, body="", kind="bookmark", + captured_at="2026-03-14", source_uri="mxc://home.local/abc", + persons=["Marge"], from_path=self.PATH, shared_bucket="family", + summary="The school calendar for March.", facts=[], + kept_media=kept or None, + ) + + async def _reprocess(self, raw: str) -> dict: + mirror = FakeMirror() + mirror._stored = {self.PATH: raw} + pipe = _pipeline(mirror=mirror) + await pipe.reprocess( + vault_path=self.PATH, user_hint="das gehört zur Schule", + sender_mxid="@marge:s", + ) + return mirror.captures[-1] + + @pytest.mark.asyncio + async def test_the_corrected_entry_still_names_the_file(self): + published = await self._reprocess(self._entry( + name="Bildschirmfoto.png", + original="/media/2026/03/shot.png", + embed="/media/2026/03/shot.webp", + )) + + assert published["kept_media"] == { + "name": "Bildschirmfoto.png", + "original": "/media/2026/03/shot.png", + "embed": "/media/2026/03/shot.webp", + } + + @pytest.mark.asyncio + async def test_a_document_stays_a_document_through_a_correction(self): + """It had no embed before the correction and must not gain one: + a PDF in an iframe is what the entry deliberately does not do.""" + published = await self._reprocess(self._entry( + name="Anmeldung.pdf", + original="/media/2026/03/form.pdf", + embed="", + )) + + assert published["kept_media"]["embed"] == "" + assert published["kept_media"]["original"] == "/media/2026/03/form.pdf" + + @pytest.mark.asyncio + async def test_an_entry_that_never_had_a_file_does_not_grow_one(self): + published = await self._reprocess(self._entry()) + + assert published["kept_media"] is None From 887992770cd3e2ec7c9402e900747b2ca0bc2c32 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 15 Sep 2026 15:10:22 +0200 Subject: [PATCH 08/13] fix(media): derive thumbnails as JPEG, not WebP WebP needs libwebp linked into ffmpeg at build time. A build without it produces no derivative at all, so the page embeds the full-size original and the bound the derivative exists to enforce is silently gone. The ffmpeg on this machine is such a build, which is why the end-to-end thumbnail test was gated and skipped rather than run. mjpeg is built into ffmpeg itself, so any build produces the derivative. JPEG at -q:v 3 is roughly a quarter larger than the WebP would have been, which is a smaller cost than a hundred-megabyte page that nothing reports. The test drops its encoder gate and now runs, covering the scale arithmetic end to end. --- lib/stack/media.py | 16 ++++++++++++---- tests/framework/test_media_archive.py | 21 ++------------------- 2 files changed, 14 insertions(+), 23 deletions(-) diff --git a/lib/stack/media.py b/lib/stack/media.py index de5c46fd..8c3b5dc7 100644 --- a/lib/stack/media.py +++ b/lib/stack/media.py @@ -80,9 +80,13 @@ MAX_IMAGE_WIDTH = 1600 # The forms a derivative takes, by the kind of artifact it came from. -# AAC in MP4 because Safari does not decode Opus in Ogg; WebP because -# it is the smallest of the formats every current browser renders. -_DERIVED_EXT = {"audio": "m4a", "image": "webp"} +# AAC in MP4 because Safari does not decode Opus in Ogg. JPEG because +# mjpeg is built into ffmpeg itself, where WebP needs libwebp linked in +# at build time: a build without it produces no derivative at all, so +# the page falls back to embedding the full-size original, which is the +# one outcome the derivative exists to prevent. WebP would be roughly a +# quarter smaller, which does not buy that risk. +_DERIVED_EXT = {"audio": "m4a", "image": "jpg"} _IGNORE_NOTE = ( "# Original uploads, kept on disk only. Deleting the source event has\n" @@ -308,7 +312,7 @@ def transcode_audio(src, dst) -> bool: def transcode_image(src, dst, *, max_width: int = MAX_IMAGE_WIDTH) -> bool: - """Convert an image to a WebP no wider than `max_width`. True when done. + """Convert an image to a JPEG no wider than `max_width`. True when done. A page that embeds originals makes a reader download originals, and twenty photographs off a phone are a hundred megabytes of them. The @@ -327,6 +331,10 @@ def transcode_image(src, dst, *, max_width: int = MAX_IMAGE_WIDTH) -> bool: # One frame: the source may be an animation, and the point of # the derivative is a bounded still. "-frames:v", "1", + # ffmpeg's JPEG default is soft enough to show on a photograph. + # 3 is near the top of the quality scale and still a fraction of + # the original's size. + "-q:v", "3", ]) diff --git a/tests/framework/test_media_archive.py b/tests/framework/test_media_archive.py index a62190ef..13c41e5a 100644 --- a/tests/framework/test_media_archive.py +++ b/tests/framework/test_media_archive.py @@ -330,26 +330,10 @@ def test_the_kind_follows_the_media_type(self, mime, expected): # ── Second copies ──────────────────────────────────────────────────────── -def _encoders() -> str: - if shutil.which("ffmpeg") is None: - return "" - return subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], - capture_output=True, check=False).stdout.decode() - - needs_ffmpeg = pytest.mark.skipif( shutil.which("ffmpeg") is None, reason="ffmpeg is a container dependency; absent here") -# The container image installs a full ffmpeg; a developer machine may -# carry a build without the WebP encoder, which is a property of that -# build and not of this code. The width arithmetic below is ours and is -# tested separately, through a format every build can write. -needs_webp = pytest.mark.skipif( - "webp" not in _encoders(), - reason="this ffmpeg build has no WebP encoder") - - def _width(path: Path) -> int: out = subprocess.run( ["ffprobe", "-v", "error", "-select_streams", "v:0", @@ -408,7 +392,6 @@ class TestDerivatives: "sender": "@homer:example.org"} @needs_ffmpeg - @needs_webp def test_a_photograph_gets_a_bounded_copy_beside_it(self, archive, tmp_path): data = _an_image(tmp_path / "photo.png", 3000) media.keep(archive, "$photo", data, ext="png", when=MARCH, @@ -417,8 +400,8 @@ def test_a_photograph_gets_a_bounded_copy_beside_it(self, archive, tmp_path): link = media.derive(archive, "$photo", ext="png", when=MARCH, kind="image") - assert link == "/media/2026/03/photo.webp" - assert _width(archive / "2026" / "03" / "photo.webp") == media.MAX_IMAGE_WIDTH + assert link == "/media/2026/03/photo.jpg" + assert _width(archive / "2026" / "03" / "photo.jpg") == media.MAX_IMAGE_WIDTH assert (archive / "2026" / "03" / "photo.png").read_bytes() == data def test_a_derivative_is_written_into_the_record_that_owns_it(self, archive): From 5fe349e69340f9dbb415fafe45450a082a81e169 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 15 Sep 2026 15:21:35 +0200 Subject: [PATCH 09/13] fix(media): exclude the archive locally, or Quartz never serves it Quartz globs its content directory through globby with `gitignore: true` (quartz/util/glob.ts), and the Assets emitter uses that same glob. A `media/` line in brain's committed `.gitignore` therefore removed the whole archive from the build: every embed and every link into it would have answered 404, with nothing anywhere reporting it. Verified in the wiki container against the pinned globby: with the rule in `.gitignore` the emitter returns an empty list; with the rule in `.git/info/exclude` it returns the files. Git honours both, globby reads only the first. So the rule moves to `.git/info/exclude`, which also removes the migration problem: nothing is committed, so an upgraded instance needs no seed change. The `media/` line comes back out of BRAIN_SEED_GITIGNORE. The tests now assert through `git check-ignore` rather than by reading a file, and one pins the invariant that broke: the rule must not appear in the tracked ignore file. --- lib/stack/media.py | 21 ++++++++++++++++++--- stacklets/memory/lib.py | 3 --- stacklets/memory/quartz/quartz.config.ts | 7 ++++++- tests/framework/test_media_archive.py | 23 ++++++++++++++++++----- tests/stacklets/test_archivist_routing.py | 18 ++++++++++++++++-- 5 files changed, 58 insertions(+), 14 deletions(-) diff --git a/lib/stack/media.py b/lib/stack/media.py index 8c3b5dc7..f30ce646 100644 --- a/lib/stack/media.py +++ b/lib/stack/media.py @@ -426,17 +426,32 @@ def ensure_ignored(repo_root) -> None: tree, which would make the deletion a false promise. The second is size - originals accumulate and every clone would carry all of them. + The rule goes in `.git/info/exclude`, not `.gitignore`, because the + site generator reads the tracked ignore file as well: Quartz globs + the content directory with `gitignore: true`, so a `.gitignore` entry + hides the archive from the build and every link into it answers 404. + `info/exclude` is git's local equivalent and nothing outside git + reads it, which is the only place the two requirements both hold. + Idempotent. A repository that already states the rule is left alone, so this is safe to call on every write. """ - gitignore = Path(repo_root) / ".gitignore" + info = Path(repo_root) / ".git" / "info" + if not info.parent.is_dir(): + # A linked worktree keeps its git directory elsewhere and points + # at it through a `.git` file. Deployed clones are not that + # shape, and resolving one would need git itself. + log.warning("no git directory at %s, archive not excluded", repo_root) + return + exclude = info / "exclude" rule = f"{ARCHIVE_DIR}/" try: - existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else "" + existing = exclude.read_text(encoding="utf-8") if exclude.exists() else "" if rule in (line.strip() for line in existing.splitlines()): return + info.mkdir(parents=True, exist_ok=True) separator = "" if not existing or existing.endswith("\n") else "\n" - gitignore.write_text( + exclude.write_text( f"{existing}{separator}{_IGNORE_NOTE}{rule}\n", encoding="utf-8", ) except OSError as e: diff --git a/stacklets/memory/lib.py b/stacklets/memory/lib.py index 2f0b1b69..a31b3e0a 100644 --- a/stacklets/memory/lib.py +++ b/stacklets/memory/lib.py @@ -100,9 +100,6 @@ ".obsidian/\n" ".trash/\n" ".DS_Store\n" - "# Archived originals (stack.media). Deleting the message that carried\n" - "# one has to delete the file, and git history would keep it anyway.\n" - "media/\n" ) BRAIN_SEED_README = ( "# Family brain (projection)\n\n" diff --git a/stacklets/memory/quartz/quartz.config.ts b/stacklets/memory/quartz/quartz.config.ts index 52789d55..1c375622 100644 --- a/stacklets/memory/quartz/quartz.config.ts +++ b/stacklets/memory/quartz/quartz.config.ts @@ -44,7 +44,12 @@ const config: QuartzConfig = { baseUrl, // Skip git internals and Obsidian config dirs. The vault is a // real git clone, not a stripped checkout, so `.git` matters. - ignorePatterns: [".git", ".obsidian", "private", "templates"], + // `media/**/*.json` keeps the archive's sidecar records out of the + // published site. Each one names the room, the sender's full mxid and + // the original filename: useful beside the file on disk, not something + // to serve. The media itself is copied by the Assets emitter as usual. + ignorePatterns: [".git", ".obsidian", "private", "templates", + "media/**/*.json"], defaultDateType: "modified", theme: { // Self-hosted. The comment above about analytics applies with more diff --git a/tests/framework/test_media_archive.py b/tests/framework/test_media_archive.py index 13c41e5a..ceec0852 100644 --- a/tests/framework/test_media_archive.py +++ b/tests/framework/test_media_archive.py @@ -166,15 +166,28 @@ def test_git_ignores_the_archive_afterwards(self, tmp_path): def test_calling_it_again_does_not_repeat_the_rule(self, tmp_path): """It runs on every write, so a second call has to be a no-op. - A .gitignore that grows a line per upload is a diff on every - curator commit.""" + A file that grows a line per upload is noise forever.""" repo = self._repo(tmp_path) + exclude = repo / ".git" / "info" / "exclude" media.ensure_ignored(repo) - once = (repo / ".gitignore").read_text() + once = exclude.read_text() media.ensure_ignored(repo) - assert (repo / ".gitignore").read_text() == once + assert exclude.read_text() == once + + def test_the_rule_stays_out_of_the_tracked_ignore_file(self, tmp_path): + """The site generator globs the content directory with + `gitignore: true`, so a rule in `.gitignore` would hide the + archive from the build and every link into it would answer 404. + Git has to ignore these files; Quartz has to see them.""" + repo = self._repo(tmp_path) + (repo / ".gitignore").write_text(".obsidian/\n") + + media.ensure_ignored(repo) + + assert "media" not in (repo / ".gitignore").read_text() + assert self._ignored(repo, "media/2026/03/a.png") def test_rules_already_in_the_file_are_kept(self, tmp_path): """The seeded projection repo ships its own ignore rules. Adding @@ -191,7 +204,7 @@ def test_a_file_without_a_trailing_newline_is_not_joined_onto(self, tmp_path): """Appending to `.DS_Store` would produce `.DS_Storemedia/` and silently ignore neither.""" repo = self._repo(tmp_path) - (repo / ".gitignore").write_text(".DS_Store") + (repo / ".git" / "info" / "exclude").write_text(".DS_Store") media.ensure_ignored(repo) diff --git a/tests/stacklets/test_archivist_routing.py b/tests/stacklets/test_archivist_routing.py index 1d0538ba..d6634f34 100644 --- a/tests/stacklets/test_archivist_routing.py +++ b/tests/stacklets/test_archivist_routing.py @@ -17,6 +17,7 @@ from __future__ import annotations import json +import subprocess import sys from datetime import datetime, timezone from pathlib import Path @@ -1075,15 +1076,28 @@ async def test_the_file_lands_where_the_wiki_will_serve_it( async def test_the_archive_is_never_committed(self, tmp_path, monkeypatch): """Deleting the message has to be able to delete the file, and a - blob that was committed once stays reachable forever.""" + blob that was committed once stays reachable forever. + + Asserted through git rather than by reading an ignore file: the + rule deliberately lives in `.git/info/exclude`, because Quartz + globs the content directory with `gitignore: true` and a + `.gitignore` entry would hide the archive from the site. + """ brain = tmp_path / "brain" brain.mkdir() + subprocess.run(["git", "init", "-q", str(brain)], check=True) + (brain / ".gitignore").write_text(".obsidian/\n", encoding="utf-8") monkeypatch.setenv("BRAIN_REPO_DIR", str(brain)) bot, _ = self._bot(tmp_path) await self._upload(bot) - assert "media/" in (brain / ".gitignore").read_text(encoding="utf-8") + ignored = subprocess.run( + ["git", "-C", str(brain), "check-ignore", "-q", + "media/2026/03/upload.pdf"], check=False, + ) + assert ignored.returncode == 0 + assert "media" not in (brain / ".gitignore").read_text(encoding="utf-8") async def test_the_file_remembers_the_room_it_was_posted_in( self, tmp_path, monkeypatch): From eacdb194866c648313f401249c5244607000fa43 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 15 Sep 2026 15:22:35 +0200 Subject: [PATCH 10/13] docs: record the media archive, and correct the stdlib invariant Replaces the diary-only media note with the design that was built: one archive for captures and diary entries, why it sits inside brain, why the exclusion is local rather than tracked, the ffmpeg and JPEG decisions, and the sidecar record. dev.md claimed `lib/stack/` imports nothing from pip. It is not true and has not been for a while: `lib/stack/ai/` imports openai and loguru. The invariant that actually holds is about the host CLI path, where ./stack runs on system Python with no venv. The wrong version of this sent an implementation down a path that avoided a dependency nothing forbids. Also records two open items the build surfaced: nothing prunes the archive, and a new recording is fetched twice on its first compile. --- docs/agent/dev.md | 4 +- .../brain/memory-pipeline-architecture.md | 132 ++++++++++++++---- 2 files changed, 110 insertions(+), 26 deletions(-) diff --git a/docs/agent/dev.md b/docs/agent/dev.md index 542532e8..330447b1 100644 --- a/docs/agent/dev.md +++ b/docs/agent/dev.md @@ -28,7 +28,7 @@ Pre-1.0 stance: we keep backwards compatibility only for critical parts. Everywh ``` famstack/ ├── stack 4-line bash wrapper → lib/stack -├── lib/stack/ CLI core (Python, stdlib only) +├── lib/stack/ CLI core (Python; top level stdlib only) ├── stacklets/ one directory per stacklet (id == dirname) │ ├── core/ always-on (Caddy, Watchtower, bot-runner) │ ├── messages/ Matrix + Element @@ -52,7 +52,7 @@ famstack/ ## Five framework invariants 1. **`.env` is a derived artifact.** Generated on every `stack up` from `stack.toml` + `[env.defaults]` + secrets. Never edit, never commit, never read as source of truth. (See [adr-006](../adr/adr-006-env-as-derived-artifact.md).) -2. **The CLI is stdlib-only.** `lib/stack/` imports nothing from pip. Container code carries its own deps; host-side tooling (tests, hooks) gets deps via `pyproject.toml [project.optional-dependencies] test`. +2. **The host CLI is stdlib-only.** `lib/stack/*.py` imports nothing from pip, because `./stack` runs on the host's system Python with no venv. A subpackage that only ever loads inside a container may use pip deps declared by the images that mount it: `lib/stack/ai/` imports `openai` and `loguru`. Container code carries its own deps; host-side tooling (tests, hooks) gets deps via `pyproject.toml [project.optional-dependencies] test`. 3. **State is derived.** No "enabled stacklets" registry. `docker ps -a` + `~/famstack-data//` decide state. 4. **Convention over configuration.** If a file exists with the documented name (`hooks/on_install.py`, `cli/foo.py`, `caddy.snippet`, `bot/bot.toml`), it is picked up. No registration step. 5. **The `stack` CLI is the sanctioned agent interface.** Bots, hooks, and external automations call `./stack ` - they do not import from `lib/`. Commands have stable exit codes, JSON output, and idempotent semantics. diff --git a/docs/design/brain/memory-pipeline-architecture.md b/docs/design/brain/memory-pipeline-architecture.md index 387563cc..e790297a 100644 --- a/docs/design/brain/memory-pipeline-architecture.md +++ b/docs/design/brain/memory-pipeline-architecture.md @@ -174,6 +174,107 @@ model had ignored. | undatable memories | Matrix has no compose-time field; offline recordings carry sync time | spoken-date extraction; honest "unrecoverable" state; recording habit: say the date aloud | | stale caches serving old wording | caches were keyed by content only; prompt changes did not change the keys | every cached artifact stores a fingerprint (hash) of the prompt or pass parameters that produced it; a prompt edit invalidates exactly the affected artifacts on the next compile | +## Media archive + +Built September 2026. Classification and filing already worked; what +was missing was that the original bytes were never retained. A page +linked an `mxc://` URL and the only copy lived in Synapse's media +store. One archive serves three consumers: note and bookmark captures, +diary entries, and anything else that files an upload. + +### Location + +`{data_dir}/memory/brain/media///.`, inside +brain's working copy, excluded from git. + +Inside brain because the wiki container mounts that directory as its +content root and Quartz's `Assets` emitter copies every non-markdown +file under it into the built site. No extra container, no extra port, +no Caddy route. The last of those decides it: port mode has no Caddy, +so a separate origin would need an absolute URL that is wrong in one +of the two deployment modes and is then baked into pages read for +decades. Files under the content root take root-relative links +(`/media/2026/03/.jpg`), which Quartz and Obsidian resolve +identically at any page depth. + +Excluded from git because redaction has to mean deletion. `resolve()` +already drops redacted events, so removing a message removes its entry; +a committed blob would stay reachable in history and make that a false +promise. It also stops brain's clone growing without bound. + +### The exclusion goes in `.git/info/exclude`, not `.gitignore` + +Quartz globs its content directory through globby with +`gitignore: true` (`quartz/util/glob.ts`), and the `Assets` emitter +uses that same glob. A `media/` rule in the tracked `.gitignore` +therefore removes the entire archive from the build, and every link +into it answers 404 with nothing reporting it. + +Measured in the wiki container against the pinned globby: + +| Rule in | Assets emitter sees | +|---|---| +| `.gitignore` | `[]` | +| `.git/info/exclude` | the files | + +Git honours both; globby reads only the first. `info/exclude` is also +per-clone, so upgraded instances need no seed migration. + +### Derivatives + +ffmpeg, installed in both the bot-runner image and the curator image. +Both are needed because `curator.py` runs the nightly diary as a +subprocess inside its own container, while a manual `stack memory +diary` runs in the bot-runner. + +- Audio gets an `.m4a` beside the original. This exists for exactly one + reason: Safari does not decode Opus in Ogg, and Matrix voice messages + are Ogg/Opus. +- Images get a JPEG bounded to 1600px. JPEG rather than WebP because + mjpeg is built into ffmpeg itself while WebP needs libwebp linked in + at build time, and a build without it produces no derivative at all, + so the page falls back to embedding the full-size original. That is + the one outcome the derivative exists to prevent. WebP would be about + a quarter smaller, which does not buy that risk. +- ffmpeg missing or failing is never fatal. The page shows the original, + or the room link. + +### The record beside the file + +Every stored artifact gets `.json` next to it: event id, room, +sender, original filename, mime, size, sha256, capture and store times, +kind, which pipeline wrote it, and the derivatives it owns. JSON rather +than TOML to match the transcript store, which already writes one JSON +per Matrix event. + +The point is that the archive explains itself. Dated ordinary files +beside Markdown pages, readable with no famstack installed. `derived` +exists so a later re-encode replaces its own output instead of orphaning +it. + +Sidecars are excluded from the published site through Quartz's +`ignorePatterns` (`media/**/*.json`). They name the room and the +sender's full mxid, which is worth having on disk and not worth +serving. + +### Rendering + +`![[path]]` in the page, which Quartz v4.5.2 turns into real HTML by +extension: `