Cut CLAUDE.md to 200 lines #217

Merged
claude merged 1 commits from task/670-cut-claude-md-to-200-lines into master 2026-08-09 11:10:28 +02:00
7 changed files with 553 additions and 415 deletions
+125 -415
View File
@@ -2,17 +2,21 @@
Guidance for Claude Code (claude.ai/code) working in this repository.
This file is loaded into every session, so it carries rules and not history. A
measurement lives in `docs/evals/<date>-<name>.md` and is never edited after the
day. A subsystem's reasoning lives in a living doc under `docs/`. When a line
here says "see X", read X before changing that subsystem.
**This is a rules file.** It loads into every session, so it carries only what
changes what an agent does. A measurement belongs in `docs/evals/`, dated and
never edited after the day. A subsystem's reasoning belongs in its living doc
under `docs/`. Read that doc before changing the subsystem.
| Read this | Before |
|---|---|
| `docs/routing.md` | touching `internal/router/` or `queryWalk` |
| `docs/deployment.md` | touching a daemon, compose, a systemd unit or the web UI |
| `docs/offload.md` | touching a daemon seam or adding a model caller |
| `docs/world.md` | touching search, Kiwix or the world chain |
| `docs/language.md` | changing a prompt contract or a Russian word list |
| `docs/ecosystem.md` | touching Nexus, Praxis or Hexis |
| `docs/rearchitecture.md`, `docs/design.md` | changing the shape of anything |
| `docs/workflow.md` | the five stores, the doc tiers, the guards |
| `AGENTS.md` | local preview, screenshots, model downloads |
## What Maven is
@@ -21,470 +25,176 @@ A self-hosted, privacy-first voice assistant in Russian and English. Go daemons
talk over unix sockets. One resident small model routes and phrases. whisper.cpp
does speech-to-text and piper does text-to-speech.
The deploy target is a Ryzen laptop (homesrv) with Vulkan offload to the Vega
iGPU (`n_gpu_layers: 99`, compose passes `/dev/dri` and the render gid). The
resident model stays at 1.7B or under either way.
The resident model is **Qwen3-1.7B** (`UD-Q4_K_XL`) on homesrv, a Thinking
variant at `n_ctx` 4096. Keep it at 1.7B or under. Sub-500M models are unusable
in Russian (`docs/evals/2026-07-31-model-bakeoff.md`). Model files live in
`/mnt/hdd1/llms`, bind-mounted over the repo's `models/llm/`, so a gguf sitting
in the repo is loaded by nothing.
### The resident model
The workstation is workpc and it holds the remote model and speech-to-text.
**It is never assumed up.** **Fall back silently** when it would only do the job
better. **Name the gap** when the resident model cannot do the job at all.
**Qwen3-1.7B** (`UD-Q4_K_XL`), stock, not yet the CPT'd one. It is a Thinking
variant, so `n_ctx` is 4096. Reasoning tokens need the room, and 4096 is what
every score was measured at.
The target is the locally CPT'd Qwen3-1.7B (V-122, training in flight). Stock
already speaks good Russian. What it gets wrong is the persona. It writes `я рад`
where Maven needs `рада`.
**Do not bother with sub-500M models.** LFM2.5-230M and 350M were measured on
2026-07-31 and both are unusable in Russian
(`docs/evals/2026-07-31-model-bakeoff.md`). Their published IFEval and BFCL
numbers are English-only.
Model files live in `/mnt/hdd1/llms`, bind-mounted to `/opt/maven/models/llm`.
That **shadows** the repo's `models/llm/`, so a 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`.
### The workstation
Model work moved to workpc on 2026-08-02 (owner's call). homesrv cannot grow a
GPU and workpc has 16GB of VRAM. So the resident model, speech-to-text and
text-to-speech are preferred remotes with a floor on homesrv.
Three rules:
- **The workstation is never assumed up.**
- **Fall back silently** when it would only do the job better.
- **Name the gap** when the resident model cannot do the job at all. A world
question goes through `LLMPhraser.PhraseWorld` and returns `worldGap`
(`cmd/mavend/worldmodel.go`) rather than an invented answer. A box with no
`workstation` block behaves exactly as it did before the seam.
Routing and replies prefer the workstation through `modelSeam`. Nudge and
reminder phrasing prefer it inside the phraser. `docs/offload.md` says which
caller is which.
**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:` and `passage:` prefixes it was trained with. Calling plain
`Embed` on a note is a bug. See `docs/evals/2026-08-04-recall-e5-small.md`.
### Speech-to-text
`sttSeam` in `cmd/mavend/voicewire.go` builds an `stt.Pair` beside `modelSeam`.
It prefers CrisperWhisper 2.0 turbo on workpc with mavsttd as the floor. It takes
only the silent half of the rule, because a worse transcript is still a turn. So
`stt.Pair` has no `TranscribeRemote` and the fallback is never spoken.
CW2 turbo scores 10.4% WER in Russian against 27.5% for the `ggml-small.bin`
mavsttd loads, over 200 Golos clips
(`docs/evals/2026-08-09-crisperwhisper2-russian-wer.md`).
**whisper.cpp cannot load CW2 at all.** It reads its language count off the
vocabulary size. CW2's 51897 tokens shift seven special token ids. So CW2 is its
own transformers service on port 8081 (`deploy/cw2/serve.py`).
`stt.HTTPTranscriber` posts raw PCM to it with a bearer token, because audio is
the most sensitive thing that crosses this seam. The switch is `workstation.stt`
in `deploy/mavend.json`, and deleting the block sends every utterance to mavsttd.
**mavgpud runs that service as a second child.** This is not an optimisation.
CW2 is a ROCm process on the same card, so it registers on the KFD like any
contender. Under its own systemd unit it made mavgpud evict llama-server every
few seconds. That took the model arm down for eight minutes on 2026-08-09. The
card needs one owner. **Any GPU service added beside mavgpud goes in
`cmd/mavgpud`, never in systemd.** CW2 is on the yield clock and not the idle
one. At 1.6GB it denies the card to nobody.
Text-to-speech has not moved. piper on homesrv is the only synthesizer.
**The embedder stays on homesrv permanently**, because it backs that floor.
`EmbedQuery` and `EmbedPassage` apply the `query:` and `passage:` prefixes
multilingual-e5-small was trained with. Calling plain `Embed` on a note is a bug.
## Build and 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`.**
toolchain wired through the Makefile. **Do not call `go build` on them bare**,
and **do not hand-write the CGO preamble**. This box runs zsh, so an unquoted
`-run Test*` dies on "no matches found" before `go` is reached. `make t` also
carries `-count=1` and sets `MAVEN_ONNX_LIB`. Without that variable the four
`TestONNX*` measurements self-skip and the run still prints `ok`.
```sh
make build # all 11 binaries
make build-web # one daemon (web/waked/poll/caldav build without CGO)
make test # go test -race across ./internal/... ./cmd/... with CGO env set
make t PKG=./internal/router/
make t PKG=./cmd/mavend/ RUN=TestSimulator
make build # all 11 binaries. make build-web for one (web/waked/poll/caldav skip CGO)
make test # go test -race across ./internal/... ./cmd/... with CGO env set
make t PKG=./internal/router/eval/ RUN='TestONNX' V=1 # V=1 for -v, RACE=0 to drop -race
```
**Do not hand-write the CGO preamble.** Past sessions pasted it about 390 times,
and that is where the shell-quoting failures came from. This box runs zsh, so an
unquoted `-run Test*` dies on "no matches found" before `go` is ever reached.
`make t` carries `-race`, so a green `make t` cannot turn red under `make test`.
It carries `-count=1`, so a cached PASS from before your edit is never mistaken
for a result. It sets `MAVEN_ONNX_LIB`, which the hand-written recipe did not.
The four `TestONNX*` measurements self-skip when that variable is unset and the
run still prints `ok`. So every targeted eval done the old way reported the hash
ratchet while reading as a real embedder score.
## The daemons (`cmd/`)
| Binary | Role |
|---|---|
| `mavend` | **Core.** Router, phraser, memory, reminders, digestion tick. Owns the DB and IPC socket. |
| `mavweb` | HTTP UI and PWA (`/dash`, `/history`, `/trace`, `/notifications`, `/tools`), WebAuthn auth. |
| `mavsttd` | Speech-to-text (whisper.cpp, CGO). |
| `mavttsd` | Text-to-speech (piper subprocess). |
| `mavwaked` | Wake-word and VAD gate. **Not deployed anywhere yet.** |
| `mavenclient` | Voice loop client (mic, stt, core, tts). **Not deployed anywhere yet.** |
| `mavpoll` | Environment poller: netdata alarms, uptime-kuma, zenmoney, wireguard presence. Writes facts, sends nothing. Telegram is `internal/delivery/telegramsink`. |
| `mavcaldav` | CalDAV calendar sync. |
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password, core never sees it. |
| `mavgpud` | GPU supervisor. **Runs on workpc**, own unit `deploy/mavgpud.service`. Keeps llama-server loaded while the card is free (V-488). Maven never asks it for anything and reads `/health` through `llm.Pair`. |
| `mavupdate` | Not a daemon. Operator CLI a human runs on the box to deploy a new build. |
Two binaries have no Makefile target and neither is deployed. `mavseal` encrypts
a live tmpfs working copy back to the ciphertext file when mavend was killed
before `defer st.Close()` sealed it. `labelgen` runs the stage 0 grammars over
utterances and prints JSONL, the training data for the routing heads.
Daemons are wired socket-to-socket, not linked. `internal/ipc` is the wire
protocol. `deploy/mavend.json` sets socket paths, model paths and the phraser and
embedder blocks, with `${VAR}` expansion from gitignored `deploy/telegram.env`.
### Who is in compose, and who is not
## The daemons
Eleven binaries under `cmd/`, wired socket-to-socket over `internal/ipc`, not
linked. `mavend` is the core and owns the DB and the IPC socket.
`deploy/mavend.json` sets sockets, model paths and the phraser and embedder
blocks, with `${VAR}` expansion from gitignored `deploy/telegram.env`.
**`docker-compose.yml` runs five**: `mavend`, `mavsttd`, `mavttsd`, `mavweb`,
`mavpoll`. Count against compose, not against the table above. Four daemons are
absent and each absence has a different reason.
`mavpoll`. Count against compose, not against `make build`. `mavwaked` runs on
workpc under systemd. `docs/deployment.md` says who else is absent and why.
`mavmaild` and `mavcaldav` are commented out, each with the reason beside it. The
first needs a mail account and the second a CalDAV account, and this box has
neither. Two things ride on the CalDAV absence (V-644). Agenda questions route to
`IntentQuery` at stage 0, and the `calendar` query source then reads a table
nobody writes. And `loop.State.CalendarBusy` is fed by the same facts, so the
gate's "do not nag mid-meeting" is permanently false.
`mavwaked` and `mavenclient` are absent **by decision** (V-463,
`docs/plans/17-where-the-voice-loop-runs.md`). homesrv has a microphone, because
it is a laptop, but it is in the wrong room. They belong on a client machine
where the owner is standing, and that machine is workpc. `ipc.Dial` already takes
`tcp://host:port?token=...`, so V-515 is a deployment and not a build.
Until then the wake word and the VAD gate are covered by unit tests and nothing
else. Push-to-talk through `/dash` is what QA covers. Deploying them does not by
itself prove a wake word. `mavwaked` has no keyword model (V-487 stage two), so
the loop runs open until that lands.
**Passwords are read from files, never taken as flag values.** `mavcaldav` uses
`-pass-file` and `-render-pass-file`. `mavpoll` and `mavmaild` follow the same
rule.
- **Passwords are read from files, never taken as flag values.**
- **The voice wire is plaintext with no auth.** mavend's voice port stays on
homesrv loopback and reaches workpc over ssh. Do not LAN-bind it.
`SurfaceVoice` caps acts at L0, and L0 does not cap reading.
- **A GPU service added beside mavgpud goes in `cmd/mavgpud`, never in systemd.**
The card needs one owner. A second unit made mavgpud evict llama-server every
few seconds and took the model arm down for eight minutes.
## 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`.
```text
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 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 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:
Nexus identifies, Praxis observes, Hexis acts, Maven understands. Maven does not
own identity, operational state, or execution. Full contract in
`docs/ecosystem.md`. All three are `nil` unless configured and each degrades
alone. An outage means a named gap, never a broken turn or a guess.
- **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. `actionFact` sets `Subject`, and `cmd/mavend/factenrichment.go`
resolves it in the background.
- **Identity lives in Nexus.** Do not invent a local fact key for something
Nexus resolves. `cmd/mavend/factenrichment.go` resolves `actionFact.Subject`.
- **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`, never `Acknowledge`.
- **No automatic attention-to-action path.** Digestion may summarise Praxis. It
entity, arguments, requester and expiry (`cmd/mavend/confirm.go`).
- **Praxis lifecycle words differ.** Surfaced is not acknowledged, acknowledged
is not resolved, execution success is not recovery. Reading an item aloud
calls `Surface`, never `Acknowledge`.
- **No automatic attention-to-action path.** Digestion may summarise Praxis and
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`.
- Every cross-service call carries a correlation id minted once per action
(`withCorrelationID`), a contract version header, and `X-Requested-By: maven`.
## Routing
**Read `docs/routing.md` before touching `internal/router/` or `queryWalk`.** It
carries the stage-by-stage reasoning, every measurement, and why each rule
exists. What follows is only what must not be broken.
carries the reasoning, the measurements and every rule's why. A route produces
two decisions. **Intent** is one of seven values. **Source** is where the answer
lives and is read on `IntentQuery` alone. Score them separately. The cascade is
stage 0 grammars, then the routing heads, then the resident model, then the
classifier. Every stage may decline and the next one answers.
A route produces two decisions. **Intent** is one of seven values. **Source** is
where the answer lives and is read on `IntentQuery` alone. They are scored
separately, because one number hides which one moved.
The cascade is stage 0 grammars, then the routing heads, then the resident model,
then the classifier. Every stage may decline and the next one answers.
- **The classifier is the floor, not dead code.** It runs when the resident model
is off. It runs when there is no llama-server, and on any error. Deleting it
makes a model outage a broken turn.
- **Any model error falls through**, so a turn never breaks on a model.
- **`baselineGrammars` in `eval_test.go` mirrors `buildRouter`.** Add a grammar
to one and it belongs in both, or the fixture scores a set nobody runs.
- **The classifier is the floor, not dead code.** It answers when the resident
model is off, absent, or erroring. **Any model error falls through.**
- **`baselineGrammars` in `eval_test.go` mirrors `buildRouter`.** A grammar
added to one belongs in both, or the fixture scores a set nobody runs.
- **Go's `\b` is ASCII-only** and never fires after a Cyrillic letter. A Russian
pattern needs an explicit `(\s|[?!.]|$)`.
- **`PraxisGrammars()` is the only path to Praxis**, not a faster one. The model
reaches Praxis 0/12 alone, because nothing in the router prompt names a Praxis
capability.
- **`voice.embedder.heads_path` must never point at `model_path`.** The resident
e5-small must not be replaced by the fine-tuned copy. Recall depends on that
file scoring what it scored. Fine-tune a copy of the weights.
- **Bump `tokenizerRev` on any change to what `encodeWord` emits.** The embedder
id carries the revision. So a tokenizer fix triggers `ReembedAll` the way
swapping the model file does.
- **A new rung in the `runTurn` ladder needs its name in `preRouteLadder`**
(`cmd/mavend/decisiontrace.go`). Otherwise that rung is silently missing from
the decision record.
- **`PraxisGrammars()` is the only path to Praxis**, not a faster one.
- **`voice.embedder.heads_path` must never point at `model_path`.** Recall
depends on the resident e5-small scoring what it scored. Fine-tune a copy.
- **Routing traces are retained 14 days**, enforced on write and again on start.
The utterance is stored in clear and nothing reads it outward. `Store.Wipe`
deletes it with everything else.
- **Bump `tokenizerRev` on any change to what `encodeWord` emits**, so a
tokenizer fix triggers `ReembedAll` the way swapping the model file does.
- **A new rung in the `runTurn` ladder needs its name in `preRouteLadder`**
(`cmd/mavend/decisiontrace.go`), or it is missing from the decision record.
### `queryWalk` and the destination
**`queryWalk` takes query sources out and moves none** (`actions_query.go`). That
is the safety argument and it is not negotiable. The table's order is
load-bearing and carries "the owner's data first, then the world".
`SourceUnknown` is the floor and walks the whole chain. A named destination
removes only the sources marked `guesses: true`, so a source that looks rather
than guesses is always asked. **The personal boundary is the one exception and
it is deliberate.** It guesses, so naming `SourceWorld` drops it. **Only a stage
0 grammar may drop it** (owner's call, V-666). `queryWalk` reads
`Decision.SourceAnchored` for the source marked `boundary: true` and no other.
`queryWalk` in `cmd/mavend/actions_query.go` takes sources **out** and moves
none. That is the safety argument and it is not negotiable. The table's order is
load-bearing, and above all it carries "the owner's data first, then the world".
Judge a routing change against the classifier (76.0% intent, 36.4% destination)
and the resident model (80.2% intent), since those always answer. The fixture
has grown from 77 cases to 96, so a number compares only to another number on
the same fixture.
`SourceUnknown` is a real value and it is the floor. Nothing named a destination,
so the daemon walks the whole chain. Naming `SourceWorld` does not send the turn
outside on its own.
## Language: model output and Russian
What comes out is only the sources marked `guesses: true`. Those decide a turn is
theirs by cosine against frozen seeds, then answer whatever they claimed. A
source that looks rather than guesses is always asked.
Both contracts are in `docs/language.md`. What must not be broken:
**The personal boundary is the one exception and it is deliberate.** It guesses,
so naming `SourceWorld` drops it. **Only a stage 0 grammar may drop it**
(owner's call, V-666). `Decision.SourceAnchored` carries the provenance, and
`queryWalk` reads it for the source marked `boundary: true` and no other. So
every other guesser still comes off the turn, whoever named the destination.
`TestOnlyAGrammarMayDropTheBoundary` and `TestNamingRecallKeepsTheBoundary` pin
both directions.
### Current numbers
| Arm | Intent | Destination | p50 |
|---|---|---|---|
| classifier + ONNX | 76.0% | 36.4% | 16.6µs |
| resident Qwen3-1.7B, cascade | 80.2% | not measured | 1.19s |
| routing heads, cascade | 96.9% | 75.8% | 27.9ms |
| workstation gemma-4-E4B, cascade | 89.6% | 57.6% | 294ms |
Judge a routing change against the classifier and the resident model, since those
are what always answer. The fixture has grown from 77 cases to 96, so a number is
comparable only to another number on the same fixture.
## LLM output contract
All phrasing paths emit `{"response":"...","mood":"..."}`, falling back 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`. `cmd/mavend/replier_llm.go`
wraps the last of those, holds the stub fallback, and does no parsing of its own.
Mood is a fixed enum.
The router prompt is a separate contract:
`[{"intent":<enum>, key?, value?, text?, verb?}, ...]` over 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 stay identical.
## Russian patterns: three mechanisms, no fourth
Hand-written Russian stem patterns were swept out on 2026-08-04 (owner's call).
A regex whose output is a fact or a route is the defect. A regex over structured
input, such as HTML, MIME, JSON, a URL or an argv list, is not. Before writing a
Russian word list, pick one of these:
- **`internal/lexicon`** for closed classes, in `lexicon_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 `в семь` and `к семи` are one
hour.
- **`internal/morph`** for grammar, from the vendored golem Russian dictionary.
`IsVerbForm` and `SameWord`. Lemma matching is BROADER than stem-plus-one-ending,
so a verb slot meaning 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.go` and the embedder** for open sets, where the question is
what a turn is ABOUT. Frozen seeds per subject plus a real `other` class, scored
against the turn's own query vector. Same shape as the personal boundary in
`personalboundary.go`, with one difference. A topic must clear the runner-up by
`topicMargin`, because a false claim here spends a network scan rather than one
honest "не знаю". The old keyword tests stay as the offline floor.
- **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.
- **One parser for model text, `parseResponseMood`** in
`internal/phraser/parse.go`. Every phrasing path reaches it. Mood is an enum.
- **The router prompt is a separate contract** over 7 intents, and
`llm/check_prompt_parity.py` keeps the Go and relabelling copies identical.
- **Russian words are matched by three mechanisms and no fourth**:
`internal/lexicon` for closed classes, `internal/morph` for grammar, and
`cmd/mavend/topics.go` with the embedder for open sets. A regex whose output
is a fact or a route is the defect. A regex over structured input is not.
- **Seeds are scoring data.** Editing one moves a recogniser and must be
re-measured against the `TestONNX*` tests, not eyeballed.
## Non-goals and hard constraints
Not a nag, not autonomous.
**The persona is feminine.** Russian self-reference uses feminine forms: `рада`
**The persona is feminine.** Russian self-reference takes feminine forms: `рада`
not `рад`, `поняла` not `понял`. The owner is male and she speaks to him
informally. Use "ты", singular, never "вы" or "ваш", and never "он" or "его". She talks TO
the owner, not about him. Pet names such as "милый" are forbidden. The name
"Ками" is not. `CheckAddress`, `CheckFeminine` and `CheckCringe` in
informally. Use "ты", singular, never "вы" or "ваш", and never "он" or "его".
She talks TO the owner, not about him. Pet names such as "милый" are forbidden.
The name "Ками" is not. `CheckAddress`, `CheckFeminine` and `CheckCringe` in
`internal/phraser/eval/checks.go` enforce this, scored by `make eval-phrasing`.
**"Never phones home" is deprecated** (owner's call, 2026-07-31). A 1.7B does not
know enough to answer world questions, so she reads external sources. What
replaces it:
**"Never phones home" is deprecated** (owner's call, 2026-07-31). She reads
external sources, and `docs/world.md` carries that chain. What holds regardless:
- **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.
- **No telemetry, no cloud model, no third-party account.** Inference stays on
the box and nothing about Maven is reported to anyone.
- **The owner's data first, then the world.** Every source reading his facts,
notes, calendar, tasks or house runs before anything outside. The personal
boundary sits between them. Reading beats recalling for a small model.
- **The owner's notes and facts are never search input.** Only the utterance goes
out. Never the persona block, the history, or matched notes.
- **External search is allowed and off unless configured**, like weather and
telegram. The code default is off. `deploy/mavend.json` ships a `search` block,
so it is on for this box and deleting the block turns it off again.
- **In the world, live search leads and the ZIMs are the fallback** (owner's
call, 2026-08-02). A self-hosted SearXNG answers first. The Kiwix ZIMs on
homesrv answer when the search is empty, unreachable, or the line is down.
### The world chain
`Response.Empty()` is the whole gate and there is no quality threshold in front
of it. Four signals were tried and none separates 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
(`docs/evals/2026-08-05-search-quality-signals.md`). **The embedder is not a
fifth signal**: query-to-passage cosine measures topic and not whether the
passage answers, and the two sets overlap
(`docs/evals/2026-08-09-kiwix-topic-retrieval.md`).
The connect phase alone is capped at `dialTimeout` (1.5s), because a blackholed
host once cost the owner 8 seconds. A slow instance that did connect keeps the
full 8 (`docs/evals/2026-08-05-kiwix-offline-fallback.md`).
**A Russian question reads `wikipedia_ru_all_maxi_2026-02` verbatim** through
`kiwix.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.
**Kiwix ranks by keyword overlap.** Never send it a whole sentence.
`kiwix.Topic` drops the narrative request, the interrogative and a verb behind
one. `kiwix.TitlePath` tries the exact article first, since a ZIM is addressable
by title and a wrong title is a 404. `TitleCandidates` tries the spoken form and
then the capitalized one. Both apply on the verbatim path alone. The rewriter
already reduces a question, and reducing twice takes the topic off its input
(V-668).
**Which query source claimed a turn is readable on `/chat`** as a badge beside
the reply. It is carried on `ipc.ChatReply.Source` and noted by `noteQuerySource`
in `cmd/mavend/querysource.go`. It rides the context, so `handleText` keeps the
one string signature the mic, telegram and the web share.
## 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 and headless
screenshot recipes are in `AGENTS.md`.
## Vikunja
This repo is project **Maven** (ID 2). MCP at `http://localhost:9100/mcp`, or
`http://192.168.1.104:9100/mcp` from workpc. Feature, bug and 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
with no id asks for one before it starts.
The MCP tool schemas are deferred. Load the four you use in ONE call at the start
of a session:
```text
ToolSearch("select:mcp__vikunja__list_tasks,mcp__vikunja__get_task_details,mcp__vikunja__create_task,mcp__vikunja__update_task")
```
**Close a finished task with `done: true` and nothing else** (owner's call,
2026-08-07). Do not write a completion summary into the description on the way
out. It is lost anyway, and the durable record is the commit messages and the
merged PR. `update_task` carrying a `description` resets `done` to false, which
is why a write-up ever took two calls.
notes, calendar, tasks or house runs first, and the personal boundary sits
between them and anything outside.
- **His notes and facts are never search input.** Only the utterance goes out,
never the persona block, the history, or matched notes.
- **External search is allowed and off unless configured.** Deleting the
`search` block in `deploy/mavend.json` turns it off.
- **`Response.Empty()` is the whole gate** on a world answer. There is no
quality threshold in front of it and four candidate signals all failed.
## Session workflow
`~/.local/bin/task` owns the branch, the commit identity and the PR. One task,
one session, one PR.
`docs/workflow.md` carries the five stores, the doc tiers and the guards. One
task, one session, one PR. `/pickup` opens a session and `/wrap` closes it. Wrap
at roughly half context rather than letting the session compact.
```sh
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/
ToolSearch("select:mcp__vikunja__list_tasks,mcp__vikunja__get_task_details,mcp__vikunja__create_task,mcp__vikunja__update_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.
**This file is a rules file.** A new measurement belongs in `docs/evals/`. The
reasoning behind a subsystem belongs in its living doc. A line here earns its
place only by changing what an agent does.
## Git guards
Two hooks in `.githooks/`, tracked, wired with `core.hooksPath`. Fresh clone:
```sh
git config core.hooksPath .githooks
```
- `pre-commit` refuses master, and refuses more than 300 changed lines in
- This repo is Vikunja project **Maven** (ID 2), MCP at
`http://localhost:9100/mcp`, or `http://192.168.1.104:9100/mcp` from workpc.
- **A session with no task id asks for one before it starts**, because work
without one is work nobody can resume.
- **Close a finished task with `done: true` and nothing else** (owner's call,
2026-08-07). `update_task` carrying a `description` resets `done` to false.
- **`pre-commit` refuses master** and more than 300 changed lines in
non-markdown files. Markdown is exempt and may land as one batch.
- `commit-msg` requires the subject to end with `(V-<id>)`. `V-` and not `#`,
because Gitea autolinks `#123` to 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.
- **`commit-msg` requires the subject to end with `(V-<id>)`.** `V-` and not
`#`, because Gitea autolinks `#123` to the wrong tracker.
- **`diff-budget.sh` blocks edits past 600 changed lines** on a `task/` branch.
- **`--no-verify` exists.** Using it means saying why in the commit body.
+192
View File
@@ -0,0 +1,192 @@
# Deployment: the boxes, the models, the daemons
*Last verified: 2026-08-09 @ a9b480a*
What runs where, and why each choice was made. `CLAUDE.md` carries only the
rules. This file carries the reasoning.
## The two boxes
**homesrv** is a Ryzen 5 5600U laptop and the deploy target. It offloads to the
Vega iGPU over Vulkan (`n_gpu_layers: 99`). Compose passes `/dev/dri` and the
render gid (993), and without both Vulkan enumerates zero devices and
llama-server falls back to CPU silently.
**workpc** is the workstation, 16GB of VRAM, reached as `kami@workpc` at
192.168.1.105. Model work moved there on 2026-08-02 by the owner's call, because
homesrv cannot grow a GPU.
Three rules govern the seam:
- **The workstation is never assumed up.**
- **Fall back silently** when it would only do the job better.
- **Name the gap** when the resident model cannot do the job at all.
A world question goes through `LLMPhraser.PhraseWorld` and returns `worldGap`
(`cmd/mavend/worldmodel.go`) rather than an invented answer. A box with no
`workstation` block behaves exactly as it did before the seam. `docs/offload.md`
says which caller is which.
## The resident model
**Qwen3-1.7B** (`UD-Q4_K_XL`), stock, not yet the CPT'd one. It is a Thinking
variant, so `n_ctx` is 4096. Reasoning tokens need the room, and 4096 is what
every score was measured at.
The target is the locally CPT'd Qwen3-1.7B (V-122, training in flight). Stock
already speaks good Russian. What it gets wrong is the persona. It writes `я рад`
where Maven needs `рада`.
**Do not bother with sub-500M models.** LFM2.5-230M and 350M were measured on
2026-07-31 and both are unusable in Russian
(`docs/evals/2026-07-31-model-bakeoff.md`). Their published IFEval and BFCL
numbers are English-only.
Model files live in `/mnt/hdd1/llms`, bind-mounted to `/opt/maven/models/llm`.
That **shadows** the repo's `models/llm/`, so a 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`.
The workstation model is gemma-4-E4B as of 2026-08-09, replacing the 12B by the
owner's call. Keep the 12B gguf. It is the better teacher for label runs, at
72.7% destination against E4B's 57.6%.
## The embedder
**It stays on homesrv permanently**, because it backs the floor. It is
multilingual-e5-small, quantized and asymmetric. `EmbedQuery` and `EmbedPassage`
apply the `query:` and `passage:` prefixes it was trained with. Calling plain
`Embed` on a note is a bug. See `docs/evals/2026-08-04-recall-e5-small.md`.
The vendored onnxruntime under `deps/` has two copies, and the stale one is
1.17.1. The live runtime is 1.26.0, and the Go binding asks for API 26. Anything
shipped to another box needs `deps/onnxruntime-linux-x64-1.26.0`.
## Speech-to-text
`sttSeam` in `cmd/mavend/voicewire.go` builds an `stt.Pair` beside `modelSeam`.
It prefers CrisperWhisper 2.0 turbo on workpc with mavsttd as the floor. It takes
only the silent half of the rule, because a worse transcript is still a turn. So
`stt.Pair` has no `TranscribeRemote` and the fallback is never spoken.
CW2 turbo scores 10.4% WER in Russian against 27.5% for the `ggml-small.bin`
mavsttd loads, over 200 Golos clips
(`docs/evals/2026-08-09-crisperwhisper2-russian-wer.md`).
**whisper.cpp cannot load CW2 at all.** It reads its language count off the
vocabulary size. CW2's 51897 tokens shift seven special token ids. So CW2 is its
own transformers service on port 8081 (`deploy/cw2/serve.py`).
`stt.HTTPTranscriber` posts raw PCM to it with a bearer token, because audio is
the most sensitive thing that crosses this seam. The switch is `workstation.stt`
in `deploy/mavend.json`, and deleting the block sends every utterance to mavsttd.
**mavgpud runs that service as a second child.** This is not an optimisation.
CW2 is a ROCm process on the same card, so it registers on the KFD like any
contender. Under its own systemd unit it made mavgpud evict llama-server every
few seconds. That took the model arm down for eight minutes on 2026-08-09. The
card needs one owner. CW2 is on the yield clock and not the idle one. At 1.6GB
it denies the card to nobody.
Text-to-speech has not moved. piper on homesrv is the only synthesizer.
## The daemons
| Binary | Role |
|---|---|
| `mavend` | **Core.** Router, phraser, memory, reminders, digestion tick. Owns the DB and IPC socket. |
| `mavweb` | HTTP UI and PWA (`/dash`, `/history`, `/trace`, `/notifications`, `/tools`), WebAuthn auth. |
| `mavsttd` | Speech-to-text (whisper.cpp, CGO). |
| `mavttsd` | Text-to-speech (piper subprocess). |
| `mavwaked` | Wake-word and VAD gate. Runs on workpc. |
| `mavenclient` | Voice loop client (mic, stt, core, tts). Not deployed. |
| `mavpoll` | Environment poller: netdata alarms, uptime-kuma, zenmoney, wireguard presence. Writes facts, sends nothing. Telegram is `internal/delivery/telegramsink`. |
| `mavcaldav` | CalDAV calendar sync. |
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password, core never sees it. |
| `mavgpud` | GPU supervisor. **Runs on workpc**, own unit `deploy/mavgpud.service`. Keeps llama-server loaded while the card is free (V-488). Maven never asks it for anything and reads `/health` through `llm.Pair`. |
| `mavupdate` | Not a daemon. Operator CLI a human runs on the box to deploy a new build. |
Two binaries have no Makefile target and neither is deployed. `mavseal` encrypts
a live tmpfs working copy back to the ciphertext file when mavend was killed
before `defer st.Close()` sealed it. `labelgen` runs the stage 0 grammars over
utterances and prints JSONL, the training data for the routing heads.
Daemons are wired socket-to-socket, not linked. `internal/ipc` is the wire
protocol. `deploy/mavend.json` sets socket paths, model paths and the phraser and
embedder blocks, with `${VAR}` expansion from gitignored `deploy/telegram.env`.
### Who is in compose, and who is not
**`docker-compose.yml` runs five**: `mavend`, `mavsttd`, `mavttsd`, `mavweb`,
`mavpoll`. Count against compose, not against the table above.
`mavmaild` and `mavcaldav` are commented out, each with the reason beside it. The
first needs a mail account and the second a CalDAV account, and this box has
neither. Two things ride on the CalDAV absence (V-644). Agenda questions route to
`IntentQuery` at stage 0, and the `calendar` query source then reads a table
nobody writes. And `loop.State.CalendarBusy` is fed by the same facts, so the
gate's "do not nag mid-meeting" is permanently false.
`mavenclient` is still absent. `mavwaked` moved to workpc on 2026-08-09 (V-515).
### The voice wire
`internal/voice` is plaintext with no auth. Its own server doc says 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 tunnel is ssh instead.
mavend publishes the voice port to homesrv loopback only, `127.0.0.1:9110`.
Host 9100 is Vikunja's MCP, hence 9110. The container side stays 9100 so mavweb
keeps reaching `mavend:9100` by name. `deploy/maven-voice-tunnel.service` on
workpc forwards it over his key.
**Do not replace this with a LAN bind.** `SurfaceVoice` caps acts at L0, so an
unauthorized speaker could not run a destructive tool. L0 does not cap reading,
so they would still hear his facts, notes and calendar read back.
Both `mavwaked` and `mavenclient` speak `voice.Dial`, not `ipc.Dial`. The
`netaddr` token guards the daemon-to-daemon IPC seam and never touches this one.
`ipc.Dial` does take `tcp://host:port?token=...`, which is why V-515 was filed
as a config change. That premise was wrong, and the ssh leg is the correction.
The voice loop belongs on a client machine where the owner is standing, and that
machine is workpc (V-463, `docs/plans/17-where-the-voice-loop-runs.md`). homesrv
has a microphone, because it is a laptop, but it is in the wrong room.
### mavwaked on workpc
`deploy/mavwaked.service`, a user unit beside `mavgpud.service`. Two flags are
deliberate.
`-vad-model` is passed. Silero answers "is this frame speech" where the energy
floor answers "is this frame loud". It declines white noise at the same RMS, 0
frames against 68 to 99, and still hears all four spoken fixtures
(`docs/evals/2026-08-09-silero-vad.md`). It costs 509µs a frame and never touches
the GPU. A model that will not load is logged and not fatal.
`-barge-in` is not passed. The threshold is room-specific and this room has no
number yet. Read the "suppressed while speaking" means out of the journal first.
The device is `plughw:0,0` and not `hw:0,0`. The fifine offers 2 channels at
44100 or 48000 and nothing else, and mavwaked asks arecord for 16kHz mono. Bare
`hw` dies on "Channels count non available" before a frame is read.
There is no wake word yet (V-487 stage two), so the loop runs open. mavwaked
connects lazily, so `voicesink` cannot push a nudge to it until it has sent one
utterance.
**Passwords are read from files, never taken as flag values.** `mavcaldav` uses
`-pass-file` and `-render-pass-file`. `mavpoll` and `mavmaild` follow the same
rule.
## 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 and headless
screenshot recipes are in `AGENTS.md`.
+6
View File
@@ -30,6 +30,12 @@ Maven is the user-facing control center, but not the source of truth for identit
Maven provides the human interface over the other systems.
| 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 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`) |
It is responsible for:
- interpreting Russian and English utterances
+74
View File
@@ -0,0 +1,74 @@
# Language: what the model emits, and how Russian is matched
*Last verified: 2026-08-09 @ a9b480a*
Two contracts live here. What a model call is allowed to return, and which
mechanism is allowed to recognise a Russian word.
## The LLM output contract
All phrasing paths emit `{"response":"...","mood":"..."}`. They fall back to
plain text when the model skips the JSON.
**One parser, `parseResponseMood` in `internal/phraser/parse.go`.** Every path
reaches it: the six `LLMPhraser` methods, `PhraseWorld`, and
`Replier.PhraseReply`. `cmd/mavend/replier_llm.go` wraps the last of those,
holds the stub fallback, and parses nothing itself. Mood is a fixed enum.
The router prompt is a separate contract:
```text
[{"intent":<enum>, key?, value?, text?, verb?}, ...]
```
over 7 intents: `fact, reminder, note, query, act, chat, system`.
`llm/check_prompt_parity.py` in the training workspace enforces that the Go
prompt and the relabelling prompt stay identical. They diverged once, and the
relabelled set then taught a head the Go router never asks for.
## Russian patterns: three mechanisms, no fourth
Hand-written Russian stem patterns were swept out on 2026-08-04 by the owner's
call. A regex whose output is a fact or a route is the defect. A regex over
structured input, such as HTML, MIME, JSON, a URL or an argv list, is not.
Before writing a Russian word list, pick one of these.
### `internal/lexicon`, for closed classes
`lexicon_ru_v1.json` holds interrogatives, capture verbs, reminder verbs,
cardinals, day offsets, parts of day, weekdays, months and spoken hours.
Editing a word is a data change and there is exactly one copy. Months used to
live in three files and drifted between them.
Cardinals carry the oblique forms, because a spoken time declines. `в семь` and
`к семи` are one hour.
### `internal/morph`, for grammar
From the vendored golem Russian dictionary. `IsVerbForm` and `SameWord`.
Lemma matching is BROADER than stem-plus-one-ending. A verb slot that means the
imperative must be matched exactly. `говори` and `говорил` share a
lemma and only one of them is a command
(`cmd/mavend/quiet_toggle.go`).
### `cmd/mavend/topics.go` and the embedder, for open sets
Use these when the question is what a turn is ABOUT. Frozen seeds per subject
plus a real `other` class, scored against the turn's own query vector.
Same shape as the personal boundary in `personalboundary.go`, with one
difference. A topic must clear the runner-up by `topicMargin`, because a false
claim here spends a network scan rather than one honest "не знаю". The old
keyword tests stay as the offline floor.
### The ecosystem trio
Use it 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. Re-measure against the `TestONNX*` tests rather
than eyeballing the change.
+5
View File
@@ -353,6 +353,11 @@ stage 0 grammar may drop it (owner's call, V-666, 2026-08-09).
one of those could stop implying the others. `definitionQueryPattern` claims "кто
такой X", so the 2026-08-07 case is still anchored and still answered.
`queryWalk` reads `SourceAnchored` for the query source marked `boundary: true`
and no other. Every other guesser still comes off the turn, whoever named the
destination. `TestOnlyAGrammarMayDropTheBoundary` and
`TestNamingRecallKeepsTheBoundary` pin both directions.
### The destination fixture
`want_source` on `eval.Case` is a pointer, because the destination has three
+80
View File
@@ -0,0 +1,80 @@
# Session workflow: the five stores and the guards
*Last verified: 2026-08-09 @ a9b480a*
How a session starts, where each kind of writing belongs, and what the hooks
refuse. `CLAUDE.md` carries the commands. This file carries the reasoning.
## Five stores
Each 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.
## Doc tiers
Tiered by path, so staleness is visible from the filename.
- Files directly under `docs/` are living. They carry a
`Last verified: <date> @ <sha>` line and are corrected in place.
- Files under `docs/evals/` are dated measurements and are never edited after
the day. A newer number is a new file, not an edit.
- Files under `docs/archive/` are dead and read by nobody by default.
## Vikunja
This repo is project **Maven** (ID 2). MCP at `http://localhost:9100/mcp`, or
`http://192.168.1.104:9100/mcp` from workpc. Feature, bug and deploy tasks go
there.
A task holds the goal, the constraints and the assumption ledger. A session
without a task id cannot be resumed by anyone, so a session with none asks for
one first.
**Close a finished task with `done: true` and nothing else** (owner's call,
2026-08-07). Do not write a completion summary into the description on the way
out. It is lost anyway, and the durable record is the commit messages and the
merged PR. `update_task` carrying a `description` resets `done` to false, which
is why a write-up ever took two calls.
## The branch tool
`~/.local/bin/task` owns the branch, the commit identity and the PR. One task,
one session, one PR.
```sh
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/
```
`/pickup` opens a session and `/wrap` closes it. Wrap at roughly half context
rather than letting the session compact.
## Guards
Two hooks in `.githooks/`, tracked, wired with `core.hooksPath`. A fresh clone
needs `git config core.hooksPath .githooks`.
- `pre-commit` refuses master, and refuses more than 300 changed lines in
non-markdown files. Markdown is exempt and may land as one batch.
- `commit-msg` requires the subject to end with `(V-<id>)`. `V-` and not `#`,
because Gitea autolinks `#123` to 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.
+71
View File
@@ -0,0 +1,71 @@
# The world chain
*Last verified: 2026-08-09 @ a9b480a*
What happens when the answer is not his. `CLAUDE.md` carries the boundary rule.
This file carries the mechanism and the measurements behind it.
## What replaced "never phones home"
That promise was deprecated on 2026-07-31 by the owner's call. A 1.7B does not
know enough to answer world questions, so she reads external sources. Four rules
replaced 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 reading his facts,
notes, calendar, tasks or house runs before anything outside. The personal
boundary sits between them. Reading beats recalling for a small model.
- **The owner's notes and facts are never search input.** Only the utterance
goes out. Never the persona block, the history, or matched notes.
- **External search is allowed and off unless configured**, like weather and
telegram. The code default is off. `deploy/mavend.json` ships a `search`
block, so it is on for this box and deleting the block turns it off again.
**Live search leads and the ZIMs are the fallback** (owner's call, 2026-08-02).
A self-hosted SearXNG answers first. The Kiwix ZIMs on homesrv answer when the
search is empty, unreachable, or the line is down.
## The gate is emptiness and nothing else
`Response.Empty()` is the whole gate. There is no quality threshold in front of
it. Four signals were tried and none separates a real question from an invented
one.
Token overlap was the closest and it still fails. "столица Франции" would lose
its answer, because the answer is Париж and that word is not in the question
(`docs/evals/2026-08-05-search-quality-signals.md`).
**The embedder is not a fifth signal.** Query-to-passage cosine measures topic
and not whether the passage answers. The two sets overlap
(`docs/evals/2026-08-09-kiwix-topic-retrieval.md`).
## Timeouts
The connect phase alone is capped at `dialTimeout`, 1.5s, because a blackholed
host once cost the owner 8 seconds. A slow instance that did connect keeps the
full 8 (`docs/evals/2026-08-05-kiwix-offline-fallback.md`).
## Kiwix
**A Russian question reads `wikipedia_ru_all_maxi_2026-02` verbatim** through
`kiwix.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.
**Kiwix ranks by keyword overlap.** Never send it a whole sentence. `kiwix.Topic`
drops the narrative request, the interrogative and a verb behind one.
`kiwix.TitlePath` tries the exact article first, since a ZIM is addressable by
title and a wrong title is a 404. `TitleCandidates` tries the spoken form and
then the capitalized one. Both apply on the verbatim path alone. The rewriter
already reduces a question, and reducing twice takes the topic off its input
(V-668).
## Which source answered
**The claiming query source is readable on `/chat`** as a badge beside the
reply. It is carried on `ipc.ChatReply.Source` and noted by `noteQuerySource` in
`cmd/mavend/querysource.go`. It rides the context, so `handleText` keeps the one
string signature the mic, telegram and the web share.