Move the reasoning out of CLAUDE.md and leave the rules (V-670)
490 lines still loads into every session, and most of them explained a
subsystem rather than constraining an agent. The owner's cap is 200. This
lands at exactly 200.
Four new living docs take what left:
docs/deployment.md the two boxes, the resident model, the embedder, STT,
the daemon table, who is in compose, the voice wire,
mavwaked on workpc, the web UI conventions
docs/world.md what replaced "never phones home", why Response.Empty()
is the whole gate, the timeouts, Kiwix
docs/language.md the LLM output contract and the three Russian mechanisms
docs/workflow.md the five stores, the doc tiers, Vikunja, the guards
CLAUDE.md keeps the pointer table and the rules. Every "do not do X", every
path and every owner's call stayed. What went is the before-and-after
narrative behind each one, which is what a living doc is for.
Verified rather than trusted. Every backticked literal in the old file was
diffed against the union of the new ones. Twenty-four came up missing and
three groups were facts rather than narrative, so they were restored:
- the ecosystem client table (nexusClient, praxisClient, the vendored hexis
client, the three config keys and their default URLs) into
docs/ecosystem.md, which did not carry it
- TestOnlyAGrammarMayDropTheBoundary and TestNamingRecallKeepsTheBoundary
into docs/routing.md, since they pin the boundary rule in both directions
- the ipc.Dial vs voice.Dial trap and docs/plans/17 into docs/deployment.md
diff-budget.sh blocked on the changed-line count again. It counts markdown,
which the repo's own pre-commit hook exempts, and this commit touches
nothing else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user