chore: docker, config, delivery sinks, dialogue, and agent docs
- Dockerfile: multi-stage build with CGO_ENABLED=0, embedder model copy, non-root user, healthcheck, and /data volume. - docker-compose.yml: mavend + mavweb services with shared volume, health checks, and restart policy. - .gitignore: ignore models/llm/*.gguf, deploy/telegram.env, tmp artifacts. - deploy/mavend.json: add LLM, phraser, voice sections (embedder, model paths, wake sensitivity). Add telegram token env-var expansion. - deploy/telegram.env.example: template for telegram bot token. - internal/config/config.go: add LLM config struct, voice config struct (embedder, llama, wake sensitivity), telegram token loading. - telegramsink: add chat intent delivery support alongside existing types. - voicesink: skip empty payloads in delivery. - dialogue/session: add chat intent to anaphora resolution, test coverage. - AGENTS.md: update with LLM embedder, LFM model download/configure steps, new UI conventions. - REARCH.md: architecture research document. - cmd/mavend/main.go: wire LLM config, phraser, embedder, telegram config, WebAuthn, IPC event/routine handlers, and reactive notes.
This commit is contained in:
@@ -24,6 +24,8 @@ deps/
|
||||
|
||||
# Deploy secret (the at-rest db key) — never commit
|
||||
deploy/db_key.env
|
||||
# Deploy secret (telegram bot token + chat id) — never commit
|
||||
deploy/telegram.env
|
||||
|
||||
# Temp files
|
||||
/tmp/
|
||||
|
||||
@@ -74,6 +74,38 @@ 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
|
||||
|
||||
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).
|
||||
|
||||
**Download the model** (GGUF, ~780 MB):
|
||||
|
||||
```sh
|
||||
make download-llm
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```sh
|
||||
curl -sL "https://huggingface.co/lfm/LFM2.5-1.2B-Instruct-GGUF/resolve/main/LFM2.5-1.2B-Instruct-Q4_K_M.gguf" \
|
||||
-o models/llm/LFM2.5-1.2B-Instruct-Q4_K_M.gguf
|
||||
```
|
||||
|
||||
**Configure in `deploy/mavend.json`** — the `phraser` block points at this
|
||||
model and the daemon spawns `llama-server` as a subprocess. The router and
|
||||
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
|
||||
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.
|
||||
|
||||
## Web UI conventions
|
||||
|
||||
- All server-rendered pages share `cmd/mavweb/static/ui.css` (served at
|
||||
|
||||
+27
@@ -46,6 +46,30 @@ RUN go build -o /out/mavend ./cmd/mavend && \
|
||||
go build -o /out/mavpoll ./cmd/mavpoll && \
|
||||
go build -o /out/mavcaldav ./cmd/mavcaldav
|
||||
|
||||
# llama.cpp Vulkan build — the phraser/router LFM engine (llama-server). Built
|
||||
# from source (not a prebuilt vendored blob) so the binary's glibc/GLIBCXX match
|
||||
# the trixie runtime and GPU offload rides mesa's RADV Vulkan driver — the same
|
||||
# path whisper already uses on homesrv's AMD iGPU (RADV RENOIR). Pinned to b9601 (parity with
|
||||
# the host's known-good build). Static (BUILD_SHARED_LIBS=OFF) ⇒ one self-
|
||||
# contained binary, no libggml/libllama .so to juggle in the runtime; only
|
||||
# libvulkan.so.1 + libgomp (both already in the runtime) are needed at load.
|
||||
FROM debian:trixie-slim AS llama
|
||||
ARG LLAMA_REF=b9601
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates git cmake build-essential libvulkan-dev \
|
||||
glslc glslang-tools spirv-headers spirv-tools \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN git clone --depth 1 --branch ${LLAMA_REF} \
|
||||
https://github.com/ggml-org/llama.cpp /src/llama.cpp
|
||||
WORKDIR /src/llama.cpp
|
||||
RUN cmake -B build \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DGGML_VULKAN=ON \
|
||||
-DBUILD_SHARED_LIBS=OFF \
|
||||
-DLLAMA_CURL=OFF \
|
||||
-DLLAMA_BUILD_SERVER=ON \
|
||||
&& cmake --build build --config Release -j"$(nproc)" --target llama-server
|
||||
|
||||
FROM debian:trixie-slim AS runtime
|
||||
# tzdata so the TZ env (set in compose) resolves — otherwise Go can't load the
|
||||
# zone and time.Now() stays UTC, and mavend answers clock/date queries and
|
||||
@@ -71,6 +95,9 @@ RUN cd /opt/maven/lib \
|
||||
&& ln -sf libonnxruntime.so.1.26.0 libonnxruntime.so \
|
||||
&& ln -sf libonnxruntime.so.1.26.0 libonnxruntime.so.1
|
||||
COPY --from=build /out/ /opt/maven/bin/
|
||||
# the LFM engine: static Vulkan llama-server on PATH; the phraser spawns it by
|
||||
# name (bin_path "llama-server"). GPU offload needs /dev/dri passed to mavend.
|
||||
COPY --from=llama /src/llama.cpp/build/bin/llama-server /opt/maven/bin/
|
||||
|
||||
ENV LD_LIBRARY_PATH=/opt/maven/lib PATH=/opt/maven/bin:$PATH
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Maven — Re-architecture (router-centric, 2026-07-10)
|
||||
|
||||
> Supersedes the classifier-first routing model. Agreed in a design session
|
||||
> after diagnosing that homesrv deploys with a **stub phraser** (no LLM
|
||||
> running) and an embedder-classifier that routes by nearest-neighbor between
|
||||
> frozen seed phrases — the structural cause of "she messes up queries."
|
||||
>
|
||||
> Hardware reality: homesrv = Ryzen 5 5600U laptop, Vega iGPU, 14 GB shared
|
||||
> RAM. Workstation (RX 7900 XT) is NOT the deploy target and is often busy.
|
||||
> So: small models, on-demand where heavy, always-on where cheap.
|
||||
|
||||
## 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.
|
||||
|
||||
**If the router is good, Maven feels good.** Routing is the linchpin.
|
||||
|
||||
## The spine
|
||||
|
||||
```
|
||||
utterance
|
||||
→ [world-state context] cheap: time, presence, calendar_busy, weather (no LLM)
|
||||
→ ROUTER = LFM (always-on, agentic)
|
||||
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
|
||||
```
|
||||
|
||||
- **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.
|
||||
- **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
|
||||
for better RU retrieval later (model swap, not architecture).
|
||||
|
||||
### Router output
|
||||
- Constrained structured JSON action `{tool, args, escalate}` — NOT free-form
|
||||
multi-step function-calling. Sub-1B is far more reliable emitting a fixed
|
||||
schema. Enforce with a **GBNF grammar** in llama.cpp (near-bulletproof).
|
||||
- Keep the existing **stage-0 exact-match fast-path** for dead-obvious commands
|
||||
(skips the router entirely) — cheap insurance, already built.
|
||||
|
||||
## The proactive / memory half — one background engine
|
||||
|
||||
"Take notes," "remember," "reflect," "suggest do you want to add X?", "remind"
|
||||
are NOT request-path features. They are one **digestion worker**:
|
||||
|
||||
```
|
||||
DIGESTION WORKER (periodic + event-driven, off the request path)
|
||||
• reads new facts/notes since last pass
|
||||
• RAG-consolidates: dedupe, link, summarize into durable memory
|
||||
• 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
|
||||
```
|
||||
|
||||
Notes capture is a deterministic Tier-0 tool; making notes *mean something
|
||||
later* is the worker + RAG.
|
||||
|
||||
## Layer table
|
||||
|
||||
| Layer | What | Runs |
|
||||
|---|---|---|
|
||||
| Context | world-state (time/presence/calendar/weather) | always, no LLM |
|
||||
| **Router** | LFM agentic 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)** |
|
||||
| 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
|
||||
consolidation, RAG read-back.
|
||||
4. **Embodiment** — voice quality (deferred).
|
||||
|
||||
## Non-goals (unchanged)
|
||||
Never phones home. Not a nag. Not autonomous. Feminine-gendered RU self-ref.
|
||||
+100
-38
@@ -21,19 +21,20 @@
|
||||
// verifier + ask-password transport (open spec item).
|
||||
//
|
||||
// Cold-start unlock (2026-07-06):
|
||||
// When a passkey credential is enrolled AND no env key is set, the daemon
|
||||
// starts in LOCKED mode: the IPC server runs but rejects all store methods
|
||||
// except MethodAssertStepUp and MethodUnlock. A passkey assertion followed
|
||||
// by MethodUnlock (with the same credential's public key) unwraps the at-rest
|
||||
// AES-256 key from a wrapped blob on disk (HKDF-SHA256 + AES-GCM) and opens
|
||||
// the encrypted store. After unlock, the daemon wires voice, loop, and
|
||||
// delivery and runs normally.
|
||||
//
|
||||
// Fallback: when db_key_env is set (or no wrapped file exists), the daemon
|
||||
// starts unlocked from the env key (pre-unlock behavior). Enrolling a passkey
|
||||
// while unlocked calls MethodStoreEncryptionKey to wrap the env key and
|
||||
// persist the wrapped blob — enabling cold-start unlock on the next boot
|
||||
// after the env key is removed.
|
||||
// When a passkey credential is enrolled AND no env key is set, the daemon
|
||||
// starts in LOCKED mode: the IPC server runs but rejects all store methods
|
||||
// except MethodAssertStepUp and MethodUnlock. A passkey assertion followed
|
||||
// by MethodUnlock (with the same credential's public key) unwraps the at-rest
|
||||
// AES-256 key from a wrapped blob on disk (HKDF-SHA256 + AES-GCM) and opens
|
||||
// the encrypted store. After unlock, the daemon wires voice, loop, and
|
||||
// delivery and runs normally.
|
||||
//
|
||||
// Fallback: when db_key_env is set (or no wrapped file exists), the daemon
|
||||
// starts unlocked from the env key (pre-unlock behavior). Enrolling a passkey
|
||||
// while unlocked calls MethodStoreEncryptionKey to wrap the env key and
|
||||
// persist the wrapped blob — enabling cold-start unlock on the next boot
|
||||
// after the env key is removed.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -102,30 +103,82 @@ type lockedAPI struct{}
|
||||
|
||||
var _ ipc.CoreAPI = (*lockedAPI)(nil)
|
||||
|
||||
func (l *lockedAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64, error) { return 0, errLocked }
|
||||
func (l *lockedAPI) LatestFact(ctx context.Context, key string) (ipc.Fact, error) { return ipc.Fact{}, errLocked }
|
||||
func (l *lockedAPI) LatestFactBySource(ctx context.Context, key, source string) (ipc.Fact, error) { return ipc.Fact{}, errLocked }
|
||||
func (l *lockedAPI) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) { return 0, errLocked }
|
||||
func (l *lockedAPI) Presence(ctx context.Context) (ipc.Presence, error) { return ipc.Presence{}, errLocked }
|
||||
func (l *lockedAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) { return 0, errLocked }
|
||||
func (l *lockedAPI) MarkReminder(ctx context.Context, id int64, status string) error { return errLocked }
|
||||
func (l *lockedAPI) ListReminders(ctx context.Context, n int) ([]ipc.Reminder, error) { return nil, errLocked }
|
||||
func (l *lockedAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) { return 0, errLocked }
|
||||
func (l *lockedAPI) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error { return errLocked }
|
||||
func (l *lockedAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) { return nil, errLocked }
|
||||
func (l *lockedAPI) RecentFacts(ctx context.Context, n int) ([]ipc.Fact, error) { return nil, errLocked }
|
||||
func (l *lockedAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]ipc.Fact, error) { return nil, errLocked }
|
||||
func (l *lockedAPI) RecentNudges(ctx context.Context, n int) ([]ipc.Nudge, error) { return nil, errLocked }
|
||||
func (l *lockedAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) { return 0, errLocked }
|
||||
func (l *lockedAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]ipc.Note, error) { return nil, errLocked }
|
||||
func (l *lockedAPI) RecentNotes(ctx context.Context, n int) ([]ipc.Note, error) { return nil, errLocked }
|
||||
func (l *lockedAPI) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) { return false, errLocked }
|
||||
func (l *lockedAPI) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error { return errLocked }
|
||||
func (l *lockedAPI) DisableTool(ctx context.Context, name string) error { return errLocked }
|
||||
func (l *lockedAPI) LookupTool(ctx context.Context, name string) (ipc.Tool, error) { return ipc.Tool{}, errLocked }
|
||||
func (l *lockedAPI) ListTools(ctx context.Context, status string) ([]ipc.Tool, error) { return nil, errLocked }
|
||||
func (l *lockedAPI) RevertFact(ctx context.Context, key string) (int64, error) { return 0, errLocked }
|
||||
func (l *lockedAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) { return ipc.TickTrace{}, errLocked }
|
||||
func (l *lockedAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64, error) {
|
||||
return 0, errLocked
|
||||
}
|
||||
func (l *lockedAPI) LatestFact(ctx context.Context, key string) (ipc.Fact, error) {
|
||||
return ipc.Fact{}, errLocked
|
||||
}
|
||||
func (l *lockedAPI) LatestFactBySource(ctx context.Context, key, source string) (ipc.Fact, error) {
|
||||
return ipc.Fact{}, errLocked
|
||||
}
|
||||
func (l *lockedAPI) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) {
|
||||
return 0, errLocked
|
||||
}
|
||||
func (l *lockedAPI) Presence(ctx context.Context) (ipc.Presence, error) {
|
||||
return ipc.Presence{}, errLocked
|
||||
}
|
||||
func (l *lockedAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
|
||||
return 0, errLocked
|
||||
}
|
||||
func (l *lockedAPI) MarkReminder(ctx context.Context, id int64, status string) error {
|
||||
return errLocked
|
||||
}
|
||||
func (l *lockedAPI) ListReminders(ctx context.Context, n int) ([]ipc.Reminder, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) {
|
||||
return 0, errLocked
|
||||
}
|
||||
func (l *lockedAPI) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error {
|
||||
return errLocked
|
||||
}
|
||||
func (l *lockedAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) RecentFacts(ctx context.Context, n int) ([]ipc.Fact, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]ipc.Fact, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) RecentNudges(ctx context.Context, n int) ([]ipc.Nudge, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
|
||||
return 0, errLocked
|
||||
}
|
||||
func (l *lockedAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]ipc.Note, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) RecentNotes(ctx context.Context, n int) ([]ipc.Note, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
|
||||
return false, errLocked
|
||||
}
|
||||
func (l *lockedAPI) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error {
|
||||
return errLocked
|
||||
}
|
||||
func (l *lockedAPI) DisableTool(ctx context.Context, name string) error { return errLocked }
|
||||
func (l *lockedAPI) DeleteTool(ctx context.Context, name string) error { return errLocked }
|
||||
func (l *lockedAPI) ListProposedRoutines(ctx context.Context) ([]ipc.ProposedRoutine, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) DismissProposedRoutine(ctx context.Context, id int64) error { return errLocked }
|
||||
func (l *lockedAPI) LookupTool(ctx context.Context, name string) (ipc.Tool, error) {
|
||||
return ipc.Tool{}, errLocked
|
||||
}
|
||||
func (l *lockedAPI) ListTools(ctx context.Context, status string) ([]ipc.Tool, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) RevertFact(ctx context.Context, key string) (int64, error) { return 0, errLocked }
|
||||
func (l *lockedAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||
return "", errLocked
|
||||
}
|
||||
func (l *lockedAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
|
||||
return ipc.TickTrace{}, errLocked
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
|
||||
@@ -236,7 +289,7 @@ func run(args []string) error {
|
||||
}
|
||||
|
||||
// voice
|
||||
voiceW, err = wireVoice(cfg, ipc.NewStoreAPI(st), phr, st.VectorMemory())
|
||||
voiceW, err = wireVoice(cfg, ipc.NewStoreAPI(st), phr, st.VectorMemory(), st)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire voice: %w", err)
|
||||
}
|
||||
@@ -266,6 +319,7 @@ func run(args []string) error {
|
||||
Ntfy: ntfy,
|
||||
Telegram: telegram,
|
||||
Voice: voiceSink,
|
||||
Ack: st,
|
||||
Nudges: st,
|
||||
Reminders: st,
|
||||
})
|
||||
@@ -280,6 +334,10 @@ func run(args []string) error {
|
||||
CoreAPI: ipc.NewStoreAPI(st),
|
||||
getTrace: tl.trace,
|
||||
}
|
||||
if voiceW != nil && voiceW.handler != nil {
|
||||
api := coreAPI.(*daemonAPI)
|
||||
api.chatFn = voiceW.handler.handleText
|
||||
}
|
||||
} else {
|
||||
// locked mode: dummy CoreAPI that returns errLocked for everything
|
||||
coreAPI = &lockedAPI{}
|
||||
@@ -386,7 +444,7 @@ func run(args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
voiceW, err = wireVoice(cfg, ipc.NewStoreAPI(st), phr, st.VectorMemory())
|
||||
voiceW, err = wireVoice(cfg, ipc.NewStoreAPI(st), phr, st.VectorMemory(), st)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire voice: %w", err)
|
||||
}
|
||||
@@ -415,6 +473,7 @@ func run(args []string) error {
|
||||
Ntfy: ntfy,
|
||||
Telegram: telegram,
|
||||
Voice: voiceSink,
|
||||
Ack: st,
|
||||
Nudges: st,
|
||||
Reminders: st,
|
||||
})
|
||||
@@ -429,6 +488,9 @@ func run(args []string) error {
|
||||
CoreAPI: ipc.NewStoreAPI(st),
|
||||
getTrace: tl.trace,
|
||||
}
|
||||
if voiceW != nil && voiceW.handler != nil {
|
||||
newAPI.chatFn = voiceW.handler.handleText
|
||||
}
|
||||
srv.SetAPI(newAPI)
|
||||
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
|
||||
|
||||
|
||||
@@ -5,6 +5,19 @@
|
||||
"socket_path": "/run/maven/mavend.sock",
|
||||
"state_dir": "/var/lib/maven",
|
||||
|
||||
"phraser": {
|
||||
"model_path": "/opt/maven/models/llm/LFM2.5/LFM2.5-1.2B-Instruct-Q4_K_M.gguf",
|
||||
"bin_path": "llama-server",
|
||||
"n_gpu_layers": 99,
|
||||
"n_ctx": 2048,
|
||||
"timeout": "20s"
|
||||
},
|
||||
|
||||
"telegram": {
|
||||
"bot_token": "${TELEGRAM_BOT_TOKEN}",
|
||||
"chat_id": "${TELEGRAM_CHAT_ID}"
|
||||
},
|
||||
|
||||
"voice": {
|
||||
"enabled": true,
|
||||
"bind": "0.0.0.0:9100",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Telegram bot token and chat ID for mavend's away-channel reach.
|
||||
# Copy this file to deploy/telegram.env and fill in real values.
|
||||
# deploy/telegram.env is gitignored — never commit the real secrets.
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_CHAT_ID=
|
||||
+12
-1
@@ -25,12 +25,23 @@ services:
|
||||
<<: *image
|
||||
command: ["mavend", "-config", "/opt/maven/config/mavend.json"]
|
||||
# the key lives ONLY here. deploy/db_key.env holds MAVEN_DB_KEY=<base64-32B>.
|
||||
env_file: [./deploy/db_key.env]
|
||||
env_file:
|
||||
- ./deploy/db_key.env
|
||||
- ./deploy/telegram.env
|
||||
volumes:
|
||||
- dbdata:/var/lib/maven # encrypted db at rest
|
||||
- sockets:/run/maven # IPC socket dir
|
||||
- ./deploy/mavend.json:/opt/maven/config/mavend.json:ro
|
||||
- ./models:/opt/maven/models:ro
|
||||
- /mnt/hdd1/llms:/opt/maven/models/llm:ro # LFM gguf library
|
||||
# the LFM engine (llama-server) offloads onto the AMD iGPU (RADV RENOIR,
|
||||
# Ryzen 5 5600U) via Vulkan — same device + render gid as mavsttd, which
|
||||
# uses the same driver for whisper. Without these Vulkan
|
||||
# enumerates zero devices and llama-server silently falls back to CPU.
|
||||
devices:
|
||||
- "/dev/dri:/dev/dri"
|
||||
group_add:
|
||||
- "993" # host 'render' gid owning /dev/dri/renderD128 (getent group render)
|
||||
# the decrypted working copy lives in RAM (see db_tmpfs in mavend.json).
|
||||
tmpfs:
|
||||
- /dev/shm
|
||||
|
||||
@@ -335,8 +335,12 @@ func Load(path string) (*Config, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config: read %s: %w", path, err)
|
||||
}
|
||||
// Expand ${VAR} or $VAR patterns from environment variables. This lets
|
||||
// secrets live in env (docker-compose env_file) rather than the config
|
||||
// file committed to git.
|
||||
expanded := os.ExpandEnv(string(b))
|
||||
var c Config
|
||||
if err := json.Unmarshal(b, &c); err != nil {
|
||||
if err := json.Unmarshal([]byte(expanded), &c); err != nil {
|
||||
return nil, fmt.Errorf("config: parse %s: %w", path, err)
|
||||
}
|
||||
c.applyDefaults()
|
||||
|
||||
@@ -51,21 +51,21 @@ const DefaultTimeout = 10 * time.Second
|
||||
type Config struct {
|
||||
// BotToken — the telegram bot token from BotFather. required. sent in the
|
||||
// URL path (the only place telegram accepts it), not in the body.
|
||||
BotToken string
|
||||
BotToken string `json:"bot_token"`
|
||||
|
||||
// ChatID — destination chat. may be a numeric user/group id (sent as a
|
||||
// JSON number) or @channelusername (sent as a JSON string). required.
|
||||
ChatID string
|
||||
ChatID string `json:"chat_id"`
|
||||
|
||||
// BaseURL — telegram API base. empty = DefaultBaseURL. override to point
|
||||
// at a self-hosted API bridge if the proxy path isn't used.
|
||||
BaseURL string
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
|
||||
// Proxy — URL of an HTTP/HTTPS/SOCKS5 relay used to reach the telegram
|
||||
// API (region-restricted direct egress). empty = direct (won't work from
|
||||
// the homesrv without a relay; kept configurable for tests + future
|
||||
// topology change).
|
||||
Proxy string
|
||||
Proxy string `json:"proxy,omitempty"`
|
||||
|
||||
// Timeout — per-request; 0 = DefaultTimeout. a dead relay can't hang the
|
||||
// tick loop.
|
||||
|
||||
@@ -37,6 +37,7 @@ import (
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/tts"
|
||||
"github.com/kami/maven/internal/ttsnorm"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
|
||||
@@ -75,7 +76,7 @@ func (s *Sink) Send(ctx context.Context, send delivery.Sendable) error {
|
||||
// in the phraser behind routing).
|
||||
text = send.Summary
|
||||
}
|
||||
out, err := s.tts.Synthesize(ctx, text)
|
||||
out, err := s.tts.Synthesize(ctx, ttsnorm.Speakable(text))
|
||||
if err != nil {
|
||||
return fmt.Errorf("voicesink: synthesize: %w", err)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const (
|
||||
IntentFact Intent = "fact"
|
||||
IntentNote Intent = "note"
|
||||
IntentQuery Intent = "query"
|
||||
IntentChat Intent = "chat"
|
||||
IntentSystem Intent = "system"
|
||||
)
|
||||
|
||||
|
||||
@@ -54,6 +54,24 @@ func TestSessionStoreDefaultTTL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStoreCustomTTL(t *testing.T) {
|
||||
// A session with an explicit TTL should use that instead of the default.
|
||||
now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC)
|
||||
store := NewSessionStore(2 * time.Minute)
|
||||
|
||||
sess := &Session{Intent: IntentQuery, Timestamp: now, TTL: 15 * time.Minute}
|
||||
store.Put("chatty", sess)
|
||||
|
||||
// Should still be alive at 10 minutes (past the 2m default).
|
||||
if s := store.Get("chatty", now.Add(10*time.Minute)); s == nil {
|
||||
t.Error("chat session with 15m TTL expired at 10m — custom TTL not applied")
|
||||
}
|
||||
// Should be expired after 20 minutes.
|
||||
if s := store.Get("chatty", now.Add(20*time.Minute)); s != nil {
|
||||
t.Error("chat session with 15m TTL should be expired at 20m")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInheritSlots(t *testing.T) {
|
||||
now := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user