From da60c14399ff5d779730795d5032ab21dd836ca6 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 10 Jul 2026 15:49:27 +0400 Subject: [PATCH] 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. --- .gitignore | 2 + AGENTS.md | 32 ++++ Dockerfile | 27 ++++ REARCH.md | 92 ++++++++++++ cmd/mavend/main.go | 138 +++++++++++++----- deploy/mavend.json | 13 ++ deploy/telegram.env.example | 5 + docker-compose.yml | 13 +- internal/config/config.go | 6 +- .../delivery/telegramsink/telegramsink.go | 8 +- internal/delivery/voicesink/voicesink.go | 3 +- internal/dialogue/session.go | 1 + internal/dialogue/session_test.go | 18 +++ 13 files changed, 313 insertions(+), 45 deletions(-) create mode 100644 REARCH.md create mode 100644 deploy/telegram.env.example diff --git a/.gitignore b/.gitignore index 1d87b62..4c81a73 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md index 7723dbd..dbea7ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/Dockerfile b/Dockerfile index 76fd677..14b366a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/REARCH.md b/REARCH.md new file mode 100644 index 0000000..f5cbed1 --- /dev/null +++ b/REARCH.md @@ -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. diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index 579c5a4..26857cf 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -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 diff --git a/deploy/mavend.json b/deploy/mavend.json index ff192db..9bf2919 100644 --- a/deploy/mavend.json +++ b/deploy/mavend.json @@ -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", diff --git a/deploy/telegram.env.example b/deploy/telegram.env.example new file mode 100644 index 0000000..edf8f73 --- /dev/null +++ b/deploy/telegram.env.example @@ -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= diff --git a/docker-compose.yml b/docker-compose.yml index d650bf8..56dd77a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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=. - 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 diff --git a/internal/config/config.go b/internal/config/config.go index 0a5f5d8..987f308 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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() diff --git a/internal/delivery/telegramsink/telegramsink.go b/internal/delivery/telegramsink/telegramsink.go index 66b0849..6358992 100644 --- a/internal/delivery/telegramsink/telegramsink.go +++ b/internal/delivery/telegramsink/telegramsink.go @@ -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. diff --git a/internal/delivery/voicesink/voicesink.go b/internal/delivery/voicesink/voicesink.go index 748197a..15b0fe2 100644 --- a/internal/delivery/voicesink/voicesink.go +++ b/internal/delivery/voicesink/voicesink.go @@ -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) } diff --git a/internal/dialogue/session.go b/internal/dialogue/session.go index 260a741..452d27f 100644 --- a/internal/dialogue/session.go +++ b/internal/dialogue/session.go @@ -13,6 +13,7 @@ const ( IntentFact Intent = "fact" IntentNote Intent = "note" IntentQuery Intent = "query" + IntentChat Intent = "chat" IntentSystem Intent = "system" ) diff --git a/internal/dialogue/session_test.go b/internal/dialogue/session_test.go index a6d13bc..819efa7 100644 --- a/internal/dialogue/session_test.go +++ b/internal/dialogue/session_test.go @@ -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)