Files
Maven/docker-compose.yml
T
claude c5264deb46 Put mavwaked on workpc, where the microphone is (V-515)
mavwaked and mavenclient have been written, tested and deployed nowhere since
V-463 parked them. homesrv has a microphone because it is a laptop, but it is
in the wrong room. workpc is where he sits, and it has a fifine on card 0.

V-515 said this was a config line: "ipc.Dial already speaks
tcp://host:port?token=... so this is config, not protocol work". That premise
is wrong and it is worth writing down. Both mavwaked and mavenclient speak
internal/voice through voice.Dial, not internal/ipc. The netaddr token guards
the daemon-to-daemon IPC seam and never touches the voice wire. That wire is
plaintext with no auth at all, and voice/server.go says so: production binds
inside the wg tunnel, because "the wg layer IS the L0 floor".

workpc is not a wg peer. It sits on wlan0. So the floor here is ssh: mavend
publishes the voice port to homesrv loopback only (127.0.0.1:9110, since host
9100 is Vikunja's MCP), and a user unit on workpc forwards it over his key.
Nothing new is on the LAN. That mattered more than it looks: SurfaceVoice caps
acts at L0, so an unauthorized speaker could not run a destructive tool, but
L0 does not cap reading. A LAN bind would let anyone on the wifi hear his
facts, his notes and his calendar read back.

Two things the deployment found that no test could:

The vendored onnxruntime under deps/ has two copies and the stale one is
1.17.1. The Go binding asks for API 26, so silero refused to load until
1.26.0 was shipped instead. mavwaked logged it and kept running on the energy
threshold, which is the designed fallback working.

The fifine offers 2 channels at 44100 or 48000 and nothing else. mavwaked asks
arecord for 16kHz mono, so hw:0,0 dies on "Channels count non available"
before a frame is read. The unit uses plughw:0,0 so ALSA downmixes and
resamples.

Verified end to end through mavwaked's own -test mode, so no human had to
speak: a 2.43s Russian fixture reached mavend over the tunnel, was transcribed
on the workstation by CW2, routed intent=query, claimed by the calendar
source, and came back as 3.68s of piper audio.

There is still no wake word (V-487 stage two), so the loop runs open. Silero
is passed on purpose, since it declines white noise the energy floor accepts.
Barge-in is not, because its threshold is room-specific and this room has no
number yet.
2026-08-09 12:29:00 +04:00

222 lines
10 KiB
YAML

name: maven
# One image (built once), one container per daemon. Only mavend holds the key
# and the db volume; the modules mount just the shared socket dir + models.
# IPC stays unix-domain over the shared `sockets` volume — no code change from
# the bare-metal setup, only paths move to /run/maven.
x-image: &image
# NOT "maven" — that's Apache Maven on Docker Hub and compose will happily
# pull it, giving every container `mvn-entrypoint.sh` and exit 127.
image: mavenai:latest
# build on EVERY service (same image name ⇒ built once) so `docker compose
# build <anyservice>` actually rebuilds. With build on only one service, the
# others silently no-op and you deploy a stale binary.
build:
context: .
# the zone is declared once, here. The image points /etc/localtime at it so
# a caller reading the system zone agrees with one reading TZ (V-545).
args:
TZ: Europe/Samara
pull_policy: never # only ever the locally-built image
restart: unless-stopped
# local time for clock/date replies AND quiet-hours evaluation. Change to
# your zone; needs tzdata in the image (installed in the Dockerfile).
environment:
- TZ=Europe/Samara
services:
mavend:
<<: *image
command: ["mavend", "-config", "/opt/maven/config/mavend.json"]
# both nets: `default` keeps the `mavend` DNS name the other daemons reach
# (mavweb -> mavend:9100); `ecosystem` reaches nexus/praxis/hexis by name.
networks: [default, ecosystem]
# the key lives ONLY here. deploy/db_key.env holds MAVEN_DB_KEY=<base64-32B>.
env_file:
- ./deploy/db_key.env
- ./deploy/telegram.env
volumes:
- dbdata:/var/lib/maven # encrypted db at rest
- sockets:/run/maven # IPC socket dir
- ./deploy/mavend.json:/opt/maven/config/mavend.json:ro
- ./models:/opt/maven/models:ro
- /mnt/hdd1/llms:/opt/maven/models/llm:ro # LFM gguf library
# the LFM engine (llama-server) offloads onto the AMD iGPU (RADV RENOIR,
# Ryzen 5 5600U) via Vulkan — same device + render gid as mavsttd, which
# uses the same driver for whisper. Without these Vulkan
# enumerates zero devices and llama-server silently falls back to CPU.
devices:
- "/dev/dri:/dev/dri"
group_add:
- "993" # host 'render' gid owning /dev/dri/renderD128 (getent group render)
# the decrypted working copy lives in RAM (see db_tmpfs in mavend.json).
tmpfs:
- /dev/shm
# the voice wire, for mavwaked and mavenclient on workpc (V-515).
#
# LOOPBACK ONLY, and that is the whole security argument. internal/voice
# is plaintext with no auth: its own server doc says production binds
# inside the wg tunnel, "the wg layer IS the L0 floor". workpc is not a wg
# peer, it is on wlan0. So the tunnel is ssh instead, terminated on this
# loopback address, and nothing new is on the LAN. Anyone who could reach
# a LAN-bound port here could push audio and hear his facts read back.
# SurfaceVoice caps acts at L0; it does not cap reading.
#
# Host 9100 is Vikunja's MCP, hence 9110. The container side stays 9100
# so mavweb keeps reaching mavend:9100 by name.
ports: ["127.0.0.1:9110:9100"]
mavsttd:
<<: *image
command: ["mavsttd", "-socket", "/run/maven/stt.sock", "-model", "/opt/maven/models/stt/ggml-small.bin"]
depends_on: [mavend]
# whisper uses libggml-vulkan (RADV on the host's AMD GPU) → needs the
# render node AND membership in the group that owns it, or Vulkan enumerates
# zero devices and falls back to CPU.
devices:
- "/dev/dri:/dev/dri"
group_add:
- "993" # host 'render' gid owning /dev/dri/renderD128 (getent group render)
volumes:
- sockets:/run/maven
- ./models:/opt/maven/models:ro
mavttsd:
<<: *image
command: ["mavttsd", "-socket", "/run/maven/tts.sock",
"-piper", "/opt/maven/piper/piper",
"-model", "/opt/maven/models/tts/ru_RU-irina-medium.onnx",
"-espeak_data", "/opt/maven/piper/espeak-ng-data"]
depends_on: [mavend]
volumes:
- sockets:/run/maven
- ./models:/opt/maven/models:ro
mavweb:
<<: *image
# voice.bind is 0.0.0.0:9100 in deploy/mavend.json so mavweb can reach it
# cross-container. Verified 2026-07-06.
# -ambient-token turns on POST /api/ambient (Vikunja #126): the phone posts
# notification text, mavweb keeps only a meeting time. Empty ⇒ no route at
# all, which is what a missing MAVEN_AMBIENT_TOKEN gives. The value comes
# from the gitignored .env docker compose reads for interpolation, NOT from
# an env_file — flags are interpolated before any service env exists.
# Weakness worth naming: mavweb takes this as a flag, so it is visible in
# `ps` inside this container, unlike the zenmoney and IMAP secrets which are
# read from files.
command: ["mavweb", "-addr", ":9201", "-voice", "mavend:9100", "-core", "/run/maven/mavend.sock",
"-ambient-token", "${MAVEN_AMBIENT_TOKEN:-}",
"-nexus", "http://nexus:9740", "-praxis", "http://praxis:8989", "-hexis", "http://hexis:9741"]
depends_on: [mavend]
# loopback-only on purpose: /tools defines+executes arbitrary argv and
# mavweb has no in-process auth of its own without -webauthn-origin/-rpid.
# Reaching the UI therefore requires the wg tunnel (or the local nginx) by
# construction, not by convention. The other daemons talk to mavweb over
# the compose network, not this published port.
ports: ["127.0.0.1:9201:9201"]
# ecosystem: reach the siblings by name for the read-only /ecosystem panel;
# default: keep resolving mavend:9100 for voice + the IPC socket peers.
networks: [default, ecosystem]
volumes:
- sockets:/run/maven
mavpoll:
<<: *image
network_mode: host
command: ["mavpoll", "-socket", "/run/maven/mavend.sock",
"-netdata", "http://127.0.0.1:19999",
"-kuma", "http://127.0.0.1:3001/metrics",
"-kuma-key", "uk5_mavpoll-key"]
# Money tracking (Vikunja #125) is OFF: it needs a zenmoney token,
# which mavpoll reads from a FILE so it never appears in `ps`, in
# this file, or in shell history. To enable, mount the token and
# append: "-zenmoney-token-file", "/run/secrets/zenmoney.token"
# (optionally "-zenmoney-interval", "1h"). Core never sees the
# token — the poller writes facts(kind=env, source=poll:zenmoney)
# and mavend only reads those back when he asks.
depends_on: [mavend]
volumes:
- sockets:/run/maven
# - ./deploy/zenmoney.token:/run/secrets/zenmoney.token:ro
# The mail reader (Vikunja #246) is OFF and commented out: it needs an IMAP
# account, and there is none on this box. mavmaild reads the password from a
# FILE so it never appears in `ps`, in this file, or in shell history — the
# same rule mavpoll follows for the zenmoney token. Core never sees the
# password: the reader hands core message text on one IPC method, and core
# writes what the model extracts as task CANDIDATES he reviews on /tasks.
# Nothing here can create a reminder, so a misread mail cannot fire.
#
# To enable: write the password to deploy/imap.password (0600, gitignored),
# add an "email": {} block to deploy/mavend.json, and uncomment this service.
# mavmaild:
# <<: *image
# command: ["mavmaild", "-socket", "/run/maven/mavend.sock",
# "-imap", "imap.example.org:993",
# "-user", "kami@example.org",
# "-password-file", "/run/secrets/imap.password",
# "-mailbox", "INBOX",
# "-interval", "15m",
# "-state", "/var/lib/mavmaild/mail-seen.json"]
# depends_on: [mavend]
# volumes:
# - sockets:/run/maven
# # Its OWN volume, not dbdata. The whole point of a separate reader is
# # that a compromise on either side does not reach the other, and dbdata
# # is the encrypted database. The reader needs one JSON file of UIDs and
# # gets a volume that holds nothing else, so neither can be restored from
# # a backup of the other.
# - maildata:/var/lib/mavmaild
# - ./deploy/imap.password:/run/secrets/imap.password:ro
# The calendar reader (Vikunja #644) is OFF and commented out: it needs a
# CalDAV account, and there is none on this box. It was built, listed in
# `make build`, and deployed nowhere, which is the worst of the three states —
# this block records the decision instead.
#
# What its absence costs, so the cost is visible from here:
# - Agenda questions route correctly and answer from nothing. Stage 0 sends
# "что у меня сегодня" to IntentQuery (V-498) and the `calendar` query
# source reads facts(kind=env, source=caldav:*) that nobody writes.
# - The nudge gate loses a suppressor. loop.State.CalendarBusy is fed by
# those same facts, so "do not nag mid-meeting" is permanently false.
#
# Core never sees the CalDAV password: the reader polls the collection itself
# and hands core one fact per event over WriteFact. Nothing here can create a
# reminder, so a misread event cannot fire.
#
# The password is read from a FILE, so it never appears in `ps`, in this file,
# or in shell history — the same rule mavpoll and mavmaild follow.
#
# To enable: write the password to deploy/caldav.password (0600, gitignored),
# point -url at the collection, and uncomment this service. No mavend.json
# block is needed — events arrive over IPC as facts. -render-url is optional
# and OFF here: it publishes Maven's own reminders back as events, and it must
# not name the collection -url reads, or the poller reads its own writes back
# in (checkRenderTarget refuses that). It takes -render-pass-file, and falls
# back to this password when that is not given.
# mavcaldav:
# <<: *image
# command: ["mavcaldav", "-socket", "/run/maven/mavend.sock",
# "-url", "http://localhost:5232/kami/personal",
# "-user", "kami",
# "-pass-file", "/run/secrets/caldav.password",
# "-interval", "5m"]
# depends_on: [mavend]
# volumes:
# - sockets:/run/maven
# - ./deploy/caldav.password:/run/secrets/caldav.password:ro
volumes:
dbdata:
sockets:
# maildata — the mail reader's seen-UID file, and nothing else. See mavmaild.
maildata:
networks:
default:
ecosystem:
external: true # created by deploy/ecosystem/docker-compose.yml
name: ecosystem