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 secret (the at-rest db key) — never commit
|
||||||
deploy/db_key.env
|
deploy/db_key.env
|
||||||
|
# Deploy secret (telegram bot token + chat id) — never commit
|
||||||
|
deploy/telegram.env
|
||||||
|
|
||||||
# Temp files
|
# Temp files
|
||||||
/tmp/
|
/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
|
Without the embedder block, the daemon uses `HashEmbedder` (works, but weak on
|
||||||
Russian recall — you may see many "clarify" responses).
|
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
|
## Web UI conventions
|
||||||
|
|
||||||
- All server-rendered pages share `cmd/mavweb/static/ui.css` (served at
|
- 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/mavpoll ./cmd/mavpoll && \
|
||||||
go build -o /out/mavcaldav ./cmd/mavcaldav
|
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
|
FROM debian:trixie-slim AS runtime
|
||||||
# tzdata so the TZ env (set in compose) resolves — otherwise Go can't load the
|
# 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
|
# 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 \
|
||||||
&& ln -sf libonnxruntime.so.1.26.0 libonnxruntime.so.1
|
&& ln -sf libonnxruntime.so.1.26.0 libonnxruntime.so.1
|
||||||
COPY --from=build /out/ /opt/maven/bin/
|
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
|
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).
|
// verifier + ask-password transport (open spec item).
|
||||||
//
|
//
|
||||||
// Cold-start unlock (2026-07-06):
|
// 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
|
// When a passkey credential is enrolled AND no env key is set, the daemon
|
||||||
// starts unlocked from the env key (pre-unlock behavior). Enrolling a passkey
|
// starts in LOCKED mode: the IPC server runs but rejects all store methods
|
||||||
// while unlocked calls MethodStoreEncryptionKey to wrap the env key and
|
// except MethodAssertStepUp and MethodUnlock. A passkey assertion followed
|
||||||
// persist the wrapped blob — enabling cold-start unlock on the next boot
|
// by MethodUnlock (with the same credential's public key) unwraps the at-rest
|
||||||
// after the env key is removed.
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -102,30 +103,82 @@ type lockedAPI struct{}
|
|||||||
|
|
||||||
var _ ipc.CoreAPI = (*lockedAPI)(nil)
|
var _ ipc.CoreAPI = (*lockedAPI)(nil)
|
||||||
|
|
||||||
func (l *lockedAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64, error) { return 0, errLocked }
|
func (l *lockedAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64, error) {
|
||||||
func (l *lockedAPI) LatestFact(ctx context.Context, key string) (ipc.Fact, error) { return ipc.Fact{}, errLocked }
|
return 0, 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) LatestFact(ctx context.Context, key string) (ipc.Fact, error) {
|
||||||
func (l *lockedAPI) Presence(ctx context.Context) (ipc.Presence, error) { return ipc.Presence{}, errLocked }
|
return ipc.Fact{}, 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) LatestFactBySource(ctx context.Context, key, source string) (ipc.Fact, error) {
|
||||||
func (l *lockedAPI) ListReminders(ctx context.Context, n int) ([]ipc.Reminder, error) { return nil, errLocked }
|
return ipc.Fact{}, 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) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) {
|
||||||
func (l *lockedAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) { return nil, errLocked }
|
return 0, 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) Presence(ctx context.Context) (ipc.Presence, error) {
|
||||||
func (l *lockedAPI) RecentNudges(ctx context.Context, n int) ([]ipc.Nudge, error) { return nil, errLocked }
|
return ipc.Presence{}, 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) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
|
||||||
func (l *lockedAPI) RecentNotes(ctx context.Context, n int) ([]ipc.Note, error) { return nil, errLocked }
|
return 0, 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) MarkReminder(ctx context.Context, id int64, status string) error {
|
||||||
func (l *lockedAPI) DisableTool(ctx context.Context, name string) error { return errLocked }
|
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) ListReminders(ctx context.Context, n int) ([]ipc.Reminder, error) {
|
||||||
func (l *lockedAPI) RevertFact(ctx context.Context, key string) (int64, error) { return 0, errLocked }
|
return nil, errLocked
|
||||||
func (l *lockedAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) { return ipc.TickTrace{}, 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 {
|
func run(args []string) error {
|
||||||
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
|
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
|
||||||
@@ -236,7 +289,7 @@ func run(args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// voice
|
// 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 {
|
if err != nil {
|
||||||
return fmt.Errorf("wire voice: %w", err)
|
return fmt.Errorf("wire voice: %w", err)
|
||||||
}
|
}
|
||||||
@@ -266,6 +319,7 @@ func run(args []string) error {
|
|||||||
Ntfy: ntfy,
|
Ntfy: ntfy,
|
||||||
Telegram: telegram,
|
Telegram: telegram,
|
||||||
Voice: voiceSink,
|
Voice: voiceSink,
|
||||||
|
Ack: st,
|
||||||
Nudges: st,
|
Nudges: st,
|
||||||
Reminders: st,
|
Reminders: st,
|
||||||
})
|
})
|
||||||
@@ -280,6 +334,10 @@ func run(args []string) error {
|
|||||||
CoreAPI: ipc.NewStoreAPI(st),
|
CoreAPI: ipc.NewStoreAPI(st),
|
||||||
getTrace: tl.trace,
|
getTrace: tl.trace,
|
||||||
}
|
}
|
||||||
|
if voiceW != nil && voiceW.handler != nil {
|
||||||
|
api := coreAPI.(*daemonAPI)
|
||||||
|
api.chatFn = voiceW.handler.handleText
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// locked mode: dummy CoreAPI that returns errLocked for everything
|
// locked mode: dummy CoreAPI that returns errLocked for everything
|
||||||
coreAPI = &lockedAPI{}
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("wire voice: %w", err)
|
return fmt.Errorf("wire voice: %w", err)
|
||||||
}
|
}
|
||||||
@@ -415,6 +473,7 @@ func run(args []string) error {
|
|||||||
Ntfy: ntfy,
|
Ntfy: ntfy,
|
||||||
Telegram: telegram,
|
Telegram: telegram,
|
||||||
Voice: voiceSink,
|
Voice: voiceSink,
|
||||||
|
Ack: st,
|
||||||
Nudges: st,
|
Nudges: st,
|
||||||
Reminders: st,
|
Reminders: st,
|
||||||
})
|
})
|
||||||
@@ -429,6 +488,9 @@ func run(args []string) error {
|
|||||||
CoreAPI: ipc.NewStoreAPI(st),
|
CoreAPI: ipc.NewStoreAPI(st),
|
||||||
getTrace: tl.trace,
|
getTrace: tl.trace,
|
||||||
}
|
}
|
||||||
|
if voiceW != nil && voiceW.handler != nil {
|
||||||
|
newAPI.chatFn = voiceW.handler.handleText
|
||||||
|
}
|
||||||
srv.SetAPI(newAPI)
|
srv.SetAPI(newAPI)
|
||||||
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
|
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,19 @@
|
|||||||
"socket_path": "/run/maven/mavend.sock",
|
"socket_path": "/run/maven/mavend.sock",
|
||||||
"state_dir": "/var/lib/maven",
|
"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": {
|
"voice": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"bind": "0.0.0.0:9100",
|
"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
|
<<: *image
|
||||||
command: ["mavend", "-config", "/opt/maven/config/mavend.json"]
|
command: ["mavend", "-config", "/opt/maven/config/mavend.json"]
|
||||||
# the key lives ONLY here. deploy/db_key.env holds MAVEN_DB_KEY=<base64-32B>.
|
# 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:
|
volumes:
|
||||||
- dbdata:/var/lib/maven # encrypted db at rest
|
- dbdata:/var/lib/maven # encrypted db at rest
|
||||||
- sockets:/run/maven # IPC socket dir
|
- sockets:/run/maven # IPC socket dir
|
||||||
- ./deploy/mavend.json:/opt/maven/config/mavend.json:ro
|
- ./deploy/mavend.json:/opt/maven/config/mavend.json:ro
|
||||||
- ./models:/opt/maven/models: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).
|
# the decrypted working copy lives in RAM (see db_tmpfs in mavend.json).
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /dev/shm
|
- /dev/shm
|
||||||
|
|||||||
@@ -335,8 +335,12 @@ func Load(path string) (*Config, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("config: read %s: %w", path, err)
|
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
|
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)
|
return nil, fmt.Errorf("config: parse %s: %w", path, err)
|
||||||
}
|
}
|
||||||
c.applyDefaults()
|
c.applyDefaults()
|
||||||
|
|||||||
@@ -51,21 +51,21 @@ const DefaultTimeout = 10 * time.Second
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
// BotToken — the telegram bot token from BotFather. required. sent in the
|
// BotToken — the telegram bot token from BotFather. required. sent in the
|
||||||
// URL path (the only place telegram accepts it), not in the body.
|
// 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
|
// ChatID — destination chat. may be a numeric user/group id (sent as a
|
||||||
// JSON number) or @channelusername (sent as a JSON string). required.
|
// 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
|
// BaseURL — telegram API base. empty = DefaultBaseURL. override to point
|
||||||
// at a self-hosted API bridge if the proxy path isn't used.
|
// 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
|
// Proxy — URL of an HTTP/HTTPS/SOCKS5 relay used to reach the telegram
|
||||||
// API (region-restricted direct egress). empty = direct (won't work from
|
// API (region-restricted direct egress). empty = direct (won't work from
|
||||||
// the homesrv without a relay; kept configurable for tests + future
|
// the homesrv without a relay; kept configurable for tests + future
|
||||||
// topology change).
|
// topology change).
|
||||||
Proxy string
|
Proxy string `json:"proxy,omitempty"`
|
||||||
|
|
||||||
// Timeout — per-request; 0 = DefaultTimeout. a dead relay can't hang the
|
// Timeout — per-request; 0 = DefaultTimeout. a dead relay can't hang the
|
||||||
// tick loop.
|
// tick loop.
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import (
|
|||||||
"github.com/kami/maven/internal/audio"
|
"github.com/kami/maven/internal/audio"
|
||||||
"github.com/kami/maven/internal/delivery"
|
"github.com/kami/maven/internal/delivery"
|
||||||
"github.com/kami/maven/internal/tts"
|
"github.com/kami/maven/internal/tts"
|
||||||
|
"github.com/kami/maven/internal/ttsnorm"
|
||||||
"github.com/kami/maven/internal/voice"
|
"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).
|
// in the phraser behind routing).
|
||||||
text = send.Summary
|
text = send.Summary
|
||||||
}
|
}
|
||||||
out, err := s.tts.Synthesize(ctx, text)
|
out, err := s.tts.Synthesize(ctx, ttsnorm.Speakable(text))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("voicesink: synthesize: %w", err)
|
return fmt.Errorf("voicesink: synthesize: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const (
|
|||||||
IntentFact Intent = "fact"
|
IntentFact Intent = "fact"
|
||||||
IntentNote Intent = "note"
|
IntentNote Intent = "note"
|
||||||
IntentQuery Intent = "query"
|
IntentQuery Intent = "query"
|
||||||
|
IntentChat Intent = "chat"
|
||||||
IntentSystem Intent = "system"
|
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) {
|
func TestInheritSlots(t *testing.T) {
|
||||||
now := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user