diff --git a/AGENTS.md b/AGENTS.md index b65be95..d9a072e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,24 +74,24 @@ sudo cp onnxruntime-linux-x64-1.15.1/lib/libonnxruntime.so* /usr/local/lib/ Without the embedder block, the daemon uses `HashEmbedder` (works, but weak on Russian recall — you may see many "clarify" responses). -## LFM model for router + phraser (Thinking variant) +## Qwen3 resident model for router + phraser -The daemon uses a single resident LFM (sub-1B) for both routing (intent -classification + slot extraction) and phrasing (nudges, reminders, reactive -replies). Without it, the `StubPhraser` + `HashEmbedder` classifier are used -— deterministic but stiff (canned confirmations, weak Russian recall). +The target daemon uses the locally trained Qwen3-1.7B checkpoint for both +routing and phrasing. Training is Qwen3 Base → RU CPT → joint persona/router +SFT → merged GGUF. Without a configured model, `StubPhraser` plus the classifier +remain the deterministic floor. -**Download the model** (GGUF, ~780 MB): +During training, use the runbook in +`docs/plans/2026-07-18-qwen3-resident-training-eval.md`. After the decision gate +and SFT pass, copy the merged GGUF into the mounted model directory and set: -```sh -make download-llm -``` - -Or manually: - -```sh -curl -sL "https://huggingface.co/lfm/LFM2.5-1.2B-Thinking-GGUF/resolve/main/LFM2.5-1.2B-Thinking-Q4_K_M.gguf" \ - -o models/llm/LFM2.5-1.2B-Thinking-Q4_K_M.gguf +```json +"phraser": { + "model_path": "/opt/maven/models/llm/Qwen3-Maven-1.7B-Q8_0.gguf", + "bin_path": "llama-server", + "n_gpu_layers": 99, + "n_ctx": 2048 +} ``` **Configure in `deploy/mavend.json`** — the `phraser` block points at this @@ -101,7 +101,7 @@ replier use the same llama-server via the shared `internal/llm` client. Telegram tokens are read from `deploy/telegram.env` (gitignored), expanded via `${VAR}` in the JSON config. -**Routing is now LFM-first** with classifier fallback. The LLM router runs +**Routing is Qwen-first** with classifier fallback. The LLM router runs after stage-0 (exact-match grammar) and before the classifier cascade. On any error or parse failure, the classifier handles the utterance — the turn never breaks on the model. diff --git a/CLAUDE.md b/CLAUDE.md index cf80d2a..55a1a40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,149 +1,88 @@ -# Maven RU LoRA — training guide +# CLAUDE.md -Fine-tuning a small LLM so Maven speaks correct, in-character Russian (and English) -without breaking Cyrillic. Training scripts live in -`/home/kami/Programs/esp32-whisper-fine-tune/llm`; the model is consumed by this -daemon. +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Two machines — don't conflate them +Maven is a self-hosted, privacy-first voice assistant (Russian + English). Go daemons +talking over unix sockets; one resident Qwen3-1.7B for routing + phrasing; whisper.cpp STT, piper TTS. +Deploy target is a CPU-only Ryzen laptop (homesrv) — the resident model stays at 1.7B. See `REARCH.md` +for the target architecture and `AGENTS.md` for local-preview + model-download recipes. -| Role | Box | Specs | Constraint | -|---|---|---|---| -| **Train** | this workstation | RX 7900 GRE, gfx1100 (RDNA3), **16GB VRAM**, ROCm 7.2 / torch 2.10 HIP, `cuda.is_available()==True` | fits 1.5B bf16 LoRA with huge headroom | -| **Deploy** | homesrv `kami@192.168.1.104` | Ryzen 5 5600U (Zen3 6c/12t), Vega iGPU (Cezanne), **13GB RAM, already swapping** (~8.8GB avail), Ubuntu 24.04 | **CPU-only** inference; caps model at ~1.5B for voice latency | +## Build & test -**Path duality via SSHFS:** `/mnt/server/home/kami/` is an SSHFS mount from homesrv. So when you're in this repo: -- `/home/kami/Programs/` → **local** to workpc -- `/mnt/server/home/kami/` → **remote** homesrv filesystem -- `/mnt/server/home/kami/apps/Maven/` = this repo = remote filesystem accessed locally +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`: -**Inference router** (`localhost:6446`) isn't running on either machine. Check for a compose file (`docker compose up -d`) on whichever box has it before assuming it's available. - -- The Vega iGPU is **not** worth ROCm inference (gfx90c). Inference is CPU llama.cpp; - Vulkan can offload a few layers (`-ngl 4`-ish) for a small speedup. -- homesrv swaps already → every ~200MB of model footprint matters. Keep `--mlock` on. -- A 4B model at any quant is ~5–8 tok/s here = too slow for voice. **Stay ≤1.5B.** - -## Root cause of broken Russian - -Cyrillic breakage is a **base-model + quantization** problem, **not** a missing LoRA: -- Shipped models (`LFM2.5-1.2B-Thinking`, `Nemotron-3-Nano-4B`) are Western, thin-RU, - run at **Q4_K** → mangled endings, Latin/Cyrillic homoglyph swaps. -- A LoRA **cannot** add spelling knowledge the base lacks. It only steers - format / persona / mood / tool-schema / language-mirroring. - -**Levers, in order of impact:** -1. **RU-native base** — `Vikhrmodels/Vikhr-Qwen-2.5-1.5B-Instruct` (continued RU - pretraining on Qwen2.5). Same latency class as the current 1.2B, doesn't shatter - Cyrillic. *(Verify exact HF repo id before download — Vikhr naming shifts.)* -2. **Less aggressive quant** — deploy Q4_K_M first (~1.0GB, matches current - footprint); bump to Q5_K_M only if Cyrillic still breaks. RU-native base tolerates - Q4 far better than the Western models did. -3. **LoRA** — format/persona/mood/tools, on top of a base that already spells. - -## Output contract: `{response,mood}` (Decision B, 2026-07-11) - -All LLM output paths now produce `{"response":"...","mood":"..."}` — the Go side -(`replier_llm.go`, `llmphraser.go`) parses it in all 4 consumers. Fallback logic -preserves backward compat with plain text and the old `{"body","summary"}` format. - -| Consumer (file) | Now parses | -|---|---| -| Replier (`cmd/mavend/replier_llm.go`) | `{"response","mood"}` via `parseResponseMood` | -| Nudges (`internal/phraser/llmphraser.go`) | `{"response","mood"}` via `parseResponseMood` → `Body`, `Mood` on struct | -| Reminders (`internal/phraser/llmphraser.go`) | same, summary derived from response | -| Chat / query (`internal/phraser/llmphraser.go`) | `{"response","mood"}` → returns plain response text | - -Every training sample's assistant turn must produce `{"response":"...","mood":"..."}`. - -## Routing architecture: LLM-as-router (REARCH.md, target) - -Target arch = **LLM-as-router** (`REARCH.md`, supersedes the classifier-first -model). One resident model — the **CPT'd Qwen3-1.7B** (RU-CPT run; replaces LFM, -"too meh") — fills **both** router and phraser roles, two call-sites / two contracts: - -| Prompt | Contract | Source of truth | -|---|---|---| -| route-prompt | `{"intent":, key?, value?, text?, verb?}` GBNF-constrained | `internal/router/llmrouter.go` (`routeSystem`+`routeGrammar`) | -| phrase-prompt | `{"response","mood"}` | above section | - -7 intents: `fact, reminder, note, query, act, chat, system`. Key rule: -«запомни/запиши» = note, «напомни/не забудь» = reminder. Embedder is **demoted** -from router to a tool (RAG hint), not a threshold gate. - -**Status: phase 1 NOT done.** Plumbing exists (`LLMRouter`, `Route` cascade calls -it at `router.go:87`) but `voice.go:209` wires it `nil` — engine OFF, classifier -stopgap still active. Flip `nil` → `NewLLMRouter(qwen)` after CPT finishes. Do NOT -read the current committed code as the intended design — it's the interim stopgap. - -Route-training data: `esp32-whisper-fine-tune/llm/gen_route_data.py` relabels real -utterances through the verbatim `routeSystem` into `{intent,...}`. Keep its -`ROUTE_SYSTEM` in sync with the Go const. - -## Data defects measured in current corpus - -- **Mood collapse:** `neutral 1051, thinking 666, happy 280, confused 206, tired 7` - (of 2210). Graceful failure (`tired`) is essentially untrained — the most important - small-assistant behavior. Rebalance to ~10–15% combined `tired`+`confused`. -- **System-prompt drift:** 3 variants in `synthetic_dataset.jsonl` + a 4th in - `function_calling.jsonl`. Pick **one canonical system prompt = the exact string the - daemon sends at inference**, normalize all data to it. Train-prompt ≠ deploy-prompt - is a silent accuracy tax. -- **Dolphin trap:** `dolphin_to_messages` wraps ~3000 free-form paragraph outputs - under a strict-format system prompt → teaches the model the format is optional. - Drop it, or reshape via the distiller. Consistency > volume for format LoRAs. -- **`user-*.jsonl`** are bare message fragments (not wrapped in `{"messages":[...]}`); - the `"messages" in s` filter silently drops them. Wrap/normalize before use. - -## Target data recipe (RU-native base — spelling is NOT the job) - -Job = output contract + persona + mood mapping + tool schema + language mirroring. - -| Bucket | ~Share | Source | -|---|---|---| -| Persona chit-chat, on-contract | 45% | distill (Qwen3-4B / API): topic → canonical prompt → generate → validate (parses, mood∈enum, Cyrillic-clean, length) | -| Graceful failure / clarify | 15% | author unanswerable prompts → `tired`/`confused` (current hole) | -| Tool calls | 20% | expand `function_calling.jsonl` (473) — paraphrase 3–5× RU+EN, vary params | -| Real utterances | 10% | mine Maven dialogue history / logs, relabel | -| English → English | 10% | same buckets, EN in/out, so language-mirroring is trained | - -**Hygiene:** dedup near-dup user turns; hold out a *real* (non-distiller) eval set; -route all data through a validator rejecting mixed-script words + invalid JSON — the -same validator is the regression metric (Cyrillic eval). - -## Training (ROCm) — `esp32-whisper-fine-tune/llm/train_rocm.py` - -Adapted from `train_llama.py`; the ROCm fix is dropping the two CUDA-only pieces: -- **No bitsandbytes** — bf16 full weights (1.5B fits 16GB easily), no 4-bit BnB. -- `optim="adamw_torch_fused"` replaces `paged_adamw_8bit`. -- Base `Vikhr-Qwen-2.5-1.5B`; Qwen ChatML masking (`<|im_start|>assistant` / `<|im_end|>`). -- `attn_implementation="eager"` (flash-attn ROCm wheels flaky). -- `HSA_OVERRIDE_GFX_VERSION=11.0.0` belt-and-suspenders (gfx1100 is officially supported). -- batch 2 × grad-accum 4 (eff. 8), LoRA r16/α32, 3 epochs, grad checkpointing. - -## Deploy path (after training) - -```bash -# 1. merge adapter → bf16 weights -python -c "from peft import AutoPeftModelForCausalLM; import torch; \ -m=AutoPeftModelForCausalLM.from_pretrained('./Vikhr-Qwen-1.5b-ru-lora',torch_dtype=torch.bfloat16); \ -m.merge_and_unload().save_pretrained('./Vikhr-merged'); \ -from transformers import AutoTokenizer; AutoTokenizer.from_pretrained('./Vikhr-Qwen-1.5b-ru-lora').save_pretrained('./Vikhr-merged')" -# 2. HF → GGUF (in llama.cpp) -python convert_hf_to_gguf.py ./Vikhr-merged --outfile vikhr-maven-f16.gguf --outtype f16 -# 3. quantize (start Q4_K_M, test Cyrillic, bump to Q5_K_M if needed) -./llama-quantize vikhr-maven-f16.gguf Vikhr-Maven-1.5B-Q4_K_M.gguf Q4_K_M -# 4. scp to homesrv; point deploy/mavend.json phraser.model_path at it; --mlock, small -ngl on Vulkan +```sh +make build # all 8 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 ``` -## Open items +Run a single test (must carry the CGO env for packages that touch STT/TTS/voice): -- [x] **Decide output contract** — **B: `{response,mood}`**, Go side patched (2026-07-11). -- [x] **Base model** — Qwen3-1.7B, RU via **continued pretraining** (not Vikhr). CPT run in progress. -- [x] **Write `gen_data.py` distiller** — ran, produced `persona_train.jsonl` (2045) + eval (107), mood collapse fixed. -- [x] **Write `gen_route_data.py`** — route-schema relabeler (run when router up). -- [ ] **Turn router engine ON** — swap `voice.go:209` `nil` → `NewLLMRouter(qwen)` after CPT (REARCH phase 1). -- [ ] Run `gen_route_data.py`; train route-LoRA (or fold into persona SFT). -- [ ] Improve `routeSystem` prompt for sub-1B disambiguation (awaiting prompt-guy input). -- [ ] Write Cyrillic-validity + JSON eval (extend `llama-eval-test.py`). -- [ ] Normalize `user-*.jsonl` into `{"messages":[...]}`. +```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. | + +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. + +## Routing — read this before touching the router + +`internal/router/` has TWO layered engines and the committed default is an **interim +stopgap, not the intended design** (see memory `routing-architecture-target`): + +- **Target (REARCH.md):** LLM-as-router. One 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. +- **Current stopgap:** `llmrouter` is wired `nil` (around `voice.go`), so the + `classifier.go` + `embedder.go` nearest-neighbour cascade actually runs. It routes by + similarity to frozen seed phrases — the known cause of weak RU query handling. + +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. + +## 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":, 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) + +Never phones home. Not a nag, not autonomous. Maven's persona is **feminine** — Russian +self-reference must use feminine forms (the user is male; see memory `maven-persona-gender`). + +## 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" ""}}`). No +per-page ` {{template "shellBottom"}}` // handleChatPage renders the chat conversation page. diff --git a/cmd/mavweb/notifications.html b/cmd/mavweb/notifications.html index 62bd7ba..8d81cd9 100644 --- a/cmd/mavweb/notifications.html +++ b/cmd/mavweb/notifications.html @@ -10,7 +10,7 @@ {{.Message}} {{end}} {{else}}
- +
no notifications yet
check back later or ask maven a question
{{end}} diff --git a/cmd/mavweb/reminders.html b/cmd/mavweb/reminders.html index 195efcb..e0f6bb6 100644 --- a/cmd/mavweb/reminders.html +++ b/cmd/mavweb/reminders.html @@ -9,7 +9,7 @@ {{.Payload}} {{end}} {{else}}
- +
no reminders yet
ask maven to remind you of something
{{end}} diff --git a/cmd/mavweb/static/ethos-icons.svg b/cmd/mavweb/static/ethos-icons.svg new file mode 100644 index 0000000..dcfd188 --- /dev/null +++ b/cmd/mavweb/static/ethos-icons.svg @@ -0,0 +1,121 @@ + + + diff --git a/cmd/mavweb/static/mavweb.js b/cmd/mavweb/static/mavweb.js index ca74bf8..6903aa4 100644 --- a/cmd/mavweb/static/mavweb.js +++ b/cmd/mavweb/static/mavweb.js @@ -47,15 +47,15 @@ let paletteEl = null; let paletteInput = null; - // Page entries for the command palette + // Page entries for the command palette — uses ethos-icons.svg sprite const palettePages = [ - { label: 'Dashboard', url: '/dash', icon: '' }, - { label: 'History', url: '/history', icon: '' }, - { label: 'Rule Trace', url: '/trace', icon: '' }, - { label: 'Notifications', url: '/notifications', icon: '' }, - { label: 'Voice', url: '/', icon: '' }, - { label: 'Tools', url: '/tools', icon: '' }, - { label: 'Passkey', url: '/auth/passkey', icon: '' }, + { label: 'Dashboard', url: '/dash', icon: '' }, + { label: 'History', url: '/history', icon: '' }, + { label: 'Rule Trace', url: '/trace', icon: '' }, + { label: 'Notifications', url: '/notifications', icon: '' }, + { label: 'Voice', url: '/', icon: '' }, + { label: 'Tools', url: '/tools', icon: '' }, + { label: 'Passkey', url: '/auth/passkey', icon: '' }, ]; function buildPalette() { @@ -65,7 +65,7 @@ paletteEl.innerHTML = '
' + '
' + - '' + + '' + '' + '
' + '
' + diff --git a/cmd/mavweb/static/ui.css b/cmd/mavweb/static/ui.css index 2d67ad0..ee1eb0b 100644 --- a/cmd/mavweb/static/ui.css +++ b/cmd/mavweb/static/ui.css @@ -1,111 +1,169 @@ -/* ═══════════════════════════════════════════════ - Ethos design system for Maven - accent: warm violet #A78BFA · motif: breath dots - Tokens → layout → components → utilities - No literal colors, spacing, radii, or font sizes - in component code — everything comes from :root. - ═══════════════════════════════════════════════ */ +/* ═══════════════════════════════════════════════════════════════ + ETHOS — design tokens + Maven component styles + Single source of truth. No literal colors/spacing in component + code — everything comes from :root. + ═══════════════════════════════════════════════════════════════ */ + +/* ── Geist — self-hosted, no CDN ── */ +@font-face { + font-family: 'Geist'; + src: url('/fonts/Geist-Variable.woff2') format('woff2'); + font-weight: 100 900; font-display: swap; +} +@font-face { + font-family: 'Geist Mono'; + src: url('/fonts/GeistMono-Variable.woff2') format('woff2'); + font-weight: 100 900; font-display: swap; +} -/* ── Ethos Design Tokens (warm, brown-tinted neutral ramp) ── */ :root { - /* Neutrals — warm room, not cool grey */ - --ethos-bg0: #14110D; - --ethos-bg1: #1B1712; - --ethos-bg2: #221D17; - --ethos-surface0: #221D17; - --ethos-surface1: #2C261D; - --ethos-surface2: #372F24; - --ethos-border: rgba(244, 234, 220, 0.09); - --ethos-border-hi: rgba(244, 234, 220, 0.16); - --ethos-text: #F4EEE4; - --ethos-secondary: #B4AA98; - --ethos-muted: #9C917D; - --ethos-disabled: #756C5C; + /* ── neutral ramp — warm, brown-tinted (dark, default) ── */ + --bg-0: #14110D; + --bg-1: #1B1712; + --bg-2: #221D17; + --bg-3: #2C261D; + --bg-4: #372F24; - /* Accent — Maven: warm violet, single use (selected/focused/active) */ - --ethos-accent: #A78BFA; - --ethos-accent-hi: #C1AEFC; - --ethos-accent-dim: rgba(167, 139, 250, 0.14); - --ethos-accent-line: rgba(167, 139, 250, 0.32); - --ethos-accent-glow: rgba(167, 139, 250, 0.4); + --line: rgba(244, 234, 220, 0.09); + --line-hi: rgba(244, 234, 220, 0.16); - /* Semantic colors */ - --ethos-green: #6FBF8B; /* running / healthy / success */ - --ethos-amber: #E0A855; /* warning / paused */ - --ethos-red: #E0715F; /* failed / danger */ - --ethos-purple: #A78BFA; /* ai agents / llms — same family as accent */ - --ethos-cyan: #5EBFC4; /* network */ - --ethos-orange: #D9895A; /* build */ + --text-hi: #F4EEE4; + --text-mid: #B4AA98; + --text-lo: #756C5C; + --text-machine: #9C917D; - /* Typography — Geist for human copy, Geist Mono for machine values */ - --ethos-font: 'Geist', -apple-system, system-ui, sans-serif; - --ethos-mono: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace; + /* ── type ── */ + --sans: 'Geist', -apple-system, system-ui, sans-serif; + --mono: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace; - /* Font sizes */ - --ethos-fs-xs: 11px; - --ethos-fs-sm: 12px; - --ethos-fs-base: 14px; - --ethos-fs-lg: 16px; - --ethos-fs-xl: 18px; - --ethos-fs-2xl: 24px; - --ethos-fs-3xl: 32px; + /* ── motion ── */ + --ease: cubic-bezier(0.2, 0, 0, 1); + --fast: 130ms; + --med: 170ms; - /* Spacing — 8px grid */ - --ethos-space-1: 4px; - --ethos-space-2: 8px; - --ethos-space-3: 12px; - --ethos-space-4: 16px; - --ethos-space-5: 24px; - --ethos-space-6: 32px; - --ethos-space-7: 40px; - --ethos-space-8: 48px; - --ethos-space-9: 64px; + /* ── radii ── */ + --r-sm: 8px; + --r-md: 12px; + --r-lg: 18px; + --r-xl: 24px; - /* Radius */ - --ethos-radius: 8px; - --ethos-radius-lg: 12px; + /* ── elevation ── */ + --shadow-soft: 0 2px 8px rgba(0,0,0,0.35), 0 12px 32px rgba(0,0,0,0.28); + --shadow-lift: 0 4px 14px rgba(0,0,0,0.40), 0 20px 48px rgba(0,0,0,0.34); + + /* ── accent fallback (neutral) ── */ + --accent: var(--text-mid); + --accent-hi: var(--text-hi); + --accent-dim: rgba(244, 234, 220, 0.10); + --accent-line: rgba(244, 234, 220, 0.24); + --accent-glow: rgba(244, 234, 220, 0.14); + + /* ── semantic — shared status vocabulary ── */ + --green: #6FBF8B; + --amber: #E0A855; + --red: #E0715F; + --purple: #A78BFA; + --cyan: #5EBFC4; + --orange: #D9895A; + + /* ── spacing (8px grid) ── */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 24px; + --space-6: 32px; + --space-7: 40px; + --space-8: 48px; + --space-9: 64px; + + /* ── typography scale ── */ + --fs-xs: 11px; + --fs-sm: 13px; + --fs-base: 14px; + --fs-lg: 16px; + --fs-xl: 18px; + --fs-2xl: 24px; + --fs-3xl: 32px; + + /* ── legacy ethos-* aliases (migration compat) ── */ + --ethos-bg0: var(--bg-0); + --ethos-bg1: var(--bg-1); + --ethos-bg2: var(--bg-2); + --ethos-surface0: var(--bg-2); + --ethos-surface1: var(--bg-3); + --ethos-surface2: var(--bg-4); + --ethos-border: var(--line); + --ethos-border-hi: var(--line-hi); + --ethos-text: var(--text-hi); + --ethos-secondary: var(--text-mid); + --ethos-muted: var(--text-machine); + --ethos-disabled: var(--text-lo); + --ethos-font: var(--sans); + --ethos-mono: var(--mono); + --ethos-accent: var(--accent); + --ethos-accent-hi: var(--accent-hi); + --ethos-accent-dim: var(--accent-dim); + --ethos-accent-line: var(--accent-line); + --ethos-accent-glow: var(--accent-glow); + --ethos-green: var(--green); + --ethos-amber: var(--amber); + --ethos-red: var(--red); + --ethos-purple: var(--purple); + --ethos-cyan: var(--cyan); + --ethos-orange: var(--orange); + --ethos-fs-xs: var(--fs-xs); + --ethos-fs-sm: var(--fs-sm); + --ethos-fs-base: var(--fs-base); + --ethos-fs-lg: var(--fs-lg); + --ethos-fs-xl: var(--fs-xl); + --ethos-fs-2xl: var(--fs-2xl); + --ethos-fs-3xl: var(--fs-3xl); + --ethos-space-1: var(--space-1); + --ethos-space-2: var(--space-2); + --ethos-space-3: var(--space-3); + --ethos-space-4: var(--space-4); + --ethos-space-5: var(--space-5); + --ethos-space-6: var(--space-6); + --ethos-space-7: var(--space-7); + --ethos-space-8: var(--space-8); + --ethos-space-9: var(--space-9); + --ethos-radius: var(--r-sm); + --ethos-radius-lg: var(--r-md); --ethos-radius-chip: 999px; + --ethos-shadow-sm: 0 1px 2px rgba(0,0,0,0.3); + --ethos-shadow-md: var(--shadow-soft); + --ethos-motion-fast: var(--fast) var(--ease); + --ethos-motion: var(--med) var(--ease); +} - /* Shadows — soft warm light, no glass, no glow-blur */ - --ethos-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); - --ethos-shadow-md: 0 2px 8px rgba(0, 0, 0, 0.35), 0 12px 32px rgba(0, 0, 0, 0.28); +/* ── Maven accent — warm violet, AI-assistant personality ── */ +[data-app="maven"] { + --accent: #A78BFA; + --accent-hi: #C1AEFC; + --accent-dim: rgba(167, 139, 250, 0.14); + --accent-line: rgba(167, 139, 250, 0.32); + --accent-glow: rgba(167, 139, 250, 0.22); +} - /* Motion — mechanical, no bounce */ - --ethos-motion-fast: 130ms cubic-bezier(0.2, 0, 0, 1); - --ethos-motion: 170ms cubic-bezier(0.2, 0, 0, 1); +/* ── light theme ── */ +[data-theme="light"] { + --bg-0: #F1ECE3; + --bg-1: #EAE4D8; + --bg-2: #E2DACB; + --bg-3: #D7CDBB; + --bg-4: #C9BDA7; - /* Legacy aliases for backward compat during migration */ - --bg: var(--ethos-bg1); - --panel: var(--ethos-surface0); - --line: var(--ethos-border); - --border: var(--ethos-border); - --fg: var(--ethos-text); - --soft: var(--ethos-secondary); - --muted: var(--ethos-muted); - --dim: var(--ethos-disabled); - --accent: var(--ethos-accent); - --ok: var(--ethos-green); - --warn: var(--ethos-amber); - --err: var(--ethos-red); - --purple: var(--ethos-purple); - --surface-page: var(--ethos-bg0); - --surface-card: var(--ethos-surface0); - --surface-raised: var(--ethos-surface1); - --surface-input: var(--ethos-bg2); - --surface-hover: var(--ethos-surface1); - --fs-xs: var(--ethos-fs-xs); - --fs-sm: var(--ethos-fs-sm); - --fs-base: var(--ethos-fs-base); - --fs-lg: var(--ethos-fs-lg); - --fs-xl: var(--ethos-fs-xl); - --space-xs: var(--ethos-space-1); - --space-sm: var(--ethos-space-2); - --space-md: var(--ethos-space-3); - --space-lg: var(--ethos-space-4); - --space-xl: var(--ethos-space-5); - --radius-sm: var(--ethos-radius); - --radius-md: var(--ethos-radius); - --radius-lg: var(--ethos-radius); + --line: rgba(28, 22, 14, 0.10); + --line-hi: rgba(28, 22, 14, 0.18); + + --text-hi: #1B1712; + --text-mid: #544C3E; + --text-lo: #877E6C; + --text-machine: #6B6252; + + --shadow-soft: 0 2px 8px rgba(60,45,25,0.10), 0 12px 32px rgba(60,45,25,0.08); + --shadow-lift: 0 4px 14px rgba(60,45,25,0.12), 0 20px 48px rgba(60,45,25,0.10); } @media (prefers-reduced-motion: reduce) { @@ -115,39 +173,39 @@ /* ── Reset / Base ── */ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } body { - background: var(--ethos-bg0); - color: var(--ethos-text); - font: var(--ethos-fs-base)/1.5 var(--ethos-font); + background: var(--bg-0); + color: var(--text-hi); + font: var(--fs-base)/1.5 var(--sans); -webkit-text-size-adjust: 100%; min-height: 100vh; - overflow: hidden; /* shell handles scrolling */ + overflow: hidden; } -a { color: var(--ethos-accent); text-decoration: none; } +a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; } /* ── Typography ── */ h1 { - font-size: var(--ethos-fs-xl); + font-size: var(--fs-xl); font-weight: 500; - color: var(--ethos-text); - margin: 0 0 var(--ethos-space-4); + color: var(--text-hi); + margin: 0 0 var(--space-4); letter-spacing: -0.02em; } h2 { - font-size: var(--ethos-fs-base); + font-size: var(--fs-base); font-weight: 500; - color: var(--ethos-secondary); - margin: 0 0 var(--ethos-space-3); + color: var(--text-mid); + margin: 0 0 var(--space-3); } h3 { - font-size: var(--ethos-fs-sm); + font-size: var(--fs-sm); font-weight: 500; - color: var(--ethos-muted); - margin: 0 0 var(--ethos-space-2); + color: var(--text-machine); + margin: 0 0 var(--space-2); text-transform: uppercase; letter-spacing: 0.05em; } -h1 small, h2 small, h3 small { color: var(--ethos-muted); font-weight: 400; letter-spacing: 0; text-transform: none; } +h1 small, h2 small, h3 small { color: var(--text-machine); font-weight: 400; letter-spacing: 0; text-transform: none; } /* ── Shell Layout ── */ .shell { @@ -157,15 +215,15 @@ h1 small, h2 small, h3 small { color: var(--ethos-muted); font-weight: 400; lett overflow: hidden; } -/* Top Bar */ +/* TopBar — 56px per ethos anatomy */ .topbar { display: flex; align-items: center; - height: 40px; - padding: 0 var(--ethos-space-4); - background: var(--ethos-bg1); - border-bottom: 1px solid var(--ethos-border); - gap: var(--ethos-space-3); + height: 56px; + padding: 0 var(--space-4); + background: var(--bg-1); + border-bottom: 1px solid var(--line); + gap: var(--space-3); flex-shrink: 0; -webkit-app-region: drag; } @@ -173,18 +231,18 @@ h1 small, h2 small, h3 small { color: var(--ethos-muted); font-weight: 400; lett .breadcrumbs { display: flex; align-items: center; - gap: var(--ethos-space-2); - font-size: var(--ethos-fs-sm); - color: var(--ethos-muted); + gap: var(--space-2); + font-size: var(--fs-sm); + color: var(--text-machine); flex: 1; min-width: 0; } -.breadcrumbs .sep { color: var(--ethos-disabled); margin: 0 2px; } -.breadcrumbs .current { color: var(--ethos-text); font-weight: 500; } +.breadcrumbs .sep { color: var(--text-lo); margin: 0 2px; } +.breadcrumbs .current { color: var(--text-hi); font-weight: 500; } .topbar-actions { display: flex; align-items: center; - gap: var(--ethos-space-2); + gap: var(--space-2); } /* Connection status dot */ @@ -192,12 +250,12 @@ h1 small, h2 small, h3 small { color: var(--ethos-muted); font-weight: 400; lett display: inline-flex; align-items: center; gap: 4px; - font-size: var(--ethos-fs-xs); - color: var(--ethos-muted); + font-size: var(--fs-xs); + color: var(--text-machine); } .conn-status .dot { width: 6px; height: 6px; border-radius: 50%; } -.conn-status .dot.online { background: var(--ethos-green); } -.conn-status .dot.offline { background: var(--ethos-disabled); } +.conn-status .dot.online { background: var(--green); } +.conn-status .dot.offline { background: var(--text-lo); } /* Shell Body — sidebar + content + inspector */ .shell-body { @@ -210,41 +268,41 @@ h1 small, h2 small, h3 small { color: var(--ethos-muted); font-weight: 400; lett .sidebar { width: 200px; min-width: 200px; - background: var(--ethos-bg1); - border-right: 1px solid var(--ethos-border); + background: var(--bg-1); + border-right: 1px solid var(--line); display: flex; flex-direction: column; overflow-y: auto; - padding: var(--ethos-space-3) 0; + padding: var(--space-3) 0; flex-shrink: 0; } -.sidebar-section { margin-bottom: var(--ethos-space-3); } +.sidebar-section { margin-bottom: var(--space-3); } .sidebar-label { - font-size: var(--ethos-fs-xs); - color: var(--ethos-muted); + font-size: var(--fs-xs); + color: var(--text-machine); text-transform: uppercase; letter-spacing: 0.08em; - padding: var(--ethos-space-1) var(--ethos-space-4); - margin-bottom: var(--ethos-space-1); + padding: var(--space-1) var(--space-4); + margin-bottom: var(--space-1); font-weight: 500; } .sidebar a { display: flex; align-items: center; - gap: var(--ethos-space-2); - padding: var(--ethos-space-1) var(--ethos-space-4); - font-size: var(--ethos-fs-sm); - color: var(--ethos-secondary); + gap: var(--space-2); + padding: var(--space-1) var(--space-4); + font-size: var(--fs-sm); + color: var(--text-mid); text-decoration: none; border-left: 2px solid transparent; height: 28px; - transition: background var(--ethos-motion), color var(--ethos-motion), border-color var(--ethos-motion); + transition: background var(--med) var(--ease), color var(--med) var(--ease), border-color var(--med) var(--ease); } -.sidebar a:hover { background: var(--ethos-surface-hover); color: var(--ethos-text); text-decoration: none; } +.sidebar a:hover { background: var(--bg-3); color: var(--text-hi); text-decoration: none; } .sidebar a.active { - color: var(--ethos-accent); - background: var(--ethos-accent-dim); - border-left-color: var(--ethos-accent); + color: var(--accent); + background: var(--accent-dim); + border-left-color: var(--accent); } .sidebar a .icon { width: 14px; height: 14px; flex-shrink: 0; opacity: 0.7; } .sidebar a.active .icon { opacity: 1; } @@ -252,20 +310,21 @@ h1 small, h2 small, h3 small { color: var(--ethos-muted); font-weight: 400; lett /* Main Content */ .content { flex: 1; + min-width: 0; /* let this flex item shrink below the tables' intrinsic width + so wide content pans inside .scroll instead of overflowing */ overflow-y: auto; overflow-x: hidden; - padding: var(--ethos-space-5); - background: var(--ethos-bg0); + padding: var(--space-5); + background: var(--bg-0); } -.content-wide { max-width: 960px; } -/* Inspector Panel (right side) */ +/* Inspector Panel */ .inspector { width: 0; overflow: hidden; - background: var(--ethos-bg1); - border-left: 1px solid var(--ethos-border); - transition: width var(--ethos-motion); + background: var(--bg-1); + border-left: 1px solid var(--line); + transition: width var(--med) var(--ease); flex-shrink: 0; } .inspector.open { width: 400px; } @@ -280,28 +339,28 @@ h1 small, h2 small, h3 small { color: var(--ethos-muted); font-weight: 400; lett display: flex; align-items: center; justify-content: space-between; - padding: var(--ethos-space-3) var(--ethos-space-4); - border-bottom: 1px solid var(--ethos-border); - font-size: var(--ethos-fs-sm); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--line); + font-size: var(--fs-sm); font-weight: 500; - color: var(--ethos-text); + color: var(--text-hi); flex-shrink: 0; } .inspector-close { background: none; border: none; - color: var(--ethos-muted); + color: var(--text-machine); cursor: pointer; padding: 4px; - border-radius: var(--ethos-radius); + border-radius: var(--r-sm); line-height: 1; } -.inspector-close:hover { color: var(--ethos-text); background: var(--ethos-surface-hover); } +.inspector-close:hover { color: var(--text-hi); background: var(--bg-3); } .inspector-body { flex: 1; overflow-y: auto; - padding: var(--ethos-space-4); - font-size: var(--ethos-fs-sm); + padding: var(--space-4); + font-size: var(--fs-sm); } /* ── Command Palette ── */ @@ -316,17 +375,17 @@ h1 small, h2 small, h3 small { color: var(--ethos-muted); font-weight: 400; lett padding-top: 12vh; opacity: 0; pointer-events: none; - transition: opacity var(--ethos-motion); + transition: opacity var(--med) var(--ease); } .cmd-palette-overlay.open { opacity: 1; pointer-events: auto; } .cmd-palette { width: 560px; max-width: 90vw; max-height: 60vh; - background: var(--ethos-surface0); - border: 1px solid var(--ethos-border); - border-radius: var(--ethos-radius); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + background: var(--bg-2); + border: 1px solid var(--line); + border-radius: var(--r-md); + box-shadow: var(--shadow-lift); display: flex; flex-direction: column; overflow: hidden; @@ -334,226 +393,214 @@ h1 small, h2 small, h3 small { color: var(--ethos-muted); font-weight: 400; lett .cmd-palette-input { display: flex; align-items: center; - padding: var(--ethos-space-3) var(--ethos-space-4); - border-bottom: 1px solid var(--ethos-border); - gap: var(--ethos-space-2); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--line); + gap: var(--space-2); } .cmd-palette-input input { flex: 1; background: transparent; border: none; - color: var(--ethos-text); - font: var(--ethos-fs-base) var(--ethos-font); + color: var(--text-hi); + font: var(--fs-base) var(--sans); outline: none; } -.cmd-palette-input input::placeholder { color: var(--ethos-disabled); } +.cmd-palette-input input::placeholder { color: var(--text-lo); } .cmd-palette-list { flex: 1; overflow-y: auto; - padding: var(--ethos-space-1) 0; + padding: var(--space-1) 0; } .cmd-palette-item { display: flex; align-items: center; - gap: var(--ethos-space-2); - padding: var(--ethos-space-2) var(--ethos-space-4); - color: var(--ethos-secondary); - font-size: var(--ethos-fs-sm); + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + color: var(--text-mid); + font-size: var(--fs-sm); cursor: pointer; - transition: background var(--ethos-motion-fast); + transition: background var(--fast) var(--ease); text-decoration: none; } .cmd-palette-item:hover, -.cmd-palette-item.selected { background: var(--ethos-surface-hover); color: var(--ethos-text); text-decoration: none; } +.cmd-palette-item.selected { background: var(--bg-3); color: var(--text-hi); text-decoration: none; } .cmd-palette-item .kbd { margin-left: auto; - font-size: var(--ethos-fs-xs); - color: var(--ethos-disabled); - font-family: var(--ethos-mono); + font-size: var(--fs-xs); + color: var(--text-lo); + font-family: var(--mono); } -/* ── Search Bar (inline, slim) ── */ +/* ── Search Trigger ── */ .search-trigger { display: inline-flex; align-items: center; gap: 6px; - background: var(--ethos-bg2); - border: 1px solid var(--ethos-border); - border-radius: var(--ethos-radius); + background: var(--bg-2); + border: 1px solid var(--line); + border-radius: var(--r-sm); padding: 2px 8px; - font-size: var(--ethos-fs-xs); - color: var(--ethos-muted); + font-size: var(--fs-xs); + color: var(--text-machine); cursor: pointer; - height: 24px; - transition: border-color var(--ethos-motion-fast); + height: 26px; + transition: border-color var(--fast) var(--ease); } -.search-trigger:hover { border-color: var(--ethos-muted); } +.search-trigger:hover { border-color: var(--text-machine); } .search-trigger .kbd-hint { - font-family: var(--ethos-mono); - color: var(--ethos-disabled); + font-family: var(--mono); + color: var(--text-lo); font-size: 10px; } /* ── Cards ── */ .card { - background: var(--ethos-surface0); - border: 1px solid var(--ethos-border); - border-radius: var(--ethos-radius); - padding: var(--ethos-space-3) var(--ethos-space-4); - margin-bottom: var(--ethos-space-4); + background: var(--bg-1); + border: 1px solid var(--line); + border-radius: var(--r-md); + padding: var(--space-3) var(--space-4); + margin-bottom: var(--space-4); } .card-title { - font-size: var(--ethos-fs-sm); + font-size: var(--fs-sm); font-weight: 500; - color: var(--ethos-secondary); - margin: 0 0 var(--ethos-space-2); + color: var(--text-mid); + margin: 0 0 var(--space-2); display: flex; align-items: center; - gap: var(--ethos-space-2); + gap: var(--space-2); } .card-sub { - font-size: var(--ethos-fs-xs); - color: var(--ethos-muted); - margin: 0 0 var(--ethos-space-2); + font-size: var(--fs-xs); + color: var(--text-machine); + margin: 0 0 var(--space-2); } .card-actions { display: flex; align-items: center; - gap: var(--ethos-space-2); - margin-top: var(--ethos-space-3); + gap: var(--space-2); + margin-top: var(--space-3); } /* ── Tables ── */ -.scroll { overflow-x: auto; margin-bottom: var(--ethos-space-2); } +.scroll { overflow-x: auto; margin-bottom: var(--space-2); } table { width: 100%; border-collapse: collapse; } th, td { - padding: var(--ethos-space-1) var(--ethos-space-2); + padding: var(--space-1) var(--space-2); text-align: left; vertical-align: top; - border-bottom: 1px solid var(--ethos-border); + border-bottom: 1px solid var(--line); } th { - color: var(--ethos-muted); - font-size: var(--ethos-fs-xs); + color: var(--text-machine); + font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: 0.05em; white-space: nowrap; font-weight: 500; - padding-top: var(--ethos-space-2); - padding-bottom: var(--ethos-space-1); + padding-top: var(--space-2); + padding-bottom: var(--space-1); } -td { color: var(--ethos-secondary); font-size: var(--ethos-fs-sm); } -tr:hover td { background: var(--ethos-surface-hover); } +td { color: var(--text-mid); font-size: var(--fs-sm); } +tr:hover td { background: var(--bg-3); } tr.clickable { cursor: pointer; } -.mono { font-family: var(--ethos-mono); } +.mono { font-family: var(--mono); font-feature-settings: "tnum" 1, "zero" 1; letter-spacing: -0.01em; } code, .key { - font-family: var(--ethos-mono); - color: var(--ethos-accent); - font-size: var(--ethos-fs-sm); + font-family: var(--mono); + color: var(--accent); + font-size: var(--fs-sm); + font-feature-settings: "tnum" 1, "zero" 1; } /* ── Status Vocabulary ── */ -/* Only these literals: Healthy, Running, Starting, Stopping, Stopped, - Paused, Waiting, Queued, Completed, Failed, Warning, Offline, Unknown */ .status-Healthy, .status-Running, -.status-Completed { color: var(--ethos-green); } +.status-Completed, +.green { color: var(--green); } .status-Starting, .status-Waiting, -.status-Queued { color: var(--ethos-amber); } - -.status-Stopping { color: var(--ethos-amber); } -.status-Stopped, +.status-Queued, +.status-Stopping, +.status-Warning, .status-Paused, -.status-Offline { color: var(--ethos-disabled); } +.amber { color: var(--amber); } -.status-Failed { color: var(--ethos-red); } -.status-Warning { color: var(--ethos-amber); } -.status-Unknown { color: var(--ethos-muted); } +.status-Stopped, +.status-Offline, +.gray { color: var(--text-lo); } -/* Legacy status aliases — mapped to ethos vocab intent during migration */ -.green, .pres, .acted { color: var(--ethos-green); } -.pending, .away, .gray { color: var(--ethos-disabled); } -.snoozed { color: var(--ethos-amber); } -.ignored, .red { color: var(--ethos-red); } -.updated, .muted { color: var(--ethos-muted); font-size: var(--ethos-fs-xs); } +.status-Failed, +.red { color: var(--red); } -.empty { - color: var(--ethos-disabled); - padding: var(--ethos-space-8) var(--ethos-space-4); - text-align: center; - font-size: var(--ethos-fs-sm); - display: flex; - flex-direction: column; - align-items: center; - gap: var(--ethos-space-3); -} -.empty .icon { opacity: 0.4; margin-bottom: var(--ethos-space-1); } -.empty .action { margin-top: var(--ethos-space-2); } -.voided { opacity: 0.45; text-decoration: line-through; } -.void-badge { color: var(--ethos-muted); font-size: var(--ethos-fs-xs); margin-right: var(--ethos-space-1); } +.status-Unknown { color: var(--text-machine); } + +.pres, .acted { color: var(--green); } +.pending, .away { color: var(--text-lo); } +.snoozed { color: var(--amber); } +.ignored { color: var(--red); } +.updated, .muted { color: var(--text-machine); font-size: var(--fs-xs); } /* ── Status Dot ── */ .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; vertical-align: middle; flex-shrink: 0; } -.dot-ok { background: var(--ethos-green); } -.dot-warn { background: var(--ethos-amber); } -.dot-err { background: var(--ethos-red); } -.dot-dim { background: var(--ethos-disabled); } -.dot-purple { background: var(--ethos-purple); } -.dot-cyan { background: var(--ethos-cyan); } -.dot-online { background: var(--ethos-green); box-shadow: 0 0 6px var(--ethos-green); } -.dot-offline { background: var(--ethos-disabled); } +.dot-ok, .dot-online { background: var(--green); } +.dot-warn { background: var(--amber); } +.dot-err { background: var(--red); } +.dot-dim { background: var(--text-lo); } +.dot-purple { background: var(--purple); } +.dot-cyan { background: var(--cyan); } +.dot-offline { background: var(--text-lo); } /* ── Badges ── */ .badge { - font-size: var(--ethos-fs-xs); - color: var(--ethos-secondary); - background: var(--ethos-surface1); + font-size: var(--fs-xs); + color: var(--text-mid); + background: var(--bg-3); padding: 2px 6px; - border-radius: var(--ethos-radius-chip); + border-radius: 999px; white-space: nowrap; font-weight: 500; } -.badge-accent { color: var(--ethos-accent-hi); background: var(--ethos-accent-dim); } -.badge-ok { color: var(--ethos-green); background: rgba(111, 191, 139, 0.14); } -.badge-warn { color: var(--ethos-amber); background: rgba(224, 168, 85, 0.14); } -.badge-err { color: var(--ethos-red); background: rgba(224, 113, 95, 0.14); } -.badge-purple { color: var(--ethos-purple); background: var(--ethos-accent-dim); } +.badge-accent { color: var(--accent-hi); background: var(--accent-dim); } +.badge-ok { color: var(--green); background: rgba(111, 191, 139, 0.14); } +.badge-warn { color: var(--amber); background: rgba(224, 168, 85, 0.14); } +.badge-err { color: var(--red); background: rgba(224, 113, 95, 0.14); } /* ── Buttons ── */ .btn { font: inherit; - font-size: var(--ethos-fs-sm); + font-size: var(--fs-sm); background: transparent; - color: var(--ethos-secondary); - border: 1px solid var(--ethos-border); - border-radius: var(--ethos-radius); - padding: var(--ethos-space-1) var(--ethos-space-3); + color: var(--text-mid); + border: 1px solid var(--line); + border-radius: var(--r-sm); + padding: var(--space-1) var(--space-3); cursor: pointer; - transition: border-color var(--ethos-motion), background var(--ethos-motion), color var(--ethos-motion); + transition: border-color var(--med) var(--ease), background var(--med) var(--ease), color var(--med) var(--ease); display: inline-flex; align-items: center; - gap: var(--ethos-space-1); + gap: var(--space-1); height: 28px; white-space: nowrap; } -.btn:hover { border-color: var(--ethos-muted); color: var(--ethos-text); background: var(--ethos-surface-hover); } +.btn:hover { border-color: var(--text-machine); color: var(--text-hi); background: var(--bg-3); } .btn:disabled { opacity: 0.4; cursor: not-allowed; } -.btn:focus-visible { outline: 2px solid var(--ethos-accent); outline-offset: 2px; } +.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .btn-primary { - background: var(--ethos-accent); - color: var(--ethos-bg0); - border-color: var(--ethos-accent); + background: var(--accent); + color: var(--bg-0); + border-color: var(--accent); font-weight: 500; } -.btn-primary:hover { background: var(--ethos-accent-hi); border-color: var(--ethos-accent-hi); color: var(--ethos-bg0); } -.btn-danger { border-color: var(--ethos-red); color: var(--ethos-red); } -.btn-danger:hover { border-color: var(--ethos-red); background: rgba(224, 113, 95, 0.14); color: var(--ethos-red); } -.btn-ghost { border-color: transparent; color: var(--ethos-muted); } -.btn-ghost:hover { border-color: transparent; background: var(--ethos-surface-hover); color: var(--ethos-text); } -.btn-sm { height: 22px; font-size: var(--ethos-fs-xs); padding: 0 var(--ethos-space-2); } -.btn-lg { height: 34px; font-size: var(--ethos-fs-base); padding: var(--ethos-space-1) var(--ethos-space-4); } +.btn-primary:hover { background: var(--accent-hi); border-color: var(--accent-hi); color: var(--bg-0); } +.btn-danger { border-color: var(--red); color: var(--red); } +.btn-danger:hover { border-color: var(--red); background: rgba(224, 113, 95, 0.14); color: var(--red); } +.btn-ghost { border-color: transparent; color: var(--text-machine); } +.btn-ghost:hover { border-color: transparent; background: var(--bg-3); color: var(--text-hi); } +.btn-sm { height: 22px; font-size: var(--fs-xs); padding: 0 var(--space-2); } +.btn-lg { height: 34px; font-size: var(--fs-base); padding: var(--space-1) var(--space-4); } +.btn-muted { border-color: transparent; color: var(--text-machine); } +.btn-muted:hover { border-color: transparent; color: var(--text-mid); } /* ── Icon Button ── */ .icon-btn { @@ -564,76 +611,139 @@ code, .key { height: 26px; border: none; background: transparent; - color: var(--ethos-muted); + color: var(--text-machine); cursor: pointer; - border-radius: var(--ethos-radius); - transition: background var(--ethos-motion-fast), color var(--ethos-motion-fast); + border-radius: var(--r-sm); + transition: background var(--fast) var(--ease), color var(--fast) var(--ease); } -.icon-btn:hover { background: var(--ethos-surface-hover); color: var(--ethos-text); } -.icon-btn:focus-visible { outline: 2px solid var(--ethos-accent); outline-offset: 2px; } +.icon-btn:hover { background: var(--bg-3); color: var(--text-hi); } +.icon-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* ── Inline form ── */ +.inline-form { display: inline; } /* ── Form Elements ── */ input[type=text], input[type=url], input[type=search], select, textarea { font: inherit; - font-size: var(--ethos-fs-sm); - background: var(--ethos-bg2); - color: var(--ethos-text); - border: 1px solid var(--ethos-border); - border-radius: var(--ethos-radius); - padding: var(--ethos-space-1) var(--ethos-space-2); + font-size: var(--fs-sm); + background: var(--bg-2); + color: var(--text-hi); + border: 1px solid var(--line); + border-radius: var(--r-sm); + padding: var(--space-1) var(--space-2); max-width: 100%; - transition: border-color var(--ethos-motion-fast); + transition: border-color var(--fast) var(--ease); height: 28px; } input[type=text]:focus, input[type=url]:focus, input[type=search]:focus, select:focus, textarea:focus { - border-color: var(--ethos-accent); + border-color: var(--accent); outline: none; } -input[type=checkbox] { accent-color: var(--ethos-accent); width: 14px; height: 14px; vertical-align: middle; } -label { color: var(--ethos-secondary); cursor: pointer; display: inline-flex; align-items: center; gap: var(--ethos-space-1); font-size: var(--ethos-fs-sm); } +input[type=checkbox] { accent-color: var(--accent); width: 14px; height: 14px; vertical-align: middle; } +label { color: var(--text-mid); cursor: pointer; display: inline-flex; align-items: center; gap: var(--space-1); font-size: var(--fs-sm); } textarea { height: auto; min-height: 60px; } -/* ── Messages / Feedback ── */ +/* ── Messages ── */ .msg { - margin: var(--ethos-space-2) 0; - padding: var(--ethos-space-2) var(--ethos-space-3); - border-radius: var(--ethos-radius); - border: 1px solid var(--ethos-border); - font-size: var(--ethos-fs-sm); - color: var(--ethos-secondary); + margin: var(--space-2) 0; + padding: var(--space-2) var(--space-3); + border-radius: var(--r-sm); + border: 1px solid var(--line); + font-size: var(--fs-sm); + color: var(--text-mid); } -.msg-ok { border-color: var(--ethos-green); color: var(--ethos-green); } -.msg-err { border-color: var(--ethos-red); color: var(--ethos-red); } +.msg-ok { border-color: var(--green); color: var(--green); } +.msg-err { border-color: var(--red); color: var(--red); } #msg { white-space: pre-wrap; } -/* ── Collapsible Details ── */ -details { color: var(--ethos-secondary); font-size: var(--ethos-fs-sm); } -summary { cursor: pointer; color: var(--ethos-muted); transition: color var(--ethos-motion); user-select: none; } -summary:hover { color: var(--ethos-text); } -details[open] { margin-top: var(--ethos-space-1); } -details[open] > summary { margin-bottom: var(--ethos-space-1); } +/* ── Empty States ── */ +.empty { + color: var(--text-lo); + padding: var(--space-8) var(--space-4); + text-align: center; + font-size: var(--fs-sm); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-3); +} +.empty .icon { opacity: 0.4; margin-bottom: var(--space-1); } +.empty .action { margin-top: var(--space-2); } -/* ── Skeleton Loading ── */ +/* ── Voided / stale rows ── */ +.voided { opacity: 0.45; text-decoration: line-through; } +.void-badge { color: var(--text-machine); font-size: var(--fs-xs); margin-right: var(--space-1); } + +/* ── Collapsible Details ── */ +details { color: var(--text-mid); font-size: var(--fs-sm); } +summary { cursor: pointer; color: var(--text-machine); transition: color var(--med) var(--ease); user-select: none; } +summary:hover { color: var(--text-hi); } +details[open] { margin-top: var(--space-1); } +details[open] > summary { margin-bottom: var(--space-1); } + +/* ── Hint / Tooltip ── */ +.hint { font-size: var(--fs-xs); color: var(--text-machine); } +.hint-icon { cursor: help; color: var(--text-lo); } + +/* ── Section header ── */ +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-3); + gap: var(--space-3); +} +.section-header h2 { margin-bottom: 0; } + +/* ── Chat message styles ── */ +.chat-msg { padding: var(--space-2) var(--space-3); border-radius: var(--r-sm); margin-bottom: var(--space-1); } +.chat-msg.user { background: var(--bg-3); } +.chat-msg.assistant { background: var(--bg-2); } +.chat-form { display: flex; gap: var(--space-2); } +.chat-form input { flex: 1; } +.chat-scroll { max-height: 60vh; overflow-y: auto; margin-bottom: var(--space-4); } + +/* ── Key-value grid ── */ +.kv { display: grid; grid-template-columns: auto 1fr; gap: var(--space-1) var(--space-3); font-size: var(--fs-sm); } +.kv dt { color: var(--text-machine); white-space: nowrap; } +.kv dd { color: var(--text-mid); word-break: break-all; } + +/* ── Notification item ── */ +.nudge-item { + display: flex; + gap: var(--space-3); + padding: var(--space-2) 0; + border-bottom: 1px solid var(--line); + align-items: flex-start; +} +.nudge-item:last-child { border-bottom: none; } +.nudge-icon { flex-shrink: 0; margin-top: 2px; } +.nudge-body { flex: 1; min-width: 0; } +.nudge-rule { font-size: var(--fs-xs); color: var(--text-machine); } +.nudge-msg { font-size: var(--fs-sm); color: var(--text-mid); margin-top: 2px; } +.nudge-meta { display: flex; gap: var(--space-2); align-items: center; margin-top: var(--space-1); } + +/* ── Skeleton ── */ .skeleton { - background: linear-gradient(90deg, var(--ethos-surface0) 25%, var(--ethos-surface1) 50%, var(--ethos-surface0) 75%); + background: linear-gradient(90deg, var(--bg-2) 25%, var(--bg-3) 50%, var(--bg-2) 75%); background-size: 200% 100%; animation: skeleton-shimmer 1.5s ease-in-out infinite; - border-radius: var(--ethos-radius); + border-radius: var(--r-sm); } @keyframes skeleton-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } -.skeleton-line { height: 12px; margin-bottom: var(--ethos-space-2); width: 100%; } +.skeleton-line { height: 12px; margin-bottom: var(--space-2); width: 100%; } .skeleton-line:last-child { width: 60%; } -/* ── Keyboard Shortcut Hints ── */ +/* ── Keyboard hints ── */ kbd, .kbd { display: inline-flex; align-items: center; justify-content: center; - font-family: var(--ethos-mono); + font-family: var(--mono); font-size: 10px; - color: var(--ethos-disabled); - background: var(--ethos-bg2); - border: 1px solid var(--ethos-border); + color: var(--text-lo); + background: var(--bg-2); + border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; min-width: 18px; @@ -641,39 +751,50 @@ kbd, .kbd { line-height: 1; } -/* ── Notification / Nudge item ── */ -.nudge-item { - display: flex; - gap: var(--ethos-space-3); - padding: var(--ethos-space-2) 0; - border-bottom: 1px solid var(--ethos-border); - align-items: flex-start; +/* ── Focus visible ── */ +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* ── Selection ── */ +::selection { background: var(--accent-dim); color: var(--accent-hi); } + +/* ── Voice page ── */ +.voice-page { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-5); padding: var(--space-4) 0; } +.voice-btn { + width: 140px; height: 140px; border-radius: 50%; border: 3px solid var(--accent); + background: var(--bg-1); color: var(--accent); font-size: 1rem; cursor: pointer; + display: flex; align-items: center; justify-content: center; + transition: all var(--med) var(--ease); + user-select: none; -webkit-tap-highlight-color: transparent; touch-action: manipulation; } -.nudge-item:last-child { border-bottom: none; } -.nudge-icon { flex-shrink: 0; margin-top: 2px; } -.nudge-body { flex: 1; min-width: 0; } -.nudge-rule { font-size: var(--ethos-fs-xs); color: var(--ethos-muted); } -.nudge-msg { font-size: var(--ethos-fs-sm); color: var(--ethos-secondary); margin-top: 2px; } -.nudge-meta { display: flex; gap: var(--ethos-space-2); align-items: center; margin-top: var(--ethos-space-1); } +.voice-btn:active, .voice-btn.active { background: var(--accent-dim); border-color: var(--green); color: var(--green); transform: scale(1.05); } +.voice-btn:disabled { opacity: 0.3; border-color: var(--line); } +.voice-status { font-size: var(--fs-sm); color: var(--text-machine); min-height: 1.2em; } +.voice-log { width: 100%; max-width: 480px; max-height: 40vh; overflow-y: auto; font-size: var(--fs-sm); color: var(--text-machine); line-height: 1.6; padding: var(--space-2); border-top: 1px solid var(--line); } +.voice-log .reply { color: var(--green); } +.voice-log .error { color: var(--red); } +.voice-log .push { color: var(--purple); } +.voice-cheat { width: 100%; max-width: 480px; font-size: var(--fs-sm); border-top: 1px solid var(--line); color: var(--text-machine); } +.voice-cheat summary { cursor: pointer; padding: var(--space-2) 0; color: var(--accent); letter-spacing: 0.05em; text-transform: uppercase; font-size: var(--fs-xs); user-select: none; } +.voice-cheat dl { display: grid; grid-template-columns: auto 1fr; gap: var(--space-1) var(--space-3); padding: var(--space-1) 0 var(--space-3); } +.voice-cheat dt { color: var(--green); font-weight: 500; white-space: nowrap; } +.voice-cheat dd { color: var(--text-mid); line-height: 1.5; } +.voice-cheat dd span { color: var(--text-machine); } +.voice-cheat .en { display: revert; } +.voice-cheat .ru { display: none; } +html[data-lang=ru] .voice-cheat .en { display: none; } +html[data-lang=ru] .voice-cheat .ru { display: revert; } +.voice-lang { display: flex; gap: 0; justify-content: center; margin-top: var(--space-4); } +.voice-lang span { font-size: var(--fs-xs); color: var(--text-machine); cursor: pointer; padding: 0.2rem 0.5rem; letter-spacing: 0.1em; text-transform: uppercase; border: 1px solid var(--line); transition: color var(--med) var(--ease); user-select: none; } +.voice-lang span:first-child { border-radius: var(--r-sm) 0 0 var(--r-sm); border-right: none; } +.voice-lang span:last-child { border-radius: 0 var(--r-sm) var(--r-sm) 0; border-left: none; } +.voice-lang span.active { color: var(--accent); border-color: var(--accent); background: var(--accent-dim); } +.voice-lang span:hover { color: var(--text-hi); } -/* ── Tooltip / Hint ── */ -.hint { font-size: var(--ethos-fs-xs); color: var(--ethos-muted); } -.hint-icon { cursor: help; color: var(--ethos-disabled); } - -/* ── Key-value pairs ── */ -.kv { display: grid; grid-template-columns: auto 1fr; gap: var(--ethos-space-1) var(--ethos-space-3); font-size: var(--ethos-fs-sm); } -.kv dt { color: var(--ethos-muted); white-space: nowrap; } -.kv dd { color: var(--ethos-secondary); word-break: break-all; } - -/* ── Section (page section header) ── */ -.section-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: var(--ethos-space-3); - gap: var(--ethos-space-3); -} -.section-header h2 { margin-bottom: 0; } +/* ── Scrollbar ── */ +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--bg-4); border-radius: 999px; } +::-webkit-scrollbar-thumb:hover { background: var(--text-lo); } /* ── Utilities ── */ .val { word-break: break-all; max-width: 20rem; } @@ -682,37 +803,34 @@ kbd, .kbd { .flex { display: flex; } .flex-col { display: flex; flex-direction: column; } .items-center { align-items: center; } -.gap-1 { gap: var(--ethos-space-1); } -.gap-2 { gap: var(--ethos-space-2); } -.gap-3 { gap: var(--ethos-space-3); } -.gap-4 { gap: var(--ethos-space-4); } -.mt-1 { margin-top: var(--ethos-space-1); } -.mt-2 { margin-top: var(--ethos-space-2); } -.mt-3 { margin-top: var(--ethos-space-3); } -.mt-4 { margin-top: var(--ethos-space-4); } -.mb-1 { margin-bottom: var(--ethos-space-1); } -.mb-2 { margin-bottom: var(--ethos-space-2); } -.mb-3 { margin-bottom: var(--ethos-space-3); } -.mb-4 { margin-bottom: var(--ethos-space-4); } +.gap-1 { gap: var(--space-1); } +.gap-2 { gap: var(--space-2); } +.gap-3 { gap: var(--space-3); } +.gap-4 { gap: var(--space-4); } +.mt-1 { margin-top: var(--space-1); } +.mt-2 { margin-top: var(--space-2); } +.mt-3 { margin-top: var(--space-3); } +.mt-4 { margin-top: var(--space-4); } +.mb-1 { margin-bottom: var(--space-1); } +.mb-2 { margin-bottom: var(--space-2); } +.mb-3 { margin-bottom: var(--space-3); } +.mb-4 { margin-bottom: var(--space-4); } /* ── Responsive ── */ @media (max-width: 768px) { .sidebar { width: 48px; min-width: 48px; } .sidebar .sidebar-label { display: none; } - .sidebar a { justify-content: center; padding: var(--ethos-space-2) 0; border-left: none; } + .sidebar a { justify-content: center; padding: var(--space-2) 0; border-left: none; } .sidebar a span:not(.icon) { display: none; } .inspector.open { width: 100%; position: fixed; inset: 0; z-index: 100; border-left: none; } - .content { padding: var(--ethos-space-3); } + .content { padding: var(--space-3); } } - @media (max-width: 480px) { .sidebar { width: 0; min-width: 0; overflow: hidden; padding: 0; border-right: none; } - .content { padding: var(--ethos-space-2); } + .content { padding: var(--space-2); } .cmd-palette { width: 100%; max-width: 100%; max-height: 80vh; border-radius: 0; } .cmd-palette-overlay { padding-top: 0; align-items: flex-start; } } - -/* ── Print ── */ @media print { .sidebar, .topbar, .inspector { display: none; } .shell, .shell-body { display: block; height: auto; overflow: visible; } diff --git a/cmd/mavweb/trace.html b/cmd/mavweb/trace.html index 022a405..3d95037 100644 --- a/cmd/mavweb/trace.html +++ b/cmd/mavweb/trace.html @@ -1,6 +1,6 @@ {{template "shellTop" "trace"}}

Rule Trace

-
{{.Now | ago}} — winner: {{if .Winner}}{{.Winner}}{{else}}nothing fired{{end}}
+
{{.Now | ago}} — winner: {{if .Winner}}{{.Winner}}{{else}}nothing fired{{end}}
{{range .Rules}} diff --git a/cmd/mavweb/voice.html b/cmd/mavweb/voice.html index 12bc2ca..4333a7c 100644 --- a/cmd/mavweb/voice.html +++ b/cmd/mavweb/voice.html @@ -1,61 +1,36 @@ {{template "shellTop" "voice"}} - -
-
tap & hold to speak
- -
-
+
+
tap & hold to speak
+ +
+
Cheatsheet
Act
-
включи свет · сделай громче
-
restart nginx · turn on the light
+
включи свет · сделай громче
+
restart nginx · turn on the light
Reminder
-
напомни через 4 часа размяться
-
remind me tomorrow at 8am to call the doctor
+
напомни через 4 часа размяться
+
remind me tomorrow at 8am to call the doctor
Fact
-
выпил кофе · вес 73 кг
-
ran 3 kilometers
+
выпил кофе · вес 73 кг
+
ran 3 kilometers
Note
-
запиши рецепт блинов
-
note: add a backup job for the db
+
запиши рецепт блинов
+
note: add a backup job for the db
Query
-
какой сегодня день · какой прогноз погоды
-
what version are you · what's the weather
+
какой сегодня день · какой прогноз погоды
+
what version are you · what's the weather
System
-
включи тихий режим · как загрузка системы
-
quiet mode on · system load
+
включи тихий режим · как загрузка системы
+
quiet mode on · system load
-
- RU - EN +
+ RU + EN
@@ -64,11 +39,11 @@ html[data-lang=ru] .cheat-ru{display:revert} var lang = new URLSearchParams(window.location.search).get('lang') || 'ru'; if (lang !== 'en') lang = 'ru'; document.documentElement.setAttribute('data-lang', lang); - document.querySelectorAll('.lang-toggle span').forEach(function(el) { + document.querySelectorAll('.voice-lang span').forEach(function(el) { el.addEventListener('click', function() { var l = el.dataset.lang; document.documentElement.setAttribute('data-lang', l); - document.querySelectorAll('.lang-toggle span').forEach(function(b) { b.classList.remove('active'); }); + document.querySelectorAll('.voice-lang span').forEach(function(b) { b.classList.remove('active'); }); el.classList.add('active'); var url = new URL(window.location); url.searchParams.set('lang', l); diff --git a/deploy/ecosystem/docker-compose.yml b/deploy/ecosystem/docker-compose.yml new file mode 100644 index 0000000..a49082e --- /dev/null +++ b/deploy/ecosystem/docker-compose.yml @@ -0,0 +1,54 @@ +# Nexus / Praxis / Hexis — the three sibling services Maven coordinates. +# One container each, built from the sibling repos (siblings of the Maven repo +# on the same mount). HTTP is published to 127.0.0.1 so the host nginx can +# reverse-proxy the *.subdomains; container-to-container (Maven → sibling) goes +# over the shared `ecosystem` network by service name, no host round-trip. +# +# docker compose -f deploy/ecosystem/docker-compose.yml up -d --build +# +# Maven's own compose joins this same network (add `ecosystem` as an external +# network there) to reach nexus:9740 / praxis:8989 / hexis:9741 directly. +name: ecosystem + +services: + nexus: + build: ../../../nexus + restart: unless-stopped + ports: ["127.0.0.1:9740:9740"] # host nginx → nexus. + volumes: + - nexus-data:/data + - sockets:/run/ecosystem # nexus.sock, shared with Maven + networks: [ecosystem] + + praxis: + build: ../../../praxis + restart: unless-stopped + ports: ["127.0.0.1:8989:8989"] # host nginx → praxis. + volumes: + - praxis-data:/data + networks: [ecosystem] + + hexis: + build: ../../../hexis + restart: unless-stopped + ports: ["127.0.0.1:9741:9741"] # host nginx → hexis. (MCP moved off hexis.*) + environment: + # hexis fronts the workspace MCP; point at wherever it listens on the + # host/network. Empty ⇒ workspace provider disabled (systemd-only). + - WORKSPACE_MCP_URL=${WORKSPACE_MCP_URL:-http://192.168.1.104:9930} + volumes: + - hexis-data:/data + - sockets:/run/ecosystem + # required when WORKSPACE_MCP_URL is set — hexisd fatals without it. + - ../../../hexis/etc/workspace-allowlist.yaml:/data/workspace-allowlist.yaml:ro + networks: [ecosystem] + +volumes: + nexus-data: + praxis-data: + hexis-data: + sockets: + +networks: + ecosystem: + name: ecosystem # stable name so Maven's compose can reference it as external diff --git a/deploy/ecosystem/nginx.conf b/deploy/ecosystem/nginx.conf new file mode 100644 index 0000000..43b863e --- /dev/null +++ b/deploy/ecosystem/nginx.conf @@ -0,0 +1,42 @@ +# Reverse-proxy the three sibling admin UIs. Drop into your nginx sites (or the +# nginx-panel app) and reload. Assumes the compose publishes each service on +# 127.0.0.1:. Add TLS (certbot / your existing cert block) per server. +# +# NOTE: hexis. previously pointed at the MCP tool — repoint that +# elsewhere first (the app now owns hexis.*). + +server { + listen 80; + server_name nexus.kvmx.ru; + location / { + proxy_pass http://127.0.0.1:9740; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +server { + listen 80; + server_name praxis.kvmx.ru; + location / { + proxy_pass http://127.0.0.1:8989; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +server { + listen 80; + server_name hexis.kvmx.ru; + location / { + proxy_pass http://127.0.0.1:9741; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/deploy/mavend.json b/deploy/mavend.json index 29ee63c..c5d711a 100644 --- a/deploy/mavend.json +++ b/deploy/mavend.json @@ -18,6 +18,10 @@ "chat_id": "${TELEGRAM_CHAT_ID}" }, + "nexus": { "url": "http://nexus:9740" }, + "praxis": { "url": "http://praxis:8989" }, + "hexis": { "url": "http://hexis:9741" }, + "voice": { "enabled": true, "bind": "0.0.0.0:9100", diff --git a/docker-compose.yml b/docker-compose.yml index 56dd77a..b6a4b71 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,9 @@ services: mavend: <<: *image command: ["mavend", "-config", "/opt/maven/config/mavend.json"] + # both nets: `default` keeps the `mavend` DNS name the other daemons reach + # (mavweb -> mavend:9100); `ecosystem` reaches nexus/praxis/hexis by name. + networks: [default, ecosystem] # the key lives ONLY here. deploy/db_key.env holds MAVEN_DB_KEY=. env_file: - ./deploy/db_key.env @@ -76,9 +79,13 @@ services: <<: *image # voice.bind is 0.0.0.0:9100 in deploy/mavend.json so mavweb can reach it # cross-container. Verified 2026-07-06. - command: ["mavweb", "-addr", ":9201", "-voice", "mavend:9100", "-core", "/run/maven/mavend.sock"] + command: ["mavweb", "-addr", ":9201", "-voice", "mavend:9100", "-core", "/run/maven/mavend.sock", + "-nexus", "http://nexus:9740", "-praxis", "http://praxis:8989", "-hexis", "http://hexis:9741"] depends_on: [mavend] ports: ["9201:9201"] + # ecosystem: reach the siblings by name for the read-only /ecosystem panel; + # default: keep resolving mavend:9100 for voice + the IPC socket peers. + networks: [default, ecosystem] volumes: - sockets:/run/maven @@ -96,3 +103,9 @@ services: volumes: dbdata: sockets: + +networks: + default: + ecosystem: + external: true # created by deploy/ecosystem/docker-compose.yml + name: ecosystem diff --git a/docs/plans/01-email-reader.md b/docs/plans/01-email-reader.md new file mode 100644 index 0000000..d433f62 --- /dev/null +++ b/docs/plans/01-email-reader.md @@ -0,0 +1,29 @@ +# Plan: Background Email Reader + +**Goal:** Maven periodically polls configured IMAP mailboxes, extracts structured items (tasks, reminders, calendar events, important facts) via LLM, and writes them into the store through `ipc.CoreAPI` — discarding spam, newsletters, and noise. + +**Done when:** +- `internal/email/` package exists with IMAP idle+poll loop +- New `email` block in `config.Config` (mailboxes, poll interval, extraction model) +- Extracted items land as `WriteFact`/`CreateReminder`/`WriteNote` via `ipc.Client` +- Spam/promotions/social are silently dropped (Gmail `X-GM-LABELS` or IMAP keyword check) +- Integration test with a throwaway mailbox verifies the round-trip + +**Scope:** +- New `internal/email/` package — IMAP client (stdlib `net/mail` + `github.com/emersion/go-imap` or raw `net/textproto`) +- Config extension: `config.Config.Email` block with `PollInterval`, `Accounts[]` (host, username, password/app-password, folders) +- LLM extraction prompt (shared `internal/llm.Client`) classifies each unseen message as `task|reminder|event|fact|junk` and fills slots — same GBNF-constrained pattern as `internal/router/llmrouter.go:routeGrammar` +- Extracted items written through `ipc.CoreAPI` interface (same seam `internal/voice/reactiveHandler` uses) +- Daemon lifecycle: wired in `cmd/mavend/main.go` alongside the tick loop, starts after unlock +- `deploy/mavend.json` gets the email block + +**Steps:** +1. Add `EmailConfig` struct to `internal/config/config.go` (accounts, poll interval, folders to scan) +2. Create `internal/email/` with `Poller` — IMAP connect, `IDLE` for push, fallback to periodic `SELECT INBOX` + `SEARCH UNSEEN` +3. Implement message → plaintext conversion (`text/plain` body, strip HTML from `text/html` via `golang.org/x/net/html` or simple regex) +4. Add LLM extraction via `internal/llm.Client.Complete()` with a GBNF grammar that outputs `{"type":"task|reminder|event|fact","key":"...","value":"...","when":"..."}` — mirroring `internal/router/llmrouter.go:routeGrammar` +5. Map extracted items to `ipc.WriteFactReq` / `ipc.CreateReminder` / `ipc.WriteNote` calls +6. Wire `Poller` into `cmd/mavend/main.go` — start on unlock, stop on context cancel +7. Test with a throwaway mailbox (Gmail app password or a self-hosted Dovecot); verify extraction accuracy +8. Add `models/seeds/email_*.txt` seed examples for the extraction classifier +9. Log poll stats (seen, extracted, junk, errors) through `log.Printf` (consistent with existing pattern in `cmd/mavend/tick.go`) diff --git a/docs/plans/02-pattern-proposal.md b/docs/plans/02-pattern-proposal.md new file mode 100644 index 0000000..6beae27 --- /dev/null +++ b/docs/plans/02-pattern-proposal.md @@ -0,0 +1,28 @@ +# Plan: Background Pattern Detection & Proactive Proposal + +**Goal:** Maven continuously monitors facts written to the store, detects recurring patterns (behavioral, operational, temporal), and proposes new routines, reminders, or config changes back to the user via the existing `ProposedRoutine` / tool-proposal mechanism. + +**Done when:** +- Background analyzer reads `RecentFacts` and `Events` on a slow cadence (every 10min, not every tick) +- New patterns beyond the existing action-lexicon `internal/pattern/extractor.go` — e.g. time-of-day correlations, service-down + user-awake sequences, repeated tool invocations +- Proposals land as `ProposedRoutine` rows in the store (reusing `store.CreateProposedRoutine`) or `ProposeTool` calls +- User confirms via voice or mavweb — existing `pendingRoutineConfirm` / `resolveConfirm` path re-used +- False-positive rate measured and acceptable (proposals are parked, never auto-enabled) + +**Scope:** +- New `internal/analyzer/` package — runs on a separate ticker (10-30min), reads from `ipc.CoreAPI` (or direct `store.Store` for performance) +- Extends `internal/pattern/` detector with multi-signal patterns (not just action+object interval) +- Reuses existing `store.Events`, `store.ProposedRoutine`, `store.Tools` tables +- Daemon wiring: new `analyzer` goroutine in `cmd/mavend/main.go` alongside tick loop + +**Steps:** +1. Create `internal/analyzer/` with periodic scan — `Analyzer.Run(ctx)` goroutine +2. Implement new detectors: + - Time-of-day behavioral patterns ("you always water plants at 9pm" → remind at 8:45pm) + - Service-down + user-present correlation ("service X died while you were away, you fixed it when you sat down" → propose auto-remediation tool) + - Repeated tool use patterns ("you restart plex every Tuesday" → propose a routine) +3. Register proposals via existing `store.CreateProposedRoutine` (reuses `internal/voice/reactiveHandler:detectPattern` pattern) and `ipc.ProposeTool` +4. Wire analyzer goroutine in `cmd/mavend/main.go` — starts after unlock alongside `tl.run(ctx)` +5. Add `analyzer_interval` to `config.Config` (default 10min) +6. Add voice confirm path — proposals detected in background trigger a nudge with a yes/no prompt, reusing `pendingRoutineConfirm` from `cmd/mavend/voice.go:resolveConfirm` +7. Test with seeded events in `internal/store/events_test.go` pattern diff --git a/docs/plans/03-memory-evaluation.md b/docs/plans/03-memory-evaluation.md new file mode 100644 index 0000000..a58be76 --- /dev/null +++ b/docs/plans/03-memory-evaluation.md @@ -0,0 +1,27 @@ +# Plan: Background Memory Evaluation & Idea Generation + +**Goal:** Maven periodically reviews her own memory stores (facts, notes, events, nudges), evaluates coherence and gaps, and generates proactive proposals — new routines, configuration tweaks, observations she can share with the user. + +**Done when:** +- `internal/memory/eval.go` — periodic evaluation loop runs on a slow cadence (1h) +- Evaluation reads `RecentFacts`, `RecentNotes`, `RecentNudges`, `RecentEvents` via `store.Store` or `ipc.CoreAPI` +- LLM summarizes state, detects anomalies (e.g. "you haven't recorded a meal in 3 days — is your routine broken?"), proposes new care rules +- Generated proposals are written as notes (kind `note`, source `infer:memory-eval`) and/or trigger nudges through the dispatcher +- Evaluation trace visible on `/history` page in mavweb + +**Scope:** +- New `internal/memory/eval.go` — evaluator struct calling `internal/llm.Client` with a summarization prompt +- Reuses `internal/delivery.Dispatcher` for surfacing insights as care nudges (sev1) +- Reuses `internal/store` for reading memory state and writing evaluation notes +- Daemon wiring: new evaluation goroutine in `cmd/mavend/main.go` +- Config: `memory_eval_interval` in `config.Config` (default 1h, 0 to disable) + +**Steps:** +1. Create `internal/memory/eval.go` — `Evaluator` struct holding `*store.Store`, `*llm.Client`, `*delivery.Dispatcher` +2. Implement `Evaluate(ctx)` — reads last N facts, notes, nudges, events, builds a prompt summarizing patterns, anomalies, gaps +3. LLM call returns structured observations: `{"observation":"...","confidence":0.8,"suggested_action":"remind|propose|notify"}` +4. High-confidence observations written as notes (`source:infer:memory-eval`) or dispatched as care nudges (sev1) through `dispatcher.DispatchNudge` +5. Wire evaluator goroutine in `cmd/mavend/main.go` — separate ticker, not on the main tick loop +6. Add `/eval` API method to `ipc.CoreAPI` (or reuse `Chat` with system context) so mavweb can show evaluation history +7. Add `memory_eval` block to `deploy/mavend.json` +8. Test with synthetic store state — verify observations match expected patterns diff --git a/docs/plans/04-self-update.md b/docs/plans/04-self-update.md new file mode 100644 index 0000000..d3d6fc7 --- /dev/null +++ b/docs/plans/04-self-update.md @@ -0,0 +1,29 @@ +# Plan: Self-Update with Rollback + +**Goal:** Maven can update her own code, config, skills (seed files), tool definitions, and integrations while running, with a rollback mechanism if the new state causes failures. + +**Done when:** +- `internal/update/` package manages versioned snapshots of the binary + config + models + seeds +- Daemon can fetch a new release artifact (git pull + `go build`, or download a pre-built binary) +- On success: atomically swaps binaries/symlinks and sends SIGHUP to itself for graceful reload +- On failure (daemon crash within a grace window): init/systemd restarts the old binary automatically, or an in-process supervisor detects crash-loop and rolls back +- Rollback is automatic on crash-loop detection (>2 crashes in 5min) — previous known-good snapshot is re-deployed +- All state (sqlite store) is forward/backward compatible within the same schema version (`store.Migrate`) + +**Scope:** +- New `internal/update/` package — snapshot manager, downloader, binary swap, health check +- New `cmd/mavend/updater.go` — the imperative orchestration (swap + SIGHUP + watch) +- Reuses `internal/store.Migrate` for schema compatibility +- Config: `update` block in `config.Config` (repo URL, auto-update channel, rollback max crashes) +- New IPC methods: `MethodCheckUpdate`, `MethodApplyUpdate`, `MethodRollback` + +**Steps:** +1. Design the update data model: versioned snapshots under `state_dir/updates/v/` — binary, config, models, seeds; current symlink at `state_dir/current` +2. Create `internal/update/checker.go` — checks GitHub releases (or a custom update server) for newer version; compares semver +3. Create `internal/update/downloader.go` — downloads artifact, verifies checksum, extracts to new snapshot dir +4. Create `internal/update/swapper.go` — atomically swaps symlink, sends SIGHUP to self (`syscall.SIGUSR1` or `SIGHUP`) +5. Wire SIGHUP handler in `cmd/mavend/main.go` (already has `signal.NotifyContext` with `SIGHUP`) — re-read config, re-open store, swap phraser/router/delivery without dropping IPC connections +6. Create crash-loop detector in `internal/update/health.go` — watches process start time, counts crashes in window, triggers rollback +7. Add IPC methods `MethodCheckUpdate`, `MethodApplyUpdate`, `MethodRollback` to `internal/ipc/api.go` and wire through `ipc.Server` dispatch +8. Add `update` block to `config.Config` and `deploy/mavend.json` +9. Test rollback: deploy a deliberately broken binary, verify crash-loop detection reverts to previous version diff --git a/docs/plans/05-model-swap.md b/docs/plans/05-model-swap.md new file mode 100644 index 0000000..be05c26 --- /dev/null +++ b/docs/plans/05-model-swap.md @@ -0,0 +1,29 @@ +# Plan: On-the-Fly Model Swap (Multi-Model Routing) + +**Goal:** Maven can switch between LLM models at runtime — including using a remote `llama-server` instance on workpc over LAN — without restarting the daemon. The phraser, router (LLM router), and replier all point at a dynamic backend that can be re-pointed via IPC. + +**Done when:** +- `internal/llm/client.go` supports a dynamic base URL that can be swapped at runtime +- `internal/phraser/llmphraser.go` can hot-swap its backend (stop current `llama-server` subprocess, start new one, or point to a remote one) +- Remote model config: `phraser.mode = "remote"` with `remote_url = "http://workpc:8080"` — connects without spawning a subprocess +- Swap is triggered via IPC (`MethodSwapModel`) with a new config block — no daemon restart +- Router's `LLMRouter` (in `internal/router/llmrouter.go`) follows the same swap +- Fallback: if the new model fails to respond within timeout, the old model stays active (never leave the user with no model) + +**Scope:** +- `internal/llm/client.go` — add `SetBaseURL(string)` method for runtime re-pointing +- `internal/phraser/llmphraser.go` — add `Swap(Config) error` method +- `internal/router/llmrouter.go` — already holds a `Completer` interface; swap the underlying client +- `cmd/mavend/voice.go` — re-creates `LLMReplier` when model changes +- New IPC method `MethodSwapModel` in `internal/ipc/api.go` +- Config: `phraser.mode` field (`local|remote`), `phraser.remote_url` + +**Steps:** +1. Add `SetBaseURL(url string)` to `internal/llm/client.go` — atomically swaps the `base` field under a mutex (add `sync.RWMutex` to `Client`) +2. Add `Swap(cfg Config) error` to `internal/phraser/llmphraser.go` — stops current `llama-server` (via `Close()`), starts new one with new config, or connects to remote URL without spawning +3. Extend `PhraserConfig` in `internal/config/config.go` with `Mode string` (`"local"` or `"remote"`) and `RemoteURL string` +4. Create new `internal/llm/manager.go` — manages a set of named backends, allows `SwitchModel(name)` that re-wires phraser + LLM router + replier atomically +5. Add `MethodSwapModel` to `internal/ipc/api.go` with request `{model_path, mode, remote_url, n_gpu_layers, n_ctx}` +6. Wire swap handler in `cmd/mavend/main.go` — `srv.ModelSwapFn` called from IPC dispatch, re-wires phraser, rebuilds router with new LLMRouter, rebuilds replier +7. Add `model` block to `config.Config` with named model definitions (local paths + remote URLs) +8. Test: swap between local `StubPhraser` and remote llama-server on LAN; verify phraser + router + replier all use the new backend diff --git a/docs/plans/06-mcp-support.md b/docs/plans/06-mcp-support.md new file mode 100644 index 0000000..155d85f --- /dev/null +++ b/docs/plans/06-mcp-support.md @@ -0,0 +1,29 @@ +# Plan: MCP (Model Context Protocol) Integration + +**Goal:** Maven acts as an MCP host — she can connect to external MCP servers (tools, data sources, file systems) via the Model Context Protocol, and expose those capabilities as tool verbs in her allowlist, or use them as context sources for routing/phrasing. + +**Done when:** +- `internal/mcp/` package implements an MCP client — connects to MCP servers over stdio or TCP, negotiates protocol version, discovers tools/resources +- MCP-discovered tools are registered as proposed tools (`ProposeTool`) in the store +- User enables them on mavweb (existing `EnableTool` flow) +- MCP resource contents are available as context for the router's `LLMRouter` or the phraser's prompts +- Multiple MCP servers can be configured in `deploy/mavend.json` +- MCP connections are managed (reconnect on drop, timeout, graceful shutdown) + +**Scope:** +- New `internal/mcp/` package — protocol client (`github.com/mark3labs/mcp-go` or raw JSON-RPC over stdio/TCP) +- New `cmd/mcphost/` or built into `cmd/mavend/` — MCP server manager goroutine +- Config extension: `mcp_servers` array in `config.Config` — `{name, command, args, env, enabled}` +- IPC extension: `MethodListMCPTools`, `MethodListMCPResources`, `MethodCallMCPTool` +- Reuses `internal/tool/Executor` and `internal/router` seams + +**Steps:** +1. Research MCP protocol spec and pick a Go client library (`github.com/mark3labs/mcp-go` exists as of 2025, or implement raw JSON-RPC 2.0 over stdio/TCP) +2. Create `internal/mcp/client.go` — `Client` struct handling stdio subprocess lifecycle: spawn, negotiate protocol version (`initialize` handshake), `ListTools`, `CallTool` +3. Create `internal/mcp/manager.go` — `Manager` that reads `mcp_servers` config, starts/stops per-server clients, exposes a consolidated tool list +4. Wire MCP-discovered tools into `ProposeTool` (same flow as `cmd/mavend/voice.go:proposeGap`) +5. Add resource content fetching — `ReadResource` returns text content that can be injected into LLM prompts +6. Add IPC methods `MethodListMCPTools`, `MethodListMCPResources`, `MethodCallMCPTool` to `internal/ipc/api.go` +7. Expose MCP tools in mavweb UI — new `/tools/mcp` page +8. Add `mcp_servers` block to `config.Config` and `deploy/mavend.json` +9. Test with a local MCP demo server (e.g., `mcp-server-sqlite` or a custom echo server) diff --git a/docs/plans/07-vision.md b/docs/plans/07-vision.md new file mode 100644 index 0000000..07ac9c1 --- /dev/null +++ b/docs/plans/07-vision.md @@ -0,0 +1,27 @@ +# Plan: Vision — Image Understanding Capability + +**Goal:** Maven can "see" — accept images (from mavweb upload, Telegram, or filesystem paths), run vision inference via a local or remote multimodal model, and answer questions about the image content or extract structured information. + +**Done when:** +- Vision model backend is configurable: local multimodal LLM (e.g., LLaVA, Qwen-VL via `llama-server` mmproj) or remote API +- `internal/vision/` package handles image preprocessing, model inference, result parsing +- Voice/text commands like "что на картинке?" or "прочитай текст с экрана" route to the vision handler +- Extracted information can be written as facts/notes through `ipc.CoreAPI` +- Telegram image messages are processed through the same pipeline + +**Scope:** +- New `internal/vision/` package — image loader (Go stdlib `image` + `golang.org/x/image`), inference client +- New config block: `voice.vision` in `config.Config` — `{enabled, provider, model_path, mmproj_path, remote_url}` +- Router intent extension: new `IntentVision` or reuse `IntentQuery` with a vision flag +- Reuses `internal/llm.Client` for API-compatible backends (OpenAI-compatible vision API) +- Reuses `internal/ipc.CoreAPI` for writing extracted data + +**Steps:** +1. Create `internal/vision/provider.go` — `Provider` interface with `Describe(image []byte, prompt string) (string, error)` and `ExtractText(image []byte) (string, error)` +2. Implement `LocalProvider` — spawns `llama-server` with mmproj, sends multimodal chat completion requests +3. Implement `RemoteProvider` — calls an OpenAI-compatible vision API endpoint, reuses `internal/llm.Client` +4. Create `internal/vision/processor.go` — image preprocessing (resize, format conversion to JPEG/PNG, base64 encoding) +5. Wire vision into `cmd/mavend/voice.go:reactiveHandler` — detect vision intent from router (new `IntentVision` or a `Slots.HasImage` flag) +6. Add IPC method `MethodDescribeImage` for programmatic access (mavweb upload, telegram bot) +7. Add vision config block to `config.Config` and wire in `cmd/mavend/main.go` +8. Test with a local multimodal model: send an image via mavweb, verify description and text extraction diff --git a/docs/plans/08-hearing.md b/docs/plans/08-hearing.md new file mode 100644 index 0000000..a7cb69f --- /dev/null +++ b/docs/plans/08-hearing.md @@ -0,0 +1,28 @@ +# Plan: Hearing — Audio Stream Monitoring & Meeting Summarization + +**Goal:** Maven can "hear" ambient audio from workpc — microphone input during meetings, system audio — and on demand (or on trigger) produce transcripts, summaries, or extract action items. A typical use case: "Maven, запиши встречу" starts capture, "хватит" stops it, and Maven writes a summary note. + +**Done when:** +- `internal/audio/capture.go` — remote microphone capture client (receives PCM stream from workpc over WebSocket or the existing voice TCP protocol) +- `internal/stt/` — streaming transcription (uses existing `stt.Transcriber` interface, extended with streaming support) +- Meeting capture triggered by voice command (IntentCapture) or configurable keyword ("maven record") +- Raw audio is either streamed to STT in real-time or saved to a WAV file and transcribed after capture ends +- Transcription + LLM summary is written as a note (`source:capture:meeting`) through `ipc.CoreAPI` +- New `mavheary` module (`cmd/mavheard/`) — the workpc-side agent that captures mic/speaker audio and streams it to mavend + +**Scope:** +- New `cmd/mavheard/` — workpc-side agent: captures microphone (PortAudio or ALSA `arecord`), streams over WebSocket to mavend +- `internal/audio/` extended with capture types: `MicCapture`, `SystemCapture`, `FileCapture` +- `internal/stt/stt.go` extended with `StreamingTranscriber` interface (or reuse existing with chunked input) +- Router: new `IntentCapture` intent for start/stop commands +- Reuses `internal/llm.Client` for summarization +- Reuses `internal/voice/server.go` TCP protocol for streaming audio + +**Steps:** +1. Create `cmd/mavheard/main.go` — workpc-side daemon: captures microphone via `arecord` pipe or PortAudio, opens WebSocket or TCP connection to mavend, streams PCM frames +2. Create `internal/audio/capture.go` — `Capture` interface: `Start()`, `Stop()`, `AudioCh <-chan Audio`; implement `MicCapture` (reads from `mavheard` stream) and `FileCapture` (reads WAV) +3. Extend `internal/stt/stt.go` — add `TranscribeStream(ctx, audio <-chan Audio) (string, error)` to `Transcriber` interface; `Stub` returns empty; `Remote` forwards chunks to worker socket +4. Add `IntentCapture` to `internal/router/intent.go` — slots: `Action` ("start"/"stop"/"status"), `Duration` +5. Wire capture handler in `cmd/mavend/voice.go:reactiveHandler` — start = spawn goroutine receiving audio, stream to STT; stop = finalize, send to LLM for summarization, write note via `WriteNote` +6. Add capture config to `voice` block in `config.Config` — `{capture_enabled, capture_timeout}` +7. Test with a recorded WAV file — simulate a meeting, verify transcription + summary note is created diff --git a/docs/plans/09-behavioral-memory.md b/docs/plans/09-behavioral-memory.md new file mode 100644 index 0000000..9313303 --- /dev/null +++ b/docs/plans/09-behavioral-memory.md @@ -0,0 +1,29 @@ +# Plan: Behavioral Memory — How I Do Stuff + +**Goal:** Maven builds a rich behavioral model of the user over time: habits, routines, preferences, recurring tasks, deadlines, and commitments. She uses this model to proactively propose plans, surface reminders, and adjust her behavior — all grounded in the existing fact/event/note stores. + +**Done when:** +- `internal/memory/behavior.go` — behavioral model builder reads facts, events, notes, nudges, reminders, tools, calendar events +- Model is exposed as a structured profile: `{"routines": [...], "preferences": {...}, "recurring_tasks": [...], "typical_schedule": {...}}` +- LLM generates this profile periodically (daily) and stores it as a note/fact +- Proactive loop uses the profile to propose daily plans: "сегодня ты обычно делаешь X, Y, Z. напомнить?" +- Profile is queryable via voice: "что я обычно делаю по вторникам?" +- Profile updates on fact write — not just periodic — so a new "walk" fact immediately adjusts the walking schedule + +**Scope:** +- `internal/memory/behavior.go` — behavior builder +- `internal/memory/profile.go` — profile data structures (routines, preferences, schedule, commitments) +- Reuses `internal/llm.Client` for profile generation +- Reuses `internal/pattern/detector.go` for interval detection on behavioral data +- Extends `internal/router/intent.go` — `IntentQuery` extended with behavioral sub-queries +- Reuses `internal/delivery.Dispatcher` for surfacing proposals as nudges + +**Steps:** +1. Design profile schema: `BehaviorProfile` struct with `Routines []Routine`, `Preferences map[string]string`, `RecurringTasks []Task`, `WeeklySchedule map[string][]Activity` +2. Create `internal/memory/behavior.go` — `Builder` that reads `store.RecentFacts(1000)`, `store.EventsFor` (all action+object combos), `store.RecentNotes(500)`, `store.ListReminders` +3. Implement profile generation — prompt for `llm.Client` that takes raw facts and outputs a structured JSON profile; stores result as a fact (`kind=config, key=behavior_profile`) +4. Create `internal/memory/planner.go` — reads the profile each morning (via routine cron `"0 8 * * *"`) and proposes a daily plan through `dispatcher.DispatchNudge` +5. Add real-time updates — when a fact is written via `WriteFact`, the behavior builder incrementally updates the relevant profile section (append-only, no full rebuild) +6. Wire voice query — `"что я обычно делаю?"` routes to `IntentQuery` → behavior profile lookup → LLM-phrased answer +7. Add IPC read method `MethodGetBehaviorProfile` so mavweb can display it on `/dash` +8. Test with synthetic fact history — verify weekly schedule is correctly inferred diff --git a/docs/plans/10-speaker-recognition.md b/docs/plans/10-speaker-recognition.md new file mode 100644 index 0000000..abd5b96 --- /dev/null +++ b/docs/plans/10-speaker-recognition.md @@ -0,0 +1,27 @@ +# Plan: Speaker Recognition + +**Goal:** Maven can distinguish between different speakers on the voice channel — recognize known voices (the user, family members) and tag facts/notes/transcripts with a speaker identity. + +**Done when:** +- Speaker embedding extractor (e.g., ECAPA-TDNN or a simple MFCC + GMM) runs on incoming voice PCM before STT +- Embedding is compared against enrolled speaker profiles (stored as vectors in the `memory_vectors` table alongside semantic memory) +- Unknown speakers are enrolled on first interaction (prompt: "кто это?") +- All voice fact/note writes are tagged with `speaker:` in the value/source metadata +- Speaker identity is available as context to the router, phraser, and replier ("ok, ") + +**Scope:** +- New `internal/speaker/` package — enrollment, recognition, embedding extraction +- Reuses `internal/store.MemoryStore` for speaker vector storage (same `memory_vectors` table, different `source` prefix) +- Reuses `internal/audio` for PCM preprocessing +- Integration point: `cmd/mavend/voice.go:HandlePushToTalk` — speaker ID extracted before STT, passed through context + +**Steps:** +1. Research speaker embedding approaches — simplest floor: MFCC + cosine similarity via `github.com/mjibson/go-dsp` or a pre-trained ONNX model (SpeechBrain ECAPA) +2. Create `internal/speaker/recognizer.go` — `Recognizer` interface: `Identify(pcm []float32) (SpeakerID, confidence)`, `Enroll(id, pcm)` +3. Create `internal/speaker/store.go` — speaker profile CRUD via `store.MemoryStore`: `Insert("speaker:", embedding, meta)`, `Search(embedding, k)` +4. Create `internal/speaker/enroll.go` — enrollment flow: capture N seconds of audio, extract embedding, prompt for name via TTS + STT round-trip +5. Wire into `cmd/mavend/voice.go:HandlePushToTalk` — run speaker ID on the PCM before STT; pass speaker ID through `context.Context` to `applyAction` +6. Tag all voice-written facts/notes with speaker ID — `Source` becomes `tap:voice:speaker:` or metadata field +7. Add IPC methods `MethodEnrollSpeaker`, `MethodListSpeakers`, `MethodRemoveSpeaker` +8. Add speaker config block to `voice` in `config.Config` — `{speaker_recognition: true, model_path}` +9. Test with 2+ recorded voice samples — verify correct identification and rejection of unknown speakers diff --git a/docs/plans/11-smarthome-integration.md b/docs/plans/11-smarthome-integration.md new file mode 100644 index 0000000..80975c7 --- /dev/null +++ b/docs/plans/11-smarthome-integration.md @@ -0,0 +1,28 @@ +# Plan: SmartHome Ecosystem Integration + +**Goal:** Maven connects to the SmartHome ecosystem — Home Assistant, MQTT, Zigbee2MQTT, or direct HTTP APIs — to read sensor states, control devices, and trigger automations based on facts and presence. + +**Done when:** +- MQTT client in `internal/smarthome/mqtt.go` — connects to broker, subscribes to topic patterns, publishes control messages +- Home Assistant API client in `internal/smarthome/ha.go` — REST API + WebSocket for state reads and service calls +- SmartHome entities are writable as facts (sensor → fact write for loop predicates) +- Tool verbs for device control: `turn_on`, `turn_off`, `set_temp`, `set_brightness` — mapped to existing `tool.Executor` or the SmartHome API directly +- Presence integration: motion sensors, door sensors, WiFi presence feed into the existing `store.PresenceProbes` pipeline +- Voice control: "maven, выключи свет в гостиной" routes through `IntentAct` → SmartHome tool + +**Scope:** +- New `internal/smarthome/` package — MQTT client, Home Assistant client, entity registry +- New `cmd/mavpoll/` extension — existing polling infrastructure extended with SmartHome sensors +- Config: `smarthome` block in `config.Config` — `{provider: "homeassistant|mqtt", url, token, mqtt_broker}` +- Reuses `internal/tool.Executor` for device control tools +- Reuses `internal/store.PresenceProbes` for presence input + +**Steps:** +1. Create `internal/smarthome/ha.go` — Home Assistant REST client: `GetStates()`, `CallService(domain, service, target, data)`, subscribe to WebSocket events +2. Create `internal/smarthome/mqtt.go` — MQTT client via `github.com/eclipse/paho.mqtt.golang`: subscribe to `zigbee2mqtt/#`, `homeassistant/#`, publish to `cmnd/#` +3. Create `internal/smarthome/entity.go` — entity registry: maps entity_id → fact key, device_class → fact kind +4. Create programmatic tool registration — on startup, enumerate SmartHome entities and call `ProposeTool` for each controllable device (light, switch, climate, cover) +5. Wire MQTT sensor updates into `store.WriteFact` — e.g., `zigbee2mqtt/temperature` → `WriteFact(kind="env", key="temp:living_room", value="22.5")` +6. Wire SmartHome presence signals into `store.PresenceProbes` — e.g., WiFi presence or motion sensor → `presence_wifi` / `presence_motion` probes +7. Add `smarthome` block to `config.Config` and wire into `cmd/mavend/main.go` — starts separate goroutine for MQTT/WebSocket event loop +8. Test with a local MQTT broker and simulated sensor messages — verify facts are written and loop predicates can read them diff --git a/docs/plans/12-bluetooth-network-scan.md b/docs/plans/12-bluetooth-network-scan.md new file mode 100644 index 0000000..3aa14a1 --- /dev/null +++ b/docs/plans/12-bluetooth-network-scan.md @@ -0,0 +1,27 @@ +# Plan: Bluetooth Control & Network Scanning + +**Goal:** Maven can scan Bluetooth devices (discover, connect, read characteristics) and scan the local network (discover hosts, open ports, service fingerprints) — exposed as tools in her allowlist for query and automation. + +**Done when:** +- `internal/bluetooth/` package wraps `bluez` D-Bus API or `hcitool`/`bluetoothctl` CLI for device discovery, pairing, and RSSI reading +- `internal/netscan/` package performs ARP scan, TCP port scan, service detection +- Both are wired as tools (same `ProposeTool`/`EnableTool` flow, status/proposed files in `deploy/mavend.json` or generated at runtime) +- Results are writable as facts/key-value observations for loop predicates +- Voice commands: "maven, просканируй bluetooth", "какие устройства в сети?" route through `IntentAct` or `IntentQuery` + +**Scope:** +- New `internal/bluetooth/` — BlueZ D-Bus client (`github.com/godbus/dbus/v5` or exec wrappers) +- New `internal/netscan/` — ARP scanner (`net/http` + arp table read), TCP connect scanner (`net.DialTimeout`), service probe +- Config: `tools` block extended with auto-generated scan tool entries +- Reuses `internal/tool.Executor` for running scan commands +- Reuses `internal/store.WriteFact` for scan results as facts + +**Steps:** +1. Create `internal/bluetooth/scanner.go` — `Scan(duration) ([]Device, error)`: calls `bluetoothctl --timeout N scan on`, parses output; or uses D-Bus `org.bluez` API +2. Create `internal/bluetooth/presence.go` — RSSI-based presence probe: scan for known MAC, write `presence_bt:` fact with RSSI value for the presence pipeline (`internal/store/presence.go`) +3. Create `internal/netscan/scanner.go` — `ScanLAN() ([]Host, error)`: reads ARP table (`/proc/net/arp`), TCP scans common ports (22, 80, 443, 8080, 9090, 9100) with `net.DialTimeout` +4. Create `internal/netscan/service.go` — service probes: HTTP GET on port 80/8080, SSH banner grab on 22, ping +5. Wire scan tools into `ProposeTool` at startup — `bluetooth_scan`, `wifi_scan`, `port_scan`, `network_map` +6. Presence integration: periodic BT scan writes `presence_bt:` facts; `internal/store/presence.go:PresenceSignals` reads them alongside WiFi probes +7. Add `bluetooth` and `netscan` config blocks to `config.Config` — `{scan_interval, known_devices, scan_timeout}` +8. Test with local network — verify host discovery matches `nmap` output; verify BT scan discovers known devices diff --git a/docs/plans/13-rss-news-feeds.md b/docs/plans/13-rss-news-feeds.md new file mode 100644 index 0000000..9edd1b5 --- /dev/null +++ b/docs/plans/13-rss-news-feeds.md @@ -0,0 +1,29 @@ +# Plan: RSS / News Feed Reader + +**Goal:** Maven periodically polls configured RSS/Atom feeds, extracts new items, classifies them by relevance using the embedder, and either stores interesting items as notes or surfaces breaking news via the dispatcher. + +**Done when:** +- `internal/rss/` package — feed parser (Go stdlib `encoding/xml` or `github.com/mmcdole/gofeed`), poll loop +- Feed config in `config.Config` — `feeds: [{name, url, category, poll_interval}]` +- New items are classified by the existing `router.Embedder` — items below a relevance threshold are silently dropped +- Interesting items: written as `WriteNote` with `source="rss:"`; breaking/high-severity items dispatched as care nudges (sev2-3) +- Queryable via voice: "что нового в лентах?", "что по технологиям?" + +**Scope:** +- New `internal/rss/` package — periodic poller, feed parser, item classifier +- Config extension: `feeds` array in `config.Config` +- Reuses `internal/router.Embedder` for relevance scoring (same ONNX model or HashEmbedder floor) +- Reuses `internal/ipc.CoreAPI` for writing notes +- Reuses `internal/delivery.Dispatcher` for breaking news nudges +- Reuses `internal/llm.Client` for item summarization (optional, for long articles) + +**Steps:** +1. Add `gofeed` or raw XML parser — `internal/rss/feed.go`: `ParseFeed(url) ([]Item, error)` where `Item{Title, Link, Summary, Published, Content}` +2. Create `internal/rss/poller.go` — `Poller` holds `[]FeedConfig`, polls each on its own interval, tracks `last_poll` per feed via a fact (`kind=config, key=rss:poll:`) +3. Implement relevance classifier — embed each item's title+summary with `router.Embedder`, compare against user interest profile (built from notes/facts), drop items below threshold +4. Interesting items → `ipc.WriteNote(ctx, ts, title+"\n"+summary, embedding, "rss:")` +5. Breaking items (keywords: "CVE", "outage", "critical") → `ipc.WriteFact(kind=env, key=news:breaking, value=title)` → loop rule `BreakingNewsRule` (sev3) dispatches nudge +6. Wire poller into `cmd/mavend/main.go` — starts after unlock, separate goroutine +7. Add voice query handler — `"что нового?"` queries `RecentNotes` filtered by source prefix `rss:` and phrases via `phraser.PhraseQuery` +8. Add `feeds` block to `config.Config` and `deploy/mavend.json` +9. Test with a live RSS feed (e.g., `https://news.ycombinator.com/rss`) — verify items appear in notes table diff --git a/docs/plans/14-web-crawler.md b/docs/plans/14-web-crawler.md new file mode 100644 index 0000000..b5c78ac --- /dev/null +++ b/docs/plans/14-web-crawler.md @@ -0,0 +1,30 @@ +# Plan: Web Crawler + +**Goal:** Maven can crawl web pages on demand or on a schedule — fetch page content, extract structured data (via LLM or CSS selectors), and store results as facts, notes, or reminders. Used for: price monitoring, documentation updates, recipe extraction, content summarization. + +**Done when:** +- `internal/crawl/` package — HTTP fetcher with polite defaults (rate limiting, robots.txt respect, user-agent) +- Content extraction: HTML→plaintext (Go stdlib `golang.org/x/net/html`), or full-page LLM summarization +- Crawl scheduler in config — `crawls: [{name, url, selector, schedule, store_as}]` +- On-demand crawl via voice: "maven, посмотри страницу X и запиши цену" +- Results are stored as facts/notes through `ipc.CoreAPI` +- Crawl history visible on mavweb `/tools` page + +**Scope:** +- New `internal/crawl/` package — fetcher, parser, scheduler, extractor +- Config extension: `crawls` array in `config.Config` +- Reuses `internal/llm.Client` for intelligent extraction (e.g., "extract the price, description, and availability from this page") +- Reuses `internal/ipc.CoreAPI` for storing results +- Reuses `internal/router.Embedder` for deduplication (don't re-store identical content) +- Reuses `internal/routine.Routine` mechanics for scheduled crawls + +**Steps:** +1. Create `internal/crawl/fetcher.go` — `Fetch(url) ([]byte, error)`: HTTP GET with timeout (30s), rate limiting (1 req/sec), `robots.txt` check via `github.com/temoto/robotstxt` +2. Create `internal/crawl/extractor.go` — `Extract(html []byte, extraction_type string) (map[string]string, error)`: for simple extraction use CSS selector (`github.com/PuerkitoBio/goquery`); for complex extraction use `llm.Client` with a prompt +3. Create `internal/crawl/scheduler.go` — `Scheduler` that reads `crawls` config, runs each on its cron schedule, tracks last-run via facts +4. Create `internal/crawl/dedup.go` — compute content hash, skip if identical to last fetched (stored as fact `kind=config, key=crawl:hash:`) +5. Wire on-demand crawl into `IntentAct` — new tool verb `crawl` that accepts a URL argument +6. Wire scheduled crawls into `cmd/mavend/main.go` — separate goroutine manages the crawl scheduler +7. Add IPC methods `MethodTriggerCrawl(name)`, `MethodListCrawls`, `MethodGetCrawlResult(name)` +8. Add `crawls` block to `config.Config` and `deploy/mavend.json` +9. Test with a static HTML page — verify extraction matches expected values, verify scheduling fires correctly diff --git a/docs/plans/2026-07-10-router-lfm-foundation.md b/docs/plans/2026-07-10-router-lfm-foundation.md index 3f02772..30a0220 100644 --- a/docs/plans/2026-07-10-router-lfm-foundation.md +++ b/docs/plans/2026-07-10-router-lfm-foundation.md @@ -1,5 +1,11 @@ # Plan — Sub-project 1: Router-as-LFM + Foundation +> **Historical/completed foundation.** The shared llama-server client, router, +> fallback and replier described here were implemented. The resident-model +> decision changed on 2026-07-18 from LFM to locally trained Qwen3-1.7B. Do not +> use the embedded LFM model paths or old single-object examples as current ops +> guidance; see `2026-07-18-qwen3-resident-training-eval.md`. + > Scope from `REARCH.md`. Make Maven trustworthy: the LFM becomes the router > (fixes "messes up queries" / "doesn't take notes"), the engine actually runs > (fixes stub replies), dates stop being read as "number dot number dot number", diff --git a/docs/plans/2026-07-11-route-data.md b/docs/plans/2026-07-11-route-data.md index 0a05d19..9797441 100644 --- a/docs/plans/2026-07-11-route-data.md +++ b/docs/plans/2026-07-11-route-data.md @@ -1,7 +1,7 @@ # Route-training data plan (LLM-as-router) Goal: training data that teaches the CPT'd Qwen3-1.7B to emit the **route -contract** — `{"intent":, key?, value?, text?, verb?}`, GBNF-constrained — +contract** — `[{"intent":, key?, value?, text?, verb?}, ...]`, GBNF-constrained — matching `internal/router/llmrouter.go` (`routeSystem` + `routeGrammar`) verbatim. Train=deploy parity: label with the EXACT prompt the daemon sends. @@ -19,24 +19,27 @@ state vs static memo). 3. **Validate** — intent ∈ enum, keys ⊆ {intent,key,value,text,verb}. Drop invalid. 4. **Balance check** — after a run, count intents. `act`/`system`/`fact` likely thin (function_calling skews query/act). Author extra examples for the holes; re-run. -5. **Better prompt first** — improve `routeSystem` for sub-1B disambiguation before a +5. **Better prompt first** — improve `routeSystem` for 1.7B disambiguation before a big generation run (awaiting prompt-guy input). Re-labeling is cheap; regenerate. -6. **Train** — route-LoRA on top of CPT base, OR fold into the persona SFT as a second - contract (decide once volume known). Eval = route accuracy on a held-out REAL set. +6. **Train** — locked decision: fold route and persona examples into one balanced + Qwen3 SFT. The system prompt selects the contract. Evaluate the two tasks + separately; a separate route adapter is the fallback if joint SFT interferes. ## Blocking - Router at `inference.kvmx.ru` / `localhost:6446` must be up (currently down). -- CPT must finish before the route-LoRA trains on it. +- CPT must finish and pass the raw-vs-CPT decision gate before joint SFT. ## Files - `esp32-whisper-fine-tune/llm/gen_route_data.py` — the relabeler (done, self-checks). `ROUTE_SYSTEM` const = verbatim copy of Go `routeSystem`; **keep in sync**. - Output: `llm/data/route_train.jsonl` (resumable append). +- Held-out: `llm/data/route_eval.jsonl`, generated from human labels by + `build_route_eval.py`; never include it in route training generation. ## Prompt-guy question (sent 2026-07-11) -How to structure the router system prompt for a sub-1B model doing 7-intent +How to structure the router system prompt for a 1.7B model doing 7-intent classification + slot extraction, GBNF-constrained — example ordering/count, contrastive near-miss pairs (note vs reminder) vs more singles, rule placement. diff --git a/docs/plans/2026-07-11-ru-cpt-base.md b/docs/plans/2026-07-11-ru-cpt-base.md index 51f7324..002671a 100644 --- a/docs/plans/2026-07-11-ru-cpt-base.md +++ b/docs/plans/2026-07-11-ru-cpt-base.md @@ -1,5 +1,11 @@ # Plan — DIY RU-native base via Continued Pretraining (CPT) +> **Execution update, 2026-07-18:** corpus packing and full-weight smoke are +> complete; training reached step 1000/8077. The materialized corpus is 264.6M +> tokens from CulturaX/Wikipedia/books only. Evaluation, the exact decision gate +> and Qwen3 joint SFT are now canonical in +> `2026-07-18-qwen3-resident-training-eval.md`. + > **Goal:** Build our *own* RU-native base model instead of using Vikhr. Take a > clean newer base (Qwen3-1.7B-Base), continue-pretrain it on a curated Russian > corpus so it spells Cyrillic natively, then run the existing persona-LoRA → @@ -256,10 +262,10 @@ plateaus (not NaN, not flat-from-step-0); checkpoint saved to ## 4. Eval — did CPT actually help? -Extend the existing `llama-eval-test.py` into `eval_cyrillic.py`. This is also an -open item in `CLAUDE.md` and is the regression metric for the whole project. +`eval_cyrillic.py` now writes deterministic machine-readable results using +pinned human-written RU and EN Universal Dependencies test sets. -**Metrics (run on a held-out set of real RU prompts — NOT synthetic, NOT training data):** +**Metrics (run on held-out human-written text — NOT synthetic, NOT training data):** 1. **Cyrillic validity %** — generate on RU prompts; % of outputs with zero mixed-script words and no homoglyph swaps (reuse §2.3 rule 3 as the checker). 2. **Perplexity** on a held-out clean RU text set (lower = better RU fit). @@ -291,10 +297,10 @@ open item in `CLAUDE.md` and is the regression metric for the whole project. ## 6. Persona LoRA on the CPT'd base (existing pipeline) -Now the CPT base is just a better base. Run the **existing** `train_rocm.py` -persona/format LoRA on top, with two changes: -- Point base model at `./Qwen3-1.7B-ru-cpt/` (the CPT checkpoint), not Vikhr/Qwen2.5. -- Confirm ChatML template matches Qwen3 (`<|im_start|>assistant … <|im_end|>`). +Now the CPT base is just a better base. The rewritten `train_rocm.py` trains a +balanced joint persona/router adapter and uses Qwen3's own chat template. It +masks the rendered assistant continuation instead of assuming literal ChatML +boundary token IDs. - The persona **data** is the `{response,mood}` corpus per `CLAUDE.md` Decision B (separate from the CPT corpus): 45% persona chit-chat, 15% graceful failure (`tired`/`confused`), 20% tool calls, 10% real utterances, 10% EN→EN. Mood diff --git a/docs/plans/2026-07-18-qwen3-resident-training-eval.md b/docs/plans/2026-07-18-qwen3-resident-training-eval.md new file mode 100644 index 0000000..5924003 --- /dev/null +++ b/docs/plans/2026-07-18-qwen3-resident-training-eval.md @@ -0,0 +1,157 @@ +# Plan — Qwen3-1.7B resident model: audit, evaluation, SFT and gate + +> **Canonical model plan as of 2026-07-18.** Qwen3-1.7B is the one resident +> router/phraser. Larger reasoners and custom Piper training are deferred until +> the main Maven features are complete. + +## Locked architecture + +``` +Qwen3-1.7B-Base + → full-weight Russian CPT + → raw-vs-CPT decision gate + → one balanced persona + route LoRA + → contract evaluation + → merge → GGUF Q8_0 → homesrv +``` + +The route system prompt selects a JSON-array action contract. The persona +system prompt selects `{response,mood}`. Metrics are reported independently so +joint-training interference is visible. If one task regresses, use separate +adapters before considering a merged multi-task checkpoint. + +## Current evidence + +- Packed corpus: 129,221 × 2,048 = **264,644,608 tokens**. +- Materialized character mix: CulturaX 69.407%, Wikipedia 17.991%, books + 12.602%. Planned synthetic/log/conversational buckets are absent from this run. +- All 64 sampled Arrow rows had the right length and valid token IDs. +- The audit found 247 CulturaX documents with glued `` placeholders and + Cyrillic text. This run continues; fix placeholder spacing before any rebuild. +- Full-weight Adafactor training reached checkpoint 1,000/8,077 (12.38%). Loss + and gradient norms are finite, so this checkpoint is the full-weight smoke + test. Do not launch a competing smoke job. + +Machine-readable audit: +`/home/kami/Programs/esp32-whisper-fine-tune/llm/CPT_CORPUS_AUDIT.json`. + +## Tools + +All commands run from `/home/kami/Programs/esp32-whisper-fine-tune` using its +`.venv`. + +```bash +# Reproduce the read-only corpus/checkpoint audit. +./.venv/bin/python llm/audit_cpt_corpus.py \ + --output llm/CPT_CORPUS_AUDIT.json + +# Fetch pinned, human-written UD RU/EN test sets and their hashes. +./.venv/bin/python llm/prepare_eval_data.py + +# Prompt parity and held-out route labels. +./.venv/bin/python llm/check_prompt_parity.py +./.venv/bin/python llm/build_route_eval.py +``` + +## Baseline and post-CPT evaluation + +The evaluation is deterministic and uses token-weighted perplexity. Preserve +the raw result; do not regenerate it with different limits when comparing CPT. + +```bash +./.venv/bin/python llm/eval_cyrillic.py \ + --model Qwen/Qwen3-1.7B-Base --limit 128 --device cuda \ + --output llm/data/eval/raw_qwen3_1.7b.json + +# After CPT finishes: +./.venv/bin/python llm/eval_cyrillic.py \ + --model llm/Qwen3-1.7B-ru-cpt --limit 128 --device cuda \ + --output llm/data/eval/cpt_qwen3_1.7b.json +``` + +For a publication-quality comparison, repeat both with `--limit 0`; the quick +128-sentence pair is the operational gate and must use identical arguments. + +## Exact CPT decision gate + +```bash +./.venv/bin/python llm/decision_gate.py \ + --raw llm/data/eval/raw_qwen3_1.7b.json \ + --cpt llm/data/eval/cpt_qwen3_1.7b.json +``` + +Exit 0 means all conditions passed: + +- RU perplexity improves by at least 2%; +- EN perplexity regresses by no more than 10%; +- RU generation validity does not decline; +- deterministic English probes remain English. +- mean repeated 4-gram rate stays below 15%, or within five percentage points + of the raw baseline when the baseline itself is worse. + +Exit 1 blocks SFT. Inspect the JSON check map before changing a threshold. A +threshold change is a documented architecture decision, not a convenient rerun. + +Checkpoint 1,000 is an informative interim result: RU PPL improved 4.82% and EN +PPL regressed only 2.20%, but repeated 4-grams rose from 8.56% to 25.90%. +Therefore it passes the language-loss checks but **fails the complete gate**. +This is not a stop signal at 12.38% of training; it is a regression to watch at +the final checkpoint and a reason the gate includes generation degeneration. + +## Resume CPT + +The latest complete checkpoint is selected by Transformers: + +```bash +cd /home/kami/Programs/esp32-whisper-fine-tune/llm +HSA_OVERRIDE_GFX_VERSION=11.0.0 ../.venv/bin/python train_cpt.py --resume \ + 2>&1 | tee -a cpt_run.log +``` + +Confirm there is only one `train_cpt.py` process before resuming. Completion is +step 8,077 and must produce the final tokenizer/model files at +`llm/Qwen3-1.7B-ru-cpt/`, not only checkpoint directories. + +## Joint Qwen3 SFT + +Generate route training data first. `route_train.jsonl` is currently a blocker; +the held-out `route_eval.jsonl` already exists and must never be merged into it. + +```bash +cd /home/kami/Programs/esp32-whisper-fine-tune +./.venv/bin/python llm/gen_route_data.py +./.venv/bin/python llm/train_rocm.py --check-data +./.venv/bin/python llm/train_rocm.py +``` + +The rewritten trainer: + +- loads the completed CPT base; +- consumes explicit persona and route train/eval files; +- balances the two tasks by oversampling only within the training set; +- renders Qwen3's own chat template with thinking disabled; +- masks loss before the assistant continuation without hard-coded ChatML IDs; +- supports deterministic seeds, resume, early stopping and best-checkpoint load; +- writes a training manifest. + +After training: + +```bash +./.venv/bin/python llm/eval_contracts.py \ + --base llm/Qwen3-1.7B-ru-cpt \ + --adapter llm/Qwen3-1.7B-maven-sft \ + --output llm/data/eval/qwen3_contracts.json +``` + +Initial deploy gates: persona JSON validity ≥99%, route JSON validity ≥99%, +route intent sequence exact ≥90%, action intent accuracy ≥95%, slot exact ≥85%. +Mood accuracy is diagnostic until the persona evaluation set is manually +quality-reviewed; the existing examples contain stale technical answers. + +## Deferred + +- Piper/phoneme training. +- On-demand larger reasoner. +- Model hot-swap and the broad capability plans. + +These resume after the main Maven feature set is complete. diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 287d9e0..db05b2d 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -71,6 +71,7 @@ func TestRequirement_Table(t *testing.T) { ipc.MethodPresence, ipc.MethodRecentOutcomes, ipc.MethodCreateReminder, ipc.MethodMarkReminder, ipc.MethodRecordNudge, ipc.MethodResolveNudge, + ipc.MethodChat, } for _, m := range reads { if got := Requirement(m); got != AuthRead { @@ -169,6 +170,7 @@ func TestGate_FloorEnrollment_PreservesPreAuth(t *testing.T) { for _, m := range []ipc.Method{ ipc.MethodPresence, ipc.MethodSince, ipc.MethodRecentOutcomes, ipc.MethodCreateReminder, ipc.MethodRecordNudge, + ipc.MethodChat, } { if err := g.Check(ctx, m, nil); err != nil { t.Errorf("floor gate Check(%s) = %v; want nil (pre-auth preserved)", m, err) @@ -310,6 +312,49 @@ func TestGate_IpcServer_CheckWiredThroughSocket(t *testing.T) { if fake.writes != 0 { t.Errorf("auth refused but CoreAPI was called %d time(s); refused calls must not reach CoreAPI", fake.writes) } + _, err = cli.Chat(context.Background(), "привет") + if !errors.Is(err, ipc.ErrForbidden) { + t.Errorf("wire: chat from unenrolled uid = %v; want ipc.ErrForbidden", err) + } + if fake.chats != 0 { + t.Errorf("auth refused chat but CoreAPI.Chat was called %d time(s)", fake.chats) + } +} + +func TestGate_IpcServer_ChatAllowedForEnrolledCaller(t *testing.T) { + gate := &Gate{Enrollment: NewFloorEnrollment()} + fake := &recordingAPI{} + sock := filepath.Join(t.TempDir(), "maven.sock") + srv, err := ipc.Listen(sock, fake) + if err != nil { + t.Fatalf("listen: %v", err) + } + srv.Check = gate.Check + done := make(chan struct{}) + go func() { + _ = srv.Serve() + close(done) + }() + t.Cleanup(func() { + _ = srv.Close() + <-done + }) + + cli, err := ipc.Dial(sock) + if err != nil { + t.Fatalf("dial: %v", err) + } + t.Cleanup(func() { _ = cli.Close() }) + reply, err := cli.Chat(context.Background(), "привет") + if err != nil { + t.Fatalf("Chat: %v", err) + } + if reply != "echo: привет" { + t.Fatalf("Chat reply = %q; want %q", reply, "echo: привет") + } + if fake.chats != 1 { + t.Fatalf("CoreAPI.Chat calls = %d; want 1", fake.chats) + } } // recordingAPI — a no-op CoreAPI that counts WriteFact invocations; the auth @@ -317,6 +362,7 @@ func TestGate_IpcServer_CheckWiredThroughSocket(t *testing.T) { // fake's counts and we fail. type recordingAPI struct { writes int + chats int } func (r *recordingAPI) WriteFact(_ context.Context, _ ipc.WriteFactReq) (int64, error) { @@ -339,6 +385,9 @@ func (r *recordingAPI) CreateReminder(_ context.Context, _ time.Time, _, _ strin return 1, nil } func (r *recordingAPI) MarkReminder(_ context.Context, _ int64, _ string) error { return nil } +func (r *recordingAPI) ListReminders(_ context.Context, _ int) ([]ipc.Reminder, error) { + return nil, nil +} func (r *recordingAPI) TickTrace(_ context.Context) (ipc.TickTrace, error) { return ipc.TickTrace{}, nil } @@ -378,6 +427,9 @@ func (r *recordingAPI) EnableTool(_ context.Context, _ string, _ []string, _ boo func (r *recordingAPI) DisableTool(_ context.Context, _ string) error { return nil } +func (r *recordingAPI) DeleteTool(_ context.Context, _ string) error { + return nil +} func (r *recordingAPI) LookupTool(_ context.Context, _ string) (ipc.Tool, error) { return ipc.Tool{}, ipc.ErrToolNotFound } @@ -388,6 +440,16 @@ func (r *recordingAPI) ListTools(_ context.Context, _ string) ([]ipc.Tool, error func (r *recordingAPI) RevertFact(_ context.Context, _ string) (int64, error) { return 0, nil } +func (r *recordingAPI) ListProposedRoutines(_ context.Context) ([]ipc.ProposedRoutine, error) { + return nil, nil +} +func (r *recordingAPI) DismissProposedRoutine(_ context.Context, _ int64) error { + return nil +} +func (r *recordingAPI) Chat(_ context.Context, text string) (string, error) { + r.chats++ + return "echo: " + text, nil +} // mustWriteFactParams — minimal WriteFactReq JSON with only the source field, // matching what ipc.dispatch hands to Server.Check (the raw params frame). diff --git a/internal/router/llmrouter.go b/internal/router/llmrouter.go index 37eba2f..3a3f4e2 100644 --- a/internal/router/llmrouter.go +++ b/internal/router/llmrouter.go @@ -34,7 +34,7 @@ string ::= "\"" ([^"\\] | "\\" .)* "\"" ws ::= [ \t\n]* ` -const routeSystem = `Классифицируй ровно одно сообщение пользователя. Верни ОДИН JSON-объект. +const routeSystem = `Классифицируй ровно одно сообщение пользователя. Верни ОДИН JSON-массив действий. Ровно одно намерение: fact, reminder, note, query, act, chat, system.
rulesevpredicategateblocked bydetailselectedlost to