diff --git a/docs/gamecult/probes/2026-10-31-album-probe.sh b/docs/gamecult/probes/2026-10-31-album-probe.sh new file mode 100644 index 00000000..e7761a6c --- /dev/null +++ b/docs/gamecult/probes/2026-10-31-album-probe.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Throwaway Synapse probe for the Persona file zone (album) shape. Never touches the live server. +set -u +D=/tmp/imag-zone-probe; NAME=imag-zone-probe; IMG=matrixdotorg/synapse:v1.164.1; PORT=18999 +docker rm -f $NAME >/dev/null 2>&1; rm -rf $D; mkdir -p $D/data +docker run --rm -v $D/data:/data -e SYNAPSE_SERVER_NAME=probe.test -e SYNAPSE_REPORT_STATS=no $IMG generate >/dev/null 2>&1 || { echo "generate failed"; exit 1; } +printf 'enable_registration: false\nregistration_shared_secret: "probe-only-secret"\n' >> $D/data/homeserver.yaml +docker run -d --name $NAME -p 127.0.0.1:$PORT:8008 -v $D/data:/data $IMG >/dev/null +for i in $(seq 60); do curl -fs http://127.0.0.1:$PORT/health >/dev/null && break; sleep 1; done +docker exec $NAME register_new_matrix_user -u aqua -p probe-pass -a -k probe-only-secret http://localhost:8008 >/dev/null 2>&1 +HS=http://127.0.0.1:$PORT +TOK=$(curl -s -XPOST $HS/_matrix/client/v3/login -d '{"type":"m.login.password","identifier":{"type":"m.id.user","user":"aqua"},"password":"probe-pass"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])') +A="Authorization: Bearer $TOK" +j() { python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('$1', d))"; } +echo "versions has v1.11: $(curl -s $HS/_matrix/client/versions | grep -c v1.11)" +grep -n "max_upload_size\|enable_authenticated_media" $D/data/homeserver.yaml || echo "max_upload_size: default (not in generated config)" +ROOM=$(curl -s -XPOST -H "$A" $HS/_matrix/client/v3/createRoom -d '{"name":"aqua files","room_alias_name":"files-aqua"}' | j room_id); echo "room $ROOM" +head -c 300000 /dev/urandom > $D/a.wav +MXC=$(curl -s -XPOST -H "$A" -H 'Content-Type: audio/wav' --data-binary @$D/a.wav "$HS/_matrix/media/v3/upload?filename=a.wav" | j content_uri); echo "upload v3: $MXC" +echo "upload v1 path: $(curl -s -o /dev/null -w '%{http_code}' -XPOST -H "$A" -H 'Content-Type: audio/wav' --data-binary @$D/a.wav "$HS/_matrix/client/v1/media/upload?filename=a.wav")" +R=$(python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))' "$ROOM") +ROOT=$(curl -s -XPUT -H "$A" "$HS/_matrix/client/v3/rooms/$R/send/m.room.message/req1-root" -d '{"msgtype":"m.text","body":"Album thruster-v3: 1 audio","org.gamecult.album":{"name":"thruster-v3","digest":"x","files":1,"follows":null}}' | j event_id); echo "root $ROOT" +ROOT2=$(curl -s -XPUT -H "$A" "$HS/_matrix/client/v3/rooms/$R/send/m.room.message/req1-root" -d '{"msgtype":"m.text","body":"Album thruster-v3: 1 audio"}' | j event_id); echo "same txn again -> $ROOT2 (same id means idempotent)" +REP=$(curl -s -XPUT -H "$A" "$HS/_matrix/client/v3/rooms/$R/send/m.room.message/req1-0" -d "{\"msgtype\":\"m.audio\",\"body\":\"a.wav\",\"filename\":\"a.wav\",\"url\":\"$MXC\",\"info\":{\"mimetype\":\"audio/wav\",\"size\":300000},\"m.relates_to\":{\"rel_type\":\"m.thread\",\"event_id\":\"$ROOT\",\"is_falling_back\":true,\"m.in_reply_to\":{\"event_id\":\"$ROOT\"}}}"); echo "thread reply: $REP" +REF=$(curl -s -XPUT -H "$A" "$HS/_matrix/client/v3/rooms/$R/send/m.room.message/req2-root" -d "{\"msgtype\":\"m.text\",\"body\":\"Album v4\",\"m.relates_to\":{\"rel_type\":\"m.reference\",\"event_id\":\"$ROOT\"}}" | j event_id); echo "root with m.reference: $REF" +echo "thread off a root that has a relation: $(curl -s -XPUT -H "$A" "$HS/_matrix/client/v3/rooms/$R/send/m.room.message/req2-0" -d "{\"msgtype\":\"m.text\",\"body\":\"f\",\"m.relates_to\":{\"rel_type\":\"m.thread\",\"event_id\":\"$REF\"}}")" +MID=${MXC#mxc://} +echo "auth download v1 with token: $(curl -s -o /dev/null -w '%{http_code}' -H "$A" $HS/_matrix/client/v1/media/download/$MID)" +echo "auth download v1 no token: $(curl -s -o /dev/null -w '%{http_code}' $HS/_matrix/client/v1/media/download/$MID)" +echo "range request v1: $(curl -s -o /dev/null -w '%{http_code} %{size_download}' -H "$A" -H 'Range: bytes=0-99' $HS/_matrix/client/v1/media/download/$MID)" +echo "threads list: $(curl -s -H "$A" "$HS/_matrix/client/v1/rooms/$R/threads" | python3 -c 'import sys,json;d=json.load(sys.stdin);print([(e["event_id"]==sys.argv[1], e["unsigned"].get("m.relations",{}).get("m.thread",{}).get("count")) for e in d["chunk"]])' "$ROOT")" +head -c 60000000 /dev/zero > $D/big.bin +echo "60MB upload: $(curl -s -w ' %{http_code}' -XPOST -H "$A" -H 'Content-Type: application/octet-stream' --data-binary @$D/big.bin "$HS/_matrix/media/v3/upload?filename=big.bin" | tail -c 160)" +docker rm -f $NAME >/dev/null; sudo -n rm -rf $D 2>/dev/null || rm -rf $D; echo "cleaned" diff --git a/docs/gamecult/ratatoskr-target.md b/docs/gamecult/ratatoskr-target.md index 166c5c72..92d3da22 100644 --- a/docs/gamecult/ratatoskr-target.md +++ b/docs/gamecult/ratatoskr-target.md @@ -300,6 +300,185 @@ as of 2026-10-31 ~15:00. (`ratatoskr:follow_up:chat-shell-compression`); the stewardship projection's room ids; a Web Push gateway if the operator picks it. +## File zone: albums + +Rulings: `ratatoskr:ruling:persona-file-sharing-zone`, +`ratatoskr:ruling:file-zone-albums-one-level`, +`ratatoskr:ruling:file-zone-looks-like-file-sharing`; under them +`eureka-body:ruling:mind-notes-via-personas` (a Self speaks only through her +Persona) and `eureka-body:ruling:live-mind-sessions` (the Persona organ will +become a live session; the album document below does not depend on how her +reply is produced). Eyes facts: `docs/research/2026-10-31-persona-file-zone.md` +on `ratatoskr/research-atmosphere`. + +A Mind's Self makes files (Aqua: `render-thruster-v1` to `-v5`, each a flat +folder of three WAVs of 0.4 to 0.6 MB beside build junk). She shares them as an +**album**: one level, a name, a short note, a set of files, and optionally the +earlier album it follows. Ratatoskr shows each Persona's albums as a +file-sharing space; Matrix is the storage and the fallback for other clients. + +### Shape, and why + +- **One room per Persona is her file zone**, created by provisioning with + room type `org.gamecult.file_zone` (`creation_content.type`, immutable, and + returned as `room_type` by `/hierarchy`, which `buildEstate` already reads). + The zone owner is the room's steward, carried in its `m.space.child` entry as + for project rooms. +- **An album is a thread.** The root is an `m.text` whose body is the + human-readable fallback (name, counts, note) and whose content carries + `org.gamecult.album`: `name`, `digest`, `files`, `kinds`, `bytes`, `follows` + (the root event id of the album it follows, or null) and `request_id`. Each + file is an `m.audio`/`m.image`/`m.video`/`m.file` event in that thread + (`m.thread`, `is_falling_back`, `m.in_reply_to` the root), which every + client already renders through authenticated media. +- **`follows` is a content field, not a relation.** Probe (below): Synapse + refuses a thread off an event that carries `m.relates_to` ("Cannot start + threads from an event with a relation"), so a root that `m.reference`d its + predecessor could not hold its own files. +- **Rejected:** MSC3089 trees (stalled since 2021, no client or server + support); a directory served by nginx (no access check exists, and it would + be a second store beside Matrix); posting albums as threads in the + conversation room (Ratatoskr runs with matrix-js-sdk `threadSupport` off, + `initMatrix.ts:44`, so thread replies land in the main timeline and one + album floods the room); turning `threadSupport` on (Ratatoskr has no thread + panel, so replies would vanish from view). + +### Model page: albums + +| Kind | Named by | Over time | Who decides | +|---|---|---|---| +| Outbox album | directory `/var/lib/mind--outbox//` | written and replaced by her Self | her Self (only writer) | +| Album choice | an `albums[]` item of her Persona's reply | per turn | her Persona | +| Share request | `gamecult.persona.chat_share_request.v1` | one per album per turn | the Persona organ emits; the mouth acts | +| File zone | room alias `#files-:gamecult.org`, room type `org.gamecult.file_zone` | created once | provisioning (`synapse-accounts-hand-managed.py`) | +| Album | thread root carrying `org.gamecult.album` in her zone | immutable once posted; a revision is a new album that `follows` it | the mouth posts, as her account | +| Zone view | derived per render from the zone room's threads | never stored | the client | + +### Authority map: write path (cuts `album-share-mouth`, `album-share-persona`) + +- **Owner:** the mouth (`Bifrost tools/persona-matrix-mouth.mjs`) owns + turning one share request into one album in her zone. The zone room is the + record of what has been shared: the mouth keeps no memory of shares. +- **Inputs:** the share request (album name, announce room, note, follows); + the album directory, read as user `bifrost` through a read ACL; her zone + room's threads (`GET /_matrix/client/v1/rooms/{id}/threads`) and the + album's thread (`/relations/{root}/m.thread`); her brake. +- **Outputs:** the root, one event per file, one announce line in the named + room (her note, the album name and count, a `matrix.to` link to the root), + and a `chat_send_receipt` (root event id, or a refusal). +- **Idempotency is derived from Matrix:** txn ids are `request_id`, + `request_id-f`, `request_id-ann` (probe: a repeated txn returns the same + event); beyond the txn window, a root in her zone with the same `name` and + `digest` is the album, and its thread's filenames say which files still need + posting, so a retry resumes rather than duplicates. +- **Forbidden writers:** the Self never touches Matrix and holds no Matrix + credential; the Persona organ never uploads and never reads file contents; + nothing but the mouth posts albums; no cache or ledger of "shared" albums + exists outside the zone room. +- **Demoted:** the Persona's `say` stays text only; a link to a file in her + sandbox is no longer how a Mind shares. +- **Shared paths:** every album, whatever the Mind or turn, goes through the + one share handler; a retry and a first attempt run the same code. + +### Outbox and limits + +- `/var/lib/mind--outbox`, owned `mind--self`, mode 0750, ACL + `u:mind-:r-x` (her Persona lists it) and `u:bifrost:r-x` (the mouth + reads it), with the same default ACLs. Created by + `install-mind-units-yggdrasil.sh`; `mind-self@.service` gains + `ReadWritePaths=/var/lib/mind-%i-outbox` (her state directory is 0700 and + `ProtectSystem=strict` makes the rest of `/var` read-only). +- An album is one level: regular files only, named by + `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`; a subdirectory, symlink, dotfile, a file + not owned by `mind--self`, or an unreadable file rejects the album + with its name (files are opened `O_NOFOLLOW` and checked with `lstat`, so an + album cannot reach another Mind's outbox or the mouth's own state). +- Per file at most 50 MiB (live Synapse `max_upload_size: 50M`; the mouth + uploads on loopback `127.0.0.1:8834`, so nginx's `50m` does not apply); per + album at most 100 files and 500 MiB. Kind by extension: wav, ogg, oga, opus, + flac, mp3, m4a are `m.audio`; png, jpg, jpeg, webp, gif are `m.image`; mp4, + webm are `m.video`; anything else is `m.file` (`application/octet-stream`). +- `digest` is SHA-256 over the sorted list of (filename, size, SHA-256 of + content); files post in bytewise filename order. + +### Authority map: zone view (cut `file-zone-view`) + +- **Owner:** one zone model in the client, a pure `buildAlbums()` over the + zone room's thread roots and an album's thread, fed by one hook; it decides + which albums show, in what order, and which events are an album's files. +- **Inputs:** `/hierarchy` (`room_type` marks zones; the estate index already + holds it); `GET /v1/rooms/{id}/threads` paged (probe: lists roots with + their reply counts); `/relations/{root}/m.thread` for one album. +- **Outputs:** albums (title, file count and kinds, date, `follows` chain) and + one album's files. +- **Derived:** files are the zone owner's media replies only; others' replies + are comments; the `follows` chain is display-only grouping. +- **Forbidden writers:** the timeline does not decide what an album holds; no + local store of albums. +- **Mount:** the navigator lists zones as their own branch kind from + `buildEstate`; opening one shows the zone view, with "Open as room" as the + fallback to the plain timeline (where the flood is accepted). +- **Media:** every fetch goes through `mxcUrlToHttp` with authentication and + the service worker's bearer header (`sw.ts:107`); probe: download without a + token is 401. Synapse answered a `Range` request with the whole body (200), + so playback fetches whole files, as `AudioContent` already does. +- **Parts:** the album list is our own folds component (no library needed); + images through `react-photo-album` (81 KB unpacked) and + `yet-another-react-lightbox` (253 KB unpacked), loaded only by the zone view + chunk; audio reuses `AudioContent` with one album queue (play all, next, + previous). The audio preview is the mel surface below + (`ratatoskr:ruling:audio-preview-mel-surface`), not a waveform, so + `@wavesurfer/react` is not adopted. No file-manager + library: chonky is abandoned and its fork archived, and the zone is + read-only. + +### Audio preview: the mel surface + +- **The mouth computes the sidecar at upload**, for every Mind, not Aqua's + tooling on export. One writer serves all eight Minds; AquaSynth's features + serve one Mind and would make the Self a second writer of a file the mouth + must pair by name. Album events are immutable, so the sidecar ships in the + write path (`album-share-mouth`), or albums shared before it never get one. +- **What it is:** a 256 x 64 8-bit grayscale PNG (time across, 64 mel bands + down, low band at the bottom): a mono mix, Hann window, FFT 2048 at the + file's rate, 256 frames spread evenly over the whole file, log power from + -80 dB to the file's peak mapped onto 0..255. A few KB; built with Node's own + `zlib` and an in-module FFT, no new dependency. A PNG because the browser + decodes it natively straight into a WebGL texture (`texImage2D` from an + `ImageBitmap`), with no parser of ours on the phone. +- **Scope:** PCM WAV (16-bit, 24-bit, 32-bit float), which is what Aqua makes. + Other audio posts with no sidecar and its tile shows a plain placeholder; + decoding compressed audio in the mouth waits for a Mind that shares it. +- **How it travels:** its own upload, referenced from the audio event's + content as `org.gamecult.mel: { url, bands: 64, frames: 256, db_floor: -80 }`. + Not inline data (about 22 KB of base64 on every event every client syncs), + and not an event of its own (other clients would show it as a stray file, + and the view would pair it by filename). The digest covers the album's own + files, not sidecars. +- **The shader:** one WebGL2 component in the zone view chunk: a 256 x 64 + grid (about 16k vertices) displaced in the vertex shader by the texture, + normals from neighbouring texels, a lit swept ribbon with time along the + sweep, mel across, energy as height and colour, and the playhead as a + uniform. Nibu owns the shader (she holds the scene shaders); Void + integrates; CultMath is not needed. Tiles never get a context each + (browsers cap live contexts at about 16, and the background already holds + one): one shared offscreen context renders each tile's still once into an + `ImageBitmap`, cached per mxc for the session; only the open file animates. + Gate: the open file's frame time measured on the operator's phone with the + background running, before the cut is called done. + +### Probe (2026-10-31, throwaway Synapse v1.164.1 container on Yggdrasil, removed after) + +| Claim | Result | +|---|---| +| Upload path | `POST /_matrix/media/v3/upload` 200 with mxc; `/_matrix/client/v1/media/upload` 404 | +| Same txn id twice | same event id | +| File as thread reply with `is_falling_back` | accepted | +| Root carrying `m.reference`, then a thread off it | refused: "Cannot start threads from an event with a relation" | +| `GET /_matrix/client/v1/media/download` with token / without | 200 / 401 | +| `Range: bytes=0-99` | 200, whole body | +| `GET /_matrix/client/v1/rooms/{id}/threads` | lists the root with count 1 | + ## Cut sequence `estate-navigator` is specced (`ratatoskr:cut_spec:cut-estate-navigator.r1`). @@ -344,6 +523,22 @@ Each later cut is one line, with what it waits on. metadata, the corner avatar and the cosmetic XP bar; waits on `emote-slot`. - `attention-bubbles`: per-person attention bubbles from Huginn's typed attention document, scoped to room members; waits on Huginn publishing it. +- `album-share-mouth`: the mouth turns a `chat_share_request` into an album + in her zone (upload, mel sidecar for WAV, thread, announce line, receipt, + resume from Matrix), and gamecult-ops makes the outboxes, their ACLs, the + Self unit's write path and the zone rooms; specced + (`ratatoskr:cut_spec:cut-album-share-mouth.r1`); waits on nothing. +- `album-share-persona`: Huginn's Persona organ lists her outbox albums in + her turn, her reply gains `albums[]` (name and follows constrained to the + listed albums, room to her rooms), and the organ sends each as a + `chat_share_request`; waits on `album-share-mouth` merging (it owns the + document); if the live-session rebuild (`eureka-body:ruling:live-mind-sessions`) + lands first, the same document is emitted from wherever her reply is decoded. +- `file-zone-view`: zones as their own branch kind in the navigator, the + album list, an album's files with image grid and lightbox, an album queue + over `AudioContent`, and the mel surface (Nibu's shader); waits on + `estate-navigator` and on albums existing; gated by a phone frame-time + measurement. - `rename-ratatoskr`: package name, app name and branding, manifest and titles, the `/preview/` path and its deploy, and every in-repo `cinny` reference that is ours (not upstream engine paths); waits on Void's forge