# GOTOOLCHAIN=local pins us to the vendored deps/go tree. Without it, a go.mod # `go` directive newer than deps/go re-execs into a downloaded toolchain module, # and those ship only 7 of the 15 GOROOT tools (no covdata) -- which makes # `test` below fail on the two packages that have no test files. deps-go builds # the missing tools in, so the vendored tree is self-sufficient. Keep the version # here in step with the `go` directive in go.mod. GO_VERSION := 1.25.12 GO := $(shell pwd)/deps/go/go/bin/go export GOTOOLCHAIN := local GOFLAGS := CGO_LDFLAGS := -L$(shell pwd)/deps/lib -Wl,-rpath,$(shell pwd)/deps/lib CGO_CFLAGS := -I$(shell pwd)/deps/include -I$(shell pwd)/deps/whisper.cpp/ggml/include WHISPER_MODEL := $(shell pwd)/models/stt/ggml-small.bin PIPER_BIN := $(shell pwd)/deps/piper/piper PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data .PHONY: t audit simulate stt-fixtures test-stt-golden test-cw2-config all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go deps-sentinel deps-vuln vuln deps-lint lint deadcode analyze tidy eval-router eval-reach eval-recall eval-phrasing eval-models build-gpud all: build build: build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav build-mail build-update build-gpud build-stt: CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \ $(GO) build $(GOFLAGS) -o mavsttd ./cmd/mavsttd/ build-tts: CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \ $(GO) build $(GOFLAGS) -o mavttsd ./cmd/mavttsd/ build-daemon: CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \ $(GO) build $(GOFLAGS) -o mavend ./cmd/mavend/ build-client: CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \ $(GO) build $(GOFLAGS) -o mavenclient ./cmd/mavenclient/ build-waked: $(GO) build $(GOFLAGS) -o mavwaked ./cmd/mavwaked/ build-web: $(GO) build $(GOFLAGS) -o mavweb ./cmd/mavweb/ build-poll: $(GO) build $(GOFLAGS) -o mavpoll ./cmd/mavpoll/ build-caldav: $(GO) build $(GOFLAGS) -o mavcaldav ./cmd/mavcaldav/ build-mail: $(GO) build $(GOFLAGS) -o mavmaild ./cmd/mavmaild/ # mavupdate is an operator CLI, not a daemon: nothing runs it but a human on the # box. It is built with the rest so a broken update path is caught by `make # build` rather than the first time it is needed. build-update: $(GO) build $(GOFLAGS) -o mavupdate ./cmd/mavupdate/ # mavgpud runs on the workstation, not here. It is built with the rest so a # broken supervisor is caught by `make build` on homesrv rather than by the # workstation refusing to serve. Copy the binary over, do not `make deploy` it. build-gpud: $(GO) build $(GOFLAGS) -o mavgpud ./cmd/mavgpud/ run-web: build-web ./mavweb -addr :9200 -voice 127.0.0.1:9100 # Install the vendored Go toolchain from scratch. Go 1.24+ release tarballs no # longer ship covdata/pprof/test2json/nm/objdump/trace prebuilt -- `go tool X` # builds them on demand, but `go test -coverprofile` calls covdata through # base.Tool(), which only stats pkg/tool and exits. So build them in once here. GO_TARBALL := go$(GO_VERSION).linux-amd64.tar.gz GO_SHA256 := 234828b7a89e0e303d2556310ee549fbcf253d28de937bac3da13d6294262ac1 deps-go: deps-sentinel @mkdir -p deps/go cd deps/go && curl -fLO 'https://go.dev/dl/$(GO_TARBALL)' cd deps/go && echo '$(GO_SHA256) $(GO_TARBALL)' | sha256sum -c - cd deps/go && rm -rf go && tar xzf $(GO_TARBALL) && rm $(GO_TARBALL) cd deps/go/go/src && for t in covdata pprof test2json nm objdump trace addr2line buildid; do \ GOROOT="$(shell pwd)/deps/go/go" $(GO) build -o "$(shell pwd)/deps/go/go/pkg/tool/linux_amd64/$$t" "cmd/$$t" || exit 1; \ done $(GO) version # deps/go.mod — the sentinel that stops the module walk at deps/ (Vikunja #454). # The vendored toolchain lives inside the module tree, so `go mod tidy` walked # Go's own compiler-error fixtures and died on files that are malformed on # purpose ("unicode//utf8": double slash). A nested module is not part of the # parent, so one three-line file ends the walk. deps/ is gitignored, so it is # generated here rather than committed, and every target that populates deps/ # writes it. deps-sentinel: @mkdir -p deps @printf 'module github.com/kami/maven/deps\n\ngo 1.21\n' > deps/go.mod # vuln — the advisory gate the 2026-08-10 audit found missing (V-682). It reads # the published database over the network, so it is not part of `test`, which # has to pass on a box with no route out. Run it before a toolchain or # dependency bump lands, because that is what it grades: on 2026-08-11 the # pinned Go 1.25.5 and x/text 0.14.0 carried 20 reachable advisories and the # bumped pair carries none. # # govulncheck is a tool and not a dependency, so it is installed into deps/ like # the toolchain rather than added to go.mod. The version is pinned here for the # same reason GO_VERSION is: a gate that moves on its own is not a gate. GOVULNCHECK_VERSION := v1.6.0 GOVULNCHECK := $(shell pwd)/deps/bin/govulncheck deps-vuln: deps-sentinel @mkdir -p deps/bin GOTOOLCHAIN=local GOBIN=$(shell pwd)/deps/bin \ $(GO) install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) # The CGO env is the same one `test` carries: govulncheck loads the packages, # and the four CGO daemons do not load without it. vuln: deps-vuln CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \ PATH="$(shell pwd)/deps/go/go/bin:$$PATH" GOTOOLCHAIN=local $(GOVULNCHECK) ./... # lint and deadcode — the other two analyzers the 2026-08-10 audit asked for # (V-694). They are not part of `test` for the same reason `vuln` is not: they # install over the network, and they are slow enough that a change to one Go # file should not pay for them. # # Neither reports zero, so neither fails on its own output. The accepted set # lives in scripts/analyzers/*.baseline and scripts/analyzer-gate.sh decides. # What is new fails, and so does a baseline entry whose finding is gone. # # deadcode runs with -test, so a test file is a root. Without it the report is # 172 lines, most of internal/router/eval, and none of it is a mistake. STATICCHECK_VERSION := v0.7.0 DEADCODE_VERSION := v0.48.0 STATICCHECK := $(shell pwd)/deps/bin/staticcheck DEADCODE := $(shell pwd)/deps/bin/deadcode deps-lint: deps-sentinel @mkdir -p deps/bin GOTOOLCHAIN=local GOBIN=$(shell pwd)/deps/bin \ $(GO) install honnef.co/go/tools/cmd/staticcheck@$(STATICCHECK_VERSION) GOTOOLCHAIN=local GOBIN=$(shell pwd)/deps/bin \ $(GO) install golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION) # Both load the packages, so both carry the CGO env `test` carries. Without it # the four CGO daemons do not load and the analyzer reports a build error # instead of a finding -- which analyzer-gate.sh fails on rather than filters. ANALYZER_ENV = CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" \ LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \ PATH="$(shell pwd)/deps/go/go/bin:$$PATH" GOTOOLCHAIN=local lint: deps-lint @$(ANALYZER_ENV) $(STATICCHECK) ./... | scripts/analyzer-gate.sh staticcheck deadcode: deps-lint @$(ANALYZER_ENV) $(DEADCODE) -test ./... | scripts/analyzer-gate.sh deadcode # Every static gate in one command. Not `check`, because it is not the thing to # run before a commit: vuln reads the network and all three are slow. analyze: lint deadcode vuln # Run the tidy the sentinel makes possible. Not part of `test`: it rewrites # go.mod, and a build target that edits the module file is a surprise. # vendor/ is committed, so a tidy that drops a requirement must be followed by # a re-vendor or the next build fails on "inconsistent vendoring". tidy: deps-sentinel GOTOOLCHAIN=local GOFLAGS=-mod=mod $(GO) mod tidy GOTOOLCHAIN=local GOFLAGS=-mod=mod $(GO) mod vendor # fmt-check fails if any file needs gofmt. docs/design.md has always said `make # test` gates on gofmt and vet; it did not, so nine files quietly drifted. # Run `gofmt -w` on whatever this prints. fmt-check: @bad=$$(gofmt -l internal cmd); \ if [ -n "$$bad" ]; then \ echo "these files need gofmt:"; echo "$$bad"; exit 1; \ fi vet: CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \ $(GO) vet ./internal/... ./cmd/... # simulate — replay every scripted day under cmd/mavend/testdata/scenarios # through the real router, store, tick loop and intake journal, on a fake clock # (Vikunja #284). Verbose so the transcript of each scenario lands in the # terminal. Also runs as part of `make test`; this target is for reading it. simulate: CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \ $(GO) test -v -count=1 -run TestSimulator ./cmd/mavend/ test-cw2-config: python3 -m unittest discover -s deploy/cw2 -p 'test_*.py' test: fmt-check vet test-cw2-config CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \ $(GO) test -race -coverprofile=coverage.out ./internal/... ./cmd/... # t — run ONE package or ONE test with the toolchain env already wired. This is # the iteration target; `test` is the gate. Reach for it instead of pasting the # CGO_CFLAGS/CGO_LDFLAGS/LD_LIBRARY_PATH preamble by hand, which is how it was # done ~390 times across past sessions and is where the shell-quoting failures # came from -- the interactive shell here is zsh, and an unquoted `-run Test*` # or `--include=*.go` dies on "no matches found" before go ever starts. # # make t # whole tree (same scope as `test`) # make t PKG=./internal/router/ # make t PKG=./cmd/mavend/ RUN=TestSimulator # make t PKG=./internal/router/eval/ RUN='TestONNX' V=1 # make t PKG=./internal/store/ RACE=0 # drop -race when iterating hot # # -race is on by default so a green `make t` cannot turn red under `make test`. # -count=1 because a cached PASS from before your edit is worse than no answer. # MAVEN_ONNX_LIB is set for the same reason: the four TestONNX* measurements # self-skip when it is unset, so a targeted eval run would otherwise report the # deterministic hash ratchet and look like it scored the real embedder. PKG ?= ./internal/... ./cmd/... RUN ?= V ?= RACE ?= 1 t: CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \ MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" \ $(GO) test $(if $(V),-v,) $(if $(filter-out 0,$(RACE)),-race,) -count=1 \ $(if $(RUN),-run '$(RUN)',) $(PKG) # eval-router — score the held-out RU routing fixture (internal/router/eval). # Verbose so the report tables land in the terminal. MAVEN_ONNX_LIB points the # prod-representative baseline at the vendored runtime; override it or set it # empty to run only the deterministic hash ratchet. This is the measurement # Vikunja #319 compares before #320 flips the route decider. MAVEN_ONNX_LIB ?= $(shell pwd)/deps/onnxruntime-linux-x64-1.26.0/lib/libonnxruntime.so eval-router: MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 ./internal/router/eval/ # eval-reach — score the held-out ecosystem reach fixture (internal/router/eval, # ru_ecosystem_v1.json). Answers "does a real Russian utterance actually arrive # at Praxis or Hexis", which routing accuracy alone does not say. Same # MAVEN_ONNX_LIB deal as eval-router; without it only the deterministic hash # ratchet runs. Vikunja #405. eval-reach: MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 -run 'Reach|Praxis' ./internal/router/eval/ # eval-recall — score the held-out note-recall fixture (internal/memory/recalleval). # Answers "can she find the note again when it matters": recall@1, recall@3, # false recall and the query_min_score sweep. Same MAVEN_ONNX_LIB deal as # eval-router; without it only the deterministic hash ratchet runs. eval-recall: MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 ./internal/memory/recalleval/ # eval-phrasing -- score nudge phrasing AND the conversational paths (chat, # query, general knowledge) in internal/phraser/eval. Verbose so the # report and every generated message land in the terminal. With no environment # it scores the deterministic Stub only, which is what CI runs. Set # MAVEN_LLM_URL to add the resident model: # MAVEN_LLM_URL=http://127.0.0.1:18099 make eval-phrasing # The model run is slow (minutes) -- the timeout is raised to match. It covers # two fixtures now (15 nudges + 27 conversational cases, and the chat replies are # the long ones), hence 90m rather than 40m. eval-phrasing: $(GO) test -v -count=1 -timeout 90m ./internal/phraser/eval/ # eval-models — score ONE llama-server against the same fixture, for the # resident-model bake-off (#278, #250). Start a server with the gguf you want, # then: # # make eval-models MAVEN_LLM_URL=http://127.0.0.1:18100 # # The report names carry the model llama-server reports, so runs from two # checkpoints stay apart. Only the LLM test runs — the classifier baselines do # not depend on the model and take the ONNX runtime with them. MAVEN_LLM_URL ?= http://127.0.0.1:18099 eval-models: MAVEN_LLM_URL="$(MAVEN_LLM_URL)" $(GO) test -v -count=1 -timeout 60m \ -run TestLLMRouterBaseline ./internal/router/eval/ # stt-fixtures — regenerate the golden STT audio in cmd/mavsttd/testdata from # the piper voices (#288). The committed WAVs are synthesised, never recorded, # so this is the only way they should ever change. The spoken text is read out # of testdata/golden_v1.json, so edit the transcript there and rerun this. # # test-stt-golden runs both golden tests: TestGoldenAudioTranscription, which # scores the fixtures against ggml-small and self-skips when the model is # absent, and TestGoldenFixturesAreCanonical, which checks the committed audio # and the manifest with no model at all. # audit — the repo inventory: LOC per package, open TODOs, real stubs, living-doc # staleness, test shape, packages with no test. Read-only, prints, writes nothing. # Run it instead of rebuilding the same greps by hand; past sessions spent 93 of # them on this before their first edit. SECTION=loc|todo|stubs|docs|tests|gaps # narrows it. SECTION ?= all audit: @SECTION="$(SECTION)" ./scripts/audit.sh stt-fixtures: ./scripts/gen-stt-fixtures.sh test-stt-golden: CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \ $(GO) test -v -count=1 -run TestGolden ./cmd/mavsttd/ run-stt: build-stt LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \ ./mavsttd -socket /tmp/maven/stt.sock -model $(WHISPER_MODEL) run-tts: build-tts LD_LIBRARY_PATH="$(shell pwd)/deps/piper" \ ./mavttsd -socket /tmp/maven/tts.sock \ -piper $(PIPER_BIN) -model $(PIPER_MODEL) -espeak_data $(PIPER_ESPEAK) deps: deps-sentinel deps-whisper deps-piper deps-whisper: cd deps/whisper.cpp && cmake -B build -DCMAKE_BUILD_TYPE=Release \ -DWHISPER_BUILD_TESTS=OFF -DWHISPER_BUILD_SERVER=OFF && \ cmake --build build --config Release -j$$(nproc) cp deps/whisper.cpp/build/bin/libwhisper.so* deps/lib/ cp deps/whisper.cpp/build/bin/libggml*.so* deps/lib/ cp deps/whisper.cpp/build/bin/libparakeet.so* deps/lib/ deps-piper: mkdir -p deps/ curl -sL "https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_x86_64.tar.gz" \ -o /tmp/piper.tar.gz tar -xzf /tmp/piper.tar.gz -C deps/ # multilingual-e5-small: an asymmetric retrieval model. It is trained to match # a short question against a longer passage, which is what note recall is. # The quantized file is the one we download, deploy and measure — see # docs/evals/2026-08-04-recall-e5-small.md for what the swap bought. EMBEDDER_DIR := $(shell pwd)/models/embedder/multilingual-e5-small EMBEDDER_MODEL_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/onnx/model_quantized.onnx EMBEDDER_TOKENIZER_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/tokenizer.json download-embedder: mkdir -p $(EMBEDDER_DIR) curl -sL "$(EMBEDDER_MODEL_URL)" -o "$(EMBEDDER_DIR)/model_quantized.onnx" curl -sL "$(EMBEDDER_TOKENIZER_URL)" -o "$(EMBEDDER_DIR)/tokenizer.json" @echo "" @echo "embedder model downloaded to $(EMBEDDER_DIR)/" @echo "To use it, add to mavend.json:" @echo ' "voice": {' @echo ' ...' @echo ' "embedder": {' @echo ' "model_path": "$(EMBEDDER_DIR)/model_quantized.onnx",' @echo ' "tokenizer_path": "$(EMBEDDER_DIR)/tokenizer.json",' @echo ' "lib_path": "/path/to/libonnxruntime.so"' @echo ' }' @echo ' }' @echo "" @echo "Install libonnxruntime.so from: https://github.com/microsoft/onnxruntime/releases" @echo "e.g. on x86_64 Linux:" @echo ' curl -sL "https://github.com/microsoft/onnxruntime/releases/download/v1.15.1/onnxruntime-linux-x64-1.15.1.tgz" | tar xz' @echo ' sudo cp onnxruntime-linux-x64-1.15.1/lib/libonnxruntime.so* /usr/local/lib/' clean: rm -f mavend mavenclient mavsttd mavttsd mavweb mavpoll mavcaldav mavwaked mavmaild