c0d61a71a4
task start and task pr already own the branch, the identity and the PR. What was missing sat on either side of them. pickup runs task start, reads TASK.md and any handoff, then restates the assumption set and stops. That pause is the point: every wasted session here began with an agent that inferred the goal instead of stating it back. wrap runs the tests, updates the durable docs, commits in slices, calls task pr, and records in Vikunja what task pr cannot know. HANDOFF.md is gitignored and injected by a SessionStart hook. It holds what the next agent needs to resume and nothing else. TASK.md is the brief for the branch and does not change. Anything that would still matter next week goes to Vikunja, CLAUDE.md or docs/. CLAUDE.md documented none of this, which is why an agent would rebuild it. It does now, including the two hooks in ~/.claude/hooks. .claude/ was ignored wholesale. The workflow is now tracked, because how a session behaves should be reviewed like code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
273 lines
16 KiB
Markdown
273 lines
16 KiB
Markdown
# CLAUDE.md
|
||
|
||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||
|
||
Maven is a self-hosted, privacy-first voice assistant (Russian + English). Go daemons
|
||
talking over unix sockets; one resident small model for routing + phrasing; whisper.cpp STT, piper TTS.
|
||
Deploy target is a Ryzen laptop (homesrv) with Vulkan offload to the Vega iGPU (`n_gpu_layers: 99`,
|
||
compose passes `/dev/dri` + the render gid) — the resident model stays ≤1.7B either way.
|
||
|
||
**Resident model:** currently **Qwen3-1.7B** (`UD-Q4_K_XL`), stock — not yet the CPT'd one.
|
||
It replaced Qwen3.5-0.8B on 2026-07-31 because it measured better on both fixtures we have:
|
||
67.5% vs 59.7% intent-only on the 77-case RU routing fixture, and 20/27 vs 11-17/27 on the
|
||
talk fixture. See `MODEL-BAKEOFF-31-07-2026.md`. It is a Thinking variant, so `n_ctx` is 4096
|
||
— reasoning tokens need the room, and 4096 is what the scores above were measured at.
|
||
|
||
The **target** is still the locally CPT'd **Qwen3-1.7B** (Vikunja #122, training in flight).
|
||
Stock already speaks good Russian; what it gets wrong is the persona — it writes `я рад`,
|
||
masculine, where Maven needs `рада`. That is what the CPT is for.
|
||
|
||
**Do not bother with sub-500M models.** LFM2.5-230M and 350M were measured on 2026-07-31 and
|
||
both are unusable in Russian: the 350M routes at 5.2% (worse than guessing) and answers
|
||
"столица Франции?" with the invented non-word "Сторзит"; the 230M replies to Russian in
|
||
Spanish. Their strong published IFEval/BFCL numbers are English-only. Model files live in
|
||
`/mnt/hdd1/llms`, bind-mounted to `/opt/maven/models/llm` — which **shadows** the repo's
|
||
`models/llm/`, so the LFM2.5 gguf sitting there is not loaded by anything. Swapping the resident
|
||
model is a one-line change to `phraser.model_path` in `deploy/mavend.json`.
|
||
|
||
See `REARCH.md` for the target architecture, `DESIGN.md` for the folded design spec, and
|
||
`AGENTS.md` for local-preview + model-download recipes.
|
||
|
||
## Build & test
|
||
|
||
CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored toolchain
|
||
and libs wired through the Makefile — **do not** call `go build` on them bare, use `make`:
|
||
|
||
```sh
|
||
make build # all 9 binaries
|
||
make build-web # single daemon (pure-Go ones: web/waked/poll/caldav build without CGO)
|
||
make test # go test -race across ./internal/... ./cmd/... with CGO env set
|
||
```
|
||
|
||
Run a single test (must carry the CGO env for packages that touch STT/TTS/voice):
|
||
|
||
```sh
|
||
CGO_CFLAGS="-I$(pwd)/deps/include -I$(pwd)/deps/whisper.cpp/ggml/include" \
|
||
CGO_LDFLAGS="-L$(pwd)/deps/lib -Wl,-rpath,$(pwd)/deps/lib" \
|
||
LD_LIBRARY_PATH="$(pwd)/deps/lib" \
|
||
deps/go/go/bin/go test -run TestName ./internal/router/
|
||
```
|
||
|
||
Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test ./pkg/`.
|
||
|
||
## The daemons (`cmd/`)
|
||
|
||
| Binary | Role |
|
||
|---|---|
|
||
| `mavend` | **Core.** Router, phraser, memory, reminders, digestion tick. Owns the DB + IPC socket. |
|
||
| `mavweb` | HTTP UI + PWA (`/dash`, `/history`, `/trace`, `/notifications`, `/tools`); WebAuthn auth. Connects to mavend's socket. |
|
||
| `mavsttd` | Speech-to-text (whisper.cpp, CGO). |
|
||
| `mavttsd` | Text-to-speech (piper subprocess). |
|
||
| `mavwaked` | Wake-word / VAD gate. |
|
||
| `mavenclient` | Voice loop client (mic → stt → core → tts). |
|
||
| `mavpoll` | Telegram long-poll reach. |
|
||
| `mavcaldav` | CalDAV calendar sync. |
|
||
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password; core never sees it. |
|
||
|
||
Daemons are wired socket-to-socket, not linked. `internal/ipc` is the client/server wire
|
||
protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from gitignored
|
||
`deploy/telegram.env`) sets socket paths, model paths, and the phraser/embedder blocks.
|
||
|
||
## 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
|
||
`MAVEN_ECOSYSTEM_ARCHITECTURE.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 still unresolved. | `praxisClient`, the HTTP tools API under `/api/v1/tools/` | `praxis.url` (`http://praxis:8989`) |
|
||
| **Hexis** | The capability registry and the only path to executing anything. | vendored `github.com/kami/hexis/pkg/client` | `hexis.url` (`http://hexis:9741`) |
|
||
|
||
All three are `nil` unless configured, and every one of them degrades on its own.
|
||
An outage means a named gap in the answer, never a broken turn and never a guess.
|
||
|
||
Rules that are not negotiable:
|
||
|
||
- **No component reads another component's database.** Praxis attention comes over
|
||
HTTP, never from its SQLite file.
|
||
- **Identity lives in Nexus.** Do not invent a local fact key for something Nexus
|
||
resolves. `actionFact` already sets `Subject`, and `cmd/mavend/factenrichment.go`
|
||
resolves it in the background against Nexus.
|
||
- **Free text never reaches a mutating Hexis call.** Resolve to a canonical entity id
|
||
first. Ambiguous resolution asks the owner, it does not pick.
|
||
- **LLM output is not authorization.** Confirmation binds capability id, target
|
||
entity, arguments, requester and expiry. See `cmd/mavend/confirm.go`.
|
||
- **Praxis lifecycle words mean different things.** Surfaced is not acknowledged,
|
||
acknowledged is not resolved, execution success is not recovery. Reading an item
|
||
aloud calls `Surface`, never `Acknowledge`.
|
||
- **No automatic attention-to-action path.** Digestion may summarise Praxis. It may
|
||
not call Hexis.
|
||
|
||
Every cross-service call carries a correlation id minted once per action
|
||
(`withCorrelationID`), a contract version header, and `X-Requested-By: maven`.
|
||
|
||
## Routing — read this before touching the router
|
||
|
||
`internal/router/` has TWO layered engines. **The LLM router is now the default and it is
|
||
on in deploy** — this section used to say it was wired `nil`, which stopped being true on
|
||
2026-07-31.
|
||
|
||
- **LLM router (the intended design, REARCH.md):** the resident Qwen3-1.7B (`llmrouter.go`)
|
||
emits GBNF-constrained structured JSON, and the SAME model phrases replies. Embedder is
|
||
demoted from a routing gate to a RAG hint. Wired at `voice.go:214` via
|
||
`pickLLMRouter(cfg.Voice.UseLLMRouter(), llmClient)`; the flag is `voice.llm_router`
|
||
(`config.go`), `DefaultLLMRouter` is **on**, and `deploy/mavend.json` sets it `true`.
|
||
- **Classifier cascade (the failure floor, not dead code):** `classifier.go` +
|
||
`embedder.go` nearest-neighbour over frozen seed phrases. It runs when the LLM router is
|
||
off, when there is no llama-server to talk to (`pickLLMRouter` logs that and degrades),
|
||
and on any per-turn LLM error. Do not delete it — routing by seed similarity is the known
|
||
cause of weak RU query handling, but a turn must never break on the model.
|
||
|
||
Cascade order: `stage0.go` exact-match fast-path → LLM router (when non-nil) → classifier
|
||
fallback. Any LLM error falls through to the classifier so a turn never breaks on the model.
|
||
|
||
Measured on the 77-case RU fixture (`MODEL-BAKEOFF-31-07-2026.md`): the classifier scores
|
||
36.8% full accuracy at p50 31ms; Qwen3-1.7B scores 67.5% intent-only / 72.7% through the
|
||
cascade at p50 ≈825ms. Accuracy roughly doubled, latency is ~27× worse, and that trade was
|
||
accepted deliberately. **The ≈2.7s figure that stood here until 2026-08-02 was contention,
|
||
not the model.** See `ROUTING-EVAL-31-07-2026.md` line 61, which measures the LLM router at
|
||
p50 825ms / p95 1.2s / max 3.0s and the full cascade at p50 0.80-1.04s. Do not plan latency
|
||
work off the bakeoff table. `Confidence: 1.0` used to be hardcoded in `llmrouter.go`, so the LLM
|
||
path could never ask for clarification (6/6 refusal cases missed on the fixture) — Vikunja
|
||
#359. Fixed 31-07-2026 with structural signal (single-token utterance, keyless fact, act with
|
||
no allowlisted fn) feeding the same stage-3 gate the classifier path already had — see
|
||
`gateLLMDecision` in `router.go`. Note the second half of that bug: the LLM branch never
|
||
consulted `r.threshold` at all, so a correct low confidence would have been discarded anyway.
|
||
|
||
Re-measured on the fixture after the fix: **missed clarify 6/6 → 1**, at the cost of 3 false
|
||
clarifies and 2.6pt of full accuracy (72.7% → 70.1%, intent-only 67.5% → 74.0%). Two of the
|
||
three false clarifies are acts the model mis-routed and the gate caught — asking beats wrongly
|
||
executing, so the fixture and the daemon disagree about what is correct there. The third,
|
||
`"поужинал"`, was a real defect: the single-token rule was an English intuition and does not
|
||
transfer to Russian, where one word is routinely a whole sentence.
|
||
|
||
Narrowed 01-08-2026. `thinSingleToken` (`internal/router/singletoken.go`) still thins a bare
|
||
one-word nominal — "вода", "бэкап" — but spares two classes: a closed lexicon of social and
|
||
control singles ("привет", "спасибо", "стоп", "yes"), and any token carrying a Russian verb
|
||
ending (past tense, 2nd person, reflexive), because a verb already contains its subject. Both
|
||
tests are offline and cost nothing. Re-measured: **false clarifies 3 → 2, intent-only 74.0% →
|
||
75.3%, full accuracy unchanged at 70.1%, missed clarify still 1.** The two remaining false
|
||
clarifies are the act-with-no-allowlisted-fn arm of the gate, not this rule.
|
||
|
||
Agenda questions taken off the model, 01-08-2026. `AgendaQueryGrammars` (`stage0.go`, wired
|
||
after the clock rules in `buildRouter`) routes "что у меня сегодня", "во сколько у меня
|
||
встреча" and anything naming a calendar to `IntentQuery` at stage 0. They were going to
|
||
`IntentSystem`, where `replySystem` has no agenda arm and answered "пока не умею" — the
|
||
fixture had said `query` since ru-query-019 was written. Measured: **full accuracy 70.1% →
|
||
72.7%, intent-only 75.3% → 77.9%, calendar 0/2 → 2/2**, clarify counts unchanged. Note that
|
||
Go's `\b` is ASCII-only and never fires after a Cyrillic letter; the pattern needs an
|
||
explicit `(\s|[?!.]|$)`.
|
||
|
||
## LLM output contract
|
||
|
||
All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and
|
||
`internal/phraser/llmphraser.go`), with fallback to plain text and the legacy
|
||
`{"body","summary"}`. Mood is a fixed enum. Router prompt is a separate contract:
|
||
`[{"intent":<enum>, key?, value?, text?, verb?}, ...]`, 7 intents (`fact, reminder,
|
||
note, query, act, chat, system`). `llm/check_prompt_parity.py` in the training
|
||
workspace enforces that the Go and relabelling prompts remain identical.
|
||
|
||
## Non-goals (hard constraints)
|
||
|
||
Not a nag, not autonomous. Maven's persona is **feminine** — Russian
|
||
self-reference must use feminine forms — `рада`, not `рад`; `поняла`, not `понял`. The owner
|
||
is male and is addressed informally: "ты", singular, never "вы"/"ваш" and never "он"/"его"
|
||
(she talks TO him, not about him). Pet names ("милый", "дорогой") are forbidden; his name
|
||
("Ками") is not. The eval enforces this: `CheckAddress`, `CheckFeminine` and `CheckCringe` in
|
||
`internal/phraser/eval/checks.go`, scored by `make eval-phrasing`.
|
||
|
||
**"Never phones home" is DEPRECATED** (owner's call, 2026-07-31). It used to be a hard
|
||
constraint and it is not one any more: a 0.8B — and a 1.7B — does not know enough to answer
|
||
world questions, so she needs to read external sources. What replaces it:
|
||
|
||
- **No telemetry, no cloud model, no third-party account.** That part never changes. Nothing
|
||
about Maven is reported to anyone, and inference stays on the box.
|
||
- **His data first, then the world.** Every source that reads his facts, notes, calendar,
|
||
tasks or house runs before anything outside, and the personal boundary sits between them.
|
||
Reading beats recalling for a small model.
|
||
- **In the world, live search leads and the ZIMs are the fallback** (owner's call,
|
||
2026-08-02). A self-hosted SearXNG (`search` block) answers first; the Kiwix ZIMs on
|
||
homesrv answer when the search is empty, unreachable, or the line is down.
|
||
- **External search is allowed and off unless configured**, like the weather and telegram
|
||
capabilities. The code default is still off. `deploy/mavend.json` now ships a `search`
|
||
block (owner's call, 2026-08-02), so it is on for this box and deleting the block turns
|
||
it off again.
|
||
- **His notes and facts are never search input.** Looking up why the sky is blue and sending
|
||
his stored personal notes to an upstream engine are different acts. Only the utterance goes
|
||
out, never the persona block, history, or matched notes.
|
||
|
||
## Web UI conventions
|
||
|
||
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the `nav`
|
||
partial (`navHTML` in `cmd/mavweb/main.go`, `{{template "nav" "<active-page>"}}`). No
|
||
per-page `<style>` beyond true one-offs. Wrap every table in `<div class=scroll>` so wide
|
||
data pans on a phone. Local preview + headless screenshot recipe is in `AGENTS.md`.
|
||
|
||
## Vikunja
|
||
|
||
This repo is project **Maven** (ID 2) in Vikunja. MCP: `http://localhost:9100/mcp` (or
|
||
`http://192.168.1.104:9100/mcp` from workpc). Feature/bug/deploy tasks go there.
|
||
|
||
Vikunja is the durable task store. A task holds the goal, the constraints and the
|
||
assumption ledger. Work without a task id is work nobody can resume, so a session that
|
||
has no id asks for one before it starts.
|
||
|
||
## Session workflow
|
||
|
||
`~/.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/
|
||
```
|
||
|
||
Around that, `/pickup` opens a session and `/wrap` closes it. Wrap at roughly half
|
||
context rather than letting the session compact.
|
||
|
||
Five stores, and each one owns something the others must not hold:
|
||
|
||
| Store | Holds | Lifetime |
|
||
|---|---|---|
|
||
| Vikunja task | goal, constraints, assumption ledger, status | durable |
|
||
| `CLAUDE.md`, `AGENTS.md` | what an agent must know before touching code | durable |
|
||
| `docs/` | design, measurements, decisions | durable |
|
||
| `TASK.md` | the brief for this branch, written by `task start`, immutable | one branch |
|
||
| `HANDOFF.md` | only what the next agent needs to resume | one session |
|
||
|
||
`TASK.md` and `.task/` are excluded through `.git/info/exclude`. `HANDOFF.md` is
|
||
gitignored and injected at session start. If a line in the handoff would still matter
|
||
next week, it is in the wrong file.
|
||
|
||
Docs are tiered by path, so staleness is visible from the filename. Files directly under
|
||
`docs/` are living and carry a `Last verified: <date> @ <sha>` line. Files under
|
||
`docs/evals/` are dated measurements and are never edited after the day, so a newer
|
||
number is a new file. Files under `docs/archive/` are dead and read by nobody by default.
|
||
|
||
## Git guards
|
||
|
||
Two hooks in `.githooks/`, tracked, wired with `core.hooksPath`. Fresh clone:
|
||
|
||
```sh
|
||
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.
|