feat(mavweb): /ecosystem page consuming Nexus/Praxis/Hexis + shell fixes
Add a read-only /ecosystem page that consumes the sibling services' JSON APIs (Nexus entities, Praxis attention, Hexis capabilities), fetched concurrently with honest per-panel error states. Siblings stay headless — mavweb is their human surface (arch §16). Wired via mavweb -nexus/-praxis/-hexis flags; mavweb joins the ecosystem compose network. Fix mobile horizontal overflow across all pages: .content is a flex child with default min-width:auto, so it refused to shrink below the tables' intrinsic width. min-width:0 lets wide tables pan inside .scroll instead of dragging the page sideways. Verified via CDP geometry check (scrollWidth === clientWidth at 430px). Also includes in-progress Ethos UI redesign, ecosystem deploy compose, and planning docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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":<enum>, 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":<enum>, key?, value?, text?, verb?}, ...]`, 7 intents (`fact, reminder,
|
||||
note, query, act, chat, system`). `llm/check_prompt_parity.py` in the training
|
||||
workspace enforces that the Go and relabelling prompts remain identical.
|
||||
|
||||
## Non-goals (hard constraints)
|
||||
|
||||
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" "<active-page>"}}`). No
|
||||
per-page `<style>` beyond true one-offs. Wrap every table in `<div class=scroll>` so wide
|
||||
data pans on a phone. Local preview + headless screenshot recipe is in `AGENTS.md`.
|
||||
|
||||
## Vikunja
|
||||
|
||||
This repo is project **Maven** (ID 2) in Vikunja. MCP: `http://localhost:9100/mcp` (or
|
||||
`http://192.168.1.104:9100/mcp` from workpc). Feature/bug/deploy tasks go there.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Maven mavweb — Ethos migration plan
|
||||
|
||||
## Task
|
||||
Adopt Ethos design language (#36, prio:3).
|
||||
|
||||
## Scope
|
||||
- Go + html/template + vanilla CSS/JS
|
||||
- Add tokens.css with :root --ethos-* variables
|
||||
- Replace literal colors/spacing/radii
|
||||
- Restructure dash.html / history.html / notifications.html / trace.html into shell
|
||||
- Tables: sort/filter/resize/hide/pin/pagination
|
||||
- Row click → inspector
|
||||
- Keyboard: Ctrl+K, Ctrl+/, Esc, Enter, Space, Alt+←, Alt+→
|
||||
- Status vocabulary
|
||||
- Density Compact default
|
||||
|
||||
## Notes
|
||||
Largest rewrite due to stack mismatch. No SPA migration — keep server-rendered.
|
||||
|
||||
## Status
|
||||
- [x] PLANS.md created
|
||||
- [ ] tokens.css
|
||||
- [ ] Shell restructure per page
|
||||
- [ ] Tables refactor
|
||||
- [ ] Keyboard shortcuts
|
||||
+7
-1
@@ -1,4 +1,10 @@
|
||||
## Maven — current state (2026-07-06)
|
||||
## Maven — current state (updated 2026-07-18)
|
||||
|
||||
Architecture decision: the target resident router/phraser is the locally
|
||||
trained Qwen3-1.7B model. Older LFM references below describe the currently
|
||||
deployed/historical stack, not the target checkpoint. RU CPT has a successful
|
||||
full-weight checkpoint at step 1000/8077; evaluation and Qwen3 SFT tooling are
|
||||
tracked in `docs/plans/2026-07-18-qwen3-resident-training-eval.md`.
|
||||
|
||||
Consolidated status. The reactive↔proactive core is closed and testable through
|
||||
the web PWA. The SPEC's open items 1–7 are landed (protocol doc, away-channel
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Maven — Re-architecture (router-centric, 2026-07-10)
|
||||
# Maven — Re-architecture (Qwen3 resident model, revised 2026-07-18)
|
||||
|
||||
> Supersedes the classifier-first routing model. Agreed in a design session
|
||||
> after diagnosing that homesrv deploys with a **stub phraser** (no LLM
|
||||
@@ -12,8 +12,9 @@
|
||||
## Principle
|
||||
|
||||
The LLM is **not** the center of everything. Deterministic tools handle the
|
||||
bulk. The LLM is used for exactly two things: **routing/reasoning** and
|
||||
**talking back**. A sub-1B agentic model (LFM 2.5) is enough for both.
|
||||
bulk. One locally trained **Qwen3-1.7B** resident model is used for routing and
|
||||
talking back. Its system prompt selects one of two independently evaluated
|
||||
contracts: route actions or persona responses.
|
||||
|
||||
**If the router is good, Maven feels good.** Routing is the linchpin.
|
||||
|
||||
@@ -22,18 +23,19 @@ bulk. The LLM is used for exactly two things: **routing/reasoning** and
|
||||
```
|
||||
utterance
|
||||
→ [world-state context] cheap: time, presence, calendar_busy, weather (no LLM)
|
||||
→ ROUTER = LFM (always-on, agentic)
|
||||
→ ROUTER = Qwen3-1.7B (always-on, grammar-constrained)
|
||||
reads utterance + context + tool schema, emits a STRUCTURED action:
|
||||
• call a tool (deterministic) • answer directly
|
||||
• escalate → 4B reasoner (on-demand)
|
||||
→ tools (deterministic, fast) / 4B reasoner (on-demand summon)
|
||||
→ PHRASER = LFM (always-on, same process as router) → TTS / text
|
||||
→ tools (deterministic, fast)
|
||||
→ PHRASER = Qwen3-1.7B (same resident process) → TTS / text
|
||||
```
|
||||
|
||||
- **Router = Phraser = one resident sub-1B LFM llama-server**, two call-sites
|
||||
(route-prompt, phrase-prompt). Always warm, no cold start. Cheap on 14 GB.
|
||||
- **4B reasoner (Qwen3-4B, already on disk)** — summoned on-demand for
|
||||
genuinely complex turns, torn down / idle-unloaded after. Never resident.
|
||||
- **Router = Phraser = one resident Qwen3-1.7B llama-server**, with separate
|
||||
route and persona prompts/contracts. Always warm; classifier/stub remain the
|
||||
failure floors.
|
||||
- The resident checkpoint is trained end-to-end as: Qwen3-1.7B-Base → RU CPT →
|
||||
joint persona/router SFT → merge → GGUF. Larger on-demand reasoning models
|
||||
are deferred until the main feature set is complete.
|
||||
- **Embedder demoted from router to tool** — it now backs `memory.search`
|
||||
(RAG) and gives the router a cheap "similar past notes/intents" hint. The
|
||||
router no longer depends on it clearing a threshold. Upgrade MiniLM → bge-m3
|
||||
@@ -58,7 +60,7 @@ DIGESTION WORKER (periodic + event-driven, off the request path)
|
||||
• reflects: detect patterns ("mentioned X three times")
|
||||
• proposes: "want me to add X / remind you about Y?" → nudge dispatcher
|
||||
• surfaces due reminders
|
||||
runs LFM (cheap) or summons 4B (real synthesis) — never blocks a turn
|
||||
uses the resident Qwen3 model for bounded synthesis — never blocks a turn
|
||||
```
|
||||
|
||||
Notes capture is a deterministic Tier-0 tool; making notes *mean something
|
||||
@@ -69,24 +71,23 @@ later* is the worker + RAG.
|
||||
| Layer | What | Runs |
|
||||
|---|---|---|
|
||||
| Context | world-state (time/presence/calendar/weather) | always, no LLM |
|
||||
| **Router** | LFM agentic orchestrator — linchpin | **always-on** |
|
||||
| **Router** | Qwen3 structured-action orchestrator — linchpin | **always-on** |
|
||||
| Tools | note/reminder/memory/calendar/weather/act (deterministic) | always |
|
||||
| Reasoner | Qwen3-4B for complex turns | **on-demand summon** |
|
||||
| Phraser | LFM — final voice | **always-on (same proc as router)** |
|
||||
| Reasoner | larger specialist model | **deferred** |
|
||||
| Phraser | Qwen3 persona response | **always-on (same proc as router)** |
|
||||
| Digestion worker | reflection → suggestions/nudges/memory | **background** |
|
||||
| Reach | telegram (+ existing ntfy/voice) | quick win |
|
||||
| Voice quality | custom/better TTS | **deferred** (workstation GPU busy) |
|
||||
|
||||
## Build order
|
||||
|
||||
1. **Foundation + router** — router-as-LFM, turn the engine ON (resident
|
||||
sub-1B), verify notes+reminders actually round-trip, date/number TTS
|
||||
normalizer, wire telegram reach. After this she's a trustworthy plain
|
||||
assistant.
|
||||
2. **On-demand 4B reasoner** — summon/idle lifecycle + router escalation path.
|
||||
3. **Digestion worker** — reflection, proactive suggestions, memory
|
||||
1. **Foundation + router** — completed shared llama-server client, router,
|
||||
fallback, replier, TTS normalization and telegram reach.
|
||||
2. **Qwen3 resident checkpoint** — finish RU CPT, pass the raw-vs-CPT gate,
|
||||
jointly SFT persona/router contracts, merge, quantize and deploy.
|
||||
3. **Main features** — reflection, proactive suggestions, memory
|
||||
consolidation, RAG read-back.
|
||||
4. **Embodiment** — voice quality (deferred).
|
||||
4. **Deferred work** — larger reasoner, custom Piper voice and other expansions.
|
||||
|
||||
## Non-goals (unchanged)
|
||||
Never phones home. Not a nag. Not autonomous. Feminine-gendered RU self-ref.
|
||||
|
||||
@@ -41,6 +41,15 @@ The server has iGPU + Vulkan. whisper.cpp already uses Vulkan; piper uses CPU
|
||||
(lightweight, real-time). Stubs let the daemon exercise end-to-end without
|
||||
any model files.
|
||||
|
||||
## Resident language model
|
||||
|
||||
One Qwen3-1.7B llama-server process serves both the grammar-constrained route
|
||||
contract and the persona/response contract. The target artifact is produced by
|
||||
RU continued pretraining followed by joint persona/router SFT. Stage-0 grammar,
|
||||
the classifier and stub phrasing remain availability fallbacks. A larger
|
||||
on-demand reasoner and custom TTS training are deferred until the main feature
|
||||
set is complete.
|
||||
|
||||
## Auth model
|
||||
|
||||
A cascade, not a pick-one:
|
||||
@@ -145,7 +154,7 @@ the append-only design means no existing row needs updating.
|
||||
|----------|-----------|---------|
|
||||
| CPU | Ryzen, 13GB RAM | loop, router classifier, delivery |
|
||||
| iGPU | Vulkan-capable | whisper.cpp (STT), piper (TTS) |
|
||||
| GPU layers | llama-server with `-ngl -1` | LFM 2.5-1.2B phraser |
|
||||
| GPU layers | llama-server with `-ngl -1` | Qwen3-Maven-1.7B resident model |
|
||||
|
||||
Models are per-component, downloaded separately (gitignored `models/` dir).
|
||||
No model is baked into the binary.
|
||||
|
||||
@@ -41,7 +41,7 @@ Config path: `~/.config/maven/mavend.json`. Full example with all options:
|
||||
"topic": "maven"
|
||||
},
|
||||
"phraser": {
|
||||
"model_path": "/mnt/hdd1/llms/LFM2.5/LFM2.5-1.2B-Instruct-Q4_K_M.gguf",
|
||||
"model_path": "/mnt/hdd1/llms/Qwen3-Maven-1.7B-Q8_0.gguf",
|
||||
"bin_path": "/usr/local/bin/llama-server",
|
||||
"n_gpu_layers": -1
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<h1>Dashboard</h1>
|
||||
<section class=card>
|
||||
<h2 class=card-title>
|
||||
<span class="dot dot-ok" style="margin-right:4px"></span>
|
||||
<span class="dot dot-ok" style="margin-right:6px"></span>
|
||||
presence
|
||||
</h2>
|
||||
<p>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The three sibling services Maven coordinates are headless JSON APIs (no web UI
|
||||
// of their own — arch §16: mavweb IS their human surface). This page is that
|
||||
// surface: a read-only observability panel that GETs each sibling's list
|
||||
// endpoints and renders them. Never mutates — enabling/executing stays on the
|
||||
// authed /tools + voice paths, never here.
|
||||
|
||||
// ecoURLs holds the sibling base URLs; empty ⇒ that panel shows "not configured".
|
||||
type ecoURLs struct {
|
||||
nexus, praxis, hexis string
|
||||
}
|
||||
|
||||
var ecoClient = &http.Client{Timeout: 6 * time.Second}
|
||||
|
||||
// getEco GETs path off base and decodes the JSON array into out. Returns a
|
||||
// human error string (empty on success) — honest per-panel state, no spinner.
|
||||
func getEco(ctx context.Context, base, path string, out any) string {
|
||||
if base == "" {
|
||||
return "not configured"
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path, nil)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
resp, err := ecoClient.Do(req)
|
||||
if err != nil {
|
||||
return "unreachable"
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Sprintf("http %d", resp.StatusCode)
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return "bad json"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Minimal projections of each sibling's list payload — only the columns the
|
||||
// panel shows. Extra JSON fields are ignored.
|
||||
type ecoEntity struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
State string `json:"state"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ecoItem struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
State string `json:"state"`
|
||||
Importance int `json:"importance"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
}
|
||||
|
||||
type ecoCap struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Operation string `json:"operation"`
|
||||
Risk string `json:"risk"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
}
|
||||
|
||||
type ecoPanel[T any] struct {
|
||||
Rows []T
|
||||
Err string
|
||||
}
|
||||
|
||||
type ecoData struct {
|
||||
Nexus ecoPanel[ecoEntity]
|
||||
Praxis ecoPanel[ecoItem]
|
||||
Hexis ecoPanel[ecoCap]
|
||||
}
|
||||
|
||||
func handleEcosystem(w http.ResponseWriter, r *http.Request, urls ecoURLs) {
|
||||
ctx := r.Context()
|
||||
var d ecoData
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(3)
|
||||
go func() { defer wg.Done(); d.Nexus.Err = getEco(ctx, urls.nexus, "/api/v1/entities?limit=50", &d.Nexus.Rows) }()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
d.Praxis.Err = getEco(ctx, urls.praxis, "/api/v1/items?limit=50", &d.Praxis.Rows)
|
||||
}()
|
||||
go func() { defer wg.Done(); d.Hexis.Err = getEco(ctx, urls.hexis, "/api/v1/capabilities", &d.Hexis.Rows) }()
|
||||
wg.Wait()
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := ecosystemTmpl.Execute(w, d); err != nil {
|
||||
log.Printf("ecosystem render: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{{template "shellTop" "ecosystem"}}
|
||||
<h1>Ecosystem</h1>
|
||||
<p class=hint>The sibling services Maven coordinates — read-only. Identity, attention, capabilities. Enabling & execution live on the authed surfaces, never here.</p>
|
||||
|
||||
<section class=card>
|
||||
<div class=section-header>
|
||||
<h2>Nexus <span class=card-sub>identity</span></h2>
|
||||
</div>
|
||||
{{with .Nexus}}
|
||||
{{if .Err}}<div class=empty>nexus — {{.Err}}</div>
|
||||
{{else if not .Rows}}<div class=empty>no entities yet.</div>
|
||||
{{else}}<div class=scroll><table class=mono><tr><th>id<th>type<th>name<th>state<th>updated</tr>
|
||||
{{range .Rows}}<tr><td class=key>{{.ID}}<td><span class=badge>{{.Type}}</span><td class=en>{{.DisplayName}}<td>{{.State}}<td>{{ago .UpdatedAt}}</tr>{{end}}
|
||||
</table></div>{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class=card>
|
||||
<div class=section-header>
|
||||
<h2>Praxis <span class=card-sub>attention</span></h2>
|
||||
</div>
|
||||
{{with .Praxis}}
|
||||
{{if .Err}}<div class=empty>praxis — {{.Err}}</div>
|
||||
{{else if not .Rows}}<div class=empty>nothing needs attention.</div>
|
||||
{{else}}<div class=scroll><table class=mono><tr><th>imp<th>kind<th>title<th>state<th>last seen</tr>
|
||||
{{range .Rows}}<tr><td>{{.Importance}}<td><span class=badge>{{.Kind}}</span><td class=en>{{.Title}}<td>{{.State}}<td>{{ago .LastSeenAt}}</tr>{{end}}
|
||||
</table></div>{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class=card>
|
||||
<div class=section-header>
|
||||
<h2>Hexis <span class=card-sub>capabilities</span></h2>
|
||||
</div>
|
||||
{{with .Hexis}}
|
||||
{{if .Err}}<div class=empty>hexis — {{.Err}}</div>
|
||||
{{else if not .Rows}}<div class=empty>no capabilities registered.</div>
|
||||
{{else}}<div class=scroll><table class=mono><tr><th>id<th>name<th>provider<th>operation<th>risk<th>access</tr>
|
||||
{{range .Rows}}<tr><td class=key>{{.ID}}<td class=en>{{.Name}}<td><span class=badge>{{.Provider}}</span><td>{{.Operation}}<td>{{.Risk}}<td>{{if .ReadOnly}}<span class="badge badge-ok">read-only</span>{{else}}<span class="badge badge-warn">mutating</span>{{end}}</tr>{{end}}
|
||||
</table></div>{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{template "shellBottom"}}
|
||||
<script>
|
||||
setInterval(() => fetch('/ecosystem').then(r => r.text()).then(html => {
|
||||
const d = new DOMParser().parseFromString(html, 'text/html');
|
||||
document.querySelectorAll('main.content section.card').forEach((old, i) => {
|
||||
const nu = d.querySelectorAll('main.content section.card')[i];
|
||||
if (nu) old.replaceWith(nu);
|
||||
});
|
||||
}), 15000);
|
||||
</script>
|
||||
</html>
|
||||
@@ -17,7 +17,7 @@
|
||||
</table></div>
|
||||
{{else}}
|
||||
<div class=empty>
|
||||
<svg class=icon width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
<svg class=icon width="24" height="24"><use href="/ethos-icons.svg#i-clock"/></svg>
|
||||
<div>no facts recorded yet</div>
|
||||
<div class=hint>facts appear here when maven observes or records events</div>
|
||||
</div>
|
||||
|
||||
+51
-34
@@ -60,6 +60,9 @@ var remindersHTML string
|
||||
//go:embed voice.html
|
||||
var voiceHTML string
|
||||
|
||||
//go:embed ecosystem.html
|
||||
var ecosystemHTML string
|
||||
|
||||
// ── Ethos Workstation Shell ──
|
||||
//
|
||||
// Two template pieces that wrap every page:
|
||||
@@ -94,6 +97,12 @@ var sidebarSections = []struct {
|
||||
{Label: "Routines", URL: "/routines", Key: "routines"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "Ecosystem",
|
||||
Pages: []struct{ Label, URL, Key string }{
|
||||
{Label: "Siblings", URL: "/ecosystem", Key: "ecosystem"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "AI",
|
||||
Pages: []struct{ Label, URL, Key string }{
|
||||
@@ -145,31 +154,33 @@ func sidebarHTML(active string) template.HTML {
|
||||
return template.HTML(b.String())
|
||||
}
|
||||
|
||||
// pageIcon returns a lucide-style inline SVG icon path for the given page.
|
||||
// pageIcon returns an ethos-icons.svg <use> reference for the given page.
|
||||
func pageIcon(key string) string {
|
||||
switch key {
|
||||
case "dash":
|
||||
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>`
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
||||
case "history":
|
||||
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-clock"/></svg>`
|
||||
case "trace":
|
||||
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>`
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
|
||||
case "notifications":
|
||||
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>`
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-bell"/></svg>`
|
||||
case "reminders":
|
||||
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
|
||||
case "routines":
|
||||
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>`
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-repeat"/></svg>`
|
||||
case "chat":
|
||||
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-message"/></svg>`
|
||||
case "voice":
|
||||
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>`
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-mic"/></svg>`
|
||||
case "ecosystem":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
||||
case "tools":
|
||||
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>`
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-settings"/></svg>`
|
||||
case "passkey":
|
||||
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-lock"/></svg>`
|
||||
default:
|
||||
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/></svg>`
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-search"/></svg>`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +203,8 @@ func pageTitle(key string) string {
|
||||
return "Chat"
|
||||
case "voice":
|
||||
return "Voice"
|
||||
case "ecosystem":
|
||||
return "Ecosystem"
|
||||
case "tools":
|
||||
return "Tools"
|
||||
case "passkey":
|
||||
@@ -209,19 +222,19 @@ const shellTopHTML = `{{define "shellTop"}}<!doctype html><meta charset=utf-8>
|
||||
<link rel=manifest href=/manifest.json>
|
||||
<title>maven · {{pageTitle .}}</title>
|
||||
<link rel=stylesheet href=/ui.css>
|
||||
<div class=shell>
|
||||
<div class=shell data-app=maven>
|
||||
<header class=topbar>
|
||||
<div class=breadcrumbs>
|
||||
<span class=current>{{pageTitle .}}</span>
|
||||
</div>
|
||||
<div class=topbar-actions>
|
||||
<div class=search-trigger onclick="window.__openSearch()" role=button tabindex=0>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<svg class=icon width="13" height="13"><use href="/ethos-icons.svg#i-search"/></svg>
|
||||
Search
|
||||
<span class=kbd-hint>Ctrl+/</span>
|
||||
</div>
|
||||
<button class=icon-btn onclick="window.__openPalette()" title="Command Palette (Ctrl+K)" aria-label="Command Palette">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 7 4"/><polyline points="17 4 20 4 20 7"/><polyline points="20 17 20 20 17 20"/><polyline points="7 20 4 20 4 17"/></svg>
|
||||
<svg class=icon width="15" height="15"><use href="/ethos-icons.svg#i-grid"/></svg>
|
||||
</button>
|
||||
<span class=conn-status>
|
||||
<span class="dot online" id=connDot></span>
|
||||
@@ -274,6 +287,10 @@ func shellFuncs() template.FuncMap {
|
||||
// CoreAPI, never writes — the store IS the audit trail, this just shows it.
|
||||
var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shellTopHTML + dashHTML + shellBottomHTML))
|
||||
|
||||
// ecosystemTmpl — read-only view of the Nexus/Praxis/Hexis siblings, whose only
|
||||
// human surface is here (they ship no web UI of their own).
|
||||
var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellTopHTML + ecosystemHTML + shellBottomHTML))
|
||||
|
||||
func noCache(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
@@ -298,6 +315,9 @@ func main() {
|
||||
pkOrigin := flag.String("webauthn-origin", "", "WebAuthn origin URL (e.g. https://maven.kvmx.ru)")
|
||||
pkRPID := flag.String("webauthn-rpid", "", "WebAuthn RP ID (e.g. maven.kvmx.ru)")
|
||||
pkFile := flag.String("passkey-file", "./passkeys.json", "path to WebAuthn credential store (JSON)")
|
||||
nexusURL := flag.String("nexus", "", "Nexus base URL for the /ecosystem panel (empty = not configured)")
|
||||
praxisURL := flag.String("praxis", "", "Praxis base URL for the /ecosystem panel (empty = not configured)")
|
||||
hexisURL := flag.String("hexis", "", "Hexis base URL for the /ecosystem panel (empty = not configured)")
|
||||
flag.Parse()
|
||||
|
||||
var core ipc.CoreAPI
|
||||
@@ -363,6 +383,10 @@ func main() {
|
||||
mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleRoutines(w, r, core)
|
||||
})
|
||||
ecoURLsCfg := ecoURLs{nexus: *nexusURL, praxis: *praxisURL, hexis: *hexisURL}
|
||||
mux.HandleFunc("/ecosystem", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleEcosystem(w, r, ecoURLsCfg)
|
||||
})
|
||||
// ----- passkey (WebAuthn) endpoints -----
|
||||
// Wired when both -core and a configured origin are present. The origin
|
||||
// must match the browser's view of mavweb (e.g. https://maven.kvmx.ru).
|
||||
@@ -601,13 +625,13 @@ const toolsHTML = `{{template "shellTop" "tools"}}
|
||||
<input type=text name=cmd class=input-wide placeholder="systemctl restart" required>
|
||||
<label><input type=checkbox name=destructive> destructive</label>
|
||||
<button class=btn>enable</button></form>
|
||||
<form method=post action=/tools style=display:inline>
|
||||
<form method=post action=/tools class=inline-form>
|
||||
<input type=hidden name=name value="{{.Name}}">
|
||||
<input type=hidden name=action value=dismiss>
|
||||
<button class="btn btn-muted">dismiss</button></form></td>
|
||||
</tr>{{end}}</table></div>
|
||||
{{else}}<div class=empty style=padding:var(--ethos-space-5)>
|
||||
<svg class=icon width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
{{else}}<div class=empty>
|
||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-search"/></svg>
|
||||
<div>no proposed tools</div>
|
||||
<div class=hint>maven will propose tools here when she needs help running an action</div>
|
||||
</div>{{end}}
|
||||
@@ -617,13 +641,13 @@ const toolsHTML = `{{template "shellTop" "tools"}}
|
||||
{{if .Enabled}}<div class=scroll><table><tr><th>name</th><th>scope</th><th>command</th><th></th><th></th></tr>
|
||||
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td><code>{{join .Cmd " "}}</code></td>
|
||||
<td>{{if .Destructive}}<span class=red>destructive</span>{{end}}</td>
|
||||
<td><form method=post action=/tools style=display:inline>
|
||||
<td><form method=post action=/tools class=inline-form>
|
||||
<input type=hidden name=name value="{{.Name}}">
|
||||
<input type=hidden name=scope value="{{.Scope}}">
|
||||
<input type=hidden name=action value=disable>
|
||||
<button class=btn>disable</button></form></td></tr>{{end}}</table></div>
|
||||
{{else}}<div class=empty style=padding:var(--ethos-space-5)>
|
||||
<svg class=icon width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
|
||||
{{else}}<div class=empty>
|
||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
|
||||
<div>no tools enabled</div>
|
||||
<div class=hint>enable proposed tools above, or ask maven to configure one</div>
|
||||
</div>{{end}}
|
||||
@@ -641,14 +665,14 @@ const routinesHTML = `{{template "shellTop" "routines"}}
|
||||
{{range .Proposed}}<tr>
|
||||
<td><code>{{.Action}}</code></td><td><code>{{.Object}}</code></td><td>{{.IntervalDays}} days</td>
|
||||
<td>
|
||||
<form method=post action=/routines style=display:inline>
|
||||
<form method=post action=/routines class=inline-form>
|
||||
<input type=hidden name=id value="{{.ID}}">
|
||||
<input type=hidden name=action value=dismiss>
|
||||
<button class="btn btn-muted">dismiss</button></form>
|
||||
</td>
|
||||
</tr>{{end}}</table></div>
|
||||
{{else}}<div class=empty style=padding:var(--ethos-space-5)>
|
||||
<svg class=icon width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
{{else}}<div class=empty>
|
||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-wave"/></svg>
|
||||
<div>no proposed routines</div>
|
||||
<div class=hint>maven will propose routines here when she detects a recurring pattern</div>
|
||||
</div>{{end}}
|
||||
@@ -1054,12 +1078,12 @@ const chatPageHTML = `{{template "shellTop" "chat"}}
|
||||
<h1>Chat</h1>
|
||||
<section class=card>
|
||||
{{if .Error}}<div class="msg msg-err">{{.Error}}</div>{{end}}
|
||||
<div class=scroll style="max-height:60vh;overflow-y:auto;margin-bottom:var(--ethos-space-4)" id=chatHistory>
|
||||
<div class="scroll chat-scroll" id=chatHistory>
|
||||
{{range .Messages}}
|
||||
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}</div>
|
||||
{{else}}
|
||||
<div class=empty style=padding:var(--ethos-space-5)>
|
||||
<svg class=icon width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<div class=empty>
|
||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-message"/></svg>
|
||||
<div>start a conversation</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -1073,13 +1097,6 @@ const chatPageHTML = `{{template "shellTop" "chat"}}
|
||||
var ch = document.getElementById('chatHistory');
|
||||
if(ch) ch.scrollTop = ch.scrollHeight;
|
||||
</script>
|
||||
<style>
|
||||
.chat-msg { padding: var(--ethos-space-2) var(--ethos-space-3); border-radius: var(--ethos-radius-m); margin-bottom: var(--ethos-space-1); }
|
||||
.chat-msg.user { background: var(--ethos-bg-subtle); }
|
||||
.chat-msg.assistant { background: var(--ethos-bg-elevated); }
|
||||
.chat-form { display:flex; gap:var(--ethos-space-2); }
|
||||
.chat-form input { flex:1; }
|
||||
</style>
|
||||
{{template "shellBottom"}}`
|
||||
|
||||
// handleChatPage renders the chat conversation page.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<td class=text-max>{{.Message}}</td>
|
||||
</tr>{{end}}</table></div>
|
||||
{{else}}<div class=empty>
|
||||
<svg class=icon width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
|
||||
<svg class=icon width="24" height="24"><use href="/ethos-icons.svg#i-bell"/></svg>
|
||||
<div>no notifications yet</div>
|
||||
<div class=hint>check back later or ask maven a question</div>
|
||||
</div>{{end}}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<td class=text-max>{{.Payload}}</td>
|
||||
</tr>{{end}}</table></div>
|
||||
{{else}}<div class=empty>
|
||||
<svg class=icon width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
<svg class=icon width="24" height="24"><use href="/ethos-icons.svg#i-calendar"/></svg>
|
||||
<div>no reminders yet</div>
|
||||
<div class=hint>ask maven to remind you of something</div>
|
||||
</div>{{end}}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
ETHOS — icon set
|
||||
One hand, one grid. Every icon: viewBox 0 0 24 24, stroke="currentColor",
|
||||
stroke-width 1.7, round caps/joins, fill="none". Transport glyphs (play,
|
||||
pause, prev, next, more) are the only filled exceptions.
|
||||
|
||||
USE: <svg class="icon" width="22" height="22"><use href="/ethos-icons.svg#i-search"/></svg>
|
||||
color + width/height come from the consumer; the icon inherits them.
|
||||
|
||||
EXTEND: add a new <symbol id="i-NAME"> on the same 24px grid, same stroke,
|
||||
same corner feel. Match the existing hand — do not import lucide or
|
||||
any other pack. Keep ids prefixed i- and kebab-cased.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
|
||||
|
||||
<!-- ── app mark / motif seeds ── -->
|
||||
<symbol id="i-wave" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round">
|
||||
<path d="M2 12h1M6 8v8M10 4v16M14 7v10M18 5v14M21 11v2"/>
|
||||
</symbol>
|
||||
<symbol id="i-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
|
||||
<rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/>
|
||||
<rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ── navigation ── -->
|
||||
<symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 11l8-6 8 6M6 10v9h12v-9"/>
|
||||
</symbol>
|
||||
<symbol id="i-listen" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 18V9l14-3v9"/><circle cx="6" cy="18" r="2.4"/><circle cx="18" cy="15" r="2.4"/>
|
||||
</symbol>
|
||||
<symbol id="i-library" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
|
||||
<rect x="4" y="4" width="6" height="16" rx="1.5"/><rect x="14" y="4" width="6" height="16" rx="1.5"/>
|
||||
</symbol>
|
||||
<symbol id="i-album" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
|
||||
<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="2"/>
|
||||
</symbol>
|
||||
<symbol id="i-artist" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="8" r="4"/><path d="M5 20c0-3.5 3.1-6 7-6s7 2.5 7 6"/>
|
||||
</symbol>
|
||||
<symbol id="i-queue" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 7h11M4 12h11M4 17h7M18 9v8"/><circle cx="18" cy="18.5" r="1.6"/>
|
||||
</symbol>
|
||||
<symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
|
||||
<path d="M4 7a1 1 0 011-1h4.5l2 2H19a1 1 0 011 1v8a1 1 0 01-1 1H5a1 1 0 01-1-1z"/>
|
||||
</symbol>
|
||||
<symbol id="i-file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
|
||||
<path d="M14 3H7a1 1 0 00-1 1v16a1 1 0 001 1h10a1 1 0 001-1V8z"/><path d="M14 3v5h5"/>
|
||||
</symbol>
|
||||
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3.2"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ── transport (filled) ── -->
|
||||
<symbol id="i-play" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></symbol>
|
||||
<symbol id="i-pause" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5h3v14H8zM13 5h3v14h-3z"/></symbol>
|
||||
<symbol id="i-prev" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6z"/><path d="M20 6v12l-9-6z"/></symbol>
|
||||
<symbol id="i-next" viewBox="0 0 24 24" fill="currentColor"><path d="M16 6h2v12h-2z"/><path d="M6 6v12l9-6z"/></symbol>
|
||||
<symbol id="i-shuffle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 4h4v4M20 4l-6 6M4 20l16-16M16 20h4v-4M14 14l6 6M4 4l4 4"/>
|
||||
</symbol>
|
||||
<symbol id="i-repeat" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 2l3 3-3 3M20 5H8a4 4 0 00-4 4v1M7 22l-3-3 3-3M4 19h12a4 4 0 004-4v-1"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ── actions / status ── -->
|
||||
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||
<circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/>
|
||||
</symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l4.5 4.5L20 7"/></symbol>
|
||||
<symbol id="i-chevron-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6"/></symbol>
|
||||
<symbol id="i-chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></symbol>
|
||||
<symbol id="i-more" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></symbol>
|
||||
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 9a6 6 0 0112 0c0 5 2 6 2 6H4s2-1 2-6M10 21h4"/>
|
||||
</symbol>
|
||||
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 4v11M8 11l4 4 4-4M5 20h14"/>
|
||||
</symbol>
|
||||
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 15V4M8 8l4-4 4 4M5 20h14"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ── terminal / code (tmux-view, drive) ── -->
|
||||
<symbol id="i-terminal" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 4l8 8-8 8M16 19h4"/>
|
||||
</symbol>
|
||||
<symbol id="i-refresh" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 4v6h-6M3 20v-6h6"/>
|
||||
<path d="M20.5 10a8.5 8.5 0 00-16 3M3.5 14a8.5 8.5 0 0016-3"/>
|
||||
</symbol>
|
||||
<symbol id="i-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="12" height="12" rx="1.5"/>
|
||||
<path d="M15 9V6a1 1 0 00-1-1H5a1 1 0 00-1 1v9a1 1 0 001 1h3"/>
|
||||
</symbol>
|
||||
<symbol id="i-send" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12h16M13 6l6 6-6 6"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ── maven-specific ── -->
|
||||
<symbol id="i-clock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/>
|
||||
</symbol>
|
||||
<symbol id="i-calendar" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="4" y="5" width="16" height="16" rx="1.5"/><path d="M4 10h16M8 3v3M16 3v3"/>
|
||||
<path d="M10 15l2 2 4-4"/>
|
||||
</symbol>
|
||||
<symbol id="i-mic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="10" y="2" width="4" height="12" rx="2"/><path d="M6 11a6 6 0 0012 0M12 19v4M9 23h6"/>
|
||||
</symbol>
|
||||
<symbol id="i-message" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 4h16a2 2 0 012 2v12a2 2 0 01-2 2H8l-4 4V6a2 2 0 012-2z"/>
|
||||
</symbol>
|
||||
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
|
||||
<rect x="6" y="11" width="12" height="10" rx="1.5"/><path d="M9 11V7a3 3 0 016 0v4"/>
|
||||
</symbol>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.4 KiB |
@@ -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: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>' },
|
||||
{ label: 'History', url: '/history', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>' },
|
||||
{ label: 'Rule Trace', url: '/trace', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>' },
|
||||
{ label: 'Notifications', url: '/notifications', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>' },
|
||||
{ label: 'Voice', url: '/', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>' },
|
||||
{ label: 'Tools', url: '/tools', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>' },
|
||||
{ label: 'Passkey', url: '/auth/passkey', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>' },
|
||||
{ label: 'Dashboard', url: '/dash', icon: '<svg width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>' },
|
||||
{ label: 'History', url: '/history', icon: '<svg width="14" height="14"><use href="/ethos-icons.svg#i-clock"/></svg>' },
|
||||
{ label: 'Rule Trace', url: '/trace', icon: '<svg width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>' },
|
||||
{ label: 'Notifications', url: '/notifications', icon: '<svg width="14" height="14"><use href="/ethos-icons.svg#i-bell"/></svg>' },
|
||||
{ label: 'Voice', url: '/', icon: '<svg width="14" height="14"><use href="/ethos-icons.svg#i-mic"/></svg>' },
|
||||
{ label: 'Tools', url: '/tools', icon: '<svg width="14" height="14"><use href="/ethos-icons.svg#i-settings"/></svg>' },
|
||||
{ label: 'Passkey', url: '/auth/passkey', icon: '<svg width="14" height="14"><use href="/ethos-icons.svg#i-lock"/></svg>' },
|
||||
];
|
||||
|
||||
function buildPalette() {
|
||||
@@ -65,7 +65,7 @@
|
||||
paletteEl.innerHTML =
|
||||
'<div class=cmd-palette>' +
|
||||
'<div class=cmd-palette-input>' +
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>' +
|
||||
'<svg width="14" height="14"><use href="/ethos-icons.svg#i-search"/></svg>' +
|
||||
'<input type=text placeholder="Go to page or run action…" spellcheck=false autofocus>' +
|
||||
'</div>' +
|
||||
'<div class=cmd-palette-list id=paletteList></div>' +
|
||||
|
||||
+492
-374
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{{template "shellTop" "trace"}}
|
||||
<h1>Rule Trace</h1>
|
||||
<div class=hint style=margin-bottom:var(--ethos-space-4)>{{.Now | ago}} — winner: <strong>{{if .Winner}}{{.Winner}}{{else}}nothing fired{{end}}</strong></div>
|
||||
<div class="hint mb-4">{{.Now | ago}} — winner: <strong>{{if .Winner}}{{.Winner}}{{else}}nothing fired{{end}}</strong></div>
|
||||
<div class=scroll><table class=mono>
|
||||
<tr><th>rule<th>sev<th>predicate<th>gate<th>blocked by<th>detail<th>selected<th>lost to</tr>
|
||||
{{range .Rules}}<tr>
|
||||
|
||||
+24
-49
@@ -1,61 +1,36 @@
|
||||
{{template "shellTop" "voice"}}
|
||||
<style>
|
||||
.voice{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1.5rem;padding:1rem 0}
|
||||
#btn{width:140px;height:140px;border-radius:50%;border:3px solid var(--ethos-accent);background:var(--ethos-surface0);color:var(--ethos-accent);font-size:1rem;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all var(--ethos-motion);user-select:none;-webkit-tap-highlight-color:transparent;touch-action:manipulation}
|
||||
#btn:active,#btn.active{background:rgba(111,191,139,.12);border-color:var(--ethos-green);color:var(--ethos-green);transform:scale(1.05)}
|
||||
#btn:disabled{opacity:.3;border-color:var(--ethos-border)}
|
||||
#status{font-size:var(--ethos-fs-sm);color:var(--ethos-muted);min-height:1.2em}
|
||||
#log{width:100%;max-width:480px;max-height:40vh;overflow-y:auto;font-size:var(--ethos-fs-sm);color:var(--ethos-muted);line-height:1.6;padding:var(--ethos-space-2);border-top:1px solid var(--ethos-border)}
|
||||
#log .reply{color:var(--ethos-green)}
|
||||
#log .error{color:var(--ethos-red)}
|
||||
#log .push{color:var(--ethos-purple)}
|
||||
#cheat{width:100%;max-width:480px;font-size:var(--ethos-fs-sm);border-top:1px solid var(--ethos-border);color:var(--ethos-muted)}
|
||||
#cheat summary{cursor:pointer;padding:var(--ethos-space-2) 0;color:var(--ethos-accent);letter-spacing:.05em;text-transform:uppercase;font-size:var(--ethos-fs-xs);user-select:none}
|
||||
#cheat dl{display:grid;grid-template-columns:auto 1fr;gap:var(--ethos-space-1) var(--ethos-space-3);padding:var(--ethos-space-1) 0 var(--ethos-space-3)}
|
||||
#cheat dt{color:var(--ethos-green);font-weight:500;white-space:nowrap}
|
||||
#cheat dd{color:var(--ethos-secondary);line-height:1.5}
|
||||
#cheat dd span{color:var(--ethos-muted)}
|
||||
.cheat-en{display:revert}
|
||||
.cheat-ru{display:none}
|
||||
html[data-lang=ru] .cheat-en{display:none}
|
||||
html[data-lang=ru] .cheat-ru{display:revert}
|
||||
.lang-toggle{display:flex;gap:0;justify-content:center;margin-top:1rem}
|
||||
.lang-toggle span{font-size:var(--ethos-fs-xs);color:var(--ethos-muted);cursor:pointer;padding:.2rem .5rem;letter-spacing:.1em;text-transform:uppercase;border:1px solid var(--ethos-border);transition:color var(--ethos-motion);user-select:none}
|
||||
.lang-toggle span:first-child{border-radius:var(--ethos-radius) 0 0 var(--ethos-radius);border-right:none}
|
||||
.lang-toggle span:last-child{border-radius:0 var(--ethos-radius) var(--ethos-radius) 0;border-left:none}
|
||||
.lang-toggle span.active{color:var(--ethos-accent);border-color:var(--ethos-accent);background:var(--ethos-accent-dim)}
|
||||
.lang-toggle span:hover{color:var(--ethos-text)}
|
||||
</style>
|
||||
<div class=voice>
|
||||
<div id="status">tap & hold to speak</div>
|
||||
<button id="btn" type="button">🎙</button>
|
||||
<div id="log"></div>
|
||||
<details id="cheat">
|
||||
<div class=voice-page>
|
||||
<div class=voice-status id=status>tap & hold to speak</div>
|
||||
<button class=voice-btn id=btn type=button>
|
||||
<svg width="36" height="36"><use href="/ethos-icons.svg#i-mic"/></svg>
|
||||
</button>
|
||||
<div class=voice-log id=log></div>
|
||||
<details class=voice-cheat id=cheat>
|
||||
<summary>Cheatsheet</summary>
|
||||
<dl>
|
||||
<dt>Act</dt>
|
||||
<dd class="cheat-ru">включи свет · сделай громче</dd>
|
||||
<dd class="cheat-en">restart nginx · turn on the light</dd>
|
||||
<dd class=ru>включи свет · сделай громче</dd>
|
||||
<dd class=en>restart nginx · turn on the light</dd>
|
||||
<dt>Reminder</dt>
|
||||
<dd class="cheat-ru">напомни через 4 часа размяться</dd>
|
||||
<dd class="cheat-en">remind me tomorrow at 8am to call the doctor</dd>
|
||||
<dd class=ru>напомни через 4 часа размяться</dd>
|
||||
<dd class=en>remind me tomorrow at 8am to call the doctor</dd>
|
||||
<dt>Fact</dt>
|
||||
<dd class="cheat-ru">выпил кофе · вес 73 кг</dd>
|
||||
<dd class="cheat-en">ran 3 kilometers</dd>
|
||||
<dd class=ru>выпил кофе · вес 73 кг</dd>
|
||||
<dd class=en>ran 3 kilometers</dd>
|
||||
<dt>Note</dt>
|
||||
<dd class="cheat-ru">запиши рецепт блинов</dd>
|
||||
<dd class="cheat-en">note: add a backup job for the db</dd>
|
||||
<dd class=ru>запиши рецепт блинов</dd>
|
||||
<dd class=en>note: add a backup job for the db</dd>
|
||||
<dt>Query</dt>
|
||||
<dd class="cheat-ru">какой сегодня день · какой прогноз погоды</dd>
|
||||
<dd class="cheat-en">what version are you · what's the weather</dd>
|
||||
<dd class=ru>какой сегодня день · какой прогноз погоды</dd>
|
||||
<dd class=en>what version are you · what's the weather</dd>
|
||||
<dt>System</dt>
|
||||
<dd class="cheat-ru">включи тихий режим · как загрузка системы</dd>
|
||||
<dd class="cheat-en">quiet mode on · system load</dd>
|
||||
<dd class=ru>включи тихий режим · как загрузка системы</dd>
|
||||
<dd class=en>quiet mode on · system load</dd>
|
||||
</dl>
|
||||
</details>
|
||||
<div class=lang-toggle>
|
||||
<span class="active" data-lang="ru">RU</span>
|
||||
<span data-lang="en">EN</span>
|
||||
<div class=voice-lang>
|
||||
<span class="active" data-lang=ru>RU</span>
|
||||
<span data-lang=en>EN</span>
|
||||
</div>
|
||||
</div>
|
||||
<script defer src="/app.js"></script>
|
||||
@@ -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);
|
||||
|
||||
@@ -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.<domain>
|
||||
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.<domain>
|
||||
volumes:
|
||||
- praxis-data:/data
|
||||
networks: [ecosystem]
|
||||
|
||||
hexis:
|
||||
build: ../../../hexis
|
||||
restart: unless-stopped
|
||||
ports: ["127.0.0.1:9741:9741"] # host nginx → hexis.<domain> (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
|
||||
@@ -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:<port>. Add TLS (certbot / your existing cert block) per server.
|
||||
#
|
||||
# NOTE: hexis.<domain> 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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
+14
-1
@@ -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=<base64-32B>.
|
||||
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
|
||||
|
||||
@@ -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`)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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<N>/` — 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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:<id>` in the value/source metadata
|
||||
- Speaker identity is available as context to the router, phraser, and replier ("ok, <name>")
|
||||
|
||||
**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:<id>", 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:<id>` 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
|
||||
@@ -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
|
||||
@@ -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:<name>` 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:<device>` 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
|
||||
@@ -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:<feed_name>"`; 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:<name>`)
|
||||
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:<feed_name>")`
|
||||
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
|
||||
@@ -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:<name>`)
|
||||
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
|
||||
@@ -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",
|
||||
|
||||
@@ -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":<enum>, key?, value?, text?, verb?}`, GBNF-constrained —
|
||||
contract** — `[{"intent":<enum>, 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<phone>` 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.
|
||||
@@ -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).
|
||||
|
||||
@@ -34,7 +34,7 @@ string ::= "\"" ([^"\\] | "\\" .)* "\""
|
||||
ws ::= [ \t\n]*
|
||||
`
|
||||
|
||||
const routeSystem = `Классифицируй ровно одно сообщение пользователя. Верни ОДИН JSON-объект.
|
||||
const routeSystem = `Классифицируй ровно одно сообщение пользователя. Верни ОДИН JSON-массив действий.
|
||||
|
||||
Ровно одно намерение: fact, reminder, note, query, act, chat, system.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user