The correction gesture now reaches all three surfaces, and CLAUDE.md said only /chat had it. Doc 23 carries the decisions: long-poll rather than a webhook, the backlog dropped on start, one accepted sender, and the two-tap keyboard.
32 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Maven is a self-hosted, privacy-first voice assistant (Russian + English). Go daemons
talking over unix sockets; one resident small model for routing + phrasing; whisper.cpp STT, piper TTS.
Deploy target is a Ryzen laptop (homesrv) with Vulkan offload to the Vega iGPU (n_gpu_layers: 99,
compose passes /dev/dri + the render gid) — the resident model stays ≤1.7B either way.
Resident model: currently Qwen3-1.7B (UD-Q4_K_XL), stock — not yet the CPT'd one.
It replaced Qwen3.5-0.8B on 2026-07-31 because it measured better on both fixtures we have:
67.5% vs 59.7% intent-only on the 77-case RU routing fixture, and 20/27 vs 11-17/27 on the
talk fixture. See docs/evals/2026-07-31-model-bakeoff.md. It is a Thinking variant, so n_ctx is 4096
— reasoning tokens need the room, and 4096 is what the scores above were measured at.
The target is still the locally CPT'd Qwen3-1.7B (Vikunja #122, training in flight).
Stock already speaks good Russian; what it gets wrong is the persona — it writes я рад,
masculine, where Maven needs рада. That is what the CPT is for.
Do not bother with sub-500M models. LFM2.5-230M and 350M were measured on 2026-07-31 and
both are unusable in Russian: the 350M routes at 5.2% (worse than guessing) and answers
"столица Франции?" with the invented non-word "Сторзит"; the 230M replies to Russian in
Spanish. Their strong published IFEval/BFCL numbers are English-only. Model files live in
/mnt/hdd1/llms, bind-mounted to /opt/maven/models/llm — which shadows the repo's
models/llm/, so the LFM2.5 gguf sitting there is not loaded by anything. Swapping the resident
model is a one-line change to phraser.model_path in deploy/mavend.json.
See docs/rearchitecture.md for the target architecture, docs/design.md for the folded design spec, and
AGENTS.md for local-preview + model-download recipes.
Model work is moving to the workstation (owner's call, 2026-08-02). homesrv cannot grow a
GPU and the workstation has 16GB of VRAM. So the resident model, STT and TTS become preferred
remotes with a floor on homesrv. The workstation is never assumed up. Fall back silently when
it would only do the job better. Name the gap when the 1.7B cannot do it at all. The embedder
stays on homesrv permanently, because it backs that floor. It is multilingual-e5-small,
quantized and asymmetric — EmbedQuery and EmbedPassage apply the query:/passage:
prefixes it was trained with, and calling plain Embed on a note is a bug. It replaced
MiniLM and bought ten points of recall@1 and 2.5× the speed; see
docs/evals/2026-08-04-recall-e5-small.md. Read docs/offload.md before
touching a daemon seam or adding a model caller. Vikunja #483 is the umbrella, #484 to #487
are the work.
Both halves are wired as of 2026-08-03. Routing and replies prefer the workstation silently
through modelSeam; nudge and reminder phrasing prefer it silently inside the phraser. A
world question goes through LLMPhraser.PhraseWorld and names the gap when the card is not
free — worldGap in cmd/mavend/worldmodel.go, which the owner hears instead of an invented
answer. A box with no workstation block behaves exactly as it did before the seam: naming
a gap requires a gap. The offload table in docs/offload.md says which caller is which.
Build & test
CGO daemons (mavend, mavsttd, mavttsd, mavenclient) need the vendored toolchain
and libs wired through the Makefile — do not call go build on them bare, use make:
make build # all 9 binaries
make build-web # single daemon (pure-Go ones: web/waked/poll/caldav build without CGO)
make test # go test -race across ./internal/... ./cmd/... with CGO env set
Run a single test (must carry the CGO env for packages that touch STT/TTS/voice):
CGO_CFLAGS="-I$(pwd)/deps/include -I$(pwd)/deps/whisper.cpp/ggml/include" \
CGO_LDFLAGS="-L$(pwd)/deps/lib -Wl,-rpath,$(pwd)/deps/lib" \
LD_LIBRARY_PATH="$(pwd)/deps/lib" \
deps/go/go/bin/go test -run TestName ./internal/router/
Pure-Go packages (router, memory, mavweb, …) run under a plain go test ./pkg/.
The daemons (cmd/)
| Binary | Role |
|---|---|
mavend |
Core. Router, phraser, memory, reminders, digestion tick. Owns the DB + IPC socket. |
mavweb |
HTTP UI + PWA (/dash, /history, /trace, /notifications, /tools); WebAuthn auth. Connects to mavend's socket. |
mavsttd |
Speech-to-text (whisper.cpp, CGO). |
mavttsd |
Text-to-speech (piper subprocess). |
mavwaked |
Wake-word / VAD gate. Not on homesrv — see below. |
mavenclient |
Voice loop client (mic → stt → core → tts). Not on homesrv — see below. |
mavpoll |
Environment poller: netdata alarms, uptime-kuma, zenmoney, wireguard presence. Writes facts, sends nothing. Telegram is internal/delivery/telegramsink, not this. |
mavcaldav |
CalDAV calendar sync. |
mavmaild |
Mail reader (IMAP, read-only). Holds the IMAP password; core never sees it. |
Daemons are wired socket-to-socket, not linked. internal/ipc is the client/server wire
protocol; the config in deploy/mavend.json (with ${VAR} env expansion from gitignored
deploy/telegram.env) sets socket paths, model paths, and the phraser/embedder blocks.
Seven of the nine run on homesrv. mavwaked and mavenclient do not, and that is the
decision, not an oversight (Vikunja #463, docs/plans/17-where-the-voice-loop-runs.md).
homesrv has a microphone — it is a laptop — but it is in the wrong room, so a wake-word
daemon there listens to nobody. They belong on a client machine where the owner is standing.
That machine is workpc (owner's correction, 2026-08-05). This section used to say no
such machine existed, which was written when the workstation was only a model host. It is
where he sits most of the day and it has the microphone. ipc.Dial already takes
tcp://host:port?token=... through the netaddr seam, so the two daemons need deploying,
not building. V-515 is that deployment.
Until they are deployed, the wake word and the VAD gate are covered by unit tests and by
nothing else, and push-to-talk through /dash is what QA actually covers. Note that
deploying them does not by itself prove a wake word: mavwaked gates on energy and has no
keyword model (V-487), so the loop runs open until that lands.
The ecosystem: Nexus, Praxis, Hexis
Maven is one of four services. It owns conversation and personal memory. It does not
own identity, operational state, or execution. Full contract in
docs/ecosystem.md.
Nexus identifies. Praxis observes. Hexis acts. Maven understands and coordinates.
| Service | Owns | Maven's client | Configured at |
|---|---|---|---|
| Nexus | Canonical entity ids, names, aliases, relationships. Projects, services, devices, people, pets, places. | nexusClient in cmd/mavend/ecosystem.go, POST /api/v1/resolve |
nexus.url (http://nexus:9740) |
| Praxis | Operational attention and item lifecycle. What needs looking at, what changed, what is still unresolved. | praxisClient, the HTTP tools API under /api/v1/tools/ |
praxis.url (http://praxis:8989) |
| Hexis | The capability registry and the only path to executing anything. | vendored github.com/kami/hexis/pkg/client |
hexis.url (http://hexis:9741) |
All three are nil unless configured, and every one of them degrades on its own.
An outage means a named gap in the answer, never a broken turn and never a guess.
Rules that are not negotiable:
- No component reads another component's database. Praxis attention comes over HTTP, never from its SQLite file.
- Identity lives in Nexus. Do not invent a local fact key for something Nexus
resolves.
actionFactalready setsSubject, andcmd/mavend/factenrichment.goresolves it in the background against Nexus. - Free text never reaches a mutating Hexis call. Resolve to a canonical entity id first. Ambiguous resolution asks the owner, it does not pick.
- LLM output is not authorization. Confirmation binds capability id, target
entity, arguments, requester and expiry. See
cmd/mavend/confirm.go. - Praxis lifecycle words mean different things. Surfaced is not acknowledged,
acknowledged is not resolved, execution success is not recovery. Reading an item
aloud calls
Surface, neverAcknowledge. - No automatic attention-to-action path. Digestion may summarise Praxis. It may not call Hexis.
Every cross-service call carries a correlation id minted once per action
(withCorrelationID), a contract version header, and X-Requested-By: maven.
Routing — read this before touching the router
internal/router/ has TWO layered engines. The LLM router is now the default and it is
on in deploy — this section used to say it was wired nil, which stopped being true on
2026-07-31.
- LLM router (the intended design, docs/rearchitecture.md): the resident Qwen3-1.7B (
llmrouter.go) emits GBNF-constrained structured JSON, and the SAME model phrases replies. Embedder is demoted from a routing gate to a RAG hint. Wired atvoice.go:214viapickLLMRouter(cfg.Voice.UseLLMRouter(), llmClient); the flag isvoice.llm_router(config.go),DefaultLLMRouteris on, anddeploy/mavend.jsonsets ittrue. - Classifier cascade (the failure floor, not dead code):
classifier.go+embedder.gonearest-neighbour over frozen seed phrases. It runs when the LLM router is off, when there is no llama-server to talk to (pickLLMRouterlogs that and degrades), and on any per-turn LLM error. Do not delete it — routing by seed similarity is the known cause of weak RU query handling, but a turn must never break on the model.
Cascade order: stage0.go exact-match fast-path → LLM router (when non-nil) → classifier
fallback. Any LLM error falls through to the classifier so a turn never breaks on the model.
A stage-0 decision is slot-extracted too, since 06-08-2026 (V-572). fillMatchedSlots
in router.go runs the stage-2 extractor over whatever a grammar built and fills only the
slots it left empty — a matched value always wins, because the rule read a literal pattern
and the extractor guesses. It did not run before, so ReminderGrammar handed the daemon
HasTime: false for "напомни в 11:00 позвонить маме" and missingFor read the silence as
absence and asked "Когда?". It applies to every grammar and is inert for all but the
reminder: Extract fills Time, Fn and Key and nothing else, and the query, clock, agenda,
feed, list, task and narrative rules all emit intents with no such slot. Benchmarked at
20000x, a stage-0 query costs 3.7µs against 3.9µs before. Slots.Text is deliberately not
filled — a grammar that left it empty meant it, and agendaQueryBuild hands the query
chain the utterance itself. Fixture unchanged at 64/91, with "slots deferred to daemon"
6 → 0.
Measured on the 77-case RU fixture. Re-measured 2026-08-02: the classifier scores 68.8%
full accuracy at p50 16.6µs, not the 36.8% at p50 31ms that stood here from
docs/evals/2026-07-31-model-bakeoff.md. That older figure predates the stage 0 rules and the
seed additions, both of which now score inside the classifier baseline. Qwen3-1.7B scores
77.9% intent-only / 72.7% through the cascade. So the router buys about 4 points of accuracy,
not a doubling, and the trade is worth re-arguing rather than assuming. The ≈2.7s figure
that stood here until 2026-08-02 was contention, not the model. See docs/evals/2026-07-31-routing.md line 61, which measures the LLM router at
p50 825ms / p95 1.2s / max 3.0s and the full cascade at p50 0.80-1.04s. Do not plan latency
work off the bakeoff table.
Re-measured 2026-08-05 on the fixture as it now stands, 91 cases (V-320 item 2,
docs/evals/2026-08-05-routing-resident-model.md): cascade + resident model scores
75.8% full / 80.2% intent-only at p50 1.19s / p95 1.65s. That is a new baseline and not
a movement, because 14 cases were added since the 77-case number above. The model alone
scores 37.4% full against 61.5% intent-only, and the gap is slots rather than routing: it
routes reminder and leaves the time to the daemon, which is what the contract asks. To
re-run it, start a second llama-server on a fixed host port — the resident one binds
--port 0 inside the container and no host process can reach it.
The numbers above are the homesrv floor, not the ceiling. With the workstation up, routing
completes through llm.Pair against gemma-4-12b and scores 84.4% full / 93.5% intent-only at
p50 329ms — better than the resident model and about 2.5× faster (docs/evals/2026-08-02-workstation-gemma4-12b.md,
Vikunja #485). The workstation is never assumed up, so both sets of numbers are live. Judge a
routing change against the classifier and the resident model, since those are what always answer.
The intended third engine is not a generative model (owner's call, 05-08-2026, V-546,
docs/plans/18-routing-heads-on-e5-small.md). Routing has a bounded output space, so it is
classification, and the 118M multilingual-e5-small is already resident. Three heads on one
forward pass: intent, mood, and BIO slot tags. Roughly 5e15 FLOPs to train, so 10 to 30
minutes on the workstation. A 100M decoder from scratch is 10 to 20 GPU hours. Two things
it buys that a decoder cannot. No grammar is needed, because a softmax cannot emit a value
that does not exist. And max softmax is a calibratable confidence, where Confidence: 1.0
was a hardcode. Fine-tune a copy of the weights. The resident embedder backs memory
recall. Training it in place couples routing accuracy to recall@1, with nothing in the
suite to name the trade.
Confidence: 1.0 used to be hardcoded in llmrouter.go, so the LLM
path could never ask for clarification (6/6 refusal cases missed on the fixture) — Vikunja
#359. Fixed 31-07-2026 with structural signal (single-token utterance, keyless fact, act with
no allowlisted fn) feeding the same stage-3 gate the classifier path already had — see
gateLLMDecision in router.go. Note the second half of that bug: the LLM branch never
consulted r.threshold at all, so a correct low confidence would have been discarded anyway.
Re-measured on the fixture after the fix: missed clarify 6/6 → 1, at the cost of 3 false
clarifies and 2.6pt of full accuracy (72.7% → 70.1%, intent-only 67.5% → 74.0%). Two of the
three false clarifies are acts the model mis-routed and the gate caught — asking beats wrongly
executing, so the fixture and the daemon disagree about what is correct there. The third,
"поужинал", was a real defect: the single-token rule was an English intuition and does not
transfer to Russian, where one word is routinely a whole sentence.
Narrowed 01-08-2026. thinSingleToken (internal/router/singletoken.go) still thins a bare
one-word nominal — "вода", "бэкап" — but spares two classes: a closed lexicon of social and
control singles ("привет", "спасибо", "стоп", "yes"), and any token carrying a Russian verb
ending (past tense, 2nd person, reflexive), because a verb already contains its subject. Both
tests are offline and cost nothing. Re-measured: false clarifies 3 → 2, intent-only 74.0% →
75.3%, full accuracy unchanged at 70.1%, missed clarify still 1. The two remaining false
clarifies are the act-with-no-allowlisted-fn arm of the gate, not this rule.
Agenda questions taken off the model, 01-08-2026. AgendaQueryGrammars (stage0.go, wired
after the clock rules in buildRouter) routes "что у меня сегодня", "во сколько у меня
встреча" and anything naming a calendar to IntentQuery at stage 0. They were going to
IntentSystem, where replySystem has no agenda arm and answered "пока не умею" — the
fixture had said query since ru-query-019 was written. Measured: full accuracy 70.1% →
72.7%, intent-only 75.3% → 77.9%, calendar 0/2 → 2/2, clarify counts unchanged. Note that
Go's \b is ASCII-only and never fires after a Cyrillic letter; the pattern needs an
explicit (\s|[?!.]|$).
Two more shapes taken off the model, 04-08-2026 (V-498). rest-of-day-query inside
AgendaQueryGrammars claims "что дальше?" / "what's next", and NarrativeQueryGrammar
(stage0.go, wired last in buildRouter, after the capture marker) claims "расскажи про
X", "объясни X", "опиши X". Neither carries a question mark or an interrogative, so the model
called both IntentFact; the write was caught downstream by IsQuestionShaped, so this was a
latency and fixture defect, not a correctness one. The narrative rule reads the same
narrativeRequests lexicon IsQuestionShaped reads, and declines chatNarrativeTopics — a
joke, a bedtime story, herself — because the query chain has no source that answers those.
New fixture cases ru-query-024 and ru-query-025. Classifier + ONNX baseline 56/80 (70.0%) →
58/82 (70.7%), no case regressed, no new false clarify. The LLM arm was not measured (no
llama-server in that run), so judge it again before quoting a cascade number.
Praxis taken off the model, 05-08-2026 (V-516). PraxisGrammars()
(internal/router/praxis.go, wired in buildRouter before the capture marker because
"отметь" is a capture verb) fills Slots.Fn with a Praxis capability name.
These grammars are the only path to Praxis, not a faster one. Measured
2026-08-05 with the resident model as router (V-517,
docs/evals/2026-08-05-reach-llm-router.md): the model alone reaches Praxis
0/12, the same as the classifier alone, because nothing in the router
prompt names a Praxis capability and there is no string for it to write.
Through the cascade it is 11/12. Deleting these rules costs every point. Praxis reach
was 0/12 and structurally so: handlePraxisAct compares Slots.Fn to a capability
alias, and that slot is filled from the deployment's enabled tool names, which no Praxis
alias is on. Measured 16/30 → 27/30 overall, praxis 0/12 → 11/12, lifecycle 0/5 → 5/5
(docs/evals/2026-08-05-praxis-reach.md). Two rules to know before editing: a stative
lifecycle word ("готово", "принято") needs an item named beside it, while a bare
imperative ("закрывай") may ask which one. The bare arm additionally requires that
the sentence name no object of its own, or "закрой шторы в комнате" goes to Praxis instead
of the house. A demonstrative ("отметь это как сделанное") resolves against
h.surfacedItems only when exactly one item was spoken. Otherwise the turn goes back to
the cascade rather than transitioning the wrong item.
Who claimed a turn is now recorded, and so is who did not (V-564, umbrella
V-558). Arbitration between the claimants on the utterance stream is order,
hardcoded in the pre-route resolver ladder, in buildRouter and in
querySources. internal/decision records one Record per turn: every
claimant, what it would have made the turn, the score it reported, and whether
it won, declined, lost on score, was thinned by a gate or was never asked.
The record rides the context, the same seam querysource.go uses, so a claim
site cannot change a route and a context with no record costs nothing. It is
installed in runTurn, so the mic, telegram and the web all leave the same
trail. Storage is a 25-turn in-memory ring on the handler (decision.Ring),
read over ipc.TurnDecisions and rendered as the second table on /trace.
It also persists, since 06-08-2026, and that reverses what this section used to
say (V-629, docs/plans/21-persisting-the-routing-trace.md). The old rule was
that nothing persists, because a turn record is read minutes later or never. The
owner reversed it: the routing heads (V-546) cannot be fitted or calibrated
without real utterances, and 9 of the 31 modes in internal/modes have no seed
example at all. The ring did not move. It is still what /trace reads and still
what a test with no store gets. cmd/mavend/routingtrace.go is a second sink
beside it, writing routing_traces (migration #23). The utterance is stored in
clear, because a 384-dimension vector of a short sentence is substantially
recoverable and storing vectors instead would be a privacy claim we cannot
support. What makes it safe is the same thing that makes the fact store safe.
Retention is 14 days, enforced on write and again on start, so a box that goes
quiet does not keep every row. Nothing reads it outward, and the rule
that his notes and facts are never search input covers this table. Store.Wipe
deletes it with everything else. A correction (V-630) is promoted out into a
seed-shaped row in routing_labels (migration #24) and kept, because a label is
not a transcript. The transcript still expires. The gesture that writes one is
two buttons beside the reply on /chat, reached over ipc.CorrectTurn and the
trace id that now rides back on ipc.ChatReply. A turn marked wrong with no
target is a usable negative, so naming the intent is never required. The target
is one of the seven intents and never free text. All three reaches offer it as
of 06-08-2026, and this section used to say only /chat did. Voice is the
repair rung, which has read spoken corrections since V-455 and now writes the
durable label beside the classifier seed it always wrote; a spoken negative with
no target is its own rung, repair-negative (V-636, docs/plans/22-correcting-a-turn.md).
Telegram is an inline keyboard under the reply, and it needed the chat to become
readable first — telegram is no longer outbound only (V-637,
docs/plans/23-inbound-telegram.md). The poller is dark unless the telegram
block says intake, it long-polls because the box takes no inbound connections,
it accepts chat_id and no other sender, and it drops whatever queued while the
daemon was down. It reaches the daemon through ipc.CoreAPI alone, so a chat
turn takes the path POST /api/chat takes. Note that the turn source is still
tap:text for both, so provenance cannot tell a chat turn from a typed one.
Adding a rung to the ladder
in runTurn means adding its name to preRouteLadder in
cmd/mavend/decisiontrace.go, or that rung is silently missing from the record.
LLM output contract
All phrasing paths emit {"response":"...","mood":"..."}, with fallback to plain text when
the model skips the JSON. One parser, parseResponseMood in
internal/phraser/parse.go, and every path reaches it: the six LLMPhraser methods,
PhraseWorld, and Replier.PhraseReply, which cmd/mavend/replier_llm.go wraps — that file
holds the stub fallback and no parsing of its own. The legacy {"body","summary"} fallback
was deleted on 2026-08-06 (V-397): it was the contract before {"response","mood"} replaced
it, no prompt asks for that shape, the GBNF cannot emit it, and no test covered it.
Mood is a fixed enum. Router prompt is a separate contract:
[{"intent":<enum>, key?, value?, text?, verb?}, ...], 7 intents (fact, reminder, note, query, act, chat, system). llm/check_prompt_parity.py in the training
workspace enforces that the Go and relabelling prompts remain identical.
Russian patterns — three mechanisms, no fourth
Hand-written Russian stem patterns were swept out on 2026-08-04 (owner's call: not a pattern, and the resident model cannot be asked per turn either). A regex whose output is a fact or a route is the defect; a regex over structured input — HTML, MIME, JSON, a URL, an argv list — is not. Before writing a Russian word list, pick one of these:
internal/lexicon— closed classes, inlexicon_ru_v1.json. Interrogatives, capture verbs, reminder verbs, cardinals, day offsets, parts of day, weekdays, months, spoken hours. Editing a word is a data change, and there is exactly one copy: months used to live in three files. Cardinals carry the oblique forms, because a spoken time declines andв семь/к семиare one hour.internal/morph— grammar, from the vendored golem Russian dictionary.IsVerbFormandSameWord. Note that lemma matching is BROADER than stem-plus-one-ending, so a verb slot that means the imperative must be matched exactly —говориandговорилare one lemma and only one of them is a command (cmd/mavend/quiet_toggle.go).cmd/mavend/topics.goand the embedder — open sets, where the question is what a turn is ABOUT. Frozen seeds per subject plus a realotherclass, scored against the turn's own query vector. Same shape as the personal boundary inpersonalboundary.go, with one difference: a topic must clear the runner-up bytopicMargin, because a false claim here spends a network scan rather than one honest "не знаю". The old keyword tests stay as the offline floor and may remain narrow, since they are no longer the only answer.- The ecosystem trio — when the answer is not in the utterance at all. Identity is Nexus's, never a local pattern.
Seeds are scoring data. Editing one moves a recogniser and must be re-measured against the
TestONNX* tests, not eyeballed.
Non-goals (hard constraints)
Not a nag, not autonomous. Maven's persona is feminine — Russian
self-reference must use feminine forms — рада, not рад; поняла, not понял. The owner
is male and is addressed informally: "ты", singular, never "вы"/"ваш" and never "он"/"его"
(she talks TO the owner, not about the owner). Pet names ("милый", "дорогой") are forbidden; the name
("Ками") is not. The eval enforces this: CheckAddress, CheckFeminine and CheckCringe in
internal/phraser/eval/checks.go, scored by make eval-phrasing.
"Never phones home" is DEPRECATED (owner's call, 2026-07-31). It used to be a hard constraint and it is not one any more: a 0.8B — and a 1.7B — does not know enough to answer world questions, so she needs to read external sources. What replaces it:
- No telemetry, no cloud model, no third-party account. That part never changes. Nothing about Maven is reported to anyone, and inference stays on the box.
- The owner's data first, then the world. Every source that reads the owner's facts, notes, calendar, tasks or house runs before anything outside, and the personal boundary sits between them. Reading beats recalling for a small model.
- In the world, live search leads and the ZIMs are the fallback (owner's call,
2026-08-02). A self-hosted SearXNG (
searchblock) answers first; the Kiwix ZIMs on homesrv answer when the search is empty, unreachable, or the line is down. Verified with the line down on 2026-08-05 (V-508,docs/evals/2026-08-05-kiwix-offline-fallback.md): a stopped SearXNG costs nothing, the ZIM answers in the same turn budget. A blackholed host cost 8 seconds the owner waited through. So the connect phase alone is capped atdialTimeout(1.5s), while a slow instance that did connect keeps the full 8. A Russian question readswikipedia_ru_all_maxi_2026-02verbatim throughkiwix.book_ru. The RU→EN rewriter is the workaround for an English book and is skipped there. Kiwix catalog names come from the filename, not the<name>field.Response.Empty()is the whole gate and there is no quality threshold in front of it: the three signals one could read were measured on 2026-08-05 and none of them separate a real question from an invented one. Token overlap would cost "столица Франции" its answer, because the answer is Париж and that word is not in the question. Seedocs/evals/2026-08-05-search-quality-signals.md(V-539). Which query source claimed a turn is readable on/chatas a badge beside the reply, carried onipc.ChatReply.Sourceand noted bynoteQuerySourceincmd/mavend/querysource.go. It rides the context, sohandleTextkeeps the one string signature the mic, telegram and the web share. - External search is allowed and off unless configured, like the weather and telegram
capabilities. The code default is still off.
deploy/mavend.jsonnow ships asearchblock (owner's call, 2026-08-02), so it is on for this box and deleting the block turns it off again. - The owner's notes and facts are never search input. Looking up why the sky is blue and sending the owner's stored personal notes to an upstream engine are different acts. Only the utterance goes out, never the persona block, history, or matched notes.
Web UI conventions
Server-rendered pages share cmd/mavweb/static/ui.css (served at /ui.css) and the shell
partial in cmd/mavweb/shell.html: a page opens with {{template "shellTop" "<page-key>"}}
and closes with {{template "shellBottom"}}, and the key marks the active sidebar link.
Every page is its own embedded .html file next to main.go — no page markup lives in Go,
and the sidebar is data (sidebarSections, pageIcon) the template renders. No
per-page <style> beyond true one-offs. Wrap every table in <div class=scroll> so wide
data pans on a phone. Local preview + headless screenshot recipe is in AGENTS.md.
Vikunja
This repo is project Maven (ID 2) in Vikunja. MCP: http://localhost:9100/mcp (or
http://192.168.1.104:9100/mcp from workpc). Feature/bug/deploy tasks go there.
Vikunja is the durable task store. A task holds the goal, the constraints and the assumption ledger. Work without a task id is work nobody can resume, so a session that has no id asks for one before it starts.
The MCP tool schemas are deferred, so load the four you actually use in ONE call at the start of a session rather than one lookup per first use:
ToolSearch("select:mcp__vikunja__list_tasks,mcp__vikunja__get_task_details,mcp__vikunja__create_task,mcp__vikunja__update_task")
update_task carrying a description resets done to false, so closing a task with a
write-up takes two calls: the description, then done: true.
Session workflow
~/.local/bin/task owns the branch, the commit identity and the PR. One task, one
session, one PR.
task start <vikunja-id> # branch off origin/master, write TASK.md, fetch review comments
task pr # push, open or refresh the PR, label Vikunja, notify
task comments # re-pull this branch's review comments into .task/
Around that, /pickup opens a session and /wrap closes it. Wrap at roughly half
context rather than letting the session compact.
Five stores, and each one owns something the others must not hold:
| Store | Holds | Lifetime |
|---|---|---|
| Vikunja task | goal, constraints, assumption ledger, status | durable |
CLAUDE.md, AGENTS.md |
what an agent must know before touching code | durable |
docs/ |
design, measurements, decisions | durable |
TASK.md |
the brief for this branch, written by task start, immutable |
one branch |
HANDOFF.md |
only what the next agent needs to resume | one session |
TASK.md and .task/ are excluded through .git/info/exclude. HANDOFF.md is
gitignored and injected at session start. If a line in the handoff would still matter
next week, it is in the wrong file.
Docs are tiered by path, so staleness is visible from the filename. Files directly under
docs/ are living and carry a Last verified: <date> @ <sha> line. Files under
docs/evals/ are dated measurements and are never edited after the day, so a newer
number is a new file. Files under docs/archive/ are dead and read by nobody by default.
Git guards
Two hooks in .githooks/, tracked, wired with core.hooksPath. Fresh clone:
git config core.hooksPath .githooks
pre-commitrefuses master, and refuses more than 300 changed lines in non-markdown files. Markdown is exempt and may land as one batch.commit-msgrequires the subject to end with(V-<id>).V-and not#, because Gitea autolinks#123to a Gitea issue, which is the wrong tracker.
Two more guards live outside the repo, in ~/.claude/hooks/. diff-budget.sh blocks
further edits past 600 changed lines on a task/ branch. prose_lint_hook.py checks
prose on every write. Both measure against origin/master, so a local master that is
ahead of the remote makes the diff budget read high.
--no-verify exists. Using it means saying why in the commit body.