Skip to content

feat(memory): keep the original file behind a wiki page - #92

Merged
arthware-dev merged 13 commits into
mainfrom
feat/media-archive
Sep 15, 2026
Merged

arthware-dev merged 13 commits into
mainfrom
feat/media-archive

Conversation

@arthware-dev

@arthware-dev arthware-dev commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Classification and filing already worked. The original bytes were never
kept: a page linked an mxc:// URL and the only copy lived in Synapse's
media store.

Uploads are now archived under {data_dir}/memory/media/<yyyy>/<mm>/
with a JSON record beside each file, and the page describing one embeds
or links it. Images inline, PDFs linked, recordings get a player. The
room link stays as the timeline anchor; it just stops being the only
copy.

The store is its own directory rather than a folder inside brain, and
the wiki container mounts it into the rendered tree at /vault/media.
Brain is a projection that gets re-cloned whenever it is rebuilt; these
bytes cannot be rebuilt from anything. It is also declared as a backup
source, which it needed more than the rest of {data_dir}/memory,
because everything else there is a git repo with a copy in Forgejo.

Two things worth not rediscovering during review:

  • Docker cannot create the /vault/media mountpoint: its parent is a
    read-only bind, mkdir fails, and the container exits before Quartz
    runs. hooks/on_start.py makes the directory on every start, because
    a re-cloned brain has no empty directories.
  • The mountpoint is excluded in .git/info/exclude, not .gitignore.
    Quartz globs its content directory with gitignore: true, so a
    tracked rule removes the archive from the build and every link into
    it 404s.

ffmpeg goes into both the bot-runner and the curator images, because
the nightly diary runs as a subprocess inside the curator.

Existing diary pages gain their media on the next compile, and months
with no media are left unchanged. Captures filed before this keep only
their mxc:// link; there is no backfill.

Adds stack.media, the archive that holds the bytes a page describes:
one file per event id under <site>/media/<yyyy>/<mm>/, 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.
The archive now writes <id>.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.
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.
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.
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.
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.
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.
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.
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.
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.
`run()` bound the archived-media map to `kept`, then the month-summary
loop rebound the same name to a cached summary string. `pages_for` was
handed that string and rendering died in `_entry_block` on
`'str' object has no attribute 'get'`.

It only fails once a summary is cached. On a first run the loop leaves
`kept` as "", and `"" or {}` yields an empty dict, so the compile
succeeds and every entry silently renders without its media. That is
why the unit suite is green: nothing drives `run()` end to end with a
warm summary cache. Found on the demo rig.

The loop variable is now `cached`.
Found on the demo rig. A 2400px screenshot archived as a 16 KB PNG
produced a 1600px JPEG of 64 KB: four times the bytes, and blurred text
as well. Flat colour and glyph edges are what PNG compresses best and
what JPEG handles worst, and screenshots pasted into notes are a main
case for this archive, not an edge one.

The derivative exists only to make a page lighter, so one that is not
lighter is deleted and the caller shows the original. Audio is exempt:
it is transcoded for a codec Safari can play, so a larger file is still
the only playable one.

The image fixtures split accordingly, because one pattern cannot show
both behaviours: a noisy source stands in for a photograph and keeps its
derivative, a flat test pattern stands in for a screenshot and loses it.
The archive lived inside brain's working copy. Brain is a projection:
its README says it is generated and rebuilt from source, and it is
re-cloned whenever that happens. The originals cannot be rebuilt from
anything, and they are excluded from git, so git is not their second
copy either. Re-cloning brain for any reason destroyed every original
and left the pages pointing at nothing.

The store moves to {data_dir}/memory/media, named by MEDIA_ARCHIVE_DIR,
and the wiki container mounts it into the rendered tree at
/vault/media. Pages are unchanged: links were already site-rooted and
independent of where the bytes sit, and the mount puts the store where
they already point. Verified on the rig that Quartz serves through a
nested mount and that its watcher sees files added after start.

Docker cannot create that mountpoint, because its parent is a
read-only bind and mkdir fails there, taking the container down before
Quartz runs. A new on_start hook makes the directory on every start,
not once at install, because a re-cloned brain has no empty
directories. The same hook owns brain's exclude rule, which is where it
belonged: the memory stacklet owns brain, the framework just stores
bytes.

Both callers now resolve the store one way, through open_archive().
They previously disagreed, with different behaviour when it was
missing, and the archivist read an env var the memory stacklet owns.

Also declares the archive as a backup source. It had none, and unlike
everything else under {data_dir}/memory it is not a git repo with a
copy in Forgejo, so it had no second copy anywhere.
@arthware-dev
arthware-dev merged commit 03e1708 into main Sep 15, 2026
1 check passed
@arthware-dev
arthware-dev deleted the feat/media-archive branch September 22, 2026 20:13
arthware-dev added a commit that referenced this pull request Sep 23, 2026
An image copy that is not smaller than its original was renamed into
the media archive and deleted again a moment later. The wiki watches the
archive, and a file that appears and disappears between two rebuilds
can reach Quartz as a deletion of a file it never copied, which fails
the rebuild the same way an archive temporary file did.

The size is now compared while the copy still has its temporary name,
so a rejected copy never appears under its final name. What is kept is
unchanged.

Fixes: 03e1708 ("feat(memory): keep the original file behind a wiki page (#92)")
arthware-dev added a commit that referenced this pull request Sep 23, 2026
A screenshot filed from a capture room showed on its wiki page as a
broken image, and its File link answered 404, until the wiki container
was next recreated.

The media archive writes through a temporary `.tmp-*` file beside the
target and renames it into place; ffmpeg derivatives are written the
same way and take long enough for the wiki's file watcher to report
them. Quartz then asks its Assets emitter to copy or unlink a path that
is already gone, and the emitter throws. Quartz 4.5.2 keeps every change
since startup and replays them on each rebuild, so the failure repeats:
the process exits, or later assets never reach the site.

Exclude `media/**/.tmp-*` from Quartz's content, which also removes the
files from what the watcher reports.

Fixes: 03e1708 ("feat(memory): keep the original file behind a wiki page (#92)")
arthware-dev added a commit that referenced this pull request Sep 25, 2026
On a first `./stack up memory` the brain projection was never cloned.
`on_start` creates the wiki's media mountpoint inside
`~/famstack-data/memory/brain` before the containers start, and the
`family/brain` repo only exists once they are up, so `git clone` met a
non-empty directory and refused. The step still printed a success mark,
and the curator waited for the projection forever: no wiki page was
regenerated and no diary was compiled.

The brain clone now turns an existing directory into a working copy in
place: tracked files take the repository's version, untracked ones such
as the mountpoint stay. Brain is a projection that can be rebuilt, so
that is safe there; the vault keeps the strict clone. A failed vault or
brain clone is now reported as a warning.

Fixes: 03e1708 ("feat(memory): keep the original file behind a wiki page (#92)")
Closes: #148
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant