Compare commits

..

2 Commits

Author SHA1 Message Date
kami c5317eb2b4 Move the quiet-toggle and pattern-extraction slices out of voice.go (#321)
Continues the decomposition PR #50 started. voice.go 542 -> 365:

  quiet_toggle.go  144  resolveQuietToggle, quietInflections, quietStem,
                        quietTokens, quietPhrase, quietOn/OffPhrases,
                        classifyQuietToggle  (quiet_toggle_test.go already
                        existed for these)
  patterns.go     +44  detectPattern, next to detectAndPropose which it calls
                        and which patterns.go's own header already pointed at

What is left in voice.go is the handler: reactiveHandler, HandlePushToTalk,
handleText, runTurn, applyAction, replySystem, chatHistory, reply.

Move-only: all 133 distinct non-blank lines removed from voice.go were
matched in the two destination files, zero lines added to voice.go. The only
non-move edits are import lists (log added to patterns.go, unicode and
internal/pattern dropped from voice.go) and two comments that pointed at
voice.go for code that is no longer there.
2026-08-01 01:30:06 +04:00
kami 9190f897a3 Add a locked-down maven.<domain> block to the nginx template (#354)
The template's wildcard `listen 80` with no ACL was fixed in 50cc17f, but it
still only covered nexus/praxis/hexis. mavweb — the one service in the set
that serves an RCE surface (POST /tools defines argv internal/tool executes)
— had no block at all, so anyone wiring it up wrote their own, which is how
the wildcard got there the first time.

Adds a maven.kvmx.ru server with the same wg+LAN bind and allow/deny,
proxying 127.0.0.1:9201, with the WebSocket upgrade /ws needs, a 32m body
limit for push-to-talk PCM, and a 300s read timeout because an LLM turn on
the iGPU is slow.

Also records in deploy/ecosystem/docker-compose.yml that the sibling
`build:` paths pin nothing and ship the sibling working tree, with the
command to check what is about to be deployed. The stale public DNS records
(item 2) are outside the repo.

Verified: nginx -t on the template inside a minimal http{} accepts it.
2026-08-01 01:27:11 +04:00
5 changed files with 251 additions and 181 deletions
+47 -1
View File
@@ -1,6 +1,6 @@
// mavend/patterns.go — the shared detect+propose step of pattern inference
// (Vikunja #43). Event *extraction* (fact -> action/object) happens at fact-
// write time in voice.go's detectPattern, tied to whichever channel wrote the
// write time in detectPattern below, tied to whichever channel wrote the
// fact. Detection — turning a run of events into a proposed routine — is
// channel-agnostic: it only needs what's already in the events table, so it
// runs both right after a voice fact-write (for the immediate "напоминать?"
@@ -13,6 +13,7 @@ import (
"context"
"errors"
"fmt"
"log"
"time"
"github.com/kami/maven/internal/pattern"
@@ -72,3 +73,48 @@ func detectAndPropose(ctx context.Context, ds *store.Store, action, object strin
}
return r, id, nil
}
// detectPattern extracts an event from the written fact and runs the pattern
// detector. If a stable recurring pattern is found and no proposed routine
// exists for this action+object yet, one is created and the user is prompted
// to confirm via the park() mechanism. Returns the suggestion phrase when a
// new proposal was created and parked; "" otherwise.
func (h *reactiveHandler) detectPattern(ctx context.Context, factID int64, key, value string, ts time.Time) string {
ev := pattern.Extract(factID, key, value, ts)
if ev == nil {
return "" // not an actionable event
}
if _, err := h.dataStore.CreateEvent(ctx, factID, ev.Action, ev.Object, ts); err != nil {
log.Printf("voice: create event: %v", err)
return ""
}
// Detect+propose (Vikunja #43) is shared with the digestion tick's
// proactive scan — see detectAndPropose above. Event *extraction* stays
// here, tied to this fact write; detection over the accumulated history does
// not need to happen right now for the voice path to have already done
// its job — it's dedupe-safe to also let the next tick find the same
// pattern independently.
r, id, err := detectAndPropose(ctx, h.dataStore, ev.Action, ev.Object, ts)
if err != nil {
log.Printf("voice: detect pattern %s/%s: %v", ev.Action, ev.Object, err)
return ""
}
if r == nil {
return "" // not enough data, too irregular, or already proposed/decided
}
log.Printf("voice: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
// Park the proposal for voice confirmation.
phrase := pattern.PhraseRoutine(r)
h.mu.Lock()
h.pendingRoutine = &pendingRoutineConfirm{
routineID: id,
action: r.Action,
object: r.Object,
interval: r.IntervalDays,
phrase: phrase,
expiry: ts.Add(confirmTTL),
}
h.mu.Unlock()
return phrase
}
+144
View File
@@ -0,0 +1,144 @@
// Quiet-mode toggle recognition — the pre-route keyword check that lets
// "тихий режим" flip the daemon-wide quiet_hours config without going through
// the router. Moved out of voice.go unchanged (Vikunja #321); the tests live in
// quiet_toggle_test.go.
package main
import (
"context"
"log"
"strings"
"unicode"
"github.com/kami/maven/internal/ipc"
)
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
// the utterance is a quiet-on/off command; ("", false) otherwise. Called from
// runTurn BEFORE the router so a classifier miscue can't drop it — which means
// both the voice path and the text path (mavweb /api/chat, telegram) reach it,
// so a false positive here is a network-reachable way to flip a daemon-wide
// setting. See classifyQuietToggle for the matching rule.
func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string) (string, bool) {
on, off := classifyQuietToggle(text)
if !on && !off {
return "", false
}
val := "false"
reply := "тихий режим выключен."
if on {
val = "true"
reply = "тихий режим включён. буду реже напоминать."
}
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
Ts: h.now(),
Kind: "config",
Key: "quiet_hours",
Value: val,
Source: "tap:voice",
Confidence: 1.0,
}); err != nil {
log.Printf("voice: write quiet_hours: %v", err)
return "не получилось переключить тихий режим.", true
}
return reply, true
}
// quietInflections — the inflectional endings a stem may carry and still be
// the same word. Adjective/adverb/noun/verb endings, all ≤3 letters. This is
// what separates "тихий"/"тихом"/"тихо" (stem "тих" + a real ending) from
// "тихонько"/"потихоньку", which are different words: "онько" is not an
// ending, and "потихоньку" doesn't start with the stem at all.
var quietInflections = []string{
"", "а", "е", "и", "й", "о", "у", "ы", "ю", "я",
"ая", "ее", "ей", "ем", "ие", "ий", "им", "их", "ия", "ию", "ое", "ой", "ом", "ую", "ые", "ый", "ым", "ых", "ья",
"ами", "ого", "ому", "ыми", "ать", "ить", "ять",
}
// quietStem reports whether tok is the given stem carrying at most one
// inflectional ending. Word boundaries come from tokenisation (see
// quietTokens), not from a regexp — Go's \b is ASCII-oriented and treats every
// Cyrillic letter as a non-word character, so `\bтих\b` would happily match
// inside "тихонько". Comparing whole tokens sidesteps that entirely.
func quietStem(tok, stem string) bool {
if !strings.HasPrefix(tok, stem) {
return false
}
suffix := tok[len(stem):]
for _, e := range quietInflections {
if suffix == e {
return true
}
}
return false
}
// quietTokens splits an utterance into lowercase word tokens, dropping
// punctuation and spacing. Unicode-aware, so Cyrillic words tokenise the same
// way ASCII ones do.
func quietTokens(text string) []string {
return strings.FieldsFunc(strings.ToLower(strings.TrimSpace(text)), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
}
// quietPhrase matches a pattern (a sequence of stems) against the token list.
// Multi-word patterns match any contiguous run of tokens — "включи тихий
// режим" carries "тихий режим". Single-word patterns match ONLY when they are
// the whole utterance: bare "тихо" is a command, but "в комнате тихо" is a
// remark about the room and must not flip a daemon-wide setting.
func quietPhrase(tokens, pattern []string) bool {
if len(pattern) == 0 || len(tokens) < len(pattern) {
return false
}
if len(pattern) == 1 {
return len(tokens) == 1 && quietStem(tokens[0], pattern[0])
}
for i := 0; i+len(pattern) <= len(tokens); i++ {
hit := true
for j, stem := range pattern {
if !quietStem(tokens[i+j], stem) {
hit = false
break
}
}
if hit {
return true
}
}
return false
}
// quietOffPhrases / quietOnPhrases — the toggle vocabulary, as stem sequences.
var (
quietOffPhrases = [][]string{
{"quiet", "off"}, {"quiet", "end"},
{"громк", "режим"}, {"шумн", "режим"},
{"отмен", "тих"}, {"выключ", "тих"}, {"не", "тих"},
}
quietOnPhrases = [][]string{
{"quiet", "on"}, {"quiet", "mode"},
{"тих", "режим"}, {"не", "шум"}, {"не", "беспоко"},
{"тих"},
}
)
// classifyQuietToggle reads an utterance as a quiet-mode command. OFF is
// resolved before ON for the same reason classifyConfirm checks negatives
// first: the OFF phrases are built out of the ON words ("выключи тихий"
// contains "тихий"), so scanning ON first would shadow them and "выключи
// тихий режим" would turn quiet mode on. Negation wins.
func classifyQuietToggle(text string) (on, off bool) {
tokens := quietTokens(text)
for _, p := range quietOffPhrases {
if quietPhrase(tokens, p) {
return false, true
}
}
for _, p := range quietOnPhrases {
if quietPhrase(tokens, p) {
return true, false
}
}
return false, false
}
-177
View File
@@ -50,13 +50,11 @@ import (
"strings"
"sync"
"time"
"unicode"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/pattern"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
@@ -277,181 +275,6 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision)
return ""
}
// detectPattern extracts an event from the written fact and runs the pattern
// detector. If a stable recurring pattern is found and no proposed routine
// exists for this action+object yet, one is created and the user is prompted
// to confirm via the park() mechanism. Returns the suggestion phrase when a
// new proposal was created and parked; "" otherwise.
func (h *reactiveHandler) detectPattern(ctx context.Context, factID int64, key, value string, ts time.Time) string {
ev := pattern.Extract(factID, key, value, ts)
if ev == nil {
return "" // not an actionable event
}
if _, err := h.dataStore.CreateEvent(ctx, factID, ev.Action, ev.Object, ts); err != nil {
log.Printf("voice: create event: %v", err)
return ""
}
// Detect+propose (Vikunja #43) is shared with the digestion tick's
// proactive scan — see patterns.go. Event *extraction* above stays here,
// tied to this fact write; detection over the accumulated history does
// not need to happen right now for the voice path to have already done
// its job — it's dedupe-safe to also let the next tick find the same
// pattern independently.
r, id, err := detectAndPropose(ctx, h.dataStore, ev.Action, ev.Object, ts)
if err != nil {
log.Printf("voice: detect pattern %s/%s: %v", ev.Action, ev.Object, err)
return ""
}
if r == nil {
return "" // not enough data, too irregular, or already proposed/decided
}
log.Printf("voice: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
// Park the proposal for voice confirmation.
phrase := pattern.PhraseRoutine(r)
h.mu.Lock()
h.pendingRoutine = &pendingRoutineConfirm{
routineID: id,
action: r.Action,
object: r.Object,
interval: r.IntervalDays,
phrase: phrase,
expiry: ts.Add(confirmTTL),
}
h.mu.Unlock()
return phrase
}
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
// the utterance is a quiet-on/off command; ("", false) otherwise. Called from
// runTurn BEFORE the router so a classifier miscue can't drop it — which means
// both the voice path and the text path (mavweb /api/chat, telegram) reach it,
// so a false positive here is a network-reachable way to flip a daemon-wide
// setting. See classifyQuietToggle for the matching rule.
func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string) (string, bool) {
on, off := classifyQuietToggle(text)
if !on && !off {
return "", false
}
val := "false"
reply := "тихий режим выключен."
if on {
val = "true"
reply = "тихий режим включён. буду реже напоминать."
}
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
Ts: h.now(),
Kind: "config",
Key: "quiet_hours",
Value: val,
Source: "tap:voice",
Confidence: 1.0,
}); err != nil {
log.Printf("voice: write quiet_hours: %v", err)
return "не получилось переключить тихий режим.", true
}
return reply, true
}
// quietInflections — the inflectional endings a stem may carry and still be
// the same word. Adjective/adverb/noun/verb endings, all ≤3 letters. This is
// what separates "тихий"/"тихом"/"тихо" (stem "тих" + a real ending) from
// "тихонько"/"потихоньку", which are different words: "онько" is not an
// ending, and "потихоньку" doesn't start with the stem at all.
var quietInflections = []string{
"", "а", "е", "и", "й", "о", "у", "ы", "ю", "я",
"ая", "ее", "ей", "ем", "ие", "ий", "им", "их", "ия", "ию", "ое", "ой", "ом", "ую", "ые", "ый", "ым", "ых", "ья",
"ами", "ого", "ому", "ыми", "ать", "ить", "ять",
}
// quietStem reports whether tok is the given stem carrying at most one
// inflectional ending. Word boundaries come from tokenisation (see
// quietTokens), not from a regexp — Go's \b is ASCII-oriented and treats every
// Cyrillic letter as a non-word character, so `\bтих\b` would happily match
// inside "тихонько". Comparing whole tokens sidesteps that entirely.
func quietStem(tok, stem string) bool {
if !strings.HasPrefix(tok, stem) {
return false
}
suffix := tok[len(stem):]
for _, e := range quietInflections {
if suffix == e {
return true
}
}
return false
}
// quietTokens splits an utterance into lowercase word tokens, dropping
// punctuation and spacing. Unicode-aware, so Cyrillic words tokenise the same
// way ASCII ones do.
func quietTokens(text string) []string {
return strings.FieldsFunc(strings.ToLower(strings.TrimSpace(text)), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
}
// quietPhrase matches a pattern (a sequence of stems) against the token list.
// Multi-word patterns match any contiguous run of tokens — "включи тихий
// режим" carries "тихий режим". Single-word patterns match ONLY when they are
// the whole utterance: bare "тихо" is a command, but "в комнате тихо" is a
// remark about the room and must not flip a daemon-wide setting.
func quietPhrase(tokens, pattern []string) bool {
if len(pattern) == 0 || len(tokens) < len(pattern) {
return false
}
if len(pattern) == 1 {
return len(tokens) == 1 && quietStem(tokens[0], pattern[0])
}
for i := 0; i+len(pattern) <= len(tokens); i++ {
hit := true
for j, stem := range pattern {
if !quietStem(tokens[i+j], stem) {
hit = false
break
}
}
if hit {
return true
}
}
return false
}
// quietOffPhrases / quietOnPhrases — the toggle vocabulary, as stem sequences.
var (
quietOffPhrases = [][]string{
{"quiet", "off"}, {"quiet", "end"},
{"громк", "режим"}, {"шумн", "режим"},
{"отмен", "тих"}, {"выключ", "тих"}, {"не", "тих"},
}
quietOnPhrases = [][]string{
{"quiet", "on"}, {"quiet", "mode"},
{"тих", "режим"}, {"не", "шум"}, {"не", "беспоко"},
{"тих"},
}
)
// classifyQuietToggle reads an utterance as a quiet-mode command. OFF is
// resolved before ON for the same reason classifyConfirm checks negatives
// first: the OFF phrases are built out of the ON words ("выключи тихий"
// contains "тихий"), so scanning ON first would shadow them and "выключи
// тихий режим" would turn quiet mode on. Negation wins.
func classifyQuietToggle(text string) (on, off bool) {
tokens := quietTokens(text)
for _, p := range quietOffPhrases {
if quietPhrase(tokens, p) {
return false, true
}
}
for _, p := range quietOnPhrases {
if quietPhrase(tokens, p) {
return true, false
}
}
return false, false
}
// replySystem answers system-observable queries using the handler's clock
// and (in future) system interfaces. The decision's utterance is parsed
// for keywords to determine what the user is asking about.
+12
View File
@@ -8,6 +8,18 @@
#
# Maven's own compose joins this same network (add `ecosystem` as an external
# network there) to reach nexus:9740 / praxis:8989 / hexis:9741 directly.
#
# NO RELEASE PINNING (Vikunja #354): each `build:` below points at a sibling
# WORKING TREE, so `up --build` ships whatever is checked out there, including
# uncommitted edits. Before bringing this up, check what you are about to
# deploy:
#
# for r in nexus praxis hexis; do git -C ../../../$r status --short; \
# git -C ../../../$r log -1 --oneline; done
#
# The host nginx that fronts these is deploy/ecosystem/nginx.conf — it binds
# the wg and LAN addresses only, with allow/deny. Keep it that way: none of
# these containers has auth of its own.
name: ecosystem
services:
+48 -3
View File
@@ -1,6 +1,7 @@
# Reverse-proxy the three sibling admin UIs. Drop into your nginx sites (or the
# nginx-panel app) and reload. Assumes the compose publishes each service on
# 127.0.0.1:<port>. Add TLS (certbot / your existing cert block) per server.
# Reverse-proxy Maven's own web UI plus the three sibling admin UIs. Drop into
# your nginx sites (or the nginx-panel app) and reload. Assumes the compose
# publishes each service on 127.0.0.1:<port>. Add TLS (certbot / your existing
# cert block) per server.
#
# NOTE: hexis.<domain> previously pointed at the MCP tool — repoint that
# elsewhere first (the app now owns hexis.*).
@@ -12,6 +13,50 @@
# Do NOT "fix" a failed bind by reverting to `listen 80` (all interfaces) —
# that removes the only access control these containers have.
# maven.<domain> → mavweb (docker-compose.yml publishes it on 127.0.0.1:9201).
# Same bind + ACL as the siblings, and for a stronger reason: mavweb serves
# POST /tools, which defines argv that internal/tool EXECUTES, plus POST
# /routines, /api/revert and /api/chat (Vikunja #317). Without
# -webauthn-origin/-webauthn-rpid mavweb has no auth of its own, so this block
# is the auth. If you add TLS and a basic-auth/oauth2-proxy layer, keep the
# allow/deny anyway — belt and braces on an RCE surface.
#
# WebSocket upgrade matters here: /ws carries push-to-talk audio, so the
# Upgrade/Connection headers below are required, not decoration. The map keeps
# `Connection: upgrade` off plain requests; it sits in the http context, which
# is where sites-available files are included — if your nginx already defines
# $connection_upgrade, drop this block.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 10.42.0.1:80;
listen 192.168.1.104:80;
server_name maven.kvmx.ru;
allow 10.42.0.0/24;
allow 192.168.1.0/24;
deny all;
# push-to-talk uploads raw PCM; the default 1m is enough for a short
# utterance but not for a long one.
client_max_body_size 32m;
location / {
proxy_pass http://127.0.0.1:9201;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s; # an LLM turn can take minutes on the iGPU
}
}
server {
listen 10.42.0.1:80;
listen 192.168.1.104:80;