Merge branch 'fix/g08' into fix/integrated

# Conflicts:
#	internal/store/migrations.go
This commit is contained in:
kami
2026-08-01 14:38:39 +04:00
39 changed files with 2753 additions and 363 deletions
+96 -9
View File
@@ -28,6 +28,7 @@ import (
"github.com/kami/maven/internal/netscan"
"github.com/kami/maven/internal/smarthome"
"github.com/kami/maven/internal/update"
"github.com/kami/maven/internal/vision"
"github.com/robfig/cron/v3"
)
@@ -277,8 +278,20 @@ type MCPConfig struct {
// MaxBytes — cap on one JSON-RPC response. 0 ⇒ webfetch.DefaultMaxBytes.
MaxBytes int64 `json:"max_bytes,omitempty"`
// HostInterval — minimum spacing between two requests to one MCP server.
// 0 ⇒ DefaultMCPHostInterval (50ms), NOT webfetch's own one-second default.
// That default was sized for a feed poll loop, and this path is in a spoken
// turn: one dial is three requests (initialize, initialized, tools/list),
// so a second of spacing is two seconds of pure sleeping per dial and up to
// another second before every tools/call leaves the box.
HostInterval Duration `json:"host_interval,omitempty"`
}
// DefaultMCPHostInterval — see MCPConfig.HostInterval. Enough to stop a
// runaway loop hammering a server, small enough not to be heard.
const DefaultMCPHostInterval = 50 * time.Millisecond
// SmartHomeConfig — the Home Assistant block (Vikunja #256). Dark until
// `"enabled": true`, and even then a discovered device is only ever PROPOSED
// into the act allowlist: Kami enables it on /tools, behind step-up, exactly as
@@ -429,6 +442,13 @@ type MCPServerConfig struct {
// Timeout — per-call budget for this server. 0 ⇒ MCPConfig.Timeout.
Timeout Duration `json:"timeout,omitempty"`
// Headers — sent verbatim on every request to a url server. This is how a
// bearer token reaches a real remote MCP server: {"Authorization": "Bearer
// ${MCP_TOKEN}"}, with the value in the gitignored env file like the
// telegram credentials. The Vikunja server on homesrv needs none only
// because it is unauthenticated on loopback.
Headers map[string]string `json:"headers,omitempty"`
// Enabled — false (the default) keeps a configured server described but
// dark, so a block can be written and reviewed before it is switched on.
Enabled bool `json:"enabled,omitempty"`
@@ -437,13 +457,31 @@ type MCPServerConfig struct {
// MCPServers maps the config blocks onto the mcp package's own type. It lives
// here so config validation and daemon wiring cannot drift on the mapping.
// Returns nil when nothing is configured or nothing is enabled.
//
// Disabled servers are dropped here, which is why validation does NOT use this
// list — see allMCPServers.
func (c *Config) MCPServers() []mcp.ServerConfig {
return c.mcpServers(true)
}
// allMCPServers is every configured server, enabled or not, for validation.
//
// Validating only the enabled ones meant a block with both command and url, or
// a bare hostname as the url, passed startup validation while it was dark. The
// doc on Enabled says a block can be written and reviewed before it is switched
// on; the review the config layer could give was the one thing skipped. Enabled
// gates the dialing, not the shape check.
func (c *Config) allMCPServers() []mcp.ServerConfig {
return c.mcpServers(false)
}
func (c *Config) mcpServers(onlyEnabled bool) []mcp.ServerConfig {
if c.MCP == nil {
return nil
}
out := make([]mcp.ServerConfig, 0, len(c.MCP.Servers))
for _, s := range c.MCP.Servers {
if !s.Enabled {
if onlyEnabled && !s.Enabled {
continue
}
timeout := time.Duration(s.Timeout)
@@ -460,8 +498,9 @@ func (c *Config) MCPServers() []mcp.ServerConfig {
AllowPrivate: s.AllowPrivate,
AllowTools: s.AllowTools,
MaxTools: s.MaxTools,
Headers: s.Headers,
Timeout: timeout,
Enabled: true,
Enabled: s.Enabled,
})
}
if len(out) == 0 {
@@ -668,6 +707,12 @@ type MediaConfig struct {
// MaxBytes — per-blob cap. 0 ⇒ media.DefaultMaxBytes (64 MiB).
MaxBytes int64 `json:"max_bytes,omitempty"`
// MaxTotalBytes — whole-store cap. 0 ⇒ media.DefaultMaxTotalBytes (4 GiB).
// The per-blob cap bounds one call; this one bounds the sum of them, which
// is what actually decides whether the disk mavend's database lives on can
// be filled from outside.
MaxTotalBytes int64 `json:"max_total_bytes,omitempty"`
}
// StoreDir reports the configured blob directory, or "" when media is not
@@ -755,9 +800,14 @@ type CaptureConfig struct {
MaxChunks int `json:"max_chunks,omitempty"`
// SaveTranscript — write the full transcript as a note alongside the
// summary. Default false: a verbatim record of what other people said in a
// room is a heavier thing to keep than a four-line summary, so it takes a
// deliberate yes. The audio blob is pruned by media.retention either way.
// summary. Default false, and the cost is not disk: a note is embedded and
// becomes recall corpus, so every later question can surface verbatim words
// other people said in a room. That is the reason it takes a deliberate yes.
// The audio blob is pruned by media.retention either way; the notes are not.
//
// A meeting with no summary writes its transcript regardless. The choice
// here is transcript IN ADDITION to a summary, not whether the meeting is
// remembered at all.
SaveTranscript bool `json:"save_transcript,omitempty"`
}
@@ -1259,11 +1309,18 @@ func (c *Config) applyDefaults() {
c.Feeds = nil
}
// Same rule for MCP: a block with no server, or none enabled, is the same
// as no block at all. Normalising it to nil keeps "off" in one place.
if c.MCP != nil && len(c.MCPServers()) == 0 {
// Same rule for MCP: a block with no server at all is the same as no block.
// A block whose servers are all disabled is NOT normalised away, because
// validate has to see their shape — a dark block with a typo in it should
// fail at startup, which is the whole reason it can be written before it is
// switched on. wireMCP builds nothing when nothing is enabled, so "off"
// still holds.
if c.MCP != nil && len(c.MCP.Servers) == 0 {
c.MCP = nil
}
if c.MCP != nil && c.MCP.HostInterval <= 0 {
c.MCP.HostInterval = Duration(DefaultMCPHostInterval)
}
// Same rule for the house: a block that is not enabled is the same as no
// block at all, so "off" stays in one place.
@@ -1389,7 +1446,7 @@ func (c *Config) validate() error {
// An MCP block with a typo (no name, both command and url, a bare hostname
// as the url) fails here, at startup, rather than at the first turn that
// needed the tool.
if err := mcp.Validate(c.MCPServers()); err != nil {
if err := mcp.Validate(c.allMCPServers()); err != nil {
return err
}
// Same for the house: a missing token or a bare hostname fails at startup,
@@ -1409,6 +1466,36 @@ func (c *Config) validate() error {
return err
}
}
// A media dir that cannot be created, or a vision endpoint that is a typo,
// used to be logged at wiring time and the capability just stayed off. A
// capability silently not existing is the hardest kind of misconfiguration
// to notice, so both fail here instead.
if c.Media != nil {
if c.Media.StoreDir() == "" {
return errors.New("media.dir is required when a media block is present")
}
if c.Media.MaxBytes < 0 || c.Media.MaxTotalBytes < 0 {
return errors.New("media: max_bytes and max_total_bytes cannot be negative")
}
if c.Media.MaxTotalBytes > 0 && c.Media.MaxBytes > c.Media.MaxTotalBytes {
return fmt.Errorf("media: max_bytes %d is above max_total_bytes %d",
c.Media.MaxBytes, c.Media.MaxTotalBytes)
}
}
if c.Vision != nil && c.Vision.Enabled {
if strings.TrimSpace(c.Vision.Endpoint) == "" {
return errors.New("vision.enabled set but vision.endpoint is empty")
}
if err := vision.ValidateEndpoint(c.Vision.Endpoint); err != nil {
return err
}
if c.Media.StoreDir() == "" {
return errors.New("vision.enabled set but there is no media block to keep the bytes in")
}
}
if c.Capture.Records() && c.Media.StoreDir() == "" {
return errors.New("capture.enabled set but there is no media block to keep the audio in")
}
if len(c.MorningRoutines) > 0 {
if err := morning.Validate(morningRoutinesFromConfig(c.MorningRoutines)); err != nil {
return err
+44 -3
View File
@@ -26,14 +26,55 @@ func TestMCPDisabledServerIsOff(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if c.MCP != nil {
t.Errorf("a block with nothing enabled must normalise to nil, got %+v", c.MCP)
}
if got := c.MCPServers(); len(got) != 0 {
t.Errorf("MCPServers() = %+v", got)
}
}
// A block with no servers at all is the same as no block.
func TestMCPEmptyBlockNormalisesToNil(t *testing.T) {
c, err := Load(writeConfig(t, `{"mcp":{"servers":[]}}`))
if err != nil {
t.Fatal(err)
}
if c.MCP != nil {
t.Errorf("mcp = %+v, want nil", c.MCP)
}
}
// A server that is written but not switched on is still shape-checked. The
// review the config layer can give is the point of writing a block dark, and
// skipping it meant a typo only surfaced on the day it was enabled.
func TestMCPDisabledServerIsStillValidated(t *testing.T) {
cases := map[string]string{
"both": `{"mcp":{"servers":[{"name":"a","command":"x","url":"http://a.test"}]}}`,
"bare host": `{"mcp":{"servers":[{"name":"a","url":"a.test"}]}}`,
"no name": `{"mcp":{"servers":[{"command":"x"}]}}`,
"duplicates": `{"mcp":{"servers":[{"name":"a","command":"x"},{"name":"a","command":"y"}]}}`,
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
if _, err := Load(writeConfig(t, body)); err == nil {
t.Fatal("a dark server with a typo must fail at startup")
}
})
}
}
// Headers carry a bearer token to a real remote server.
func TestMCPServerHeaders(t *testing.T) {
c, err := Load(writeConfig(t, `{"mcp":{"servers":[
{"name":"remote","url":"https://mcp.example.test/mcp","enabled":true,
"headers":{"Authorization":"Bearer sekret"}}]}}`))
if err != nil {
t.Fatal(err)
}
got := c.MCPServers()
if len(got) != 1 || got[0].Headers["Authorization"] != "Bearer sekret" {
t.Fatalf("headers not mapped: %+v", got)
}
}
func TestMCPEnabledServerMapping(t *testing.T) {
c, err := Load(writeConfig(t, `{"mcp":{
"timeout":"5s",
+37
View File
@@ -221,3 +221,40 @@ func TestSpeakerBlockParsesFromJSON(t *testing.T) {
t.Errorf("thresholds = %+v", cfg.Speaker)
}
}
// A typo in the vision endpoint, or a media block with no dir, used to be
// logged once at wiring time and the capability just stayed off. A capability
// that silently does not exist is the hardest misconfiguration to notice, so
// both fail at startup now.
func TestSensesBlocksAreValidatedAtStartup(t *testing.T) {
bad := map[string]string{
"media with no dir": `{"media":{"retention":"48h"}}`,
"negative budget": `{"media":{"dir":"/srv/media","max_total_bytes":-1}}`,
"blob over the budget": `{"media":{"dir":"/srv/media","max_bytes":100,"max_total_bytes":10}}`,
"vision with no media dir": `{"vision":{"enabled":true,"endpoint":"http://127.0.0.1:8081"}}`,
"vision endpoint typo": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true,"endpoint":"127.0.0.1:8081"}}`,
"vision on the wan": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true,"endpoint":"http://8.8.8.8:8081"}}`,
"vision, empty endpoint": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true}}`,
"capture with no store": `{"capture":{"enabled":true}}`,
}
for name, body := range bad {
t.Run(name, func(t *testing.T) {
if _, err := Load(writeConfig(t, body)); err == nil {
t.Fatal("want a startup error")
}
})
}
good := map[string]string{
"media alone": `{"media":{"dir":"/srv/media"}}`,
"media + vision": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true,"endpoint":"http://127.0.0.1:8081"}}`,
"media + capture": `{"media":{"dir":"/srv/media"},"capture":{"enabled":true}}`,
"vision off": `{"vision":{"endpoint":"http://8.8.8.8:8081"}}`,
}
for name, body := range good {
t.Run(name, func(t *testing.T) {
if _, err := Load(writeConfig(t, body)); err != nil {
t.Fatalf("valid config refused: %v", err)
}
})
}
}