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:
kami
2026-07-10 15:49:27 +04:00
parent 7a95097cc7
commit da60c14399
13 changed files with 313 additions and 45 deletions
+2
View File
@@ -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/
+32
View File
@@ -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
View File
@@ -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
+92
View File
@@ -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
View File
@@ -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
+13
View File
@@ -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",
+5
View File
@@ -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
View File
@@ -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
+5 -1
View File
@@ -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.
+2 -1
View File
@@ -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)
}
+1
View File
@@ -13,6 +13,7 @@ const (
IntentFact Intent = "fact"
IntentNote Intent = "note"
IntentQuery Intent = "query"
IntentChat Intent = "chat"
IntentSystem Intent = "system"
)
+18
View File
@@ -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)