Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c7bd8ceeb | |||
| aa1a26532c | |||
| d92349ca6e | |||
| 8d5e357b57 | |||
| 95ae900a58 | |||
| be066a4b04 | |||
| ad074cea31 |
@@ -8,6 +8,7 @@
|
||||
/mavcaldav
|
||||
/mavwaked
|
||||
/mavmaild
|
||||
/mavupdate
|
||||
|
||||
# Certs (private keys, don't commit)
|
||||
certs/
|
||||
|
||||
@@ -20,7 +20,7 @@ PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
|
||||
|
||||
all: build
|
||||
|
||||
build: build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav build-mail
|
||||
build: build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav build-mail build-update
|
||||
|
||||
build-stt:
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
@@ -53,6 +53,12 @@ build-caldav:
|
||||
build-mail:
|
||||
$(GO) build $(GOFLAGS) -o mavmaild ./cmd/mavmaild/
|
||||
|
||||
# mavupdate is an operator CLI, not a daemon: nothing runs it but a human on the
|
||||
# box. It is built with the rest so a broken update path is caught by `make
|
||||
# build` rather than the first time it is needed.
|
||||
build-update:
|
||||
$(GO) build $(GOFLAGS) -o mavupdate ./cmd/mavupdate/
|
||||
|
||||
run-web: build-web
|
||||
./mavweb -addr :9200 -voice 127.0.0.1:9100
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/mcp"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
)
|
||||
@@ -52,6 +53,12 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
||||
return "выполнить «" + phrase + "»? скажи «да» или «нет»."
|
||||
case errors.Is(err, tool.ErrNotEnabled):
|
||||
return h.proposeGap(ctx, dec)
|
||||
case errors.Is(err, mcp.ErrNeedsArgs):
|
||||
// An MCP tool that wants named arguments a spoken verb cannot
|
||||
// supply. Guessing them would be a wrong act, so she says so
|
||||
// instead — the tool is still runnable from the authed surface,
|
||||
// where a human types them.
|
||||
return "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."
|
||||
}
|
||||
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
|
||||
if out != "" {
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
// mavend/capture.go — core's half of the meeting recorder (Vikunja #253,
|
||||
// docs/plans/08-hearing.md).
|
||||
//
|
||||
// The split: a client that has a microphone (mavenclient, or a phone on the PWA)
|
||||
// is told to start, streams frames over ipc.MethodCaptureAppend, and is told to
|
||||
// stop. Core keeps the PCM, stores it as a WAV blob under the same media store
|
||||
// and the same retention as images, transcribes it through the ONE STT Maven has
|
||||
// (mavsttd's whisper.cpp, reused — not a second engine), and summarises the
|
||||
// transcript on the resident model in windows that fit n_ctx 4096.
|
||||
//
|
||||
// # Off unless configured, twice over
|
||||
//
|
||||
// No `media` block ⇒ nowhere to keep audio ⇒ the four capture methods do not
|
||||
// exist. No `capture` block with enabled ⇒ they still do not exist. On an
|
||||
// unconfigured box there is no wire path that starts a recording, which is the
|
||||
// only guarantee worth making about a capability like this one.
|
||||
//
|
||||
// # What this file refuses to do
|
||||
//
|
||||
// - Nothing listens. There is no VAD hook here, no wake-word branch, no
|
||||
// "start when you hear a meeting". The plan document's keyword-triggered
|
||||
// recorder is refused in internal/capture's package comment for the reason
|
||||
// that applies here too: noticing a keyword requires listening, which is
|
||||
// the behaviour this capability must not have.
|
||||
// - No transcript note by default. The summary is written where he will read
|
||||
// it; the verbatim record of what other people said takes a deliberate
|
||||
// capture.save_transcript.
|
||||
// - The transcript is never search input beyond this box, and the audio never
|
||||
// leaves it at all.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/capture"
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// captureSummaryTimeout — the budget for one Stop, which is a map-reduce over
|
||||
// the whole meeting: one model call per transcript window plus a reduce, each of
|
||||
// which is seconds on this box. Forty windows is the configured ceiling, so the
|
||||
// budget has to be minutes, not the 60s the reply path uses.
|
||||
const captureSummaryTimeout = 20 * time.Minute
|
||||
|
||||
// llmCompleter adapts *llm.Client to capture.Completer. The pure package names
|
||||
// the two strings it needs and stays free of the llm request struct; the client
|
||||
// itself is the swap-aware one from llmClientFor, so a model swap re-points it.
|
||||
type llmCompleter struct {
|
||||
c *llm.Client
|
||||
maxTokens int
|
||||
}
|
||||
|
||||
func (l llmCompleter) Complete(ctx context.Context, system, user string) (string, error) {
|
||||
return l.c.Complete(ctx, llm.Req{System: system, User: user, MaxTokens: l.maxTokens})
|
||||
}
|
||||
|
||||
// captureWiring — the recorder plus what it needs to write the result down.
|
||||
type captureWiring struct {
|
||||
rec *capture.Recorder
|
||||
st *store.Store
|
||||
emb router.Embedder
|
||||
cfg *config.CaptureConfig
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// newCaptureWiring returns nil when the recorder should not exist: no media
|
||||
// store, no capture block, capture disabled, or no STT to transcribe with.
|
||||
//
|
||||
// A missing llama-server is NOT a reason to return nil. Without one the
|
||||
// recording is still made, stored and transcribed, and the summary is simply
|
||||
// absent — the honest degradation, and much better than refusing to record a
|
||||
// meeting that is happening now.
|
||||
func newCaptureWiring(keeper *mediaKeeper, st *store.Store, voiceW *voiceWiring, phr phraser.Phraser, emb router.Embedder, cfg *config.Config) *captureWiring {
|
||||
if keeper == nil || !cfg.Capture.Records() {
|
||||
return nil
|
||||
}
|
||||
tr := transcriberOf(voiceW)
|
||||
if tr == nil {
|
||||
// Voice off ⇒ no STT client ⇒ nothing could turn the audio into words.
|
||||
// Storing hours of unreadable audio of other people is worse than not
|
||||
// recording, so this is a refusal, not a degradation.
|
||||
log.Printf("capture: enabled but voice/stt is not wired — meeting capture disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
cc := cfg.Capture
|
||||
var sum *capture.Summarizer
|
||||
if lp, ok := phr.(*phraser.LLMPhraser); ok {
|
||||
client := llmClientFor(lp, captureSummaryTimeout)
|
||||
sum = capture.NewSummarizer(
|
||||
llmCompleter{c: client, maxTokens: 512},
|
||||
cc.ChunkRunes, cc.MaxChunks, contextBlockFn(cfg, time.Now),
|
||||
)
|
||||
} else {
|
||||
log.Printf("capture: no llama-server phraser — meetings are transcribed, not summarised")
|
||||
}
|
||||
|
||||
rec, err := capture.New(keeper.store, tr, sum, capture.Config{
|
||||
MaxDuration: cc.MaxDuration(),
|
||||
STTWindow: time.Duration(cc.STTWindow),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("capture: %v — meeting capture disabled", err)
|
||||
return nil
|
||||
}
|
||||
log.Printf("capture: enabled, sessions capped at %s", rec.MaxDuration())
|
||||
return &captureWiring{rec: rec, st: st, emb: emb, cfg: cc, now: time.Now}
|
||||
}
|
||||
|
||||
// start handles ipc.MethodCaptureStart.
|
||||
func (c *captureWiring) start(_ context.Context, req ipc.CaptureStartReq) (ipc.CaptureStartResp, error) {
|
||||
s, err := c.rec.Start(req.Label)
|
||||
if err != nil {
|
||||
return ipc.CaptureStartResp{}, err
|
||||
}
|
||||
// The label is logged; nothing that was said ever is.
|
||||
log.Printf("capture: started %q", s.Label)
|
||||
return ipc.CaptureStartResp{
|
||||
Label: s.Label,
|
||||
Started: s.Started,
|
||||
MaxSeconds: int(c.rec.MaxDuration().Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// append handles ipc.MethodCaptureAppend. ErrExpired is reported as a successful
|
||||
// response with Expired set rather than an error: the cap firing is the designed
|
||||
// behaviour, and the client needs the flag to stop sending and call stop.
|
||||
func (c *captureWiring) append(_ context.Context, req ipc.CaptureAppendReq) (ipc.CaptureAppendResp, error) {
|
||||
err := c.rec.Append(req.Audio)
|
||||
st := c.rec.Status()
|
||||
if errors.Is(err, capture.ErrExpired) {
|
||||
log.Printf("capture: %q hit the %s cap — stopping", st.Label, c.rec.MaxDuration())
|
||||
return ipc.CaptureAppendResp{Seconds: st.Duration.Seconds(), Expired: true}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return ipc.CaptureAppendResp{}, err
|
||||
}
|
||||
return ipc.CaptureAppendResp{Seconds: st.Duration.Seconds()}, nil
|
||||
}
|
||||
|
||||
// stop handles ipc.MethodCaptureStop.
|
||||
//
|
||||
// The error handling here mirrors vision's, and for the same reason: the audio is
|
||||
// stored first, so a transcription or summary failure returns what exists rather
|
||||
// than nothing. A response can carry a blob id with no transcript (STT failed,
|
||||
// re-runnable), or a transcript with no summary (the model failed, the words are
|
||||
// kept) — both are degraded successes and neither is an error to the caller.
|
||||
func (c *captureWiring) stop(ctx context.Context, req ipc.CaptureStopReq) (ipc.CaptureStopResp, error) {
|
||||
if req.Discard {
|
||||
// "забудь, не записывай" — nothing is stored, transcribed or noted.
|
||||
if !c.rec.Abort() {
|
||||
return ipc.CaptureStopResp{}, capture.ErrNoSession
|
||||
}
|
||||
log.Printf("capture: session discarded on request")
|
||||
return ipc.CaptureStopResp{Discarded: true}, nil
|
||||
}
|
||||
|
||||
res, err := c.rec.Stop(ctx)
|
||||
resp := ipc.CaptureStopResp{
|
||||
BlobID: res.BlobID,
|
||||
Label: res.Label,
|
||||
Started: res.Started,
|
||||
Seconds: res.Duration.Seconds(),
|
||||
Transcript: res.Transcript,
|
||||
Summary: res.Summary,
|
||||
Chunks: res.Chunks,
|
||||
}
|
||||
if err != nil {
|
||||
if res.BlobID == "" && res.Transcript == "" {
|
||||
// Nothing survived: no session, or an empty recording. There is
|
||||
// nothing to hand back, so this is a real error.
|
||||
return ipc.CaptureStopResp{}, err
|
||||
}
|
||||
log.Printf("capture: %q partially finished: %v", res.Label, err)
|
||||
}
|
||||
|
||||
if id, werr := c.writeNotes(ctx, res); werr != nil {
|
||||
log.Printf("capture: note write for %q failed: %v", res.Label, werr)
|
||||
} else {
|
||||
resp.NoteID = id
|
||||
}
|
||||
log.Printf("capture: finished %q — %s of audio, %d summary chunk(s)",
|
||||
res.Label, res.Duration.Round(time.Second), res.Chunks)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// writeNotes stores the summary as a note, and the transcript too when
|
||||
// capture.save_transcript is set. Returns the summary note's id, or 0 when there
|
||||
// was no summary to write.
|
||||
//
|
||||
// The note source carries the blob id, which is the only link back to the audio.
|
||||
// When retention prunes the blob the note remains — words about a meeting are a
|
||||
// far lighter thing to keep than a recording of it.
|
||||
func (c *captureWiring) writeNotes(ctx context.Context, res capture.Result) (int64, error) {
|
||||
source := "capture:meeting"
|
||||
if res.BlobID != "" {
|
||||
source = "capture:meeting:" + res.BlobID[:12]
|
||||
}
|
||||
var id int64
|
||||
if text := res.Summary; text != "" {
|
||||
var err error
|
||||
id, err = c.writeNote(ctx, text, source)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("summary note: %w", err)
|
||||
}
|
||||
}
|
||||
if c.cfg.SaveTranscript && res.Transcript != "" {
|
||||
if _, err := c.writeNote(ctx, res.Transcript, source+":transcript"); err != nil {
|
||||
return id, fmt.Errorf("transcript note: %w", err)
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (c *captureWiring) writeNote(ctx context.Context, text, source string) (int64, error) {
|
||||
var vec []float32
|
||||
if c.emb != nil {
|
||||
// EmbedPassage, not Embed: this is text being searched FOR, and the e5
|
||||
// embedder is asymmetric. Backwards here makes the meeting unfindable by
|
||||
// the question that should have matched it.
|
||||
var err error
|
||||
vec, err = router.EmbedPassage(ctx, c.emb, text)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("embed: %w", err)
|
||||
}
|
||||
}
|
||||
return c.st.WriteNote(ctx, c.now(), text, vec, source)
|
||||
}
|
||||
|
||||
// status handles ipc.MethodCaptureStatus.
|
||||
func (c *captureWiring) status(_ context.Context) (ipc.CaptureStatusResp, error) {
|
||||
st := c.rec.Status()
|
||||
return ipc.CaptureStatusResp{
|
||||
Running: st.Running,
|
||||
Label: st.Label,
|
||||
Started: st.Started,
|
||||
Seconds: st.Duration.Seconds(),
|
||||
Bytes: st.Bytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// wireCapture installs the four IPC hooks, or leaves them nil so every capture
|
||||
// method reports ErrUnknownMethod. Takes the media keeper wireVision already
|
||||
// opened: one blob store, one retention loop, images and audio side by side.
|
||||
func wireCapture(srv *ipc.Server, keeper *mediaKeeper, st *store.Store, voiceW *voiceWiring, phr phraser.Phraser, cfg *config.Config) {
|
||||
cw := newCaptureWiring(keeper, st, voiceW, phr, embedderOf(voiceW), cfg)
|
||||
if cw == nil {
|
||||
return
|
||||
}
|
||||
srv.CaptureStartFn = cw.start
|
||||
srv.CaptureAppendFn = cw.append
|
||||
srv.CaptureStopFn = cw.stop
|
||||
srv.CaptureStatusFn = cw.status
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/rss"
|
||||
"github.com/kami/maven/internal/stt"
|
||||
"github.com/kami/maven/internal/webfetch"
|
||||
)
|
||||
|
||||
@@ -116,6 +117,17 @@ func embedderOf(w *voiceWiring) router.Embedder {
|
||||
return w.embedder
|
||||
}
|
||||
|
||||
// transcriberOf — the STT the voice path is using, or nil when voice is off.
|
||||
// The meeting recorder reuses it rather than dialling mavsttd a second time:
|
||||
// Maven has one speech-to-text engine and adding a second would mean two
|
||||
// whisper contexts competing for the same iGPU.
|
||||
func transcriberOf(w *voiceWiring) stt.Transcriber {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
return w.transcriber
|
||||
}
|
||||
|
||||
// feedFetcher adapts webfetch to rss.Fetcher — the pure package names the two
|
||||
// fields it needs and stays free of net/http.
|
||||
type feedFetcher struct{ f *webfetch.Fetcher }
|
||||
|
||||
+1
-2
@@ -27,7 +27,6 @@ import (
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/email"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
@@ -66,7 +65,7 @@ func newMailIntake(st *store.Store, phr phraser.Phraser, cfg *config.Config) *ma
|
||||
if timeout <= 0 {
|
||||
timeout = config.DefaultEmailTimeout
|
||||
}
|
||||
ex := email.NewExtractor(llm.New(lp.BaseURL(), timeout), cfg.Email.MaxTasks, contextBlockFn(cfg, time.Now))
|
||||
ex := email.NewExtractor(llmClientFor(lp, timeout), cfg.Email.MaxTasks, contextBlockFn(cfg, time.Now))
|
||||
log.Printf("mail intake: enabled (max %d candidates per message, timeout %s)", cfg.Email.MaxTasks, timeout)
|
||||
return &mailIntake{st: st, ex: ex, timeout: timeout, now: time.Now}
|
||||
}
|
||||
|
||||
@@ -280,6 +280,9 @@ func run(args []string) error {
|
||||
api := coreAPI.(*daemonAPI)
|
||||
api.chatFn = voiceW.handler.handleText
|
||||
}
|
||||
if voiceW != nil && voiceW.mcp != nil {
|
||||
coreAPI.(*daemonAPI).getMCPServers = voiceW.mcp.status
|
||||
}
|
||||
} else {
|
||||
// locked mode: no real store yet, so there's no meaningful CoreAPI to
|
||||
// serve. srv.Check below is the actual guard — every CoreAPI call is
|
||||
@@ -329,6 +332,18 @@ func run(args []string) error {
|
||||
// ipc.MethodIngestMail reports ErrUnknownMethod.
|
||||
if !locked {
|
||||
wireMailIntake(srv, st, phr, cfg)
|
||||
wireModelSwap(srv, phr, cfg)
|
||||
// Vision + the media blob store (Vikunja #252). Both stay dark without a
|
||||
// media block; MethodDescribeImage answers ErrUnknownMethod then.
|
||||
keeper := wireVision(ctx, srv, st, embedderOf(voiceW), cfg)
|
||||
// The meeting recorder (Vikunja #253) shares that blob store and its
|
||||
// retention loop. Off unless a capture block enables it, in which case
|
||||
// all four capture methods answer ErrUnknownMethod.
|
||||
wireCapture(srv, keeper, st, voiceW, phr, cfg)
|
||||
// Voice identification (Vikunja #255). Enrolment plumbing only until a
|
||||
// speaker-embedding model exists on disk; off entirely without a speaker
|
||||
// block, so no wire path takes a voiceprint on a default box.
|
||||
wireSpeaker(srv, st, cfg)
|
||||
}
|
||||
|
||||
// WrapKeyFn — wraps the env key with a passkey credential public key and
|
||||
@@ -471,6 +486,13 @@ func run(args []string) error {
|
||||
srv.SetAPI(newAPI)
|
||||
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
|
||||
wireMailIntake(srv, st, phr, cfg)
|
||||
wireModelSwap(srv, phr, cfg)
|
||||
keeper := wireVision(ctx, srv, st, embedderOf(voiceW), cfg)
|
||||
wireCapture(srv, keeper, st, voiceW, phr, cfg)
|
||||
// Voice identification (Vikunja #255). Enrolment plumbing only until a
|
||||
// speaker-embedding model exists on disk; off entirely without a speaker
|
||||
// block, so no wire path takes a voiceprint on a default box.
|
||||
wireSpeaker(srv, st, cfg)
|
||||
|
||||
// Start voice server.
|
||||
if voiceW != nil {
|
||||
@@ -516,6 +538,11 @@ func run(args []string) error {
|
||||
}()
|
||||
}
|
||||
|
||||
// Keep MCP connections alive (nil unless configured).
|
||||
if voiceW != nil && voiceW.mcp != nil {
|
||||
go voiceW.mcp.run(ctx)
|
||||
}
|
||||
|
||||
dl.unlock()
|
||||
log.Printf("mavend: unlocked via passkey assertion")
|
||||
return nil
|
||||
@@ -575,6 +602,13 @@ func run(args []string) error {
|
||||
crawlWkr.run(ctx)
|
||||
}()
|
||||
}
|
||||
if voiceW != nil && voiceW.mcp != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
voiceW.mcp.run(ctx)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/mcp"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/webfetch"
|
||||
)
|
||||
|
||||
// mcpRefreshInterval — how often the manager re-dials a server that is down.
|
||||
// The manager applies its own backoff on top, so this being short is cheap.
|
||||
const mcpRefreshInterval = time.Minute
|
||||
|
||||
// mcpWiring — the MCP client, when the `mcp` block configures at least one
|
||||
// enabled server. nil ⇒ nothing was configured, nothing is connected, and an
|
||||
// allowlist row that happens to look like an MCP row refuses to run.
|
||||
//
|
||||
// It lives on the voice wiring because MCP tools ARE acts: they run through
|
||||
// tool.Executor, the enabled allowlist and the confirm turn, which only exist
|
||||
// on the voice/chat path. No voice surface ⇒ nothing that could call a tool.
|
||||
type mcpWiring struct {
|
||||
mgr *mcp.Manager
|
||||
st *store.Store
|
||||
}
|
||||
|
||||
// wireMCP builds the manager, connects, and proposes what it found. It never
|
||||
// fails the daemon: a server that is unreachable at boot is logged and retried,
|
||||
// because Maven starting is not contingent on someone else's process.
|
||||
func wireMCP(cfg *config.Config, st *store.Store) *mcpWiring {
|
||||
servers := cfg.MCPServers()
|
||||
if len(servers) == 0 {
|
||||
return nil
|
||||
}
|
||||
limits := webfetch.Config{}
|
||||
if cfg.MCP != nil {
|
||||
limits.AllowHosts = cfg.MCP.AllowHosts
|
||||
limits.DenyHosts = cfg.MCP.DenyHosts
|
||||
limits.MaxBytes = cfg.MCP.MaxBytes
|
||||
limits.Timeout = time.Duration(cfg.MCP.Timeout)
|
||||
}
|
||||
mgr, err := mcp.NewManager(mcp.WebfetchDoor(limits), servers)
|
||||
if err != nil {
|
||||
// Validation already ran in config.validate, so this is a programming
|
||||
// error rather than a config one. Still not fatal: MCP off is a working
|
||||
// Maven.
|
||||
log.Printf("mcp: not wired: %v", err)
|
||||
return nil
|
||||
}
|
||||
w := &mcpWiring{mgr: mgr, st: st}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
mgr.Connect(ctx)
|
||||
w.propose(ctx)
|
||||
return w
|
||||
}
|
||||
|
||||
// propose writes a 'proposed' allowlist row for every discovered tool. It does
|
||||
// NOT enable anything: a configured server is a place Maven may look, not a
|
||||
// capability she has. Kami enables what he wants on /tools, behind step-up,
|
||||
// which is the same gate a shell tool goes through.
|
||||
//
|
||||
// Re-running on every boot is idempotent — ProposeMCPTool never touches an
|
||||
// existing row, so a tool he disabled stays disabled and one he enabled keeps
|
||||
// the cmd he enabled it with.
|
||||
func (w *mcpWiring) propose(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
fresh := 0
|
||||
for _, t := range w.mgr.Tools() {
|
||||
name := mcp.LocalName(t.Server, t.Name)
|
||||
// No readOnlyHint ⇒ assume it mutates ⇒ the confirm turn. Being wrong
|
||||
// in this direction only costs a question.
|
||||
destructive := !t.ReadOnly
|
||||
provenance := fmt.Sprintf("mcp %s/%s", t.Server, t.Name)
|
||||
if t.Description != "" {
|
||||
provenance += ": " + t.Description
|
||||
}
|
||||
ok, err := w.st.ProposeMCPTool(ctx, name, mcp.Scope(t.Server),
|
||||
mcp.Cmd(t.Server, t.Name), destructive, provenance, now)
|
||||
if err != nil {
|
||||
log.Printf("mcp: propose %s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
fresh++
|
||||
}
|
||||
}
|
||||
if fresh > 0 {
|
||||
log.Printf("mcp: %d new tool proposal(s) waiting on /tools", fresh)
|
||||
}
|
||||
}
|
||||
|
||||
// run re-dials downed servers and picks up tools that appeared, until ctx is
|
||||
// canceled.
|
||||
func (w *mcpWiring) run(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
t := time.NewTicker(mcpRefreshInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
w.mgr.Refresh(ctx)
|
||||
w.propose(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// status maps the manager's view onto the wire type the web surface reads.
|
||||
func (w *mcpWiring) status() []ipc.MCPServerStatus {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
in := w.mgr.Status()
|
||||
out := make([]ipc.MCPServerStatus, 0, len(in))
|
||||
for _, s := range in {
|
||||
out = append(out, ipc.MCPServerStatus{
|
||||
Name: s.Name,
|
||||
Transport: s.Transport,
|
||||
Target: s.Target,
|
||||
Connected: s.Connected,
|
||||
Server: s.Server,
|
||||
Tools: s.Tools,
|
||||
Err: s.Err,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (w *mcpWiring) close() {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
_ = w.mgr.Close()
|
||||
}
|
||||
|
||||
// caller is the tool.MCPCaller the executor gets, or nil when MCP is off.
|
||||
func (w *mcpWiring) caller() *mcp.Manager {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
return w.mgr
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
)
|
||||
|
||||
func TestWireMCPOffWhenUnconfigured(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
for name, cfg := range map[string]*config.Config{
|
||||
"no block": {},
|
||||
"nothing enabled": {MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{
|
||||
{Name: "vikunja", URL: "http://192.168.1.104:9100/mcp"},
|
||||
}}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if w := wireMCP(cfg, st); w != nil {
|
||||
t.Fatal("MCP must be off unless a server is configured AND enabled")
|
||||
}
|
||||
})
|
||||
}
|
||||
// nil wiring must be safe to use everywhere it is reachable.
|
||||
var w *mcpWiring
|
||||
w.close()
|
||||
w.propose(context.Background())
|
||||
if w.status() != nil || w.caller() != nil {
|
||||
t.Fatal("a nil wiring must report nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// An unreachable server must not stop the daemon, must be reported as down, and
|
||||
// must propose nothing.
|
||||
func TestWireMCPUnreachableServerIsNotFatal(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
w := wireMCP(&config.Config{MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{{
|
||||
Name: "dead", Command: "/nonexistent/mcp-server", Enabled: true,
|
||||
}}}}, st)
|
||||
if w == nil {
|
||||
t.Fatal("a configured server should still wire")
|
||||
}
|
||||
defer w.close()
|
||||
st2 := w.status()
|
||||
if len(st2) != 1 || st2[0].Connected || st2[0].Err == "" {
|
||||
t.Fatalf("status = %+v", st2)
|
||||
}
|
||||
tools, err := st.ListTools(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tools) != 0 {
|
||||
t.Fatalf("a server that never answered must propose nothing, got %+v", tools)
|
||||
}
|
||||
}
|
||||
|
||||
// A url server whose address is private is refused by webfetch unless that
|
||||
// server sets allow_private. This is the guard the whole MCP path rides on, so
|
||||
// it is asserted here too, at the wiring level.
|
||||
func TestWireMCPPrivateURLRefusedWithoutAllowPrivate(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
w := wireMCP(&config.Config{MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{{
|
||||
Name: "lan", URL: "http://127.0.0.1:9100/mcp", Enabled: true,
|
||||
}}}}, st)
|
||||
if w == nil {
|
||||
t.Fatal("should wire")
|
||||
}
|
||||
defer w.close()
|
||||
s := w.status()[0]
|
||||
if s.Connected {
|
||||
t.Fatal("a loopback server must not connect without allow_private")
|
||||
}
|
||||
if !strings.Contains(s.Err, "private address") {
|
||||
t.Fatalf("err = %q, want the private-address refusal", s.Err)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/memeval"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -50,7 +49,7 @@ func newMemoryEvalWorker(st *store.Store, phr phraser.Phraser, cfg *config.Confi
|
||||
}
|
||||
// A generous per-request timeout: this is a long prompt to a Thinking model
|
||||
// and nobody is waiting on the answer.
|
||||
client := llm.New(lp.BaseURL(), 5*time.Minute)
|
||||
client := llmClientFor(lp, 5*time.Minute)
|
||||
ev := memeval.NewEvaluator(st, st, client, memeval.Config{
|
||||
MaxItems: cfg.MemoryEval.MaxItems,
|
||||
MinConfidence: cfg.MemoryEval.MinConfidence,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// Swapping the resident model while the daemon runs (Vikunja #250).
|
||||
//
|
||||
// Off unless configured: with no phraser.swap_models allowlist the two IPC
|
||||
// methods are never wired, so they answer ErrUnknownMethod. When it is wired the
|
||||
// swap method is AuthStepUp (internal/auth), which means an authed human surface
|
||||
// only — there is no act, no intent and no timer that reaches it. The daemon
|
||||
// never decides to change its own brain.
|
||||
//
|
||||
// The allowlist is exact-match against paths a human wrote in mavend.json. The
|
||||
// request carries a path and llama-server is started with it as `-m`, so
|
||||
// anything looser would turn "swap the model" into "load any file on my disk".
|
||||
func wireModelSwap(srv *ipc.Server, phr phraser.Phraser, cfg *config.Config) {
|
||||
if cfg.Phraser == nil || len(cfg.Phraser.SwapModels) == 0 {
|
||||
return
|
||||
}
|
||||
lp, ok := phr.(*phraser.LLMPhraser)
|
||||
if !ok {
|
||||
log.Printf("model swap: phraser.swap_models is set but there is no llama-server phraser — swap disabled")
|
||||
return
|
||||
}
|
||||
allowed := map[string]bool{}
|
||||
for _, m := range cfg.Phraser.SwapModels {
|
||||
allowed[filepath.Clean(m)] = true
|
||||
}
|
||||
// The configured model is always swappable back to, listed or not: the way
|
||||
// out of a bad swap must not depend on remembering to allowlist the model
|
||||
// you are already running.
|
||||
allowed[filepath.Clean(cfg.Phraser.ModelPath)] = true
|
||||
|
||||
srv.SwapModelFn = func(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error) {
|
||||
path := filepath.Clean(req.ModelPath)
|
||||
if !allowed[path] {
|
||||
log.Printf("model swap: REFUSED %q — not in phraser.swap_models", req.ModelPath)
|
||||
return ipc.SwapModelResp{}, fmt.Errorf("%w: %q is not in phraser.swap_models", ipc.ErrForbidden, req.ModelPath)
|
||||
}
|
||||
res, err := lp.Swap(ctx, phraser.SwapSpec{
|
||||
ModelPath: path,
|
||||
NGpuLayers: req.NGpuLayers,
|
||||
NCtx: req.NCtx,
|
||||
})
|
||||
resp := ipc.SwapModelResp{
|
||||
Model: res.Model,
|
||||
ModelPath: res.ModelPath,
|
||||
BaseURL: res.BaseURL,
|
||||
RolledBack: res.RolledBack,
|
||||
TookMs: res.Took.Milliseconds(),
|
||||
}
|
||||
if err != nil {
|
||||
// A rolled-back swap is a failure that left a working daemon behind.
|
||||
// Both halves matter to the caller, so the response is filled in even
|
||||
// though the error is returned.
|
||||
log.Printf("model swap: %v", err)
|
||||
return resp, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
srv.ModelStatusFn = func(ctx context.Context) (ipc.ModelStatusResp, error) {
|
||||
path, ngl, nctx := lp.LiveModel()
|
||||
base := lp.BaseURL()
|
||||
resp := ipc.ModelStatusResp{
|
||||
ModelPath: path,
|
||||
BaseURL: base,
|
||||
NGpuLayers: ngl,
|
||||
NCtx: nctx,
|
||||
Swappable: cfg.Phraser.SwapModels,
|
||||
}
|
||||
if base == "" {
|
||||
resp.Model = llm.UnknownModel
|
||||
return resp, nil
|
||||
}
|
||||
id, err := llm.ModelID(ctx, base)
|
||||
if err != nil {
|
||||
// Report the honest "I could not confirm it" rather than echoing the
|
||||
// configured filename as if the server had said it.
|
||||
resp.Model = llm.UnknownModel
|
||||
return resp, nil
|
||||
}
|
||||
resp.Model = id
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
log.Printf("model swap: enabled, %d allowlisted model(s) — step-up required", len(cfg.Phraser.SwapModels))
|
||||
}
|
||||
|
||||
// llmClientFor builds a completion client on the phraser's llama-server and
|
||||
// keeps it pointed at the right one across a model swap.
|
||||
//
|
||||
// Without the OnSwap registration every holder of a base URL — the LLM router,
|
||||
// the replier, the mail extractor, the memory evaluator — would keep talking to
|
||||
// the port of a server that no longer exists, and the daemon would degrade to
|
||||
// the classifier permanently after the first swap. The client is re-pointed, not
|
||||
// rebuilt, so nothing that holds it has to know a swap happened.
|
||||
func llmClientFor(lp *phraser.LLMPhraser, timeout time.Duration) *llm.Client {
|
||||
c := llm.New(lp.BaseURL(), timeout)
|
||||
lp.OnSwap(func(base string) { c.SetBaseURL(base) })
|
||||
return c
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// mavend/speaker.go — core's half of voice identification (Vikunja #255,
|
||||
// docs/plans/10-speaker-recognition.md).
|
||||
//
|
||||
// # What is actually wired here, and what is not
|
||||
//
|
||||
// The enrolment plumbing is real: profiles are stored, listed and deleted, and
|
||||
// the wire methods exist as soon as a speaker block is configured. The
|
||||
// recognising half is NOT, and cannot be on this box, because there is no
|
||||
// speaker-embedding model on disk — no ECAPA, no x-vector, no titanet, no
|
||||
// wespeaker, nothing in /mnt/hdd1/llms but text ggufs. Until one is downloaded,
|
||||
// newSpeakerEmbedder returns nil, internal/speaker falls back to
|
||||
// speaker.Disabled, and every Identify answers ErrDisabled. The daemon logs
|
||||
// which half is off at startup rather than pretending.
|
||||
//
|
||||
// This is deliberately not papered over with a hand-rolled MFCC floor. A
|
||||
// biometric that is confidently wrong writes false claims about named people
|
||||
// into his memory, and that is worse than a capability that is honestly absent.
|
||||
//
|
||||
// # Off unless configured
|
||||
//
|
||||
// No speaker block, or one without enabled, ⇒ the three methods do not exist and
|
||||
// answer ErrUnknownMethod. On an unconfigured box there is no wire path that
|
||||
// takes a voiceprint at all.
|
||||
//
|
||||
// # The refused design step
|
||||
//
|
||||
// The plan asks for unknown speakers to be enrolled on first interaction. That
|
||||
// is refused in internal/speaker/enroll.go and there is no handler for it here:
|
||||
// no request shape in the protocol enrols whoever just spoke. Taking a biometric
|
||||
// of a guest who walked past the microphone is not something this daemon does.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/speaker"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// speakerWiring holds the recognizer behind the three IPC handlers.
|
||||
type speakerWiring struct {
|
||||
rec *speaker.Recognizer
|
||||
}
|
||||
|
||||
// newSpeakerEmbedder loads the speaker-embedding model named by the config.
|
||||
//
|
||||
// It always returns nil today. The seam exists so that wiring a real model is a
|
||||
// change to this one function and nothing else: give it a loader, and Identify
|
||||
// starts working with no change to the store, the protocol, the auth table or
|
||||
// the handlers. See the plan document for what to download.
|
||||
func newSpeakerEmbedder(cfg *config.SpeakerConfig) speaker.Embedder {
|
||||
if cfg == nil || cfg.ModelPath == "" {
|
||||
return nil
|
||||
}
|
||||
log.Printf("speaker: model_path %q is configured but no embedding backend is built yet; "+
|
||||
"enrolment and deletion work, recognition does not (Vikunja #255)", cfg.ModelPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// newSpeakerWiring builds the recognizer, or nil when the capability is off.
|
||||
func newSpeakerWiring(st *store.Store, cfg *config.Config) *speakerWiring {
|
||||
if cfg == nil || cfg.Speaker == nil || !cfg.Speaker.Enabled {
|
||||
return nil
|
||||
}
|
||||
if st == nil {
|
||||
log.Print("speaker: enabled but there is no store to keep profiles in; staying off")
|
||||
return nil
|
||||
}
|
||||
rec, err := speaker.New(newSpeakerEmbedder(cfg.Speaker), st.VectorMemory(), speaker.Config{
|
||||
Threshold: cfg.Speaker.Threshold,
|
||||
MinSeconds: cfg.Speaker.MinSeconds,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("speaker: %v; staying off", err)
|
||||
return nil
|
||||
}
|
||||
if rec.Enabled() {
|
||||
log.Printf("speaker: recognition on, threshold %.2f", rec.Threshold())
|
||||
} else {
|
||||
log.Print("speaker: enrolment on, recognition BLOCKED — no speaker-embedding model " +
|
||||
"on this box (see docs/plans/10-speaker-recognition.md)")
|
||||
}
|
||||
return &speakerWiring{rec: rec}
|
||||
}
|
||||
|
||||
func (w *speakerWiring) enroll(ctx context.Context, req ipc.EnrollSpeakerReq) (ipc.EnrollSpeakerResp, error) {
|
||||
p, err := w.rec.Enroll(ctx, req.ID, req.Name, req.Samples)
|
||||
if err != nil {
|
||||
return ipc.EnrollSpeakerResp{}, speakerErr(err)
|
||||
}
|
||||
return ipc.EnrollSpeakerResp{Speaker: toWireSpeaker(p)}, nil
|
||||
}
|
||||
|
||||
func (w *speakerWiring) list(ctx context.Context) (ipc.ListSpeakersResp, error) {
|
||||
ps, err := w.rec.List(ctx)
|
||||
if err != nil {
|
||||
return ipc.ListSpeakersResp{}, speakerErr(err)
|
||||
}
|
||||
out := make([]ipc.Speaker, 0, len(ps))
|
||||
for _, p := range ps {
|
||||
out = append(out, toWireSpeaker(p))
|
||||
}
|
||||
return ipc.ListSpeakersResp{Speakers: out, Enabled: w.rec.Enabled()}, nil
|
||||
}
|
||||
|
||||
func (w *speakerWiring) forget(ctx context.Context, req ipc.ForgetSpeakerReq) error {
|
||||
return speakerErr(w.rec.Forget(ctx, req.ID))
|
||||
}
|
||||
|
||||
// toWireSpeaker drops the voiceprint. A listing says who is enrolled; it does
|
||||
// not hand the biometric back out over the socket.
|
||||
func toWireSpeaker(p speaker.Profile) ipc.Speaker {
|
||||
return ipc.Speaker{ID: p.ID, Name: p.Name, Enrolled: p.Enrolled, Samples: p.Samples}
|
||||
}
|
||||
|
||||
// speakerErr maps the package sentinels onto the wire vocabulary so a surface
|
||||
// can tell "you asked wrong" from "core broke".
|
||||
func speakerErr(err error) error {
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, speaker.ErrNotFound):
|
||||
return ipc.ErrNoFact
|
||||
case errors.Is(err, speaker.ErrBadID),
|
||||
errors.Is(err, speaker.ErrBadFormat),
|
||||
errors.Is(err, speaker.ErrTooShort):
|
||||
return errors.Join(ipc.ErrBadParams, err)
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// wireSpeaker attaches the three handlers when the capability is configured.
|
||||
func wireSpeaker(srv *ipc.Server, st *store.Store, cfg *config.Config) {
|
||||
w := newSpeakerWiring(st, cfg)
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
srv.EnrollSpeakerFn = w.enroll
|
||||
srv.ListSpeakersFn = w.list
|
||||
srv.ForgetSpeakerFn = w.forget
|
||||
}
|
||||
@@ -941,6 +941,7 @@ type daemonAPI struct {
|
||||
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
||||
getDayPlan func(ctx context.Context) ipc.DayPlan
|
||||
chatFn func(ctx context.Context, text string) string
|
||||
getMCPServers func() []ipc.MCPServerStatus
|
||||
}
|
||||
|
||||
func (d *daemonAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||
@@ -950,6 +951,16 @@ func (d *daemonAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||
return d.chatFn(ctx, text), nil
|
||||
}
|
||||
|
||||
// MCPServers — the configured MCP servers and their health (Vikunja #251).
|
||||
// Empty, not an error, when the mcp block is absent: "not configured" is the
|
||||
// default state and the web surface renders it as such.
|
||||
func (d *daemonAPI) MCPServers(ctx context.Context) ([]ipc.MCPServerStatus, error) {
|
||||
if d.getMCPServers == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return d.getMCPServers(), nil
|
||||
}
|
||||
|
||||
func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
|
||||
trace := d.getTrace()
|
||||
if trace == nil {
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// mavend/vision.go — core's half of image understanding (Vikunja #252,
|
||||
// docs/plans/07-vision.md).
|
||||
//
|
||||
// The split: any surface that can receive a picture (mavweb upload, a Telegram
|
||||
// photo through mavpoll, a path he names) hands the bytes to core over
|
||||
// ipc.MethodDescribeImage. Core stores them content-addressed under
|
||||
// media.dir, prepares a downscaled JPEG, and asks a local vision server what it
|
||||
// is. The description comes back as words; nothing about the image is echoed.
|
||||
//
|
||||
// Off unless configured twice over: no `media` block ⇒ nowhere to keep the
|
||||
// bytes, so the method does not exist; no `vision` block with enabled + a local
|
||||
// endpoint ⇒ the store is wired but the describing half refuses, and the method
|
||||
// still does not exist. A surface cannot make Maven look at pictures by merely
|
||||
// sending one.
|
||||
//
|
||||
// Two things this file deliberately does not do:
|
||||
//
|
||||
// - No cloud vision call, ever. internal/vision refuses a non-private
|
||||
// endpoint at construction; there is no config shape here that could reach
|
||||
// an upstream API even if someone wanted one.
|
||||
// - No automatic memory. SaveNote is opt-in per call. Glancing at a screenshot
|
||||
// is not the same act as remembering it, and a 1.7B-class VLM's guess about
|
||||
// a photo is not a fact worth carrying around.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/media"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/vision"
|
||||
)
|
||||
|
||||
// prunePeriod — how often stored blobs are checked against media.retention.
|
||||
// Hourly is far more often than needed for a 7-day retention and costs a
|
||||
// directory walk over a handful of sidecars; the point is that the promise is
|
||||
// kept by a loop that runs, not by an operator remembering a cron.
|
||||
const prunePeriod = time.Hour
|
||||
|
||||
// mediaKeeper — the blob store plus the loop that enforces its retention. The
|
||||
// two are one object because a store without the loop is a directory that grows
|
||||
// forever, and shipping that would break the only interesting promise this
|
||||
// capability makes.
|
||||
type mediaKeeper struct {
|
||||
store *media.Store
|
||||
}
|
||||
|
||||
// openMediaStore builds the blob store from config, or returns nil when media is
|
||||
// not configured. A relative dir resolves against StateDir, the same rule the db
|
||||
// and socket paths follow.
|
||||
func openMediaStore(cfg *config.Config) *mediaKeeper {
|
||||
dir := cfg.Media.StoreDir()
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
if !filepath.IsAbs(dir) && cfg.StateDir != "" {
|
||||
dir = filepath.Join(cfg.StateDir, dir)
|
||||
}
|
||||
st, err := media.Open(dir, cfg.Media.MaxBytes, time.Duration(cfg.Media.Retention))
|
||||
if err != nil {
|
||||
log.Printf("media: %v — image and audio intake disabled", err)
|
||||
return nil
|
||||
}
|
||||
log.Printf("media: blob store at %s, retention %s", st.Dir(), st.Retention())
|
||||
return &mediaKeeper{store: st}
|
||||
}
|
||||
|
||||
// runPrune deletes over-retention blobs on a loop until ctx ends. It prunes once
|
||||
// immediately, so a daemon restarted after a long downtime does not sit on a
|
||||
// month of stale recordings until the first tick.
|
||||
func (k *mediaKeeper) runPrune(ctx context.Context) {
|
||||
prune := func() {
|
||||
n, err := k.store.Prune()
|
||||
if err != nil {
|
||||
log.Printf("media: prune: %v", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("media: pruned %d blob(s) older than %s", n, k.store.Retention())
|
||||
}
|
||||
}
|
||||
prune()
|
||||
t := time.NewTicker(prunePeriod)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
prune()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// visionIntake — one image at a time: store, prepare, describe, optionally note.
|
||||
type visionIntake struct {
|
||||
in *vision.Intake
|
||||
st *store.Store
|
||||
emb router.Embedder
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// newVisionIntake returns nil when there is nothing to wire. keeper == nil means
|
||||
// no media block, which disables the method outright; a missing or disabled
|
||||
// vision block still wires the method, because storing an image and answering
|
||||
// "I can't look at it yet" is more useful than pretending the surface does not
|
||||
// exist — and it is exactly the state this box is in until a vision model is on
|
||||
// disk.
|
||||
func newVisionIntake(keeper *mediaKeeper, st *store.Store, emb router.Embedder, cfg *config.Config) *visionIntake {
|
||||
if keeper == nil {
|
||||
return nil
|
||||
}
|
||||
vc := cfg.Vision
|
||||
maxDim := 0
|
||||
var provider vision.Provider = vision.Disabled{}
|
||||
if vc.LooksAtImages() {
|
||||
p, err := vision.NewLocal(vision.Config{
|
||||
Endpoint: vc.Endpoint,
|
||||
Model: vc.Model,
|
||||
Timeout: time.Duration(vc.Timeout),
|
||||
MaxTokens: vc.MaxTokens,
|
||||
Prompt: vc.Prompt,
|
||||
})
|
||||
if err != nil {
|
||||
// A public endpoint, a hostname, a bad URL. Logged once here rather
|
||||
// than failing every turn, and the store still works.
|
||||
log.Printf("vision: %v — she can store images but not describe them", err)
|
||||
} else {
|
||||
provider = p
|
||||
maxDim = vc.MaxDim
|
||||
log.Printf("vision: enabled against %s", p.Endpoint())
|
||||
}
|
||||
} else {
|
||||
log.Printf("vision: not configured — images are stored, not described")
|
||||
}
|
||||
return &visionIntake{
|
||||
in: vision.NewIntake(keeper.store, provider, maxDim),
|
||||
st: st,
|
||||
emb: emb,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// describe handles one ipc.MethodDescribeImage call.
|
||||
//
|
||||
// A description failure is NOT an error out of this method when the bytes were
|
||||
// stored: the caller gets the id and an empty description, which is honest ("it
|
||||
// is kept, I cannot read it yet") and re-runnable. A failure to store, or bytes
|
||||
// that are not an image at all, is an error — there is nothing to come back to.
|
||||
func (v *visionIntake) describe(ctx context.Context, req ipc.DescribeImageReq) (ipc.DescribeImageResp, error) {
|
||||
if len(req.Data) == 0 && req.ID == "" {
|
||||
return ipc.DescribeImageResp{}, fmt.Errorf("describe image: neither data nor id")
|
||||
}
|
||||
|
||||
var (
|
||||
res vision.Result
|
||||
err error
|
||||
)
|
||||
if req.ID != "" {
|
||||
res, err = v.in.Rerun(ctx, req.ID, req.Question)
|
||||
} else {
|
||||
res, err = v.in.Accept(ctx, req.Data, sourceOrDefault(req.Source), req.Question)
|
||||
}
|
||||
if res.Blob.ID == "" {
|
||||
// Nothing was stored: bad format, over the size cap, unwritable dir.
|
||||
return ipc.DescribeImageResp{}, fmt.Errorf("describe image: %w", err)
|
||||
}
|
||||
|
||||
resp := ipc.DescribeImageResp{
|
||||
ID: res.Blob.ID,
|
||||
Description: res.Description,
|
||||
Width: res.Image.Width,
|
||||
Height: res.Image.Height,
|
||||
}
|
||||
if err != nil {
|
||||
// Bytes are safe, words are not available. The log names the blob and the
|
||||
// reason; it never names what was in the picture.
|
||||
if errors.Is(err, vision.ErrDisabled) {
|
||||
log.Printf("vision: stored %s, no vision model configured", res.Blob)
|
||||
} else {
|
||||
log.Printf("vision: stored %s, describe failed: %v", res.Blob, err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if req.SaveNote {
|
||||
id, werr := v.writeNote(ctx, res)
|
||||
if werr != nil {
|
||||
// The description is still returned: losing the note is worse as a
|
||||
// silent failure than as a log line next to a successful answer.
|
||||
log.Printf("vision: note write for %s failed: %v", res.Blob, werr)
|
||||
} else {
|
||||
resp.NoteID = id
|
||||
}
|
||||
}
|
||||
log.Printf("vision: described %s (%dx%d)", res.Blob, res.Image.Width, res.Image.Height)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// writeNote stores the description as an ordinary note so it is recallable. The
|
||||
// note carries the blob id in its source, which is the only link back to the
|
||||
// bytes — the note text is words about the picture, never the picture.
|
||||
func (v *visionIntake) writeNote(ctx context.Context, res vision.Result) (int64, error) {
|
||||
var vec []float32
|
||||
if v.emb != nil {
|
||||
// EmbedPassage, not Embed: a description is text being searched FOR, and
|
||||
// the e5 embedder is asymmetric. Backwards here makes it unfindable by
|
||||
// the question that should have matched it.
|
||||
var err error
|
||||
vec, err = router.EmbedPassage(ctx, v.emb, res.Description)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("embed: %w", err)
|
||||
}
|
||||
}
|
||||
source := "media:image:" + res.Blob.ID[:12]
|
||||
return v.st.WriteNote(ctx, v.now(), res.Description, vec, source)
|
||||
}
|
||||
|
||||
// sourceOrDefault labels a blob whose sender did not say where it came from.
|
||||
func sourceOrDefault(s string) string {
|
||||
if s == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// wireVision installs the IPC hook and starts the retention loop, or leaves the
|
||||
// hook nil so ipc.MethodDescribeImage reports ErrUnknownMethod. Called on both
|
||||
// startup paths (unlocked boot and passkey unlock) so vision behaves the same
|
||||
// either way.
|
||||
//
|
||||
// Returns the media keeper so the meeting recorder can share it: one blob store
|
||||
// with one retention loop holds both the images and the audio, which is the
|
||||
// whole point of internal/media being a shared package. nil ⇒ no media block,
|
||||
// and neither capability exists.
|
||||
func wireVision(ctx context.Context, srv *ipc.Server, st *store.Store, emb router.Embedder, cfg *config.Config) *mediaKeeper {
|
||||
keeper := openMediaStore(cfg)
|
||||
if keeper == nil {
|
||||
return nil
|
||||
}
|
||||
go keeper.runPrune(ctx)
|
||||
|
||||
vi := newVisionIntake(keeper, st, emb, cfg)
|
||||
if vi == nil {
|
||||
return keeper
|
||||
}
|
||||
srv.DescribeImageFn = vi.describe
|
||||
return keeper
|
||||
}
|
||||
+22
-1
@@ -40,6 +40,15 @@ type voiceWiring struct {
|
||||
// mavsttd / mavttsd don't keep a stale conn into a restarting daemon.
|
||||
sttClient *worker.Client
|
||||
ttsClient *worker.Client
|
||||
// transcriber — the STT in use, exposed so the meeting recorder
|
||||
// (cmd/mavend/capture.go) can reuse it. Maven has exactly one STT and does
|
||||
// not grow a second one for capture: this is the same whisper.cpp worker the
|
||||
// voice path talks to.
|
||||
transcriber stt.Transcriber
|
||||
// mcp — the MCP client, nil unless the `mcp` block configures an enabled
|
||||
// server (Vikunja #251). Its tools land in the same allowlist as every
|
||||
// other act, so nothing else here has to know about it.
|
||||
mcp *mcpWiring
|
||||
}
|
||||
|
||||
// close releases the listener + worker conns. Safe to call on nil (when
|
||||
@@ -60,6 +69,7 @@ func (w *voiceWiring) close() {
|
||||
if w.ttsClient != nil {
|
||||
_ = w.ttsClient.Close()
|
||||
}
|
||||
w.mcp.close()
|
||||
}
|
||||
|
||||
// wireVoice builds the audio path from cfg + a CoreAPI + a router. Returns
|
||||
@@ -87,6 +97,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
} else {
|
||||
transcriber = stt.NewStub()
|
||||
}
|
||||
w.transcriber = transcriber
|
||||
|
||||
// ----- tts (Stub in-process OR Remote) -----
|
||||
var synthesizer tts.Synthesizer
|
||||
@@ -131,6 +142,14 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
// daemon restart.
|
||||
seedTools(coreAPI, cfg.Voice.Tools)
|
||||
exec := tool.NewExecutor(coreAPI, time.Duration(cfg.Voice.ToolTimeout))
|
||||
// MCP servers (Vikunja #251): discovery PROPOSES tools into the same
|
||||
// allowlist, so an MCP tool is enabled by hand on /tools like any other and
|
||||
// runs through the same confirm turn. Off unless the `mcp` block configures
|
||||
// an enabled server.
|
||||
w.mcp = wireMCP(cfg, dataStore)
|
||||
if w.mcp != nil {
|
||||
exec = exec.WithMCP(w.mcp.caller())
|
||||
}
|
||||
matcher := tool.NewMatcher(coreAPI)
|
||||
|
||||
// ----- weather provider (Open-Meteo when configured, Stub otherwise) -----
|
||||
@@ -148,7 +167,9 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
// The replier uses the same llama-server as the phraser.
|
||||
var llmClient *llm.Client
|
||||
if lp, ok := phr.(*phraser.LLMPhraser); ok {
|
||||
llmClient = llm.New(lp.BaseURL(), 60*time.Second)
|
||||
// llmClientFor, not llm.New: this client must follow the phraser onto
|
||||
// the new llama-server when the resident model is swapped (Vikunja #250).
|
||||
llmClient = llmClientFor(lp, 60*time.Second)
|
||||
}
|
||||
// ----- router (the cascade; floor examples seed the classifier) -----
|
||||
// The act matcher's allowlist is exactly the enabled tool names — the
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// Command mavupdate deploys a new build of Maven to the box she runs on, with
|
||||
// an automatic rollback when the new build does not come up (Vikunja #249).
|
||||
//
|
||||
// It is a CLI on purpose, and it is the ONLY trigger for the update path.
|
||||
//
|
||||
// The obvious design — an IPC method plus a button on the web UI behind the
|
||||
// step-up passkey gate, the way /tools works — was considered and refused. A
|
||||
// step-up gate protects against the wrong person clicking; it does not change
|
||||
// the fact that anything reachable over the network becomes, in the event of a
|
||||
// mavweb bug, a remote arbitrary-code path with a build system attached. An
|
||||
// update needs shell access on the host, which is a strictly higher bar than
|
||||
// the gate that guards the tool allowlist. That is deliberate and it is the
|
||||
// reason there is no MethodApplyUpdate anywhere in internal/ipc.
|
||||
//
|
||||
// Consequently: mavend does not import internal/update, nothing runs on a timer,
|
||||
// nothing checks a release server, and no act, intent, tool or LLM output can
|
||||
// reach any of this. She cannot update herself. She can be updated, by him.
|
||||
//
|
||||
// mavupdate -config deploy/mavend.json list # snapshots available to roll back to
|
||||
// mavupdate -config deploy/mavend.json verify # make build + make test, deploys nothing
|
||||
// mavupdate -config deploy/mavend.json apply -yes # the whole thing
|
||||
// mavupdate -config deploy/mavend.json rollback [id] # restore + restart (default: newest)
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/update"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfgPath := flag.String("config", "deploy/mavend.json", "path to mavend.json (the update block is read from it)")
|
||||
yes := flag.Bool("yes", false, "required by `apply` and `rollback`: yes, restart the daemon")
|
||||
flag.Usage = usage
|
||||
flag.Parse()
|
||||
|
||||
// The stdlib flag package stops parsing at the first non-flag argument, so a
|
||||
// `-yes` written after the subcommand (which is how anyone would type it, and
|
||||
// how the usage text shows it) lands in Args instead of the flag. Pick it out
|
||||
// by hand rather than silently treating "apply -yes" as an unconfirmed apply.
|
||||
var args []string
|
||||
for _, a := range flag.Args() {
|
||||
if a == "-yes" || a == "--yes" {
|
||||
*yes = true
|
||||
continue
|
||||
}
|
||||
args = append(args, a)
|
||||
}
|
||||
if len(args) == 0 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
if err != nil {
|
||||
die("config: %v", err)
|
||||
}
|
||||
if cfg.Update == nil {
|
||||
die("no `update` block in %s — the update capability is off unless configured.\nSee the package comment in internal/update for what it does and does not do.", *cfgPath)
|
||||
}
|
||||
|
||||
logf := func(format string, a ...any) {
|
||||
fmt.Fprintf(os.Stderr, "%s %s\n", time.Now().Format("15:04:05"), fmt.Sprintf(format, a...))
|
||||
}
|
||||
u, err := update.New(*cfg.Update, update.WithLogger(logf))
|
||||
if err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
|
||||
// Ctrl-C cancels the build or the health wait. It cannot cancel a rollback
|
||||
// midway into leaving the box in an unknown state, because the rollback runs
|
||||
// on its own context — see cmdApply.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
switch args[0] {
|
||||
case "list":
|
||||
cmdList(u)
|
||||
case "verify":
|
||||
cmdVerify(ctx, u)
|
||||
case "apply":
|
||||
if !*yes {
|
||||
die("apply restarts mavend and can roll her back. Re-run with -yes if that is what you want.")
|
||||
}
|
||||
cmdApply(ctx, u)
|
||||
case "rollback":
|
||||
if !*yes {
|
||||
die("rollback restores the previous artifacts and restarts mavend. Re-run with -yes.")
|
||||
}
|
||||
id := ""
|
||||
if len(args) > 1 {
|
||||
id = args[1]
|
||||
}
|
||||
cmdRollback(ctx, u, id)
|
||||
default:
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdList(u *update.Updater) {
|
||||
snaps, err := u.Snapshots()
|
||||
if err != nil {
|
||||
die("snapshots: %v", err)
|
||||
}
|
||||
if len(snaps) == 0 {
|
||||
fmt.Println("no snapshots yet — the first `apply` takes one before it builds anything")
|
||||
return
|
||||
}
|
||||
fmt.Printf("%-18s %-12s %s\n", "SNAPSHOT", "COMMIT", "FILES")
|
||||
for _, s := range snaps {
|
||||
commit := s.Commit
|
||||
if len(commit) > 12 {
|
||||
commit = commit[:12]
|
||||
}
|
||||
if commit == "" {
|
||||
commit = "-"
|
||||
}
|
||||
fmt.Printf("%-18s %-12s %d\n", s.ID, commit, len(s.Files))
|
||||
}
|
||||
fmt.Printf("\nrollback to the newest with: mavupdate rollback -yes\n")
|
||||
}
|
||||
|
||||
func cmdVerify(ctx context.Context, u *update.Updater) {
|
||||
steps, err := u.Verify(ctx)
|
||||
report(steps)
|
||||
if err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
fmt.Println("verified: the tree builds and passes its own tests. Nothing was deployed — run `apply -yes` for that.")
|
||||
}
|
||||
|
||||
func cmdApply(ctx context.Context, u *update.Updater) {
|
||||
res, err := u.Apply(ctx)
|
||||
report(res.Steps)
|
||||
summarize(res)
|
||||
switch {
|
||||
case err == nil:
|
||||
fmt.Println("\nupdate committed: she answers on the new build.")
|
||||
case errors.Is(err, update.ErrRollbackFailed):
|
||||
die("\n%v\n\nSHE IS PROBABLY DOWN. The previous artifacts are in the snapshot dir; copy them\nover the install dir and restart by hand.", err)
|
||||
case errors.Is(err, update.ErrRolledBack):
|
||||
die("\n%v\n\nShe is answering again on the previous build. Nothing was lost; fix the change and retry.", err)
|
||||
default:
|
||||
die("\n%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdRollback(ctx context.Context, u *update.Updater, id string) {
|
||||
res, err := u.Rollback(ctx, id)
|
||||
report(res.Steps)
|
||||
summarize(res)
|
||||
if err != nil && !errors.Is(err, update.ErrRolledBack) {
|
||||
die("\n%v", err)
|
||||
}
|
||||
fmt.Printf("\nrolled back to %s; she answers on it.\n", res.SnapshotID)
|
||||
}
|
||||
|
||||
func report(steps []update.Step) {
|
||||
for _, s := range steps {
|
||||
status := "ok"
|
||||
if s.Err != nil {
|
||||
status = "FAILED: " + s.Err.Error()
|
||||
}
|
||||
fmt.Printf(" %-8s %-8s %s\n", s.Name, s.Took.Round(time.Second), status)
|
||||
if s.Output != "" {
|
||||
fmt.Printf("---- %s output ----\n%s\n-------------------\n", s.Name, s.Output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func summarize(res update.Result) {
|
||||
fmt.Printf("\nverified=%v snapshot=%s installed=%d restarted=%v healthy=%v rolled_back=%v rollback_healthy=%v took=%s\n",
|
||||
res.Verified, res.SnapshotID, len(res.Installed), res.Restarted, res.Healthy, res.RolledBack, res.RollbackHealthy, res.Took.Round(time.Second))
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprint(os.Stderr, `mavupdate — deploy a new build of Maven, with rollback.
|
||||
|
||||
mavupdate [-config path] list
|
||||
mavupdate [-config path] verify
|
||||
mavupdate [-config path] apply -yes
|
||||
mavupdate [-config path] rollback [snapshot-id] -yes
|
||||
|
||||
apply is: health-check the running daemon, snapshot the deployed artifacts,
|
||||
make build, make test, install, restart, health-check — and restore the
|
||||
snapshot if any of that fails. It never fetches code and never runs by itself.
|
||||
|
||||
`)
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
|
||||
func die(format string, a ...any) {
|
||||
fmt.Fprintf(os.Stderr, format+"\n", a...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -67,6 +67,14 @@ type fakeCore struct {
|
||||
// for handleChatAPI tests
|
||||
chatText string
|
||||
chatErr error
|
||||
|
||||
// for the MCP section of /tools
|
||||
mcpServers []ipc.MCPServerStatus
|
||||
mcpErr error
|
||||
}
|
||||
|
||||
func (f *fakeCore) MCPServers(context.Context) ([]ipc.MCPServerStatus, error) {
|
||||
return f.mcpServers, f.mcpErr
|
||||
}
|
||||
|
||||
func (f *fakeCore) Chat(_ context.Context, text string) (string, error) {
|
||||
@@ -1118,3 +1126,49 @@ func TestHandleChatAPI_FailOpenByDefault(t *testing.T) {
|
||||
t.Errorf("core.Chat text = %q, want %q", core.chatText, "привет")
|
||||
}
|
||||
}
|
||||
|
||||
// The MCP section renders the configured servers, and a proposal that already
|
||||
// knows its cmd prefills the enable form so the argv is not retyped by hand.
|
||||
func TestHandleTools_GET_MCPSection(t *testing.T) {
|
||||
core := &fakeCore{
|
||||
proposed: []ipc.Tool{{
|
||||
Name: "vikunja_list_tasks", Scope: "mcp:vikunja",
|
||||
Cmd: []string{"mcp", "vikunja", "list_tasks"}, Destructive: true,
|
||||
Utterance: "mcp vikunja/list_tasks: List tasks in a project.",
|
||||
}},
|
||||
mcpServers: []ipc.MCPServerStatus{
|
||||
{Name: "vikunja", Transport: "http", Target: "http://192.168.1.104:9100/mcp", Connected: true, Server: "vikunja 0.1.0", Tools: 4},
|
||||
{Name: "files", Transport: "stdio", Target: "mcp-server-fs /srv", Err: "start: no such file"},
|
||||
},
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), core, nil, false)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{
|
||||
"MCP servers", "vikunja", "192.168.1.104:9100/mcp", "vikunja 0.1.0",
|
||||
"files", "no such file",
|
||||
`value="mcp vikunja list_tasks"`, // the enable form is prefilled
|
||||
"checked", // and pre-marked destructive (no readOnlyHint)
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("missing %q in /tools output", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MCP off (or an older core that does not know the method) renders the section
|
||||
// empty instead of breaking the page.
|
||||
func TestHandleTools_GET_MCPUnavailable(t *testing.T) {
|
||||
core := &fakeCore{mcpErr: ipc.ErrNotImplemented}
|
||||
rr := httptest.NewRecorder()
|
||||
handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), core, nil, false)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "no MCP servers configured") {
|
||||
t.Error("expected the empty-state copy")
|
||||
}
|
||||
}
|
||||
|
||||
+36
-4
@@ -124,6 +124,7 @@ var sidebarSections = []struct {
|
||||
Label: "Settings",
|
||||
Pages: []struct{ Label, URL, Key string }{
|
||||
{Label: "Tools", URL: "/tools", Key: "tools"},
|
||||
{Label: "Model", URL: "/models", Key: "models"},
|
||||
{Label: "Passkey", URL: "/auth/passkey", Key: "passkey"},
|
||||
},
|
||||
},
|
||||
@@ -191,6 +192,8 @@ func pageIcon(key string) string {
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
||||
case "tools":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-settings"/></svg>`
|
||||
case "models":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
|
||||
case "passkey":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-lock"/></svg>`
|
||||
default:
|
||||
@@ -225,6 +228,8 @@ func pageTitle(key string) string {
|
||||
return "Ecosystem"
|
||||
case "tools":
|
||||
return "Tools"
|
||||
case "models":
|
||||
return "Resident Model"
|
||||
case "passkey":
|
||||
return "Passkey"
|
||||
default:
|
||||
@@ -479,6 +484,12 @@ func main() {
|
||||
mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleRoutines(w, r, core, stepUpSession, *requireStepUp)
|
||||
})
|
||||
// /models — the resident-model surface (Vikunja #250). Same step-up gate as
|
||||
// /tools, and for a comparable reason: which model is loaded decides how every
|
||||
// utterance is routed and how every reply is worded. GET is read-only.
|
||||
mux.HandleFunc("/models", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleModels(w, r, core, stepUpSession, *requireStepUp)
|
||||
})
|
||||
|
||||
// State-changing routes on this server, and their gate (Vikunja #317):
|
||||
//
|
||||
@@ -681,7 +692,7 @@ const toolsHTML = `{{template "shellTop" "tools"}}
|
||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
|
||||
{{if .Proposed}}<p class=hint>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable.</p>
|
||||
{{if .Proposed}}<p class=hint>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable. A row in an <code>mcp:</code> scope came from an MCP server and already knows what it calls — check the command, then enable.</p>
|
||||
<div class=scroll><table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
|
||||
{{range .Proposed}}<tr>
|
||||
<td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
|
||||
@@ -689,8 +700,8 @@ const toolsHTML = `{{template "shellTop" "tools"}}
|
||||
<input type=hidden name=name value="{{.Name}}">
|
||||
<input type=hidden name=scope value="{{.Scope}}">
|
||||
<input type=hidden name=action value=enable>
|
||||
<input type=text name=cmd class=input-wide placeholder="systemctl restart" required>
|
||||
<label><input type=checkbox name=destructive> destructive</label>
|
||||
<input type=text name=cmd class=input-wide placeholder="systemctl restart" value="{{join .Cmd " "}}" required>
|
||||
<label><input type=checkbox name=destructive {{if .Destructive}}checked{{end}}> destructive</label>
|
||||
<button class=btn>enable</button></form>
|
||||
<form method=post action=/tools class=inline-form>
|
||||
<input type=hidden name=name value="{{.Name}}">
|
||||
@@ -719,6 +730,19 @@ const toolsHTML = `{{template "shellTop" "tools"}}
|
||||
<div class=hint>enable proposed tools above, or ask maven to configure one</div>
|
||||
</div>{{end}}
|
||||
</section>
|
||||
<section class=card>
|
||||
<h2 class=card-title>MCP servers <span class=badge>{{len .MCP}}</span></h2>
|
||||
{{if .MCP}}<p class=hint>servers she connects OUT to. Their tools appear above as proposals — a configured server is a place she may look, not a capability she has. A <code>stdio</code> target is a process on this box; an <code>http</code> one on a loopback or LAN address is inside the network, so treat its tools accordingly.</p>
|
||||
<div class=scroll><table><tr><th>name</th><th>transport</th><th>target</th><th>state</th><th>tools</th></tr>
|
||||
{{range .MCP}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Transport}}</span></td><td><code>{{.Target}}</code></td>
|
||||
<td>{{if .Connected}}connected{{if .Server}} — {{.Server}}{{end}}{{else}}<span class=red>down</span>{{if .Err}} — {{.Err}}{{end}}{{end}}</td>
|
||||
<td>{{.Tools}}</td></tr>{{end}}</table></div>
|
||||
{{else}}<div class=empty>
|
||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
|
||||
<div>no MCP servers configured</div>
|
||||
<div class=hint>add an <code>mcp.servers</code> block to mavend.json to let her use an external tool server</div>
|
||||
</div>{{end}}
|
||||
</section>
|
||||
{{template "shellBottom"}}`
|
||||
|
||||
// routinesHTML — proposed routine review surface. One row per thing maven
|
||||
@@ -1309,12 +1333,20 @@ func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sessi
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
// MCP is off by default and an older core may not know the method at all,
|
||||
// so a failure here renders an empty section rather than breaking the page.
|
||||
servers, err := core.MCPServers(ctx)
|
||||
if err != nil {
|
||||
log.Printf("tools: mcp servers: %v", err)
|
||||
servers = nil
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := toolsTmpl.Execute(w, struct {
|
||||
Msg string
|
||||
Proposed []ipc.Tool
|
||||
Enabled []ipc.Tool
|
||||
}{msg, proposed, enabled}); err != nil {
|
||||
MCP []ipc.MCPServerStatus
|
||||
}{msg, proposed, enabled, servers}); err != nil {
|
||||
log.Printf("tools render: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
// The resident-model surface (Vikunja #250).
|
||||
//
|
||||
// GET shows which model llama-server actually has loaded and which files the
|
||||
// daemon is configured to allow. POST swaps to one of them, behind the same
|
||||
// step-up gate as POST /tools: the loaded model decides how every utterance is
|
||||
// routed and how every reply is worded, so it is an owner action.
|
||||
//
|
||||
// There is nothing on this page Maven can press. The swap is an IPC method rated
|
||||
// AuthStepUp in internal/auth, unreachable from an act, an intent or a timer.
|
||||
|
||||
// modelController — the two non-CoreAPI methods this page needs. *ipc.Client
|
||||
// satisfies it; a core without a swap allowlist answers ErrUnknownMethod, which
|
||||
// the page renders as "not configured" rather than an error.
|
||||
type modelController interface {
|
||||
ModelStatus(ctx context.Context) (ipc.ModelStatusResp, error)
|
||||
SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error)
|
||||
}
|
||||
|
||||
var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(shellTopHTML + modelsHTML + shellBottomHTML))
|
||||
|
||||
const modelsHTML = `{{template "shellTop" "models"}}
|
||||
<h1>Resident model</h1>
|
||||
<p class=hint>swapping requires step-up — <a href=/auth/passkey>assert a passkey</a> first. The old model is unloaded before the new one is loaded (one model fits the iGPU at a time), so turns during the load are refused and fall back to the classifier.</p>
|
||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||
{{if .Err}}<div class="msg msg-err">{{.Err}}</div>{{end}}
|
||||
{{if .Off}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>swap not configured</h2>
|
||||
<p class=hint>this core has no <code>phraser.swap_models</code> allowlist, so there is nothing to swap to. Add the gguf paths you allow to <code>deploy/mavend.json</code> and restart once.</p>
|
||||
</section>
|
||||
{{else}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>loaded now</h2>
|
||||
<div class=scroll><table>
|
||||
<tr><th>model</th><td><code>{{.Status.Model}}</code></td></tr>
|
||||
<tr><th>file</th><td><code>{{.Status.ModelPath}}</code></td></tr>
|
||||
<tr><th>server</th><td><code>{{.Status.BaseURL}}</code></td></tr>
|
||||
<tr><th>n_ctx</th><td>{{.Status.NCtx}}</td></tr>
|
||||
<tr><th>n_gpu_layers</th><td>{{.Status.NGpuLayers}}</td></tr>
|
||||
</table></div>
|
||||
<p class=hint>the model name is what llama-server reports for itself, not what the config says it should be.</p>
|
||||
</section>
|
||||
<section class=card>
|
||||
<h2 class=card-title>allowed models <span class=badge>{{len .Status.Swappable}}</span></h2>
|
||||
{{if .Status.Swappable}}<div class=scroll><table><tr><th>file</th><th></th></tr>
|
||||
{{range .Status.Swappable}}<tr><td><code>{{.}}</code></td>
|
||||
<td><form method=post action=/models class=inline-form>
|
||||
<input type=hidden name=model_path value="{{.}}">
|
||||
<button class=btn>load this one</button></form></td></tr>{{end}}
|
||||
</table></div>
|
||||
{{else}}<div class=empty><div>no models allowlisted</div></div>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{template "shellBottom"}}`
|
||||
|
||||
type modelsPage struct {
|
||||
Msg string
|
||||
Err string
|
||||
Off bool
|
||||
Status ipc.ModelStatusResp
|
||||
}
|
||||
|
||||
// handleModels renders the model surface (GET) and applies a swap (POST).
|
||||
//
|
||||
// A failed swap is reported as a failure with the model that is still serving
|
||||
// named, because that is the state the operator needs: the daemon rolled back
|
||||
// and is answering turns, it just is not answering them with what he asked for.
|
||||
func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if core == nil {
|
||||
http.Error(w, "models disabled (no -core)", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
mc, ok := core.(modelController)
|
||||
if !ok {
|
||||
http.Error(w, "models unavailable: core connection does not support model swap", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
page := modelsPage{}
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
if !stepUpOK(session, requireStepUp) {
|
||||
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
path := strings.TrimSpace(r.FormValue("model_path"))
|
||||
if path == "" {
|
||||
http.Error(w, "model_path required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req := ipc.SwapModelReq{ModelPath: path}
|
||||
if v, err := strconv.Atoi(r.FormValue("n_ctx")); err == nil {
|
||||
req.NCtx = v
|
||||
}
|
||||
res, err := mc.SwapModel(ctx, req)
|
||||
switch {
|
||||
case err == nil:
|
||||
page.Msg = "loaded " + res.Model + " (" + strconv.FormatInt(res.TookMs, 10) + "ms)"
|
||||
log.Printf("models: swapped to %s (%s) in %dms", res.ModelPath, res.Model, res.TookMs)
|
||||
case errors.Is(err, ipc.ErrForbidden):
|
||||
http.Error(w, "refused: that model is not in phraser.swap_models, or step-up was not asserted", http.StatusForbidden)
|
||||
return
|
||||
case errors.Is(err, ipc.ErrUnknownMethod):
|
||||
http.Error(w, "swap not configured on this core", http.StatusServiceUnavailable)
|
||||
return
|
||||
case res.RolledBack:
|
||||
page.Err = "swap failed, rolled back to " + res.Model + " — she is still answering, with the old model"
|
||||
log.Printf("models: swap to %s failed, rolled back: %v", path, err)
|
||||
default:
|
||||
page.Err = "swap failed: " + err.Error()
|
||||
log.Printf("models: swap to %s failed: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
st, err := mc.ModelStatus(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, ipc.ErrUnknownMethod) {
|
||||
page.Off = true
|
||||
} else {
|
||||
log.Printf("models: status: %v", err)
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
page.Status = st
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := modelsTmpl.Execute(w, page); err != nil {
|
||||
log.Printf("models render: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
// fakeModelCore is a core that supports the two model methods. It records what
|
||||
// the page asked for, so the tests can assert the gate rather than the HTML.
|
||||
type fakeModelCore struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
|
||||
status ipc.ModelStatusResp
|
||||
statusErr error
|
||||
|
||||
swapResp ipc.SwapModelResp
|
||||
swapErr error
|
||||
swapped []ipc.SwapModelReq
|
||||
}
|
||||
|
||||
func (f *fakeModelCore) ModelStatus(ctx context.Context) (ipc.ModelStatusResp, error) {
|
||||
return f.status, f.statusErr
|
||||
}
|
||||
|
||||
func (f *fakeModelCore) SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error) {
|
||||
f.swapped = append(f.swapped, req)
|
||||
return f.swapResp, f.swapErr
|
||||
}
|
||||
|
||||
func modelsGET(t *testing.T, core ipc.CoreAPI) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
handleModels(w, httptest.NewRequest(http.MethodGet, "/models", nil), core, nil, false)
|
||||
return w
|
||||
}
|
||||
|
||||
func modelsPOST(t *testing.T, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path="+path))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
handleModels(w, r, core, session, requireStepUp)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestModels_GETShowsTheLoadedModelAndTheAllowlist(t *testing.T) {
|
||||
core := &fakeModelCore{status: ipc.ModelStatusResp{
|
||||
Model: "Qwen3-1.7B-UD-Q4_K_XL",
|
||||
ModelPath: "/opt/maven/models/llm/qwen3.gguf",
|
||||
BaseURL: "http://127.0.0.1:18099",
|
||||
NCtx: 4096,
|
||||
Swappable: []string{"/opt/maven/models/llm/qwen3.gguf", "/opt/maven/models/llm/qwen3-cpt.gguf"},
|
||||
}}
|
||||
w := modelsGET(t, core)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /models = %d; want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"Qwen3-1.7B-UD-Q4_K_XL", "qwen3-cpt.gguf", "4096"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("page does not mention %q", want)
|
||||
}
|
||||
}
|
||||
if len(core.swapped) != 0 {
|
||||
t.Errorf("a GET swapped the model: %v", core.swapped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModels_POSTRequiresStepUpWhenFailingClosed(t *testing.T) {
|
||||
// No WebAuthn configured (nil session) + -require-stepup ⇒ deny, exactly
|
||||
// like POST /tools. Nothing reaches core.
|
||||
core := &fakeModelCore{}
|
||||
w := modelsPOST(t, core, nil, true, "/opt/maven/models/llm/qwen3.gguf")
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("POST /models without assertable step-up = %d; want 403", w.Code)
|
||||
}
|
||||
if len(core.swapped) != 0 {
|
||||
t.Fatalf("a denied POST still called SwapModel: %v", core.swapped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModels_POSTSwapsAndReportsTheModelThatAnswered(t *testing.T) {
|
||||
core := &fakeModelCore{
|
||||
swapResp: ipc.SwapModelResp{Model: "qwen3-cpt", ModelPath: "/m/cpt.gguf", TookMs: 4200},
|
||||
status: ipc.ModelStatusResp{Model: "qwen3-cpt", ModelPath: "/m/cpt.gguf"},
|
||||
}
|
||||
w := modelsPOST(t, core, nil, false, "/m/cpt.gguf")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("POST /models = %d; want 200", w.Code)
|
||||
}
|
||||
if len(core.swapped) != 1 || core.swapped[0].ModelPath != "/m/cpt.gguf" {
|
||||
t.Fatalf("SwapModel calls = %v; want one for /m/cpt.gguf", core.swapped)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "loaded qwen3-cpt") {
|
||||
t.Errorf("page does not report which model was loaded:\n%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestModels_RolledBackSwapSaysSheIsStillAnswering(t *testing.T) {
|
||||
core := &fakeModelCore{
|
||||
swapResp: ipc.SwapModelResp{Model: "qwen3", ModelPath: "/m/old.gguf", RolledBack: true},
|
||||
swapErr: errBrokenModel{},
|
||||
status: ipc.ModelStatusResp{Model: "qwen3", ModelPath: "/m/old.gguf"},
|
||||
}
|
||||
w := modelsPOST(t, core, nil, false, "/m/cpt.gguf")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("POST /models after a rollback = %d; want 200 with the failure rendered", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "rolled back to qwen3") {
|
||||
t.Errorf("page does not say it rolled back:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModels_RefusedPathIs403(t *testing.T) {
|
||||
core := &fakeModelCore{swapErr: ipc.ErrForbidden}
|
||||
w := modelsPOST(t, core, nil, false, "/etc/passwd")
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("POST /models with a non-allowlisted path = %d; want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModels_UnconfiguredCoreRendersOff(t *testing.T) {
|
||||
core := &fakeModelCore{statusErr: ipc.ErrUnknownMethod}
|
||||
w := modelsGET(t, core)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /models against a core without the swap = %d; want 200", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "swap not configured") {
|
||||
t.Errorf("page does not say the capability is off:\n%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestModels_CoreWithoutTheMethodsIs503(t *testing.T) {
|
||||
// An in-process CoreAPI (no swap methods) must not 500 the page.
|
||||
w := modelsGET(t, ipc.UnimplementedCoreAPI{})
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("GET /models on a core without the methods = %d; want 503", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
type errBrokenModel struct{}
|
||||
|
||||
func (errBrokenModel) Error() string { return "llm: server did not start" }
|
||||
@@ -114,3 +114,49 @@ build on the target host, most likely in one of these:
|
||||
work fine over the core socket.
|
||||
- **netdata** — `mavpoll` reaches it via `host.docker.internal`; adjust if
|
||||
netdata runs elsewhere.
|
||||
|
||||
## Updating her (`mavupdate`, Vikunja #249)
|
||||
|
||||
Off unless configured, and there is deliberately no button for it. There is no
|
||||
IPC method, no web route, no timer and no act that starts an update — the trigger
|
||||
is a human running `mavupdate` on the host, which needs shell access, a strictly
|
||||
higher bar than the step-up passkey gate that guards `/tools`. She cannot update
|
||||
herself; she can be updated. Nothing here ever fetches code: the new version is
|
||||
whatever you pulled into the working tree yourself.
|
||||
|
||||
Add an `update` block to `mavend.json` (mavend ignores it — only the CLI reads
|
||||
it), with paths as they exist **on the host**, not inside a container:
|
||||
|
||||
```json
|
||||
"update": {
|
||||
"source_dir": "/home/kami/apps/Maven",
|
||||
"install_dir": "/home/kami/apps/Maven",
|
||||
"snapshot_dir": "/var/lib/maven-snapshots",
|
||||
"binaries": ["mavend", "mavweb", "mavsttd", "mavttsd", "mavwaked",
|
||||
"mavenclient", "mavpoll", "mavcaldav", "mavmaild"],
|
||||
"config_files": ["deploy/mavend.json"],
|
||||
"restart_cmd": ["docker", "compose", "up", "-d", "--build"],
|
||||
"health_socket": "/var/lib/docker/volumes/maven_sockets/_data/mavend.sock",
|
||||
"health_timeout_sec": 120
|
||||
}
|
||||
```
|
||||
|
||||
`snapshot_dir` must be outside `install_dir` (a restore must not read from what
|
||||
the install writes) and `health_socket` is required: an update that cannot check
|
||||
its own result cannot roll itself back, so the config is refused without one.
|
||||
|
||||
Then:
|
||||
|
||||
```sh
|
||||
mavupdate -config deploy/mavend.json verify # make build + make test, deploys nothing
|
||||
mavupdate -config deploy/mavend.json apply -yes # snapshot, verify, install, restart, health-check
|
||||
mavupdate -config deploy/mavend.json list # what you can roll back to
|
||||
mavupdate -config deploy/mavend.json rollback -yes # restore the previous artifacts and restart
|
||||
```
|
||||
|
||||
`apply` refuses to start if she is not already answering — otherwise a failed
|
||||
update and a box that was already broken are indistinguishable afterwards. On any
|
||||
failure after the install it restores the snapshot, restarts, and checks again;
|
||||
if that also fails it says so loudly and names the directory to copy back by hand.
|
||||
The database is never snapshotted or rolled back (see the package comment in
|
||||
`internal/update`); schema compatibility is `store.Migrate`'s job.
|
||||
|
||||
@@ -31,6 +31,20 @@
|
||||
"cooldown": "24h"
|
||||
},
|
||||
|
||||
"mcp": {
|
||||
"timeout": "15s",
|
||||
"servers": [
|
||||
{
|
||||
"name": "vikunja",
|
||||
"url": "http://192.168.1.104:9100/mcp",
|
||||
"allow_private": true,
|
||||
"allow_tools": ["list_projects", "list_tasks", "get_task_details", "create_task"],
|
||||
"max_tools": 6,
|
||||
"enabled": false
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"nexus": { "url": "http://nexus:9740" },
|
||||
"praxis": { "url": "http://praxis:8989" },
|
||||
"hexis": { "url": "http://hexis:9741" },
|
||||
|
||||
+101
-22
@@ -1,27 +1,106 @@
|
||||
# Plan: Vision — Image Understanding Capability
|
||||
|
||||
**Goal:** Maven can "see" — accept images (from mavweb upload, Telegram, or filesystem paths), run vision inference via a local or remote multimodal model, and answer questions about the image content or extract structured information.
|
||||
**Goal:** Maven can "see" — accept images (from mavweb upload, Telegram, or filesystem paths), store them, run inference via a **local** multimodal model, and answer questions about the image content or extract text from it.
|
||||
|
||||
**Done when:**
|
||||
- Vision model backend is configurable: local multimodal LLM (e.g., LLaVA, Qwen-VL via `llama-server` mmproj) or remote API
|
||||
- `internal/vision/` package handles image preprocessing, model inference, result parsing
|
||||
- Voice/text commands like "что на картинке?" or "прочитай текст с экрана" route to the vision handler
|
||||
- Extracted information can be written as facts/notes through `ipc.CoreAPI`
|
||||
- Telegram image messages are processed through the same pipeline
|
||||
**Status (2026-08-01):** intake, storage, config seam and the provider are shipped. The
|
||||
describing half is **BLOCKED on a model download** — see "What is blocked" below.
|
||||
|
||||
**Scope:**
|
||||
- New `internal/vision/` package — image loader (Go stdlib `image` + `golang.org/x/image`), inference client
|
||||
- New config block: `voice.vision` in `config.Config` — `{enabled, provider, model_path, mmproj_path, remote_url}`
|
||||
- Router intent extension: new `IntentVision` or reuse `IntentQuery` with a vision flag
|
||||
- Reuses `internal/llm.Client` for API-compatible backends (OpenAI-compatible vision API)
|
||||
- Reuses `internal/ipc.CoreAPI` for writing extracted data
|
||||
## What shipped
|
||||
|
||||
**Steps:**
|
||||
1. Create `internal/vision/provider.go` — `Provider` interface with `Describe(image []byte, prompt string) (string, error)` and `ExtractText(image []byte) (string, error)`
|
||||
2. Implement `LocalProvider` — spawns `llama-server` with mmproj, sends multimodal chat completion requests
|
||||
3. Implement `RemoteProvider` — calls an OpenAI-compatible vision API endpoint, reuses `internal/llm.Client`
|
||||
4. Create `internal/vision/processor.go` — image preprocessing (resize, format conversion to JPEG/PNG, base64 encoding)
|
||||
5. Wire vision into `cmd/mavend/voice.go:reactiveHandler` — detect vision intent from router (new `IntentVision` or a `Slots.HasImage` flag)
|
||||
6. Add IPC method `MethodDescribeImage` for programmatic access (mavweb upload, telegram bot)
|
||||
7. Add vision config block to `config.Config` and wire in `cmd/mavend/main.go`
|
||||
8. Test with a local multimodal model: send an image via mavweb, verify description and text extraction
|
||||
| Piece | Where |
|
||||
|---|---|
|
||||
| Blob store (content-addressed, retention-pruned) | `internal/media/store.go` |
|
||||
| Image decode / flatten / downscale / JPEG | `internal/media/image.go` |
|
||||
| `Provider` seam + `Disabled` floor + `LocalProvider` | `internal/vision/vision.go` |
|
||||
| Store-then-describe orchestration, re-runnable | `internal/vision/intake.go` |
|
||||
| Config blocks `media` and `vision` | `internal/config/config.go` |
|
||||
| IPC method `describe_image` (`AuthRead`) | `internal/ipc/{wire,api,client,server}.go`, `internal/auth/policy.go` |
|
||||
| Daemon wiring + hourly retention prune | `cmd/mavend/vision.go` |
|
||||
|
||||
`internal/media` is deliberately shared: hearing (#253) and speaker recognition (#255) have
|
||||
the same intake problem — a blob arrives, gets stored, gets described — and they store their
|
||||
audio in the same place under the same retention.
|
||||
|
||||
## Design decisions worth knowing
|
||||
|
||||
**Store before describe.** `Intake.Accept` writes the blob to disk *first*, then asks the
|
||||
model. If the model is missing or broken — which is this box's actual state — the answer is
|
||||
"it's kept, I can't read it yet" with a content-addressed id, and `Intake.Rerun(id, question)`
|
||||
describes it later. Nothing is lost to a missing model.
|
||||
|
||||
**No `RemoteProvider`.** The original step 3 called for "an OpenAI-compatible vision API
|
||||
endpoint". Refused. The surviving hard constraint in CLAUDE.md after "never phones home" was
|
||||
deprecated is *no cloud model, inference stays on the box*, and a photo of his flat is the
|
||||
worst possible exception. `vision.NewLocal` therefore validates the endpoint at construction:
|
||||
loopback, a private IP, or `localhost`. A hostname is refused too — it could resolve anywhere,
|
||||
and resolving it would mean trusting DNS with his pictures.
|
||||
|
||||
**Blobs are not in the database.** The sqlite store is small, encrypted and read every tick;
|
||||
a 40 MB blob has no business there. What lands in the database is the *text* the blob produced,
|
||||
as an ordinary note (`source: media:image:<id-prefix>`), and only when the caller asks for it
|
||||
(`save_note`). Glancing at a screenshot is not the same act as remembering it.
|
||||
|
||||
**Images are never search input and never embedded.** Only the derived description
|
||||
participates in recall, and only after he can see it as a note.
|
||||
|
||||
**Retention is enforced by a loop, not by a promise.** `media.retention` defaults to 7 days
|
||||
and `cmd/mavend` prunes hourly, starting at boot. A store that grows forever would be the real
|
||||
failure mode of this capability.
|
||||
|
||||
**No webp.** The stdlib has no webp decoder and this repo takes no new dependencies (the box
|
||||
is offline). `media.SniffImage` recognises webp well enough to refuse it *by name*, so the log
|
||||
says "webp is not supported" instead of "not an image". Telegram sends webp for stickers; that
|
||||
is a known gap, not a mystery.
|
||||
|
||||
**Text extraction is not a second method.** "прочитай текст с картинки" is a prompt. A VLM has
|
||||
no separate OCR mode to select, and a second interface method would only duplicate the first.
|
||||
|
||||
## What is blocked, and on what
|
||||
|
||||
There is **no vision-capable gguf and no mmproj file on this box**. Checked 2026-08-01:
|
||||
|
||||
```
|
||||
/mnt/hdd1/llms/{Bonsai,LFM2.5,llama3.2,ministral,nemotron3-nano,qwen3,qwen3.5}
|
||||
```
|
||||
|
||||
— sixteen ggufs, all text-only, no `*mmproj*` anywhere. The resident Qwen3-1.7B is text-only
|
||||
by construction, so vision needs a *second* model. The ≤1.7B ceiling in CLAUDE.md is about the
|
||||
resident router/phraser, not about a second model loaded on demand — but iGPU VRAM still is,
|
||||
so keep it small.
|
||||
|
||||
To unblock, download one pair to `/mnt/hdd1/llms/vision/` (bind-mounted to
|
||||
`/opt/maven/models/llm`), a gguf **and** its mmproj:
|
||||
|
||||
- `Qwen2.5-VL-3B-Instruct` (Q4_K_M + `mmproj-F16.gguf`) — the safe default; reads Russian, and
|
||||
its OCR is the best of this size class.
|
||||
- `SmolVLM2-2.2B-Instruct` — smaller and faster, weaker at Cyrillic text in images.
|
||||
- `moondream2` — smallest, English-only in practice. Do not bother, per the sub-500M lesson.
|
||||
|
||||
Then run a second llama-server on 8081 with `--mmproj`, point `vision.endpoint` at it, and
|
||||
walk the QA steps on Vikunja #252.
|
||||
|
||||
## Config
|
||||
|
||||
```json
|
||||
"media": { "dir": "media", "retention": "168h", "max_bytes": 67108864 },
|
||||
"vision": {
|
||||
"enabled": true,
|
||||
"endpoint": "http://127.0.0.1:8081",
|
||||
"model": "qwen2.5-vl-3b",
|
||||
"max_dim": 896,
|
||||
"max_tokens": 300,
|
||||
"timeout": "90s"
|
||||
}
|
||||
```
|
||||
|
||||
Both absent by default. No `media` block ⇒ `describe_image` does not exist at all; a `media`
|
||||
block with no `vision` block ⇒ images are stored and honestly not described.
|
||||
|
||||
## Still open
|
||||
|
||||
- **Router intent.** "что на картинке?" does not route anywhere yet. Adding an intent is
|
||||
premature while nothing can answer it; the IPC method is the surface a Telegram photo or a
|
||||
mavweb upload calls today.
|
||||
- **Telegram photo path** in `mavpoll` (download the file, call `DescribeImage`).
|
||||
- **mavweb upload page** and a `/media` listing so stored blobs are visible and deletable from
|
||||
the authed surface.
|
||||
|
||||
+117
-24
@@ -1,28 +1,121 @@
|
||||
# Plan: Hearing — Audio Stream Monitoring & Meeting Summarization
|
||||
# Plan: Hearing — Meeting Capture & Summarisation
|
||||
|
||||
**Goal:** Maven can "hear" ambient audio from workpc — microphone input during meetings, system audio — and on demand (or on trigger) produce transcripts, summaries, or extract action items. A typical use case: "Maven, запиши встречу" starts capture, "хватит" stops it, and Maven writes a summary note.
|
||||
**Goal:** "Maven, запиши встречу" starts a recording, "хватит" stops it, and she writes a
|
||||
summary note. The audio stays on the box, is pruned by retention, and nothing is recorded that
|
||||
nobody asked for.
|
||||
|
||||
**Done when:**
|
||||
- `internal/audio/capture.go` — remote microphone capture client (receives PCM stream from workpc over WebSocket or the existing voice TCP protocol)
|
||||
- `internal/stt/` — streaming transcription (uses existing `stt.Transcriber` interface, extended with streaming support)
|
||||
- Meeting capture triggered by voice command (IntentCapture) or configurable keyword ("maven record")
|
||||
- Raw audio is either streamed to STT in real-time or saved to a WAV file and transcribed after capture ends
|
||||
- Transcription + LLM summary is written as a note (`source:capture:meeting`) through `ipc.CoreAPI`
|
||||
- New `mavheary` module (`cmd/mavheard/`) — the workpc-side agent that captures mic/speaker audio and streams it to mavend
|
||||
**Status (2026-08-01):** the recorder, the storage, the chunked transcription, the map-reduce
|
||||
summariser, the config seam and the four IPC methods are shipped and tested. What is not
|
||||
shipped is the workpc-side microphone agent and the router intent — see "Still open".
|
||||
|
||||
**Scope:**
|
||||
- New `cmd/mavheard/` — workpc-side agent: captures microphone (PortAudio or ALSA `arecord`), streams over WebSocket to mavend
|
||||
- `internal/audio/` extended with capture types: `MicCapture`, `SystemCapture`, `FileCapture`
|
||||
- `internal/stt/stt.go` extended with `StreamingTranscriber` interface (or reuse existing with chunked input)
|
||||
- Router: new `IntentCapture` intent for start/stop commands
|
||||
- Reuses `internal/llm.Client` for summarization
|
||||
- Reuses `internal/voice/server.go` TCP protocol for streaming audio
|
||||
## What shipped
|
||||
|
||||
**Steps:**
|
||||
1. Create `cmd/mavheard/main.go` — workpc-side daemon: captures microphone via `arecord` pipe or PortAudio, opens WebSocket or TCP connection to mavend, streams PCM frames
|
||||
2. Create `internal/audio/capture.go` — `Capture` interface: `Start()`, `Stop()`, `AudioCh <-chan Audio`; implement `MicCapture` (reads from `mavheard` stream) and `FileCapture` (reads WAV)
|
||||
3. Extend `internal/stt/stt.go` — add `TranscribeStream(ctx, audio <-chan Audio) (string, error)` to `Transcriber` interface; `Stub` returns empty; `Remote` forwards chunks to worker socket
|
||||
4. Add `IntentCapture` to `internal/router/intent.go` — slots: `Action` ("start"/"stop"/"status"), `Duration`
|
||||
5. Wire capture handler in `cmd/mavend/voice.go:reactiveHandler` — start = spawn goroutine receiving audio, stream to STT; stop = finalize, send to LLM for summarization, write note via `WriteNote`
|
||||
6. Add capture config to `voice` block in `config.Config` — `{capture_enabled, capture_timeout}`
|
||||
7. Test with a recorded WAV file — simulate a meeting, verify transcription + summary note is created
|
||||
| Piece | Where |
|
||||
|---|---|
|
||||
| Session state machine: start / append / stop / abort / status | `internal/capture/capture.go` |
|
||||
| Map-reduce summarisation against `n_ctx` 4096 | `internal/capture/summarize.go` |
|
||||
| Audio blobs in the shared store, pruned by `media.retention` | `internal/media` (from #252) |
|
||||
| Config block `capture`, off by default | `internal/config/config.go` |
|
||||
| IPC `capture_start` / `capture_append` / `capture_stop` / `capture_status` | `internal/ipc/{wire,api,client,server}.go` |
|
||||
| Authority: the three write methods `AuthWrite`, status `AuthRead` | `internal/auth/policy.go` |
|
||||
| Daemon wiring, note write, STT reuse | `cmd/mavend/capture.go` |
|
||||
|
||||
The audio lands in the same content-addressed blob store as images, under the same retention
|
||||
loop, because #252 and #253 have the same intake problem and solving it twice would mean two
|
||||
directories to remember to prune.
|
||||
|
||||
## The refusals, and why
|
||||
|
||||
**Nothing listens.** The original step 8 called for capture "triggered by voice command
|
||||
(IntentCapture) **or configurable keyword ('maven record')**". The keyword half is refused.
|
||||
Noticing a keyword requires listening to the room continuously, which is precisely the
|
||||
behaviour this capability must not have, and the refusal is in the code rather than in a
|
||||
comment: `Recorder.Append` is the only way audio enters, and it returns `ErrNoSession` unless
|
||||
someone explicitly started a session. Audio arriving at an idle core is dropped, not buffered
|
||||
"just in case".
|
||||
|
||||
**Off unless configured, twice over.** No `media` block ⇒ nowhere to keep audio ⇒ the four
|
||||
methods do not exist. No `capture` block with `enabled: true` ⇒ they still do not exist. On an
|
||||
unconfigured box there is no wire path at all that begins a recording. That is the only
|
||||
guarantee worth making here, and it is the reason the hooks use the nil-hook ⇒
|
||||
`ErrUnknownMethod` pattern rather than an in-handler check.
|
||||
|
||||
**A forgotten session ends itself.** `max_minutes` defaults to 120 and is checked on every
|
||||
append, not on a timer that could be missed. Past the cap `Append` returns `ErrExpired`
|
||||
permanently, so a client that ignores the error cannot grow the recording; the audio collected
|
||||
before the cap is kept and `Stop` still works.
|
||||
|
||||
**"Забудь, не записывай" leaves nothing behind.** `capture_stop` with `discard: true` throws
|
||||
the session away without storing, transcribing or summarising anything — not a blob with a note
|
||||
saying it was abandoned. Nothing.
|
||||
|
||||
**The transcript is not saved by default.** The summary is written where he will read it; the
|
||||
verbatim record of what other people said in a room is a heavier thing to keep and takes a
|
||||
deliberate `save_transcript: true`. The audio blob is pruned by `media.retention` either way.
|
||||
|
||||
**No second STT.** Step 3 of the original plan extended the `Transcriber` interface with
|
||||
streaming. Not needed and not done: whisper.cpp already runs as `mavsttd`, and `internal/capture`
|
||||
takes the ordinary `stt.Transcriber` the voice path already holds (exposed as
|
||||
`voiceWiring.transcriber`). Long recordings are handed over in five-minute windows —
|
||||
`chunkAudio`, cut on sample boundaries — for the same reason whisper itself works in 30-second
|
||||
windows: an hour of PCM in one call either times out or blocks the voice path for minutes.
|
||||
Capture with voice off is refused rather than degraded, because storing hours of unreadable
|
||||
audio of other people is worse than not recording.
|
||||
|
||||
**Not `AuthStepUp`.** Recording people is invasive enough to argue for the top rung, and it is
|
||||
still wrong: step-up needs a passkey gesture, which the voice path cannot make, so
|
||||
"запиши встречу" could never work by voice — the only way he will actually use this. `AuthWrite`
|
||||
plus the off-unless-configured gate is the honest combination.
|
||||
|
||||
## Long audio against a 4096-token context
|
||||
|
||||
The resident model is a Thinking variant at `n_ctx` 4096, so an hour of transcript does not fit
|
||||
in one prompt and never will. `summarize.go` does map-reduce and nothing cleverer: split the
|
||||
transcript on sentence boundaries into 3000-rune windows (about 1100 Qwen tokens of Russian,
|
||||
leaving room for the persona block, the reasoning and the answer), summarise each, then
|
||||
summarise the summaries. A transcript that fits in one window skips the reduce step.
|
||||
|
||||
Truncation was the alternative and is rejected: a truncated meeting summary reads as complete
|
||||
and is not, and he would act on it. Past `max_chunks` (40, roughly the two-hour cap) the
|
||||
transcript *is* cut, and the summary says so in the note.
|
||||
|
||||
Two degradations are deliberate and both are reported rather than hidden:
|
||||
|
||||
- No llama-server ⇒ transcript, no summary. The words exist.
|
||||
- The reduce call fails ⇒ the per-chunk summaries are returned joined. Real work, not thrown
|
||||
away over the last call.
|
||||
|
||||
The map and reduce prompts contain no first person at all, so the persona's feminine-form rules
|
||||
have nothing to get wrong in them; the reply she actually gives him is phrased by the ordinary
|
||||
replier, which does carry the persona.
|
||||
|
||||
## Config
|
||||
|
||||
```json
|
||||
"media": { "dir": "media", "retention": "168h" },
|
||||
"capture": {
|
||||
"enabled": true,
|
||||
"max_minutes": 120,
|
||||
"stt_window": "5m",
|
||||
"chunk_runes": 3000,
|
||||
"max_chunks": 40,
|
||||
"save_transcript": false
|
||||
}
|
||||
```
|
||||
|
||||
Both absent by default. `capture` alone does nothing without `media`.
|
||||
|
||||
## Still open
|
||||
|
||||
- **`cmd/mavheard`** — the workpc-side microphone agent. Deferred, not refused: the core half
|
||||
is the part with the invariants in it, and a mic client is straightforward once there is a
|
||||
stable wire to stream at. It should be an explicit-start process, not a resident one, for the
|
||||
same reason the recorder has no keyword trigger. The four IPC methods are the wire it will
|
||||
use; `mavenclient` already has the mic plumbing to borrow.
|
||||
- **Router intent.** "запиши встречу" / "хватит" does not route anywhere yet. It needs the
|
||||
`system` intent plus slots, and it needs care: "хватит" is also how someone tells her to stop
|
||||
talking, so the recorder's stop and the speech barge-in must not collide.
|
||||
- **A `/dash` panel** showing a running session, so a recording is visible on a surface and not
|
||||
only in a log line.
|
||||
- **Speaker attribution** — who said what — is #255 and is blocked on a model; see
|
||||
`docs/plans/10-speaker-recognition.md`.
|
||||
|
||||
@@ -1,27 +1,125 @@
|
||||
# Plan: Speaker Recognition
|
||||
|
||||
**Goal:** Maven can distinguish between different speakers on the voice channel — recognize known voices (the user, family members) and tag facts/notes/transcripts with a speaker identity.
|
||||
**Goal:** Maven can tell who is speaking on the voice channel, and tag what she writes with
|
||||
who said it.
|
||||
|
||||
**Done when:**
|
||||
- Speaker embedding extractor (e.g., ECAPA-TDNN or a simple MFCC + GMM) runs on incoming voice PCM before STT
|
||||
- Embedding is compared against enrolled speaker profiles (stored as vectors in the `memory_vectors` table alongside semantic memory)
|
||||
- Unknown speakers are enrolled on first interaction (prompt: "кто это?")
|
||||
- All voice fact/note writes are tagged with `speaker:<id>` in the value/source metadata
|
||||
- Speaker identity is available as context to the router, phraser, and replier ("ok, <name>")
|
||||
**Status (2026-08-01, Vikunja #255):** the enrolment half is shipped. The recognising half is
|
||||
**BLOCKED on a model download** — there is no speaker-embedding model on this box, and one
|
||||
was not invented to fill the gap. See "Blocked, and on what" below.
|
||||
|
||||
**Scope:**
|
||||
- New `internal/speaker/` package — enrollment, recognition, embedding extraction
|
||||
- Reuses `internal/store.MemoryStore` for speaker vector storage (same `memory_vectors` table, different `source` prefix)
|
||||
- Reuses `internal/audio` for PCM preprocessing
|
||||
- Integration point: `cmd/mavend/voice.go:HandlePushToTalk` — speaker ID extracted before STT, passed through context
|
||||
## What shipped
|
||||
|
||||
**Steps:**
|
||||
1. Research speaker embedding approaches — simplest floor: MFCC + cosine similarity via `github.com/mjibson/go-dsp` or a pre-trained ONNX model (SpeechBrain ECAPA)
|
||||
2. Create `internal/speaker/recognizer.go` — `Recognizer` interface: `Identify(pcm []float32) (SpeakerID, confidence)`, `Enroll(id, pcm)`
|
||||
3. Create `internal/speaker/store.go` — speaker profile CRUD via `store.MemoryStore`: `Insert("speaker:<id>", embedding, meta)`, `Search(embedding, k)`
|
||||
4. Create `internal/speaker/enroll.go` — enrollment flow: capture N seconds of audio, extract embedding, prompt for name via TTS + STT round-trip
|
||||
5. Wire into `cmd/mavend/voice.go:HandlePushToTalk` — run speaker ID on the PCM before STT; pass speaker ID through `context.Context` to `applyAction`
|
||||
6. Tag all voice-written facts/notes with speaker ID — `Source` becomes `tap:voice:speaker:<id>` or metadata field
|
||||
7. Add IPC methods `MethodEnrollSpeaker`, `MethodListSpeakers`, `MethodRemoveSpeaker`
|
||||
8. Add speaker config block to `voice` in `config.Config` — `{speaker_recognition: true, model_path}`
|
||||
9. Test with 2+ recorded voice samples — verify correct identification and rejection of unknown speakers
|
||||
| Piece | Where | State |
|
||||
|---|---|---|
|
||||
| `Recognizer` — identify, list, get, forget | `internal/speaker/recognizer.go` | done; `Identify` answers `ErrDisabled` until a model exists |
|
||||
| Enrolment — several samples, averaged, re-normalised | `internal/speaker/enroll.go` | done |
|
||||
| Profile shape, id validation, cosine similarity | `internal/speaker/speaker.go` | done |
|
||||
| Profile storage as `speaker:<id>` vectors | `internal/memory` `Catalog` + `internal/store/memory.go` | done, no schema migration |
|
||||
| Config block, off by default | `internal/config` `SpeakerConfig` | done |
|
||||
| `enroll_speaker` / `list_speakers` / `forget_speaker` | `internal/ipc` | done, absent unless configured |
|
||||
| Authority rows | `internal/auth/policy.go` | done — enrol step-up, forget write, list read |
|
||||
| Daemon wiring + honest startup log | `cmd/mavend/speaker.go` | done |
|
||||
| Embedding backend | `newSpeakerEmbedder` | **BLOCKED** — returns nil, seam only |
|
||||
| Tagging voice writes with the speaker | `cmd/mavend/voice.go` | not wired; nothing to tag with yet |
|
||||
|
||||
## Blocked, and on what
|
||||
|
||||
A voiceprint needs a speaker-embedding model. The box was searched: `/mnt/hdd1/llms` holds
|
||||
sixteen ggufs across seven families and every one of them is a text model. There is no ECAPA,
|
||||
no x-vector, no titanet, no wespeaker, and no `.onnx` under `/mnt/hdd1` at all. There are also
|
||||
no enrolment samples, because nothing has ever recorded any.
|
||||
|
||||
To unblock, two things are needed and neither can be done from inside the repo:
|
||||
|
||||
1. **A model.** SpeechBrain ECAPA-TDNN exported to ONNX (`speechbrain/spkrec-ecapa-voxceleb`,
|
||||
192-dim) is the usual choice and runs on CPU in well under a second for a few seconds of
|
||||
audio. Download it per the recipe in `AGENTS.md`, put it beside the other models so the
|
||||
bind mount picks it up, and point `speaker.model_path` at it.
|
||||
2. **An implementation of one function.** `newSpeakerEmbedder` in `cmd/mavend/speaker.go` is
|
||||
the entire seam: give it an ONNX session that turns `audio.Audio` into a `[]float32` and
|
||||
`Identify` starts working. Nothing else changes — not the store, not the protocol, not the
|
||||
authority table, not the handlers. `internal/onnx` already loads the e5 embedder, so the
|
||||
runtime wiring exists to copy.
|
||||
3. **Enrolment samples**, three or more per person, recorded deliberately.
|
||||
|
||||
### Why there is no fallback
|
||||
|
||||
The original plan offered "a simple MFCC + GMM" as the floor. That is refused. MFCC cosine
|
||||
distance is a channel and loudness detector as much as a voice detector: it will happily match
|
||||
two different people who sit at the same distance from the same microphone, and it drifts when
|
||||
the room changes. A general classifier that is sometimes wrong is a nuisance; a **biometric**
|
||||
that is confidently wrong writes false claims about named people into his memory, and then
|
||||
those claims get recalled as fact. For this capability a bad floor is worse than none, so the
|
||||
shipped state is honest absence: `speaker.Disabled`, `ErrDisabled`, and a startup line saying
|
||||
so.
|
||||
|
||||
## The refusals, and why
|
||||
|
||||
- **Unknown speakers are NOT enrolled on first interaction.** The plan's fourth "done when"
|
||||
bullet asked for exactly that, with a TTS "кто это?" prompt. It is refused in
|
||||
`enroll.go`'s doc comment and there is no request shape in the protocol that could express
|
||||
it. Enrolling a voice is taking a biometric of a person; doing it automatically to whoever
|
||||
walks past the microphone does it to guests who are not party to the exchange, and a
|
||||
synthesised question into a room is not consent from whoever happens to answer. Enrolment is
|
||||
an explicit act: an id, a name, and samples recorded for the purpose.
|
||||
- **One sample is not enough.** Three separate utterances and nine seconds minimum. A profile
|
||||
built from one sentence encodes that sentence as much as the person, and the threshold then
|
||||
behaves unpredictably against everything else.
|
||||
- **An unknown voice stays unknown.** Below threshold, `Identify` returns `ErrUnknown` naming
|
||||
the closest profile in the error text for diagnosis, never as an answer. Guessing who is in
|
||||
the room is how false memories about people get written.
|
||||
- **Deletion is one authority rung below enrolment.** Everywhere else in `policy.go` the
|
||||
destructive direction is gated at least as hard as the constructive one. Here that would be
|
||||
backwards: getting rid of a biometric must never be the harder half.
|
||||
- **The voiceprint never crosses the socket.** `ListSpeakersResp` carries ids, names, dates
|
||||
and sample counts. The vector stays in core.
|
||||
- **Off unless configured.** No `speaker` block ⇒ the three methods answer
|
||||
`ErrUnknownMethod`. There is no wire path on a default box that takes a voiceprint.
|
||||
|
||||
## Storage
|
||||
|
||||
Profiles live in the existing `memory_vectors` table under the `speaker:` id prefix, as the
|
||||
plan intended, so there is no migration. What that needed was a wider interface than
|
||||
`memory.Store`: `memory.Catalog` adds `ByPrefix` and `Delete`. `Delete` is the load-bearing
|
||||
one — a voiceprint someone asked to be rid of has to actually go, and a search-only store
|
||||
cannot do that. `InMemoryStore.Insert` also became an upsert by id, matching what the
|
||||
persistent store already did, so re-enrolling replaces a profile instead of stacking a second
|
||||
one behind the first.
|
||||
|
||||
Profiles do not collide with note or fact vectors: they are only ever read through
|
||||
`ByPrefix("speaker:")`, and a note search never returns one because the prefix is not in its
|
||||
query path.
|
||||
|
||||
## Config
|
||||
|
||||
```json
|
||||
"speaker": {
|
||||
"enabled": true,
|
||||
"model_path": "/opt/maven/models/spk/ecapa-voxceleb.onnx",
|
||||
"lib_path": "/opt/maven/lib",
|
||||
"threshold": 0.7,
|
||||
"min_seconds": 2.0
|
||||
}
|
||||
```
|
||||
|
||||
`Recognizes()` requires both `enabled` and a `model_path`, so a half-filled block reads as off
|
||||
rather than as a capability that fails every turn. With `enabled` and no model the daemon still
|
||||
attaches the three methods — profiles can be created, listed and deleted — and logs that
|
||||
recognition is blocked.
|
||||
|
||||
## Still open
|
||||
|
||||
- The embedding backend (above). Everything below waits on it.
|
||||
- **Tagging voice writes.** `Profile.Source("tap:voice")` already produces
|
||||
`tap:voice:speaker:kami`, which is the shape step 6 asked for, but nothing calls it yet:
|
||||
with no recogniser there is no id to tag with. When the model lands, the hook is in the
|
||||
voice path before STT.
|
||||
- **Speaker as router/phraser context.** Same dependency. Note the persona constraint when it
|
||||
arrives: Maven addresses the owner informally and speaks to him, so "ok, <name>" needs care
|
||||
for anyone who is not him.
|
||||
- **An enrolment surface.** The three IPC methods exist; no page drives them. Enrolment is
|
||||
step-up, so it belongs on `/dash` behind a passkey, with a per-profile forget button next to
|
||||
each row — that button is the reason `list_speakers` exists.
|
||||
- **A speaker column on the meeting recorder** (#253). Attributing lines in a transcript is
|
||||
the obvious pairing, and it is the place where getting attribution wrong is most damaging,
|
||||
so it waits for a real model too.
|
||||
|
||||
@@ -393,3 +393,82 @@ func mustWriteFactParams(source string) []byte {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// TestRequirement_SwapModel — loading a different resident model is an owner
|
||||
// action at the same rung as mutating the tool allowlist: it decides how every
|
||||
// utterance is routed and how every reply is worded. The read side is not.
|
||||
func TestRequirement_SwapModel(t *testing.T) {
|
||||
if got := Requirement(ipc.MethodSwapModel); got != AuthStepUp {
|
||||
t.Errorf("SwapModel authority = %v; want AuthStepUp", got)
|
||||
}
|
||||
if got := Requirement(ipc.MethodModelStatus); got != AuthRead {
|
||||
t.Errorf("ModelStatus authority = %v; want AuthRead", got)
|
||||
}
|
||||
// A surface that cannot carry a passkey gesture cannot swap the model, no
|
||||
// matter what it is enrolled as — this is the "never through voice" property.
|
||||
voice := Scope{Surface: SurfaceVoice, Module: "voice", SourceScope: []string{"*"}}
|
||||
if err := Can(ipc.MethodSwapModel, voice, nil); !errors.Is(err, ErrForbidden) {
|
||||
t.Errorf("voice swapping the model = %v; want ErrForbidden", err)
|
||||
}
|
||||
// And with no step-up session asserted, the gate refuses even a capable surface.
|
||||
noSession := &Gate{Enrollment: NewFloorEnrollment()}
|
||||
if err := noSession.Check(context.Background(), ipc.MethodSwapModel, nil); !errors.Is(err, ipc.ErrForbidden) {
|
||||
t.Errorf("SwapModel with no asserted step-up = %v; want ErrForbidden", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequirement_Capture — recording other people is a write, not a read: it
|
||||
// puts audio of them on disk. The read side, "что ты записываешь?", is not.
|
||||
//
|
||||
// It is deliberately NOT AuthStepUp. Step-up needs a passkey gesture, which the
|
||||
// voice path cannot make, so putting it there would mean "запиши встречу" could
|
||||
// never work by voice. The real gate on this capability is that the methods do
|
||||
// not exist at all unless the operator enabled a capture block.
|
||||
func TestRequirement_Capture(t *testing.T) {
|
||||
for _, m := range []ipc.Method{
|
||||
ipc.MethodCaptureStart, ipc.MethodCaptureAppend, ipc.MethodCaptureStop,
|
||||
} {
|
||||
if got := Requirement(m); got != AuthWrite {
|
||||
t.Errorf("%s authority = %v; want AuthWrite", m, got)
|
||||
}
|
||||
}
|
||||
if got := Requirement(ipc.MethodCaptureStatus); got != AuthRead {
|
||||
t.Errorf("CaptureStatus authority = %v; want AuthRead", got)
|
||||
}
|
||||
// Voice can start one: it is the surface he will actually use to say
|
||||
// "запиши встречу", and it carries AuthWrite.
|
||||
voice := Scope{Surface: SurfaceVoice, Module: "voice", SourceScope: []string{"*"}}
|
||||
if err := Can(ipc.MethodCaptureStart, voice, nil); err != nil {
|
||||
t.Errorf("voice starting a capture = %v; want allowed", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequirement_Speaker — a voiceprint is a biometric of a named person, so
|
||||
// taking one is step-up: a deliberate act from a surface that can carry a
|
||||
// passkey gesture, never something the voice path does mid-conversation.
|
||||
//
|
||||
// Deletion is one rung lower, and that asymmetry is the point. Everywhere else
|
||||
// in the table the destructive direction is gated at least as hard as the
|
||||
// constructive one; for a biometric that would be backwards, because getting
|
||||
// rid of it must never be the harder half.
|
||||
func TestRequirement_Speaker(t *testing.T) {
|
||||
if got := Requirement(ipc.MethodEnrollSpeaker); got != AuthStepUp {
|
||||
t.Errorf("EnrollSpeaker authority = %v; want AuthStepUp", got)
|
||||
}
|
||||
if got := Requirement(ipc.MethodForgetSpeaker); got != AuthWrite {
|
||||
t.Errorf("ForgetSpeaker authority = %v; want AuthWrite", got)
|
||||
}
|
||||
if got := Requirement(ipc.MethodListSpeakers); got != AuthRead {
|
||||
t.Errorf("ListSpeakers authority = %v; want AuthRead", got)
|
||||
}
|
||||
// Voice cannot enrol anybody, however the utterance is phrased.
|
||||
voice := Scope{Surface: SurfaceVoice, Module: "voice", SourceScope: []string{"*"}}
|
||||
if err := Can(ipc.MethodEnrollSpeaker, voice, nil); err == nil {
|
||||
t.Error("voice enrolling a speaker was allowed; want refused")
|
||||
}
|
||||
// But it can read the roster, which is what answering "кого ты знаешь?"
|
||||
// needs.
|
||||
if err := Can(ipc.MethodListSpeakers, voice, nil); err != nil {
|
||||
t.Errorf("voice listing speakers = %v; want allowed", err)
|
||||
}
|
||||
}
|
||||
|
||||
+57
-1
@@ -53,6 +53,44 @@ func Requirement(m ipc.Method) Authority {
|
||||
// asserted — never a module or the voice/chat path. maven can propose
|
||||
// (MethodProposeTool, no step-up: she has no passkey) but never en/disable.
|
||||
return AuthStepUp
|
||||
case ipc.MethodSwapModel:
|
||||
// Swapping the resident model changes what routes every utterance and
|
||||
// what words every reply. It is the owner's call, from a surface that can
|
||||
// carry a passkey gesture — the same rung as mutating the tool allowlist,
|
||||
// and for the same reason: nothing Maven says or does may reach it.
|
||||
// MethodModelStatus is only the read side, so it stays at AuthRead.
|
||||
return AuthStepUp
|
||||
case ipc.MethodCaptureStart, ipc.MethodCaptureAppend, ipc.MethodCaptureStop:
|
||||
// Recording a meeting (Vikunja #253). AuthWrite, not AuthRead: it puts
|
||||
// audio of other people on disk, which is a heavier thing than reading a
|
||||
// fact, and it is not something a read-only surface should be able to
|
||||
// begin. Append and Stop sit on the same rung as Start deliberately —
|
||||
// a surface that may not start a recording has no business feeding or
|
||||
// harvesting one either.
|
||||
//
|
||||
// Not AuthStepUp, and this is the interesting line: step-up needs a
|
||||
// passkey gesture, which the voice path cannot make. Putting it here
|
||||
// would mean "запиши встречу" could never work by voice, and the real
|
||||
// gate on this capability is elsewhere and stronger — the methods do not
|
||||
// exist at all unless the operator enabled a capture block, and no
|
||||
// recording can begin without someone saying so.
|
||||
return AuthWrite
|
||||
case ipc.MethodEnrollSpeaker:
|
||||
// Taking a voiceprint (Vikunja #255). AuthStepUp, and unlike recording a
|
||||
// meeting there is no reason to soften it: enrolment is not a thing anyone
|
||||
// does by voice mid-conversation. It is a deliberate sit-down with a
|
||||
// surface that can carry a passkey gesture, and it writes a biometric of a
|
||||
// named person. If the gesture is inconvenient, that is the correct amount
|
||||
// of friction for this particular write.
|
||||
return AuthStepUp
|
||||
case ipc.MethodForgetSpeaker:
|
||||
// Deleting a voiceprint. One rung BELOW enrolment on purpose. Everywhere
|
||||
// else in this table the destructive direction is gated at least as hard
|
||||
// as the constructive one, and here that would be wrong: getting rid of a
|
||||
// biometric must never be the harder half. The worst a caller at this rung
|
||||
// can do is make Maven stop recognising someone, which is the state the
|
||||
// box ships in anyway.
|
||||
return AuthWrite
|
||||
case ipc.MethodWriteFact:
|
||||
return AuthWrite
|
||||
case ipc.MethodAssertStepUp:
|
||||
@@ -79,7 +117,25 @@ func Requirement(m ipc.Method) Authority {
|
||||
// produce: candidate tasks and nothing else. It cannot write a fact, set a
|
||||
// reminder, or touch the tool allowlist, so a compromised mail reader can
|
||||
// at worst put junk on a review page he clears in one click.
|
||||
ipc.MethodIngestMail:
|
||||
ipc.MethodIngestMail,
|
||||
// Looking at one image (Vikunja #252). AuthRead because of what it can
|
||||
// produce: words about a picture, and optionally a note. It cannot write
|
||||
// a fact, set a reminder, or touch the tool allowlist. The invasive part
|
||||
// of this capability is not the authority rung — it is that the bytes are
|
||||
// kept on disk, which media.retention bounds, and that they never leave
|
||||
// the box, which internal/vision enforces by refusing a non-private
|
||||
// endpoint.
|
||||
ipc.MethodDescribeImage,
|
||||
// "что ты записываешь?" — the read side of the recorder. It reports a
|
||||
// label, a start time and a byte count, begins nothing and keeps nothing.
|
||||
ipc.MethodCaptureStatus,
|
||||
// Who is enrolled. Returns ids, names and enrolment dates — never the
|
||||
// voiceprints themselves, which stay in core. Listing the people Maven can
|
||||
// recognise is exactly the read a surface needs to offer a "forget" button.
|
||||
ipc.MethodListSpeakers,
|
||||
// The read side of the model swap: which model is resident, which ones are
|
||||
// allowlisted. It loads nothing and changes nothing.
|
||||
ipc.MethodModelStatus:
|
||||
return AuthRead
|
||||
}
|
||||
// Unknown method ⇒ AuthRead, but ipc.dispatch returns ErrUnknownMethod
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
// Package capture is Maven's meeting recorder (Vikunja #253,
|
||||
// docs/plans/08-hearing.md).
|
||||
//
|
||||
// One session at a time, with an explicit start and an explicit stop:
|
||||
//
|
||||
// Start("встреча") → audio frames appended → Stop() → transcript → summary
|
||||
//
|
||||
// # Nothing here listens
|
||||
//
|
||||
// This is the most invasive capability in the backlog and the design is
|
||||
// constrained accordingly. The constraints are the code, not a preamble:
|
||||
//
|
||||
// - There is no ambient path. `Session.Append` is the only way audio enters,
|
||||
// and it only accepts frames while a session someone started is running.
|
||||
// A keyword-triggered recorder ("maven record" heard in the room) was in the
|
||||
// plan document and is refused: it requires listening in order to notice the
|
||||
// keyword, which is the exact behaviour this capability must not have.
|
||||
// - A session that is not stopped stops itself. MaxDuration is a hard cap
|
||||
// checked on every Append, not a suggestion; a forgotten recording is a
|
||||
// recording that ends, not one that runs until the disk is full.
|
||||
// - Audio is stored under internal/media, which means retention prunes it and
|
||||
// it never leaves the box. Both the audio blob and the transcript stay
|
||||
// local; only the summary is written where he will read it.
|
||||
// - The transcript is never search input for anything outside this box. It is
|
||||
// text about a conversation with other people in it.
|
||||
//
|
||||
// # Long audio against a 4096-token context
|
||||
//
|
||||
// The resident model is a Thinking variant at n_ctx 4096, so an hour of meeting
|
||||
// transcript does not fit in one prompt and never will. summarize.go does the
|
||||
// obvious map-reduce: split the transcript on sentence boundaries into windows
|
||||
// that fit, summarise each, then summarise the summaries. That is handled
|
||||
// explicitly rather than by truncation, because a truncated meeting summary is
|
||||
// worse than none — it looks complete and is not.
|
||||
//
|
||||
// # Transcription
|
||||
//
|
||||
// There is exactly one STT in Maven and this package does not add a second: it
|
||||
// takes an stt.Transcriber, which in deploy is the whisper.cpp worker behind
|
||||
// cmd/mavsttd. Long audio is transcribed in windows too (see chunkAudio), for
|
||||
// the same reason whisper itself works in 30s windows — handing a worker an hour
|
||||
// of PCM in one call is a request that either times out or blocks everything
|
||||
// else for minutes.
|
||||
package capture
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/media"
|
||||
"github.com/kami/maven/internal/stt"
|
||||
)
|
||||
|
||||
// DefaultMaxDuration — how long one capture may run before it stops itself.
|
||||
// Two hours covers a long meeting and bounds the damage of a forgotten session:
|
||||
// at 16 kHz mono that is about 230 MB of PCM, which is over media's default
|
||||
// per-blob cap, so a session at the limit is stored truncated rather than
|
||||
// refused. That trade is deliberate — a partial recording of a meeting he asked
|
||||
// for beats an error after two hours.
|
||||
const DefaultMaxDuration = 2 * time.Hour
|
||||
|
||||
// DefaultSTTWindow — how much audio goes to the transcriber in one call. Five
|
||||
// minutes of 16 kHz mono is under 10 MB, transcribes in well under whisper's
|
||||
// own timeout on this box, and keeps the worker responsive to the voice path
|
||||
// between windows.
|
||||
const DefaultSTTWindow = 5 * time.Minute
|
||||
|
||||
// Errors callers distinguish.
|
||||
var (
|
||||
// ErrDisabled — capture is not configured. A capability is off unless
|
||||
// configured, and a recorder most of all.
|
||||
ErrDisabled = errors.New("capture: not configured")
|
||||
// ErrBusy — a session is already running. One at a time: two concurrent
|
||||
// recordings would make "хватит" ambiguous.
|
||||
ErrBusy = errors.New("capture: a session is already running")
|
||||
// ErrNoSession — stop or append with nothing running.
|
||||
ErrNoSession = errors.New("capture: nothing is being recorded")
|
||||
// ErrBadFormat — a frame is not the canonical 16 kHz mono PCM shape.
|
||||
ErrBadFormat = errors.New("capture: audio format not supported")
|
||||
// ErrEmptyCapture — the session ended with no audio in it.
|
||||
ErrEmptyCapture = errors.New("capture: nothing was recorded")
|
||||
// ErrExpired — the session hit MaxDuration and was closed. Returned from
|
||||
// Append so the caller stops sending; the audio collected so far is kept.
|
||||
ErrExpired = errors.New("capture: session reached its time limit")
|
||||
)
|
||||
|
||||
// Session — one recording in progress. Not created directly; Recorder.Start
|
||||
// makes it. Guarded by a mutex because frames arrive from a network goroutine
|
||||
// while a status call may read from another.
|
||||
type Session struct {
|
||||
Label string
|
||||
Started time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
pcm []byte
|
||||
format audio.Format
|
||||
expired bool
|
||||
}
|
||||
|
||||
// Duration is how much audio has been collected, from the bytes rather than the
|
||||
// wall clock: a stream that dropped frames should report the audio that exists,
|
||||
// not the time that passed.
|
||||
func (s *Session) Duration() time.Duration {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.duration()
|
||||
}
|
||||
|
||||
func (s *Session) duration() time.Duration {
|
||||
a := audio.Audio{Format: s.format, Bytes: s.pcm}
|
||||
return time.Duration(a.Duration() * float64(time.Second))
|
||||
}
|
||||
|
||||
// Bytes is how much PCM has been collected. For a status line.
|
||||
func (s *Session) Bytes() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.pcm)
|
||||
}
|
||||
|
||||
// Status — what a "что записываешь?" answer needs, and what /dash shows. It is
|
||||
// the read side of a running session and is safe to ask for at any time.
|
||||
type Status struct {
|
||||
Running bool `json:"running"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Started time.Time `json:"started,omitempty"`
|
||||
Duration time.Duration `json:"duration,omitempty"`
|
||||
Bytes int `json:"bytes,omitempty"`
|
||||
}
|
||||
|
||||
// Recorder owns the single session slot, the blob store and the two models a
|
||||
// finished capture needs. Build it with New; a zero Recorder is not usable.
|
||||
type Recorder struct {
|
||||
blobs *media.Store
|
||||
tr stt.Transcriber
|
||||
sum *Summarizer
|
||||
maxDuration time.Duration
|
||||
sttWindow time.Duration
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
current *Session
|
||||
}
|
||||
|
||||
// Config — the recorder's knobs, built from config.CaptureConfig by the daemon.
|
||||
type Config struct {
|
||||
// MaxDuration — hard cap on one session. 0 ⇒ DefaultMaxDuration.
|
||||
MaxDuration time.Duration
|
||||
// STTWindow — audio per transcription call. 0 ⇒ DefaultSTTWindow.
|
||||
STTWindow time.Duration
|
||||
}
|
||||
|
||||
// New builds a Recorder. blobs and tr are required — a recorder with nowhere to
|
||||
// put the audio, or nothing to transcribe it with, is not a recorder. sum may be
|
||||
// nil: the transcript is still produced and stored, and the summary is simply
|
||||
// absent, which is the honest degradation when there is no llama-server.
|
||||
func New(blobs *media.Store, tr stt.Transcriber, sum *Summarizer, cfg Config) (*Recorder, error) {
|
||||
if blobs == nil {
|
||||
return nil, errors.New("capture: no blob store")
|
||||
}
|
||||
if tr == nil {
|
||||
return nil, errors.New("capture: no transcriber")
|
||||
}
|
||||
maxDur := cfg.MaxDuration
|
||||
if maxDur <= 0 {
|
||||
maxDur = DefaultMaxDuration
|
||||
}
|
||||
window := cfg.STTWindow
|
||||
if window <= 0 {
|
||||
window = DefaultSTTWindow
|
||||
}
|
||||
return &Recorder{
|
||||
blobs: blobs,
|
||||
tr: tr,
|
||||
sum: sum,
|
||||
maxDuration: maxDur,
|
||||
sttWindow: window,
|
||||
now: time.Now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MaxDuration is the configured hard cap. For the reply that tells him how long
|
||||
// she will keep going if he forgets to say "хватит".
|
||||
func (r *Recorder) MaxDuration() time.Duration { return r.maxDuration }
|
||||
|
||||
// Start opens a session. label is what the meeting is called ("встреча с
|
||||
// подрядчиком"); it ends up in the summary note so the note is findable.
|
||||
// ErrBusy if one is already running — the caller says so rather than silently
|
||||
// discarding the first recording.
|
||||
func (r *Recorder) Start(label string) (*Session, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.current != nil {
|
||||
return nil, fmt.Errorf("%w: %q since %s", ErrBusy, r.current.Label,
|
||||
r.current.Started.Format(time.Kitchen))
|
||||
}
|
||||
s := &Session{
|
||||
Label: strings.TrimSpace(label),
|
||||
Started: r.now().UTC(),
|
||||
format: audio.PCM16kMono,
|
||||
}
|
||||
r.current = s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Append adds one frame to the running session. ErrNoSession when nothing is
|
||||
// running, which is the guard that makes an ambient path impossible: a stream
|
||||
// arriving at a Recorder nobody started is refused frame by frame.
|
||||
//
|
||||
// ErrExpired once the session is at MaxDuration. The audio collected so far is
|
||||
// kept and Stop still works — the cap ends the recording, it does not throw it
|
||||
// away.
|
||||
func (r *Recorder) Append(a audio.Audio) error {
|
||||
if !a.Format.IsValid() {
|
||||
return fmt.Errorf("%w: %+v", ErrBadFormat, a.Format)
|
||||
}
|
||||
r.mu.Lock()
|
||||
s := r.current
|
||||
r.mu.Unlock()
|
||||
if s == nil {
|
||||
return ErrNoSession
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.expired {
|
||||
return ErrExpired
|
||||
}
|
||||
s.pcm = append(s.pcm, a.Bytes...)
|
||||
if s.duration() >= r.maxDuration {
|
||||
s.expired = true
|
||||
return ErrExpired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status reports the running session, or Running=false.
|
||||
func (r *Recorder) Status() Status {
|
||||
r.mu.Lock()
|
||||
s := r.current
|
||||
r.mu.Unlock()
|
||||
if s == nil {
|
||||
return Status{}
|
||||
}
|
||||
return Status{
|
||||
Running: true,
|
||||
Label: s.Label,
|
||||
Started: s.Started,
|
||||
Duration: s.Duration(),
|
||||
Bytes: s.Bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
// Result — a finished capture.
|
||||
type Result struct {
|
||||
// BlobID — the stored audio, content-addressed. Empty only if storing failed.
|
||||
BlobID string
|
||||
// Label / Started / Duration — what was recorded and when.
|
||||
Label string
|
||||
Started time.Time
|
||||
Duration time.Duration
|
||||
// Transcript — the full text, joined across STT windows.
|
||||
Transcript string
|
||||
// Summary — the map-reduced summary, or empty when no summarizer was wired
|
||||
// or the model failed. Empty summary with a non-empty transcript is a
|
||||
// degraded success, not a failure: the words are there.
|
||||
Summary string
|
||||
// Chunks — how many windows the transcript was summarised in. 1 means it fit
|
||||
// in one prompt. Reported so a suspiciously vague summary can be explained.
|
||||
Chunks int
|
||||
}
|
||||
|
||||
// Stop ends the session and produces the result: store the audio, transcribe it
|
||||
// in windows, summarise it in windows. The session slot is freed before any of
|
||||
// the slow work starts, so a stuck model cannot block the next recording.
|
||||
//
|
||||
// The order matters and is the same as vision's: the audio is stored FIRST. If
|
||||
// transcription or summarisation fails, the recording is still on disk and can
|
||||
// be run again; a meeting that happened once must not be lost to a model error.
|
||||
func (r *Recorder) Stop(ctx context.Context) (Result, error) {
|
||||
r.mu.Lock()
|
||||
s := r.current
|
||||
r.current = nil
|
||||
r.mu.Unlock()
|
||||
if s == nil {
|
||||
return Result{}, ErrNoSession
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
pcm := s.pcm
|
||||
format := s.format
|
||||
s.mu.Unlock()
|
||||
|
||||
res := Result{Label: s.Label, Started: s.Started}
|
||||
if len(pcm) == 0 {
|
||||
return res, ErrEmptyCapture
|
||||
}
|
||||
full := audio.Audio{Format: format, Bytes: pcm}
|
||||
res.Duration = time.Duration(full.Duration() * float64(time.Second))
|
||||
|
||||
// Stored as WAV, not headerless PCM: a blob on disk that `aplay` and whisper
|
||||
// can both open without being told the format is worth 44 bytes.
|
||||
wav, err := audio.WAVFromPCM(format, pcm)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("capture: wav: %w", err)
|
||||
}
|
||||
blob, err := r.blobs.Put(media.KindAudio, "audio/wav", "capture:meeting", wav)
|
||||
if err != nil {
|
||||
// Over the per-blob cap is the expected case for a very long meeting.
|
||||
// Report it and keep going: a transcript without the audio still beats
|
||||
// nothing, and the words are what he will read.
|
||||
return res, fmt.Errorf("capture: store audio: %w", err)
|
||||
}
|
||||
res.BlobID = blob.ID
|
||||
|
||||
text, err := r.transcribe(ctx, full)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("capture: transcribe: %w", err)
|
||||
}
|
||||
res.Transcript = text
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return res, ErrEmptyCapture
|
||||
}
|
||||
|
||||
if r.sum == nil {
|
||||
return res, nil
|
||||
}
|
||||
summary, chunks, err := r.sum.Summarize(ctx, s.Label, text)
|
||||
res.Chunks = chunks
|
||||
if err != nil {
|
||||
// Degraded success: the transcript is real and stored, only the summary
|
||||
// is missing. The caller writes the transcript note and says so.
|
||||
return res, fmt.Errorf("capture: summarize: %w", err)
|
||||
}
|
||||
res.Summary = summary
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Abort throws the running session away without transcribing or storing it.
|
||||
// This is what "забудь, не записывай" must map to: a recording someone changed
|
||||
// their mind about leaves nothing behind, not a blob with a note saying it was
|
||||
// abandoned. Returns whether anything was running.
|
||||
func (r *Recorder) Abort() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.current == nil {
|
||||
return false
|
||||
}
|
||||
r.current = nil
|
||||
return true
|
||||
}
|
||||
|
||||
// transcribe runs the transcriber over the audio in windows and joins the text.
|
||||
// A window that fails is fatal: a summary of a meeting with a silent hole in the
|
||||
// middle is a summary that misleads.
|
||||
func (r *Recorder) transcribe(ctx context.Context, a audio.Audio) (string, error) {
|
||||
windows := chunkAudio(a, r.sttWindow)
|
||||
parts := make([]string, 0, len(windows))
|
||||
for i, w := range windows {
|
||||
text, _, err := r.tr.Transcribe(ctx, w)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("window %d/%d: %w", i+1, len(windows), err)
|
||||
}
|
||||
if t := strings.TrimSpace(text); t != "" {
|
||||
parts = append(parts, t)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " "), nil
|
||||
}
|
||||
|
||||
// chunkAudio splits audio into windows of at most window duration, cut on
|
||||
// sample boundaries. A window shorter than one sample is impossible; audio
|
||||
// shorter than one window comes back as a single element, so the caller never
|
||||
// special-cases the short case.
|
||||
func chunkAudio(a audio.Audio, window time.Duration) []audio.Audio {
|
||||
bytesPerSample := a.Format.SampleBits / 8 * a.Format.Channels
|
||||
if bytesPerSample <= 0 || a.Format.SampleRate <= 0 || window <= 0 {
|
||||
return []audio.Audio{a}
|
||||
}
|
||||
per := int(window.Seconds()) * a.Format.SampleRate * bytesPerSample
|
||||
if per <= 0 || len(a.Bytes) <= per {
|
||||
return []audio.Audio{a}
|
||||
}
|
||||
var out []audio.Audio
|
||||
for off := 0; off < len(a.Bytes); off += per {
|
||||
end := off + per
|
||||
if end > len(a.Bytes) {
|
||||
end = len(a.Bytes)
|
||||
}
|
||||
// Never cut mid-sample: a split inside an int16 shifts every following
|
||||
// sample by a byte and turns the tail of the window into noise.
|
||||
end -= (end - off) % bytesPerSample
|
||||
if end <= off {
|
||||
break
|
||||
}
|
||||
out = append(out, audio.Audio{Format: a.Format, Bytes: a.Bytes[off:end]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/media"
|
||||
)
|
||||
|
||||
// fakeTranscriber returns a fixed phrase per call so a windowed transcription is
|
||||
// visible in the joined output.
|
||||
type fakeTranscriber struct {
|
||||
calls int
|
||||
err error
|
||||
phrase string
|
||||
}
|
||||
|
||||
func (f *fakeTranscriber) Transcribe(_ context.Context, a audio.Audio) (string, float64, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return "", 0, f.err
|
||||
}
|
||||
p := f.phrase
|
||||
if p == "" {
|
||||
p = "окно"
|
||||
}
|
||||
return fmt.Sprintf("%s%d", p, f.calls), 1.0, nil
|
||||
}
|
||||
|
||||
// fakeCompleter records prompts and replies from a script.
|
||||
type fakeCompleter struct {
|
||||
replies []string
|
||||
systems []string
|
||||
users []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeCompleter) Complete(_ context.Context, system, user string) (string, error) {
|
||||
f.systems = append(f.systems, system)
|
||||
f.users = append(f.users, user)
|
||||
if f.err != nil {
|
||||
return "", f.err
|
||||
}
|
||||
if len(f.replies) == 0 {
|
||||
return "итог", nil
|
||||
}
|
||||
r := f.replies[0]
|
||||
f.replies = f.replies[1:]
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// frame builds n seconds of silence in the canonical format.
|
||||
func frame(seconds float64) audio.Audio {
|
||||
n := int(seconds*16000) * 2
|
||||
return audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, n)}
|
||||
}
|
||||
|
||||
func testRecorder(t *testing.T, tr *fakeTranscriber, sum *Summarizer, cfg Config) (*Recorder, *media.Store) {
|
||||
t.Helper()
|
||||
blobs, err := media.Open(t.TempDir(), 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := New(blobs, tr, sum, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r, blobs
|
||||
}
|
||||
|
||||
func TestNewRequiresStoreAndTranscriber(t *testing.T) {
|
||||
blobs, err := media.Open(t.TempDir(), 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := New(nil, &fakeTranscriber{}, nil, Config{}); err == nil {
|
||||
t.Error("recorder built with no blob store")
|
||||
}
|
||||
if _, err := New(blobs, nil, nil, Config{}); err == nil {
|
||||
t.Error("recorder built with no transcriber")
|
||||
}
|
||||
}
|
||||
|
||||
// The invariant that matters most: audio arriving at a recorder nobody started
|
||||
// is refused. There is no ambient path in.
|
||||
func TestAppendWithoutStartIsRefused(t *testing.T) {
|
||||
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
|
||||
if err := r.Append(frame(1)); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("got %v, want ErrNoSession", err)
|
||||
}
|
||||
if r.Status().Running {
|
||||
t.Error("a refused frame started a session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopWithoutStartIsRefused(t *testing.T) {
|
||||
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
|
||||
if _, err := r.Stop(context.Background()); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("got %v, want ErrNoSession", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneSessionAtATime(t *testing.T) {
|
||||
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
|
||||
if _, err := r.Start("встреча"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.Start("вторая"); !errors.Is(err, ErrBusy) {
|
||||
t.Fatalf("got %v, want ErrBusy", err)
|
||||
}
|
||||
if _, err := r.Stop(context.Background()); !errors.Is(err, ErrEmptyCapture) {
|
||||
t.Fatalf("empty stop: %v", err)
|
||||
}
|
||||
// The slot is free again after a stop, even a failed one.
|
||||
if _, err := r.Start("третья"); err != nil {
|
||||
t.Errorf("slot not released: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTripStoresAudioTranscriptAndSummary(t *testing.T) {
|
||||
tr := &fakeTranscriber{phrase: "совещание"}
|
||||
sum := NewSummarizer(&fakeCompleter{replies: []string{"— решили купить насос"}}, 0, 0, nil)
|
||||
r, blobs := testRecorder(t, tr, sum, Config{})
|
||||
|
||||
if _, err := r.Start("встреча с подрядчиком"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := r.Append(frame(2)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
res, err := r.Stop(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("stop: %v", err)
|
||||
}
|
||||
if res.BlobID == "" {
|
||||
t.Error("no audio blob stored")
|
||||
}
|
||||
blob, data, err := blobs.Read(res.BlobID)
|
||||
if err != nil {
|
||||
t.Fatalf("blob unreadable: %v", err)
|
||||
}
|
||||
if blob.Kind != media.KindAudio || blob.Source != "capture:meeting" {
|
||||
t.Errorf("blob metadata = %+v", blob)
|
||||
}
|
||||
if string(data[:4]) != "RIFF" {
|
||||
t.Error("audio was not stored as a playable WAV")
|
||||
}
|
||||
if res.Transcript == "" {
|
||||
t.Error("no transcript")
|
||||
}
|
||||
if !strings.Contains(res.Summary, "насос") {
|
||||
t.Errorf("summary = %q", res.Summary)
|
||||
}
|
||||
if !strings.Contains(res.Summary, "встреча с подрядчиком") {
|
||||
t.Errorf("label missing from summary: %q", res.Summary)
|
||||
}
|
||||
if res.Duration != 6*time.Second {
|
||||
t.Errorf("duration = %v, want 6s", res.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
// A forgotten session stops itself, and the audio collected before the cap is
|
||||
// kept rather than thrown away.
|
||||
func TestMaxDurationEndsTheSessionAndKeepsAudio(t *testing.T) {
|
||||
tr := &fakeTranscriber{}
|
||||
r, _ := testRecorder(t, tr, nil, Config{MaxDuration: 4 * time.Second})
|
||||
if _, err := r.Start("длинная"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Append(frame(3)); err != nil {
|
||||
t.Fatalf("first frame: %v", err)
|
||||
}
|
||||
if err := r.Append(frame(3)); !errors.Is(err, ErrExpired) {
|
||||
t.Fatalf("got %v, want ErrExpired", err)
|
||||
}
|
||||
// Further frames keep being refused, so a client that ignores the error
|
||||
// cannot grow the recording past the cap.
|
||||
if err := r.Append(frame(3)); !errors.Is(err, ErrExpired) {
|
||||
t.Fatalf("post-expiry frame: %v", err)
|
||||
}
|
||||
res, err := r.Stop(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("stop after expiry: %v", err)
|
||||
}
|
||||
if res.Duration != 6*time.Second {
|
||||
t.Errorf("duration = %v, want the 6s collected before the cap", res.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendRejectsWrongFormat(t *testing.T) {
|
||||
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
|
||||
if _, err := r.Start("x"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bad := audio.Audio{Format: audio.Format{SampleRate: 44100, Channels: 2, SampleBits: 16, Encoding: "pcm_s16le"}, Bytes: make([]byte, 100)}
|
||||
if err := r.Append(bad); !errors.Is(err, ErrBadFormat) {
|
||||
t.Fatalf("got %v, want ErrBadFormat", err)
|
||||
}
|
||||
}
|
||||
|
||||
// "забудь, не записывай" must leave nothing behind — no blob, no transcript.
|
||||
func TestAbortLeavesNothing(t *testing.T) {
|
||||
tr := &fakeTranscriber{}
|
||||
r, blobs := testRecorder(t, tr, nil, Config{})
|
||||
if _, err := r.Start("зря начали"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Append(frame(5)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !r.Abort() {
|
||||
t.Fatal("Abort reported nothing running")
|
||||
}
|
||||
if r.Status().Running {
|
||||
t.Error("session survived Abort")
|
||||
}
|
||||
list, err := blobs.List(media.KindAudio)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Errorf("Abort stored %d blob(s)", len(list))
|
||||
}
|
||||
if tr.calls != 0 {
|
||||
t.Errorf("Abort transcribed anyway (%d calls)", tr.calls)
|
||||
}
|
||||
if r.Abort() {
|
||||
t.Error("second Abort reported a session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusReportsTheRunningSession(t *testing.T) {
|
||||
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
|
||||
if got := r.Status(); got.Running {
|
||||
t.Error("idle recorder reports running")
|
||||
}
|
||||
if _, err := r.Start("планёрка"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Append(frame(10)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := r.Status()
|
||||
if !st.Running || st.Label != "планёрка" {
|
||||
t.Fatalf("status = %+v", st)
|
||||
}
|
||||
if st.Duration != 10*time.Second {
|
||||
t.Errorf("duration = %v", st.Duration)
|
||||
}
|
||||
if st.Bytes != 10*16000*2 {
|
||||
t.Errorf("bytes = %d", st.Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// Long audio goes to the transcriber in windows: handing a whisper worker an
|
||||
// hour of PCM in one call blocks the voice path for minutes.
|
||||
func TestLongAudioIsTranscribedInWindows(t *testing.T) {
|
||||
tr := &fakeTranscriber{}
|
||||
r, _ := testRecorder(t, tr, nil, Config{STTWindow: 2 * time.Second})
|
||||
if _, err := r.Start("длинная"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Append(frame(9)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := r.Stop(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("stop: %v", err)
|
||||
}
|
||||
if tr.calls != 5 { // 2+2+2+2+1
|
||||
t.Errorf("transcriber called %d times, want 5", tr.calls)
|
||||
}
|
||||
if !strings.Contains(res.Transcript, "окно5") {
|
||||
t.Errorf("last window missing from transcript: %q", res.Transcript)
|
||||
}
|
||||
}
|
||||
|
||||
// A hole in the middle of a meeting summary would mislead, so a failed window is
|
||||
// fatal — but the audio is already stored and re-runnable.
|
||||
func TestTranscriptionFailureKeepsTheAudio(t *testing.T) {
|
||||
tr := &fakeTranscriber{err: errors.New("whisper is down")}
|
||||
r, blobs := testRecorder(t, tr, nil, Config{})
|
||||
if _, err := r.Start("встреча"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Append(frame(2)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := r.Stop(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("transcription failure was not reported")
|
||||
}
|
||||
if res.BlobID == "" {
|
||||
t.Fatal("no blob id to retry with")
|
||||
}
|
||||
if _, _, err := blobs.Read(res.BlobID); err != nil {
|
||||
t.Errorf("audio was not kept: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// No llama-server ⇒ transcript only. That is the honest degradation, not an
|
||||
// error.
|
||||
func TestNoSummarizerStillProducesATranscript(t *testing.T) {
|
||||
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
|
||||
if _, err := r.Start("встреча"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Append(frame(1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := r.Stop(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("stop: %v", err)
|
||||
}
|
||||
if res.Transcript == "" {
|
||||
t.Error("no transcript")
|
||||
}
|
||||
if res.Summary != "" {
|
||||
t.Errorf("summary appeared from nowhere: %q", res.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
// A summariser failure is a degraded success: the words exist and are returned.
|
||||
func TestSummaryFailureStillReturnsTheTranscript(t *testing.T) {
|
||||
sum := NewSummarizer(&fakeCompleter{err: errors.New("llama is down")}, 0, 0, nil)
|
||||
r, _ := testRecorder(t, &fakeTranscriber{}, sum, Config{})
|
||||
if _, err := r.Start("встреча"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Append(frame(1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := r.Stop(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("summary failure was not reported")
|
||||
}
|
||||
if res.Transcript == "" {
|
||||
t.Error("transcript lost to a summary failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkAudioNeverCutsMidSample(t *testing.T) {
|
||||
a := audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 16000*2*5+1)}
|
||||
for _, w := range chunkAudio(a, 2*time.Second) {
|
||||
if len(w.Bytes)%2 != 0 {
|
||||
t.Fatalf("window of %d bytes cuts an int16 in half", len(w.Bytes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkAudioShortInputIsOneWindow(t *testing.T) {
|
||||
a := frame(1)
|
||||
if got := chunkAudio(a, time.Minute); len(got) != 1 {
|
||||
t.Errorf("got %d windows, want 1", len(got))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// DefaultChunkRunes — how much transcript goes into one summarisation prompt.
|
||||
//
|
||||
// The resident model runs at n_ctx 4096 and is a Thinking variant, so reasoning
|
||||
// tokens need room too. Russian runs roughly 2.5–3 characters per token on a
|
||||
// Qwen tokenizer, so 3000 runes is about 1100 tokens of transcript, leaving the
|
||||
// prompt, the persona block, the reasoning and the answer comfortable space.
|
||||
// This is the same reasoning internal/crawl used to land on 4000 runes, tightened
|
||||
// because a meeting transcript is denser in named entities than a web page and
|
||||
// the reduce step has to fit several summaries at once.
|
||||
const DefaultChunkRunes = 3000
|
||||
|
||||
// DefaultMaxChunks — how many windows one meeting may be summarised in. Forty
|
||||
// chunks at 3000 runes is roughly a two-hour meeting, which is MaxDuration; past
|
||||
// that the transcript is truncated and the summary says so, because forty-one
|
||||
// sequential model calls on this box is half an hour of work nobody is waiting
|
||||
// through.
|
||||
const DefaultMaxChunks = 40
|
||||
|
||||
// ErrNoSummary — the model returned nothing usable for every chunk.
|
||||
var ErrNoSummary = errors.New("capture: model produced no summary")
|
||||
|
||||
// Completer is the one thing the summarizer needs from a model: text in, text
|
||||
// out. It is an interface rather than an *llm.Client so this package stays pure
|
||||
// and testable, and so the daemon can pass whatever it already has.
|
||||
type Completer interface {
|
||||
Complete(ctx context.Context, system, user string) (string, error)
|
||||
}
|
||||
|
||||
// Summarizer turns a transcript into something worth reading. It is map-reduce
|
||||
// and nothing cleverer: summarise each window, then summarise the summaries.
|
||||
//
|
||||
// Truncation was the alternative and is rejected. A truncated meeting summary
|
||||
// reads as complete and is not, which is worse than no summary at all — he would
|
||||
// act on it.
|
||||
type Summarizer struct {
|
||||
llm Completer
|
||||
chunkRunes int
|
||||
maxChunks int
|
||||
// context is the persona/context block the daemon prepends to every prompt,
|
||||
// or empty. Passed in rather than built here so this package does not import
|
||||
// internal/persona and the feminine self-reference rules stay in one place.
|
||||
context func() string
|
||||
}
|
||||
|
||||
// NewSummarizer wires a summarizer. llm nil ⇒ nil Summarizer, which Recorder
|
||||
// treats as "transcript only", the honest degradation with no llama-server.
|
||||
// chunkRunes ≤ 0 ⇒ DefaultChunkRunes; maxChunks ≤ 0 ⇒ DefaultMaxChunks.
|
||||
func NewSummarizer(llm Completer, chunkRunes, maxChunks int, contextBlock func() string) *Summarizer {
|
||||
if llm == nil {
|
||||
return nil
|
||||
}
|
||||
if chunkRunes <= 0 {
|
||||
chunkRunes = DefaultChunkRunes
|
||||
}
|
||||
if maxChunks <= 0 {
|
||||
maxChunks = DefaultMaxChunks
|
||||
}
|
||||
if contextBlock == nil {
|
||||
contextBlock = func() string { return "" }
|
||||
}
|
||||
return &Summarizer{llm: llm, chunkRunes: chunkRunes, maxChunks: maxChunks, context: contextBlock}
|
||||
}
|
||||
|
||||
// chunkPrompt — the map step. Deliberately plain: this is not Maven speaking to
|
||||
// him, it is a model condensing text, so there is no first person in it at all
|
||||
// and therefore nothing for the persona's gender rules to get wrong. The reply
|
||||
// she gives him afterwards is phrased by the ordinary replier, which does carry
|
||||
// the persona.
|
||||
const chunkPrompt = `Ты обрабатываешь фрагмент расшифровки разговора.
|
||||
Сожми его до 2-4 пунктов: о чём говорили, какие решения приняли, какие задачи назвали.
|
||||
Без вступлений и выводов. Только по тексту — не придумывай того, чего в нём нет.
|
||||
Если во фрагменте нет ничего содержательного, ответь одним словом: пусто.`
|
||||
|
||||
// reducePrompt — the reduce step. Same rules, over the chunk summaries.
|
||||
const reducePrompt = `Ниже — конспекты фрагментов одной встречи, по порядку.
|
||||
Собери из них один короткий итог: о чём была встреча, какие решения приняли, что кому делать.
|
||||
Не повторяйся, не придумывай, не добавляй вступлений.`
|
||||
|
||||
// emptyMarker — what the map step answers for a chunk with nothing in it. Such
|
||||
// chunks are dropped before the reduce step rather than padding it with noise.
|
||||
const emptyMarker = "пусто"
|
||||
|
||||
// Summarize returns the summary and the number of chunks the transcript was
|
||||
// split into. One chunk means it fit in a single prompt and the reduce step was
|
||||
// skipped, which is the common case for a short meeting and saves a model call.
|
||||
func (s *Summarizer) Summarize(ctx context.Context, label, transcript string) (string, int, error) {
|
||||
if s == nil {
|
||||
return "", 0, ErrDisabled
|
||||
}
|
||||
chunks := ChunkText(transcript, s.chunkRunes)
|
||||
if len(chunks) == 0 {
|
||||
return "", 0, ErrEmptyCapture
|
||||
}
|
||||
truncated := false
|
||||
if len(chunks) > s.maxChunks {
|
||||
chunks = chunks[:s.maxChunks]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
system := s.context() + chunkPrompt
|
||||
parts := make([]string, 0, len(chunks))
|
||||
for i, c := range chunks {
|
||||
out, err := s.llm.Complete(ctx, system, c)
|
||||
if err != nil {
|
||||
return "", len(chunks), fmt.Errorf("chunk %d/%d: %w", i+1, len(chunks), err)
|
||||
}
|
||||
out = strings.TrimSpace(out)
|
||||
if out == "" || strings.EqualFold(out, emptyMarker) {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, out)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "", len(chunks), ErrNoSummary
|
||||
}
|
||||
|
||||
summary := parts[0]
|
||||
if len(parts) > 1 {
|
||||
joined := strings.Join(parts, "\n\n")
|
||||
reduced, err := s.llm.Complete(ctx, s.context()+reducePrompt, joined)
|
||||
if err != nil {
|
||||
// The per-chunk summaries are real work; hand them over rather than
|
||||
// losing them to a failure in the last step.
|
||||
return joined, len(chunks), fmt.Errorf("reduce: %w", err)
|
||||
}
|
||||
if r := strings.TrimSpace(reduced); r != "" {
|
||||
summary = r
|
||||
} else {
|
||||
summary = joined
|
||||
}
|
||||
}
|
||||
if label != "" {
|
||||
summary = label + "\n\n" + summary
|
||||
}
|
||||
if truncated {
|
||||
// Said in the note, not swallowed: a summary that silently covers the
|
||||
// first hour of a three-hour meeting is the failure mode this guards.
|
||||
summary += fmt.Sprintf("\n\n(расшифровка обрезана: обработано %d фрагментов из большего числа)", s.maxChunks)
|
||||
}
|
||||
return summary, len(chunks), nil
|
||||
}
|
||||
|
||||
// ChunkText splits text into windows of at most maxRunes runes, cutting on
|
||||
// sentence boundaries where it can and on a word boundary otherwise. Exported
|
||||
// because it is the part worth testing on its own and the part a future
|
||||
// transcript viewer will want.
|
||||
//
|
||||
// A sentence longer than maxRunes (a transcript with no punctuation at all,
|
||||
// which whisper does produce) is cut on whitespace rather than dropped or run
|
||||
// past the limit.
|
||||
func ChunkText(text string, maxRunes int) []string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
if maxRunes <= 0 {
|
||||
maxRunes = DefaultChunkRunes
|
||||
}
|
||||
if len([]rune(text)) <= maxRunes {
|
||||
return []string{text}
|
||||
}
|
||||
|
||||
var out []string
|
||||
var cur []rune
|
||||
flush := func() {
|
||||
if s := strings.TrimSpace(string(cur)); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
cur = cur[:0]
|
||||
}
|
||||
for _, sent := range splitSentences(text) {
|
||||
sr := []rune(sent)
|
||||
if len(sr) > maxRunes {
|
||||
// Oversized sentence: emit what is buffered, then cut this one on
|
||||
// word boundaries.
|
||||
flush()
|
||||
for _, piece := range splitWords(sr, maxRunes) {
|
||||
out = append(out, piece)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(cur)+len(sr) > maxRunes {
|
||||
flush()
|
||||
}
|
||||
cur = append(cur, sr...)
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
// splitSentences cuts on sentence-ending punctuation followed by a space,
|
||||
// keeping the punctuation with the sentence it ends. Good enough for a
|
||||
// transcript: whisper emits periods and question marks, and being wrong about an
|
||||
// abbreviation costs a slightly uneven chunk, nothing more.
|
||||
func splitSentences(text string) []string {
|
||||
runes := []rune(text)
|
||||
var out []string
|
||||
start := 0
|
||||
for i := 0; i < len(runes); i++ {
|
||||
if runes[i] != '.' && runes[i] != '!' && runes[i] != '?' && runes[i] != '\n' {
|
||||
continue
|
||||
}
|
||||
// Consume a run of punctuation ("?!", "...") so it stays together.
|
||||
j := i
|
||||
for j+1 < len(runes) && isSentenceEnd(runes[j+1]) {
|
||||
j++
|
||||
}
|
||||
if j+1 < len(runes) && !unicode.IsSpace(runes[j+1]) {
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
end := j + 1
|
||||
for end < len(runes) && unicode.IsSpace(runes[end]) {
|
||||
end++
|
||||
}
|
||||
out = append(out, string(runes[start:end]))
|
||||
start = end
|
||||
i = end - 1
|
||||
}
|
||||
if start < len(runes) {
|
||||
out = append(out, string(runes[start:]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isSentenceEnd(r rune) bool {
|
||||
return r == '.' || r == '!' || r == '?'
|
||||
}
|
||||
|
||||
// splitWords cuts an oversized run on whitespace, falling back to a hard cut
|
||||
// when a single "word" is itself longer than the limit.
|
||||
func splitWords(runes []rune, maxRunes int) []string {
|
||||
var out []string
|
||||
for len(runes) > maxRunes {
|
||||
cut := maxRunes
|
||||
for cut > 0 && !unicode.IsSpace(runes[cut]) {
|
||||
cut--
|
||||
}
|
||||
if cut == 0 {
|
||||
cut = maxRunes
|
||||
}
|
||||
if s := strings.TrimSpace(string(runes[:cut])); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
runes = runes[cut:]
|
||||
}
|
||||
if s := strings.TrimSpace(string(runes)); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNilSummarizerWithoutAModel(t *testing.T) {
|
||||
if s := NewSummarizer(nil, 0, 0, nil); s != nil {
|
||||
t.Fatal("a summarizer with no model is not nil")
|
||||
}
|
||||
var s *Summarizer
|
||||
if _, _, err := s.Summarize(context.Background(), "x", "текст"); !errors.Is(err, ErrDisabled) {
|
||||
t.Fatalf("got %v, want ErrDisabled", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The common case: a short meeting fits in one prompt, so there is exactly one
|
||||
// model call and no reduce step.
|
||||
func TestShortTranscriptSkipsTheReduceStep(t *testing.T) {
|
||||
f := &fakeCompleter{replies: []string{"— договорились о смете"}}
|
||||
s := NewSummarizer(f, 0, 0, nil)
|
||||
out, chunks, err := s.Summarize(context.Background(), "смета", "Обсудили смету. Решили подписать.")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if chunks != 1 {
|
||||
t.Errorf("chunks = %d, want 1", chunks)
|
||||
}
|
||||
if len(f.users) != 1 {
|
||||
t.Fatalf("%d model calls, want 1", len(f.users))
|
||||
}
|
||||
if !strings.Contains(out, "смете") || !strings.HasPrefix(out, "смета") {
|
||||
t.Errorf("summary = %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongTranscriptIsMappedThenReduced(t *testing.T) {
|
||||
f := &roleCompleter{mapReply: "часть", reduceReply: "общий итог"}
|
||||
s := NewSummarizer(f, 40, 0, nil)
|
||||
long := strings.Repeat("Говорили про насос и трубы. ", 12)
|
||||
out, chunks, err := s.Summarize(context.Background(), "", long)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if chunks < 2 {
|
||||
t.Fatalf("chunks = %d, want the transcript split", chunks)
|
||||
}
|
||||
// One map call per chunk, then exactly one reduce.
|
||||
if f.maps != chunks {
|
||||
t.Errorf("%d map calls for %d chunks", f.maps, chunks)
|
||||
}
|
||||
if f.reduces != 1 {
|
||||
t.Errorf("%d reduce calls, want 1", f.reduces)
|
||||
}
|
||||
if out != "общий итог" {
|
||||
t.Errorf("summary = %q, want the reduced text", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Losing every per-chunk summary because the last call failed would throw away
|
||||
// most of the work.
|
||||
func TestReduceFailureReturnsTheJoinedParts(t *testing.T) {
|
||||
f := &roleCompleter{mapReply: "часть", reduceFails: true}
|
||||
s := NewSummarizer(f, 40, 0, nil)
|
||||
long := strings.Repeat("Говорили про насос и трубы. ", 12)
|
||||
out, _, err := s.Summarize(context.Background(), "", long)
|
||||
if err == nil {
|
||||
t.Fatal("reduce failure was not reported")
|
||||
}
|
||||
if !strings.Contains(out, "часть1") || !strings.Contains(out, "часть2") {
|
||||
t.Errorf("per-chunk work was lost: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkFailureIsReported(t *testing.T) {
|
||||
f := &fakeCompleter{err: errors.New("llama is down")}
|
||||
s := NewSummarizer(f, 0, 0, nil)
|
||||
if _, _, err := s.Summarize(context.Background(), "", "текст"); err == nil {
|
||||
t.Fatal("chunk failure was not reported")
|
||||
}
|
||||
}
|
||||
|
||||
// "пусто" chunks are noise; they must not pad the reduce prompt, and a
|
||||
// transcript that is entirely empty chunks is an honest ErrNoSummary rather than
|
||||
// an invented summary.
|
||||
func TestEmptyChunksAreDropped(t *testing.T) {
|
||||
f := &roleCompleter{mapReply: "пусто", literalMap: true, reduceReply: "не должно вызываться"}
|
||||
s := NewSummarizer(f, 40, 0, nil)
|
||||
long := strings.Repeat("Тишина в комнате. ", 12)
|
||||
if _, _, err := s.Summarize(context.Background(), "", long); !errors.Is(err, ErrNoSummary) {
|
||||
t.Fatalf("got %v, want ErrNoSummary", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyTranscriptIsRefused(t *testing.T) {
|
||||
s := NewSummarizer(&fakeCompleter{}, 0, 0, nil)
|
||||
if _, _, err := s.Summarize(context.Background(), "", " \n "); !errors.Is(err, ErrEmptyCapture) {
|
||||
t.Fatalf("got %v, want ErrEmptyCapture", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A summary that silently covers the first fraction of a long meeting is the
|
||||
// failure mode; it has to say so.
|
||||
func TestTruncationIsStatedInTheSummary(t *testing.T) {
|
||||
f := &fakeCompleter{replies: []string{"a", "b", "итог"}}
|
||||
s := NewSummarizer(f, 30, 2, nil)
|
||||
long := strings.Repeat("Говорили про насос и про трубы. ", 20)
|
||||
out, chunks, err := s.Summarize(context.Background(), "", long)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if chunks != 2 {
|
||||
t.Errorf("chunks = %d, want the cap of 2", chunks)
|
||||
}
|
||||
if !strings.Contains(out, "обрезана") {
|
||||
t.Errorf("truncation not stated: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The persona block belongs to the daemon, not this package, and must reach the
|
||||
// model when it is supplied.
|
||||
func TestContextBlockIsPrependedToEveryPrompt(t *testing.T) {
|
||||
f := &fakeCompleter{replies: []string{"итог"}}
|
||||
s := NewSummarizer(f, 0, 0, func() string { return "ПЕРСОНА\n\n" })
|
||||
if _, _, err := s.Summarize(context.Background(), "", "Обсудили смету."); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, sys := range f.systems {
|
||||
if !strings.HasPrefix(sys, "ПЕРСОНА") {
|
||||
t.Errorf("call %d lost the context block: %q", i, sys)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The map/reduce prompts must contain no first person at all: the persona's
|
||||
// feminine forms live in the replier, and a first-person instruction here is a
|
||||
// place for the model to write "я рад".
|
||||
func TestPromptsHaveNoFirstPerson(t *testing.T) {
|
||||
for name, p := range map[string]string{"chunk": chunkPrompt, "reduce": reducePrompt} {
|
||||
for _, bad := range []string{" я ", "рад", "поняла", "мне ", "вы ", "ваш"} {
|
||||
if strings.Contains(strings.ToLower(" "+p+" "), bad) {
|
||||
t.Errorf("%s prompt contains %q", name, bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkTextSplitsOnSentenceBoundaries(t *testing.T) {
|
||||
text := "Раз два три. Четыре пять шесть. Семь восемь девять."
|
||||
got := ChunkText(text, 20)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d chunks: %q", len(got), got)
|
||||
}
|
||||
for _, c := range got {
|
||||
if !strings.HasSuffix(c, ".") {
|
||||
t.Errorf("chunk does not end on a sentence: %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkTextPacksSentencesUpToTheLimit(t *testing.T) {
|
||||
text := "Раз. Два. Три. Четыре."
|
||||
got := ChunkText(text, 12)
|
||||
if len(got) < 2 {
|
||||
t.Fatalf("nothing was split: %q", got)
|
||||
}
|
||||
for _, c := range got {
|
||||
if n := len([]rune(c)); n > 12 {
|
||||
t.Errorf("chunk of %d runes exceeds the limit: %q", n, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// whisper does emit long unpunctuated runs; those must be cut on whitespace, not
|
||||
// dropped and not run past the context limit.
|
||||
func TestChunkTextCutsUnpunctuatedRuns(t *testing.T) {
|
||||
text := strings.TrimSpace(strings.Repeat("слово ", 50))
|
||||
got := ChunkText(text, 30)
|
||||
if len(got) < 2 {
|
||||
t.Fatalf("unpunctuated run was not split: %d chunks", len(got))
|
||||
}
|
||||
total := 0
|
||||
for _, c := range got {
|
||||
if n := len([]rune(c)); n > 30 {
|
||||
t.Errorf("chunk of %d runes exceeds the limit", n)
|
||||
}
|
||||
total += strings.Count(c, "слово")
|
||||
}
|
||||
if total != 50 {
|
||||
t.Errorf("%d of 50 words survived chunking", total)
|
||||
}
|
||||
}
|
||||
|
||||
// A single token longer than the window must still come out, hard-cut.
|
||||
func TestChunkTextHandlesOneOversizedWord(t *testing.T) {
|
||||
text := strings.Repeat("я", 70)
|
||||
got := ChunkText(text, 20)
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("got %d chunks, want 4", len(got))
|
||||
}
|
||||
if joined := strings.Join(got, ""); len([]rune(joined)) != 70 {
|
||||
t.Errorf("%d runes survived, want 70", len([]rune(joined)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkTextShortInputAndEmpty(t *testing.T) {
|
||||
if got := ChunkText("коротко", 100); len(got) != 1 || got[0] != "коротко" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if got := ChunkText(" ", 100); got != nil {
|
||||
t.Errorf("blank text produced %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// roleCompleter answers by which prompt it was handed, so a test does not have
|
||||
// to predict how many chunks the text splits into. Map replies are numbered
|
||||
// ("часть1", "часть2", …) unless literalMap is set.
|
||||
type roleCompleter struct {
|
||||
mapReply string
|
||||
literalMap bool
|
||||
reduceReply string
|
||||
reduceFails bool
|
||||
maps int
|
||||
reduces int
|
||||
}
|
||||
|
||||
func (f *roleCompleter) Complete(_ context.Context, system, _ string) (string, error) {
|
||||
if strings.Contains(system, "конспекты фрагментов") {
|
||||
f.reduces++
|
||||
if f.reduceFails {
|
||||
return "", errors.New("llama fell over")
|
||||
}
|
||||
return f.reduceReply, nil
|
||||
}
|
||||
f.maps++
|
||||
if f.literalMap {
|
||||
return f.mapReply, nil
|
||||
}
|
||||
return fmt.Sprintf("%s%d", f.mapReply, f.maps), nil
|
||||
}
|
||||
@@ -18,11 +18,14 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/delivery/ntfysink"
|
||||
"github.com/kami/maven/internal/delivery/telegramsink"
|
||||
"github.com/kami/maven/internal/mcp"
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/kami/maven/internal/update"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
@@ -108,6 +111,16 @@ type Config struct {
|
||||
// calls its /v1/chat/completions endpoint to phrase nudges and reminders.
|
||||
Phraser *PhraserConfig `json:"phraser,omitempty"`
|
||||
|
||||
// Update — how THIS box deploys a new build of Maven (Vikunja #249). nil ⇒
|
||||
// the update capability does not exist, which is the state to leave it in
|
||||
// unless the operator has read internal/update's package comment.
|
||||
//
|
||||
// mavend never reads this block: the daemon does not import internal/update
|
||||
// and cannot update itself. It lives here because cmd/mavupdate — a CLI the
|
||||
// owner runs on the host, the only trigger there is — reads the same config
|
||||
// file to find the socket it health-checks.
|
||||
Update *update.Config `json:"update,omitempty"`
|
||||
|
||||
// Voice — the client↔core surface + the stt/tts modules the daemon
|
||||
// wires. nil ⇒ the daemon doesn't wire voice: the TCP listener stays
|
||||
// down, the dispatcher's Voice slot stays nil (the routing table's
|
||||
@@ -180,6 +193,142 @@ type Config struct {
|
||||
// discovers and executes capabilities through Hexis for ecosystem actions.
|
||||
// nil ⇒ no capability-aware routing.
|
||||
Hexis *HexisConfig `json:"hexis,omitempty"`
|
||||
|
||||
// Vision — image understanding (Vikunja #252). nil / absent ⇒ she cannot
|
||||
// look at pictures at all: the intake refuses, and no vision server is
|
||||
// contacted. See VisionConfig.
|
||||
Vision *VisionConfig `json:"vision,omitempty"`
|
||||
|
||||
// Media — where images and captured audio are kept on disk, and for how
|
||||
// long. nil / absent ⇒ no blob store is wired, which is what disables both
|
||||
// vision intake and meeting capture regardless of their own blocks: nothing
|
||||
// in this repo holds a recording only in memory. See MediaConfig.
|
||||
Media *MediaConfig `json:"media,omitempty"`
|
||||
|
||||
// Capture — meeting recording and summarisation (Vikunja #253). nil /
|
||||
// absent ⇒ the recorder does not exist: the start/stop methods are not
|
||||
// served at all, so nothing on this box can begin a recording. This is the
|
||||
// most invasive capability Maven has and it is the one most firmly off by
|
||||
// default. See CaptureConfig.
|
||||
Capture *CaptureConfig `json:"capture,omitempty"`
|
||||
|
||||
// Speaker — voice identification (Vikunja #255). nil / absent ⇒ no
|
||||
// voiceprint is ever computed and nobody can be enrolled. Enabling it needs
|
||||
// a speaker-embedding model, which is not on this box. See SpeakerConfig.
|
||||
Speaker *SpeakerConfig `json:"speaker,omitempty"`
|
||||
|
||||
// MCP — Model Context Protocol servers Maven connects OUT to (Vikunja
|
||||
// #251). nil / absent / no enabled server ⇒ no connection is made and no
|
||||
// tool is discovered, like every other capability that reaches outside the
|
||||
// box. She is a client here, never a server: nothing exposes her own
|
||||
// capabilities to an outside caller. See MCPConfig.
|
||||
MCP *MCPConfig `json:"mcp,omitempty"`
|
||||
}
|
||||
|
||||
// MCPConfig — the MCP client block. Servers are dark until one has
|
||||
// `"enabled": true`, and a discovered tool is only ever PROPOSED: Kami enables
|
||||
// it on /tools, on the authed surface, exactly as he would a shell tool. The
|
||||
// voice path can never grant a capability to itself.
|
||||
type MCPConfig struct {
|
||||
// Servers — the configured servers. Each needs exactly one of command
|
||||
// (a subprocess on this box) or url (a streamable-HTTP endpoint).
|
||||
Servers []MCPServerConfig `json:"servers,omitempty"`
|
||||
|
||||
// Timeout — per-call budget for every server that does not set its own.
|
||||
// 0 ⇒ mcp.DefaultTimeout (15s). A tool slower than this is not usable in a
|
||||
// spoken turn.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
|
||||
// AllowHosts / DenyHosts — the host lists for the shared webfetch door that
|
||||
// url servers go through. Deny wins. Private addresses are refused
|
||||
// unconditionally unless the individual server sets allow_private.
|
||||
AllowHosts []string `json:"allow_hosts,omitempty"`
|
||||
DenyHosts []string `json:"deny_hosts,omitempty"`
|
||||
|
||||
// MaxBytes — cap on one JSON-RPC response. 0 ⇒ webfetch.DefaultMaxBytes.
|
||||
MaxBytes int64 `json:"max_bytes,omitempty"`
|
||||
}
|
||||
|
||||
// MCPServerConfig — one MCP server.
|
||||
type MCPServerConfig struct {
|
||||
// Name — the local handle. It prefixes every tool this server contributes
|
||||
// ("vikunja" + "list_tasks" ⇒ the allowlist row "vikunja_list_tasks") and
|
||||
// becomes the store scope "mcp:<name>", so its provenance is readable on
|
||||
// /tools without opening the config.
|
||||
Name string `json:"name"`
|
||||
|
||||
// Command / Args / Env / Dir — a stdio server: a child process of mavend,
|
||||
// on this box, under this user. argv, never a shell string.
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env []string `json:"env,omitempty"`
|
||||
Dir string `json:"dir,omitempty"`
|
||||
|
||||
// URL — a streamable-HTTP endpoint. It is fetched through
|
||||
// internal/webfetch, so the SSRF guard, the redirect cap, the size cap and
|
||||
// the one-request-per-host-per-second limit all apply.
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
// AllowPrivate — let THIS server be a loopback or LAN address. The Vikunja
|
||||
// server on homesrv is "http://localhost:9100/mcp", which is refused
|
||||
// without this flag. Understand what it means before setting it: a local
|
||||
// server is a DIFFERENT trust level from a public one. It is inside the
|
||||
// network, it usually needs no credential, and it can change things that
|
||||
// matter — so an argument the router got wrong lands somewhere real. Set it
|
||||
// only for a server you run yourself, and prefer allow_tools with it.
|
||||
AllowPrivate bool `json:"allow_private,omitempty"`
|
||||
|
||||
// AllowTools — when set, the ONLY remote tool names taken from this server.
|
||||
// This is the knob that keeps the catalogue deliberate: the resident model
|
||||
// is a 1.7B with a 4096-token context, and a tool name it half-remembers is
|
||||
// a wrong act, so fewer and better-chosen beats complete.
|
||||
AllowTools []string `json:"allow_tools,omitempty"`
|
||||
|
||||
// MaxTools — cap on this server's contribution. 0 ⇒ mcp.DefaultMaxTools (12).
|
||||
MaxTools int `json:"max_tools,omitempty"`
|
||||
|
||||
// Timeout — per-call budget for this server. 0 ⇒ MCPConfig.Timeout.
|
||||
Timeout Duration `json:"timeout,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"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (c *Config) MCPServers() []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 {
|
||||
continue
|
||||
}
|
||||
timeout := time.Duration(s.Timeout)
|
||||
if timeout <= 0 {
|
||||
timeout = time.Duration(c.MCP.Timeout)
|
||||
}
|
||||
out = append(out, mcp.ServerConfig{
|
||||
Name: s.Name,
|
||||
Command: s.Command,
|
||||
Args: s.Args,
|
||||
Env: s.Env,
|
||||
Dir: s.Dir,
|
||||
URL: s.URL,
|
||||
AllowPrivate: s.AllowPrivate,
|
||||
AllowTools: s.AllowTools,
|
||||
MaxTools: s.MaxTools,
|
||||
Timeout: timeout,
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PraxisConfig — maven's connection to the Praxis attention service.
|
||||
@@ -349,6 +498,171 @@ type VoiceConfig struct {
|
||||
ToolTimeout Duration `json:"tool_timeout,omitempty"`
|
||||
}
|
||||
|
||||
// MediaConfig — the on-disk blob store for images and captured audio
|
||||
// (internal/media). It is shared by all three senses: vision intake, meeting
|
||||
// capture, and speaker enrolment samples all write here.
|
||||
//
|
||||
// Absent ⇒ off, and off means Maven cannot accept an image or start a recording
|
||||
// at all. That default is deliberate: a capability that keeps photos and audio of
|
||||
// people on disk should require someone to have typed a path.
|
||||
type MediaConfig struct {
|
||||
// Dir — the blob store root, created 0700. Relative paths resolve against
|
||||
// StateDir. Required; an empty dir means the store is not wired.
|
||||
Dir string `json:"dir,omitempty"`
|
||||
|
||||
// Retention — how long a blob is kept before the tick prunes it. 0 ⇒
|
||||
// media.DefaultRetention (7 days). This is the knob that stops recordings
|
||||
// of people accumulating; raising it past a few weeks should need a reason.
|
||||
Retention Duration `json:"retention,omitempty"`
|
||||
|
||||
// MaxBytes — per-blob cap. 0 ⇒ media.DefaultMaxBytes (64 MiB).
|
||||
MaxBytes int64 `json:"max_bytes,omitempty"`
|
||||
}
|
||||
|
||||
// StoreDir reports the configured blob directory, or "" when media is not
|
||||
// wired. Safe on a nil receiver.
|
||||
func (m *MediaConfig) StoreDir() string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m.Dir)
|
||||
}
|
||||
|
||||
// VisionConfig — the vision provider (internal/vision, docs/plans/07-vision.md).
|
||||
//
|
||||
// Absent, or enabled=false, ⇒ the daemon wires vision.Disabled and every attempt
|
||||
// to look at an image answers that vision is not set up. There is no cloud
|
||||
// option in this block on purpose: Endpoint must be a loopback or private
|
||||
// address and internal/vision refuses anything else at startup, because
|
||||
// inference stays on the box and a photo of his flat is the last thing to make
|
||||
// an exception for.
|
||||
type VisionConfig struct {
|
||||
// Enabled — may she look at images. Default false.
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
|
||||
// Endpoint — base URL of a llama-server running a vision model with its
|
||||
// mmproj, e.g. "http://127.0.0.1:8081". Loopback / private only.
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
|
||||
// Model — model name sent in the request. llama-server ignores it.
|
||||
Model string `json:"model,omitempty"`
|
||||
|
||||
// MaxDim — longest edge the image is scaled to before inference. 0 ⇒
|
||||
// media.DefaultMaxDim (896).
|
||||
MaxDim int `json:"max_dim,omitempty"`
|
||||
|
||||
// MaxTokens — cap on the description. 0 ⇒ vision.DefaultMaxTokens (300).
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
|
||||
// Timeout — per-description budget. 0 ⇒ vision.DefaultTimeout (90s). A small
|
||||
// VLM on an iGPU is slow; a tight timeout here just means no answer ever.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
|
||||
// Prompt — the default question when he only sent a picture. Empty ⇒
|
||||
// vision.DefaultPrompt (Russian, "опиши что на изображении").
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
}
|
||||
|
||||
// LooksAtImages reports whether vision is configured well enough to try. Safe on
|
||||
// a nil receiver, and false without an endpoint — enabled with nothing to talk
|
||||
// to is a misconfiguration, not a capability.
|
||||
func (v *VisionConfig) LooksAtImages() bool {
|
||||
return v != nil && v.Enabled && strings.TrimSpace(v.Endpoint) != ""
|
||||
}
|
||||
|
||||
// CaptureConfig — the meeting recorder (internal/capture,
|
||||
// docs/plans/08-hearing.md).
|
||||
//
|
||||
// Absent, or enabled=false, ⇒ the recorder is not wired and the capture methods
|
||||
// return "unknown method", so no client can start a recording however it asks.
|
||||
// A media block is required too: audio is never held only in memory.
|
||||
//
|
||||
// There is deliberately no "auto", no keyword trigger and no duration default
|
||||
// long enough to be forgotten about. Recording other people is an explicit act
|
||||
// with a start, a stop, and a cap.
|
||||
type CaptureConfig struct {
|
||||
// Enabled — may she record a meeting when asked. Default false.
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
|
||||
// MaxMinutes — hard cap on one session; it stops itself there. 0 ⇒
|
||||
// capture.DefaultMaxDuration (120 minutes).
|
||||
MaxMinutes int `json:"max_minutes,omitempty"`
|
||||
|
||||
// STTWindow — audio handed to whisper per call. 0 ⇒
|
||||
// capture.DefaultSTTWindow (5m). Larger windows transcribe slightly better
|
||||
// and block the STT worker for longer.
|
||||
STTWindow Duration `json:"stt_window,omitempty"`
|
||||
|
||||
// ChunkRunes — transcript runes per summarisation prompt. 0 ⇒
|
||||
// capture.DefaultChunkRunes (3000), sized for the resident model's n_ctx of
|
||||
// 4096. Raise this only if the resident model's context grows.
|
||||
ChunkRunes int `json:"chunk_runes,omitempty"`
|
||||
|
||||
// MaxChunks — how many windows one meeting may be summarised in before the
|
||||
// transcript is truncated and the summary says so. 0 ⇒
|
||||
// capture.DefaultMaxChunks (40).
|
||||
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.
|
||||
SaveTranscript bool `json:"save_transcript,omitempty"`
|
||||
}
|
||||
|
||||
// Records reports whether the recorder should be wired. Safe on a nil receiver.
|
||||
func (c *CaptureConfig) Records() bool {
|
||||
return c != nil && c.Enabled
|
||||
}
|
||||
|
||||
// MaxDuration is the configured session cap as a duration, or 0 for the
|
||||
// package default. Safe on a nil receiver.
|
||||
func (c *CaptureConfig) MaxDuration() time.Duration {
|
||||
if c == nil || c.MaxMinutes <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(c.MaxMinutes) * time.Minute
|
||||
}
|
||||
|
||||
// SpeakerConfig — voice identification (internal/speaker,
|
||||
// docs/plans/10-speaker-recognition.md).
|
||||
//
|
||||
// Absent, or enabled=false, ⇒ no voiceprint is computed for any turn, the
|
||||
// enrolment methods do not exist, and nobody can be enrolled. A voiceprint is
|
||||
// biometric data about a person, so this one is off until someone typed a model
|
||||
// path on purpose.
|
||||
//
|
||||
// It cannot currently be turned on: there is no speaker-embedding model on this
|
||||
// box. See the plan document for what to download.
|
||||
type SpeakerConfig struct {
|
||||
// Enabled — may she work out who is speaking. Default false.
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
|
||||
// ModelPath — an ECAPA-TDNN (or equivalent) speaker-embedding ONNX model.
|
||||
// Required; without it the recognizer runs disabled and says so once.
|
||||
ModelPath string `json:"model_path,omitempty"`
|
||||
|
||||
// LibPath — onnxruntime shared library, as for the text embedder. Empty ⇒
|
||||
// the same default the embedder block uses.
|
||||
LibPath string `json:"lib_path,omitempty"`
|
||||
|
||||
// Threshold — cosine similarity a match must beat. 0 ⇒
|
||||
// speaker.DefaultThreshold (0.7). Lower it and she starts calling guests by
|
||||
// his name, which is the expensive direction of this error.
|
||||
Threshold float64 `json:"threshold,omitempty"`
|
||||
|
||||
// MinSeconds — least speech an identification will look at. 0 ⇒
|
||||
// speaker.DefaultMinSeconds (2s).
|
||||
MinSeconds float64 `json:"min_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// Recognizes reports whether voice identification should be wired. Safe on a
|
||||
// nil receiver, and false without a model path — enabled with nothing to embed
|
||||
// with is a misconfiguration, not a capability.
|
||||
func (s *SpeakerConfig) Recognizes() bool {
|
||||
return s != nil && s.Enabled && strings.TrimSpace(s.ModelPath) != ""
|
||||
}
|
||||
|
||||
// WeatherConfig configures the weather provider for voice queries.
|
||||
type WeatherConfig struct {
|
||||
Provider string `json:"provider,omitempty"` // "open-meteo" or "" → stub
|
||||
@@ -579,6 +893,21 @@ type PhraserConfig struct {
|
||||
// persona and invented units). Chat, query and reminder phrasing always go
|
||||
// through the model regardless. See phraser.Config.LLMNudges.
|
||||
LLMNudges bool `json:"llm_nudges,omitempty"`
|
||||
|
||||
// SwapModels — the gguf files the running daemon is allowed to swap to
|
||||
// without a restart (Vikunja #250). Empty (the default) means the swap
|
||||
// capability does not exist: ipc.MethodSwapModel answers ErrUnknownMethod,
|
||||
// exactly like an unconfigured weather or telegram block.
|
||||
//
|
||||
// It is an allowlist and not a directory on purpose. The request carries a
|
||||
// path, and llama-server is started with it as `-m`; anything short of an
|
||||
// exact match against a list a human wrote in this file would make "swap the
|
||||
// model" mean "load a file of your choosing off my disk". ModelPath is
|
||||
// always swappable back to whether or not it is listed.
|
||||
//
|
||||
// Paths must be absolute — the daemon's working directory is not the
|
||||
// operator's, and a relative path here would resolve somewhere surprising.
|
||||
SwapModels []string `json:"swap_models,omitempty"`
|
||||
}
|
||||
|
||||
// EmbedderConfig — paths for the ONNX multilingual embedder. The daemon
|
||||
@@ -757,6 +1086,12 @@ 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 {
|
||||
c.MCP = nil
|
||||
}
|
||||
|
||||
// Same rule for the crawler: a block that neither answers on demand nor
|
||||
// watches anything has nothing to do, so it is normalised to "off".
|
||||
if c.Crawl != nil && !c.Crawl.OnDemand && len(c.Crawl.Watches) == 0 {
|
||||
@@ -820,6 +1155,22 @@ func (c *Config) validate() error {
|
||||
if c.Phraser.ModelPath == "" {
|
||||
return errors.New("phraser.model_path is required")
|
||||
}
|
||||
// A relative entry in the swap allowlist would resolve against the
|
||||
// daemon's working directory, so the path a human reads in this file
|
||||
// would not be the path llama-server is handed. Fail at startup.
|
||||
for _, m := range c.Phraser.SwapModels {
|
||||
if !filepath.IsAbs(m) {
|
||||
return fmt.Errorf("phraser.swap_models: %q must be an absolute path", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The update block is validated here even though mavend never acts on it: a
|
||||
// half-written update config that is only noticed by cmd/mavupdate is noticed
|
||||
// at the worst possible moment, halfway through deploying a new build.
|
||||
if c.Update != nil {
|
||||
if err := c.Update.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.Voice != nil && c.Voice.Enabled {
|
||||
if c.Voice.Bind == "" {
|
||||
@@ -845,6 +1196,12 @@ func (c *Config) validate() error {
|
||||
return fmt.Errorf("routine %q: bad cron %q: %w", r.Name, r.Cron, err)
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
if len(c.MorningRoutines) > 0 {
|
||||
if err := morning.Validate(morningRoutinesFromConfig(c.MorningRoutines)); err != nil {
|
||||
return err
|
||||
|
||||
@@ -293,3 +293,76 @@ func TestPatternProposalNotifyDefaultsOff(t *testing.T) {
|
||||
t.Errorf("cooldown = %v, want 6h", c.PatternProposals.Cooldown)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwapModelsAbsentMeansOff — the swap capability does not exist unless the
|
||||
// operator lists the models he allows (Vikunja #250).
|
||||
func TestSwapModelsAbsentMeansOff(t *testing.T) {
|
||||
c, err := Load(writeConfig(t, `{"phraser": {"model_path": "/m/qwen.gguf"}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if len(c.Phraser.SwapModels) != 0 {
|
||||
t.Errorf("swap_models = %v; want empty when unconfigured", c.Phraser.SwapModels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwapModelsParsedAndMustBeAbsolute(t *testing.T) {
|
||||
c, err := Load(writeConfig(t, `{"phraser": {
|
||||
"model_path": "/m/qwen.gguf",
|
||||
"swap_models": ["/m/qwen.gguf", "/m/qwen-cpt.gguf"]
|
||||
}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if len(c.Phraser.SwapModels) != 2 {
|
||||
t.Fatalf("swap_models = %v; want 2 entries", c.Phraser.SwapModels)
|
||||
}
|
||||
// A relative entry would resolve against the daemon's cwd, not the operator's.
|
||||
if _, err := Load(writeConfig(t, `{"phraser": {
|
||||
"model_path": "/m/qwen.gguf",
|
||||
"swap_models": ["models/llm/qwen.gguf"]
|
||||
}}`)); err == nil {
|
||||
t.Error("Load accepted a relative swap_models entry; want a startup failure")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateBlockAbsentMeansOff — mavend never updates itself; the block only
|
||||
// exists so cmd/mavupdate can find the deployment it is asked to update
|
||||
// (Vikunja #249). Absent is the normal state.
|
||||
func TestUpdateBlockAbsentMeansOff(t *testing.T) {
|
||||
c, err := Load(writeConfig(t, `{}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if c.Update != nil {
|
||||
t.Errorf("update = %+v; want nil when unconfigured", c.Update)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBlockValidatedAtStartup(t *testing.T) {
|
||||
good := `{"update": {
|
||||
"source_dir": "/srv/maven",
|
||||
"install_dir": "/srv/maven",
|
||||
"snapshot_dir": "/var/lib/maven/snapshots",
|
||||
"binaries": ["mavend", "mavweb"],
|
||||
"restart_cmd": ["docker", "compose", "up", "-d", "--build", "mavend"],
|
||||
"health_socket": "/run/maven/mavend.sock"
|
||||
}}`
|
||||
c, err := Load(writeConfig(t, good))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if c.Update == nil || len(c.Update.Binaries) != 2 {
|
||||
t.Fatalf("update block = %+v; want it parsed", c.Update)
|
||||
}
|
||||
// A block with no health check cannot detect its own failure, so it cannot
|
||||
// roll back — refused at load, not halfway through a deploy.
|
||||
noHealth := `{"update": {
|
||||
"source_dir": "/srv/maven", "install_dir": "/srv/maven",
|
||||
"snapshot_dir": "/var/lib/maven/snapshots",
|
||||
"binaries": ["mavend"], "restart_cmd": ["true"]
|
||||
}}`
|
||||
if _, err := Load(writeConfig(t, noHealth)); err == nil {
|
||||
t.Error("Load accepted an update block with no health_socket")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMCPAbsentIsOff(t *testing.T) {
|
||||
c, err := Load(writeConfig(t, `{}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.MCP != nil {
|
||||
t.Error("no mcp block ⇒ nil")
|
||||
}
|
||||
if got := c.MCPServers(); got != nil {
|
||||
t.Errorf("MCPServers() = %+v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A described-but-not-enabled server must not be wired. This is how a block can
|
||||
// sit in the config file, reviewed, before it is switched on.
|
||||
func TestMCPDisabledServerIsOff(t *testing.T) {
|
||||
c, err := Load(writeConfig(t, `{"mcp":{"servers":[
|
||||
{"name":"vikunja","url":"http://localhost:9100/mcp","allow_private":true}]}}`))
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPEnabledServerMapping(t *testing.T) {
|
||||
c, err := Load(writeConfig(t, `{"mcp":{
|
||||
"timeout":"5s",
|
||||
"servers":[
|
||||
{"name":"vikunja","url":"http://localhost:9100/mcp","allow_private":true,
|
||||
"allow_tools":["list_tasks"],"max_tools":3,"enabled":true},
|
||||
{"name":"files","command":"mcp-server-fs","args":["/srv"],"timeout":"1s","enabled":true},
|
||||
{"name":"off","command":"nope"}
|
||||
]}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := c.MCPServers()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("servers = %+v", got)
|
||||
}
|
||||
if got[0].Name != "vikunja" || !got[0].AllowPrivate || got[0].MaxTools != 3 ||
|
||||
len(got[0].AllowTools) != 1 || got[0].Timeout != 5*time.Second {
|
||||
t.Errorf("vikunja mapped wrong: %+v", got[0])
|
||||
}
|
||||
if got[1].Command != "mcp-server-fs" || len(got[1].Args) != 1 || got[1].Timeout != time.Second {
|
||||
t.Errorf("files mapped wrong: %+v", got[1])
|
||||
}
|
||||
// allow_private is per server and must not leak to the other one.
|
||||
if got[1].AllowPrivate {
|
||||
t.Error("allow_private leaked between servers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPBadServerFailsAtStartup(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"no name": `{"mcp":{"servers":[{"command":"x","enabled":true}]}}`,
|
||||
"both": `{"mcp":{"servers":[{"name":"a","command":"x","url":"http://a.test","enabled":true}]}}`,
|
||||
"neither": `{"mcp":{"servers":[{"name":"a","enabled":true}]}}`,
|
||||
"bad scheme": `{"mcp":{"servers":[{"name":"a","url":"unix:///run/x.sock","enabled":true}]}}`,
|
||||
"duplicate": `{"mcp":{"servers":[{"name":"a","command":"x","enabled":true},{"name":"a","command":"y","enabled":true}]}}`,
|
||||
"spacey name": `{"mcp":{"servers":[{"name":"a b","command":"x","enabled":true}]}}`,
|
||||
}
|
||||
for name, body := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := Load(writeConfig(t, body)); err == nil {
|
||||
t.Fatal("want a startup error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Absent blocks must read as off on a nil receiver: the daemon calls these
|
||||
// helpers before it knows whether the operator configured anything.
|
||||
func TestSensesOffByDefault(t *testing.T) {
|
||||
var cfg Config
|
||||
if cfg.Media.StoreDir() != "" {
|
||||
t.Error("media store dir is set with no media block")
|
||||
}
|
||||
if cfg.Vision.LooksAtImages() {
|
||||
t.Error("vision is on with no vision block")
|
||||
}
|
||||
if cfg.Capture.Records() {
|
||||
t.Error("the recorder is on with no capture block")
|
||||
}
|
||||
if cfg.Capture.MaxDuration() != 0 {
|
||||
t.Error("a nil capture block invented a duration")
|
||||
}
|
||||
if cfg.Speaker.Recognizes() {
|
||||
t.Error("speaker recognition is on with no speaker block")
|
||||
}
|
||||
}
|
||||
|
||||
// The recorder is the capability that most needs its default to be off, so it
|
||||
// gets its own test rather than a line in the one above.
|
||||
func TestCaptureIsOffUntilExplicitlyEnabled(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
c *CaptureConfig
|
||||
want bool
|
||||
}{
|
||||
{"absent", nil, false},
|
||||
{"present but not enabled", &CaptureConfig{MaxMinutes: 60}, false},
|
||||
{"enabled", &CaptureConfig{Enabled: true}, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := c.c.Records(); got != c.want {
|
||||
t.Errorf("%s: Records() = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureBlockParsesFromJSON(t *testing.T) {
|
||||
raw := `{"capture":{"enabled":true,"max_minutes":45,"stt_window":"2m",
|
||||
"chunk_runes":2000,"max_chunks":10,"save_transcript":true}}`
|
||||
var cfg Config
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if !cfg.Capture.Records() {
|
||||
t.Fatal("capture did not parse as enabled")
|
||||
}
|
||||
if cfg.Capture.MaxDuration() != 45*time.Minute {
|
||||
t.Errorf("max duration = %v", cfg.Capture.MaxDuration())
|
||||
}
|
||||
if time.Duration(cfg.Capture.STTWindow) != 2*time.Minute {
|
||||
t.Errorf("stt window = %v", time.Duration(cfg.Capture.STTWindow))
|
||||
}
|
||||
if cfg.Capture.ChunkRunes != 2000 || cfg.Capture.MaxChunks != 10 {
|
||||
t.Errorf("summariser limits = %+v", cfg.Capture)
|
||||
}
|
||||
if !cfg.Capture.SaveTranscript {
|
||||
t.Error("save_transcript did not parse")
|
||||
}
|
||||
}
|
||||
|
||||
// Keeping the verbatim record of what other people said is the heavier act, so
|
||||
// it is separately opt-in from recording at all.
|
||||
func TestTranscriptIsNotSavedByDefault(t *testing.T) {
|
||||
var cfg Config
|
||||
if err := json.Unmarshal([]byte(`{"capture":{"enabled":true}}`), &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Capture.SaveTranscript {
|
||||
t.Error("transcripts are saved without anyone asking")
|
||||
}
|
||||
if cfg.Capture.MaxDuration() != 0 {
|
||||
t.Error("max_minutes defaulted in config instead of in the package")
|
||||
}
|
||||
}
|
||||
|
||||
// enabled with nothing to talk to is a misconfiguration, not a capability.
|
||||
func TestVisionNeedsBothEnabledAndEndpoint(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
v *VisionConfig
|
||||
want bool
|
||||
}{
|
||||
{"absent", nil, false},
|
||||
{"endpoint but not enabled", &VisionConfig{Endpoint: "http://127.0.0.1:8081"}, false},
|
||||
{"enabled but no endpoint", &VisionConfig{Enabled: true}, false},
|
||||
{"enabled, blank endpoint", &VisionConfig{Enabled: true, Endpoint: " "}, false},
|
||||
{"both", &VisionConfig{Enabled: true, Endpoint: "http://127.0.0.1:8081"}, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := c.v.LooksAtImages(); got != c.want {
|
||||
t.Errorf("%s: LooksAtImages() = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSensesBlocksParseFromJSON(t *testing.T) {
|
||||
raw := `{
|
||||
"db_path": "/tmp/x.db",
|
||||
"socket_path": "/tmp/x.sock",
|
||||
"media": {"dir": "media", "retention": "48h", "max_bytes": 1048576},
|
||||
"vision": {
|
||||
"enabled": true,
|
||||
"endpoint": "http://127.0.0.1:8081",
|
||||
"model": "qwen2.5-vl",
|
||||
"max_dim": 640,
|
||||
"max_tokens": 200,
|
||||
"timeout": "45s",
|
||||
"prompt": "Что тут?"
|
||||
}
|
||||
}`
|
||||
var cfg Config
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if cfg.Media.StoreDir() != "media" {
|
||||
t.Errorf("media dir = %q", cfg.Media.StoreDir())
|
||||
}
|
||||
if time.Duration(cfg.Media.Retention) != 48*time.Hour {
|
||||
t.Errorf("retention = %v", time.Duration(cfg.Media.Retention))
|
||||
}
|
||||
if cfg.Media.MaxBytes != 1<<20 {
|
||||
t.Errorf("max_bytes = %d", cfg.Media.MaxBytes)
|
||||
}
|
||||
if !cfg.Vision.LooksAtImages() {
|
||||
t.Fatal("vision did not parse as enabled")
|
||||
}
|
||||
if cfg.Vision.MaxDim != 640 || cfg.Vision.MaxTokens != 200 {
|
||||
t.Errorf("vision limits = %+v", cfg.Vision)
|
||||
}
|
||||
if time.Duration(cfg.Vision.Timeout) != 45*time.Second {
|
||||
t.Errorf("vision timeout = %v", time.Duration(cfg.Vision.Timeout))
|
||||
}
|
||||
if cfg.Vision.Prompt != "Что тут?" {
|
||||
t.Errorf("prompt = %q", cfg.Vision.Prompt)
|
||||
}
|
||||
}
|
||||
|
||||
// A media dir set with no vision block is a valid state, and the useful one on a
|
||||
// box with no vision model: images can be kept, they just cannot be described.
|
||||
func TestMediaWithoutVisionIsValid(t *testing.T) {
|
||||
var cfg Config
|
||||
if err := json.Unmarshal([]byte(`{"media":{"dir":"/srv/media"}}`), &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Media.StoreDir() != "/srv/media" {
|
||||
t.Errorf("dir = %q", cfg.Media.StoreDir())
|
||||
}
|
||||
if cfg.Vision.LooksAtImages() {
|
||||
t.Error("vision came on by itself")
|
||||
}
|
||||
}
|
||||
|
||||
// A voiceprint is a biometric of a named person. Nothing about it turns on by
|
||||
// itself: no speaker block means no recognition, and no enrolment either.
|
||||
func TestSpeakerIsOffUntilExplicitlyEnabled(t *testing.T) {
|
||||
var cfg Config
|
||||
if err := json.Unmarshal([]byte(`{}`), &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Speaker.Recognizes() {
|
||||
t.Error("speaker recognition came on with no config at all")
|
||||
}
|
||||
var empty Config
|
||||
if err := json.Unmarshal([]byte(`{"speaker":{}}`), &empty); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if empty.Speaker.Recognizes() {
|
||||
t.Error("an empty speaker block enabled recognition")
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled alone is not enough: recognition needs a model, and on this box there
|
||||
// is none. Recognizes() must stay false so the daemon reports the honest state
|
||||
// instead of claiming a capability it cannot perform.
|
||||
func TestSpeakerNeedsBothEnabledAndAModel(t *testing.T) {
|
||||
var cfg Config
|
||||
if err := json.Unmarshal([]byte(`{"speaker":{"enabled":true}}`), &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Speaker.Recognizes() {
|
||||
t.Error("enabled with no model_path claimed to recognise")
|
||||
}
|
||||
var only Config
|
||||
if err := json.Unmarshal([]byte(`{"speaker":{"model_path":"/opt/x.onnx"}}`), &only); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if only.Speaker.Recognizes() {
|
||||
t.Error("a model_path alone enabled recognition")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakerBlockParsesFromJSON(t *testing.T) {
|
||||
const raw = `{"speaker":{"enabled":true,"model_path":"/opt/maven/models/spk/ecapa.onnx",` +
|
||||
`"lib_path":"/opt/maven/lib","threshold":0.62,"min_seconds":1.5}}`
|
||||
var cfg Config
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if !cfg.Speaker.Recognizes() {
|
||||
t.Fatal("speaker did not parse as enabled")
|
||||
}
|
||||
if cfg.Speaker.ModelPath != "/opt/maven/models/spk/ecapa.onnx" {
|
||||
t.Errorf("model_path = %q", cfg.Speaker.ModelPath)
|
||||
}
|
||||
if cfg.Speaker.LibPath != "/opt/maven/lib" {
|
||||
t.Errorf("lib_path = %q", cfg.Speaker.LibPath)
|
||||
}
|
||||
if cfg.Speaker.Threshold != 0.62 || cfg.Speaker.MinSeconds != 1.5 {
|
||||
t.Errorf("thresholds = %+v", cfg.Speaker)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
)
|
||||
|
||||
// DTOs — wire-level data. Decoupled from internal/store so the protocol is
|
||||
@@ -176,6 +178,218 @@ type IngestMailResp struct {
|
||||
Skipped bool `json:"skipped,omitempty"`
|
||||
}
|
||||
|
||||
// DescribeImageReq — one image handed to core to look at (Vikunja #252).
|
||||
//
|
||||
// Data is the raw image file as received (png / jpeg / gif). Core sniffs it and
|
||||
// refuses anything else; a declared content type is not part of this request
|
||||
// because the sender's claim about its own bytes is not evidence. Base64 on the
|
||||
// wire via the usual JSON marshal of []byte.
|
||||
//
|
||||
// Question is what he asked about the picture ("что тут написано?"). Empty ⇒
|
||||
// core uses its configured default prompt.
|
||||
//
|
||||
// Source is provenance recorded on the stored blob: "telegram", "web:upload".
|
||||
//
|
||||
// Exactly one of Data or ID is set. ID re-describes an image core already has —
|
||||
// a different question, or the first attempt that succeeds after a vision model
|
||||
// finally lands on disk.
|
||||
//
|
||||
// The method exists only when core has both a media store and an enabled vision
|
||||
// block; otherwise it answers ErrUnknownMethod, which is what "off unless
|
||||
// configured" looks like at the wire. A surface cannot make Maven look at
|
||||
// pictures by merely sending one.
|
||||
type DescribeImageReq struct {
|
||||
Data []byte `json:"data,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Question string `json:"question,omitempty"`
|
||||
// SaveNote — also write the description as a note (source
|
||||
// "media:image:<id-prefix>") so it is recallable later. Default false: a
|
||||
// glance at a screenshot is not automatically a memory.
|
||||
SaveNote bool `json:"save_note,omitempty"`
|
||||
}
|
||||
|
||||
// DescribeImageResp — what she saw. ID is the stored blob's content address, and
|
||||
// it is set even when Description is empty because the description failed: the
|
||||
// bytes are on disk and the same id can be retried. NoteID is non-zero only when
|
||||
// SaveNote was set and the write succeeded.
|
||||
//
|
||||
// The image itself is never echoed back.
|
||||
type DescribeImageResp struct {
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
NoteID int64 `json:"note_id,omitempty"`
|
||||
}
|
||||
|
||||
// CaptureStartReq — begin recording a meeting (Vikunja #253).
|
||||
//
|
||||
// Label is what the meeting is called ("встреча с подрядчиком"); it goes into
|
||||
// the summary note so the note is findable later. Empty is allowed.
|
||||
//
|
||||
// There is no "auto", no keyword and no schedule in this request, and there will
|
||||
// not be: the only way audio enters the recorder is a client that was told to
|
||||
// start, appending frames it was told to append. All four capture methods answer
|
||||
// ErrUnknownMethod unless the operator enabled a capture block, so a surface
|
||||
// cannot start a recording by asking nicely.
|
||||
type CaptureStartReq struct {
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
// CaptureStartResp — the session that opened. MaxSeconds is the hard cap after
|
||||
// which it stops itself; the caller tells him, so a forgotten recording is his
|
||||
// own informed choice rather than a surprise.
|
||||
type CaptureStartResp struct {
|
||||
Label string `json:"label,omitempty"`
|
||||
Started time.Time `json:"started"`
|
||||
MaxSeconds int `json:"max_seconds"`
|
||||
}
|
||||
|
||||
// CaptureAppendReq — one chunk of audio for the running session. Refused with
|
||||
// "nothing is being recorded" when no session is open, which is the guard that
|
||||
// makes an ambient path impossible: audio arriving at an idle core is dropped on
|
||||
// the floor, not buffered "just in case".
|
||||
type CaptureAppendReq struct {
|
||||
Audio audio.Audio `json:"audio"`
|
||||
}
|
||||
|
||||
// CaptureAppendResp — how much has been collected, so a client can show a timer
|
||||
// and notice the cap coming. Expired means the session hit its limit and closed;
|
||||
// stop sending and call capture_stop, the audio so far is kept.
|
||||
type CaptureAppendResp struct {
|
||||
Seconds float64 `json:"seconds"`
|
||||
Expired bool `json:"expired,omitempty"`
|
||||
}
|
||||
|
||||
// CaptureStopReq — end the running session.
|
||||
//
|
||||
// Discard throws the recording away without transcribing, storing or
|
||||
// summarising anything. This is what "забудь, не записывай" maps to, and it is a
|
||||
// flag rather than a separate method so the client that says "stop" and the
|
||||
// client that says "stop and forget" take the same path to the same session.
|
||||
type CaptureStopReq struct {
|
||||
Discard bool `json:"discard,omitempty"`
|
||||
}
|
||||
|
||||
// CaptureStopResp — the finished capture. BlobID is the stored WAV, kept under
|
||||
// media.retention like any other blob and pruned with it.
|
||||
//
|
||||
// A response with a Transcript and an empty Summary is a degraded success: the
|
||||
// words exist, only the model failed. A response with a BlobID and neither is
|
||||
// the audio surviving a transcription failure — the same id can be run again.
|
||||
// Discarded is true when nothing was kept.
|
||||
type CaptureStopResp struct {
|
||||
BlobID string `json:"blob_id,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Started time.Time `json:"started,omitempty"`
|
||||
Seconds float64 `json:"seconds,omitempty"`
|
||||
Transcript string `json:"transcript,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Chunks int `json:"chunks,omitempty"`
|
||||
NoteID int64 `json:"note_id,omitempty"`
|
||||
Discarded bool `json:"discarded,omitempty"`
|
||||
}
|
||||
|
||||
// CaptureStatusResp — what "что ты записываешь?" needs, and what /dash shows.
|
||||
// Running=false with everything else empty is the normal state.
|
||||
type CaptureStatusResp struct {
|
||||
Running bool `json:"running"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Started time.Time `json:"started,omitempty"`
|
||||
Seconds float64 `json:"seconds,omitempty"`
|
||||
Bytes int `json:"bytes,omitempty"`
|
||||
}
|
||||
|
||||
// EnrollSpeakerReq — register a voice (Vikunja #255).
|
||||
//
|
||||
// Samples are separate utterances recorded deliberately for this purpose, not
|
||||
// audio harvested from ordinary turns. internal/speaker requires several of
|
||||
// them totalling enough seconds, and refuses one long clip: a profile built
|
||||
// from a single sentence encodes that sentence as much as the person.
|
||||
//
|
||||
// There is no "enrol whoever just spoke" request shape, and that omission is
|
||||
// the point. Taking a biometric of a guest because they walked past the
|
||||
// microphone is not something a wire protocol should make easy.
|
||||
type EnrollSpeakerReq struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Samples []audio.Audio `json:"samples"`
|
||||
}
|
||||
|
||||
// Speaker — one enrolled voice as a surface sees it. The voiceprint itself is
|
||||
// never sent: a listing says who is enrolled, it does not hand out the
|
||||
// biometric.
|
||||
type Speaker struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Enrolled time.Time `json:"enrolled"`
|
||||
Samples int `json:"samples"`
|
||||
}
|
||||
|
||||
// EnrollSpeakerResp — the profile that was written.
|
||||
type EnrollSpeakerResp struct {
|
||||
Speaker Speaker `json:"speaker"`
|
||||
}
|
||||
|
||||
// ListSpeakersResp — who is enrolled, sorted by id. Enabled is false when no
|
||||
// embedding model is wired, which is this box's state: the profiles can be
|
||||
// listed and deleted, nothing can be recognised.
|
||||
type ListSpeakersResp struct {
|
||||
Speakers []Speaker `json:"speakers"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// ForgetSpeakerReq — delete one voiceprint. This is the request that must
|
||||
// always work; a biometric someone asked to be rid of has to actually go.
|
||||
type ForgetSpeakerReq struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// SwapModelReq — load another resident model without restarting the daemon
|
||||
// (Vikunja #250). ModelPath must be one of the paths in phraser.swap_models;
|
||||
// anything else is ErrForbidden, and an unconfigured allowlist makes the whole
|
||||
// method ErrUnknownMethod.
|
||||
//
|
||||
// NGpuLayers and NCtx are zero for "keep what is loaded now", which is the
|
||||
// normal case — the same laptop iGPU, a different gguf.
|
||||
//
|
||||
// This is an owner action. It is AuthStepUp in the authority table, it is not on
|
||||
// CoreAPI, and no act, intent or timer can reach it: swapping the model is not
|
||||
// something Maven does to herself.
|
||||
type SwapModelReq struct {
|
||||
ModelPath string `json:"model_path"`
|
||||
NGpuLayers int `json:"n_gpu_layers,omitempty"`
|
||||
NCtx int `json:"n_ctx,omitempty"`
|
||||
}
|
||||
|
||||
// SwapModelResp — what the daemon ended up serving. Model is the identity the
|
||||
// new llama-server reported for itself, not an echo of the request: if the file
|
||||
// was not the model the operator thought it was, this is where it shows.
|
||||
//
|
||||
// RolledBack is true when the requested model failed to load or would not answer
|
||||
// and the previous one was put back. In that case the call also returns an error
|
||||
// — the swap did not happen — and Model names the model still serving.
|
||||
type SwapModelResp struct {
|
||||
Model string `json:"model"`
|
||||
ModelPath string `json:"model_path"`
|
||||
BaseURL string `json:"base_url"`
|
||||
RolledBack bool `json:"rolled_back,omitempty"`
|
||||
TookMs int64 `json:"took_ms"`
|
||||
}
|
||||
|
||||
// ModelStatusResp — which model is resident and which ones may be swapped in.
|
||||
// Read-only; the authed page renders it. Swappable is the configured allowlist,
|
||||
// so an empty list means the capability is off.
|
||||
type ModelStatusResp struct {
|
||||
Model string `json:"model"`
|
||||
ModelPath string `json:"model_path"`
|
||||
BaseURL string `json:"base_url"`
|
||||
NGpuLayers int `json:"n_gpu_layers"`
|
||||
NCtx int `json:"n_ctx"`
|
||||
Swappable []string `json:"swappable,omitempty"`
|
||||
}
|
||||
|
||||
type listTasksReq struct {
|
||||
Status string `json:"status"` // "" all | "live" | candidate|open|done|dropped
|
||||
}
|
||||
@@ -275,6 +489,19 @@ type Tool struct {
|
||||
Updated time.Time `json:"updated"`
|
||||
}
|
||||
|
||||
// MCPServerStatus — one configured MCP server, as the web surface sees it.
|
||||
// Target is the command or url; Tools is how many tools discovery kept after
|
||||
// allow_tools / max_tools, not how many the server offers.
|
||||
type MCPServerStatus struct {
|
||||
Name string `json:"name"`
|
||||
Transport string `json:"transport"` // "stdio" (a local subprocess) or "http"
|
||||
Target string `json:"target"`
|
||||
Connected bool `json:"connected"`
|
||||
Server string `json:"server,omitempty"` // the server's own name + version
|
||||
Tools int `json:"tools"`
|
||||
Err string `json:"err,omitempty"`
|
||||
}
|
||||
|
||||
// chatReq / chatResp — text chat round-trip for the IPC Chat method.
|
||||
type chatReq struct {
|
||||
Text string `json:"text"`
|
||||
@@ -413,6 +640,14 @@ type CoreAPI interface {
|
||||
// TickTrace.
|
||||
MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error)
|
||||
|
||||
// MCPServers reports the configured MCP servers and their health
|
||||
// (Vikunja #251). Read-only introspection for /tools — there is no
|
||||
// "call this tool" method on purpose: an MCP tool runs through the same
|
||||
// allowlist, confirm turn and act path as any other tool, and a second
|
||||
// mutation path would be a second thing to get wrong. Empty when the
|
||||
// mcp config block is absent, which is the default.
|
||||
MCPServers(ctx context.Context) ([]MCPServerStatus, error)
|
||||
|
||||
// DayPlan returns today's ordered plan — calendar events, pending
|
||||
// reminders and any morning checklist still outstanding (see
|
||||
// internal/morning.BuildPlan) — plus the spoken RU rendering of it.
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
)
|
||||
|
||||
// The load-bearing default for the most invasive capability Maven has: on a core
|
||||
// that was never configured to record, there is no wire path that starts a
|
||||
// recording, feeds one, or harvests one. Every one of the four methods refuses.
|
||||
func TestCapture_OffUnlessConfigured(t *testing.T) {
|
||||
_, _, cli, _ := newServerWithStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := cli.CaptureStart(ctx, CaptureStartReq{Label: "встреча"}); !errors.Is(err, ErrUnknownMethod) {
|
||||
t.Errorf("CaptureStart error = %v, want ErrUnknownMethod", err)
|
||||
}
|
||||
if _, err := cli.CaptureAppend(ctx, CaptureAppendReq{}); !errors.Is(err, ErrUnknownMethod) {
|
||||
t.Errorf("CaptureAppend error = %v, want ErrUnknownMethod", err)
|
||||
}
|
||||
if _, err := cli.CaptureStop(ctx, CaptureStopReq{}); !errors.Is(err, ErrUnknownMethod) {
|
||||
t.Errorf("CaptureStop error = %v, want ErrUnknownMethod", err)
|
||||
}
|
||||
if _, err := cli.CaptureStatus(ctx); !errors.Is(err, ErrUnknownMethod) {
|
||||
t.Errorf("CaptureStatus error = %v, want ErrUnknownMethod", err)
|
||||
}
|
||||
}
|
||||
|
||||
// With the hooks wired, a whole session crosses the boundary intact: the label
|
||||
// out, the audio in, the summary back.
|
||||
func TestCapture_RoundTrip(t *testing.T) {
|
||||
_, srv, cli, _ := newServerWithStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
started := time.Now().UTC().Truncate(time.Second)
|
||||
var gotLabel string
|
||||
var gotBytes int
|
||||
var gotDiscard bool
|
||||
|
||||
srv.CaptureStartFn = func(_ context.Context, req CaptureStartReq) (CaptureStartResp, error) {
|
||||
gotLabel = req.Label
|
||||
return CaptureStartResp{Label: req.Label, Started: started, MaxSeconds: 7200}, nil
|
||||
}
|
||||
srv.CaptureAppendFn = func(_ context.Context, req CaptureAppendReq) (CaptureAppendResp, error) {
|
||||
gotBytes = len(req.Audio.Bytes)
|
||||
return CaptureAppendResp{Seconds: 1.5}, nil
|
||||
}
|
||||
srv.CaptureStopFn = func(_ context.Context, req CaptureStopReq) (CaptureStopResp, error) {
|
||||
gotDiscard = req.Discard
|
||||
return CaptureStopResp{BlobID: "abc", Summary: "— решили купить насос", Chunks: 1}, nil
|
||||
}
|
||||
srv.CaptureStatusFn = func(context.Context) (CaptureStatusResp, error) {
|
||||
return CaptureStatusResp{Running: true, Label: "встреча", Seconds: 1.5}, nil
|
||||
}
|
||||
|
||||
start, err := cli.CaptureStart(ctx, CaptureStartReq{Label: "встреча с подрядчиком"})
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureStart: %v", err)
|
||||
}
|
||||
if gotLabel != "встреча с подрядчиком" || start.MaxSeconds != 7200 {
|
||||
t.Errorf("start = %+v (label seen: %q)", start, gotLabel)
|
||||
}
|
||||
if !start.Started.Equal(started) {
|
||||
t.Errorf("started = %v, want %v", start.Started, started)
|
||||
}
|
||||
|
||||
// Audio must survive the JSON round trip byte for byte — a base64 mistake
|
||||
// here would be silence in the transcript, not a visible error.
|
||||
pcm := []byte{1, 2, 3, 4, 5, 6, 7, 8}
|
||||
ap, err := cli.CaptureAppend(ctx, CaptureAppendReq{
|
||||
Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: pcm},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureAppend: %v", err)
|
||||
}
|
||||
if gotBytes != len(pcm) {
|
||||
t.Errorf("%d bytes arrived, sent %d", gotBytes, len(pcm))
|
||||
}
|
||||
if ap.Seconds != 1.5 || ap.Expired {
|
||||
t.Errorf("append resp = %+v", ap)
|
||||
}
|
||||
|
||||
st, err := cli.CaptureStatus(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureStatus: %v", err)
|
||||
}
|
||||
if !st.Running || st.Label != "встреча" {
|
||||
t.Errorf("status = %+v", st)
|
||||
}
|
||||
|
||||
stop, err := cli.CaptureStop(ctx, CaptureStopReq{})
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureStop: %v", err)
|
||||
}
|
||||
if gotDiscard {
|
||||
t.Error("a plain stop arrived as a discard")
|
||||
}
|
||||
if stop.BlobID != "abc" || stop.Summary == "" {
|
||||
t.Errorf("stop = %+v", stop)
|
||||
}
|
||||
}
|
||||
|
||||
// "забудь, не записывай" has to reach core as a discard, not as an ordinary
|
||||
// stop that quietly keeps everything.
|
||||
func TestCapture_DiscardCrossesTheWire(t *testing.T) {
|
||||
_, srv, cli, _ := newServerWithStore(t)
|
||||
var gotDiscard bool
|
||||
srv.CaptureStopFn = func(_ context.Context, req CaptureStopReq) (CaptureStopResp, error) {
|
||||
gotDiscard = req.Discard
|
||||
return CaptureStopResp{Discarded: req.Discard}, nil
|
||||
}
|
||||
resp, err := cli.CaptureStop(context.Background(), CaptureStopReq{Discard: true})
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureStop: %v", err)
|
||||
}
|
||||
if !gotDiscard || !resp.Discarded {
|
||||
t.Errorf("discard lost: sent true, core saw %v, resp %+v", gotDiscard, resp)
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@ var readOnlyMethods = map[Method]bool{
|
||||
MethodListTasks: true,
|
||||
MethodTickTrace: true,
|
||||
MethodMorningStatus: true,
|
||||
MethodMCPServers: true,
|
||||
MethodDayPlan: true,
|
||||
}
|
||||
|
||||
@@ -459,6 +460,111 @@ func (c *Client) IngestMail(ctx context.Context, req IngestMailReq) (IngestMailR
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// DescribeImage hands one image to core to look at (Vikunja #252).
|
||||
// ErrUnknownMethod means core has no media store or vision is off — the caller
|
||||
// should stop asking, not retry. A response with an ID and an empty Description
|
||||
// means the bytes were stored but nothing could describe them yet, which is the
|
||||
// expected state on a box with no vision model on disk.
|
||||
func (c *Client) DescribeImage(ctx context.Context, req DescribeImageReq) (DescribeImageResp, error) {
|
||||
var r DescribeImageResp
|
||||
if err := c.call(ctx, MethodDescribeImage, req, &r); err != nil {
|
||||
return DescribeImageResp{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// CaptureStart begins recording a meeting (Vikunja #253). ErrUnknownMethod
|
||||
// means the operator has not enabled capture — the caller should say so and stop
|
||||
// asking, not retry.
|
||||
func (c *Client) CaptureStart(ctx context.Context, req CaptureStartReq) (CaptureStartResp, error) {
|
||||
var r CaptureStartResp
|
||||
if err := c.call(ctx, MethodCaptureStart, req, &r); err != nil {
|
||||
return CaptureStartResp{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// CaptureAppend hands one chunk of audio to the running session. An error means
|
||||
// the frame was not kept: either nothing is being recorded, or the session hit
|
||||
// its time limit. Either way the client stops sending.
|
||||
func (c *Client) CaptureAppend(ctx context.Context, req CaptureAppendReq) (CaptureAppendResp, error) {
|
||||
var r CaptureAppendResp
|
||||
if err := c.call(ctx, MethodCaptureAppend, req, &r); err != nil {
|
||||
return CaptureAppendResp{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// CaptureStop ends the session. Slow — it transcribes and summarises the whole
|
||||
// recording — so pass a context with room. Set Discard to throw the recording
|
||||
// away instead.
|
||||
func (c *Client) CaptureStop(ctx context.Context, req CaptureStopReq) (CaptureStopResp, error) {
|
||||
var r CaptureStopResp
|
||||
if err := c.call(ctx, MethodCaptureStop, req, &r); err != nil {
|
||||
return CaptureStopResp{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// CaptureStatus reports the running session, if any.
|
||||
func (c *Client) CaptureStatus(ctx context.Context) (CaptureStatusResp, error) {
|
||||
var r CaptureStatusResp
|
||||
if err := c.call(ctx, MethodCaptureStatus, nil, &r); err != nil {
|
||||
return CaptureStatusResp{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// EnrollSpeaker registers a voice from several deliberately recorded samples
|
||||
// (Vikunja #255). ErrUnknownMethod means no speaker block is configured, which
|
||||
// is the default: on an unconfigured box there is no way to take a voiceprint.
|
||||
func (c *Client) EnrollSpeaker(ctx context.Context, req EnrollSpeakerReq) (EnrollSpeakerResp, error) {
|
||||
var r EnrollSpeakerResp
|
||||
if err := c.call(ctx, MethodEnrollSpeaker, req, &r); err != nil {
|
||||
return EnrollSpeakerResp{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// ListSpeakers reports who is enrolled. The voiceprints themselves stay in
|
||||
// core. Enabled is false when profiles exist but no embedding model is wired,
|
||||
// so a surface can say "enrolled, not recognising" rather than implying Maven
|
||||
// knows who is talking.
|
||||
func (c *Client) ListSpeakers(ctx context.Context) (ListSpeakersResp, error) {
|
||||
var r ListSpeakersResp
|
||||
if err := c.call(ctx, MethodListSpeakers, nil, &r); err != nil {
|
||||
return ListSpeakersResp{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// ForgetSpeaker deletes one voiceprint.
|
||||
func (c *Client) ForgetSpeaker(ctx context.Context, id string) error {
|
||||
return c.call(ctx, MethodForgetSpeaker, ForgetSpeakerReq{ID: id}, nil)
|
||||
}
|
||||
|
||||
// SwapModel asks core to load another resident model (Vikunja #250).
|
||||
// ErrUnknownMethod means core has no phraser.swap_models allowlist configured;
|
||||
// ErrForbidden means the path is not on it, or step-up was not asserted. A
|
||||
// non-nil error with RolledBack set means nothing changed — the old model is
|
||||
// still serving.
|
||||
func (c *Client) SwapModel(ctx context.Context, req SwapModelReq) (SwapModelResp, error) {
|
||||
var r SwapModelResp
|
||||
if err := c.call(ctx, MethodSwapModel, req, &r); err != nil {
|
||||
return SwapModelResp{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// ModelStatus reports the resident model and the swap allowlist. Read-only.
|
||||
func (c *Client) ModelStatus(ctx context.Context) (ModelStatusResp, error) {
|
||||
var r ModelStatusResp
|
||||
if err := c.call(ctx, MethodModelStatus, nil, &r); err != nil {
|
||||
return ModelStatusResp{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (c *Client) DismissProposedRoutine(ctx context.Context, id int64) error {
|
||||
return c.call(ctx, MethodDismissProposedRoutine, dismissProposedRoutineReq{ID: id}, nil)
|
||||
}
|
||||
@@ -483,6 +589,14 @@ func (c *Client) TickTrace(ctx context.Context) (TickTrace, error) {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (c *Client) MCPServers(ctx context.Context) ([]MCPServerStatus, error) {
|
||||
var s []MCPServerStatus
|
||||
if err := c.call(ctx, MethodMCPServers, nil, &s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (c *Client) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
|
||||
var s []MorningRoutineStatus
|
||||
if err := c.call(ctx, MethodMorningStatus, nil, &s); err != nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
@@ -598,3 +599,44 @@ func TestIngestMail_Hook(t *testing.T) {
|
||||
t.Errorf("req across the wire = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwapModel_OffUnlessConfigured — no allowlist in the config means the
|
||||
// daemon never sets the hook, and the method does not exist. That is what "off
|
||||
// unless configured" looks like at the wire for the model swap (Vikunja #250).
|
||||
func TestSwapModel_OffUnlessConfigured(t *testing.T) {
|
||||
_, _, cli, _ := newServerWithStore(t)
|
||||
if _, err := cli.SwapModel(context.Background(), SwapModelReq{ModelPath: "/m/x.gguf"}); !errors.Is(err, ErrUnknownMethod) {
|
||||
t.Fatalf("SwapModel error = %v, want ErrUnknownMethod", err)
|
||||
}
|
||||
if _, err := cli.ModelStatus(context.Background()); !errors.Is(err, ErrUnknownMethod) {
|
||||
t.Fatalf("ModelStatus error = %v, want ErrUnknownMethod", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwapModel_Hook — the request crosses the boundary intact and the reported
|
||||
// identity comes back. A refusal from the daemon's allowlist arrives as
|
||||
// ErrForbidden, which is what a caller keys its error message off.
|
||||
func TestSwapModel_Hook(t *testing.T) {
|
||||
_, srv, cli, _ := newServerWithStore(t)
|
||||
var got SwapModelReq
|
||||
srv.SwapModelFn = func(_ context.Context, req SwapModelReq) (SwapModelResp, error) {
|
||||
got = req
|
||||
if req.ModelPath != "/m/allowed.gguf" {
|
||||
return SwapModelResp{}, fmt.Errorf("%w: not allowlisted", ErrForbidden)
|
||||
}
|
||||
return SwapModelResp{Model: "allowed", ModelPath: req.ModelPath, BaseURL: "http://127.0.0.1:9", TookMs: 12}, nil
|
||||
}
|
||||
resp, err := cli.SwapModel(context.Background(), SwapModelReq{ModelPath: "/m/allowed.gguf", NCtx: 4096})
|
||||
if err != nil {
|
||||
t.Fatalf("SwapModel: %v", err)
|
||||
}
|
||||
if resp.Model != "allowed" || resp.TookMs != 12 {
|
||||
t.Errorf("resp = %+v", resp)
|
||||
}
|
||||
if got.NCtx != 4096 {
|
||||
t.Errorf("req across the wire = %+v", got)
|
||||
}
|
||||
if _, err := cli.SwapModel(context.Background(), SwapModelReq{ModelPath: "/etc/shadow"}); !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("swap to a non-allowlisted path = %v; want ErrForbidden", err)
|
||||
}
|
||||
}
|
||||
|
||||
+212
-3
@@ -211,6 +211,10 @@ func (a *storeAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, e
|
||||
return nil, errors.New("store: morning status not available via direct store API")
|
||||
}
|
||||
|
||||
func (a *storeAPI) MCPServers(ctx context.Context) ([]MCPServerStatus, error) {
|
||||
return nil, nil // no manager behind a bare store: nothing configured
|
||||
}
|
||||
|
||||
func (a *storeAPI) DayPlan(ctx context.Context) (DayPlan, error) {
|
||||
return DayPlan{}, errors.New("store: day plan not available via direct store API")
|
||||
}
|
||||
@@ -432,6 +436,50 @@ type Server struct {
|
||||
// every CoreAPI implementation has to carry.
|
||||
IngestMailFn IngestMailFunc
|
||||
|
||||
// SwapModelFn / ModelStatusFn — the on-the-fly resident model swap (Vikunja
|
||||
// #250) and its read side. Set by the daemon only when phraser.swap_models
|
||||
// lists at least one model AND the phraser owns a llama-server; nil ⇒ both
|
||||
// methods answer ErrUnknownMethod, which is what "off unless configured"
|
||||
// looks like at the wire.
|
||||
//
|
||||
// They bypass CoreAPI for the same reason IngestMailFn does: this is not a
|
||||
// store operation, it needs the daemon's llama-server, and no other CoreAPI
|
||||
// implementation should have to carry it. MethodSwapModel is AuthStepUp in
|
||||
// internal/auth — owner-triggered, never an act and never a timer.
|
||||
SwapModelFn SwapModelFunc
|
||||
ModelStatusFn ModelStatusFunc
|
||||
|
||||
// DescribeImageFn — looks at one image (Vikunja #252). Set by the daemon only
|
||||
// when a media store is configured AND vision is enabled with a local
|
||||
// endpoint; nil ⇒ MethodDescribeImage answers ErrUnknownMethod, so a surface
|
||||
// cannot make Maven accept a photo by merely sending one.
|
||||
//
|
||||
// It bypasses CoreAPI for the same reason IngestMailFn does: it needs a blob
|
||||
// store and a vision server, neither of which is a store operation, and no
|
||||
// other CoreAPI implementation should have to carry it.
|
||||
DescribeImageFn DescribeImageFunc
|
||||
|
||||
// Capture* — the meeting recorder (Vikunja #253). Set by the daemon only
|
||||
// when a media store is configured AND capture.enabled is true; nil ⇒ all
|
||||
// four methods answer ErrUnknownMethod. That is the load-bearing default for
|
||||
// this capability: on an unconfigured box there is no wire path that begins a
|
||||
// recording, so nothing can be recorded by accident, by a bug in a surface,
|
||||
// or by a model deciding it would be helpful.
|
||||
//
|
||||
// They bypass CoreAPI because a recorder needs a blob store, an STT worker
|
||||
// and a llama-server, none of which is a store operation.
|
||||
CaptureStartFn CaptureStartFunc
|
||||
CaptureAppendFn CaptureAppendFunc
|
||||
CaptureStopFn CaptureStopFunc
|
||||
CaptureStatusFn CaptureStatusFunc
|
||||
|
||||
// Speaker* — voice identification (Vikunja #255). Set by the daemon only
|
||||
// when a speaker block is configured; nil ⇒ all three methods answer
|
||||
// ErrUnknownMethod, so on an unconfigured box no wire path enrols a voice.
|
||||
EnrollSpeakerFn EnrollSpeakerFunc
|
||||
ListSpeakersFn ListSpeakersFunc
|
||||
ForgetSpeakerFn ForgetSpeakerFunc
|
||||
|
||||
// UnlockFn — unwraps the store encryption key from the wrapped blob using
|
||||
// the passkey credential public key, opens the encrypted store, and wires
|
||||
// the rest of the daemon (voice, loop, delivery). Set by the daemon when
|
||||
@@ -450,9 +498,31 @@ type WrapKeyFunc func(ctx context.Context, publicKey []byte) error
|
||||
// public key and completes daemon initialization.
|
||||
type UnlockFunc func(ctx context.Context, publicKey []byte) error
|
||||
|
||||
// SwapModelFunc — loads another resident model in place of the live one.
|
||||
type SwapModelFunc func(ctx context.Context, req SwapModelReq) (SwapModelResp, error)
|
||||
|
||||
// ModelStatusFunc — reports the resident model and the swap allowlist.
|
||||
type ModelStatusFunc func(ctx context.Context) (ModelStatusResp, error)
|
||||
|
||||
// IngestMailFunc — core-side mail extraction. Returns what was captured.
|
||||
type IngestMailFunc func(ctx context.Context, req IngestMailReq) (IngestMailResp, error)
|
||||
|
||||
// DescribeImageFunc — core-side image intake + description.
|
||||
type DescribeImageFunc func(ctx context.Context, req DescribeImageReq) (DescribeImageResp, error)
|
||||
|
||||
// CaptureStartFunc / CaptureAppendFunc / CaptureStopFunc / CaptureStatusFunc —
|
||||
// the four core-side halves of the meeting recorder.
|
||||
type CaptureStartFunc func(ctx context.Context, req CaptureStartReq) (CaptureStartResp, error)
|
||||
type CaptureAppendFunc func(ctx context.Context, req CaptureAppendReq) (CaptureAppendResp, error)
|
||||
type CaptureStopFunc func(ctx context.Context, req CaptureStopReq) (CaptureStopResp, error)
|
||||
type CaptureStatusFunc func(ctx context.Context) (CaptureStatusResp, error)
|
||||
|
||||
// EnrollSpeakerFunc / ListSpeakersFunc / ForgetSpeakerFunc — the core-side
|
||||
// halves of voice enrolment.
|
||||
type EnrollSpeakerFunc func(ctx context.Context, req EnrollSpeakerReq) (EnrollSpeakerResp, error)
|
||||
type ListSpeakersFunc func(ctx context.Context) (ListSpeakersResp, error)
|
||||
type ForgetSpeakerFunc func(ctx context.Context, req ForgetSpeakerReq) error
|
||||
|
||||
// CheckFunc — the auth hook signature. Wired by the daemon (auth.Gate.Check
|
||||
// satisfies this); dispatch calls it once per request after param-unmarshal
|
||||
// independence (it gets the raw params, may unmarshal what it needs — ipc
|
||||
@@ -620,9 +690,11 @@ func withoutParams[R any](fn func(ctx context.Context, api CoreAPI) (R, error))
|
||||
// existed) as an argument — so SetAPI's runtime swap (the unlock transition)
|
||||
// is still honored on the very next request with no extra plumbing here.
|
||||
//
|
||||
// MethodAssertStepUp, MethodStoreEncryptionKey, MethodUnlock and
|
||||
// MethodIngestMail are NOT in this table: they bypass CoreAPI entirely
|
||||
// (s.StepUp / s.WrapKeyFn / s.UnlockFn / s.IngestMailFn), so dispatch
|
||||
// MethodAssertStepUp, MethodStoreEncryptionKey, MethodUnlock,
|
||||
// MethodIngestMail, MethodSwapModel, MethodModelStatus,
|
||||
// MethodDescribeImage and the four MethodCapture* methods are NOT in this
|
||||
// table: they bypass CoreAPI entirely (s.StepUp / s.WrapKeyFn / s.UnlockFn /
|
||||
// s.IngestMailFn / s.DescribeImageFn / s.Capture*Fn), so dispatch
|
||||
// special-cases them before consulting the table.
|
||||
var methodTable = map[Method]handlerFunc{
|
||||
MethodWriteFact: withParams(func(ctx context.Context, api CoreAPI, p WriteFactReq) (idResp, error) {
|
||||
@@ -812,6 +884,16 @@ var methodTable = map[Method]handlerFunc{
|
||||
MethodMorningStatus: withoutParams(func(ctx context.Context, api CoreAPI) ([]MorningRoutineStatus, error) {
|
||||
return api.MorningStatus(ctx)
|
||||
}),
|
||||
MethodMCPServers: withoutParams(func(ctx context.Context, api CoreAPI) ([]MCPServerStatus, error) {
|
||||
out, err := api.MCPServers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []MCPServerStatus{}
|
||||
}
|
||||
return out, nil
|
||||
}),
|
||||
}
|
||||
|
||||
// dispatch unmarshals params for req.Method and calls the matching CoreAPI
|
||||
@@ -874,6 +956,133 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
case MethodSwapModel:
|
||||
if s.SwapModelFn != nil {
|
||||
var p SwapModelReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.SwapModelFn(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
case MethodDescribeImage:
|
||||
if s.DescribeImageFn != nil {
|
||||
var p DescribeImageReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.DescribeImageFn(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
case MethodCaptureStart:
|
||||
if s.CaptureStartFn != nil {
|
||||
var p CaptureStartReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.CaptureStartFn(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
case MethodCaptureAppend:
|
||||
if s.CaptureAppendFn != nil {
|
||||
var p CaptureAppendReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.CaptureAppendFn(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
case MethodCaptureStop:
|
||||
if s.CaptureStopFn != nil {
|
||||
var p CaptureStopReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.CaptureStopFn(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
case MethodCaptureStatus:
|
||||
if s.CaptureStatusFn != nil {
|
||||
resp, err := s.CaptureStatusFn(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
case MethodEnrollSpeaker:
|
||||
if s.EnrollSpeakerFn != nil {
|
||||
var p EnrollSpeakerReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.EnrollSpeakerFn(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
case MethodListSpeakers:
|
||||
if s.ListSpeakersFn != nil {
|
||||
resp, err := s.ListSpeakersFn(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
case MethodForgetSpeaker:
|
||||
if s.ForgetSpeakerFn != nil {
|
||||
var p ForgetSpeakerReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.ForgetSpeakerFn(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(nil), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
case MethodModelStatus:
|
||||
if s.ModelStatusFn != nil {
|
||||
resp, err := s.ModelStatusFn(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
}
|
||||
|
||||
h, ok := methodTable[req.Method]
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
)
|
||||
|
||||
// The default that matters most for a biometric: on a core that was never
|
||||
// configured with a speaker block, there is no wire path that takes a
|
||||
// voiceprint, and none that lists the ones that might exist.
|
||||
func TestSpeaker_OffUnlessConfigured(t *testing.T) {
|
||||
_, _, cli, _ := newServerWithStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := cli.EnrollSpeaker(ctx, EnrollSpeakerReq{ID: "kami"}); !errors.Is(err, ErrUnknownMethod) {
|
||||
t.Errorf("EnrollSpeaker error = %v, want ErrUnknownMethod", err)
|
||||
}
|
||||
if _, err := cli.ListSpeakers(ctx); !errors.Is(err, ErrUnknownMethod) {
|
||||
t.Errorf("ListSpeakers error = %v, want ErrUnknownMethod", err)
|
||||
}
|
||||
if err := cli.ForgetSpeaker(ctx, "kami"); !errors.Is(err, ErrUnknownMethod) {
|
||||
t.Errorf("ForgetSpeaker error = %v, want ErrUnknownMethod", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Enrolment carries several samples across the boundary byte for byte — a
|
||||
// profile averaged over the wrong bytes is a profile of nobody.
|
||||
func TestSpeaker_EnrollCrossesTheWire(t *testing.T) {
|
||||
_, srv, cli, _ := newServerWithStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
enrolled := time.Now().UTC().Truncate(time.Second)
|
||||
var gotID, gotName string
|
||||
var gotSamples [][]byte
|
||||
|
||||
srv.EnrollSpeakerFn = func(_ context.Context, req EnrollSpeakerReq) (EnrollSpeakerResp, error) {
|
||||
gotID, gotName = req.ID, req.Name
|
||||
for _, s := range req.Samples {
|
||||
gotSamples = append(gotSamples, s.Bytes)
|
||||
}
|
||||
return EnrollSpeakerResp{Speaker: Speaker{
|
||||
ID: req.ID, Name: req.Name, Enrolled: enrolled, Samples: len(req.Samples),
|
||||
}}, nil
|
||||
}
|
||||
|
||||
mk := func(b byte, n int) audio.Audio {
|
||||
buf := make([]byte, n)
|
||||
for i := range buf {
|
||||
buf[i] = b
|
||||
}
|
||||
return audio.Audio{Format: audio.PCM16kMono, Bytes: buf}
|
||||
}
|
||||
samples := []audio.Audio{mk(1, 64), mk(2, 96), mk(3, 128)}
|
||||
|
||||
resp, err := cli.EnrollSpeaker(ctx, EnrollSpeakerReq{ID: "kami", Name: "Ками", Samples: samples})
|
||||
if err != nil {
|
||||
t.Fatalf("EnrollSpeaker: %v", err)
|
||||
}
|
||||
if gotID != "kami" || gotName != "Ками" {
|
||||
t.Errorf("server saw id=%q name=%q", gotID, gotName)
|
||||
}
|
||||
if len(gotSamples) != 3 {
|
||||
t.Fatalf("server saw %d samples, want 3", len(gotSamples))
|
||||
}
|
||||
for i, want := range samples {
|
||||
if string(gotSamples[i]) != string(want.Bytes) {
|
||||
t.Errorf("sample %d altered in transit", i)
|
||||
}
|
||||
}
|
||||
if resp.Speaker.Samples != 3 || !resp.Speaker.Enrolled.Equal(enrolled) {
|
||||
t.Errorf("profile came back wrong: %+v", resp.Speaker)
|
||||
}
|
||||
}
|
||||
|
||||
// A listing says who is enrolled and whether recognition actually works. On
|
||||
// this box the honest answer is "enrolled, not recognising", and the response
|
||||
// has to be able to say so — otherwise a surface implies Maven knows who is
|
||||
// talking when nothing on disk can tell.
|
||||
func TestSpeaker_ListReportsDisabledRecognition(t *testing.T) {
|
||||
_, srv, cli, _ := newServerWithStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
srv.ListSpeakersFn = func(context.Context) (ListSpeakersResp, error) {
|
||||
return ListSpeakersResp{
|
||||
Speakers: []Speaker{{ID: "kami", Name: "Ками", Samples: 3}},
|
||||
Enabled: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
resp, err := cli.ListSpeakers(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSpeakers: %v", err)
|
||||
}
|
||||
if len(resp.Speakers) != 1 || resp.Speakers[0].ID != "kami" {
|
||||
t.Fatalf("speakers = %+v", resp.Speakers)
|
||||
}
|
||||
if resp.Enabled {
|
||||
t.Error("Enabled = true; the seam must be able to report that nothing recognises")
|
||||
}
|
||||
}
|
||||
|
||||
// Deletion reaches core with the id intact and reports success. This is the
|
||||
// request that must always work.
|
||||
func TestSpeaker_ForgetReachesCore(t *testing.T) {
|
||||
_, srv, cli, _ := newServerWithStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
var forgot string
|
||||
srv.ForgetSpeakerFn = func(_ context.Context, req ForgetSpeakerReq) error {
|
||||
forgot = req.ID
|
||||
return nil
|
||||
}
|
||||
if err := cli.ForgetSpeaker(ctx, "гость"); err != nil {
|
||||
t.Fatalf("ForgetSpeaker: %v", err)
|
||||
}
|
||||
if forgot != "гость" {
|
||||
t.Errorf("core forgot %q, want %q", forgot, "гость")
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,9 @@ func (UnimplementedCoreAPI) TickTrace(ctx context.Context) (TickTrace, error) {
|
||||
func (UnimplementedCoreAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) MCPServers(ctx context.Context) ([]MCPServerStatus, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) DayPlan(ctx context.Context) (DayPlan, error) {
|
||||
return DayPlan{}, ErrNotImplemented
|
||||
}
|
||||
|
||||
@@ -45,12 +45,23 @@ const (
|
||||
MethodRevertFact Method = "revert_fact"
|
||||
MethodTickTrace Method = "tick_trace"
|
||||
MethodMorningStatus Method = "morning_status"
|
||||
MethodMCPServers Method = "mcp_servers"
|
||||
MethodDayPlan Method = "day_plan"
|
||||
MethodChat Method = "chat"
|
||||
MethodCaptureTask Method = "capture_task"
|
||||
MethodListTasks Method = "list_tasks"
|
||||
MethodSetTaskStatus Method = "set_task_status"
|
||||
MethodIngestMail Method = "ingest_mail"
|
||||
MethodSwapModel Method = "swap_model"
|
||||
MethodModelStatus Method = "model_status"
|
||||
MethodDescribeImage Method = "describe_image"
|
||||
MethodCaptureStart Method = "capture_start"
|
||||
MethodCaptureAppend Method = "capture_append"
|
||||
MethodCaptureStop Method = "capture_stop"
|
||||
MethodCaptureStatus Method = "capture_status"
|
||||
MethodEnrollSpeaker Method = "enroll_speaker"
|
||||
MethodListSpeakers Method = "list_speakers"
|
||||
MethodForgetSpeaker Method = "forget_speaker"
|
||||
)
|
||||
|
||||
// Request — one frame from module to core. Params is the JSON-encoded argument
|
||||
|
||||
+27
-1
@@ -10,10 +10,17 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
// mu guards base only. The base URL changes when the daemon swaps the
|
||||
// resident model (Vikunja #250): llama-server is relaunched on a fresh
|
||||
// port, and every holder of this client — the LLM router, the replier, the
|
||||
// mail extractor — must follow without being rebuilt. One mutexed field is
|
||||
// the whole mechanism; a swap re-points the client, it does not replace it.
|
||||
mu sync.RWMutex
|
||||
base string
|
||||
http *http.Client
|
||||
}
|
||||
@@ -22,6 +29,25 @@ func New(baseURL string, timeout time.Duration) *Client {
|
||||
return &Client{base: baseURL, http: &http.Client{Timeout: timeout}}
|
||||
}
|
||||
|
||||
// SetBaseURL re-points the client at another llama-server. Safe to call while
|
||||
// requests are in flight: a request that already read the old base finishes
|
||||
// against the old base (or fails, and every caller of Complete has a fallback),
|
||||
// and the next one uses the new base. It is deliberately NOT a queue-and-retry —
|
||||
// the phraser quiesces around a swap, so the window is small and a lost turn
|
||||
// degrades to the classifier rather than hanging.
|
||||
func (c *Client) SetBaseURL(base string) {
|
||||
c.mu.Lock()
|
||||
c.base = base
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// BaseURL is the server this client currently talks to.
|
||||
func (c *Client) BaseURL() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.base
|
||||
}
|
||||
|
||||
type Req struct {
|
||||
System string
|
||||
User string
|
||||
@@ -63,7 +89,7 @@ func (c *Client) Complete(ctx context.Context, r Req) (string, error) {
|
||||
RepeatPenalty: r.RepeatPenalty,
|
||||
Stop: r.Stop,
|
||||
})
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.base+"/v1/chat/completions", bytes.NewReader(b))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL()+"/v1/chat/completions", bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -57,3 +57,37 @@ func TestComplete(t *testing.T) {
|
||||
t.Errorf("got %q, want %q", got, "ok")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetBaseURL — a model swap re-points every holder of the client rather than
|
||||
// rebuilding the router, the replier and the extractors (Vikunja #250).
|
||||
func TestSetBaseURL(t *testing.T) {
|
||||
var hit string
|
||||
srvA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hit = "A"
|
||||
w.Write([]byte(`{"choices":[{"message":{"content":"a"}}]}`))
|
||||
}))
|
||||
defer srvA.Close()
|
||||
srvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hit = "B"
|
||||
w.Write([]byte(`{"choices":[{"message":{"content":"b"}}]}`))
|
||||
}))
|
||||
defer srvB.Close()
|
||||
|
||||
c := New(srvA.URL, 5*time.Second)
|
||||
if _, err := c.Complete(context.Background(), Req{User: "x"}); err != nil {
|
||||
t.Fatalf("Complete against A: %v", err)
|
||||
}
|
||||
if hit != "A" {
|
||||
t.Fatalf("first request went to %q; want A", hit)
|
||||
}
|
||||
c.SetBaseURL(srvB.URL)
|
||||
if got := c.BaseURL(); got != srvB.URL {
|
||||
t.Errorf("BaseURL = %q; want %q", got, srvB.URL)
|
||||
}
|
||||
if _, err := c.Complete(context.Background(), Req{User: "x"}); err != nil {
|
||||
t.Fatalf("Complete against B: %v", err)
|
||||
}
|
||||
if hit != "B" {
|
||||
t.Errorf("request after the swap went to %q; want B", hit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CmdPrefix is the reserved first argv element that marks an allowlist row as
|
||||
// an MCP call rather than a process. An MCP tool row looks like
|
||||
//
|
||||
// name: "vikunja_list_tasks" cmd: ["mcp", "vikunja", "list_tasks"]
|
||||
//
|
||||
// which is why there is no new column and no migration: the store, the /tools
|
||||
// page, ProposeTool, EnableTool, DisableTool, the act matcher and the confirm
|
||||
// turn all keep working unchanged. The executor is the only place that has to
|
||||
// know the difference, and it is one branch on Cmd[0].
|
||||
//
|
||||
// The rest of the allowlist discipline is inherited whole: a row that is not
|
||||
// status='enabled' does not run, and a row marked destructive does not run on
|
||||
// first hearing. Nothing here can enable itself — discovery only proposes.
|
||||
const CmdPrefix = "mcp"
|
||||
|
||||
// Cmd builds the argv encoding for a discovered tool.
|
||||
func Cmd(server, tool string) []string { return []string{CmdPrefix, server, tool} }
|
||||
|
||||
// ParseCmd recognises an MCP allowlist row. ok=false for an ordinary process
|
||||
// tool, which is what almost every row is.
|
||||
func ParseCmd(cmd []string) (server, tool string, ok bool) {
|
||||
if len(cmd) != 3 || cmd[0] != CmdPrefix {
|
||||
return "", "", false
|
||||
}
|
||||
if cmd[1] == "" || cmd[2] == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return cmd[1], cmd[2], true
|
||||
}
|
||||
|
||||
var notName = regexp.MustCompile(`[^a-z0-9_]+`)
|
||||
|
||||
// LocalName is the allowlist name for a discovered tool: the server handle, an
|
||||
// underscore, the remote name, lowercased and stripped of anything that is not
|
||||
// a word character. Namespacing by server is what keeps two servers that both
|
||||
// offer "search" from colliding, and what makes the provenance of a row on the
|
||||
// /tools page obvious without opening the diff.
|
||||
func LocalName(server, tool string) string {
|
||||
clean := func(s string) string {
|
||||
return strings.Trim(notName.ReplaceAllString(strings.ToLower(strings.TrimSpace(s)), "_"), "_")
|
||||
}
|
||||
s, t := clean(server), clean(tool)
|
||||
switch {
|
||||
case s == "":
|
||||
return t
|
||||
case t == "":
|
||||
return s
|
||||
}
|
||||
return s + "_" + t
|
||||
}
|
||||
|
||||
// Scope is the store scope for a server's rows, so the /tools page can group
|
||||
// them and a human can tell at a glance where a capability came from.
|
||||
func Scope(server string) string { return "mcp:" + server }
|
||||
@@ -0,0 +1,267 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Errors callers distinguish.
|
||||
var (
|
||||
// ErrClosed — the transport is gone (subprocess died, client closed).
|
||||
ErrClosed = errors.New("mcp: connection is closed")
|
||||
// ErrNotInitialized — a call was made before the initialize handshake.
|
||||
ErrNotInitialized = errors.New("mcp: not initialized")
|
||||
// ErrToolFailed — the server ran the tool and reported an error result.
|
||||
ErrToolFailed = errors.New("mcp: tool reported an error")
|
||||
)
|
||||
|
||||
// Tool is one tool a server offers, in the form Maven cares about.
|
||||
//
|
||||
// ReadOnly comes from the server's own readOnlyHint annotation and decides
|
||||
// whether the allowlist row is marked destructive: no hint, or a false one,
|
||||
// means "assume it mutates", which routes the call through the confirm turn.
|
||||
// Guessing wrong in that direction only costs a question.
|
||||
type Tool struct {
|
||||
Server string
|
||||
Name string
|
||||
Description string
|
||||
InputSchema json.RawMessage
|
||||
ReadOnly bool
|
||||
}
|
||||
|
||||
// Resource is one resource a server offers. Contents are fetched separately —
|
||||
// listing is cheap, reading is not.
|
||||
type Resource struct {
|
||||
Server string
|
||||
URI string
|
||||
Name string
|
||||
MIMEType string
|
||||
}
|
||||
|
||||
// ServerInfo is what came back from the handshake.
|
||||
type ServerInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
ProtocolVersion string `json:"-"`
|
||||
}
|
||||
|
||||
// Client is one connected MCP server. Safe for concurrent use.
|
||||
type Client struct {
|
||||
name string
|
||||
tr transport
|
||||
next atomic.Int64
|
||||
|
||||
mu sync.Mutex
|
||||
info ServerInfo
|
||||
ready bool
|
||||
}
|
||||
|
||||
// newClient wraps a transport. Callers use Dial* in manager.go.
|
||||
func newClient(name string, tr transport) *Client {
|
||||
return &Client{name: name, tr: tr}
|
||||
}
|
||||
|
||||
// Name — the local name of this server (the config key, not the server's own).
|
||||
func (c *Client) Name() string { return c.name }
|
||||
|
||||
// Info — what the server said about itself during the handshake.
|
||||
func (c *Client) Info() ServerInfo {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.info
|
||||
}
|
||||
|
||||
// Initialize performs the MCP handshake and sends notifications/initialized.
|
||||
// Capabilities we declare are empty on purpose: Maven consumes, she does not
|
||||
// offer sampling or roots back to the server.
|
||||
func (c *Client) Initialize(ctx context.Context) error {
|
||||
var out struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
ServerInfo ServerInfo `json:"serverInfo"`
|
||||
}
|
||||
err := c.call(ctx, "initialize", map[string]any{
|
||||
"protocolVersion": ProtocolVersion,
|
||||
"capabilities": map[string]any{},
|
||||
"clientInfo": map[string]any{"name": "maven", "version": "1.0"},
|
||||
}, &out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(out.ProtocolVersion) == "" {
|
||||
return fmt.Errorf("mcp: %s: handshake returned no protocol version", c.name)
|
||||
}
|
||||
out.ServerInfo.ProtocolVersion = out.ProtocolVersion
|
||||
c.mu.Lock()
|
||||
c.info, c.ready = out.ServerInfo, true
|
||||
c.mu.Unlock()
|
||||
// Best effort: a stateless HTTP server may not care, and a failure here is
|
||||
// not worth dropping a working connection over.
|
||||
_ = c.tr.Notify(ctx, "notifications/initialized", map[string]any{})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListTools discovers the server's tools.
|
||||
func (c *Client) ListTools(ctx context.Context) ([]Tool, error) {
|
||||
if !c.initialized() {
|
||||
return nil, ErrNotInitialized
|
||||
}
|
||||
var out struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema json.RawMessage `json:"inputSchema"`
|
||||
Annotations *struct {
|
||||
ReadOnlyHint bool `json:"readOnlyHint"`
|
||||
} `json:"annotations"`
|
||||
} `json:"tools"`
|
||||
}
|
||||
if err := c.call(ctx, "tools/list", map[string]any{}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tools := make([]Tool, 0, len(out.Tools))
|
||||
for _, t := range out.Tools {
|
||||
if strings.TrimSpace(t.Name) == "" {
|
||||
continue
|
||||
}
|
||||
tools = append(tools, Tool{
|
||||
Server: c.name,
|
||||
Name: t.Name,
|
||||
Description: strings.TrimSpace(t.Description),
|
||||
InputSchema: t.InputSchema,
|
||||
ReadOnly: t.Annotations != nil && t.Annotations.ReadOnlyHint,
|
||||
})
|
||||
}
|
||||
return tools, nil
|
||||
}
|
||||
|
||||
// CallTool runs one tool and returns its text content, joined by newlines.
|
||||
// Non-text content (images, blobs) is dropped: everything downstream of here
|
||||
// is a spoken or written sentence.
|
||||
//
|
||||
// args is exactly what the router produced. Nothing else — no history, no
|
||||
// notes, no persona — is in scope here, by construction.
|
||||
func (c *Client) CallTool(ctx context.Context, name string, args map[string]any) (string, error) {
|
||||
if !c.initialized() {
|
||||
return "", ErrNotInitialized
|
||||
}
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
var out struct {
|
||||
IsError bool `json:"isError"`
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
}
|
||||
if err := c.call(ctx, "tools/call", map[string]any{"name": name, "arguments": args}, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var parts []string
|
||||
for _, ct := range out.Content {
|
||||
if ct.Type == "text" && strings.TrimSpace(ct.Text) != "" {
|
||||
parts = append(parts, strings.TrimSpace(ct.Text))
|
||||
}
|
||||
}
|
||||
text := strings.Join(parts, "\n")
|
||||
if out.IsError {
|
||||
return text, fmt.Errorf("%w: %s/%s: %s", ErrToolFailed, c.name, name, text)
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// ListResources discovers the server's resources. A server without the
|
||||
// resources capability answers with an error; that is not fatal, the caller
|
||||
// gets an empty list.
|
||||
func (c *Client) ListResources(ctx context.Context) ([]Resource, error) {
|
||||
if !c.initialized() {
|
||||
return nil, ErrNotInitialized
|
||||
}
|
||||
var out struct {
|
||||
Resources []struct {
|
||||
URI string `json:"uri"`
|
||||
Name string `json:"name"`
|
||||
MIMEType string `json:"mimeType"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
if err := c.call(ctx, "resources/list", map[string]any{}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := make([]Resource, 0, len(out.Resources))
|
||||
for _, r := range out.Resources {
|
||||
if strings.TrimSpace(r.URI) == "" {
|
||||
continue
|
||||
}
|
||||
res = append(res, Resource{Server: c.name, URI: r.URI, Name: r.Name, MIMEType: r.MIMEType})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ReadResource returns a resource's text contents, joined by newlines. This is
|
||||
// the RAG-hint path: the text can be pasted into a router or phraser prompt.
|
||||
func (c *Client) ReadResource(ctx context.Context, uri string) (string, error) {
|
||||
if !c.initialized() {
|
||||
return "", ErrNotInitialized
|
||||
}
|
||||
var out struct {
|
||||
Contents []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"contents"`
|
||||
}
|
||||
if err := c.call(ctx, "resources/read", map[string]any{"uri": uri}, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var parts []string
|
||||
for _, ct := range out.Contents {
|
||||
if strings.TrimSpace(ct.Text) != "" {
|
||||
parts = append(parts, strings.TrimSpace(ct.Text))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n"), nil
|
||||
}
|
||||
|
||||
// Close drops the connection.
|
||||
func (c *Client) Close() error {
|
||||
c.mu.Lock()
|
||||
c.ready = false
|
||||
c.mu.Unlock()
|
||||
return c.tr.Close()
|
||||
}
|
||||
|
||||
func (c *Client) initialized() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.ready
|
||||
}
|
||||
|
||||
// alive reports whether the underlying transport can still carry a call. HTTP
|
||||
// is stateless, so it is always alive; a dead subprocess is not.
|
||||
func (c *Client) alive() bool {
|
||||
if s, ok := c.tr.(*stdioTransport); ok {
|
||||
return s.alive()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *Client) call(ctx context.Context, method string, params any, out any) error {
|
||||
req := &rpcRequest{JSONRPC: "2.0", ID: c.next.Add(1), Method: method, Params: params}
|
||||
resp, err := c.tr.Call(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp: %s: %s: %w", c.name, method, err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return fmt.Errorf("mcp: %s: %s: %w", c.name, method, resp.Error)
|
||||
}
|
||||
if out == nil || len(resp.Result) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(resp.Result, out); err != nil {
|
||||
return fmt.Errorf("mcp: %s: %s: decode result: %w", c.name, method, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Poster is the HTTP seam: internal/webfetch.Fetcher satisfies it. The
|
||||
// transport takes it as an interface so a test can serve a fake without a
|
||||
// listener, and so that the ONLY implementation wired in production is the
|
||||
// guarded fetcher — an MCP endpoint cannot get a bare http.Client this way.
|
||||
type Poster interface {
|
||||
Post(ctx context.Context, rawURL, contentType string, body []byte, hdr map[string]string) (*PostResponse, error)
|
||||
}
|
||||
|
||||
// PostResponse is the shape webfetch returns, restated here so this package
|
||||
// does not depend on it structurally.
|
||||
type PostResponse struct {
|
||||
Status int
|
||||
ContentType string
|
||||
Body []byte
|
||||
Header map[string]string
|
||||
}
|
||||
|
||||
// httpTransport speaks streamable HTTP: every request is a POST to one
|
||||
// endpoint, and the reply is either a JSON object or a text/event-stream frame
|
||||
// carrying one. Both are accepted — servers pick per response, and the two the
|
||||
// LAN runs disagree about which.
|
||||
type httpTransport struct {
|
||||
poster Poster
|
||||
url string
|
||||
|
||||
mu sync.Mutex
|
||||
session string // Mcp-Session-Id, echoed back when the server issues one
|
||||
}
|
||||
|
||||
func newHTTPTransport(post Poster, endpoint string) *httpTransport {
|
||||
return &httpTransport{poster: post, url: endpoint}
|
||||
}
|
||||
|
||||
func (t *httpTransport) Call(ctx context.Context, req *rpcRequest) (*rpcResponse, error) {
|
||||
body, err := t.send(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
frame, err := decodeFrame(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp rpcResponse
|
||||
if err := json.Unmarshal(frame, &resp); err != nil {
|
||||
return nil, fmt.Errorf("mcp: decode response: %w", err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (t *httpTransport) Notify(ctx context.Context, method string, params any) error {
|
||||
_, err := t.send(ctx, &rpcRequest{JSONRPC: "2.0", Method: method, Params: params})
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *httpTransport) send(ctx context.Context, req *rpcRequest) ([]byte, error) {
|
||||
req.JSONRPC = "2.0"
|
||||
raw, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hdr := map[string]string{"Accept": "application/json, text/event-stream"}
|
||||
t.mu.Lock()
|
||||
if t.session != "" {
|
||||
hdr["Mcp-Session-Id"] = t.session
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
resp, err := t.poster.Post(ctx, t.url, "application/json", raw, hdr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sid := headerGet(resp.Header, "Mcp-Session-Id"); sid != "" {
|
||||
t.mu.Lock()
|
||||
t.session = sid
|
||||
t.mu.Unlock()
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
func (t *httpTransport) Close() error {
|
||||
t.mu.Lock()
|
||||
t.session = ""
|
||||
t.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func headerGet(h map[string]string, key string) string {
|
||||
if h == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := h[key]; ok {
|
||||
return v
|
||||
}
|
||||
lower := strings.ToLower(key)
|
||||
for k, v := range h {
|
||||
if strings.ToLower(k) == lower {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// decodeFrame pulls the JSON object out of a body that is either raw JSON or
|
||||
// SSE. For SSE we take the LAST data: payload that parses, which is the
|
||||
// response — earlier frames on the stream are progress notifications.
|
||||
func decodeFrame(body []byte) ([]byte, error) {
|
||||
trimmed := bytes.TrimSpace(body)
|
||||
if len(trimmed) == 0 {
|
||||
return nil, errors.New("mcp: empty response body")
|
||||
}
|
||||
if trimmed[0] == '{' || trimmed[0] == '[' {
|
||||
return trimmed, nil
|
||||
}
|
||||
var last []byte
|
||||
sc := bufio.NewScanner(bytes.NewReader(trimmed))
|
||||
sc.Buffer(make([]byte, 0, 64<<10), maxLine)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if payload == "" {
|
||||
continue
|
||||
}
|
||||
var probe map[string]json.RawMessage
|
||||
if json.Unmarshal([]byte(payload), &probe) != nil {
|
||||
continue
|
||||
}
|
||||
if _, isResp := probe["id"]; isResp {
|
||||
last = []byte(payload)
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, fmt.Errorf("mcp: read event stream: %w", err)
|
||||
}
|
||||
if last == nil {
|
||||
return nil, errors.New("mcp: no JSON-RPC response in event stream")
|
||||
}
|
||||
return last, nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Package mcp is Maven's Model Context Protocol CLIENT. She is a host: she
|
||||
// connects OUT to MCP servers, discovers the tools and resources they offer,
|
||||
// and hands them to the parts of her that already exist for this — the tool
|
||||
// allowlist in the store, the confirm turn for anything that mutates, the
|
||||
// stage-3 gate that makes an uncertain act ask instead of run.
|
||||
//
|
||||
// She is not an MCP server. Nothing here exposes her own capabilities to an
|
||||
// outside caller; docs/plans/06-mcp-support.md asks for the host direction only.
|
||||
//
|
||||
// Boundaries, in code rather than in prose:
|
||||
//
|
||||
// - OFF unless configured. No mcp_servers block ⇒ no manager, no goroutine,
|
||||
// no socket.
|
||||
// - A remote server is reached through internal/webfetch, so the SSRF guard,
|
||||
// the size cap, the redirect cap and the per-host rate limit all apply to
|
||||
// an MCP endpoint exactly as they do to a news feed. Reaching a loopback
|
||||
// or LAN server means explicitly setting allow_private on THAT server —
|
||||
// a different trust level, spelled out per server rather than globally.
|
||||
// - Only the tool name and the arguments the router produced are sent. This
|
||||
// package never sees his notes, facts, history or the persona block, and
|
||||
// has no API through which a caller could pass them.
|
||||
// - Discovery proposes, it does not enable. A discovered tool lands as a
|
||||
// 'proposed' row; a human enables it on the authed surface.
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ProtocolVersion — the spec revision we ask for in the initialize handshake.
|
||||
// A server that answers with a different one is accepted (the spec says the
|
||||
// client may proceed if it can support what came back); we only refuse when it
|
||||
// answers with no version at all, which means it is not an MCP server.
|
||||
const ProtocolVersion = "2025-06-18"
|
||||
|
||||
// rpcRequest / rpcResponse — JSON-RPC 2.0. Deliberately hand-rolled: the wire
|
||||
// format is four fields, and the repo vendors its dependencies, so pulling a
|
||||
// library in for this would cost more than it saves.
|
||||
type rpcRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int64 `json:"id,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Params any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type rpcResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID *int64 `json:"id"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e *rpcError) Error() string { return fmt.Sprintf("mcp: rpc error %d: %s", e.Code, e.Message) }
|
||||
|
||||
// transport carries one JSON-RPC conversation. Implementations: stdioTransport
|
||||
// (a subprocess on this box) and httpTransport (streamable HTTP, guarded by
|
||||
// webfetch). Both must be safe for concurrent use by the Client.
|
||||
type transport interface {
|
||||
// Call sends a request and returns the matching response.
|
||||
Call(ctx context.Context, req *rpcRequest) (*rpcResponse, error)
|
||||
// Notify sends a notification (no id, no reply expected).
|
||||
Notify(ctx context.Context, method string, params any) error
|
||||
// Close releases the transport (kills the subprocess, drops the session).
|
||||
Close() error
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Defaults for a server block. Small numbers on purpose — see MaxTools.
|
||||
const (
|
||||
// DefaultTimeout bounds one JSON-RPC call. A tool that takes longer than
|
||||
// this is not usable in a spoken turn anyway.
|
||||
DefaultTimeout = 15 * time.Second
|
||||
// DefaultMaxTools caps how many tools ONE server may contribute. The
|
||||
// resident model is a 1.7B with a 4096-token context: a catalogue of forty
|
||||
// tool names does not fit in its head, and a name it half-remembers is a
|
||||
// wrong act. Twelve per server is already generous.
|
||||
DefaultMaxTools = 12
|
||||
// DefaultReconnectEvery is how long the manager waits before re-dialing a
|
||||
// server whose connection died.
|
||||
DefaultReconnectEvery = 30 * time.Second
|
||||
)
|
||||
|
||||
// ErrNoServer — the named server is not configured or not connected.
|
||||
var ErrNoServer = errors.New("mcp: no such server")
|
||||
|
||||
// ServerConfig is one configured MCP server. Off unless present.
|
||||
//
|
||||
// Exactly one of Command (a subprocess on this box) or URL (a remote or
|
||||
// loopback HTTP endpoint) must be set.
|
||||
type ServerConfig struct {
|
||||
// Name is the local handle. It prefixes every tool this server
|
||||
// contributes, so it must be short and a valid identifier-ish word.
|
||||
Name string `json:"name"`
|
||||
// Command + Args + Env + Dir describe a stdio server: a child process of
|
||||
// mavend, on this box, under this user. argv, never a shell string.
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env []string `json:"env,omitempty"`
|
||||
Dir string `json:"dir,omitempty"`
|
||||
// URL is a streamable-HTTP endpoint. It goes through internal/webfetch, so
|
||||
// it inherits the SSRF guard, the size cap and the per-host rate limit.
|
||||
URL string `json:"url,omitempty"`
|
||||
// AllowPrivate lets THIS server be a loopback or LAN address
|
||||
// (http://localhost:9100/mcp is the Vikunja server on homesrv). It is a
|
||||
// per-server hole in the private-address guard and it is not the same trust
|
||||
// level as a public endpoint: whatever is behind it is inside the network,
|
||||
// so an argument the router got wrong reaches something that matters. Set
|
||||
// it only for a server you run.
|
||||
AllowPrivate bool `json:"allow_private,omitempty"`
|
||||
// AllowTools, when non-empty, is the ONLY set of remote tool names taken
|
||||
// from this server. This is the knob for keeping the catalogue small and
|
||||
// deliberate rather than "whatever the server grew this week".
|
||||
AllowTools []string `json:"allow_tools,omitempty"`
|
||||
// MaxTools caps the contribution (0 ⇒ DefaultMaxTools).
|
||||
MaxTools int `json:"max_tools,omitempty"`
|
||||
// Timeout bounds one call (0 ⇒ DefaultTimeout).
|
||||
Timeout time.Duration `json:"-"`
|
||||
// Enabled=false keeps a configured server described but dark.
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// PosterFactory builds the HTTP door for one server. It is a factory rather
|
||||
// than a single shared Poster because allow_private is per server: the fetcher
|
||||
// that may reach http://localhost:9100/mcp must NOT be the same fetcher another
|
||||
// server's public URL goes through, or one loopback exemption would quietly
|
||||
// unlock the LAN for all of them.
|
||||
type PosterFactory func(cfg ServerConfig) (Poster, error)
|
||||
|
||||
// Manager owns the connections. Nothing here starts unless at least one server
|
||||
// is configured and enabled.
|
||||
type Manager struct {
|
||||
newPoster PosterFactory
|
||||
mu sync.Mutex
|
||||
conns map[string]*conn
|
||||
order []string
|
||||
}
|
||||
|
||||
type conn struct {
|
||||
cfg ServerConfig
|
||||
client *Client
|
||||
tools []Tool
|
||||
lastErr error
|
||||
lastTry time.Time
|
||||
dialedAt time.Time
|
||||
}
|
||||
|
||||
// NewManager builds a manager for the enabled servers in cfgs. newPoster is
|
||||
// the guarded HTTP door factory for url servers; pass nil only when no url
|
||||
// server is configured (a nil factory with a url server is reported per server
|
||||
// at dial time rather than fatally, so one bad block never stops the daemon).
|
||||
//
|
||||
// Dialing is lazy: NewManager validates and records, Connect dials.
|
||||
func NewManager(newPoster PosterFactory, cfgs []ServerConfig) (*Manager, error) {
|
||||
m := &Manager{newPoster: newPoster, conns: map[string]*conn{}}
|
||||
for _, c := range cfgs {
|
||||
if !c.Enabled {
|
||||
continue
|
||||
}
|
||||
if err := validate(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, dup := m.conns[c.Name]; dup {
|
||||
return nil, fmt.Errorf("mcp: duplicate server name %q", c.Name)
|
||||
}
|
||||
if c.Timeout <= 0 {
|
||||
c.Timeout = DefaultTimeout
|
||||
}
|
||||
if c.MaxTools <= 0 {
|
||||
c.MaxTools = DefaultMaxTools
|
||||
}
|
||||
m.conns[c.Name] = &conn{cfg: c}
|
||||
m.order = append(m.order, c.Name)
|
||||
}
|
||||
sort.Strings(m.order)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Validate checks a set of server blocks without dialling anything, so a typo
|
||||
// fails at startup rather than at the first turn that needed the tool.
|
||||
func Validate(cfgs []ServerConfig) error {
|
||||
seen := map[string]bool{}
|
||||
for _, c := range cfgs {
|
||||
if err := validate(c); err != nil {
|
||||
return err
|
||||
}
|
||||
if seen[c.Name] {
|
||||
return fmt.Errorf("mcp: duplicate server name %q", c.Name)
|
||||
}
|
||||
seen[c.Name] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validate(c ServerConfig) error {
|
||||
if strings.TrimSpace(c.Name) == "" {
|
||||
return errors.New("mcp: server needs a name")
|
||||
}
|
||||
if strings.ContainsAny(c.Name, " \t/:") {
|
||||
return fmt.Errorf("mcp: server name %q must be one word without spaces, slashes or colons", c.Name)
|
||||
}
|
||||
hasCmd, hasURL := c.Command != "", c.URL != ""
|
||||
if hasCmd == hasURL {
|
||||
return fmt.Errorf("mcp: server %q needs exactly one of command or url", c.Name)
|
||||
}
|
||||
if hasURL && !strings.HasPrefix(c.URL, "http://") && !strings.HasPrefix(c.URL, "https://") {
|
||||
return fmt.Errorf("mcp: server %q url must be http or https", c.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Servers — the configured, enabled server names, sorted.
|
||||
func (m *Manager) Servers() []string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return append([]string(nil), m.order...)
|
||||
}
|
||||
|
||||
// Empty reports whether nothing is configured. The daemon uses it to skip
|
||||
// wiring entirely.
|
||||
func (m *Manager) Empty() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return len(m.conns) == 0
|
||||
}
|
||||
|
||||
// Connect dials every configured server, handshakes, and discovers tools.
|
||||
// A server that fails is recorded and retried later by Refresh — one bad
|
||||
// server never blocks the others, and never blocks boot.
|
||||
func (m *Manager) Connect(ctx context.Context) {
|
||||
for _, name := range m.Servers() {
|
||||
if err := m.dial(ctx, name); err != nil {
|
||||
log.Printf("mcp: %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) dial(ctx context.Context, name string) error {
|
||||
m.mu.Lock()
|
||||
c, ok := m.conns[name]
|
||||
if !ok {
|
||||
m.mu.Unlock()
|
||||
return ErrNoServer
|
||||
}
|
||||
cfg := c.cfg
|
||||
c.lastTry = time.Now()
|
||||
m.mu.Unlock()
|
||||
|
||||
var tr transport
|
||||
var err error
|
||||
if cfg.Command != "" {
|
||||
tr, err = newStdioTransport(ctx, append([]string{cfg.Command}, cfg.Args...), cfg.Env, cfg.Dir)
|
||||
} else if m.newPoster == nil {
|
||||
err = fmt.Errorf("server %q has a url but no http door was wired", name)
|
||||
} else {
|
||||
var poster Poster
|
||||
if poster, err = m.newPoster(cfg); err == nil {
|
||||
tr = newHTTPTransport(poster, cfg.URL)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
m.fail(name, err)
|
||||
return err
|
||||
}
|
||||
|
||||
cl := newClient(name, tr)
|
||||
ictx, cancel := context.WithTimeout(ctx, cfg.Timeout)
|
||||
defer cancel()
|
||||
if err := cl.Initialize(ictx); err != nil {
|
||||
_ = cl.Close()
|
||||
m.fail(name, err)
|
||||
return err
|
||||
}
|
||||
tools, err := cl.ListTools(ictx)
|
||||
if err != nil {
|
||||
// A server with no tools capability is still a usable resource server.
|
||||
log.Printf("mcp: %s: list tools: %v", name, err)
|
||||
tools = nil
|
||||
}
|
||||
tools = filterTools(cfg, tools)
|
||||
|
||||
m.mu.Lock()
|
||||
if old := m.conns[name].client; old != nil {
|
||||
_ = old.Close()
|
||||
}
|
||||
m.conns[name].client = cl
|
||||
m.conns[name].tools = tools
|
||||
m.conns[name].lastErr = nil
|
||||
m.conns[name].dialedAt = time.Now()
|
||||
m.mu.Unlock()
|
||||
log.Printf("mcp: %s connected (%s %s), %d tool(s)", name, cl.Info().Name, cl.Info().Version, len(tools))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) fail(name string, err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if c := m.conns[name]; c != nil {
|
||||
c.lastErr = err
|
||||
c.client = nil
|
||||
c.tools = nil
|
||||
}
|
||||
}
|
||||
|
||||
// filterTools applies AllowTools and MaxTools, and drops nameless entries.
|
||||
// Sorted first, so the cap is deterministic rather than "whatever order the
|
||||
// server felt like".
|
||||
func filterTools(cfg ServerConfig, in []Tool) []Tool {
|
||||
sort.Slice(in, func(i, j int) bool { return in[i].Name < in[j].Name })
|
||||
out := make([]Tool, 0, len(in))
|
||||
for _, t := range in {
|
||||
if len(cfg.AllowTools) > 0 && !contains(cfg.AllowTools, t.Name) {
|
||||
continue
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
if cfg.MaxTools > 0 && len(out) > cfg.MaxTools {
|
||||
log.Printf("mcp: %s offers %d tools, taking the first %d (raise max_tools or set allow_tools)",
|
||||
cfg.Name, len(out), cfg.MaxTools)
|
||||
out = out[:cfg.MaxTools]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func contains(hay []string, needle string) bool {
|
||||
for _, h := range hay {
|
||||
if h == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Refresh re-dials any server that is down, if enough time has passed since the
|
||||
// last attempt. Call it from the daemon's periodic tick — it is cheap when
|
||||
// everything is up.
|
||||
func (m *Manager) Refresh(ctx context.Context) {
|
||||
now := time.Now()
|
||||
var stale []string
|
||||
m.mu.Lock()
|
||||
for _, name := range m.order {
|
||||
c := m.conns[name]
|
||||
down := c.client == nil || !c.client.alive()
|
||||
if down && now.Sub(c.lastTry) >= DefaultReconnectEvery {
|
||||
stale = append(stale, name)
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
for _, name := range stale {
|
||||
if err := m.dial(ctx, name); err != nil {
|
||||
log.Printf("mcp: %s: reconnect: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tools — every discovered tool across connected servers, sorted by
|
||||
// server then name.
|
||||
func (m *Manager) Tools() []Tool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var out []Tool
|
||||
for _, name := range m.order {
|
||||
out = append(out, m.conns[name].tools...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Status is one server's health, for the web surface.
|
||||
type Status struct {
|
||||
Name string
|
||||
Transport string // "stdio" or "http"
|
||||
Target string // command or url
|
||||
Connected bool
|
||||
Server string // the server's own name+version
|
||||
Tools int
|
||||
Err string
|
||||
}
|
||||
|
||||
// Status reports every configured server.
|
||||
func (m *Manager) Status() []Status {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]Status, 0, len(m.order))
|
||||
for _, name := range m.order {
|
||||
c := m.conns[name]
|
||||
s := Status{Name: name, Tools: len(c.tools)}
|
||||
if c.cfg.Command != "" {
|
||||
s.Transport, s.Target = "stdio", strings.Join(append([]string{c.cfg.Command}, c.cfg.Args...), " ")
|
||||
} else {
|
||||
s.Transport, s.Target = "http", c.cfg.URL
|
||||
}
|
||||
if c.client != nil {
|
||||
s.Connected = true
|
||||
s.Server = strings.TrimSpace(c.client.Info().Name + " " + c.client.Info().Version)
|
||||
}
|
||||
if c.lastErr != nil {
|
||||
s.Err = c.lastErr.Error()
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Call runs server's tool with args. Args come from the router and nothing
|
||||
// else; there is no path here through which a note or a fact could travel.
|
||||
func (m *Manager) Call(ctx context.Context, server, tool string, args map[string]any) (string, error) {
|
||||
m.mu.Lock()
|
||||
c := m.conns[server]
|
||||
m.mu.Unlock()
|
||||
if c == nil {
|
||||
return "", fmt.Errorf("%w: %s", ErrNoServer, server)
|
||||
}
|
||||
m.mu.Lock()
|
||||
cl, timeout, known := c.client, c.cfg.Timeout, false
|
||||
for _, t := range c.tools {
|
||||
if t.Name == tool {
|
||||
known = true
|
||||
break
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if cl == nil {
|
||||
return "", fmt.Errorf("mcp: %s is not connected", server)
|
||||
}
|
||||
// The discovered-and-filtered set is the second allowlist: even an enabled
|
||||
// store row cannot reach a tool the server stopped offering, or one
|
||||
// allow_tools excludes.
|
||||
if !known {
|
||||
return "", fmt.Errorf("mcp: %s offers no tool %q", server, tool)
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
return cl.CallTool(cctx, tool, args)
|
||||
}
|
||||
|
||||
// Resources lists resources across connected servers.
|
||||
func (m *Manager) Resources(ctx context.Context) []Resource {
|
||||
m.mu.Lock()
|
||||
clients := make([]*Client, 0, len(m.order))
|
||||
for _, name := range m.order {
|
||||
if cl := m.conns[name].client; cl != nil {
|
||||
clients = append(clients, cl)
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
var out []Resource
|
||||
for _, cl := range clients {
|
||||
rs, err := cl.ListResources(ctx)
|
||||
if err != nil {
|
||||
continue // no resources capability; not an error worth logging per tick
|
||||
}
|
||||
out = append(out, rs...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ReadResource reads one resource from one server.
|
||||
func (m *Manager) ReadResource(ctx context.Context, server, uri string) (string, error) {
|
||||
m.mu.Lock()
|
||||
c := m.conns[server]
|
||||
var cl *Client
|
||||
var timeout time.Duration
|
||||
if c != nil {
|
||||
cl, timeout = c.client, c.cfg.Timeout
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if cl == nil {
|
||||
return "", fmt.Errorf("%w: %s", ErrNoServer, server)
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
return cl.ReadResource(cctx, uri)
|
||||
}
|
||||
|
||||
// Close shuts every connection down.
|
||||
func (m *Manager) Close() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, name := range m.order {
|
||||
if cl := m.conns[name].client; cl != nil {
|
||||
_ = cl.Close()
|
||||
m.conns[name].client = nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrNeedsArgs — the tool requires arguments that a voice verb cannot supply.
|
||||
var ErrNeedsArgs = errors.New("mcp: tool needs named arguments")
|
||||
|
||||
// CallPositional is the voice path's way in. The router gives an act a verb and
|
||||
// a tail of positional words; an MCP tool wants a named-argument object. There
|
||||
// is no general mapping between those two, and inventing one is exactly the
|
||||
// improvisation this codebase refuses, so the rule is deliberately narrow:
|
||||
//
|
||||
// - a tool with no required properties runs with no arguments (a spare tail
|
||||
// is ignored — "покажи проекты пожалуйста" should still list projects);
|
||||
// - a READ-ONLY tool with exactly one required property, of type string or
|
||||
// integer/number, gets the tail bound to it;
|
||||
// - anything else is refused with ErrNeedsArgs. Such a tool is still callable
|
||||
// with explicit arguments from the authed surface, where a human types
|
||||
// them.
|
||||
//
|
||||
// The refusal is the point, and the read-only condition on it was learned the
|
||||
// hard way while testing against the Vikunja server: `update_task` requires
|
||||
// only `task_id` and takes every other field as optional, so calling it with
|
||||
// one guessed argument and no others BLANKED the fields it did not receive. A
|
||||
// mutating tool therefore never gets a guessed argument — the one thing a
|
||||
// partially-filled write can do is destroy what it did not mention. A mutating
|
||||
// tool with nothing required is still fine: nothing was guessed, and it still
|
||||
// goes through the confirm turn.
|
||||
func (m *Manager) CallPositional(ctx context.Context, server, tool string, args []string) (string, error) {
|
||||
m.mu.Lock()
|
||||
c := m.conns[server]
|
||||
var schema json.RawMessage
|
||||
found, readOnly := false, false
|
||||
if c != nil {
|
||||
for _, t := range c.tools {
|
||||
if t.Name == tool {
|
||||
schema, readOnly, found = t.InputSchema, t.ReadOnly, true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if !found {
|
||||
return "", fmt.Errorf("mcp: %s offers no tool %q", server, tool)
|
||||
}
|
||||
named, err := bindPositional(schema, args, readOnly)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return m.Call(ctx, server, tool, named)
|
||||
}
|
||||
|
||||
// bindPositional implements the rule documented on CallPositional.
|
||||
func bindPositional(schema json.RawMessage, args []string, readOnly bool) (map[string]any, error) {
|
||||
var s struct {
|
||||
Required []string `json:"required"`
|
||||
Properties map[string]struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"properties"`
|
||||
}
|
||||
if len(schema) > 0 {
|
||||
if err := json.Unmarshal(schema, &s); err != nil {
|
||||
return nil, fmt.Errorf("mcp: unreadable input schema: %w", err)
|
||||
}
|
||||
}
|
||||
switch len(s.Required) {
|
||||
case 0:
|
||||
return map[string]any{}, nil
|
||||
case 1:
|
||||
name := s.Required[0]
|
||||
if !readOnly {
|
||||
return nil, fmt.Errorf("%w: %q, and a tool that writes never gets a guessed one", ErrNeedsArgs, name)
|
||||
}
|
||||
tail := strings.TrimSpace(strings.Join(args, " "))
|
||||
if tail == "" {
|
||||
return nil, fmt.Errorf("%w: %q", ErrNeedsArgs, name)
|
||||
}
|
||||
switch s.Properties[name].Type {
|
||||
case "string", "":
|
||||
return map[string]any{name: tail}, nil
|
||||
case "integer", "number":
|
||||
n, err := strconv.ParseFloat(tail, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %q wants a number, got %q", ErrNeedsArgs, name, tail)
|
||||
}
|
||||
return map[string]any{name: n}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %q is a %s", ErrNeedsArgs, name, s.Properties[name].Type)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %s", ErrNeedsArgs, strings.Join(s.Required, ", "))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakePoster answers POSTs from a canned handler, in either JSON or SSE form.
|
||||
type fakePoster struct {
|
||||
mu sync.Mutex
|
||||
handler func(method string, params json.RawMessage) (any, *rpcError)
|
||||
sse bool
|
||||
session string
|
||||
seen []map[string]string // headers of each request, for the session test
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (f *fakePoster) Post(_ context.Context, _, _ string, body []byte, hdr map[string]string) (*PostResponse, error) {
|
||||
var req struct {
|
||||
ID *int64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.seen = append(f.seen, hdr)
|
||||
f.calls = append(f.calls, req.Method)
|
||||
f.mu.Unlock()
|
||||
|
||||
if req.ID == nil { // notification
|
||||
return &PostResponse{Status: 202, Body: []byte(`{}`)}, nil
|
||||
}
|
||||
result, rerr := f.handler(req.Method, req.Params)
|
||||
resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID}
|
||||
if rerr != nil {
|
||||
resp["error"] = map[string]any{"code": rerr.Code, "message": rerr.Message}
|
||||
} else {
|
||||
resp["result"] = result
|
||||
}
|
||||
raw, _ := json.Marshal(resp)
|
||||
out := &PostResponse{Status: 200, Body: raw, ContentType: "application/json", Header: map[string]string{}}
|
||||
if f.sse {
|
||||
out.ContentType = "text/event-stream"
|
||||
out.Body = []byte("event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\"}\n\nevent: message\ndata: " + string(raw) + "\n\n")
|
||||
}
|
||||
if f.session != "" {
|
||||
out.Header["Mcp-Session-Id"] = f.session
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// echoServer is a handler with two tools, one read-only and one not.
|
||||
func echoServer() func(string, json.RawMessage) (any, *rpcError) {
|
||||
return func(method string, params json.RawMessage) (any, *rpcError) {
|
||||
switch method {
|
||||
case "initialize":
|
||||
return map[string]any{
|
||||
"protocolVersion": ProtocolVersion,
|
||||
"serverInfo": map[string]any{"name": "fake", "version": "0.1"},
|
||||
}, nil
|
||||
case "tools/list":
|
||||
return map[string]any{"tools": []any{
|
||||
map[string]any{
|
||||
"name": "read_thing", "description": "reads",
|
||||
"inputSchema": map[string]any{"type": "object"},
|
||||
"annotations": map[string]any{"readOnlyHint": true},
|
||||
},
|
||||
map[string]any{"name": "break_thing", "description": "mutates"},
|
||||
}}, nil
|
||||
case "tools/call":
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Args map[string]any `json:"arguments"`
|
||||
}
|
||||
_ = json.Unmarshal(params, &p)
|
||||
if p.Name == "break_thing" {
|
||||
return map[string]any{"isError": true, "content": []any{
|
||||
map[string]any{"type": "text", "text": "не вышло"}}}, nil
|
||||
}
|
||||
return map[string]any{"content": []any{
|
||||
map[string]any{"type": "text", "text": fmt.Sprintf("%s:%v", p.Name, p.Args["q"])},
|
||||
map[string]any{"type": "image", "text": "ignored"},
|
||||
}}, nil
|
||||
case "resources/list":
|
||||
return map[string]any{"resources": []any{
|
||||
map[string]any{"uri": "note://one", "name": "one", "mimeType": "text/plain"},
|
||||
map[string]any{"uri": "", "name": "nameless"},
|
||||
}}, nil
|
||||
case "resources/read":
|
||||
return map[string]any{"contents": []any{map[string]any{"text": "тело ресурса"}}}, nil
|
||||
}
|
||||
return nil, &rpcError{Code: -32601, Message: "method not found"}
|
||||
}
|
||||
}
|
||||
|
||||
func dialFake(t *testing.T, p *fakePoster) *Client {
|
||||
t.Helper()
|
||||
c := newClient("fake", newHTTPTransport(p, "http://example.test/mcp"))
|
||||
if err := c.Initialize(context.Background()); err != nil {
|
||||
t.Fatalf("initialize: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestHandshakeAndDiscovery(t *testing.T) {
|
||||
for _, sse := range []bool{false, true} {
|
||||
name := "json"
|
||||
if sse {
|
||||
name = "sse"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
p := &fakePoster{handler: echoServer(), sse: sse}
|
||||
c := dialFake(t, p)
|
||||
if got := c.Info().Name; got != "fake" {
|
||||
t.Fatalf("server name = %q", got)
|
||||
}
|
||||
if got := c.Info().ProtocolVersion; got != ProtocolVersion {
|
||||
t.Fatalf("protocol = %q", got)
|
||||
}
|
||||
tools, err := c.ListTools(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list tools: %v", err)
|
||||
}
|
||||
if len(tools) != 2 {
|
||||
t.Fatalf("tools = %+v", tools)
|
||||
}
|
||||
byName := map[string]Tool{}
|
||||
for _, tl := range tools {
|
||||
byName[tl.Name] = tl
|
||||
}
|
||||
if !byName["read_thing"].ReadOnly {
|
||||
t.Error("read_thing should be read-only (readOnlyHint true)")
|
||||
}
|
||||
// The important direction: no annotation ⇒ assume it mutates.
|
||||
if byName["break_thing"].ReadOnly {
|
||||
t.Error("break_thing has no readOnlyHint, must NOT be treated as read-only")
|
||||
}
|
||||
if byName["read_thing"].Server != "fake" {
|
||||
t.Error("tool should carry its server handle")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallToolTextOnly(t *testing.T) {
|
||||
c := dialFake(t, &fakePoster{handler: echoServer()})
|
||||
out, err := c.CallTool(context.Background(), "read_thing", map[string]any{"q": "привет"})
|
||||
if err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
if out != "read_thing:привет" {
|
||||
t.Fatalf("out = %q (non-text content must be dropped)", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallToolErrorResult(t *testing.T) {
|
||||
c := dialFake(t, &fakePoster{handler: echoServer()})
|
||||
out, err := c.CallTool(context.Background(), "break_thing", nil)
|
||||
if err == nil {
|
||||
t.Fatal("isError result must surface as an error")
|
||||
}
|
||||
if out != "не вышло" {
|
||||
t.Fatalf("text should still come back, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResources(t *testing.T) {
|
||||
c := dialFake(t, &fakePoster{handler: echoServer()})
|
||||
rs, err := c.ListResources(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list resources: %v", err)
|
||||
}
|
||||
if len(rs) != 1 || rs[0].URI != "note://one" {
|
||||
t.Fatalf("resources = %+v (a uri-less entry must be dropped)", rs)
|
||||
}
|
||||
body, err := c.ReadResource(context.Background(), "note://one")
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if body != "тело ресурса" {
|
||||
t.Fatalf("body = %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallBeforeInitializeRefused(t *testing.T) {
|
||||
c := newClient("fake", newHTTPTransport(&fakePoster{handler: echoServer()}, "http://example.test/mcp"))
|
||||
if _, err := c.CallTool(context.Background(), "read_thing", nil); err != ErrNotInitialized {
|
||||
t.Fatalf("err = %v, want ErrNotInitialized", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionIDEchoed(t *testing.T) {
|
||||
p := &fakePoster{handler: echoServer(), session: "sess-1"}
|
||||
c := dialFake(t, p)
|
||||
if _, err := c.ListTools(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
last := p.seen[len(p.seen)-1]
|
||||
if last["Mcp-Session-Id"] != "sess-1" {
|
||||
t.Fatalf("session header not echoed: %+v", last)
|
||||
}
|
||||
if !strings.Contains(last["Accept"], "text/event-stream") {
|
||||
t.Fatalf("Accept must offer both forms: %q", last["Accept"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandshakeWithoutProtocolVersionRefused(t *testing.T) {
|
||||
p := &fakePoster{handler: func(m string, _ json.RawMessage) (any, *rpcError) {
|
||||
return map[string]any{"serverInfo": map[string]any{"name": "not-mcp"}}, nil
|
||||
}}
|
||||
c := newClient("x", newHTTPTransport(p, "http://example.test/mcp"))
|
||||
if err := c.Initialize(context.Background()); err == nil {
|
||||
t.Fatal("a reply with no protocolVersion is not an MCP server")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPCErrorSurfaces(t *testing.T) {
|
||||
c := dialFake(t, &fakePoster{handler: echoServer()})
|
||||
if _, err := c.callRaw(context.Background(), "nope/nope"); err == nil {
|
||||
t.Fatal("want an rpc error")
|
||||
} else if !strings.Contains(err.Error(), "method not found") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// callRaw is a test-only shim so the rpc-error path can be exercised without a
|
||||
// typed wrapper for a method the server does not implement.
|
||||
func (c *Client) callRaw(ctx context.Context, method string) (any, error) {
|
||||
var out any
|
||||
err := c.call(ctx, method, map[string]any{}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func TestDecodeFrame(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, in, want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "plain json", in: `{"id":1,"result":{}}`, want: `{"id":1,"result":{}}`},
|
||||
{name: "sse single", in: "event: message\ndata: {\"id\":1,\"result\":1}\n\n", want: `{"id":1,"result":1}`},
|
||||
{
|
||||
name: "sse picks the response not the notification",
|
||||
in: "data: {\"method\":\"notifications/progress\"}\n\ndata: {\"id\":2,\"result\":2}\n\n",
|
||||
want: `{"id":2,"result":2}`,
|
||||
},
|
||||
{name: "empty", in: " ", wantErr: true},
|
||||
{name: "sse with no response", in: "data: {\"method\":\"x\"}\n\n", wantErr: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := decodeFrame([]byte(tc.in))
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("want error, got %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != tc.want {
|
||||
t.Fatalf("got %q want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg ServerConfig
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "stdio ok", cfg: ServerConfig{Name: "a", Command: "echo"}},
|
||||
{name: "http ok", cfg: ServerConfig{Name: "a", URL: "http://x.test/mcp"}},
|
||||
{name: "no name", cfg: ServerConfig{Command: "echo"}, wantErr: true},
|
||||
{name: "spacey name", cfg: ServerConfig{Name: "a b", Command: "echo"}, wantErr: true},
|
||||
{name: "neither", cfg: ServerConfig{Name: "a"}, wantErr: true},
|
||||
{name: "both", cfg: ServerConfig{Name: "a", Command: "echo", URL: "http://x.test"}, wantErr: true},
|
||||
{name: "bad scheme", cfg: ServerConfig{Name: "a", URL: "file:///etc/passwd"}, wantErr: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := Validate([]ServerConfig{tc.cfg})
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("err = %v, wantErr = %v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := Validate([]ServerConfig{{Name: "a", Command: "x"}, {Name: "a", Command: "y"}}); err == nil {
|
||||
t.Error("duplicate names must be refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerOffWhenNothingEnabled(t *testing.T) {
|
||||
m, err := NewManager(nil, []ServerConfig{{Name: "a", Command: "echo"}}) // Enabled=false
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !m.Empty() {
|
||||
t.Fatal("a server that is not enabled must not be wired")
|
||||
}
|
||||
m.Connect(context.Background())
|
||||
if got := m.Tools(); len(got) != 0 {
|
||||
t.Fatalf("tools = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerDiscoversAndCalls(t *testing.T) {
|
||||
p := &fakePoster{handler: echoServer()}
|
||||
m, err := NewManager(func(ServerConfig) (Poster, error) { return p, nil },
|
||||
[]ServerConfig{{Name: "fake", URL: "http://example.test/mcp", Enabled: true}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Connect(context.Background())
|
||||
defer m.Close()
|
||||
|
||||
tools := m.Tools()
|
||||
if len(tools) != 2 {
|
||||
t.Fatalf("tools = %+v", tools)
|
||||
}
|
||||
out, err := m.Call(context.Background(), "fake", "read_thing", map[string]any{"q": "да"})
|
||||
if err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
if out != "read_thing:да" {
|
||||
t.Fatalf("out = %q", out)
|
||||
}
|
||||
// The discovered set is a second allowlist.
|
||||
if _, err := m.Call(context.Background(), "fake", "not_offered", nil); err == nil {
|
||||
t.Error("a tool the server does not offer must be refused")
|
||||
}
|
||||
if _, err := m.Call(context.Background(), "other", "read_thing", nil); err == nil {
|
||||
t.Error("an unconfigured server must be refused")
|
||||
}
|
||||
st := m.Status()
|
||||
if len(st) != 1 || !st[0].Connected || st[0].Transport != "http" || st[0].Tools != 2 {
|
||||
t.Fatalf("status = %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerAllowToolsAndMaxTools(t *testing.T) {
|
||||
p := &fakePoster{handler: echoServer()}
|
||||
mk := func(cfg ServerConfig) *Manager {
|
||||
cfg.Name, cfg.URL, cfg.Enabled = "fake", "http://example.test/mcp", true
|
||||
m, err := NewManager(func(ServerConfig) (Poster, error) { return p, nil }, []ServerConfig{cfg})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Connect(context.Background())
|
||||
return m
|
||||
}
|
||||
m := mk(ServerConfig{AllowTools: []string{"read_thing"}})
|
||||
defer m.Close()
|
||||
if got := m.Tools(); len(got) != 1 || got[0].Name != "read_thing" {
|
||||
t.Fatalf("allow_tools ignored: %+v", got)
|
||||
}
|
||||
if _, err := m.Call(context.Background(), "fake", "break_thing", nil); err == nil {
|
||||
t.Error("a tool excluded by allow_tools must be unreachable")
|
||||
}
|
||||
m2 := mk(ServerConfig{MaxTools: 1})
|
||||
defer m2.Close()
|
||||
if got := m2.Tools(); len(got) != 1 || got[0].Name != "break_thing" {
|
||||
t.Fatalf("max_tools should keep the first name-sorted tool: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerURLServerWithoutHTTPDoor(t *testing.T) {
|
||||
m, err := NewManager(nil, []ServerConfig{{Name: "fake", URL: "http://example.test/mcp", Enabled: true}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Connect(context.Background())
|
||||
st := m.Status()
|
||||
if len(st) != 1 || st[0].Connected || st[0].Err == "" {
|
||||
t.Fatalf("a url server with no poster must be recorded as failed: %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerReconnectAfterFailure(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
fail := true
|
||||
m, err := NewManager(func(ServerConfig) (Poster, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if fail {
|
||||
return nil, fmt.Errorf("down")
|
||||
}
|
||||
return &fakePoster{handler: echoServer()}, nil
|
||||
}, []ServerConfig{{Name: "fake", URL: "http://example.test/mcp", Enabled: true}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer m.Close()
|
||||
m.Connect(context.Background())
|
||||
if m.Status()[0].Connected {
|
||||
t.Fatal("should be down")
|
||||
}
|
||||
mu.Lock()
|
||||
fail = false
|
||||
mu.Unlock()
|
||||
// Refresh honours the backoff, so pretend the last attempt was long ago.
|
||||
m.mu.Lock()
|
||||
m.conns["fake"].lastTry = time.Now().Add(-2 * DefaultReconnectEvery)
|
||||
m.mu.Unlock()
|
||||
m.Refresh(context.Background())
|
||||
if !m.Status()[0].Connected {
|
||||
t.Fatalf("should have reconnected: %+v", m.Status())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalNameAndCmd(t *testing.T) {
|
||||
cases := [][3]string{
|
||||
{"vikunja", "list_tasks", "vikunja_list_tasks"},
|
||||
{"Vikunja", "Get Task Details", "vikunja_get_task_details"},
|
||||
{"fs", "read-file", "fs_read_file"},
|
||||
{"", "search", "search"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := LocalName(c[0], c[1]); got != c[2] {
|
||||
t.Errorf("LocalName(%q,%q) = %q want %q", c[0], c[1], got, c[2])
|
||||
}
|
||||
}
|
||||
server, tool, ok := ParseCmd(Cmd("vikunja", "list_tasks"))
|
||||
if !ok || server != "vikunja" || tool != "list_tasks" {
|
||||
t.Fatalf("ParseCmd round-trip: %q %q %v", server, tool, ok)
|
||||
}
|
||||
for _, bad := range [][]string{nil, {"systemctl", "restart", "nginx"}, {"mcp", "vikunja"}, {"mcp", "", "x"}} {
|
||||
if _, _, ok := ParseCmd(bad); ok {
|
||||
t.Errorf("ParseCmd(%v) must not claim an ordinary tool row", bad)
|
||||
}
|
||||
}
|
||||
if Scope("vikunja") != "mcp:vikunja" {
|
||||
t.Error("scope")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindPositional(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, schema string
|
||||
args []string
|
||||
mutating bool
|
||||
want map[string]any
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "no required runs with nothing",
|
||||
// A spare tail is fine: "покажи проекты пожалуйста" still lists them.
|
||||
schema: `{"type":"object","properties":{},"required":[]}`,
|
||||
args: []string{"пожалуйста"},
|
||||
want: map[string]any{},
|
||||
},
|
||||
{
|
||||
name: "empty schema",
|
||||
schema: ``,
|
||||
want: map[string]any{},
|
||||
},
|
||||
{
|
||||
name: "one required string gets the tail",
|
||||
schema: `{"properties":{"q":{"type":"string"}},"required":["q"]}`,
|
||||
args: []string{"почему", "небо", "синее"},
|
||||
want: map[string]any{"q": "почему небо синее"},
|
||||
},
|
||||
{
|
||||
name: "one required string with no tail",
|
||||
schema: `{"properties":{"q":{"type":"string"}},"required":["q"]}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "one required integer parses",
|
||||
schema: `{"properties":{"task_id":{"type":"integer"}},"required":["task_id"]}`,
|
||||
args: []string{"251"},
|
||||
want: map[string]any{"task_id": float64(251)},
|
||||
},
|
||||
{
|
||||
name: "one required integer with words",
|
||||
schema: `{"properties":{"task_id":{"type":"integer"}},"required":["task_id"]}`,
|
||||
args: []string{"двести", "пятьдесят", "один"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "two required is refused rather than guessed",
|
||||
schema: `{"properties":{"a":{"type":"string"},"b":{"type":"string"}},"required":["a","b"]}`,
|
||||
args: []string{"что-то"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "one required object is refused",
|
||||
schema: `{"properties":{"payload":{"type":"object"}},"required":["payload"]}`,
|
||||
args: []string{"что-то"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
// Learned from Vikunja's update_task: required ["task_id"], every
|
||||
// other field optional, so one guessed argument blanks the rest.
|
||||
name: "one required on a mutating tool is refused",
|
||||
schema: `{"properties":{"task_id":{"type":"integer"}},"required":["task_id"]}`,
|
||||
args: []string{"251"},
|
||||
mutating: true,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
// Nothing was guessed, so there is nothing to get wrong. It still
|
||||
// goes through the confirm turn upstream.
|
||||
name: "no required on a mutating tool still runs",
|
||||
schema: `{"properties":{},"required":[]}`,
|
||||
mutating: true,
|
||||
want: map[string]any{},
|
||||
},
|
||||
{
|
||||
name: "unreadable schema",
|
||||
schema: `not json`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := bindPositional(json.RawMessage(tc.schema), tc.args, !tc.mutating)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("want an error, got %v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fmt.Sprint(got) != fmt.Sprint(tc.want) {
|
||||
t.Fatalf("got %v want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallPositionalThroughManager(t *testing.T) {
|
||||
p := &fakePoster{handler: echoServer()}
|
||||
m, err := NewManager(func(ServerConfig) (Poster, error) { return p, nil },
|
||||
[]ServerConfig{{Name: "fake", URL: "http://example.test/mcp", Enabled: true}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Connect(context.Background())
|
||||
defer m.Close()
|
||||
// echoServer's tools declare no required properties.
|
||||
out, err := m.CallPositional(context.Background(), "fake", "read_thing", []string{"хвост"})
|
||||
if err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
if out != "read_thing:<nil>" {
|
||||
t.Fatalf("out = %q", out)
|
||||
}
|
||||
if _, err := m.CallPositional(context.Background(), "fake", "absent", nil); err == nil {
|
||||
t.Error("an unknown tool must be refused")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// maxLine bounds one JSON-RPC frame from a subprocess. A tool result bigger
|
||||
// than this is a misbehaving server, not something to buffer.
|
||||
const maxLine = 1 << 20 // 1 MiB
|
||||
|
||||
// stdioTransport speaks newline-delimited JSON-RPC to a child process. This is
|
||||
// the local transport: the server runs on this box, under this user, and gets
|
||||
// no network guard because it never touches the network on our behalf.
|
||||
//
|
||||
// Args are argv, never a shell string — the same discipline internal/tool
|
||||
// keeps, for the same reason.
|
||||
type stdioTransport struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
in io.WriteCloser
|
||||
out *bufio.Reader
|
||||
dead bool
|
||||
}
|
||||
|
||||
func newStdioTransport(ctx context.Context, argv []string, env []string, dir string) (*stdioTransport, error) {
|
||||
if len(argv) == 0 {
|
||||
return nil, errors.New("mcp: stdio server needs a command")
|
||||
}
|
||||
cmd := exec.Command(argv[0], argv[1:]...)
|
||||
cmd.Dir = dir
|
||||
if len(env) > 0 {
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
}
|
||||
cmd.Stderr = os.Stderr
|
||||
in, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mcp: stdin pipe: %w", err)
|
||||
}
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mcp: stdout pipe: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("mcp: start %q: %w", argv[0], err)
|
||||
}
|
||||
return &stdioTransport{cmd: cmd, in: in, out: bufio.NewReaderSize(out, 64<<10)}, nil
|
||||
}
|
||||
|
||||
func (t *stdioTransport) Call(ctx context.Context, req *rpcRequest) (*rpcResponse, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.dead {
|
||||
return nil, ErrClosed
|
||||
}
|
||||
if err := t.write(req); err != nil {
|
||||
t.dead = true
|
||||
return nil, err
|
||||
}
|
||||
// Read until the frame with our id turns up; anything else on the pipe is
|
||||
// a notification or a server-initiated request we do not answer.
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
line, err := t.readLine()
|
||||
if err != nil {
|
||||
t.dead = true
|
||||
return nil, err
|
||||
}
|
||||
var resp rpcResponse
|
||||
if err := json.Unmarshal(line, &resp); err != nil {
|
||||
continue // not a response frame; ignore rather than break the turn
|
||||
}
|
||||
if resp.ID == nil || *resp.ID != req.ID {
|
||||
continue
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (t *stdioTransport) Notify(ctx context.Context, method string, params any) error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.dead {
|
||||
return ErrClosed
|
||||
}
|
||||
return t.write(&rpcRequest{JSONRPC: "2.0", Method: method, Params: params})
|
||||
}
|
||||
|
||||
func (t *stdioTransport) write(req *rpcRequest) error {
|
||||
req.JSONRPC = "2.0"
|
||||
raw, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := t.in.Write(append(raw, '\n')); err != nil {
|
||||
return fmt.Errorf("mcp: write %s: %w", req.Method, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *stdioTransport) readLine() ([]byte, error) {
|
||||
for {
|
||||
line, err := t.out.ReadString('\n')
|
||||
if err != nil {
|
||||
if len(strings.TrimSpace(line)) == 0 {
|
||||
return nil, fmt.Errorf("mcp: read: %w", err)
|
||||
}
|
||||
return []byte(line), nil
|
||||
}
|
||||
if len(line) > maxLine {
|
||||
return nil, fmt.Errorf("mcp: frame exceeds %d bytes", maxLine)
|
||||
}
|
||||
if s := strings.TrimSpace(line); s != "" {
|
||||
return []byte(s), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *stdioTransport) Close() error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.dead = true
|
||||
if t.in != nil {
|
||||
_ = t.in.Close()
|
||||
}
|
||||
if t.cmd.Process != nil {
|
||||
_ = t.cmd.Process.Kill()
|
||||
_ = t.cmd.Wait()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// alive reports whether the transport can still carry a call. The manager uses
|
||||
// it to decide on a reconnect instead of retrying into a dead pipe.
|
||||
func (t *stdioTransport) alive() bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return !t.dead
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The stdio transport is tested against a real subprocess — this test binary,
|
||||
// re-executed with MAVEN_MCP_FAKE set, acting as a minimal MCP server. No
|
||||
// python, no fixture file, no network.
|
||||
func TestMain(m *testing.M) {
|
||||
if os.Getenv("MAVEN_MCP_FAKE") != "" {
|
||||
fakeStdioServer()
|
||||
return
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func fakeStdioServer() {
|
||||
h := echoServer()
|
||||
sc := bufio.NewScanner(os.Stdin)
|
||||
out := bufio.NewWriter(os.Stdout)
|
||||
defer out.Flush()
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var req struct {
|
||||
ID *int64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
if json.Unmarshal([]byte(line), &req) != nil {
|
||||
continue
|
||||
}
|
||||
if req.ID == nil {
|
||||
// A notification gets no reply, but we emit an unrelated
|
||||
// notification so the client's frame-skipping is exercised.
|
||||
_, _ = out.WriteString("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/message\"}\n")
|
||||
_ = out.Flush()
|
||||
continue
|
||||
}
|
||||
result, rerr := h(req.Method, req.Params)
|
||||
resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID}
|
||||
if rerr != nil {
|
||||
resp["error"] = map[string]any{"code": rerr.Code, "message": rerr.Message}
|
||||
} else {
|
||||
resp["result"] = result
|
||||
}
|
||||
raw, _ := json.Marshal(resp)
|
||||
_, _ = out.Write(append(raw, '\n'))
|
||||
_ = out.Flush()
|
||||
if os.Getenv("MAVEN_MCP_FAKE") == "die" && req.Method == "tools/list" {
|
||||
return // hang up, so the reconnect path has something to see
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stdioManager(t *testing.T, mode string) *Manager {
|
||||
t.Helper()
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Skipf("no executable path: %v", err)
|
||||
}
|
||||
if _, err := exec.LookPath(self); err != nil && !strings.Contains(self, "/") {
|
||||
t.Skip("test binary not executable")
|
||||
}
|
||||
m, err := NewManager(nil, []ServerConfig{{
|
||||
Name: "fake",
|
||||
Command: self,
|
||||
Env: []string{"MAVEN_MCP_FAKE=" + mode},
|
||||
Enabled: true,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Connect(context.Background())
|
||||
return m
|
||||
}
|
||||
|
||||
func TestStdioTransportEndToEnd(t *testing.T) {
|
||||
m := stdioManager(t, "1")
|
||||
defer m.Close()
|
||||
st := m.Status()
|
||||
if len(st) != 1 || !st[0].Connected {
|
||||
t.Fatalf("status = %+v", st)
|
||||
}
|
||||
if st[0].Transport != "stdio" {
|
||||
t.Fatalf("transport = %q", st[0].Transport)
|
||||
}
|
||||
if got := len(m.Tools()); got != 2 {
|
||||
t.Fatalf("tools = %d", got)
|
||||
}
|
||||
out, err := m.Call(context.Background(), "fake", "read_thing", map[string]any{"q": "стдио"})
|
||||
if err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
if out != "read_thing:стдио" {
|
||||
t.Fatalf("out = %q", out)
|
||||
}
|
||||
res := m.Resources(context.Background())
|
||||
if len(res) != 1 || res[0].URI != "note://one" {
|
||||
t.Fatalf("resources = %+v", res)
|
||||
}
|
||||
body, err := m.ReadResource(context.Background(), "fake", "note://one")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body != "тело ресурса" {
|
||||
t.Fatalf("body = %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioServerThatDiesIsNotUsable(t *testing.T) {
|
||||
m := stdioManager(t, "die")
|
||||
defer m.Close()
|
||||
// The server hung up after tools/list; the next call must fail cleanly
|
||||
// rather than hang or panic.
|
||||
if _, err := m.Call(context.Background(), "fake", "read_thing", nil); err == nil {
|
||||
t.Fatal("a call into a dead server must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioMissingCommand(t *testing.T) {
|
||||
m, err := NewManager(nil, []ServerConfig{{
|
||||
Name: "nope", Command: "/nonexistent/mcp-server-that-is-not-there", Enabled: true,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Connect(context.Background())
|
||||
st := m.Status()
|
||||
if st[0].Connected || st[0].Err == "" {
|
||||
t.Fatalf("a missing binary must be recorded, not fatal: %+v", st)
|
||||
}
|
||||
if got := len(m.Tools()); got != 0 {
|
||||
t.Fatalf("tools = %d", got)
|
||||
}
|
||||
if !strings.Contains(fmt.Sprint(st[0].Err), "start") {
|
||||
t.Logf("err = %q", st[0].Err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/kami/maven/internal/webfetch"
|
||||
)
|
||||
|
||||
// WebfetchDoor builds the PosterFactory used in production: one guarded
|
||||
// webfetch.Fetcher per url server, with that server's allow_private and the
|
||||
// shared host lists and limits.
|
||||
//
|
||||
// One fetcher PER server is the point. allow_private is a hole in the
|
||||
// private-address guard, and a hole punched for the Vikunja server on loopback
|
||||
// must not become a hole for some public endpoint that happens to redirect at
|
||||
// the LAN. Rate limiting is per fetcher too, which is the right shape here:
|
||||
// separate servers are separate hosts.
|
||||
func WebfetchDoor(limits webfetch.Config) PosterFactory {
|
||||
return func(cfg ServerConfig) (Poster, error) {
|
||||
c := limits
|
||||
c.AllowPrivate = cfg.AllowPrivate
|
||||
if c.Timeout <= 0 && cfg.Timeout > 0 {
|
||||
c.Timeout = cfg.Timeout
|
||||
}
|
||||
return fetcherPoster{webfetch.New(c)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// fetcherPoster adapts webfetch.Fetcher to Poster. It exists so this package
|
||||
// does not have to know webfetch's Response type, and so a test can substitute
|
||||
// a fake without a listener.
|
||||
type fetcherPoster struct{ f *webfetch.Fetcher }
|
||||
|
||||
func (p fetcherPoster) Post(ctx context.Context, rawURL, contentType string, body []byte, hdr map[string]string) (*PostResponse, error) {
|
||||
resp, err := p.f.Post(ctx, rawURL, contentType, body, hdr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mcp: post %s: %w", rawURL, err)
|
||||
}
|
||||
return &PostResponse{
|
||||
Status: resp.Status,
|
||||
ContentType: resp.ContentType,
|
||||
Body: resp.Body,
|
||||
Header: resp.Header,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DefaultMaxDim — the longest edge an image is scaled down to before it goes to
|
||||
// a vision model. 896 is the tile size the current crop of small
|
||||
// vision-language models (Qwen2.5-VL, SmolVLM, moondream) work in; sending a
|
||||
// 12-megapixel phone photo instead just costs the box minutes of prefill for
|
||||
// tiles that get pooled away anyway.
|
||||
const DefaultMaxDim = 896
|
||||
|
||||
// JPEGQuality for the re-encode. 85 is the usual "no visible artefacts" point,
|
||||
// and the re-encode exists to shrink the payload, not to archive it — the
|
||||
// original bytes stay in the blob store untouched.
|
||||
const JPEGQuality = 85
|
||||
|
||||
// ErrUnsupportedImage — the bytes are not an image format this build can
|
||||
// decode. Notably webp: the stdlib has no webp decoder and this repo takes no
|
||||
// new dependencies, so a webp arriving from Telegram is refused here with a
|
||||
// clear error rather than handed to a model as garbage.
|
||||
var ErrUnsupportedImage = errors.New("media: unsupported image format")
|
||||
|
||||
// SniffImage identifies image bytes by magic number and returns the mime. It
|
||||
// exists because a caller-declared content type is a claim, and the store's file
|
||||
// extension (and the vision provider's data URI) should follow the bytes.
|
||||
//
|
||||
// Returns ErrUnsupportedImage for anything unrecognised, including webp — which
|
||||
// is recognised well enough to name in the error, so the log says "webp is not
|
||||
// supported" instead of "not an image".
|
||||
func SniffImage(data []byte) (string, error) {
|
||||
switch {
|
||||
case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
|
||||
return "image/jpeg", nil
|
||||
case len(data) >= 8 && string(data[:8]) == "\x89PNG\r\n\x1a\n":
|
||||
return "image/png", nil
|
||||
case len(data) >= 6 && (string(data[:6]) == "GIF87a" || string(data[:6]) == "GIF89a"):
|
||||
return "image/gif", nil
|
||||
case len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP":
|
||||
return "", fmt.Errorf("%w: webp (no decoder in this build)", ErrUnsupportedImage)
|
||||
}
|
||||
return "", ErrUnsupportedImage
|
||||
}
|
||||
|
||||
// Image — an image prepared for a vision model: JPEG bytes, downscaled, with
|
||||
// the dimensions it ended up at. It is deliberately a separate type from Blob:
|
||||
// a Blob is what he sent, an Image is what the model sees, and the two are not
|
||||
// the same bytes.
|
||||
type Image struct {
|
||||
JPEG []byte
|
||||
Width int
|
||||
Height int
|
||||
// Source names where the original came from ("telegram", "web:upload"),
|
||||
// carried through only so a log line can say what was looked at.
|
||||
Source string
|
||||
}
|
||||
|
||||
// DataURI renders the image as a `data:image/jpeg;base64,...` URI, which is how
|
||||
// every OpenAI-compatible multimodal endpoint takes an image. The string is
|
||||
// large (roughly 4/3 of the JPEG); nothing caches it.
|
||||
func (im Image) DataURI() string {
|
||||
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(im.JPEG)
|
||||
}
|
||||
|
||||
// PrepareImage decodes data, scales it so its longest edge is at most maxDim
|
||||
// (never up — a small image is left alone), and re-encodes it as JPEG.
|
||||
// maxDim ≤ 0 ⇒ DefaultMaxDim.
|
||||
//
|
||||
// An image with an alpha channel is composited onto white rather than having
|
||||
// alpha dropped to black, because the common case is a screenshot or a
|
||||
// transparent-background diagram, and text on black-on-black is unreadable to
|
||||
// the model for no reason.
|
||||
func PrepareImage(data []byte, source string, maxDim int) (Image, error) {
|
||||
if len(data) == 0 {
|
||||
return Image{}, ErrEmpty
|
||||
}
|
||||
if maxDim <= 0 {
|
||||
maxDim = DefaultMaxDim
|
||||
}
|
||||
mime, err := SniffImage(data)
|
||||
if err != nil {
|
||||
return Image{}, err
|
||||
}
|
||||
src, err := decode(data, mime)
|
||||
if err != nil {
|
||||
return Image{}, fmt.Errorf("media: decode %s: %w", mime, err)
|
||||
}
|
||||
|
||||
dst := flattenAndScale(src, maxDim)
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: JPEGQuality}); err != nil {
|
||||
return Image{}, fmt.Errorf("media: encode jpeg: %w", err)
|
||||
}
|
||||
b := dst.Bounds()
|
||||
return Image{JPEG: buf.Bytes(), Width: b.Dx(), Height: b.Dy(), Source: source}, nil
|
||||
}
|
||||
|
||||
func decode(data []byte, mime string) (image.Image, error) {
|
||||
r := bytes.NewReader(data)
|
||||
switch strings.ToLower(mime) {
|
||||
case "image/jpeg":
|
||||
return jpeg.Decode(r)
|
||||
case "image/png":
|
||||
return png.Decode(r)
|
||||
case "image/gif":
|
||||
return gif.Decode(r)
|
||||
}
|
||||
return nil, ErrUnsupportedImage
|
||||
}
|
||||
|
||||
// flattenAndScale composites onto white and box-scales down to maxDim. The
|
||||
// scaler is a plain area average over the source pixels mapping to each
|
||||
// destination pixel — nearest-neighbour would alias small text into noise,
|
||||
// which defeats the point of reading a screenshot, and an area average is a
|
||||
// dozen lines against pulling in golang.org/x/image on an offline box.
|
||||
func flattenAndScale(src image.Image, maxDim int) *image.RGBA {
|
||||
sb := src.Bounds()
|
||||
sw, sh := sb.Dx(), sb.Dy()
|
||||
dw, dh := fit(sw, sh, maxDim)
|
||||
|
||||
flat := image.NewRGBA(image.Rect(0, 0, sw, sh))
|
||||
draw.Draw(flat, flat.Bounds(), image.NewUniform(image.White), image.Point{}, draw.Src)
|
||||
draw.Draw(flat, flat.Bounds(), src, sb.Min, draw.Over)
|
||||
if dw == sw && dh == sh {
|
||||
return flat
|
||||
}
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, dw, dh))
|
||||
for y := 0; y < dh; y++ {
|
||||
y0, y1 := y*sh/dh, (y+1)*sh/dh
|
||||
if y1 <= y0 {
|
||||
y1 = y0 + 1
|
||||
}
|
||||
for x := 0; x < dw; x++ {
|
||||
x0, x1 := x*sw/dw, (x+1)*sw/dw
|
||||
if x1 <= x0 {
|
||||
x1 = x0 + 1
|
||||
}
|
||||
var r, g, b, n uint32
|
||||
for sy := y0; sy < y1; sy++ {
|
||||
for sx := x0; sx < x1; sx++ {
|
||||
i := flat.PixOffset(sx, sy)
|
||||
r += uint32(flat.Pix[i])
|
||||
g += uint32(flat.Pix[i+1])
|
||||
b += uint32(flat.Pix[i+2])
|
||||
n++
|
||||
}
|
||||
}
|
||||
o := dst.PixOffset(x, y)
|
||||
dst.Pix[o] = uint8(r / n)
|
||||
dst.Pix[o+1] = uint8(g / n)
|
||||
dst.Pix[o+2] = uint8(b / n)
|
||||
dst.Pix[o+3] = 0xFF
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// fit returns the largest w×h with the same aspect ratio whose longest edge is
|
||||
// at most maxDim, never enlarging. Both edges are clamped to at least 1 so a
|
||||
// 2000×1 strip does not scale to zero height.
|
||||
func fit(w, h, maxDim int) (int, int) {
|
||||
if w <= maxDim && h <= maxDim {
|
||||
return w, h
|
||||
}
|
||||
if w >= h {
|
||||
nh := h * maxDim / w
|
||||
if nh < 1 {
|
||||
nh = 1
|
||||
}
|
||||
return maxDim, nh
|
||||
}
|
||||
nw := w * maxDim / h
|
||||
if nw < 1 {
|
||||
nw = 1
|
||||
}
|
||||
return nw, maxDim
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// pngBytes builds a w×h test image: left half red, right half a light grey, so
|
||||
// a downscale that averages produces a predictable mid value and a scaler that
|
||||
// silently returns the wrong region is visible.
|
||||
func pngBytes(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
if x < w/2 {
|
||||
img.Set(x, y, color.RGBA{255, 0, 0, 255})
|
||||
} else {
|
||||
img.Set(x, y, color.RGBA{200, 200, 200, 255})
|
||||
}
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestSniffImage(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
data []byte
|
||||
want string
|
||||
}{
|
||||
{"png", pngBytes(t, 4, 4), "image/png"},
|
||||
{"jpeg", jpegBytes(t, 4, 4), "image/jpeg"},
|
||||
{"gif", gifBytes(t, 4, 4), "image/gif"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := SniffImage(c.data)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", c.name, err)
|
||||
continue
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("%s: got %q want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// webp is common from Telegram and there is no stdlib decoder, so it must be
|
||||
// refused by name rather than mis-sniffed or fed to a model as noise.
|
||||
func TestSniffRefusesWebpByName(t *testing.T) {
|
||||
webp := append([]byte("RIFF\x00\x00\x00\x00WEBP"), make([]byte, 8)...)
|
||||
_, err := SniffImage(webp)
|
||||
if !errors.Is(err, ErrUnsupportedImage) {
|
||||
t.Fatalf("got %v, want ErrUnsupportedImage", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "webp") {
|
||||
t.Errorf("error does not name the format: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSniffRefusesGarbage(t *testing.T) {
|
||||
for _, data := range [][]byte{nil, []byte("hello"), []byte("\x00\x01\x02\x03")} {
|
||||
if _, err := SniffImage(data); !errors.Is(err, ErrUnsupportedImage) {
|
||||
t.Errorf("SniffImage(%q) = %v", data, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareImageDownscalesLongestEdge(t *testing.T) {
|
||||
im, err := PrepareImage(pngBytes(t, 2000, 1000), "web:upload", 500)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare: %v", err)
|
||||
}
|
||||
if im.Width != 500 || im.Height != 250 {
|
||||
t.Errorf("got %dx%d, want 500x250", im.Width, im.Height)
|
||||
}
|
||||
if _, err := jpeg.Decode(bytes.NewReader(im.JPEG)); err != nil {
|
||||
t.Errorf("output is not decodable jpeg: %v", err)
|
||||
}
|
||||
if im.Source != "web:upload" {
|
||||
t.Errorf("source lost: %q", im.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// Tall images scale on the other axis; a scaler that only handles landscape is
|
||||
// the classic version of this bug.
|
||||
func TestPrepareImageHandlesPortrait(t *testing.T) {
|
||||
im, err := PrepareImage(pngBytes(t, 400, 1600), "telegram", 800)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare: %v", err)
|
||||
}
|
||||
if im.Height != 800 || im.Width != 200 {
|
||||
t.Errorf("got %dx%d, want 200x800", im.Width, im.Height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareImageNeverEnlarges(t *testing.T) {
|
||||
im, err := PrepareImage(pngBytes(t, 64, 32), "telegram", 896)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare: %v", err)
|
||||
}
|
||||
if im.Width != 64 || im.Height != 32 {
|
||||
t.Errorf("got %dx%d, want the original 64x32", im.Width, im.Height)
|
||||
}
|
||||
}
|
||||
|
||||
// A degenerate strip must not scale to zero on the short axis — jpeg.Encode
|
||||
// fails on a zero-height image, which would turn a weird screenshot into a
|
||||
// hard error.
|
||||
func TestPrepareImageClampsDegenerateAspect(t *testing.T) {
|
||||
im, err := PrepareImage(pngBytes(t, 2000, 2), "web:upload", 100)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare: %v", err)
|
||||
}
|
||||
if im.Height < 1 || im.Width != 100 {
|
||||
t.Errorf("got %dx%d", im.Width, im.Height)
|
||||
}
|
||||
}
|
||||
|
||||
// Transparent pixels composite onto white, not black: the common case is a
|
||||
// screenshot or a diagram, and dark-on-black is unreadable to the model.
|
||||
func TestPrepareImageFlattensAlphaOntoWhite(t *testing.T) {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 8, 8)) // fully transparent
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
im, err := PrepareImage(buf.Bytes(), "web:upload", 8)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare: %v", err)
|
||||
}
|
||||
decoded, err := jpeg.Decode(bytes.NewReader(im.JPEG))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, g, b, _ := decoded.At(4, 4).RGBA()
|
||||
if r>>8 < 240 || g>>8 < 240 || b>>8 < 240 {
|
||||
t.Errorf("transparent pixel became rgb(%d,%d,%d), want near-white", r>>8, g>>8, b>>8)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareImageRejectsEmpty(t *testing.T) {
|
||||
if _, err := PrepareImage(nil, "x", 0); !errors.Is(err, ErrEmpty) {
|
||||
t.Errorf("got %v, want ErrEmpty", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataURIIsAJPEGDataURI(t *testing.T) {
|
||||
im, err := PrepareImage(pngBytes(t, 16, 16), "x", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uri := im.DataURI()
|
||||
if !strings.HasPrefix(uri, "data:image/jpeg;base64,") {
|
||||
t.Fatalf("bad prefix: %.40s", uri)
|
||||
}
|
||||
if len(uri) <= len("data:image/jpeg;base64,") {
|
||||
t.Error("data uri carries no payload")
|
||||
}
|
||||
}
|
||||
|
||||
func jpegBytes(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func gifBytes(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
img := image.NewPaletted(image.Rect(0, 0, w, h), []color.Color{color.Black, color.White})
|
||||
var buf bytes.Buffer
|
||||
if err := gif.Encode(&buf, img, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Package media is the intake for everything Maven sees or hears that is not
|
||||
// text: a photo he sends her, a meeting she was asked to record, a voice sample
|
||||
// used to enrol a speaker. All three senses (vision, hearing, speaker
|
||||
// recognition) share one problem — a blob arrives, it has to be stored, and
|
||||
// something has to describe it — so the storing half lives here once instead of
|
||||
// three times.
|
||||
//
|
||||
// # What this package is
|
||||
//
|
||||
// A content-addressed blob store on the local filesystem. Put returns a Blob
|
||||
// keyed by the sha256 of its bytes, so the same photo sent twice is one file.
|
||||
// Each blob gets a sidecar `.json` with its kind, mime, size, source and
|
||||
// creation time; the sidecar is the whole index, because at personal scale a
|
||||
// directory walk is cheaper than another sqlite table and the store has to be
|
||||
// readable with `ls` when something goes wrong.
|
||||
//
|
||||
// Blobs are NOT in the sqlite database. The database is small, encrypted, and
|
||||
// read on every tick; a 40 MB meeting recording has no business in it. What
|
||||
// goes in the database is the *text* a blob produced — a transcript, a
|
||||
// description — written as an ordinary note, which is the durable artefact and
|
||||
// the only part worth recalling later.
|
||||
//
|
||||
// # Invariants (these are the point of the package, not decoration)
|
||||
//
|
||||
// - Nothing is captured that was not asked for. This package never records;
|
||||
// it stores what a caller hands it, and every caller is an explicit act
|
||||
// with a start and a stop. There is no ambient path in, and none may be
|
||||
// added: see the refusal recorded in docs/plans/08-hearing.md.
|
||||
// - A blob never leaves the box. No provider in this repo may upload one, and
|
||||
// the vision provider refuses a non-private endpoint for exactly that
|
||||
// reason (internal/vision).
|
||||
// - A blob is never search input and never embedded. His photos and the audio
|
||||
// of his meetings are not corpus. Only text derived from them, once he can
|
||||
// see it as a note, participates in recall.
|
||||
// - Storage is bounded. Retention is a config knob with a default, Prune
|
||||
// enforces it, and an unpruned store is a bug: audio of people accumulating
|
||||
// forever on disk is the failure mode this capability has to avoid.
|
||||
//
|
||||
// # Layout
|
||||
//
|
||||
// <dir>/<kind>/<aa>/<sha256>.<ext> the bytes
|
||||
// <dir>/<kind>/<aa>/<sha256>.json the sidecar metadata
|
||||
//
|
||||
// `aa` is the first two hex chars of the digest — one fan-out level, enough to
|
||||
// keep a directory listing usable after a few thousand blobs.
|
||||
package media
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Kind — what a blob is. Two values today; the kind is a directory name and a
|
||||
// retention bucket, so adding a third is additive.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
// KindImage — a still image (png / jpeg / gif / webp bytes as received).
|
||||
KindImage Kind = "image"
|
||||
// KindAudio — raw PCM in the canonical internal/audio format, or a WAV
|
||||
// container. Meeting captures and enrolment samples both land here.
|
||||
KindAudio Kind = "audio"
|
||||
)
|
||||
|
||||
// Valid reports whether k is a kind this package will store. An unknown kind is
|
||||
// refused at Put rather than creating a stray directory.
|
||||
func (k Kind) Valid() bool { return k == KindImage || k == KindAudio }
|
||||
|
||||
// Errors callers distinguish. ErrNotFound is the only one a caller usually
|
||||
// handles; the rest mean the call was wrong.
|
||||
var (
|
||||
// ErrNotFound — no blob with that id in this store.
|
||||
ErrNotFound = errors.New("media: not found")
|
||||
// ErrEmpty — Put was handed zero bytes. Storing an empty capture would
|
||||
// leave a sidecar claiming a recording exists when it does not.
|
||||
ErrEmpty = errors.New("media: empty payload")
|
||||
// ErrTooLarge — the payload is over the store's cap. The cap exists so a
|
||||
// runaway capture cannot fill the disk that mavend's database lives on.
|
||||
ErrTooLarge = errors.New("media: payload too large")
|
||||
// ErrBadKind — unknown Kind.
|
||||
ErrBadKind = errors.New("media: unknown kind")
|
||||
// ErrBadID — the id is not a 64-char lowercase hex digest, so it cannot
|
||||
// have come from this store and must not be turned into a path.
|
||||
ErrBadID = errors.New("media: malformed id")
|
||||
)
|
||||
|
||||
// Blob — one stored item. ID is the sha256 of the bytes in lowercase hex, which
|
||||
// makes it both the primary key and the dedupe mechanism. Path is absolute and
|
||||
// local; it is a debugging affordance and the argument a subprocess (whisper,
|
||||
// llama-server) is pointed at, never something handed to a network client.
|
||||
type Blob struct {
|
||||
ID string `json:"id"`
|
||||
Kind Kind `json:"kind"`
|
||||
MIME string `json:"mime"`
|
||||
Size int64 `json:"size"`
|
||||
Source string `json:"source"` // provenance: "telegram", "web:upload", "capture:meeting", "enroll"
|
||||
Created time.Time `json:"created"` // UTC
|
||||
Path string `json:"-"` // filled by the store; not part of the sidecar
|
||||
}
|
||||
|
||||
// Age is how long ago the blob was stored, measured against now. Prune uses it;
|
||||
// it is exported because the /media surface will want to show it.
|
||||
func (b Blob) Age(now time.Time) time.Duration { return now.Sub(b.Created) }
|
||||
|
||||
// String is a one-line summary for logs. Deliberately does not include Path:
|
||||
// a log line is not the place to spell out where his meeting audio lives.
|
||||
func (b Blob) String() string {
|
||||
return fmt.Sprintf("%s %s %dB from %s", b.Kind, shortID(b.ID), b.Size, b.Source)
|
||||
}
|
||||
|
||||
// shortID trims a digest to something readable in a log line. Twelve hex chars
|
||||
// is unambiguous at personal scale and short enough to fit next to the rest.
|
||||
func shortID(id string) string {
|
||||
if len(id) <= 12 {
|
||||
return id
|
||||
}
|
||||
return id[:12]
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultMaxBytes — the per-blob cap when a store is built without one. 64 MiB
|
||||
// is about an hour of 16 kHz mono PCM, which is also the hearing capture's own
|
||||
// ceiling; a single item bigger than that is a mistake, not a meeting.
|
||||
const DefaultMaxBytes int64 = 64 << 20
|
||||
|
||||
// DefaultRetention — how long a blob is kept when no retention is configured.
|
||||
// Seven days is long enough to re-run a transcription that came out wrong and
|
||||
// short enough that "she has a month of my meetings on disk" is never true.
|
||||
const DefaultRetention = 7 * 24 * time.Hour
|
||||
|
||||
// Store — a content-addressed blob directory. Zero value is not usable; build
|
||||
// one with Open, which creates the directory 0700. The store holds no lock and
|
||||
// no cache: every operation is a filesystem call, and two writers of the same
|
||||
// bytes produce the same file, so concurrent Puts do not need coordinating.
|
||||
type Store struct {
|
||||
dir string
|
||||
maxBytes int64
|
||||
retention time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// Open prepares a blob store rooted at dir. maxBytes ≤ 0 ⇒ DefaultMaxBytes;
|
||||
// retention ≤ 0 ⇒ DefaultRetention. The directory (and every kind subdirectory
|
||||
// created later) is 0700: these are recordings of people, and the daemon's user
|
||||
// is the only reader.
|
||||
func Open(dir string, maxBytes int64, retention time.Duration) (*Store, error) {
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
return nil, errors.New("media: empty dir")
|
||||
}
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("media: resolve dir: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(abs, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("media: create dir: %w", err)
|
||||
}
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = DefaultMaxBytes
|
||||
}
|
||||
if retention <= 0 {
|
||||
retention = DefaultRetention
|
||||
}
|
||||
return &Store{dir: abs, maxBytes: maxBytes, retention: retention, now: time.Now}, nil
|
||||
}
|
||||
|
||||
// Dir is the store root. Exported for logs and for pointing a subprocess at a
|
||||
// path under it.
|
||||
func (s *Store) Dir() string { return s.dir }
|
||||
|
||||
// Retention is the configured age limit Prune enforces.
|
||||
func (s *Store) Retention() time.Duration { return s.retention }
|
||||
|
||||
// Put stores data and returns its Blob. The id is the sha256 of data, so
|
||||
// storing the same bytes twice is idempotent: the second call rewrites the
|
||||
// sidecar (keeping the ORIGINAL creation time, so a re-send cannot extend
|
||||
// retention indefinitely) and returns the same id.
|
||||
//
|
||||
// mime is recorded as given and used only to pick a file extension; nothing
|
||||
// dispatches on it. Callers that need the mime to be trustworthy sniff it
|
||||
// first — see SniffImage.
|
||||
func (s *Store) Put(kind Kind, mime, source string, data []byte) (Blob, error) {
|
||||
if !kind.Valid() {
|
||||
return Blob{}, ErrBadKind
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return Blob{}, ErrEmpty
|
||||
}
|
||||
if int64(len(data)) > s.maxBytes {
|
||||
return Blob{}, fmt.Errorf("%w: %d > %d", ErrTooLarge, len(data), s.maxBytes)
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
id := hex.EncodeToString(sum[:])
|
||||
|
||||
blobPath, metaPath, err := s.paths(kind, id, mime)
|
||||
if err != nil {
|
||||
return Blob{}, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(blobPath), 0o700); err != nil {
|
||||
return Blob{}, fmt.Errorf("media: create bucket: %w", err)
|
||||
}
|
||||
|
||||
b := Blob{ID: id, Kind: kind, MIME: mime, Size: int64(len(data)), Source: source,
|
||||
Created: s.now().UTC(), Path: blobPath}
|
||||
|
||||
// A blob already here keeps its first-seen time. Re-sending the same photo
|
||||
// every hour must not keep it alive past retention.
|
||||
if prev, err := readMeta(metaPath); err == nil && !prev.Created.IsZero() {
|
||||
b.Created = prev.Created
|
||||
}
|
||||
|
||||
if err := writeFile(blobPath, data); err != nil {
|
||||
return Blob{}, err
|
||||
}
|
||||
if err := writeMeta(metaPath, b); err != nil {
|
||||
return Blob{}, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Get returns the blob's metadata without reading its bytes.
|
||||
func (s *Store) Get(id string) (Blob, error) {
|
||||
if !validID(id) {
|
||||
return Blob{}, ErrBadID
|
||||
}
|
||||
for _, kind := range []Kind{KindImage, KindAudio} {
|
||||
metaPath := filepath.Join(s.dir, string(kind), id[:2], id+".json")
|
||||
b, err := readMeta(metaPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
p, err := s.locate(kind, id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
b.Path = p
|
||||
return b, nil
|
||||
}
|
||||
return Blob{}, ErrNotFound
|
||||
}
|
||||
|
||||
// Read returns the blob's bytes together with its metadata. This is the only
|
||||
// way out of the store, and it is a local read: nothing in this package can
|
||||
// send bytes anywhere.
|
||||
func (s *Store) Read(id string) (Blob, []byte, error) {
|
||||
b, err := s.Get(id)
|
||||
if err != nil {
|
||||
return Blob{}, nil, err
|
||||
}
|
||||
data, err := os.ReadFile(b.Path)
|
||||
if err != nil {
|
||||
return Blob{}, nil, fmt.Errorf("media: read %s: %w", shortID(id), err)
|
||||
}
|
||||
return b, data, nil
|
||||
}
|
||||
|
||||
// List returns every blob of the given kind, newest first. An empty kind lists
|
||||
// both. It walks the directory; at personal volumes (tens to hundreds of items
|
||||
// inside the retention window) that is cheap, and it means the sidecars are the
|
||||
// single source of truth with no index to fall out of sync.
|
||||
func (s *Store) List(kind Kind) ([]Blob, error) {
|
||||
kinds := []Kind{KindImage, KindAudio}
|
||||
if kind != "" {
|
||||
if !kind.Valid() {
|
||||
return nil, ErrBadKind
|
||||
}
|
||||
kinds = []Kind{kind}
|
||||
}
|
||||
var out []Blob
|
||||
for _, k := range kinds {
|
||||
root := filepath.Join(s.dir, string(k))
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil // kind never used; not an error
|
||||
}
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(path, ".json") {
|
||||
return nil
|
||||
}
|
||||
b, err := readMeta(path)
|
||||
if err != nil {
|
||||
return nil // a corrupt sidecar is skipped, not fatal
|
||||
}
|
||||
if p, err := s.locate(b.Kind, b.ID); err == nil {
|
||||
b.Path = p
|
||||
}
|
||||
out = append(out, b)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("media: list %s: %w", k, err)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Created.Equal(out[j].Created) {
|
||||
return out[i].ID < out[j].ID
|
||||
}
|
||||
return out[i].Created.After(out[j].Created)
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Delete removes a blob and its sidecar. Missing is not an error: the caller
|
||||
// asked for it gone and it is gone.
|
||||
func (s *Store) Delete(id string) error {
|
||||
if !validID(id) {
|
||||
return ErrBadID
|
||||
}
|
||||
for _, kind := range []Kind{KindImage, KindAudio} {
|
||||
bucket := filepath.Join(s.dir, string(kind), id[:2])
|
||||
entries, err := os.ReadDir(bucket)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), id) {
|
||||
if err := os.Remove(filepath.Join(bucket, e.Name())); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("media: delete %s: %w", shortID(id), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Prune deletes every blob older than the store's retention and reports how
|
||||
// many went. It is the enforcement half of the retention promise; a caller that
|
||||
// never runs it has a store that grows without bound, which is why the daemon
|
||||
// runs it on the digestion tick rather than leaving it to a cron the operator
|
||||
// might not add.
|
||||
func (s *Store) Prune() (int, error) {
|
||||
blobs, err := s.List("")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
now := s.now()
|
||||
deleted := 0
|
||||
for _, b := range blobs {
|
||||
if b.Age(now) <= s.retention {
|
||||
continue
|
||||
}
|
||||
if err := s.Delete(b.ID); err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// paths returns the blob and sidecar paths for an id.
|
||||
func (s *Store) paths(kind Kind, id, mime string) (blobPath, metaPath string, err error) {
|
||||
if !validID(id) {
|
||||
return "", "", ErrBadID
|
||||
}
|
||||
bucket := filepath.Join(s.dir, string(kind), id[:2])
|
||||
return filepath.Join(bucket, id+extFor(mime, kind)), filepath.Join(bucket, id+".json"), nil
|
||||
}
|
||||
|
||||
// locate finds the stored bytes for an id whose extension we do not know,
|
||||
// because the extension came from the mime at Put time.
|
||||
func (s *Store) locate(kind Kind, id string) (string, error) {
|
||||
if !validID(id) {
|
||||
return "", ErrBadID
|
||||
}
|
||||
bucket := filepath.Join(s.dir, string(kind), id[:2])
|
||||
entries, err := os.ReadDir(bucket)
|
||||
if err != nil {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, id) && !strings.HasSuffix(name, ".json") {
|
||||
return filepath.Join(bucket, name), nil
|
||||
}
|
||||
}
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
// validID guards every path built from an id. Without it a caller-supplied id
|
||||
// is a path traversal: Get("../../etc/passwd") would read outside the store.
|
||||
func validID(id string) bool {
|
||||
if len(id) != 64 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(id); i++ {
|
||||
c := id[i]
|
||||
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// extFor maps a mime to a file extension, defaulting per kind. The extension is
|
||||
// cosmetic — the id is the key — but it is what makes the store browsable and
|
||||
// lets a subprocess that sniffs by name (piper, some image tools) cope.
|
||||
func extFor(mime string, kind Kind) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mime)) {
|
||||
case "image/jpeg", "image/jpg":
|
||||
return ".jpg"
|
||||
case "image/png":
|
||||
return ".png"
|
||||
case "image/gif":
|
||||
return ".gif"
|
||||
case "image/webp":
|
||||
return ".webp"
|
||||
case "audio/wav", "audio/x-wav", "audio/wave":
|
||||
return ".wav"
|
||||
case "audio/l16", "audio/pcm":
|
||||
return ".pcm"
|
||||
}
|
||||
if kind == KindImage {
|
||||
return ".bin"
|
||||
}
|
||||
return ".pcm"
|
||||
}
|
||||
|
||||
// writeFile writes data 0600 via a temp file in the same directory, so a
|
||||
// crash mid-write cannot leave a truncated blob under a digest that claims
|
||||
// to describe the whole thing.
|
||||
func writeFile(path string, data []byte) error {
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("media: temp: %w", err)
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("media: chmod: %w", err)
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("media: write: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("media: close: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), path); err != nil {
|
||||
return fmt.Errorf("media: rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeMeta(path string, b Blob) error {
|
||||
data, err := json.Marshal(b)
|
||||
if err != nil {
|
||||
return fmt.Errorf("media: marshal meta: %w", err)
|
||||
}
|
||||
return writeFile(path, data)
|
||||
}
|
||||
|
||||
func readMeta(path string) (Blob, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Blob{}, err
|
||||
}
|
||||
var b Blob
|
||||
if err := json.Unmarshal(data, &b); err != nil {
|
||||
return Blob{}, err
|
||||
}
|
||||
if !validID(b.ID) || !b.Kind.Valid() {
|
||||
return Blob{}, errors.New("media: corrupt sidecar")
|
||||
}
|
||||
b.Created = b.Created.UTC()
|
||||
return b, nil
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := Open(t.TempDir(), 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestPutAndRead(t *testing.T) {
|
||||
s := testStore(t)
|
||||
b, err := s.Put(KindImage, "image/png", "web:upload", []byte("pretend png"))
|
||||
if err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
if len(b.ID) != 64 {
|
||||
t.Fatalf("id is not a sha256 hex digest: %q", b.ID)
|
||||
}
|
||||
if b.Size != int64(len("pretend png")) {
|
||||
t.Errorf("size = %d", b.Size)
|
||||
}
|
||||
if !strings.HasSuffix(b.Path, ".png") {
|
||||
t.Errorf("extension not taken from mime: %s", b.Path)
|
||||
}
|
||||
got, data, err := s.Read(b.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if string(data) != "pretend png" {
|
||||
t.Errorf("data = %q", data)
|
||||
}
|
||||
if got.Source != "web:upload" || got.Kind != KindImage {
|
||||
t.Errorf("metadata not round-tripped: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The same bytes twice must be one file, and must NOT get a fresh creation
|
||||
// time — otherwise re-sending a photo keeps it alive past retention forever.
|
||||
func TestPutIsIdempotentAndKeepsFirstSeenTime(t *testing.T) {
|
||||
s := testStore(t)
|
||||
base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
s.now = func() time.Time { return base }
|
||||
|
||||
first, err := s.Put(KindAudio, "audio/wav", "capture:meeting", []byte("pcm"))
|
||||
if err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
s.now = func() time.Time { return base.Add(72 * time.Hour) }
|
||||
second, err := s.Put(KindAudio, "audio/wav", "capture:meeting", []byte("pcm"))
|
||||
if err != nil {
|
||||
t.Fatalf("re-put: %v", err)
|
||||
}
|
||||
if first.ID != second.ID {
|
||||
t.Fatalf("same bytes produced two ids")
|
||||
}
|
||||
if !second.Created.Equal(base) {
|
||||
t.Errorf("re-put moved created time to %v, want %v", second.Created, base)
|
||||
}
|
||||
list, err := s.List(KindAudio)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Errorf("got %d blobs, want 1", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutRejects(t *testing.T) {
|
||||
s, err := Open(t.TempDir(), 8, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Put(KindImage, "image/png", "x", nil); !errors.Is(err, ErrEmpty) {
|
||||
t.Errorf("empty payload: %v", err)
|
||||
}
|
||||
if _, err := s.Put("video", "video/mp4", "x", []byte("ab")); !errors.Is(err, ErrBadKind) {
|
||||
t.Errorf("bad kind: %v", err)
|
||||
}
|
||||
if _, err := s.Put(KindImage, "image/png", "x", []byte("way too many bytes")); !errors.Is(err, ErrTooLarge) {
|
||||
t.Errorf("over cap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A caller-supplied id becomes a path, so a traversal attempt must be refused
|
||||
// before it touches the filesystem rather than escaping the store root.
|
||||
func TestMalformedIDIsRefused(t *testing.T) {
|
||||
s := testStore(t)
|
||||
for _, id := range []string{"", "../../etc/passwd", strings.Repeat("z", 64), strings.Repeat("a", 63)} {
|
||||
if _, err := s.Get(id); !errors.Is(err, ErrBadID) && !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("Get(%q) = %v, want a refusal", id, err)
|
||||
}
|
||||
if _, _, err := s.Read(id); err == nil {
|
||||
t.Errorf("Read(%q) succeeded", id)
|
||||
}
|
||||
if err := s.Delete(id); err == nil && id != "" {
|
||||
// Delete of a well-formed but absent id is fine; these are not
|
||||
// well-formed.
|
||||
t.Errorf("Delete(%q) succeeded", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMissingIsNotFound(t *testing.T) {
|
||||
s := testStore(t)
|
||||
if _, err := s.Get(strings.Repeat("a", 64)); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("got %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneEnforcesRetention(t *testing.T) {
|
||||
s, err := Open(t.TempDir(), 0, 48*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
s.now = func() time.Time { return now.Add(-96 * time.Hour) }
|
||||
old, _ := s.Put(KindAudio, "audio/wav", "capture:meeting", []byte("old meeting"))
|
||||
s.now = func() time.Time { return now.Add(-1 * time.Hour) }
|
||||
fresh, _ := s.Put(KindImage, "image/png", "telegram", []byte("recent photo"))
|
||||
|
||||
s.now = func() time.Time { return now }
|
||||
n, err := s.Prune()
|
||||
if err != nil {
|
||||
t.Fatalf("prune: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("pruned %d, want 1", n)
|
||||
}
|
||||
if _, err := s.Get(old.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("stale blob survived prune: %v", err)
|
||||
}
|
||||
if _, err := s.Get(fresh.ID); err != nil {
|
||||
t.Errorf("fresh blob was pruned: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListIsNewestFirstAcrossKinds(t *testing.T) {
|
||||
s := testStore(t)
|
||||
base := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
|
||||
s.now = func() time.Time { return base }
|
||||
_, _ = s.Put(KindImage, "image/png", "telegram", []byte("one"))
|
||||
s.now = func() time.Time { return base.Add(time.Hour) }
|
||||
newest, _ := s.Put(KindAudio, "audio/wav", "capture:meeting", []byte("two"))
|
||||
|
||||
all, err := s.List("")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("got %d, want 2", len(all))
|
||||
}
|
||||
if all[0].ID != newest.ID {
|
||||
t.Errorf("list is not newest-first")
|
||||
}
|
||||
}
|
||||
|
||||
// Recordings of people are 0700/0600 and nothing else.
|
||||
func TestPermissionsAreOwnerOnly(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := Open(filepath.Join(dir, "blobs"), 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := s.Put(KindAudio, "audio/wav", "capture:meeting", []byte("pcm"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
di, err := os.Stat(s.Dir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if di.Mode().Perm() != 0o700 {
|
||||
t.Errorf("store dir mode = %o, want 700", di.Mode().Perm())
|
||||
}
|
||||
fi, err := os.Stat(b.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fi.Mode().Perm() != 0o600 {
|
||||
t.Errorf("blob mode = %o, want 600", fi.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRemovesBytesAndSidecar(t *testing.T) {
|
||||
s := testStore(t)
|
||||
b, _ := s.Put(KindImage, "image/png", "telegram", []byte("bytes"))
|
||||
if err := s.Delete(b.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(b.Path); !os.IsNotExist(err) {
|
||||
t.Errorf("bytes survived delete")
|
||||
}
|
||||
if _, err := s.Get(b.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("sidecar survived delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRejectsEmptyDir(t *testing.T) {
|
||||
if _, err := Open(" ", 0, 0); err == nil {
|
||||
t.Error("empty dir accepted")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package memory
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
@@ -19,6 +20,33 @@ type Store interface {
|
||||
Search(ctx context.Context, vec []float32, topK int) ([]Result, error)
|
||||
}
|
||||
|
||||
// Record is a stored vector read back whole — id, vector and metadata — as
|
||||
// opposed to Result, which is a search hit and carries a score instead of the
|
||||
// vector.
|
||||
type Record struct {
|
||||
ID string
|
||||
Vec []float32
|
||||
Meta map[string]string
|
||||
}
|
||||
|
||||
// Catalog is a Store that can also be enumerated by id prefix and deleted from.
|
||||
//
|
||||
// Search is not enough for every user of the vector table. Speaker profiles
|
||||
// (internal/speaker) need to list exactly their own rows without scoring
|
||||
// anything, because listing enrolled voices is not a similarity question, and
|
||||
// they need Delete because a voiceprint is data about a person and "forget this
|
||||
// voice" has to actually remove it. Note and fact recall use plain Store and are
|
||||
// unaffected.
|
||||
type Catalog interface {
|
||||
Store
|
||||
// ByPrefix returns every row whose id starts with prefix, in no particular
|
||||
// order. An empty prefix returns everything.
|
||||
ByPrefix(ctx context.Context, prefix string) ([]Record, error)
|
||||
// Delete removes one row by id. Deleting a row that is not there is not an
|
||||
// error: the caller asked for it to be gone and it is gone.
|
||||
Delete(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// item is a single stored vector with metadata.
|
||||
type item struct {
|
||||
id string
|
||||
@@ -32,14 +60,54 @@ type InMemoryStore struct {
|
||||
items []item
|
||||
}
|
||||
|
||||
// compile-time check: InMemoryStore satisfies Catalog.
|
||||
var _ Catalog = (*InMemoryStore)(nil)
|
||||
|
||||
func NewInMemoryStore() *InMemoryStore {
|
||||
return &InMemoryStore{}
|
||||
}
|
||||
|
||||
// Insert upserts by id, matching the persistent store.MemoryStore: a repeated
|
||||
// id replaces the prior row rather than accumulating a second copy. Re-indexing
|
||||
// a note is an update, and re-enrolling a voice must replace the old voiceprint
|
||||
// rather than leave it searchable.
|
||||
func (s *InMemoryStore) Insert(_ context.Context, id string, vec []float32, meta map[string]string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.items {
|
||||
if s.items[i].id == id {
|
||||
s.items[i] = item{id: id, vec: vec, meta: meta}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
s.items = append(s.items, item{id: id, vec: vec, meta: meta})
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ByPrefix implements Catalog.
|
||||
func (s *InMemoryStore) ByPrefix(_ context.Context, prefix string) ([]Record, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var out []Record
|
||||
for _, it := range s.items {
|
||||
if !strings.HasPrefix(it.id, prefix) {
|
||||
continue
|
||||
}
|
||||
out = append(out, Record{ID: it.id, Vec: append([]float32(nil), it.vec...), Meta: it.meta})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Delete implements Catalog.
|
||||
func (s *InMemoryStore) Delete(_ context.Context, id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.items {
|
||||
if s.items[i].id == id {
|
||||
s.items = append(s.items[:i], s.items[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
@@ -40,8 +41,9 @@ func TestTopKTruncation(t *testing.T) {
|
||||
s := NewInMemoryStore()
|
||||
ctx := context.Background()
|
||||
|
||||
// Distinct ids: Insert upserts by id, so ten rows need ten ids.
|
||||
for i := 0; i < 10; i++ {
|
||||
s.Insert(ctx, "", []float32{float32(i) / 10, 0, 0}, nil)
|
||||
s.Insert(ctx, fmt.Sprintf("n%d", i), []float32{float32(i) / 10, 0, 0}, nil)
|
||||
}
|
||||
|
||||
results, err := s.Search(ctx, []float32{1, 0, 0}, 3)
|
||||
|
||||
+150
-34
@@ -27,14 +27,46 @@ var listenRE = regexp.MustCompile(`listening on (https?://\S+)`)
|
||||
type LLMPhraser struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
port string
|
||||
cmd *exec.Cmd
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
|
||||
// tmpl — the hand-written Russian nudges. Default path for nudges; see
|
||||
// Config.LLMNudges. nil only if the template file failed to load.
|
||||
tmpl *NudgeTemplates
|
||||
|
||||
// spawnCtx — the parent of every llama-server this phraser starts, i.e. the
|
||||
// daemon's own context. Deliberately NOT the per-request context of the call
|
||||
// that asked for a model swap: that one is cancelled the moment the request
|
||||
// returns, which would kill the model it had just loaded.
|
||||
spawnCtx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
// launch / probe — the two side effects of a swap, injectable so the swap
|
||||
// logic is testable without a real llama-server and a real model file.
|
||||
// launch is nil when this phraser does not own its server (NewLLMPhraserAt),
|
||||
// which is also what makes Swap refuse there.
|
||||
launch func(ctx context.Context, cfg Config) (backend, error)
|
||||
probe func(ctx context.Context, base string) (string, error)
|
||||
|
||||
// swapMu — single-flight around Swap. Held for the whole swap, including the
|
||||
// model load, so two concurrent swap requests can never both be loading.
|
||||
swapMu sync.Mutex
|
||||
|
||||
// mu guards everything below: the live backend, the swap gate and the
|
||||
// in-flight request count. See acquire/quiesce in swap.go.
|
||||
mu sync.Mutex
|
||||
be backend
|
||||
live liveModel
|
||||
swapping bool
|
||||
inflight int
|
||||
observers []func(baseURL string)
|
||||
}
|
||||
|
||||
// liveModel — what is actually loaded right now. Distinct from Config, which
|
||||
// stays immutable after construction: a swap changes these three fields and
|
||||
// nothing else, so no reader of cfg (prompts, grammar, timeouts) races a swap.
|
||||
type liveModel struct {
|
||||
ModelPath string
|
||||
NGpuLayers int
|
||||
NCtx int
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
@@ -85,15 +117,21 @@ func DefaultConfig(modelPath string) Config {
|
||||
func NewLLMPhraser(ctx context.Context, cfg Config) (*LLMPhraser, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
p := &LLMPhraser{
|
||||
cfg: cfg,
|
||||
client: &http.Client{Timeout: cfg.Timeout},
|
||||
cancel: cancel,
|
||||
tmpl: loadNudgeTemplates(),
|
||||
cfg: cfg,
|
||||
client: &http.Client{Timeout: cfg.Timeout},
|
||||
tmpl: loadNudgeTemplates(),
|
||||
spawnCtx: ctx,
|
||||
cancel: cancel,
|
||||
launch: spawnLlamaServer,
|
||||
probe: defaultProbe,
|
||||
live: liveModel{ModelPath: cfg.ModelPath, NGpuLayers: cfg.NGpuLayers, NCtx: cfg.NCtx},
|
||||
}
|
||||
if err := p.start(ctx); err != nil {
|
||||
be, err := p.launch(ctx, cfg)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
p.be = be
|
||||
return p, nil
|
||||
}
|
||||
|
||||
@@ -106,11 +144,17 @@ func NewLLMPhraser(ctx context.Context, cfg Config) (*LLMPhraser, error) {
|
||||
// still uses NewLLMPhraser and still owns its own child process.
|
||||
func NewLLMPhraserAt(baseURL string, cfg Config) *LLMPhraser {
|
||||
return &LLMPhraser{
|
||||
cfg: cfg,
|
||||
client: &http.Client{Timeout: cfg.Timeout},
|
||||
port: strings.TrimSuffix(baseURL, "/"),
|
||||
cancel: func() {},
|
||||
tmpl: loadNudgeTemplates(),
|
||||
cfg: cfg,
|
||||
client: &http.Client{Timeout: cfg.Timeout},
|
||||
tmpl: loadNudgeTemplates(),
|
||||
spawnCtx: context.Background(),
|
||||
cancel: func() {},
|
||||
probe: defaultProbe,
|
||||
// launch stays nil: we did not start this server, so we must not stop it.
|
||||
// Swap therefore refuses here (ErrSwapNotOwned) instead of killing a
|
||||
// server another process depends on.
|
||||
be: borrowedBackend(strings.TrimSuffix(baseURL, "/")),
|
||||
live: liveModel{ModelPath: cfg.ModelPath, NGpuLayers: cfg.NGpuLayers, NCtx: cfg.NCtx},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,16 +170,66 @@ func loadNudgeTemplates() *NudgeTemplates {
|
||||
return nt
|
||||
}
|
||||
|
||||
func (p *LLMPhraser) start(ctx context.Context) error {
|
||||
// backend — one llama-server this phraser talks to. Two implementations: a
|
||||
// llamaProc we spawned and must reap, and a borrowedBackend someone else owns.
|
||||
type backend interface {
|
||||
BaseURL() string
|
||||
Close() error
|
||||
}
|
||||
|
||||
// borrowedBackend — a server started and owned by someone else (the phrasing
|
||||
// scorer's shared llama-server). Closing it is a no-op by construction.
|
||||
type borrowedBackend string
|
||||
|
||||
func (b borrowedBackend) BaseURL() string { return string(b) }
|
||||
func (b borrowedBackend) Close() error { return nil }
|
||||
|
||||
// llamaProc — a llama-server child process plus the goroutine reading its
|
||||
// stderr. Close kills and reaps it; see the Pdeathsig note in spawnLlamaServer.
|
||||
type llamaProc struct {
|
||||
base string
|
||||
cmd *exec.Cmd
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func (l *llamaProc) BaseURL() string { return l.base }
|
||||
|
||||
func (l *llamaProc) Close() error {
|
||||
l.cancel()
|
||||
if l.cmd != nil && l.cmd.Process != nil {
|
||||
_ = l.cmd.Process.Kill()
|
||||
_ = l.cmd.Wait() // reap the process — without Wait, the child becomes a zombie
|
||||
}
|
||||
l.wg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
// spawnLlamaServer starts one llama-server for cfg and waits until it says which
|
||||
// address it is listening on. ctx owns the process lifetime, so it must be the
|
||||
// daemon's context, not a request's.
|
||||
func spawnLlamaServer(ctx context.Context, cfg Config) (backend, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
p, err := startLlamaProc(ctx, cfg)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
p.cancel = cancel
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func startLlamaProc(ctx context.Context, cfg Config) (*llamaProc, error) {
|
||||
p := &llamaProc{}
|
||||
args := []string{
|
||||
"-m", p.cfg.ModelPath,
|
||||
"-m", cfg.ModelPath,
|
||||
"--host", "127.0.0.1",
|
||||
"--port", extractPort(p.cfg.Listen),
|
||||
"-c", fmt.Sprintf("%d", p.cfg.NCtx),
|
||||
"-ngl", fmt.Sprintf("%d", p.cfg.NGpuLayers),
|
||||
"--port", extractPort(cfg.Listen),
|
||||
"-c", fmt.Sprintf("%d", cfg.NCtx),
|
||||
"-ngl", fmt.Sprintf("%d", cfg.NGpuLayers),
|
||||
"--no-webui",
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, p.cfg.BinPath, args...)
|
||||
cmd := exec.CommandContext(ctx, cfg.BinPath, args...)
|
||||
// Pdeathsig: the kernel SIGKILLs llama-server the moment mavend dies — by
|
||||
// ANY means, including SIGKILL/OOM/panic where our Close() never runs. Without
|
||||
// it a hard-killed mavend orphans its llama-server (reparented to init, keeps
|
||||
@@ -148,12 +242,12 @@ func (p *LLMPhraser) start(ctx context.Context) error {
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("llm: stderr pipe: %w", err)
|
||||
return nil, fmt.Errorf("llm: stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
stderr.Close()
|
||||
return fmt.Errorf("llm: start: %w", err)
|
||||
return nil, fmt.Errorf("llm: start: %w", err)
|
||||
}
|
||||
|
||||
portCh := make(chan string, 1)
|
||||
@@ -186,32 +280,44 @@ func (p *LLMPhraser) start(ctx context.Context) error {
|
||||
|
||||
select {
|
||||
case addr := <-portCh:
|
||||
p.port = addr
|
||||
return nil
|
||||
p.base = addr
|
||||
return p, nil
|
||||
case err := <-errCh:
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
return fmt.Errorf("llm: server output: %w", err)
|
||||
return nil, fmt.Errorf("llm: server output: %w", err)
|
||||
case <-ctx.Done():
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
return ctx.Err()
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(60 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
return fmt.Errorf("llm: server did not start within 60s")
|
||||
return nil, fmt.Errorf("llm: server did not start within 60s")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *LLMPhraser) BaseURL() string { return p.port }
|
||||
// BaseURL is the llama-server this phraser talks to right now. It changes when
|
||||
// the model is swapped, so callers that cache it must register an observer
|
||||
// (OnSwap) rather than keeping the string forever.
|
||||
func (p *LLMPhraser) BaseURL() string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.be == nil {
|
||||
return ""
|
||||
}
|
||||
return p.be.BaseURL()
|
||||
}
|
||||
|
||||
func (p *LLMPhraser) Close() error {
|
||||
p.cancel()
|
||||
if p.cmd != nil && p.cmd.Process != nil {
|
||||
_ = p.cmd.Process.Kill()
|
||||
_ = p.cmd.Wait() // reap the process — without Wait, the child becomes a zombie
|
||||
p.mu.Lock()
|
||||
be := p.be
|
||||
p.be = nil
|
||||
p.mu.Unlock()
|
||||
if be != nil {
|
||||
return be.Close()
|
||||
}
|
||||
p.wg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -350,6 +456,11 @@ func chatSystemPrompt(block func() string) string {
|
||||
// the LLM completion endpoint. Like chatWithSystem but for an arbitrary message
|
||||
// slice — the caller owns the system prompt placement.
|
||||
func (p *LLMPhraser) chatWithMessages(ctx context.Context, msgs []chatMsg, maxTokens int) (string, error) {
|
||||
base, release, err := p.acquire()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer release()
|
||||
req := chatReq{
|
||||
Messages: msgs,
|
||||
Temperature: 0.7,
|
||||
@@ -360,7 +471,7 @@ func (p *LLMPhraser) chatWithMessages(ctx context.Context, msgs []chatMsg, maxTo
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("llm: marshal: %w", err)
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", p.port+"/v1/chat/completions", bytes.NewReader(body))
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", base+"/v1/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("llm: request: %w", err)
|
||||
}
|
||||
@@ -493,6 +604,11 @@ func (p *LLMPhraser) chat(ctx context.Context, userPrompt string) (string, error
|
||||
}
|
||||
|
||||
func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, maxTokens int) (string, error) {
|
||||
base, release, err := p.acquire()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer release()
|
||||
req := chatReq{
|
||||
Messages: []chatMsg{
|
||||
{Role: "system", Content: system},
|
||||
@@ -507,7 +623,7 @@ func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, ma
|
||||
return "", fmt.Errorf("llm: marshal: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", p.port+"/v1/chat/completions", bytes.NewReader(body))
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", base+"/v1/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("llm: request: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
)
|
||||
|
||||
// Swapping the resident model without restarting the daemon (Vikunja #250).
|
||||
//
|
||||
// Three properties this file exists to hold, in order of importance:
|
||||
//
|
||||
// 1. NEVER two models resident at once. The deploy target is a laptop iGPU
|
||||
// with the whole 1.7B offloaded to it (`n_gpu_layers: 99`); loading a second
|
||||
// model beside the first is how you OOM the box, and a blue/green swap that
|
||||
// "keeps the old one warm until the new one answers" does exactly that. So
|
||||
// the old server is killed FIRST and the new one loaded after. The cost of
|
||||
// that ordering is a window with no model at all, which is why:
|
||||
//
|
||||
// 2. A swap is atomic from a turn's point of view. An in-flight turn finishes
|
||||
// on the old model — Swap waits for the last one to return before killing
|
||||
// anything. A turn that arrives during the swap is REFUSED immediately with
|
||||
// ErrSwapping rather than blocked: every phrasing path already has a
|
||||
// fallback (templates, "вот что я нашла", the classifier for routing), so a
|
||||
// fast refusal degrades one turn instead of hanging it for the length of a
|
||||
// model load. No turn ever gets half of one model and half of another.
|
||||
//
|
||||
// 3. A failed load rolls back to the model that was working. The new server is
|
||||
// probed (it must say which model it loaded) before it is published; if the
|
||||
// launch or the probe fails, the previous config is relaunched and the
|
||||
// phraser goes back to serving. Only if the rollback ALSO fails is the
|
||||
// phraser left without a backend, and then it says so loudly and every turn
|
||||
// degrades rather than breaks.
|
||||
//
|
||||
// Not here, deliberately: nothing calls Swap on a timer, and no act or intent can
|
||||
// reach it. It is an IPC method behind the step-up gate, i.e. owner-triggered.
|
||||
|
||||
var (
|
||||
// ErrSwapping — a turn arrived while the model was being swapped. Callers
|
||||
// treat it like any other LLM error and use their fallback.
|
||||
ErrSwapping = errors.New("phraser: model swap in progress")
|
||||
|
||||
// ErrSwapNotOwned — this phraser did not start its llama-server, so it must
|
||||
// not stop one (NewLLMPhraserAt: the eval harness shares a server).
|
||||
ErrSwapNotOwned = errors.New("phraser: llama-server is not ours to swap")
|
||||
|
||||
// ErrNoBackend — no model is loaded at all. Only reachable after a failed
|
||||
// swap whose rollback also failed.
|
||||
ErrNoBackend = errors.New("phraser: no llama-server loaded")
|
||||
|
||||
// ErrSwapBusy — a turn was still running when the drain deadline expired, so
|
||||
// the swap was abandoned. Nothing was killed; ask again.
|
||||
ErrSwapBusy = errors.New("phraser: turns still in flight, swap abandoned")
|
||||
)
|
||||
|
||||
// SwapSpec — what to load. Zero NGpuLayers/NCtx keep whatever is live, so the
|
||||
// common case ("same settings, different gguf") is one field.
|
||||
type SwapSpec struct {
|
||||
ModelPath string
|
||||
NGpuLayers int
|
||||
NCtx int
|
||||
}
|
||||
|
||||
// SwapResult — what happened. Model is the identity the NEW server reported, so
|
||||
// it is evidence rather than an echo of the request: if the file at ModelPath is
|
||||
// not what the operator thought it was, this is where that shows up.
|
||||
type SwapResult struct {
|
||||
Model string
|
||||
BaseURL string
|
||||
ModelPath string
|
||||
RolledBack bool
|
||||
Took time.Duration
|
||||
}
|
||||
|
||||
// drainTimeout — how long Swap waits for in-flight turns before giving up. A
|
||||
// turn is at most Config.Timeout (30s in deploy) plus the model's own latency;
|
||||
// 90s covers a slow Thinking generation without wedging the caller forever.
|
||||
const drainTimeout = 90 * time.Second
|
||||
|
||||
// probeTimeout — how long the new server gets to answer "which model do you
|
||||
// have". The load itself is bounded by spawnLlamaServer's own 60s wait.
|
||||
const probeTimeout = 30 * time.Second
|
||||
|
||||
// defaultProbe asks the server which model it has loaded. This is the health
|
||||
// check: a server that answers /v1/models has finished loading weights and is
|
||||
// serving, and its answer is the identity we report back.
|
||||
func defaultProbe(ctx context.Context, base string) (string, error) {
|
||||
return llm.ModelID(ctx, base)
|
||||
}
|
||||
|
||||
// OnSwap registers a callback fired with the new base URL every time the live
|
||||
// backend changes, including after a rollback. Holders of an *llm.Client (the
|
||||
// LLM router, the replier, the mail extractor) register SetBaseURL here so a
|
||||
// swap re-points them without rebuilding the router or the handler.
|
||||
//
|
||||
// Callbacks run with no lock held, in registration order.
|
||||
func (p *LLMPhraser) OnSwap(fn func(baseURL string)) {
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.observers = append(p.observers, fn)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// LiveModel is the model file currently loaded (and its load settings). Empty
|
||||
// ModelPath means no model is loaded.
|
||||
func (p *LLMPhraser) LiveModel() (path string, nGpuLayers, nCtx int) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.live.ModelPath, p.live.NGpuLayers, p.live.NCtx
|
||||
}
|
||||
|
||||
// acquire reserves a slot for one request and returns the base URL to use.
|
||||
// Every request path must call it and must call the returned release exactly
|
||||
// once — that count is what Swap drains.
|
||||
func (p *LLMPhraser) acquire() (string, func(), error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.swapping {
|
||||
return "", nil, ErrSwapping
|
||||
}
|
||||
if p.be == nil {
|
||||
return "", nil, ErrNoBackend
|
||||
}
|
||||
p.inflight++
|
||||
base := p.be.BaseURL()
|
||||
var once bool
|
||||
return base, func() {
|
||||
p.mu.Lock()
|
||||
if !once {
|
||||
once = true
|
||||
p.inflight--
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Swap loads another model in place of the live one. See the file comment for
|
||||
// the properties it guarantees. Returns the new model's reported identity, or
|
||||
// an error plus RolledBack=true when the old model was put back.
|
||||
//
|
||||
// ctx bounds the drain and the probe. It does NOT own the new server's lifetime
|
||||
// — that is the daemon's context, captured at construction — so a swap survives
|
||||
// the request that asked for it.
|
||||
func (p *LLMPhraser) Swap(ctx context.Context, spec SwapSpec) (SwapResult, error) {
|
||||
if spec.ModelPath == "" {
|
||||
return SwapResult{}, fmt.Errorf("phraser: swap needs a model path")
|
||||
}
|
||||
p.swapMu.Lock()
|
||||
defer p.swapMu.Unlock()
|
||||
|
||||
if p.launch == nil {
|
||||
return SwapResult{}, ErrSwapNotOwned
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
oldLive := p.liveSnapshot()
|
||||
newLive := liveModel{
|
||||
ModelPath: spec.ModelPath,
|
||||
NGpuLayers: pickInt(spec.NGpuLayers, oldLive.NGpuLayers),
|
||||
NCtx: pickInt(spec.NCtx, oldLive.NCtx),
|
||||
}
|
||||
if newLive == oldLive && p.BaseURL() != "" {
|
||||
// Already serving exactly this. Report the live identity rather than
|
||||
// pointlessly unloading and reloading the same weights.
|
||||
base := p.BaseURL()
|
||||
id, err := p.probeWith(ctx, base)
|
||||
if err != nil {
|
||||
return SwapResult{}, err
|
||||
}
|
||||
return SwapResult{Model: id, BaseURL: base, ModelPath: oldLive.ModelPath, Took: time.Since(started)}, nil
|
||||
}
|
||||
|
||||
if err := p.quiesce(ctx); err != nil {
|
||||
return SwapResult{}, err
|
||||
}
|
||||
defer p.resume()
|
||||
|
||||
// Property 1: the old model leaves the GPU before the new one arrives.
|
||||
p.mu.Lock()
|
||||
old := p.be
|
||||
p.be = nil
|
||||
p.mu.Unlock()
|
||||
if old != nil {
|
||||
_ = old.Close()
|
||||
}
|
||||
|
||||
be, err := p.loadAndProbe(ctx, newLive)
|
||||
if err != nil {
|
||||
log.Printf("phraser: swap to %s FAILED (%v) — rolling back to %s", newLive.ModelPath, err, oldLive.ModelPath)
|
||||
rb, rbErr := p.loadAndProbe(ctx, oldLive)
|
||||
if rbErr != nil {
|
||||
log.Printf("phraser: ROLLBACK to %s ALSO FAILED (%v) — no model is loaded, every phrasing path is on its fallback and routing is on the classifier until the daemon is restarted", oldLive.ModelPath, rbErr)
|
||||
return SwapResult{RolledBack: true, Took: time.Since(started)},
|
||||
fmt.Errorf("phraser: swap failed (%w) and rollback failed too: %v", err, rbErr)
|
||||
}
|
||||
p.publish(rb, oldLive)
|
||||
return SwapResult{
|
||||
Model: rb.id, BaseURL: rb.be.BaseURL(), ModelPath: oldLive.ModelPath,
|
||||
RolledBack: true, Took: time.Since(started),
|
||||
},
|
||||
fmt.Errorf("phraser: swap to %s failed, rolled back to %s: %w", newLive.ModelPath, oldLive.ModelPath, err)
|
||||
}
|
||||
p.publish(be, newLive)
|
||||
log.Printf("phraser: model swapped to %s (%s) at %s in %s", newLive.ModelPath, be.id, be.be.BaseURL(), time.Since(started).Round(time.Millisecond))
|
||||
return SwapResult{
|
||||
Model: be.id, BaseURL: be.be.BaseURL(), ModelPath: newLive.ModelPath,
|
||||
Took: time.Since(started),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// loaded — a started server plus the identity it reported.
|
||||
type loaded struct {
|
||||
be backend
|
||||
id string
|
||||
}
|
||||
|
||||
// loadAndProbe starts a server for lm and verifies it answers. A server that
|
||||
// starts but will not say what it loaded is treated as a failed load and is
|
||||
// killed here — publishing it would hand every turn to a backend we could not
|
||||
// confirm.
|
||||
func (p *LLMPhraser) loadAndProbe(ctx context.Context, lm liveModel) (loaded, error) {
|
||||
cfg := p.cfg
|
||||
cfg.ModelPath = lm.ModelPath
|
||||
cfg.NGpuLayers = lm.NGpuLayers
|
||||
cfg.NCtx = lm.NCtx
|
||||
// p.spawnCtx, not ctx: the process must outlive the request asking for it.
|
||||
be, err := p.launch(p.spawnCtx, cfg)
|
||||
if err != nil {
|
||||
return loaded{}, err
|
||||
}
|
||||
id, err := p.probeWith(ctx, be.BaseURL())
|
||||
if err != nil {
|
||||
_ = be.Close()
|
||||
return loaded{}, fmt.Errorf("phraser: %s started but would not answer: %w", lm.ModelPath, err)
|
||||
}
|
||||
return loaded{be: be, id: id}, nil
|
||||
}
|
||||
|
||||
func (p *LLMPhraser) probeWith(ctx context.Context, base string) (string, error) {
|
||||
probe := p.probe
|
||||
if probe == nil {
|
||||
probe = defaultProbe
|
||||
}
|
||||
pctx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||
defer cancel()
|
||||
return probe(pctx, base)
|
||||
}
|
||||
|
||||
// quiesce closes the door on new turns and waits for the ones already running.
|
||||
// Polling rather than a sync.Cond: the wait happens once per swap, a 25ms poll
|
||||
// is invisible next to a model load, and a poll cannot deadlock on a release
|
||||
// path that panicked.
|
||||
func (p *LLMPhraser) quiesce(ctx context.Context) error {
|
||||
p.mu.Lock()
|
||||
if p.swapping {
|
||||
p.mu.Unlock()
|
||||
return ErrSwapping
|
||||
}
|
||||
p.swapping = true
|
||||
inflight := p.inflight
|
||||
p.mu.Unlock()
|
||||
if inflight == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(drainTimeout)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
p.resume()
|
||||
return ctx.Err()
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
p.mu.Lock()
|
||||
inflight = p.inflight
|
||||
p.mu.Unlock()
|
||||
if inflight == 0 {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
// Nothing has been killed yet, so abandoning is free: reopen the door
|
||||
// and let the operator try again rather than cutting a live turn off
|
||||
// mid-generation.
|
||||
p.resume()
|
||||
return fmt.Errorf("%w (%d still running after %s)", ErrSwapBusy, inflight, drainTimeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *LLMPhraser) resume() {
|
||||
p.mu.Lock()
|
||||
p.swapping = false
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// publish makes l the live backend and tells everyone holding a base URL.
|
||||
func (p *LLMPhraser) publish(l loaded, lm liveModel) {
|
||||
p.mu.Lock()
|
||||
p.be = l.be
|
||||
p.live = lm
|
||||
obs := make([]func(string), len(p.observers))
|
||||
copy(obs, p.observers)
|
||||
p.mu.Unlock()
|
||||
base := l.be.BaseURL()
|
||||
for _, fn := range obs {
|
||||
fn(base)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *LLMPhraser) liveSnapshot() liveModel {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.live
|
||||
}
|
||||
|
||||
// pickInt returns v when the caller set it, and fallback otherwise. 0 is the
|
||||
// "unset" value: -1 already means "offload every layer" and deploy uses 99, so
|
||||
// nothing legitimate asks for exactly zero GPU layers through this path.
|
||||
func pickInt(v, fallback int) int {
|
||||
if v == 0 {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeModel — a stand-in llama-server. It answers /v1/models with its own name
|
||||
// and /v1/chat/completions with a phrasing-contract reply that names itself, so
|
||||
// a test can tell WHICH model answered a turn — the property the swap is about.
|
||||
type fakeModel struct {
|
||||
srv *httptest.Server
|
||||
name string
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func newFakeModel(t *testing.T, name string) *fakeModel {
|
||||
t.Helper()
|
||||
f := &fakeModel{name: name}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1/models", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"data":[{"id":"/models/` + name + `.gguf"}]}`))
|
||||
})
|
||||
mux.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"choices":[{"message":{"content":"{\"response\":\"` + name + `\",\"mood\":\"neutral\"}"}}]}`))
|
||||
})
|
||||
f.srv = httptest.NewServer(mux)
|
||||
t.Cleanup(f.srv.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeModel) BaseURL() string { return f.srv.URL }
|
||||
func (f *fakeModel) Close() error { f.closed.Store(true); return nil }
|
||||
|
||||
// fakeFleet is the injected launcher: it hands out a prepared fakeModel per
|
||||
// model path, and refuses paths the test did not prepare (that is what a bad
|
||||
// gguf looks like from here). It also asserts the invariant that matters on a
|
||||
// laptop iGPU: never two servers alive at the same time.
|
||||
type fakeFleet struct {
|
||||
mu sync.Mutex
|
||||
models map[string]string // model path → fake name
|
||||
live int
|
||||
maxLive int
|
||||
launch int
|
||||
}
|
||||
|
||||
func (fl *fakeFleet) launcher(t *testing.T) func(context.Context, Config) (backend, error) {
|
||||
return func(ctx context.Context, cfg Config) (backend, error) {
|
||||
fl.mu.Lock()
|
||||
name, ok := fl.models[cfg.ModelPath]
|
||||
fl.launch++
|
||||
if !ok {
|
||||
fl.mu.Unlock()
|
||||
return nil, errors.New("no such model file: " + cfg.ModelPath)
|
||||
}
|
||||
fl.live++
|
||||
if fl.live > fl.maxLive {
|
||||
fl.maxLive = fl.live
|
||||
}
|
||||
fl.mu.Unlock()
|
||||
f := newFakeModel(t, name)
|
||||
return &fleetBackend{fleet: fl, model: f}, nil
|
||||
}
|
||||
}
|
||||
|
||||
type fleetBackend struct {
|
||||
fleet *fakeFleet
|
||||
model *fakeModel
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (b *fleetBackend) BaseURL() string { return b.model.BaseURL() }
|
||||
func (b *fleetBackend) Close() error {
|
||||
b.once.Do(func() {
|
||||
b.fleet.mu.Lock()
|
||||
b.fleet.live--
|
||||
b.fleet.mu.Unlock()
|
||||
})
|
||||
return b.model.Close()
|
||||
}
|
||||
|
||||
// newSwapPhraser builds an LLMPhraser with an injected launcher, so the swap
|
||||
// path is exercised without a gguf or a GPU.
|
||||
func newSwapPhraser(t *testing.T, fl *fakeFleet, modelPath string) *LLMPhraser {
|
||||
t.Helper()
|
||||
cfg := DefaultConfig(modelPath)
|
||||
cfg.Timeout = 5 * time.Second
|
||||
p := &LLMPhraser{
|
||||
cfg: cfg,
|
||||
client: &http.Client{Timeout: cfg.Timeout},
|
||||
spawnCtx: context.Background(),
|
||||
cancel: func() {},
|
||||
launch: fl.launcher(t),
|
||||
probe: defaultProbe,
|
||||
live: liveModel{ModelPath: modelPath, NGpuLayers: cfg.NGpuLayers, NCtx: cfg.NCtx},
|
||||
}
|
||||
be, err := p.launch(p.spawnCtx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("initial launch: %v", err)
|
||||
}
|
||||
p.be = be
|
||||
t.Cleanup(func() { p.Close() })
|
||||
return p
|
||||
}
|
||||
|
||||
func TestSwap_LoadsNewModelAndRepointsHolders(t *testing.T) {
|
||||
fl := &fakeFleet{models: map[string]string{"/m/old.gguf": "old", "/m/new.gguf": "new"}}
|
||||
p := newSwapPhraser(t, fl, "/m/old.gguf")
|
||||
|
||||
// A holder of the base URL (the LLM router's client, in the daemon).
|
||||
var seen []string
|
||||
p.OnSwap(func(base string) { seen = append(seen, base) })
|
||||
|
||||
before, err := p.PhraseChat(context.Background(), "привет", nil)
|
||||
if err != nil || before != "old" {
|
||||
t.Fatalf("before swap: %q, %v; want the old model to answer", before, err)
|
||||
}
|
||||
|
||||
res, err := p.Swap(context.Background(), SwapSpec{ModelPath: "/m/new.gguf"})
|
||||
if err != nil {
|
||||
t.Fatalf("Swap: %v", err)
|
||||
}
|
||||
if res.Model != "new" {
|
||||
t.Errorf("res.Model = %q; want the identity the NEW server reported (%q)", res.Model, "new")
|
||||
}
|
||||
if res.RolledBack {
|
||||
t.Errorf("res.RolledBack = true on a successful swap")
|
||||
}
|
||||
after, err := p.PhraseChat(context.Background(), "привет", nil)
|
||||
if err != nil || after != "new" {
|
||||
t.Fatalf("after swap: %q, %v; want the new model to answer", after, err)
|
||||
}
|
||||
if path, _, _ := p.LiveModel(); path != "/m/new.gguf" {
|
||||
t.Errorf("LiveModel = %q; want /m/new.gguf", path)
|
||||
}
|
||||
if len(seen) != 1 || seen[0] != p.BaseURL() {
|
||||
t.Errorf("observers saw %v; want exactly one call with the new base %q", seen, p.BaseURL())
|
||||
}
|
||||
if fl.maxLive > 1 {
|
||||
t.Errorf("%d servers were alive at once; the iGPU only fits one model", fl.maxLive)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwap_FailedLoadRollsBackToTheWorkingModel(t *testing.T) {
|
||||
fl := &fakeFleet{models: map[string]string{"/m/old.gguf": "old"}}
|
||||
p := newSwapPhraser(t, fl, "/m/old.gguf")
|
||||
|
||||
res, err := p.Swap(context.Background(), SwapSpec{ModelPath: "/m/broken.gguf"})
|
||||
if err == nil {
|
||||
t.Fatal("Swap to a model that will not load returned nil error")
|
||||
}
|
||||
if !res.RolledBack {
|
||||
t.Errorf("res.RolledBack = false; a failed swap must say it rolled back")
|
||||
}
|
||||
if res.Model != "old" {
|
||||
t.Errorf("res.Model = %q; want the old model back", res.Model)
|
||||
}
|
||||
// The point of the rollback: turns keep working.
|
||||
got, err := p.PhraseChat(context.Background(), "привет", nil)
|
||||
if err != nil || got != "old" {
|
||||
t.Fatalf("after rollback: %q, %v; want the old model serving again", got, err)
|
||||
}
|
||||
if path, _, _ := p.LiveModel(); path != "/m/old.gguf" {
|
||||
t.Errorf("LiveModel = %q; want the old model", path)
|
||||
}
|
||||
if fl.maxLive > 1 {
|
||||
t.Errorf("%d servers alive at once during a rollback", fl.maxLive)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwap_ProbeFailureIsTreatedAsAFailedLoad(t *testing.T) {
|
||||
// A server that starts but will not say what it loaded must never be
|
||||
// published — we would be serving turns from a backend we cannot confirm.
|
||||
fl := &fakeFleet{models: map[string]string{"/m/old.gguf": "old", "/m/mute.gguf": "mute"}}
|
||||
p := newSwapPhraser(t, fl, "/m/old.gguf")
|
||||
// Fail the probe once — for the newly launched server — and let the
|
||||
// rollback's probe through.
|
||||
calls := 0
|
||||
p.probe = func(ctx context.Context, base string) (string, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return "", errors.New("no answer from the new server")
|
||||
}
|
||||
return defaultProbe(ctx, base)
|
||||
}
|
||||
|
||||
_, err := p.Swap(context.Background(), SwapSpec{ModelPath: "/m/mute.gguf"})
|
||||
if err == nil {
|
||||
t.Fatal("Swap published a server that failed its probe")
|
||||
}
|
||||
if path, _, _ := p.LiveModel(); path != "/m/old.gguf" {
|
||||
t.Errorf("LiveModel = %q; want the old model after a failed probe", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwap_RollbackFailureLeavesNoBackendAndDegrades(t *testing.T) {
|
||||
fl := &fakeFleet{models: map[string]string{"/m/old.gguf": "old"}}
|
||||
p := newSwapPhraser(t, fl, "/m/old.gguf")
|
||||
// Make the rollback fail too: the old file "disappears" mid-swap.
|
||||
fl.mu.Lock()
|
||||
delete(fl.models, "/m/old.gguf")
|
||||
fl.mu.Unlock()
|
||||
|
||||
_, err := p.Swap(context.Background(), SwapSpec{ModelPath: "/m/broken.gguf"})
|
||||
if err == nil {
|
||||
t.Fatal("Swap returned nil when both the load and the rollback failed")
|
||||
}
|
||||
// Nothing is loaded, and the request path says so rather than panicking.
|
||||
if _, _, aerr := p.acquire(); !errors.Is(aerr, ErrNoBackend) {
|
||||
t.Errorf("acquire error = %v; want ErrNoBackend", aerr)
|
||||
}
|
||||
// Phrasing degrades to its fallback instead of failing the turn.
|
||||
got, err := p.PhraseChat(context.Background(), "привет", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PhraseChat after a total failure returned an error: %v", err)
|
||||
}
|
||||
if got == "" {
|
||||
t.Error("PhraseChat returned empty; the fallback must still say something")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwap_WaitsForInFlightTurnAndRefusesNewOnes(t *testing.T) {
|
||||
fl := &fakeFleet{models: map[string]string{"/m/old.gguf": "old", "/m/new.gguf": "new"}}
|
||||
p := newSwapPhraser(t, fl, "/m/old.gguf")
|
||||
|
||||
// Hold one turn open by taking a slot directly — the same slot every
|
||||
// request path takes.
|
||||
base, release, err := p.acquire()
|
||||
if err != nil {
|
||||
t.Fatalf("acquire: %v", err)
|
||||
}
|
||||
if base == "" {
|
||||
t.Fatal("acquire returned an empty base URL")
|
||||
}
|
||||
|
||||
swapped := make(chan error, 1)
|
||||
go func() { _, e := p.Swap(context.Background(), SwapSpec{ModelPath: "/m/new.gguf"}); swapped <- e }()
|
||||
|
||||
// While the swap waits to drain, a NEW turn is refused immediately rather
|
||||
// than blocked for the length of a model load.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
_, rel, aerr := p.acquire()
|
||||
if rel != nil {
|
||||
rel()
|
||||
}
|
||||
if errors.Is(aerr, ErrSwapping) {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("new turns were never refused during a swap (last error: %v)", aerr)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
// The swap cannot have completed while our turn was still in flight.
|
||||
select {
|
||||
case e := <-swapped:
|
||||
t.Fatalf("Swap finished before the in-flight turn released: %v", e)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
release()
|
||||
if e := <-swapped; e != nil {
|
||||
t.Fatalf("Swap after drain: %v", e)
|
||||
}
|
||||
got, err := p.PhraseChat(context.Background(), "привет", nil)
|
||||
if err != nil || got != "new" {
|
||||
t.Fatalf("after swap: %q, %v; want the new model", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwap_RefusedWhenWeDoNotOwnTheServer(t *testing.T) {
|
||||
// NewLLMPhraserAt points at a shared server the eval harness owns. Swapping
|
||||
// there would kill a server another process depends on.
|
||||
p := NewLLMPhraserAt("http://127.0.0.1:1/", DefaultConfig("/m/old.gguf"))
|
||||
if _, err := p.Swap(context.Background(), SwapSpec{ModelPath: "/m/new.gguf"}); !errors.Is(err, ErrSwapNotOwned) {
|
||||
t.Fatalf("Swap on a borrowed server = %v; want ErrSwapNotOwned", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwap_SameModelIsANoOp(t *testing.T) {
|
||||
fl := &fakeFleet{models: map[string]string{"/m/old.gguf": "old"}}
|
||||
p := newSwapPhraser(t, fl, "/m/old.gguf")
|
||||
launchesBefore := fl.launch
|
||||
|
||||
res, err := p.Swap(context.Background(), SwapSpec{ModelPath: "/m/old.gguf"})
|
||||
if err != nil {
|
||||
t.Fatalf("Swap to the live model: %v", err)
|
||||
}
|
||||
if res.Model != "old" {
|
||||
t.Errorf("res.Model = %q; want old", res.Model)
|
||||
}
|
||||
if fl.launch != launchesBefore {
|
||||
t.Errorf("%d extra launches; swapping to the live model must not reload weights", fl.launch-launchesBefore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwap_EmptyModelPathRefused(t *testing.T) {
|
||||
fl := &fakeFleet{models: map[string]string{"/m/old.gguf": "old"}}
|
||||
p := newSwapPhraser(t, fl, "/m/old.gguf")
|
||||
if _, err := p.Swap(context.Background(), SwapSpec{}); err == nil {
|
||||
t.Fatal("Swap with no model path returned nil error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package speaker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
)
|
||||
|
||||
// Enroll registers a voice under an id and a spoken name.
|
||||
//
|
||||
// Several separate samples are required (MinEnrollSamples, MinEnrollSeconds
|
||||
// total): a profile built from one sentence encodes that sentence as much as the
|
||||
// person, and the resulting threshold behaviour is unpredictable. The samples
|
||||
// are embedded individually and the voiceprints averaged, then re-normalised.
|
||||
//
|
||||
// Re-enrolling an existing id REPLACES the profile. That is the intended way to
|
||||
// improve a weak one, and it is why the store upserts by id.
|
||||
//
|
||||
// # The refused step
|
||||
//
|
||||
// The plan document's fourth bullet reads "unknown speakers are enrolled on
|
||||
// first interaction (prompt: 'кто это?')". That is refused. Enrolling a voice is
|
||||
// taking a biometric of a person; doing it automatically the first time someone
|
||||
// walks past the microphone is doing it to guests, without them being part of
|
||||
// the exchange, and a TTS question into a room is not consent from whoever
|
||||
// happens to answer. Enrolment here is an explicit act: an id, a name, and
|
||||
// samples deliberately recorded for the purpose. An unknown voice stays unknown,
|
||||
// which the rest of the system is built to cope with.
|
||||
func (r *Recognizer) Enroll(ctx context.Context, id, name string, samples []audio.Audio) (Profile, error) {
|
||||
id = NormalizeID(id)
|
||||
if !ValidID(id) {
|
||||
return Profile{}, fmt.Errorf("%w: %q", ErrBadID, id)
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = id
|
||||
}
|
||||
if len(samples) < MinEnrollSamples {
|
||||
return Profile{}, fmt.Errorf("%w: %d sample(s), need %d separate ones",
|
||||
ErrTooShort, len(samples), MinEnrollSamples)
|
||||
}
|
||||
|
||||
var total float64
|
||||
for i, s := range samples {
|
||||
if !s.Format.IsValid() {
|
||||
return Profile{}, fmt.Errorf("%w: sample %d: %+v", ErrBadFormat, i+1, s.Format)
|
||||
}
|
||||
total += seconds(s)
|
||||
}
|
||||
if total < MinEnrollSeconds {
|
||||
return Profile{}, fmt.Errorf("%w: %.1fs total, need %.1fs",
|
||||
ErrTooShort, total, MinEnrollSeconds)
|
||||
}
|
||||
|
||||
// Embed first, store second. A model failure halfway through must not leave
|
||||
// a half-built profile that would then be matched against.
|
||||
var (
|
||||
sum []float32
|
||||
dim int
|
||||
)
|
||||
for i, s := range samples {
|
||||
vec, err := r.embed(ctx, s)
|
||||
if err != nil {
|
||||
return Profile{}, fmt.Errorf("speaker: enroll %q sample %d: %w", id, i+1, err)
|
||||
}
|
||||
if sum == nil {
|
||||
sum = make([]float32, len(vec))
|
||||
dim = len(vec)
|
||||
} else if len(vec) != dim {
|
||||
// One model, one width. A mixed-width average would be nonsense.
|
||||
return Profile{}, fmt.Errorf("%w: sample %d is %d wide, expected %d",
|
||||
ErrBadVector, i+1, len(vec), dim)
|
||||
}
|
||||
for j, f := range vec {
|
||||
sum[j] += f
|
||||
}
|
||||
}
|
||||
mean, err := Normalize(sum)
|
||||
if err != nil {
|
||||
// Samples that cancel each other out to zero are not one voice.
|
||||
return Profile{}, fmt.Errorf("speaker: enroll %q: %w", id, err)
|
||||
}
|
||||
|
||||
p := Profile{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Enrolled: r.now().UTC(),
|
||||
Samples: len(samples),
|
||||
Dim: dim,
|
||||
Vec: mean,
|
||||
}
|
||||
meta := map[string]string{
|
||||
"name": p.Name,
|
||||
"samples": strconv.Itoa(p.Samples),
|
||||
"enrolled": p.Enrolled.Format(time.RFC3339),
|
||||
// kind marks the row for anything walking the vector table, so a future
|
||||
// export or debug page can tell a voiceprint from a note embedding
|
||||
// without parsing the id.
|
||||
"kind": "speaker",
|
||||
}
|
||||
if err := r.cat.Insert(ctx, Prefix+id, mean, meta); err != nil {
|
||||
return Profile{}, fmt.Errorf("speaker: enroll %q: %w", id, err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package speaker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
)
|
||||
|
||||
// Recognizer holds the embedder and the enrolled profiles.
|
||||
//
|
||||
// The profiles live in the shared vector table under the "speaker:" id prefix,
|
||||
// which is what the plan asked for and what keeps them inside the encrypted
|
||||
// store rather than in a sidecar file. They are read through memory.Catalog
|
||||
// (ByPrefix / Delete) rather than Search, because "who is enrolled" is not a
|
||||
// similarity question and note recall must never rank a voiceprint.
|
||||
type Recognizer struct {
|
||||
emb Embedder
|
||||
cat memory.Catalog
|
||||
threshold float64
|
||||
minSec float64
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// Config — the recognizer's knobs, built from config.SpeakerConfig.
|
||||
type Config struct {
|
||||
// Threshold — cosine similarity a match must beat. 0 ⇒ DefaultThreshold.
|
||||
Threshold float64
|
||||
// MinSeconds — least speech an identification will look at. 0 ⇒
|
||||
// DefaultMinSeconds.
|
||||
MinSeconds float64
|
||||
}
|
||||
|
||||
// New builds a Recognizer. emb nil ⇒ Disabled, which is this box's state and
|
||||
// makes every Identify answer ErrDisabled while enrolment and listing still
|
||||
// behave sensibly (they refuse for the same reason, with the same error).
|
||||
func New(emb Embedder, cat memory.Catalog, cfg Config) (*Recognizer, error) {
|
||||
if cat == nil {
|
||||
return nil, fmt.Errorf("speaker: no profile store")
|
||||
}
|
||||
if emb == nil {
|
||||
emb = Disabled{}
|
||||
}
|
||||
th := cfg.Threshold
|
||||
if th <= 0 {
|
||||
th = DefaultThreshold
|
||||
}
|
||||
min := cfg.MinSeconds
|
||||
if min <= 0 {
|
||||
min = DefaultMinSeconds
|
||||
}
|
||||
return &Recognizer{emb: emb, cat: cat, threshold: th, minSec: min, now: time.Now}, nil
|
||||
}
|
||||
|
||||
// Enabled reports whether an embedding model is actually wired. Surfaces use it
|
||||
// to say "recognition is off" once instead of failing every turn.
|
||||
func (r *Recognizer) Enabled() bool {
|
||||
_, disabled := r.emb.(Disabled)
|
||||
return !disabled
|
||||
}
|
||||
|
||||
// Threshold is the configured match floor, for a status line.
|
||||
func (r *Recognizer) Threshold() float64 { return r.threshold }
|
||||
|
||||
// Identify names the voice in a. ErrUnknown when nothing is close enough, which
|
||||
// is a normal answer and not a failure: a guest is a guest, and the caller
|
||||
// carries on with no speaker attached rather than guessing.
|
||||
//
|
||||
// Identification never decides whether Maven listens. It annotates the turn.
|
||||
func (r *Recognizer) Identify(ctx context.Context, a audio.Audio) (Match, error) {
|
||||
if !a.Format.IsValid() {
|
||||
return Match{}, fmt.Errorf("%w: %+v", ErrBadFormat, a.Format)
|
||||
}
|
||||
if seconds(a) < r.minSec {
|
||||
return Match{}, fmt.Errorf("%w: %.1fs, need %.1fs", ErrTooShort, seconds(a), r.minSec)
|
||||
}
|
||||
vec, err := r.embed(ctx, a)
|
||||
if err != nil {
|
||||
return Match{}, err
|
||||
}
|
||||
profiles, err := r.List(ctx)
|
||||
if err != nil {
|
||||
return Match{}, err
|
||||
}
|
||||
if len(profiles) == 0 {
|
||||
return Match{}, ErrNoProfiles
|
||||
}
|
||||
|
||||
best := Match{Score: -2}
|
||||
for _, p := range profiles {
|
||||
if s := Similarity(vec, p.Vec); s > best.Score {
|
||||
best = Match{Profile: p, Score: s}
|
||||
}
|
||||
}
|
||||
if best.Score < r.threshold {
|
||||
// The closest profile is reported in the error for a log line, because
|
||||
// "не узнала, ближе всего Ками на 0.61" is what makes a threshold
|
||||
// tunable. The caller must not use it as an identification.
|
||||
return Match{}, fmt.Errorf("%w (closest %s at %.2f, need %.2f)",
|
||||
ErrUnknown, best.Profile.ID, best.Score, r.threshold)
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
// List returns every enrolled profile, sorted by id so a listing is stable.
|
||||
func (r *Recognizer) List(ctx context.Context) ([]Profile, error) {
|
||||
recs, err := r.cat.ByPrefix(ctx, Prefix)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("speaker: list: %w", err)
|
||||
}
|
||||
out := make([]Profile, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
out = append(out, profileFromRecord(rec))
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Get returns one profile by id.
|
||||
func (r *Recognizer) Get(ctx context.Context, id string) (Profile, error) {
|
||||
id = NormalizeID(id)
|
||||
if !ValidID(id) {
|
||||
return Profile{}, fmt.Errorf("%w: %q", ErrBadID, id)
|
||||
}
|
||||
recs, err := r.cat.ByPrefix(ctx, Prefix+id)
|
||||
if err != nil {
|
||||
return Profile{}, fmt.Errorf("speaker: get: %w", err)
|
||||
}
|
||||
for _, rec := range recs {
|
||||
if rec.ID == Prefix+id {
|
||||
return profileFromRecord(rec), nil
|
||||
}
|
||||
}
|
||||
return Profile{}, fmt.Errorf("%w: %q", ErrNotFound, id)
|
||||
}
|
||||
|
||||
// Forget deletes a profile. This is the one operation that must always work:
|
||||
// a voiceprint is data about a person, and "перестань узнавать её" has to
|
||||
// actually remove it, not mark it inactive.
|
||||
func (r *Recognizer) Forget(ctx context.Context, id string) error {
|
||||
id = NormalizeID(id)
|
||||
if !ValidID(id) {
|
||||
return fmt.Errorf("%w: %q", ErrBadID, id)
|
||||
}
|
||||
if _, err := r.Get(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.cat.Delete(ctx, Prefix+id); err != nil {
|
||||
return fmt.Errorf("speaker: forget %q: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// embed runs the model and normalises the result.
|
||||
func (r *Recognizer) embed(ctx context.Context, a audio.Audio) ([]float32, error) {
|
||||
raw, err := r.emb.Embed(ctx, a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vec, err := Normalize(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return vec, nil
|
||||
}
|
||||
|
||||
// profileFromRecord reads a stored row back into a Profile. A row with
|
||||
// unreadable metadata still yields a usable voiceprint — the vector is the part
|
||||
// that matters, and losing a name should not lose the enrolment.
|
||||
func profileFromRecord(rec memory.Record) Profile {
|
||||
p := Profile{
|
||||
ID: trimPrefix(rec.ID),
|
||||
Vec: rec.Vec,
|
||||
Dim: len(rec.Vec),
|
||||
Name: rec.Meta["name"],
|
||||
}
|
||||
if s := rec.Meta["samples"]; s != "" {
|
||||
p.Samples = atoi(s)
|
||||
}
|
||||
if ts := rec.Meta["enrolled"]; ts != "" {
|
||||
if t, err := time.Parse(time.RFC3339, ts); err == nil {
|
||||
p.Enrolled = t
|
||||
}
|
||||
}
|
||||
if p.Name == "" {
|
||||
p.Name = p.ID
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func trimPrefix(id string) string {
|
||||
if len(id) > len(Prefix) && id[:len(Prefix)] == Prefix {
|
||||
return id[len(Prefix):]
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// atoi is a tolerant small-integer parse: metadata that is not a number reads
|
||||
// as 0 rather than failing the whole listing.
|
||||
func atoi(s string) int {
|
||||
n := 0
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return 0
|
||||
}
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// Package speaker is voice identification (Vikunja #255,
|
||||
// docs/plans/10-speaker-recognition.md).
|
||||
//
|
||||
// The shape is the same seam internal/vision uses: an Embedder turns audio into
|
||||
// a voiceprint, a Recognizer compares one against the enrolled profiles, and a
|
||||
// Disabled floor refuses politely when nothing is wired. On this box nothing is
|
||||
// wired, and that is the honest state — see "Blocked" below.
|
||||
//
|
||||
// # A voiceprint is not like the other vectors
|
||||
//
|
||||
// Everything else in the vector table is something he wrote or said. A speaker
|
||||
// profile is biometric data about a person, quite possibly a person who never
|
||||
// asked for Maven to exist. The rules that follow from that are in the code:
|
||||
//
|
||||
// - Enrolment is explicit and named. There is no "enrol the unknown voice
|
||||
// automatically" path; see the refusal in enroll.go.
|
||||
// - A profile is deletable, individually, and Forget really removes the row.
|
||||
// - Below the threshold the answer is "I do not know", never the closest
|
||||
// guess. A misattributed fact is worse than an unattributed one.
|
||||
// - Nothing here gates whether Maven listens or answers. Identification
|
||||
// annotates a turn; it never authorises one, and an unrecognised voice is
|
||||
// not turned away.
|
||||
// - Voiceprints never leave the box. They live in the encrypted store with
|
||||
// everything else and are never search input to anything external.
|
||||
//
|
||||
// # Blocked
|
||||
//
|
||||
// There is no speaker-embedding model on this box: no ECAPA-TDNN, no x-vector,
|
||||
// no wespeaker or titanet ONNX anywhere under /mnt/hdd1 or models/ (checked
|
||||
// 2026-08-01; the only ONNX files are the e5 text embedder and the piper voice).
|
||||
// There are also no enrolment samples. So Recognizer runs against Disabled and
|
||||
// every Identify answers ErrDisabled until a model lands.
|
||||
//
|
||||
// The MFCC + GMM "simplest floor" in the plan document is refused rather than
|
||||
// deferred. A hand-rolled spectral distance would identify people confidently
|
||||
// and wrongly, and its output would be written into facts as "Ками said this".
|
||||
// For a biometric, a bad floor is worse than none: no answer is honest, and a
|
||||
// wrong answer is a false memory about a person.
|
||||
package speaker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
)
|
||||
|
||||
// Prefix — the id prefix speaker profiles carry in the shared vector table.
|
||||
// It is what ByPrefix enumerates and what keeps voiceprints out of note recall.
|
||||
const Prefix = "speaker:"
|
||||
|
||||
// DefaultThreshold — cosine similarity a match must beat to be a match.
|
||||
//
|
||||
// 0.7 is the usual operating point for ECAPA-style embeddings on clean speech
|
||||
// and it is deliberately on the strict side here. The two error directions are
|
||||
// not symmetric: refusing to name a voice costs a "не узнала", while naming the
|
||||
// wrong person writes his wife's remark into a fact attributed to him.
|
||||
const DefaultThreshold = 0.7
|
||||
|
||||
// DefaultMinSeconds — how much speech an identification needs. Under about two
|
||||
// seconds a voiceprint is mostly noise and the similarity score is not worth
|
||||
// reading.
|
||||
const DefaultMinSeconds = 2.0
|
||||
|
||||
// MinEnrollSamples / MinEnrollSeconds — what enrolment requires. Several
|
||||
// separate utterances, not one long one: a profile built from a single sentence
|
||||
// encodes that sentence's prosody as much as the voice.
|
||||
const (
|
||||
MinEnrollSamples = 3
|
||||
MinEnrollSeconds = 9.0
|
||||
)
|
||||
|
||||
// Errors callers distinguish.
|
||||
var (
|
||||
// ErrDisabled — no embedding model is wired. The state of this box.
|
||||
ErrDisabled = errors.New("speaker: recognition is not configured")
|
||||
// ErrTooShort — not enough speech to say anything about.
|
||||
ErrTooShort = errors.New("speaker: not enough audio")
|
||||
// ErrUnknown — audio embedded fine, but no enrolled profile is close
|
||||
// enough. Not an error in the sense of something being broken: it is the
|
||||
// correct answer for a guest, and the caller should carry on without a
|
||||
// speaker rather than treat the turn as failed.
|
||||
ErrUnknown = errors.New("speaker: voice not recognised")
|
||||
// ErrNoProfiles — nobody is enrolled yet.
|
||||
ErrNoProfiles = errors.New("speaker: nobody is enrolled")
|
||||
// ErrNotFound — no profile with that id.
|
||||
ErrNotFound = errors.New("speaker: no such profile")
|
||||
// ErrBadID — an id that is empty or carries characters an id should not.
|
||||
ErrBadID = errors.New("speaker: invalid profile id")
|
||||
// ErrBadFormat — audio that is not the canonical 16 kHz mono PCM shape.
|
||||
ErrBadFormat = errors.New("speaker: audio format not supported")
|
||||
// ErrBadVector — an embedder returned something unusable (empty, or all
|
||||
// zeroes, which normalises to nothing and would match everything equally).
|
||||
ErrBadVector = errors.New("speaker: embedder returned an unusable vector")
|
||||
)
|
||||
|
||||
// Embedder turns speech into a voiceprint. Implementations are expected to
|
||||
// return an L2-normalised vector, because the whole store compares by dot
|
||||
// product; Normalize is applied anyway rather than trusted.
|
||||
//
|
||||
// This is the seam a downloaded ECAPA-TDNN ONNX model plugs into. It is an
|
||||
// interface rather than a concrete ONNX type so the package is testable with no
|
||||
// model on disk, which is the only way it could be tested here at all.
|
||||
type Embedder interface {
|
||||
Embed(ctx context.Context, a audio.Audio) ([]float32, error)
|
||||
// Dim is the vector width, used to reject a profile recorded with a
|
||||
// different model rather than silently scoring it as zero.
|
||||
Dim() int
|
||||
}
|
||||
|
||||
// Disabled is the floor: no model, no answers, no guesses.
|
||||
type Disabled struct{}
|
||||
|
||||
// Embed always fails with ErrDisabled.
|
||||
func (Disabled) Embed(context.Context, audio.Audio) ([]float32, error) { return nil, ErrDisabled }
|
||||
|
||||
// Dim is 0 for the disabled embedder.
|
||||
func (Disabled) Dim() int { return 0 }
|
||||
|
||||
// Profile — one enrolled voice.
|
||||
//
|
||||
// Name is what she calls the person out loud ("Ками"). ID is the stable handle
|
||||
// used in sources and metadata. Samples records how many utterances the
|
||||
// voiceprint was averaged from, so a profile enrolled from the bare minimum is
|
||||
// visibly weaker than one built from ten.
|
||||
type Profile struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Enrolled time.Time `json:"enrolled"`
|
||||
Samples int `json:"samples"`
|
||||
Dim int `json:"dim"`
|
||||
|
||||
// Vec is the voiceprint. Not serialised to any surface: a listing tells him
|
||||
// who is enrolled, it does not hand out the biometric itself.
|
||||
Vec []float32 `json:"-"`
|
||||
}
|
||||
|
||||
// Source is what a fact or note written during this speaker's turn is tagged
|
||||
// with, e.g. "tap:voice:speaker:kami". Attribution belongs in the source rather
|
||||
// than in the text, so it can be corrected or dropped later without rewriting
|
||||
// what was said.
|
||||
func (p Profile) Source(base string) string {
|
||||
if p.ID == "" {
|
||||
return base
|
||||
}
|
||||
return base + ":" + Prefix + p.ID
|
||||
}
|
||||
|
||||
// Match — an identification result. Score is cosine similarity in [-1, 1].
|
||||
type Match struct {
|
||||
Profile Profile
|
||||
Score float64
|
||||
}
|
||||
|
||||
// ValidID reports whether an id is usable as a profile handle. Deliberately
|
||||
// narrow: lowercase letters, digits, dash and underscore. Ids end up in note
|
||||
// sources and in vector-table keys, so a permissive id would be a way to write
|
||||
// into a neighbouring key space.
|
||||
func ValidID(id string) bool {
|
||||
if id == "" || len(id) > 64 {
|
||||
return false
|
||||
}
|
||||
for _, r := range id {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// NormalizeID lowercases and trims a proposed id before validating it, so
|
||||
// "Ками" typed as "Kami " does not fail for a reason nobody can see.
|
||||
func NormalizeID(id string) string {
|
||||
return strings.ToLower(strings.TrimSpace(id))
|
||||
}
|
||||
|
||||
// Normalize returns an L2-normalised copy of v, or ErrBadVector when there is
|
||||
// nothing to normalise. A zero vector is refused rather than passed on: it
|
||||
// scores 0 against everything, which reads as "no match" but for the wrong
|
||||
// reason and would hide a broken embedder.
|
||||
func Normalize(v []float32) ([]float32, error) {
|
||||
if len(v) == 0 {
|
||||
return nil, ErrBadVector
|
||||
}
|
||||
var sum float64
|
||||
for _, f := range v {
|
||||
if math.IsNaN(float64(f)) || math.IsInf(float64(f), 0) {
|
||||
return nil, ErrBadVector
|
||||
}
|
||||
sum += float64(f) * float64(f)
|
||||
}
|
||||
norm := math.Sqrt(sum)
|
||||
if norm == 0 {
|
||||
return nil, ErrBadVector
|
||||
}
|
||||
out := make([]float32, len(v))
|
||||
for i, f := range v {
|
||||
out[i] = float32(float64(f) / norm)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Similarity is the cosine similarity of two L2-normalised vectors. Different
|
||||
// widths score 0: a profile enrolled with another model must not accidentally
|
||||
// match, and 0 is below every sane threshold.
|
||||
func Similarity(a, b []float32) float64 {
|
||||
if len(a) != len(b) || len(a) == 0 {
|
||||
return 0
|
||||
}
|
||||
var sum float64
|
||||
for i := range a {
|
||||
sum += float64(a[i]) * float64(b[i])
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
// seconds is the playback length of a frame, for the minimum-audio checks.
|
||||
func seconds(a audio.Audio) float64 { return a.Duration() }
|
||||
@@ -0,0 +1,397 @@
|
||||
package speaker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
)
|
||||
|
||||
// fakeEmbedder returns a fixed vector per "voice", so a test can enrol one
|
||||
// person and present another without a model. Wobble adds a small perturbation
|
||||
// so repeated samples of one voice are close but not identical, which is what a
|
||||
// real embedder produces.
|
||||
type fakeEmbedder struct {
|
||||
vec []float32
|
||||
err error
|
||||
calls int
|
||||
wobble float32
|
||||
}
|
||||
|
||||
func (f *fakeEmbedder) Embed(_ context.Context, _ audio.Audio) ([]float32, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
out := append([]float32(nil), f.vec...)
|
||||
if f.wobble != 0 && len(out) > 1 {
|
||||
out[0] += f.wobble * float32(f.calls)
|
||||
out[1] -= f.wobble * float32(f.calls)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeEmbedder) Dim() int { return len(f.vec) }
|
||||
|
||||
// speech builds n seconds of the canonical audio shape.
|
||||
func speech(sec float64) audio.Audio {
|
||||
return audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, int(sec*16000)*2)}
|
||||
}
|
||||
|
||||
func newRec(t *testing.T, emb Embedder) (*Recognizer, memory.Catalog) {
|
||||
t.Helper()
|
||||
cat := memory.NewInMemoryStore()
|
||||
r, err := New(emb, cat, Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r, cat
|
||||
}
|
||||
|
||||
func enrolSamples(n int, sec float64) []audio.Audio {
|
||||
out := make([]audio.Audio, n)
|
||||
for i := range out {
|
||||
out[i] = speech(sec)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The state of this box: no model on disk. Every identification refuses rather
|
||||
// than guessing, and it says why.
|
||||
func TestDisabledRefusesEverything(t *testing.T) {
|
||||
r, _ := newRec(t, nil)
|
||||
if r.Enabled() {
|
||||
t.Error("a recognizer with no model reports itself enabled")
|
||||
}
|
||||
if _, err := r.Identify(context.Background(), speech(5)); !errors.Is(err, ErrDisabled) {
|
||||
t.Errorf("Identify = %v, want ErrDisabled", err)
|
||||
}
|
||||
if _, err := r.Enroll(context.Background(), "kami", "Ками", enrolSamples(3, 4)); !errors.Is(err, ErrDisabled) {
|
||||
t.Errorf("Enroll = %v, want ErrDisabled", err)
|
||||
}
|
||||
// Listing still works: knowing that nobody is enrolled needs no model.
|
||||
got, err := r.List(context.Background())
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Errorf("List = %v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRequiresAProfileStore(t *testing.T) {
|
||||
if _, err := New(nil, nil, Config{}); err == nil {
|
||||
t.Error("built a recognizer with nowhere to keep profiles")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrollThenIdentify(t *testing.T) {
|
||||
emb := &fakeEmbedder{vec: []float32{1, 0, 0, 0}, wobble: 0.01}
|
||||
r, _ := newRec(t, emb)
|
||||
ctx := context.Background()
|
||||
|
||||
p, err := r.Enroll(ctx, "Kami ", "Ками", enrolSamples(3, 4))
|
||||
if err != nil {
|
||||
t.Fatalf("enroll: %v", err)
|
||||
}
|
||||
if p.ID != "kami" {
|
||||
t.Errorf("id = %q, want the normalised %q", p.ID, "kami")
|
||||
}
|
||||
if p.Name != "Ками" || p.Samples != 3 || p.Dim != 4 {
|
||||
t.Errorf("profile = %+v", p)
|
||||
}
|
||||
|
||||
m, err := r.Identify(ctx, speech(5))
|
||||
if err != nil {
|
||||
t.Fatalf("identify: %v", err)
|
||||
}
|
||||
if m.Profile.ID != "kami" || m.Profile.Name != "Ками" {
|
||||
t.Errorf("match = %+v", m)
|
||||
}
|
||||
if m.Score < r.Threshold() {
|
||||
t.Errorf("score %.3f is below the threshold it supposedly passed", m.Score)
|
||||
}
|
||||
}
|
||||
|
||||
// The error direction that matters. Naming the wrong person writes a false
|
||||
// memory about them, so a voice that is not close enough gets no name at all.
|
||||
func TestUnfamiliarVoiceIsNotGuessed(t *testing.T) {
|
||||
emb := &fakeEmbedder{vec: []float32{1, 0, 0, 0}}
|
||||
r, _ := newRec(t, emb)
|
||||
ctx := context.Background()
|
||||
if _, err := r.Enroll(ctx, "kami", "Ками", enrolSamples(3, 4)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A different voice: orthogonal voiceprint, similarity 0.
|
||||
emb.vec = []float32{0, 1, 0, 0}
|
||||
m, err := r.Identify(ctx, speech(5))
|
||||
if !errors.Is(err, ErrUnknown) {
|
||||
t.Fatalf("Identify = %v, want ErrUnknown", err)
|
||||
}
|
||||
if m.Profile.ID != "" {
|
||||
t.Errorf("a refused identification still handed back %q", m.Profile.ID)
|
||||
}
|
||||
// The log line needs the near miss to make the threshold tunable.
|
||||
if !contains(err.Error(), "kami") {
|
||||
t.Errorf("error does not name the closest profile: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Just under the threshold is still unknown. A boundary this important gets its
|
||||
// own test rather than being implied.
|
||||
func TestThresholdIsAFloorNotASuggestion(t *testing.T) {
|
||||
cat := memory.NewInMemoryStore()
|
||||
emb := &fakeEmbedder{vec: []float32{1, 0}}
|
||||
r, err := New(emb, cat, Config{Threshold: 0.9})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
if _, err := r.Enroll(ctx, "kami", "Ками", enrolSamples(3, 4)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// cos ≈ 0.866, comfortably similar and still not similar enough.
|
||||
emb.vec = []float32{0.866, 0.5}
|
||||
if _, err := r.Identify(ctx, speech(5)); !errors.Is(err, ErrUnknown) {
|
||||
t.Fatalf("0.866 against a 0.9 threshold = %v, want ErrUnknown", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortAudioIsRefusedBeforeTheModelRuns(t *testing.T) {
|
||||
emb := &fakeEmbedder{vec: []float32{1, 0}}
|
||||
r, _ := newRec(t, emb)
|
||||
if _, err := r.Identify(context.Background(), speech(0.5)); !errors.Is(err, ErrTooShort) {
|
||||
t.Fatalf("got %v, want ErrTooShort", err)
|
||||
}
|
||||
if emb.calls != 0 {
|
||||
t.Error("a half-second of audio was sent to the model anyway")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongAudioFormatIsRefused(t *testing.T) {
|
||||
r, _ := newRec(t, &fakeEmbedder{vec: []float32{1, 0}})
|
||||
bad := audio.Audio{
|
||||
Format: audio.Format{SampleRate: 44100, Channels: 2, SampleBits: 16, Encoding: "pcm_s16le"},
|
||||
Bytes: make([]byte, 44100*4*5),
|
||||
}
|
||||
if _, err := r.Identify(context.Background(), bad); !errors.Is(err, ErrBadFormat) {
|
||||
t.Fatalf("got %v, want ErrBadFormat", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentifyWithNobodyEnrolled(t *testing.T) {
|
||||
r, _ := newRec(t, &fakeEmbedder{vec: []float32{1, 0}})
|
||||
if _, err := r.Identify(context.Background(), speech(5)); !errors.Is(err, ErrNoProfiles) {
|
||||
t.Fatalf("got %v, want ErrNoProfiles", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Enrolment is an explicit act with real samples behind it, not a byproduct of
|
||||
// someone speaking once.
|
||||
func TestEnrollmentRequiresSeveralRealSamples(t *testing.T) {
|
||||
r, _ := newRec(t, &fakeEmbedder{vec: []float32{1, 0}})
|
||||
ctx := context.Background()
|
||||
cases := []struct {
|
||||
name string
|
||||
samples []audio.Audio
|
||||
}{
|
||||
{"one long sample", enrolSamples(1, 30)},
|
||||
{"two samples", enrolSamples(2, 10)},
|
||||
{"three samples but seconds of audio", enrolSamples(3, 1)},
|
||||
{"none at all", nil},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if _, err := r.Enroll(ctx, "kami", "Ками", c.samples); !errors.Is(err, ErrTooShort) {
|
||||
t.Errorf("%s: %v, want ErrTooShort", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrollRejectsBadIDs(t *testing.T) {
|
||||
r, _ := newRec(t, &fakeEmbedder{vec: []float32{1, 0}})
|
||||
for _, id := range []string{"", " ", "../etc/passwd", "speaker:kami", "имя", "a/b", "x y"} {
|
||||
if _, err := r.Enroll(context.Background(), id, "n", enrolSamples(3, 4)); !errors.Is(err, ErrBadID) {
|
||||
t.Errorf("id %q accepted or wrong error: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-enrolling replaces the voiceprint. Leaving the old one searchable would
|
||||
// mean a person's rejected profile keeps matching them.
|
||||
func TestReEnrollReplaces(t *testing.T) {
|
||||
emb := &fakeEmbedder{vec: []float32{1, 0, 0}}
|
||||
r, _ := newRec(t, emb)
|
||||
ctx := context.Background()
|
||||
if _, err := r.Enroll(ctx, "kami", "Ками", enrolSamples(3, 4)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
emb.vec = []float32{0, 1, 0}
|
||||
if _, err := r.Enroll(ctx, "kami", "Ками", enrolSamples(4, 4)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err := r.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("%d profiles after re-enrolling one person", len(list))
|
||||
}
|
||||
if list[0].Samples != 4 {
|
||||
t.Errorf("sample count = %d, want the new 4", list[0].Samples)
|
||||
}
|
||||
// The new voiceprint is the one that matches.
|
||||
if m, err := r.Identify(ctx, speech(5)); err != nil || m.Score < 0.99 {
|
||||
t.Errorf("identify after re-enrol: %v (score %.3f)", err, m.Score)
|
||||
}
|
||||
}
|
||||
|
||||
// "Перестань узнавать её" has to actually delete the biometric.
|
||||
func TestForgetRemovesTheVoiceprint(t *testing.T) {
|
||||
emb := &fakeEmbedder{vec: []float32{1, 0}}
|
||||
r, cat := newRec(t, emb)
|
||||
ctx := context.Background()
|
||||
if _, err := r.Enroll(ctx, "guest", "Гостья", enrolSamples(3, 4)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Forget(ctx, "Guest "); err != nil {
|
||||
t.Fatalf("forget: %v", err)
|
||||
}
|
||||
recs, err := cat.ByPrefix(ctx, Prefix)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(recs) != 0 {
|
||||
t.Errorf("%d row(s) survived Forget", len(recs))
|
||||
}
|
||||
if _, err := r.Get(ctx, "guest"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("Get after Forget = %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := r.Forget(ctx, "guest"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("second Forget = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Voiceprints share the vector table with note and fact embeddings, so the
|
||||
// prefix has to actually partition it.
|
||||
func TestProfilesDoNotCollideWithNoteVectors(t *testing.T) {
|
||||
emb := &fakeEmbedder{vec: []float32{1, 0}}
|
||||
r, cat := newRec(t, emb)
|
||||
ctx := context.Background()
|
||||
if err := cat.Insert(ctx, "note:1", []float32{1, 0}, map[string]string{"text": "заметка"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.Enroll(ctx, "kami", "Ками", enrolSamples(3, 4)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err := r.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 1 || list[0].ID != "kami" {
|
||||
t.Errorf("listing picked up a non-speaker row: %+v", list)
|
||||
}
|
||||
// And an identical note vector is never returned as a match.
|
||||
m, err := r.Identify(ctx, speech(5))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Profile.ID != "kami" {
|
||||
t.Errorf("matched %q", m.Profile.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbedderFailurePropagates(t *testing.T) {
|
||||
emb := &fakeEmbedder{vec: []float32{1, 0}, err: errors.New("onnx fell over")}
|
||||
r, _ := newRec(t, emb)
|
||||
if _, err := r.Identify(context.Background(), speech(5)); err == nil {
|
||||
t.Error("a model failure was reported as a successful identification")
|
||||
}
|
||||
if _, err := r.Enroll(context.Background(), "kami", "К", enrolSamples(3, 4)); err == nil {
|
||||
t.Error("a model failure produced a profile")
|
||||
}
|
||||
}
|
||||
|
||||
// A zero vector scores 0 against everything, which reads as "no match" for the
|
||||
// wrong reason and would hide a broken model.
|
||||
func TestUnusableVectorsAreRefused(t *testing.T) {
|
||||
r, _ := newRec(t, &fakeEmbedder{vec: []float32{0, 0, 0}})
|
||||
if _, err := r.Enroll(context.Background(), "kami", "К", enrolSamples(3, 4)); !errors.Is(err, ErrBadVector) {
|
||||
t.Errorf("zero vector: %v, want ErrBadVector", err)
|
||||
}
|
||||
if _, err := Normalize(nil); !errors.Is(err, ErrBadVector) {
|
||||
t.Errorf("empty: %v", err)
|
||||
}
|
||||
if _, err := Normalize([]float32{float32(nan())}); !errors.Is(err, ErrBadVector) {
|
||||
t.Errorf("NaN: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProducesAUnitVector(t *testing.T) {
|
||||
v, err := Normalize([]float32{3, 4})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := Similarity(v, v); got < 0.999 || got > 1.001 {
|
||||
t.Errorf("self-similarity = %f, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A profile enrolled with another model must not accidentally match.
|
||||
func TestDifferentWidthsScoreZero(t *testing.T) {
|
||||
if got := Similarity([]float32{1, 0}, []float32{1, 0, 0}); got != 0 {
|
||||
t.Errorf("mismatched widths scored %f", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Attribution belongs in the source, so it can be corrected without rewriting
|
||||
// what was said.
|
||||
func TestProfileSource(t *testing.T) {
|
||||
p := Profile{ID: "kami"}
|
||||
if got := p.Source("tap:voice"); got != "tap:voice:speaker:kami" {
|
||||
t.Errorf("source = %q", got)
|
||||
}
|
||||
var anon Profile
|
||||
if got := anon.Source("tap:voice"); got != "tap:voice" {
|
||||
t.Errorf("unattributed source = %q, want the base unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidID(t *testing.T) {
|
||||
for _, ok := range []string{"kami", "guest-2", "a_b", "x"} {
|
||||
if !ValidID(ok) {
|
||||
t.Errorf("%q rejected", ok)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "Kami", "имя", "a b", "a/b", "a:b", "..", strings.Repeat("a", 65)} {
|
||||
if ValidID(bad) {
|
||||
t.Errorf("%q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileMetadataSurvivesARoundTrip(t *testing.T) {
|
||||
emb := &fakeEmbedder{vec: []float32{1, 0}}
|
||||
r, _ := newRec(t, emb)
|
||||
r.now = func() time.Time { return time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) }
|
||||
ctx := context.Background()
|
||||
if _, err := r.Enroll(ctx, "kami", "Ками", enrolSamples(3, 4)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := r.Get(ctx, "kami")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Name != "Ками" || got.Samples != 3 {
|
||||
t.Errorf("profile = %+v", got)
|
||||
}
|
||||
if !got.Enrolled.Equal(time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)) {
|
||||
t.Errorf("enrolled = %v", got.Enrolled)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool { return strings.Contains(s, sub) }
|
||||
|
||||
func nan() float64 { return math.NaN() }
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/memory"
|
||||
@@ -37,8 +38,10 @@ func (s *Store) VectorMemory() *MemoryStore {
|
||||
return &MemoryStore{db: s.db}
|
||||
}
|
||||
|
||||
// compile-time check: MemoryStore satisfies the memory.Store interface.
|
||||
// compile-time check: MemoryStore satisfies the memory.Store interface, and the
|
||||
// wider Catalog that speaker profiles need (enumerate by prefix, delete by id).
|
||||
var _ memory.Store = (*MemoryStore)(nil)
|
||||
var _ memory.Catalog = (*MemoryStore)(nil)
|
||||
|
||||
// Insert upserts a vector by id: a repeated id replaces the prior row rather
|
||||
// than accumulating duplicates (the note/fact ids are stable and unique, so a
|
||||
@@ -95,6 +98,56 @@ func (m *MemoryStore) Search(ctx context.Context, vec []float32, topK int) ([]me
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ByPrefix returns every row whose id starts with prefix, vectors included.
|
||||
//
|
||||
// This is not a similarity query and deliberately does not score anything:
|
||||
// listing the enrolled voices is a question about which rows exist, and asking
|
||||
// it through Search would mean inventing a query vector to rank them by. The
|
||||
// prefix is matched with LIKE against an escaped pattern, so a profile id
|
||||
// containing % or _ cannot widen the match.
|
||||
func (m *MemoryStore) ByPrefix(ctx context.Context, prefix string) ([]memory.Record, error) {
|
||||
pattern := escapeLike(prefix) + "%"
|
||||
rows, err := m.db.QueryContext(ctx,
|
||||
`SELECT id, vec, meta FROM memory_vectors WHERE id LIKE ? ESCAPE '\'`, pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory: by prefix %q: %w", prefix, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []memory.Record
|
||||
for rows.Next() {
|
||||
var id, metaJSON string
|
||||
var blob []byte
|
||||
if err := rows.Scan(&id, &blob, &metaJSON); err != nil {
|
||||
return nil, fmt.Errorf("memory: row: %w", err)
|
||||
}
|
||||
meta := map[string]string{}
|
||||
if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil {
|
||||
return nil, fmt.Errorf("memory: unmarshal meta for %q: %w", id, err)
|
||||
}
|
||||
out = append(out, memory.Record{ID: id, Vec: decodeVec(blob), Meta: meta})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("memory: rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Delete removes one vector by id. A row that is not there is not an error —
|
||||
// "forget this voice" is satisfied either way.
|
||||
func (m *MemoryStore) Delete(ctx context.Context, id string) error {
|
||||
if _, err := m.db.ExecContext(ctx, `DELETE FROM memory_vectors WHERE id = ?`, id); err != nil {
|
||||
return fmt.Errorf("memory: delete %q: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// escapeLike neutralises the LIKE wildcards in a literal prefix.
|
||||
func escapeLike(s string) string {
|
||||
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// encodeVec serializes a float32 slice as little-endian IEEE-754 bytes (4 bytes
|
||||
// per element) for the BLOB column.
|
||||
func encodeVec(v []float32) []byte {
|
||||
|
||||
@@ -56,6 +56,45 @@ func (s *Store) ProposeTool(ctx context.Context, name, utterance, scope string,
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// ProposeMCPTool is ProposeTool for a tool discovered on an MCP server
|
||||
// (Vikunja #251): the proposal already knows what it would run, so cmd and
|
||||
// destructive are written with it and Kami only has to press enable.
|
||||
//
|
||||
// It is still a PROPOSAL. Discovery cannot grant a capability — that is the
|
||||
// whole reason a server can be configured without its tools becoming live.
|
||||
// Like ProposeTool it never touches an existing row, so re-discovery on every
|
||||
// restart is idempotent and cannot silently re-arm a tool that was disabled or
|
||||
// change the cmd of one already enabled.
|
||||
func (s *Store) ProposeMCPTool(ctx context.Context, name, scope string, cmd []string, destructive bool, utterance string, ts time.Time) (bool, error) {
|
||||
if len(cmd) == 0 {
|
||||
return false, ErrToolCmd
|
||||
}
|
||||
if scope == "" {
|
||||
scope = "homelab"
|
||||
}
|
||||
raw, err := json.Marshal(cmd)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("propose mcp tool: %w", err)
|
||||
}
|
||||
d := 0
|
||||
if destructive {
|
||||
d = 1
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO tools (name, scope, cmd, destructive, status, utterance, created_ts, updated_ts)
|
||||
VALUES (?, ?, ?, ?, 'proposed', ?, ?, ?)
|
||||
ON CONFLICT(name) DO NOTHING`,
|
||||
name, scope, string(raw), d, utterance, ts.UnixMilli(), ts.UnixMilli())
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("propose mcp tool: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("propose mcp tool: rows affected: %w", err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// EnableTool fills cmd + destructive and flips status to 'enabled'. This is the
|
||||
// human "enable" act (the authed surface calls it); it upserts so enabling a
|
||||
// name that was never proposed still works. An empty cmd is refused — an
|
||||
|
||||
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -60,3 +61,64 @@ func TestToolLifecycle(t *testing.T) {
|
||||
t.Fatalf("disable absent must be no-op: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A discovered MCP tool arrives as a proposal that already knows its cmd, so
|
||||
// enabling it is one click rather than one retyped argv.
|
||||
func TestProposeMCPTool(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
cmd := []string{"mcp", "vikunja", "list_tasks"}
|
||||
|
||||
fresh, err := s.ProposeMCPTool(ctx, "vikunja_list_tasks", "mcp:vikunja", cmd, false, "mcp vikunja/list_tasks: List tasks", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !fresh {
|
||||
t.Fatal("first proposal should be new")
|
||||
}
|
||||
got, err := s.LookupTool(ctx, "vikunja_list_tasks")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != "proposed" {
|
||||
t.Fatalf("status = %q — discovery must never enable", got.Status)
|
||||
}
|
||||
if len(got.Cmd) != 3 || got.Cmd[0] != "mcp" || got.Cmd[2] != "list_tasks" {
|
||||
t.Fatalf("cmd = %v", got.Cmd)
|
||||
}
|
||||
if got.Scope != "mcp:vikunja" || got.Utterance == "" {
|
||||
t.Fatalf("provenance lost: %+v", got)
|
||||
}
|
||||
|
||||
// Re-discovery on the next boot is idempotent.
|
||||
fresh, err = s.ProposeMCPTool(ctx, "vikunja_list_tasks", "mcp:vikunja", cmd, true, "changed", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fresh {
|
||||
t.Error("re-proposing an existing row must report nothing new")
|
||||
}
|
||||
|
||||
// And it must not re-arm or rewrite a row a human already acted on.
|
||||
if err := s.EnableTool(ctx, "vikunja_list_tasks", cmd, false, "mcp:vikunja", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.ProposeMCPTool(ctx, "vikunja_list_tasks", "mcp:vikunja", []string{"mcp", "vikunja", "delete_task"}, true, "x", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = s.LookupTool(ctx, "vikunja_list_tasks")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != "enabled" || got.Cmd[2] != "list_tasks" || got.Destructive {
|
||||
t.Fatalf("an enabled row was modified by discovery: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposeMCPToolNeedsCmd(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
if _, err := s.ProposeMCPTool(context.Background(), "x", "mcp:y", nil, false, "", time.Now()); !errors.Is(err, ErrToolCmd) {
|
||||
t.Fatalf("err = %v, want ErrToolCmd", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
// - Args are passed as argv, NEVER through a shell. STT text lands as
|
||||
// positional arguments to Cmd; there is no `sh -c`, so "restart nginx;
|
||||
// rm -rf" can't inject — the tail is one argv element to the named binary.
|
||||
// - An enabled row whose cmd is ["mcp", "<server>", "<tool>"] is a call to a
|
||||
// configured MCP server instead of a process (Vikunja #251). It goes
|
||||
// through every rule above unchanged — enabled, and confirmed if it
|
||||
// mutates — because the store is still the allowlist; only the dispatch at
|
||||
// the bottom of Exec differs.
|
||||
// - Destructive tools don't run on first hearing: Exec returns ErrNeedsConfirm
|
||||
// and the handler runs a confirm turn ("выполнить X? да/нет"); only a
|
||||
// confirmed re-Exec runs them. A gate assumes a fully-formed action, which
|
||||
@@ -31,6 +36,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/mcp"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -50,12 +56,21 @@ var (
|
||||
ErrNeedsConfirm = errors.New("destructive tool needs confirmation")
|
||||
)
|
||||
|
||||
// MCPCaller is the seam for an act that is an MCP tool call rather than a
|
||||
// process (Vikunja #251). internal/mcp.Manager satisfies it via CallPositional.
|
||||
// nil ⇒ MCP is not configured, and an MCP row refuses to run rather than
|
||||
// silently doing nothing.
|
||||
type MCPCaller interface {
|
||||
CallPositional(ctx context.Context, server, tool string, args []string) (string, error)
|
||||
}
|
||||
|
||||
// Executor runs enabled tools. run is the exec seam (default: real process);
|
||||
// tests swap it. timeout bounds each invocation.
|
||||
type Executor struct {
|
||||
api API
|
||||
timeout time.Duration
|
||||
run func(ctx context.Context, argv []string) (string, error)
|
||||
mcp MCPCaller
|
||||
}
|
||||
|
||||
// NewExecutor builds the executor. timeout<=0 defaults to 30s.
|
||||
@@ -66,6 +81,13 @@ func NewExecutor(api API, timeout time.Duration) *Executor {
|
||||
return &Executor{api: api, timeout: timeout, run: runProcess}
|
||||
}
|
||||
|
||||
// WithMCP attaches the MCP caller. Called once at wiring time when the mcp
|
||||
// config block is present; without it, a row whose cmd is ["mcp", …] refuses.
|
||||
func (e *Executor) WithMCP(m MCPCaller) *Executor {
|
||||
e.mcp = m
|
||||
return e
|
||||
}
|
||||
|
||||
// Exec looks up name in the store and runs Cmd+args as argv (no shell).
|
||||
// confirmed=true is the second turn of a destructive act (the user said "да");
|
||||
// it bypasses the ErrNeedsConfirm gate. Non-enabled ⇒ ErrNotEnabled; a
|
||||
@@ -84,6 +106,17 @@ func (e *Executor) Exec(ctx context.Context, name string, args []string, confirm
|
||||
if t.Destructive && !confirmed {
|
||||
return "", ErrNeedsConfirm
|
||||
}
|
||||
// An MCP row is a call to a configured server, not a process. Everything
|
||||
// above still applied: it had to be enabled, and a mutating one had to be
|
||||
// confirmed. Only the dispatch differs.
|
||||
if server, remote, ok := mcp.ParseCmd(t.Cmd); ok {
|
||||
if e.mcp == nil {
|
||||
return "", ErrNotEnabled
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, e.timeout)
|
||||
defer cancel()
|
||||
return e.mcp.CallPositional(ctx, server, remote, args)
|
||||
}
|
||||
argv := append(append([]string(nil), t.Cmd...), args...)
|
||||
if len(argv) == 0 {
|
||||
return "", ErrNotEnabled
|
||||
|
||||
@@ -85,3 +85,103 @@ func TestExec(t *testing.T) {
|
||||
t.Fatal("proposed tool must not match (not enabled)")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeMCP records what the executor asked it to call.
|
||||
type fakeMCP struct {
|
||||
server, tool string
|
||||
args []string
|
||||
out string
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeMCP) CallPositional(_ context.Context, server, tool string, args []string) (string, error) {
|
||||
f.calls++
|
||||
f.server, f.tool, f.args = server, tool, args
|
||||
return f.out, f.err
|
||||
}
|
||||
|
||||
// An MCP row dispatches to the caller instead of a process, and the process
|
||||
// seam is never touched.
|
||||
func TestExecMCPRowDispatchesToMCP(t *testing.T) {
|
||||
api := fakeAPI{tools: map[string]ipc.Tool{
|
||||
"vikunja_list_tasks": {
|
||||
Name: "vikunja_list_tasks", Status: "enabled", Scope: "mcp:vikunja",
|
||||
Cmd: []string{"mcp", "vikunja", "list_tasks"},
|
||||
},
|
||||
}}
|
||||
m := &fakeMCP{out: "две задачи"}
|
||||
ran := false
|
||||
e := NewExecutor(api, time.Second).WithMCP(m)
|
||||
e.run = func(context.Context, []string) (string, error) { ran = true; return "", nil }
|
||||
|
||||
out, err := e.Exec(context.Background(), "vikunja_list_tasks", []string{"мавен"}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("exec: %v", err)
|
||||
}
|
||||
if out != "две задачи" {
|
||||
t.Fatalf("out = %q", out)
|
||||
}
|
||||
if ran {
|
||||
t.Fatal("an MCP row must not be executed as a process")
|
||||
}
|
||||
if m.server != "vikunja" || m.tool != "list_tasks" || len(m.args) != 1 || m.args[0] != "мавен" {
|
||||
t.Fatalf("dispatched wrong: %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// The allowlist rules still apply to an MCP row: destructive means a confirm
|
||||
// turn first, and nothing is called until the second turn.
|
||||
func TestExecMCPRowStillNeedsConfirm(t *testing.T) {
|
||||
api := fakeAPI{tools: map[string]ipc.Tool{
|
||||
"vikunja_delete_task": {
|
||||
Name: "vikunja_delete_task", Status: "enabled", Destructive: true,
|
||||
Cmd: []string{"mcp", "vikunja", "delete_task"},
|
||||
},
|
||||
}}
|
||||
m := &fakeMCP{out: "удалила"}
|
||||
e := NewExecutor(api, time.Second).WithMCP(m)
|
||||
if _, err := e.Exec(context.Background(), "vikunja_delete_task", nil, false); !errors.Is(err, ErrNeedsConfirm) {
|
||||
t.Fatalf("err = %v, want ErrNeedsConfirm", err)
|
||||
}
|
||||
if m.calls != 0 {
|
||||
t.Fatal("a destructive MCP tool must not reach the server before confirmation")
|
||||
}
|
||||
if _, err := e.Exec(context.Background(), "vikunja_delete_task", nil, true); err != nil {
|
||||
t.Fatalf("confirmed exec: %v", err)
|
||||
}
|
||||
if m.calls != 1 {
|
||||
t.Fatalf("calls = %d", m.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// A proposed MCP row does not run, exactly like a proposed shell tool.
|
||||
func TestExecMCPRowNotEnabled(t *testing.T) {
|
||||
api := fakeAPI{tools: map[string]ipc.Tool{
|
||||
"vikunja_list_tasks": {Name: "vikunja_list_tasks", Status: "proposed", Cmd: []string{"mcp", "vikunja", "list_tasks"}},
|
||||
}}
|
||||
m := &fakeMCP{}
|
||||
e := NewExecutor(api, time.Second).WithMCP(m)
|
||||
if _, err := e.Exec(context.Background(), "vikunja_list_tasks", nil, false); !errors.Is(err, ErrNotEnabled) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if m.calls != 0 {
|
||||
t.Fatal("a proposal must not call anything")
|
||||
}
|
||||
}
|
||||
|
||||
// With MCP unconfigured, an MCP row refuses rather than trying to exec "mcp".
|
||||
func TestExecMCPRowWithoutCallerRefuses(t *testing.T) {
|
||||
api := fakeAPI{tools: map[string]ipc.Tool{
|
||||
"vikunja_list_tasks": {Name: "vikunja_list_tasks", Status: "enabled", Cmd: []string{"mcp", "vikunja", "list_tasks"}},
|
||||
}}
|
||||
ran := false
|
||||
e := NewExecutor(api, time.Second)
|
||||
e.run = func(context.Context, []string) (string, error) { ran = true; return "", nil }
|
||||
if _, err := e.Exec(context.Background(), "vikunja_list_tasks", nil, false); !errors.Is(err, ErrNotEnabled) {
|
||||
t.Fatalf("err = %v, want ErrNotEnabled", err)
|
||||
}
|
||||
if ran {
|
||||
t.Fatal(`"mcp" must never be run as a binary`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Result — the full account of one Apply. Every field is filled in on the
|
||||
// failure paths too, because "what state is my box in" is the only question that
|
||||
// matters after a failed update.
|
||||
type Result struct {
|
||||
Verified bool
|
||||
SnapshotID string // the rollback target; named even when the rollback failed
|
||||
Installed []string
|
||||
Restarted bool
|
||||
Healthy bool
|
||||
RolledBack bool
|
||||
// RollbackHealthy — whether she answered again after the restore. False with
|
||||
// RolledBack true is the manual-recovery case.
|
||||
RollbackHealthy bool
|
||||
Steps []Step
|
||||
Took time.Duration
|
||||
}
|
||||
|
||||
// Apply is the whole update, in the only order that is safe.
|
||||
//
|
||||
// It is called by a human running cmd/mavupdate on the box. Nothing else calls
|
||||
// it: no timer, no IPC method, no web route, no act. See the package comment.
|
||||
func (u *Updater) Apply(ctx context.Context) (Result, error) {
|
||||
start := u.now()
|
||||
res := Result{}
|
||||
defer func() { res.Took = u.now().Sub(start) }()
|
||||
|
||||
// 0. She has to be answering before we start. Otherwise a failed update and
|
||||
// a box that was already broken look identical afterwards, and the rollback
|
||||
// has no baseline to prove itself against.
|
||||
u.log("preflight: checking the running daemon")
|
||||
if err := u.health(ctx, u.cfg.HealthSocket); err != nil {
|
||||
return res, fmt.Errorf("%w: %v", ErrUnhealthyBefore, err)
|
||||
}
|
||||
|
||||
// 1. Snapshot what is deployed now, BEFORE the build.
|
||||
//
|
||||
// The order matters and it is not the obvious one. `make build` writes its
|
||||
// binaries into the working tree, and on the docker deployment the working
|
||||
// tree IS the install dir — so snapshotting after the build would snapshot
|
||||
// the new artifacts and leave nothing to roll back to. The snapshot is the
|
||||
// only thing standing between a bad build and a box that needs a screwdriver,
|
||||
// so it is taken first, while the deployed bytes are still the old ones.
|
||||
names := append(append([]string{}, u.cfg.Binaries...), u.cfg.ConfigFiles...)
|
||||
snap, err := u.store.Save(u.cfg.InstallDir, names, u.gitHead(ctx), "pre-update")
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.SnapshotID = snap.ID
|
||||
u.log("snapshot: %s (%d files) in %s", snap.ID, len(snap.Files), snap.Dir())
|
||||
|
||||
// 2. Build and test before anything is deployed. A broken tree costs time
|
||||
// and nothing else — but `make build` has already overwritten the binaries in
|
||||
// the tree, so restore them: otherwise a later restart by hand would deploy
|
||||
// code that failed its own tests. Nothing has been restarted, so this is a
|
||||
// file restore with no restart and no health check.
|
||||
steps, err := u.Verify(ctx)
|
||||
res.Steps = append(res.Steps, steps...)
|
||||
if err != nil {
|
||||
if rerr := snap.Restore(u.cfg.InstallDir); rerr != nil {
|
||||
u.log("verify failed and the artifacts could not be put back: %v — the previous ones are in %s", rerr, snap.Dir())
|
||||
} else {
|
||||
res.RolledBack = true
|
||||
u.log("verify failed; the previously deployed artifacts are back in place, she was never restarted")
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
res.Verified = true
|
||||
|
||||
// 3. Install. Per-file temp+rename, so an interruption leaves whole files.
|
||||
// Config is snapshotted but never overwritten — an update does not get to
|
||||
// replace the operator's config.
|
||||
installed, err := u.install()
|
||||
res.Installed = installed
|
||||
if err != nil {
|
||||
// Files may be half-swapped across the set, so restore before returning
|
||||
// even though nothing has been restarted yet.
|
||||
u.log("install failed: %v — restoring", err)
|
||||
return u.rollback(ctx, snap, res, err)
|
||||
}
|
||||
u.log("install: %d artifact(s) into %s", len(installed), u.cfg.InstallDir)
|
||||
|
||||
// 4. Restart, then 5. prove she answers.
|
||||
if err := u.restart(ctx, &res); err != nil {
|
||||
return u.rollback(ctx, snap, res, err)
|
||||
}
|
||||
u.log("restart: ok, waiting for her to answer (up to %s)", u.cfg.healthTimeout())
|
||||
if err := u.waitHealthy(ctx, u.cfg.healthTimeout()); err != nil {
|
||||
return u.rollback(ctx, snap, res, err)
|
||||
}
|
||||
res.Healthy = true
|
||||
u.log("health: she answers on %s — update committed", u.cfg.HealthSocket)
|
||||
|
||||
if err := u.store.Prune(u.cfg.KeepSnapshots); err != nil {
|
||||
u.log("prune: %v (harmless)", err)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Rollback restores a snapshot by id (empty = the newest) and restarts. Exposed
|
||||
// separately so the operator can undo an update that verified, restarted and
|
||||
// answered a Presence call but is wrong in a way no health check can see.
|
||||
func (u *Updater) Rollback(ctx context.Context, id string) (Result, error) {
|
||||
var snap Snapshot
|
||||
var err error
|
||||
if id == "" {
|
||||
snaps, lerr := u.store.List()
|
||||
if lerr != nil {
|
||||
return Result{}, lerr
|
||||
}
|
||||
if len(snaps) == 0 {
|
||||
return Result{}, errors.New("update: no snapshots to roll back to")
|
||||
}
|
||||
snap = snaps[0]
|
||||
} else if snap, err = u.store.Load(id); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
res := Result{SnapshotID: snap.ID}
|
||||
return u.rollback(ctx, snap, res, errors.New("operator asked for a rollback"))
|
||||
}
|
||||
|
||||
// rollback restores the snapshot and restarts, then reports whether that worked.
|
||||
// It depends on nothing that the update changed: file copies out of the snapshot
|
||||
// dir and the same restart command. No build, no migration, no cooperation from
|
||||
// the code being replaced.
|
||||
func (u *Updater) rollback(ctx context.Context, snap Snapshot, res Result, cause error) (Result, error) {
|
||||
// A rollback interrupted halfway is the one outcome worse than the failure
|
||||
// that triggered it, so it does not inherit the caller's cancellation: a
|
||||
// Ctrl-C during the health wait must not abandon the restore mid-restart.
|
||||
ctx = context.WithoutCancel(ctx)
|
||||
res.RolledBack = true
|
||||
u.log("rollback: restoring snapshot %s over %s", snap.ID, u.cfg.InstallDir)
|
||||
if err := snap.Restore(u.cfg.InstallDir); err != nil {
|
||||
u.log("rollback: RESTORE FAILED: %v", err)
|
||||
return res, fmt.Errorf("%w: %v (after %v); the previous artifacts are in %s — copy them back by hand", ErrRollbackFailed, err, cause, snap.Dir())
|
||||
}
|
||||
// A restore with no restart leaves the failed process running, so a failed
|
||||
// restart here is still the manual-recovery case.
|
||||
if err := u.restart(ctx, &res); err != nil {
|
||||
u.log("rollback: RESTART FAILED: %v", err)
|
||||
return res, fmt.Errorf("%w: restored %s but the restart failed: %v (after %v)", ErrRollbackFailed, snap.ID, err, cause)
|
||||
}
|
||||
if err := u.waitHealthy(ctx, u.cfg.healthTimeout()); err != nil {
|
||||
u.log("rollback: she still does not answer: %v", err)
|
||||
return res, fmt.Errorf("%w: restored %s and restarted but she does not answer: %v (after %v)", ErrRollbackFailed, snap.ID, err, cause)
|
||||
}
|
||||
res.RollbackHealthy = true
|
||||
u.log("rollback: she answers again on the previous build (%s)", snap.ID)
|
||||
return res, fmt.Errorf("%w to %s: %v", ErrRolledBack, snap.ID, cause)
|
||||
}
|
||||
|
||||
// install copies the freshly built binaries from SourceDir into InstallDir.
|
||||
//
|
||||
// When the two are the same directory — the docker deployment builds the image
|
||||
// from the working tree — this is a no-op by design rather than by accident: the
|
||||
// artifacts are already where they belong and the restart command rebuilds the
|
||||
// image from them.
|
||||
func (u *Updater) install() ([]string, error) {
|
||||
if filepath.Clean(u.cfg.SourceDir) == filepath.Clean(u.cfg.InstallDir) {
|
||||
return u.cfg.Binaries, nil
|
||||
}
|
||||
var done []string
|
||||
for _, name := range u.cfg.Binaries {
|
||||
src := filepath.Join(u.cfg.SourceDir, name)
|
||||
fi, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return done, fmt.Errorf("update: install %s: %w (did `make build` produce it?)", name, err)
|
||||
}
|
||||
if _, err := copyFile(src, filepath.Join(u.cfg.InstallDir, name), fi.Mode().Perm()); err != nil {
|
||||
return done, fmt.Errorf("update: install %s: %w", name, err)
|
||||
}
|
||||
done = append(done, name)
|
||||
}
|
||||
return done, nil
|
||||
}
|
||||
|
||||
func (u *Updater) restart(ctx context.Context, res *Result) error {
|
||||
u.log("restart: %v", u.cfg.RestartCmd)
|
||||
out, err := u.run(ctx, u.cfg.SourceDir, u.cfg.RestartCmd)
|
||||
if err != nil {
|
||||
res.Steps = append(res.Steps, Step{Name: "restart", Argv: u.cfg.RestartCmd, Err: err, Output: tail(out, 4000)})
|
||||
return fmt.Errorf("update: restart %v: %w", u.cfg.RestartCmd, err)
|
||||
}
|
||||
res.Restarted = true
|
||||
res.Steps = append(res.Steps, Step{Name: "restart", Argv: u.cfg.RestartCmd})
|
||||
return nil
|
||||
}
|
||||
|
||||
// gitHead records which commit produced a snapshot, for the operator's benefit.
|
||||
// Best-effort: a tree without git is not a reason to refuse to snapshot.
|
||||
func (u *Updater) gitHead(ctx context.Context) string {
|
||||
out, err := u.run(ctx, u.cfg.SourceDir, []string{"git", "rev-parse", "HEAD"})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(out)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// The health check is the whole basis for rolling back, so it has to mean
|
||||
// something. "The process is running" does not: mavend can be up with a dead
|
||||
// store, a socket it never bound, or a config it failed to parse. What is
|
||||
// checked instead is that she answers a real read over the real IPC socket —
|
||||
// which exercises the socket, the dispatch table and the store in one call.
|
||||
//
|
||||
// Presence is the method used because it is read-only (safe to retry), needs no
|
||||
// arguments, and touches the store. It cannot write anything, so a health check
|
||||
// never leaves a trace in her memory.
|
||||
|
||||
// DialHealth connects to the mavend socket and performs one read.
|
||||
func DialHealth(ctx context.Context, socket string) error {
|
||||
c, err := ipc.Dial(socket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update: health dial: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
if _, err := c.Presence(ctx); err != nil {
|
||||
return fmt.Errorf("update: health read: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitHealthy retries the health check until it passes or the timeout elapses.
|
||||
// A restart is not instantaneous — she loads a 1.7B on boot — so the first few
|
||||
// failures are expected and are not a reason to roll back.
|
||||
func (u *Updater) waitHealthy(ctx context.Context, timeout time.Duration) error {
|
||||
deadline := u.now().Add(timeout)
|
||||
delay := 500 * time.Millisecond
|
||||
var last error
|
||||
for {
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
err := u.health(attemptCtx, u.cfg.HealthSocket)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
last = err
|
||||
if u.now().After(deadline) {
|
||||
return fmt.Errorf("update: not healthy after %s: %w", timeout, last)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(delay):
|
||||
}
|
||||
if delay < 5*time.Second {
|
||||
delay *= 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A snapshot is a byte-for-byte copy of the deployed artifacts plus a manifest
|
||||
// of their sha256 sums, taken before an install.
|
||||
//
|
||||
// It is copies, not hardlinks and not a git stash, for one reason: the restore
|
||||
// path must work when everything else is broken. A hardlink into the install dir
|
||||
// would be clobbered by the very install it exists to undo, and a git-based
|
||||
// undo needs a toolchain, a clean tree, and a rebuild — three things a failed
|
||||
// update is likely to have taken away. Copying two dozen megabytes of Go
|
||||
// binaries costs a second and needs nothing but the filesystem.
|
||||
//
|
||||
// The sums are what make a restore verifiable rather than hopeful: Restore
|
||||
// re-hashes every file it writes, so "the old bytes are back" is checked, not
|
||||
// assumed.
|
||||
|
||||
// FileRec — one file in a snapshot.
|
||||
type FileRec struct {
|
||||
Name string `json:"name"` // relative name inside the install dir
|
||||
SHA256 string `json:"sha256"` // of the snapshotted bytes
|
||||
Mode os.FileMode `json:"mode"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// Snapshot — the manifest. Written last, so a directory without a readable
|
||||
// manifest.json is an aborted snapshot and is never offered as a rollback target.
|
||||
type Snapshot struct {
|
||||
ID string `json:"id"` // sortable timestamp, also the directory name
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Commit string `json:"commit,omitempty"` // git HEAD of the tree that produced it, when known
|
||||
Note string `json:"note,omitempty"`
|
||||
Files []FileRec `json:"files"`
|
||||
|
||||
dir string // absolute path, filled in by List/Load
|
||||
}
|
||||
|
||||
// Dir — where this snapshot's file copies live.
|
||||
func (s Snapshot) Dir() string { return s.dir }
|
||||
|
||||
const manifestName = "manifest.json"
|
||||
|
||||
// Store is a directory of snapshots.
|
||||
type Store struct {
|
||||
Dir string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func (st *Store) clock() time.Time {
|
||||
if st.now != nil {
|
||||
return st.now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// Save copies names (relative to srcDir) into a new snapshot and writes the
|
||||
// manifest. A name that does not exist is skipped rather than fatal: the first
|
||||
// ever run happens on a box where some artifact may legitimately be missing, and
|
||||
// refusing to snapshot then would mean refusing to update.
|
||||
func (st *Store) Save(srcDir string, names []string, commit, note string) (Snapshot, error) {
|
||||
ts := st.clock().UTC()
|
||||
snap := Snapshot{
|
||||
ID: ts.Format("20060102-150405"),
|
||||
CreatedAt: ts,
|
||||
Commit: commit,
|
||||
Note: note,
|
||||
}
|
||||
snap.dir = filepath.Join(st.Dir, snap.ID)
|
||||
if err := os.MkdirAll(snap.dir, 0o700); err != nil {
|
||||
return Snapshot{}, fmt.Errorf("update: snapshot dir: %w", err)
|
||||
}
|
||||
for _, name := range names {
|
||||
src := filepath.Join(srcDir, name)
|
||||
fi, err := os.Stat(src)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return Snapshot{}, fmt.Errorf("update: snapshot %s: %w", name, err)
|
||||
}
|
||||
if fi.IsDir() {
|
||||
return Snapshot{}, fmt.Errorf("update: snapshot %s: is a directory (only files are deployable artifacts)", name)
|
||||
}
|
||||
dst := filepath.Join(snap.dir, name)
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
sum, err := copyFile(src, dst, fi.Mode().Perm())
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("update: snapshot %s: %w", name, err)
|
||||
}
|
||||
snap.Files = append(snap.Files, FileRec{Name: name, SHA256: sum, Mode: fi.Mode().Perm(), Size: fi.Size()})
|
||||
}
|
||||
if len(snap.Files) == 0 {
|
||||
os.RemoveAll(snap.dir)
|
||||
return Snapshot{}, fmt.Errorf("update: snapshot of %s is empty — none of the listed artifacts exist", srcDir)
|
||||
}
|
||||
// Manifest last: its presence is what makes the snapshot usable.
|
||||
blob, err := json.MarshalIndent(snap, "", " ")
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(snap.dir, manifestName), blob, 0o600); err != nil {
|
||||
return Snapshot{}, fmt.Errorf("update: snapshot manifest: %w", err)
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
// List returns the complete snapshots, newest first.
|
||||
func (st *Store) List() ([]Snapshot, error) {
|
||||
ents, err := os.ReadDir(st.Dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var out []Snapshot
|
||||
for _, e := range ents {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
s, err := st.Load(e.Name())
|
||||
if err != nil {
|
||||
continue // aborted or hand-mangled: not a rollback target
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Load reads one snapshot's manifest.
|
||||
func (st *Store) Load(id string) (Snapshot, error) {
|
||||
dir := filepath.Join(st.Dir, id)
|
||||
blob, err := os.ReadFile(filepath.Join(dir, manifestName))
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
var s Snapshot
|
||||
if err := json.Unmarshal(blob, &s); err != nil {
|
||||
return Snapshot{}, fmt.Errorf("update: manifest %s: %w", id, err)
|
||||
}
|
||||
s.dir = dir
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Restore copies a snapshot's files back over dstDir and verifies every write
|
||||
// against the manifest sum. Only the named files are touched; anything else in
|
||||
// dstDir is left alone.
|
||||
//
|
||||
// This is the function the whole package exists to be able to run. It uses the
|
||||
// filesystem and nothing else — no toolchain, no build, no cooperation from the
|
||||
// code being replaced.
|
||||
func (s Snapshot) Restore(dstDir string) error {
|
||||
if s.dir == "" {
|
||||
return errors.New("update: snapshot has no directory (load it through the store)")
|
||||
}
|
||||
for _, f := range s.Files {
|
||||
src := filepath.Join(s.dir, f.Name)
|
||||
sum, err := hashFile(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update: restore %s: %w", f.Name, err)
|
||||
}
|
||||
if sum != f.SHA256 {
|
||||
return fmt.Errorf("update: restore %s: snapshot is corrupt (sha256 %s, manifest says %s)", f.Name, sum, f.SHA256)
|
||||
}
|
||||
dst := filepath.Join(dstDir, f.Name)
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
got, err := copyFile(src, dst, f.Mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update: restore %s: %w", f.Name, err)
|
||||
}
|
||||
if got != f.SHA256 {
|
||||
return fmt.Errorf("update: restore %s: wrote the wrong bytes (sha256 %s)", f.Name, got)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Prune keeps the newest keep snapshots and removes the rest. The newest is
|
||||
// never pruned regardless of keep — it is the rollback target.
|
||||
func (st *Store) Prune(keep int) error {
|
||||
if keep < 1 {
|
||||
keep = 1
|
||||
}
|
||||
snaps, err := st.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, s := range snaps[min(keep, len(snaps)):] {
|
||||
if err := os.RemoveAll(s.dir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyFile writes src to dst atomically (temp + rename, so a reader never sees a
|
||||
// half file and an interrupted copy leaves the old one intact) and returns the
|
||||
// sha256 of what was written.
|
||||
func copyFile(src, dst string, mode os.FileMode) (string, error) {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer in.Close()
|
||||
if mode == 0 {
|
||||
mode = 0o644
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(dst), ".update-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName) // no-op once the rename succeeds
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(io.MultiWriter(tmp, h), in); err != nil {
|
||||
tmp.Close()
|
||||
return "", err
|
||||
}
|
||||
// fsync before the rename: a binary that is renamed into place but whose
|
||||
// bytes are still in the page cache is exactly the file a power cut turns
|
||||
// into an unbootable daemon.
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := tmp.Chmod(mode); err != nil {
|
||||
tmp.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmpName, dst); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func hashFile(p string) (string, error) {
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// Package update applies a new build of Maven to the box she runs on, with a
|
||||
// verified-before-committed install and an automatic rollback (Vikunja #249).
|
||||
//
|
||||
// # What this package refuses to be
|
||||
//
|
||||
// This is the highest-risk capability in the backlog — code that changes the
|
||||
// running system — so the refusals are as much of the design as the features,
|
||||
// and they are enforced here rather than described in a doc:
|
||||
//
|
||||
// - It is never automatic and never on a timer. There is no checker, no
|
||||
// channel, no "check for updates" call and nothing that fires from the tick
|
||||
// loop. Apply runs exactly when a human runs cmd/mavupdate on the box.
|
||||
// - The daemon cannot update itself. mavend does not import this package and
|
||||
// there is no IPC method and no web route that reaches it, so no act, no
|
||||
// intent, no tool and no LLM output can start an update. The trigger needs
|
||||
// shell access to the host, which is a strictly higher bar than the step-up
|
||||
// passkey gate that guards /tools — an update is not a thing to expose to
|
||||
// anything reachable over the network.
|
||||
// - It does not fetch code. Nothing here talks to a release server, a
|
||||
// registry, or GitHub. The new version is whatever is in the working tree
|
||||
// the operator points it at, which he pulled himself. Downloading and
|
||||
// running code on the strength of a checksum in the same download is not a
|
||||
// property we can verify on one box.
|
||||
// - It does not supervise its own death. The plan asked for an in-process
|
||||
// crash-loop detector; a process cannot reliably notice that it keeps
|
||||
// dying, and one that thinks it can is worse than nothing. Restart-on-crash
|
||||
// belongs to whatever starts mavend (compose `restart: unless-stopped`,
|
||||
// systemd `Restart=`). What this package guarantees instead is narrower and
|
||||
// real: within one Apply, the new build is proven to answer before the old
|
||||
// one is considered replaced, and if it does not answer the old bytes go
|
||||
// back and are proven to answer again.
|
||||
//
|
||||
// # The order of operations, and why
|
||||
//
|
||||
// Apply is: health-check the CURRENT daemon → build → test → snapshot → install
|
||||
// → restart → health-check → rollback on any failure.
|
||||
//
|
||||
// The first health check is not ceremony. If she is already not answering, a
|
||||
// failed update and a broken box are indistinguishable afterwards, and the
|
||||
// rollback has nothing to prove itself against — so Apply refuses to start.
|
||||
//
|
||||
// Build and test run BEFORE anything is written to the install dir, so a broken
|
||||
// tree costs nothing but time. Install is per-file write-temp-then-rename, so a
|
||||
// crash mid-install leaves whole files, not half ones.
|
||||
//
|
||||
// The rollback path deliberately depends on nothing that just changed: it copies
|
||||
// byte-for-byte from a snapshot taken before the install and re-runs the same
|
||||
// restart command. It does not ask the new binary to do anything, does not run
|
||||
// a migration, and does not need the update to have gotten far enough to leave
|
||||
// a working anything behind.
|
||||
//
|
||||
// # What is out of scope on purpose
|
||||
//
|
||||
// The database is not snapshotted or rolled back. It is encrypted, live, and
|
||||
// often larger than the disk headroom; a store rolled back under a schema that
|
||||
// already migrated forward loses writes silently, which is worse than a failed
|
||||
// update. Schema compatibility is store.Migrate's job. A snapshot here is the
|
||||
// deployable artifacts only: binaries and config.
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotConfigured — no update block in the config. The capability does not
|
||||
// exist unless the operator described his own deployment.
|
||||
ErrNotConfigured = errors.New("update: not configured")
|
||||
|
||||
// ErrUnhealthyBefore — the daemon was already not answering when Apply
|
||||
// started. Refused: see the package comment.
|
||||
ErrUnhealthyBefore = errors.New("update: the running daemon is not healthy — refusing to update on top of a broken box")
|
||||
|
||||
// ErrVerifyFailed — build or test failed. Nothing was installed.
|
||||
ErrVerifyFailed = errors.New("update: verification failed")
|
||||
|
||||
// ErrRolledBack — the new build was installed and did not come up healthy,
|
||||
// so the previous snapshot was restored. Wraps the underlying failure.
|
||||
ErrRolledBack = errors.New("update: rolled back")
|
||||
|
||||
// ErrRollbackFailed — the worst case: the new build failed AND the restore
|
||||
// did not bring her back. The operator has to fix the box by hand; the
|
||||
// snapshot directory is named in the result so he knows what to copy.
|
||||
ErrRollbackFailed = errors.New("update: ROLLBACK FAILED — manual recovery required")
|
||||
)
|
||||
|
||||
// Config — the operator's description of his own deployment. Every path is
|
||||
// absolute and validated; nothing is guessed, because guessing wrong here means
|
||||
// overwriting the wrong file.
|
||||
type Config struct {
|
||||
// SourceDir — the git working tree to build. The operator pulls it himself;
|
||||
// this package never fetches.
|
||||
SourceDir string `json:"source_dir"`
|
||||
|
||||
// InstallDir — where the built binaries are copied to. On the docker
|
||||
// deployment this is the tree the image is built from, so it is usually the
|
||||
// same as SourceDir and Install is a no-op copy; on a bare-metal deployment
|
||||
// it is /opt/maven/bin.
|
||||
InstallDir string `json:"install_dir"`
|
||||
|
||||
// SnapshotDir — where the pre-install copies live. Must not be inside
|
||||
// InstallDir: a restore reading from a directory the install is writing to
|
||||
// is not a restore.
|
||||
SnapshotDir string `json:"snapshot_dir"`
|
||||
|
||||
// Binaries — the artifact names to snapshot and install, relative to
|
||||
// SourceDir (built) and InstallDir (deployed). Listed explicitly rather than
|
||||
// globbed so a stray file in the tree never gets deployed.
|
||||
Binaries []string `json:"binaries"`
|
||||
|
||||
// ConfigFiles — extra files to snapshot alongside the binaries, relative to
|
||||
// InstallDir. Snapshotted, never overwritten by an install: the operator's
|
||||
// config is not something an update gets to replace.
|
||||
ConfigFiles []string `json:"config_files,omitempty"`
|
||||
|
||||
// RestartCmd — how this deployment restarts mavend, e.g.
|
||||
// ["docker","compose","up","-d","--build","mavend"] or
|
||||
// ["systemctl","restart","mavend"]. Run in SourceDir. Required: there is no
|
||||
// portable default and picking one would mean restarting the wrong thing.
|
||||
RestartCmd []string `json:"restart_cmd"`
|
||||
|
||||
// HealthSocket — mavend's IPC socket, used to prove she answers after a
|
||||
// restart. Required: without a health check there is no signal to roll back
|
||||
// on, and an update that cannot detect its own failure is not what this
|
||||
// package is for.
|
||||
HealthSocket string `json:"health_socket"`
|
||||
|
||||
// HealthTimeoutSec — how long to wait for the restarted daemon to answer.
|
||||
// Default 90s; she loads a 1.7B on boot, so this is not a couple of seconds.
|
||||
HealthTimeoutSec int `json:"health_timeout_sec,omitempty"`
|
||||
|
||||
// VerifyTimeoutMin — cap on `make build` + `make test`. Default 20m.
|
||||
VerifyTimeoutMin int `json:"verify_timeout_min,omitempty"`
|
||||
|
||||
// KeepSnapshots — how many snapshots to retain. Default 5, minimum 1: the
|
||||
// most recent one is the rollback target and is never pruned.
|
||||
KeepSnapshots int `json:"keep_snapshots,omitempty"`
|
||||
}
|
||||
|
||||
// Validate — fail at startup, not halfway through an install.
|
||||
func (c Config) Validate() error {
|
||||
if c.SourceDir == "" || c.InstallDir == "" || c.SnapshotDir == "" {
|
||||
return errors.New("update: source_dir, install_dir and snapshot_dir are all required")
|
||||
}
|
||||
for _, p := range []string{c.SourceDir, c.InstallDir, c.SnapshotDir} {
|
||||
if !filepath.IsAbs(p) {
|
||||
return fmt.Errorf("update: %q must be an absolute path", p)
|
||||
}
|
||||
}
|
||||
if within(c.SnapshotDir, c.InstallDir) {
|
||||
return fmt.Errorf("update: snapshot_dir %q is inside install_dir %q — a restore must not read from what the install writes", c.SnapshotDir, c.InstallDir)
|
||||
}
|
||||
if len(c.Binaries) == 0 {
|
||||
return errors.New("update: binaries is empty — nothing to install")
|
||||
}
|
||||
for _, b := range append(append([]string{}, c.Binaries...), c.ConfigFiles...) {
|
||||
if filepath.IsAbs(b) || strings.Contains(b, "..") {
|
||||
return fmt.Errorf("update: %q must be a plain relative name", b)
|
||||
}
|
||||
}
|
||||
if len(c.RestartCmd) == 0 {
|
||||
return errors.New("update: restart_cmd is required — there is no safe default for restarting someone else's deployment")
|
||||
}
|
||||
if c.HealthSocket == "" {
|
||||
return errors.New("update: health_socket is required — an update that cannot check its own result cannot roll back on failure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Config) withDefaults() Config {
|
||||
if c.HealthTimeoutSec <= 0 {
|
||||
c.HealthTimeoutSec = 90
|
||||
}
|
||||
if c.VerifyTimeoutMin <= 0 {
|
||||
c.VerifyTimeoutMin = 20
|
||||
}
|
||||
if c.KeepSnapshots < 1 {
|
||||
c.KeepSnapshots = 5
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c Config) healthTimeout() time.Duration {
|
||||
return time.Duration(c.HealthTimeoutSec) * time.Second
|
||||
}
|
||||
|
||||
func (c Config) verifyTimeout() time.Duration {
|
||||
return time.Duration(c.VerifyTimeoutMin) * time.Minute
|
||||
}
|
||||
|
||||
// within reports whether p is dir or lives under it.
|
||||
func within(p, dir string) bool {
|
||||
p, dir = filepath.Clean(p), filepath.Clean(dir)
|
||||
if p == dir {
|
||||
return true
|
||||
}
|
||||
rel, err := filepath.Rel(dir, p)
|
||||
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||
}
|
||||
|
||||
// Runner runs one command and returns its combined output. Injected so the
|
||||
// tests can drive build/test/restart failures without a toolchain, a container
|
||||
// or a real daemon to break.
|
||||
type Runner func(ctx context.Context, dir string, argv []string) (string, error)
|
||||
|
||||
// ExecRunner is the real one.
|
||||
func ExecRunner(ctx context.Context, dir string, argv []string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// HealthCheck proves the daemon at socket answers. Injected for the same reason
|
||||
// as Runner.
|
||||
type HealthCheck func(ctx context.Context, socket string) error
|
||||
|
||||
// Logger receives one line per step. The CLI prints these as they happen: an
|
||||
// update that goes quiet for four minutes during `make test` reads as a hang.
|
||||
type Logger func(format string, args ...any)
|
||||
|
||||
// Updater is the whole capability. Construct with New and call Apply or
|
||||
// Rollback; there is no background goroutine and nothing starts on its own.
|
||||
type Updater struct {
|
||||
cfg Config
|
||||
store *Store
|
||||
run Runner
|
||||
health HealthCheck
|
||||
log Logger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// New builds an Updater. Every seam has a real default; the tests replace them.
|
||||
func New(cfg Config, opts ...Option) (*Updater, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u := &Updater{
|
||||
cfg: cfg.withDefaults(),
|
||||
store: &Store{Dir: cfg.SnapshotDir},
|
||||
run: ExecRunner,
|
||||
health: DialHealth,
|
||||
log: func(string, ...any) {},
|
||||
now: time.Now,
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(u)
|
||||
}
|
||||
u.store.now = u.now
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Option — a constructor seam.
|
||||
type Option func(*Updater)
|
||||
|
||||
func WithRunner(r Runner) Option { return func(u *Updater) { u.run = r } }
|
||||
func WithHealth(h HealthCheck) Option { return func(u *Updater) { u.health = h } }
|
||||
func WithLogger(l Logger) Option { return func(u *Updater) { u.log = l } }
|
||||
func WithClock(f func() time.Time) Option {
|
||||
return func(u *Updater) { u.now = f }
|
||||
}
|
||||
|
||||
// Snapshots lists what is available to roll back to, newest first.
|
||||
func (u *Updater) Snapshots() ([]Snapshot, error) { return u.store.List() }
|
||||
@@ -0,0 +1,437 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The tests drive the whole orchestration against a fake box: a directory tree
|
||||
// standing in for the install dir, an injected Runner standing in for
|
||||
// make/git/docker, and an injected HealthCheck standing in for mavend. That is
|
||||
// what makes the failure paths — the ones that matter — testable at all: you
|
||||
// cannot ask a real deployment to fail its health check on demand, and the
|
||||
// rollback path is exactly the path nobody exercises by hand.
|
||||
|
||||
type fakeBox struct {
|
||||
t *testing.T
|
||||
root string
|
||||
|
||||
// what the fake `make build` writes into the source tree
|
||||
newBytes string
|
||||
// scripted failures
|
||||
buildErr error
|
||||
testErr error
|
||||
restartErr error
|
||||
|
||||
// health: fails until the Nth call, then follows healthy
|
||||
healthErrs int // remaining failures to serve
|
||||
healthy bool
|
||||
healthChecks int
|
||||
// deployedAtRestart records the installed bytes each time restart runs, so a
|
||||
// test can prove the rollback put the old bytes back BEFORE restarting.
|
||||
deployedAtRestart []string
|
||||
|
||||
ran []string
|
||||
}
|
||||
|
||||
func newFakeBox(t *testing.T) *fakeBox {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for _, d := range []string{"src", "install", "snapshots"} {
|
||||
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// The currently deployed build, and a config file next to it.
|
||||
write(t, filepath.Join(root, "install", "mavend"), "OLD-BUILD")
|
||||
write(t, filepath.Join(root, "install", "mavend.json"), `{"tick_interval":"60s"}`)
|
||||
// The source tree already contains a stale binary; `make build` overwrites it.
|
||||
write(t, filepath.Join(root, "src", "mavend"), "STALE")
|
||||
return &fakeBox{t: t, root: root, newBytes: "NEW-BUILD", healthy: true}
|
||||
}
|
||||
|
||||
func (b *fakeBox) cfg() Config {
|
||||
return Config{
|
||||
SourceDir: filepath.Join(b.root, "src"),
|
||||
InstallDir: filepath.Join(b.root, "install"),
|
||||
SnapshotDir: filepath.Join(b.root, "snapshots"),
|
||||
Binaries: []string{"mavend"},
|
||||
ConfigFiles: []string{"mavend.json"},
|
||||
RestartCmd: []string{"restart-the-thing"},
|
||||
HealthSocket: filepath.Join(b.root, "mavend.sock"),
|
||||
HealthTimeoutSec: 1,
|
||||
KeepSnapshots: 3,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *fakeBox) run(ctx context.Context, dir string, argv []string) (string, error) {
|
||||
b.ran = append(b.ran, strings.Join(argv, " "))
|
||||
switch strings.Join(argv, " ") {
|
||||
case "make build":
|
||||
if b.buildErr != nil {
|
||||
return "ld: undefined reference to everything", b.buildErr
|
||||
}
|
||||
// A real build writes its artifacts into the working tree — the behaviour
|
||||
// the snapshot-before-build ordering exists to survive.
|
||||
write(b.t, filepath.Join(b.root, "src", "mavend"), b.newBytes)
|
||||
return "built", nil
|
||||
case "make test":
|
||||
if b.testErr != nil {
|
||||
return "--- FAIL: TestSomething", b.testErr
|
||||
}
|
||||
return "ok", nil
|
||||
case "git rev-parse HEAD":
|
||||
return "cafebabecafebabecafebabecafebabecafebabe\n", nil
|
||||
case "restart-the-thing":
|
||||
b.deployedAtRestart = append(b.deployedAtRestart, read(b.t, filepath.Join(b.root, "install", "mavend")))
|
||||
if b.restartErr != nil {
|
||||
return "no such container", b.restartErr
|
||||
}
|
||||
return "restarted", nil
|
||||
}
|
||||
return "", errors.New("unexpected command: " + strings.Join(argv, " "))
|
||||
}
|
||||
|
||||
func (b *fakeBox) health(ctx context.Context, socket string) error {
|
||||
b.healthChecks++
|
||||
if b.healthErrs > 0 {
|
||||
b.healthErrs--
|
||||
return errors.New("connection refused")
|
||||
}
|
||||
if !b.healthy {
|
||||
return errors.New("she does not answer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *fakeBox) updater(t *testing.T, extra ...Option) *Updater {
|
||||
t.Helper()
|
||||
opts := append([]Option{WithRunner(b.run), WithHealth(b.health)}, extra...)
|
||||
u, err := New(b.cfg(), opts...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (b *fakeBox) deployed() string { return read(b.t, filepath.Join(b.root, "install", "mavend")) }
|
||||
|
||||
func write(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func read(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestApply_HappyPath(t *testing.T) {
|
||||
b := newFakeBox(t)
|
||||
res, err := b.updater(t).Apply(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if !res.Verified || !res.Restarted || !res.Healthy || res.RolledBack {
|
||||
t.Fatalf("result = %+v; want verified+restarted+healthy and no rollback", res)
|
||||
}
|
||||
if got := b.deployed(); got != "NEW-BUILD" {
|
||||
t.Errorf("deployed binary = %q; want the new build", got)
|
||||
}
|
||||
// The order is the property: health, snapshot, build, test, install, restart.
|
||||
want := []string{"git rev-parse HEAD", "make build", "make test", "restart-the-thing"}
|
||||
if strings.Join(b.ran, "|") != strings.Join(want, "|") {
|
||||
t.Errorf("commands ran = %v; want %v", b.ran, want)
|
||||
}
|
||||
if res.SnapshotID == "" {
|
||||
t.Error("no snapshot was taken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_RefusesWhenSheIsAlreadyDown(t *testing.T) {
|
||||
// A box that is already broken has no baseline for the rollback to prove
|
||||
// itself against, so the update never starts.
|
||||
b := newFakeBox(t)
|
||||
b.healthy = false
|
||||
res, err := b.updater(t).Apply(context.Background())
|
||||
if !errors.Is(err, ErrUnhealthyBefore) {
|
||||
t.Fatalf("Apply on an unhealthy box = %v; want ErrUnhealthyBefore", err)
|
||||
}
|
||||
if len(b.ran) != 0 {
|
||||
t.Errorf("a refused update still ran %v", b.ran)
|
||||
}
|
||||
if res.SnapshotID != "" {
|
||||
t.Error("a refused update still took a snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_TestFailureDeploysNothingAndPutsTheTreeBack(t *testing.T) {
|
||||
b := newFakeBox(t)
|
||||
b.testErr = errors.New("exit status 1")
|
||||
res, err := b.updater(t).Apply(context.Background())
|
||||
if !errors.Is(err, ErrVerifyFailed) {
|
||||
t.Fatalf("Apply with failing tests = %v; want ErrVerifyFailed", err)
|
||||
}
|
||||
if res.Verified {
|
||||
t.Error("result claims verified after a failing test suite")
|
||||
}
|
||||
for _, c := range b.ran {
|
||||
if c == "restart-the-thing" {
|
||||
t.Fatal("a failed verification restarted the daemon")
|
||||
}
|
||||
}
|
||||
if got := b.deployed(); got != "OLD-BUILD" {
|
||||
t.Errorf("deployed binary = %q; want the old build untouched", got)
|
||||
}
|
||||
// The failing output is kept so the operator can see why.
|
||||
var found bool
|
||||
for _, s := range res.Steps {
|
||||
if s.Name == "test" && strings.Contains(s.Output, "FAIL") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("the failing test output was not retained")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_BuildFailureIsCaughtBeforeTheTests(t *testing.T) {
|
||||
b := newFakeBox(t)
|
||||
b.buildErr = errors.New("exit status 2")
|
||||
if _, err := b.updater(t).Apply(context.Background()); !errors.Is(err, ErrVerifyFailed) {
|
||||
t.Fatalf("Apply with a failing build = %v; want ErrVerifyFailed", err)
|
||||
}
|
||||
for _, c := range b.ran {
|
||||
if c == "make test" {
|
||||
t.Error("ran the test suite after the build failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_UnhealthyAfterRestartRollsBackToTheOldBytes(t *testing.T) {
|
||||
// The case the package exists for: everything verifies, the new build
|
||||
// installs, and then she does not come up.
|
||||
b := newFakeBox(t)
|
||||
b.healthErrs = 1 // the preflight check passes, then she stops answering
|
||||
b.healthy = false
|
||||
u := b.updater(t)
|
||||
// Once the rollback restores the old build, she answers again.
|
||||
restored := false
|
||||
u.health = func(ctx context.Context, socket string) error {
|
||||
b.healthChecks++
|
||||
if b.deployed() == "OLD-BUILD" && restored {
|
||||
return nil
|
||||
}
|
||||
if b.healthChecks == 1 {
|
||||
return nil // preflight: the old build is up
|
||||
}
|
||||
if b.deployed() == "OLD-BUILD" {
|
||||
restored = true
|
||||
return nil
|
||||
}
|
||||
return errors.New("she does not answer on the new build")
|
||||
}
|
||||
res, err := u.Apply(context.Background())
|
||||
if !errors.Is(err, ErrRolledBack) {
|
||||
t.Fatalf("Apply with a dead new build = %v; want ErrRolledBack", err)
|
||||
}
|
||||
if !res.RolledBack || !res.RollbackHealthy || res.Healthy {
|
||||
t.Fatalf("result = %+v; want rolled back and healthy again on the old build", res)
|
||||
}
|
||||
if got := b.deployed(); got != "OLD-BUILD" {
|
||||
t.Errorf("deployed binary after the rollback = %q; want OLD-BUILD", got)
|
||||
}
|
||||
// And the restore happened BEFORE the second restart, not after it.
|
||||
if len(b.deployedAtRestart) != 2 {
|
||||
t.Fatalf("restarts = %v; want two (the update and the rollback)", b.deployedAtRestart)
|
||||
}
|
||||
if b.deployedAtRestart[0] != "NEW-BUILD" || b.deployedAtRestart[1] != "OLD-BUILD" {
|
||||
t.Errorf("bytes in place at each restart = %v; want [NEW-BUILD OLD-BUILD]", b.deployedAtRestart)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_RestartFailureRollsBack(t *testing.T) {
|
||||
b := newFakeBox(t)
|
||||
b.restartErr = errors.New("exit status 1")
|
||||
res, err := b.updater(t).Apply(context.Background())
|
||||
// The rollback's own restart fails too, so this is the manual-recovery case —
|
||||
// and it says so instead of reporting a tidy rollback.
|
||||
if !errors.Is(err, ErrRollbackFailed) {
|
||||
t.Fatalf("Apply with a broken restart command = %v; want ErrRollbackFailed", err)
|
||||
}
|
||||
if !res.RolledBack || res.RollbackHealthy {
|
||||
t.Fatalf("result = %+v; want rolled back but not healthy", res)
|
||||
}
|
||||
if got := b.deployed(); got != "OLD-BUILD" {
|
||||
t.Errorf("deployed binary = %q; want the old bytes restored even so", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_RollbackNeedsNoBuildAndNoNewCode(t *testing.T) {
|
||||
// The rollback must not depend on the toolchain, the source tree, or the
|
||||
// code it is replacing. Prove it: delete the source tree's binary and make
|
||||
// every command except the restart fail, then roll back.
|
||||
b := newFakeBox(t)
|
||||
if _, err := b.updater(t).Apply(context.Background()); err != nil {
|
||||
t.Fatalf("setup Apply: %v", err)
|
||||
}
|
||||
if b.deployed() != "NEW-BUILD" {
|
||||
t.Fatal("setup did not deploy")
|
||||
}
|
||||
os.RemoveAll(filepath.Join(b.root, "src"))
|
||||
if err := os.MkdirAll(filepath.Join(b.root, "src"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.buildErr = errors.New("no toolchain here")
|
||||
b.testErr = errors.New("no toolchain here")
|
||||
b.ran = nil
|
||||
|
||||
res, err := b.updater(t).Rollback(context.Background(), "")
|
||||
if err != nil && !errors.Is(err, ErrRolledBack) {
|
||||
t.Fatalf("Rollback: %v", err)
|
||||
}
|
||||
if !res.RollbackHealthy {
|
||||
t.Fatalf("result = %+v; want a healthy rollback", res)
|
||||
}
|
||||
if got := b.deployed(); got != "OLD-BUILD" {
|
||||
t.Errorf("deployed binary = %q; want OLD-BUILD", got)
|
||||
}
|
||||
for _, c := range b.ran {
|
||||
if strings.HasPrefix(c, "make") {
|
||||
t.Errorf("the rollback ran %q — it must not need a build", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_ConfigIsSnapshottedButNeverOverwritten(t *testing.T) {
|
||||
b := newFakeBox(t)
|
||||
// A config in the source tree must not be deployed over the operator's.
|
||||
write(t, filepath.Join(b.root, "src", "mavend.json"), `{"tick_interval":"1s"}`)
|
||||
if _, err := b.updater(t).Apply(context.Background()); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if got := read(t, filepath.Join(b.root, "install", "mavend.json")); !strings.Contains(got, "60s") {
|
||||
t.Errorf("installed config = %q; an update must not replace his config", got)
|
||||
}
|
||||
snaps, err := b.updater(t).Snapshots()
|
||||
if err != nil || len(snaps) == 0 {
|
||||
t.Fatalf("Snapshots: %v %v", snaps, err)
|
||||
}
|
||||
var names []string
|
||||
for _, f := range snaps[0].Files {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
if len(names) != 2 {
|
||||
t.Errorf("snapshot files = %v; want the binary and the config", names)
|
||||
}
|
||||
if snaps[0].Commit == "" {
|
||||
t.Error("the snapshot did not record which commit produced it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollback_CorruptSnapshotIsRefusedNotRestored(t *testing.T) {
|
||||
b := newFakeBox(t)
|
||||
if _, err := b.updater(t).Apply(context.Background()); err != nil {
|
||||
t.Fatalf("setup Apply: %v", err)
|
||||
}
|
||||
snaps, _ := b.updater(t).Snapshots()
|
||||
// Something ate the snapshot. Restoring it would deploy garbage.
|
||||
write(t, filepath.Join(snaps[0].Dir(), "mavend"), "CORRUPT")
|
||||
_, err := b.updater(t).Rollback(context.Background(), snaps[0].ID)
|
||||
if !errors.Is(err, ErrRollbackFailed) || !strings.Contains(err.Error(), "corrupt") {
|
||||
t.Fatalf("Rollback of a corrupt snapshot = %v; want a refusal naming the corruption", err)
|
||||
}
|
||||
if got := b.deployed(); got != "NEW-BUILD" {
|
||||
t.Errorf("deployed binary = %q; a refused restore must change nothing", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollback_NoSnapshots(t *testing.T) {
|
||||
b := newFakeBox(t)
|
||||
if _, err := b.updater(t).Rollback(context.Background(), ""); err == nil {
|
||||
t.Error("Rollback with no snapshots succeeded; want an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrune_KeepsTheNewestAsTheRollbackTarget(t *testing.T) {
|
||||
b := newFakeBox(t)
|
||||
st := &Store{Dir: filepath.Join(b.root, "snapshots")}
|
||||
base := time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC)
|
||||
for i := 0; i < 4; i++ {
|
||||
i := i
|
||||
st.now = func() time.Time { return base.Add(time.Duration(i) * time.Minute) }
|
||||
if _, err := st.Save(filepath.Join(b.root, "install"), []string{"mavend"}, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := st.Prune(0); err != nil { // 0 is clamped to 1, never to zero
|
||||
t.Fatal(err)
|
||||
}
|
||||
snaps, err := st.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(snaps) != 1 {
|
||||
t.Fatalf("kept %d snapshots; want 1", len(snaps))
|
||||
}
|
||||
if snaps[0].ID != "20260801-030300" {
|
||||
t.Errorf("kept %s; want the newest", snaps[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_IgnoresSnapshotsWithNoManifest(t *testing.T) {
|
||||
// An interrupted snapshot has files but no manifest. It must never be offered
|
||||
// as a rollback target — restoring a half-copied binary is the worst outcome
|
||||
// in the package.
|
||||
b := newFakeBox(t)
|
||||
dir := filepath.Join(b.root, "snapshots", "20260801-000000")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write(t, filepath.Join(dir, "mavend"), "HALF")
|
||||
snaps, err := (&Store{Dir: filepath.Join(b.root, "snapshots")}).List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(snaps) != 0 {
|
||||
t.Errorf("List returned %d snapshots; want none (no manifest)", len(snaps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidate(t *testing.T) {
|
||||
ok := (&fakeBox{root: t.TempDir()}).cfg()
|
||||
if err := ok.Validate(); err != nil {
|
||||
t.Fatalf("valid config rejected: %v", err)
|
||||
}
|
||||
bad := map[string]func(c Config) Config{
|
||||
"relative source": func(c Config) Config { c.SourceDir = "src"; return c },
|
||||
"no restart command": func(c Config) Config { c.RestartCmd = nil; return c },
|
||||
"no health socket": func(c Config) Config { c.HealthSocket = ""; return c },
|
||||
"no binaries": func(c Config) Config { c.Binaries = nil; return c },
|
||||
"escaping artifact name": func(c Config) Config { c.Binaries = []string{"../../etc/passwd"}; return c },
|
||||
"absolute artifact name": func(c Config) Config { c.Binaries = []string{"/usr/bin/mavend"}; return c },
|
||||
"snapshots inside install": func(c Config) Config { c.SnapshotDir = filepath.Join(c.InstallDir, "snaps"); return c },
|
||||
}
|
||||
for name, mutate := range bad {
|
||||
if err := mutate(ok).Validate(); err == nil {
|
||||
t.Errorf("%s was accepted; want a startup failure", name)
|
||||
}
|
||||
}
|
||||
// And New refuses an invalid config outright rather than half-configuring.
|
||||
if _, err := New(mutate(ok, "no health socket", bad)); err == nil {
|
||||
t.Error("New accepted a config with no health socket")
|
||||
}
|
||||
}
|
||||
|
||||
func mutate(c Config, key string, m map[string]func(Config) Config) Config { return m[key](c) }
|
||||
@@ -0,0 +1,65 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Verification is "does this tree build and does it pass its own tests", run
|
||||
// before a single byte is written to the install dir.
|
||||
//
|
||||
// It is `make build` and `make test`, not `go build`: the CGO daemons need the
|
||||
// vendored toolchain and the whisper/piper include and library paths wired
|
||||
// through the Makefile, and a bare `go build` on them fails in a way that has
|
||||
// nothing to do with the change being deployed. `make test` is the -race suite
|
||||
// with the CGO env set, and it is the only evidence available on a single box
|
||||
// that the new code does what the old code did.
|
||||
//
|
||||
// This is not a substitute for a second environment. A test suite that passes
|
||||
// says the code is self-consistent; it does not say the new build will start
|
||||
// against this machine's actual models, sockets and encrypted store. That is
|
||||
// what the post-restart health check is for, and it is why the install is
|
||||
// reversible rather than merely careful.
|
||||
|
||||
// Step — one verification or orchestration step and how it went. Kept so the CLI
|
||||
// can print a truthful account of what was done, including on the failure path.
|
||||
type Step struct {
|
||||
Name string
|
||||
Argv []string
|
||||
Took time.Duration
|
||||
Err error
|
||||
Output string // combined output, only retained for failures
|
||||
}
|
||||
|
||||
// Verify runs the build and the test suite in SourceDir.
|
||||
func (u *Updater) Verify(ctx context.Context) ([]Step, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, u.cfg.verifyTimeout())
|
||||
defer cancel()
|
||||
var steps []Step
|
||||
for _, argv := range [][]string{{"make", "build"}, {"make", "test"}} {
|
||||
u.log("verify: %v (this takes a while)", argv)
|
||||
start := u.now()
|
||||
out, err := u.run(ctx, u.cfg.SourceDir, argv)
|
||||
st := Step{Name: argv[len(argv)-1], Argv: argv, Took: u.now().Sub(start), Err: err}
|
||||
if err != nil {
|
||||
st.Output = tail(out, 4000)
|
||||
}
|
||||
steps = append(steps, st)
|
||||
if err != nil {
|
||||
u.log("verify: %v FAILED after %s", argv, st.Took.Round(time.Second))
|
||||
return steps, fmt.Errorf("%w: %v: %v", ErrVerifyFailed, argv, err)
|
||||
}
|
||||
u.log("verify: %v ok in %s", argv, st.Took.Round(time.Second))
|
||||
}
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
// tail keeps the last n bytes — a failing `make test` prints far more than is
|
||||
// useful, and the failure is always at the end.
|
||||
func tail(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package vision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/media"
|
||||
)
|
||||
|
||||
// Intake is the whole path from "bytes arrived" to "here is what she saw",
|
||||
// in one place, so that every surface that can receive an image — a Telegram
|
||||
// photo, a mavweb upload, a file path he names — goes through the same steps in
|
||||
// the same order:
|
||||
//
|
||||
// 1. sniff the bytes (the sender's declared content type is not trusted);
|
||||
// 2. store them content-addressed, so the same photo twice is one file and the
|
||||
// original is still on disk if the description came out wrong;
|
||||
// 3. prepare a downscaled JPEG for the model;
|
||||
// 4. describe it.
|
||||
//
|
||||
// Step 2 happens BEFORE step 4 deliberately. If the vision model is missing or
|
||||
// broken — which is today's actual state on this box — the image is still safely
|
||||
// stored and describable later, and the failure is "I can't look at it yet", not
|
||||
// "it's gone".
|
||||
//
|
||||
// Writing the description as a note is NOT done here. That needs the store and
|
||||
// the embedder and belongs to the daemon; Intake returns the text and lets the
|
||||
// caller decide whether it becomes a note, a reply, or both.
|
||||
type Intake struct {
|
||||
store *media.Store
|
||||
provider Provider
|
||||
maxDim int
|
||||
}
|
||||
|
||||
// NewIntake wires an intake. provider may be Disabled — storing still works,
|
||||
// which is the point. maxDim ≤ 0 ⇒ media.DefaultMaxDim.
|
||||
func NewIntake(store *media.Store, provider Provider, maxDim int) *Intake {
|
||||
if provider == nil {
|
||||
provider = Disabled{}
|
||||
}
|
||||
return &Intake{store: store, provider: provider, maxDim: maxDim}
|
||||
}
|
||||
|
||||
// Result — what an intake produced. Blob is always set when Store succeeded, so
|
||||
// a caller that got an error from the description still knows what was kept and
|
||||
// can retry against the same id later.
|
||||
type Result struct {
|
||||
Blob media.Blob
|
||||
Image media.Image
|
||||
Description string
|
||||
}
|
||||
|
||||
// Accept stores data and describes it. source is provenance recorded on the
|
||||
// blob ("telegram", "web:upload"); question is what he asked about the image, or
|
||||
// empty for the default "what is this".
|
||||
//
|
||||
// A description failure is returned alongside a populated Result: the caller
|
||||
// gets the blob id for the log and the reply, and the error to explain why there
|
||||
// are no words yet.
|
||||
func (in *Intake) Accept(ctx context.Context, data []byte, source, question string) (Result, error) {
|
||||
if in == nil || in.store == nil {
|
||||
return Result{}, fmt.Errorf("vision: intake not wired")
|
||||
}
|
||||
mime, err := media.SniffImage(data)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
blob, err := in.store.Put(media.KindImage, mime, source, data)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
im, err := media.PrepareImage(data, source, in.maxDim)
|
||||
if err != nil {
|
||||
return Result{Blob: blob}, err
|
||||
}
|
||||
res := Result{Blob: blob, Image: im}
|
||||
text, err := in.provider.Describe(ctx, im, question)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Description = strings.TrimSpace(text)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Rerun describes an already-stored image again — a different question, or the
|
||||
// first successful attempt after the model finally landed on disk. It is the
|
||||
// reason step 2 comes before step 4.
|
||||
func (in *Intake) Rerun(ctx context.Context, id, question string) (Result, error) {
|
||||
if in == nil || in.store == nil {
|
||||
return Result{}, fmt.Errorf("vision: intake not wired")
|
||||
}
|
||||
blob, data, err := in.store.Read(id)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if blob.Kind != media.KindImage {
|
||||
return Result{Blob: blob}, fmt.Errorf("vision: %s is %s, not an image", id[:12], blob.Kind)
|
||||
}
|
||||
im, err := media.PrepareImage(data, blob.Source, in.maxDim)
|
||||
if err != nil {
|
||||
return Result{Blob: blob}, err
|
||||
}
|
||||
res := Result{Blob: blob, Image: im}
|
||||
text, err := in.provider.Describe(ctx, im, question)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Description = strings.TrimSpace(text)
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package vision
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"image"
|
||||
"image/png"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/media"
|
||||
)
|
||||
|
||||
type fakeProvider struct {
|
||||
reply string
|
||||
err error
|
||||
seen int
|
||||
lastQ string
|
||||
lastDim int
|
||||
}
|
||||
|
||||
func (f *fakeProvider) Describe(_ context.Context, im media.Image, prompt string) (string, error) {
|
||||
f.seen++
|
||||
f.lastQ = prompt
|
||||
f.lastDim = im.Width
|
||||
return f.reply, f.err
|
||||
}
|
||||
|
||||
func pngPayload(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, w, h))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func testIntake(t *testing.T, p Provider) (*Intake, *media.Store) {
|
||||
t.Helper()
|
||||
s, err := media.Open(t.TempDir(), 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewIntake(s, p, 64), s
|
||||
}
|
||||
|
||||
func TestAcceptStoresThenDescribes(t *testing.T) {
|
||||
fp := &fakeProvider{reply: "кот на подоконнике"}
|
||||
in, store := testIntake(t, fp)
|
||||
|
||||
res, err := in.Accept(context.Background(), pngPayload(t, 200, 100), "telegram", "кто это?")
|
||||
if err != nil {
|
||||
t.Fatalf("accept: %v", err)
|
||||
}
|
||||
if res.Description != "кот на подоконнике" {
|
||||
t.Errorf("description = %q", res.Description)
|
||||
}
|
||||
if fp.lastQ != "кто это?" {
|
||||
t.Errorf("question not passed through: %q", fp.lastQ)
|
||||
}
|
||||
if fp.lastDim != 64 {
|
||||
t.Errorf("image not downscaled to maxDim: width %d", fp.lastDim)
|
||||
}
|
||||
// The sniffed mime wins over anything a sender claimed.
|
||||
got, _, err := store.Read(res.Blob.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("blob not stored: %v", err)
|
||||
}
|
||||
if got.MIME != "image/png" || got.Source != "telegram" {
|
||||
t.Errorf("blob metadata = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The ordering promise: with no vision model on the box — today's real state —
|
||||
// the image is still on disk and the id is still reported, so it can be
|
||||
// described later instead of being lost.
|
||||
func TestAcceptKeepsBlobWhenDescribeFails(t *testing.T) {
|
||||
in, store := testIntake(t, Disabled{})
|
||||
res, err := in.Accept(context.Background(), pngPayload(t, 32, 32), "web:upload", "")
|
||||
if !errors.Is(err, ErrDisabled) {
|
||||
t.Fatalf("got %v, want ErrDisabled", err)
|
||||
}
|
||||
if res.Blob.ID == "" {
|
||||
t.Fatal("no blob id reported on a description failure")
|
||||
}
|
||||
if _, _, err := store.Read(res.Blob.ID); err != nil {
|
||||
t.Errorf("blob was not kept: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRerunDescribesAStoredBlob(t *testing.T) {
|
||||
fp := &fakeProvider{reply: "текст: ошибка E24"}
|
||||
in, _ := testIntake(t, fp)
|
||||
first, err := in.Accept(context.Background(), pngPayload(t, 40, 40), "telegram", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := in.Rerun(context.Background(), first.Blob.ID, "прочитай текст")
|
||||
if err != nil {
|
||||
t.Fatalf("rerun: %v", err)
|
||||
}
|
||||
if res.Description != "текст: ошибка E24" {
|
||||
t.Errorf("description = %q", res.Description)
|
||||
}
|
||||
if fp.lastQ != "прочитай текст" {
|
||||
t.Errorf("new question not used: %q", fp.lastQ)
|
||||
}
|
||||
if fp.seen != 2 {
|
||||
t.Errorf("provider called %d times, want 2", fp.seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRerunRefusesAudioBlob(t *testing.T) {
|
||||
in, store := testIntake(t, &fakeProvider{reply: "x"})
|
||||
b, err := store.Put(media.KindAudio, "audio/wav", "capture:meeting", []byte("pcm bytes"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := in.Rerun(context.Background(), b.ID, ""); err == nil {
|
||||
t.Error("audio blob was accepted as an image")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRerunUnknownID(t *testing.T) {
|
||||
in, _ := testIntake(t, &fakeProvider{})
|
||||
if _, err := in.Rerun(context.Background(), "nope", ""); err == nil {
|
||||
t.Error("malformed id accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptRefusesNonImage(t *testing.T) {
|
||||
in, _ := testIntake(t, &fakeProvider{})
|
||||
if _, err := in.Accept(context.Background(), []byte("this is a text file"), "web:upload", ""); !errors.Is(err, media.ErrUnsupportedImage) {
|
||||
t.Errorf("got %v, want ErrUnsupportedImage", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilProviderDegradesToDisabled(t *testing.T) {
|
||||
s, err := media.Open(t.TempDir(), 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := NewIntake(s, nil, 0)
|
||||
if _, err := in.Accept(context.Background(), pngPayload(t, 8, 8), "x", ""); !errors.Is(err, ErrDisabled) {
|
||||
t.Errorf("got %v, want ErrDisabled", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
// Package vision is Maven's image-understanding seam (Vikunja #252,
|
||||
// docs/plans/07-vision.md).
|
||||
//
|
||||
// One interface, Provider, with one method: describe an image, in words, in
|
||||
// Russian, with an optional question about it. Text extraction is not a second
|
||||
// method — "прочитай текст с картинки" is a prompt, and a vision-language model
|
||||
// does not have a separate OCR mode to select.
|
||||
//
|
||||
// # What is deliberately NOT here
|
||||
//
|
||||
// The plan document called for a `RemoteProvider` calling "an OpenAI-compatible
|
||||
// vision API endpoint". That step is refused: CLAUDE.md's surviving hard
|
||||
// constraint after "never phones home" was deprecated is *no cloud model,
|
||||
// inference stays on the box*, and a photo of his flat is the single worst thing
|
||||
// to make an exception for. Endpoint is therefore checked at construction and
|
||||
// must be a loopback or private address — a public host is a config error, not a
|
||||
// deployment option. That check is the reason this package does not simply reuse
|
||||
// internal/llm.Client.
|
||||
//
|
||||
// # State on this box, honestly
|
||||
//
|
||||
// The resident model is Qwen3-1.7B, which is text-only, and as of 2026-08-01
|
||||
// there is no vision-capable gguf and no mmproj file anywhere under
|
||||
// /mnt/hdd1/llms. So LocalProvider is written, tested against a fake server, and
|
||||
// currently has nothing real to talk to: the describing half is BLOCKED on a
|
||||
// model download (see docs/plans/07-vision.md for the candidates and the
|
||||
// recipe). What works today without any download is the intake — an image
|
||||
// arrives, is stored, is prepared — and the config seam that turns the rest on.
|
||||
//
|
||||
// Provider is nil-safe through Disabled, and vision is OFF unless configured,
|
||||
// like the weather and telegram.
|
||||
package vision
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/media"
|
||||
"github.com/kami/maven/internal/webfetch"
|
||||
)
|
||||
|
||||
// DefaultTimeout — budget for one description. A small VLM doing prefill over
|
||||
// an 896px image on a Vega iGPU is slow; 90s is generous because nobody is
|
||||
// holding a conversation open on this path — the answer arrives as a reply or a
|
||||
// note, and a too-tight timeout just means it never arrives at all.
|
||||
const DefaultTimeout = 90 * time.Second
|
||||
|
||||
// DefaultMaxTokens — cap on the description. A paragraph is what a spoken
|
||||
// answer can carry; a page is not.
|
||||
const DefaultMaxTokens = 300
|
||||
|
||||
// DefaultPrompt — what she is asked when he did not ask anything specific,
|
||||
// only sent a picture. Russian, because that is the channel language, and
|
||||
// feminine self-reference is not needed here (the prompt is an instruction, the
|
||||
// persona block is added by the caller that phrases the reply).
|
||||
const DefaultPrompt = "Опиши, что на этом изображении. Коротко, 2-3 предложения. Если на нём есть текст, приведи его."
|
||||
|
||||
// Errors callers distinguish.
|
||||
var (
|
||||
// ErrDisabled — vision is not configured. Returned by Disabled, which is
|
||||
// what the daemon wires when the config block is absent.
|
||||
ErrDisabled = errors.New("vision: not configured")
|
||||
// ErrNotPrivate — the configured endpoint is not on this box or its
|
||||
// network. Refused at construction; see the package comment.
|
||||
ErrNotPrivate = errors.New("vision: endpoint must be a local or private address")
|
||||
// ErrEmptyReply — the model returned nothing usable.
|
||||
ErrEmptyReply = errors.New("vision: empty description")
|
||||
)
|
||||
|
||||
// Provider — the image-understanding contract. Describe takes an image already
|
||||
// prepared by internal/media (decoded, downscaled, JPEG) and a prompt; an empty
|
||||
// prompt means DefaultPrompt.
|
||||
type Provider interface {
|
||||
Describe(ctx context.Context, im media.Image, prompt string) (string, error)
|
||||
}
|
||||
|
||||
// Disabled — the floor Provider. Every call fails with ErrDisabled, which the
|
||||
// caller turns into "я не умею смотреть картинки — зрение не настроено". It
|
||||
// exists so that no call site needs a nil check and switching vision off cannot
|
||||
// crash a turn.
|
||||
type Disabled struct{}
|
||||
|
||||
// Describe always fails. The signature matches Provider.
|
||||
func (Disabled) Describe(context.Context, media.Image, string) (string, error) {
|
||||
return "", ErrDisabled
|
||||
}
|
||||
|
||||
// Config — how to reach the local vision server. Built from
|
||||
// config.VisionConfig by the daemon; kept separate so this package does not
|
||||
// import internal/config.
|
||||
type Config struct {
|
||||
// Endpoint — base URL of a llama-server started with a vision model and its
|
||||
// mmproj (`llama-server -m model.gguf --mmproj mmproj.gguf`). Must be
|
||||
// loopback or private. The path is appended by the provider; give it
|
||||
// "http://127.0.0.1:8081".
|
||||
Endpoint string
|
||||
// Model — the model name to send. llama-server ignores it; it matters if the
|
||||
// endpoint is something else OpenAI-shaped on the same box.
|
||||
Model string
|
||||
// Timeout — per-description budget. 0 ⇒ DefaultTimeout.
|
||||
Timeout time.Duration
|
||||
// MaxTokens — cap on the reply. 0 ⇒ DefaultMaxTokens.
|
||||
MaxTokens int
|
||||
// Prompt — the default question. Empty ⇒ DefaultPrompt.
|
||||
Prompt string
|
||||
}
|
||||
|
||||
// LocalProvider talks to a llama-server on this box over its
|
||||
// /v1/chat/completions endpoint, sending the image as a data URI content part.
|
||||
// It is the only real Provider, and it is a plain HTTP client: no subprocess
|
||||
// spawning, because the daemon already owns llama-server lifecycle for the
|
||||
// resident model and a second managed process is a bigger change than this task.
|
||||
type LocalProvider struct {
|
||||
endpoint string
|
||||
model string
|
||||
prompt string
|
||||
maxTokens int
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewLocal builds a LocalProvider, refusing a non-private endpoint. A bad URL
|
||||
// or a public host is an error at construction so the daemon logs it once at
|
||||
// startup instead of failing every turn.
|
||||
func NewLocal(cfg Config) (*LocalProvider, error) {
|
||||
base := strings.TrimRight(strings.TrimSpace(cfg.Endpoint), "/")
|
||||
if base == "" {
|
||||
return nil, errors.New("vision: empty endpoint")
|
||||
}
|
||||
if err := checkPrivate(base); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = DefaultTimeout
|
||||
}
|
||||
maxTokens := cfg.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = DefaultMaxTokens
|
||||
}
|
||||
prompt := strings.TrimSpace(cfg.Prompt)
|
||||
if prompt == "" {
|
||||
prompt = DefaultPrompt
|
||||
}
|
||||
return &LocalProvider{
|
||||
endpoint: base,
|
||||
model: cfg.Model,
|
||||
prompt: prompt,
|
||||
maxTokens: maxTokens,
|
||||
http: &http.Client{Timeout: timeout},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Endpoint is the server this provider talks to. For logs and /dash.
|
||||
func (p *LocalProvider) Endpoint() string { return p.endpoint }
|
||||
|
||||
// checkPrivate refuses any endpoint that is not on this box or its LAN. A
|
||||
// hostname that is not an IP literal is refused too: "vision.example.com" could
|
||||
// resolve anywhere, and resolving it here would be trusting DNS with his photos.
|
||||
// localhost is the one name allowed, because it is the common case.
|
||||
func checkPrivate(raw string) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("vision: parse endpoint: %w", err)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("vision: endpoint scheme %q not supported", u.Scheme)
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return errors.New("vision: endpoint has no host")
|
||||
}
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return nil
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return fmt.Errorf("%w: %q is a name, not an address", ErrNotPrivate, host)
|
||||
}
|
||||
if !webfetch.IsPrivateIP(ip) {
|
||||
return fmt.Errorf("%w: %s", ErrNotPrivate, host)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// chat request shapes. Content is the OpenAI multimodal array form: a text part
|
||||
// and an image_url part whose url is a data URI.
|
||||
type textPart struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
type imageURL struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
type imagePart struct {
|
||||
Type string `json:"type"`
|
||||
ImageURL imageURL `json:"image_url"`
|
||||
}
|
||||
type chatReq struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Messages []any `json:"messages"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temp float64 `json:"temperature"`
|
||||
}
|
||||
type userMsg struct {
|
||||
Role string `json:"role"`
|
||||
Content []any `json:"content"`
|
||||
}
|
||||
type chatResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
// Describe sends the image and prompt and returns the model's answer. An empty
|
||||
// prompt uses the configured default. Errors are wrapped, never fatal: the
|
||||
// caller says she could not make out the picture and the turn continues.
|
||||
func (p *LocalProvider) Describe(ctx context.Context, im media.Image, prompt string) (string, error) {
|
||||
if len(im.JPEG) == 0 {
|
||||
return "", media.ErrEmpty
|
||||
}
|
||||
q := strings.TrimSpace(prompt)
|
||||
if q == "" {
|
||||
q = p.prompt
|
||||
}
|
||||
body, err := json.Marshal(chatReq{
|
||||
Model: p.model,
|
||||
MaxTokens: p.maxTokens,
|
||||
Messages: []any{userMsg{Role: "user", Content: []any{
|
||||
textPart{Type: "text", Text: q},
|
||||
imagePart{Type: "image_url", ImageURL: imageURL{URL: im.DataURI()}},
|
||||
}}},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("vision: marshal: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
p.endpoint+"/v1/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("vision: request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := p.http.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("vision: post: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("vision: status %d", resp.StatusCode)
|
||||
}
|
||||
var out chatResp
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return "", fmt.Errorf("vision: decode: %w", err)
|
||||
}
|
||||
if len(out.Choices) == 0 {
|
||||
return "", ErrEmptyReply
|
||||
}
|
||||
text := strings.TrimSpace(out.Choices[0].Message.Content)
|
||||
if text == "" {
|
||||
// Same fallback as internal/llm: a Thinking model sometimes puts the
|
||||
// whole answer in reasoning_content and leaves content empty.
|
||||
text = strings.TrimSpace(out.Choices[0].Message.ReasoningContent)
|
||||
}
|
||||
if text == "" {
|
||||
return "", ErrEmptyReply
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package vision
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/media"
|
||||
)
|
||||
|
||||
func testImage(t *testing.T) media.Image {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 32, 32))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
im, err := media.PrepareImage(buf.Bytes(), "test", 32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return im
|
||||
}
|
||||
|
||||
func TestDisabledAlwaysRefuses(t *testing.T) {
|
||||
_, err := Disabled{}.Describe(context.Background(), testImage(t), "что тут?")
|
||||
if !errors.Is(err, ErrDisabled) {
|
||||
t.Fatalf("got %v, want ErrDisabled", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole reason this package has its own HTTP client instead of reusing
|
||||
// internal/llm.Client: a vision endpoint that is not on this box is refused.
|
||||
func TestNewLocalRefusesNonPrivateEndpoints(t *testing.T) {
|
||||
bad := []string{
|
||||
"https://api.openai.com",
|
||||
"http://8.8.8.8:8080",
|
||||
"https://vision.example.com", // a name could resolve anywhere
|
||||
"ftp://127.0.0.1:8080", // wrong scheme
|
||||
"", // nothing to talk to
|
||||
}
|
||||
for _, ep := range bad {
|
||||
if _, err := NewLocal(Config{Endpoint: ep}); err == nil {
|
||||
t.Errorf("NewLocal(%q) was accepted", ep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLocalAcceptsLocalEndpoints(t *testing.T) {
|
||||
for _, ep := range []string{"http://127.0.0.1:8081", "http://localhost:8081/", "http://192.168.1.104:8081", "http://[::1]:8081"} {
|
||||
p, err := NewLocal(Config{Endpoint: ep})
|
||||
if err != nil {
|
||||
t.Errorf("NewLocal(%q): %v", ep, err)
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(p.Endpoint(), "/") {
|
||||
t.Errorf("trailing slash kept: %q", p.Endpoint())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeSendsImageAsDataURIAndReturnsText(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
if err := json.Unmarshal(raw, &gotBody); err != nil {
|
||||
t.Errorf("unmarshal request: %v", err)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":" На картинке кот "}}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p, err := NewLocal(Config{Endpoint: srv.URL, Model: "qwen-vl"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text, err := p.Describe(context.Background(), testImage(t), "кто на фото?")
|
||||
if err != nil {
|
||||
t.Fatalf("describe: %v", err)
|
||||
}
|
||||
if text != "На картинке кот" {
|
||||
t.Errorf("text = %q (should be trimmed)", text)
|
||||
}
|
||||
|
||||
msgs, ok := gotBody["messages"].([]any)
|
||||
if !ok || len(msgs) != 1 {
|
||||
t.Fatalf("messages = %#v", gotBody["messages"])
|
||||
}
|
||||
parts, ok := msgs[0].(map[string]any)["content"].([]any)
|
||||
if !ok || len(parts) != 2 {
|
||||
t.Fatalf("content parts = %#v", msgs[0])
|
||||
}
|
||||
if got := parts[0].(map[string]any)["text"]; got != "кто на фото?" {
|
||||
t.Errorf("prompt = %v", got)
|
||||
}
|
||||
url := parts[1].(map[string]any)["image_url"].(map[string]any)["url"].(string)
|
||||
if !strings.HasPrefix(url, "data:image/jpeg;base64,") {
|
||||
t.Errorf("image not sent as a jpeg data uri: %.40s", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeUsesDefaultPromptWhenNoQuestion(t *testing.T) {
|
||||
var sentPrompt string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Messages []struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
sentPrompt = body.Messages[0].Content[0].Text
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ок"}}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p, err := NewLocal(Config{Endpoint: srv.URL, Prompt: "Опиши по-русски."})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := p.Describe(context.Background(), testImage(t), " "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sentPrompt != "Опиши по-русски." {
|
||||
t.Errorf("prompt = %q", sentPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
// A Thinking model sometimes leaves content empty and puts the answer in
|
||||
// reasoning_content; internal/llm has the same fallback and vision needs it too.
|
||||
func TestDescribeFallsBackToReasoningContent(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","reasoning_content":"схема платы"}}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
p, _ := NewLocal(Config{Endpoint: srv.URL})
|
||||
text, err := p.Describe(context.Background(), testImage(t), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if text != "схема платы" {
|
||||
t.Errorf("text = %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeErrors(t *testing.T) {
|
||||
t.Run("no choices", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"choices":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
p, _ := NewLocal(Config{Endpoint: srv.URL})
|
||||
if _, err := p.Describe(context.Background(), testImage(t), ""); !errors.Is(err, ErrEmptyReply) {
|
||||
t.Errorf("got %v, want ErrEmptyReply", err)
|
||||
}
|
||||
})
|
||||
t.Run("server error", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
p, _ := NewLocal(Config{Endpoint: srv.URL})
|
||||
if _, err := p.Describe(context.Background(), testImage(t), ""); err == nil {
|
||||
t.Error("500 was not an error")
|
||||
}
|
||||
})
|
||||
t.Run("empty image", func(t *testing.T) {
|
||||
p, _ := NewLocal(Config{Endpoint: "http://127.0.0.1:1"})
|
||||
if _, err := p.Describe(context.Background(), media.Image{}, ""); !errors.Is(err, media.ErrEmpty) {
|
||||
t.Errorf("got %v, want media.ErrEmpty", err)
|
||||
}
|
||||
})
|
||||
t.Run("context cancelled", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"поздно"}}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
p, _ := NewLocal(Config{Endpoint: srv.URL})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := p.Describe(ctx, testImage(t), ""); err == nil {
|
||||
t.Error("cancelled context returned no error")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -28,6 +28,7 @@
|
||||
package webfetch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -92,6 +93,9 @@ type Response struct {
|
||||
Status int
|
||||
ContentType string
|
||||
Body []byte
|
||||
// Header — the response headers, one value each (the first). Populated for
|
||||
// every request; MCP needs Mcp-Session-Id, nothing else reads it.
|
||||
Header map[string]string
|
||||
}
|
||||
|
||||
// Fetcher performs guarded GETs. Safe for concurrent use; the per-host rate
|
||||
@@ -159,6 +163,37 @@ func New(cfg Config) *Fetcher {
|
||||
// Get fetches rawURL. The body is capped: a larger response is an error, not a
|
||||
// truncation, because half an XML document is worse than none.
|
||||
func (f *Fetcher) Get(ctx context.Context, rawURL string) (*Response, error) {
|
||||
return f.do(ctx, http.MethodGet, rawURL, nil, nil)
|
||||
}
|
||||
|
||||
// Post sends body to rawURL and returns the reply, under exactly the same
|
||||
// guards as Get: scheme rule, host lists, the dialer's private-address check on
|
||||
// every hop, the size cap and the per-host rate limit.
|
||||
//
|
||||
// It exists for JSON-RPC over HTTP (internal/mcp), which cannot be expressed as
|
||||
// a GET. That an outbound request now carries a body does not widen the
|
||||
// address policy one bit — a POST to the LAN is refused for the same reason a
|
||||
// GET is, unless AllowPrivate was set for that specific fetcher.
|
||||
//
|
||||
// hdr is merged over the defaults; a caller may not override User-Agent or
|
||||
// Accept-Encoding, because identity encoding and an honest UA are part of the
|
||||
// contract with whatever is on the other end.
|
||||
func (f *Fetcher) Post(ctx context.Context, rawURL, contentType string, body []byte, hdr map[string]string) (*Response, error) {
|
||||
if contentType == "" {
|
||||
contentType = "application/json"
|
||||
}
|
||||
if hdr == nil {
|
||||
hdr = map[string]string{}
|
||||
}
|
||||
merged := make(map[string]string, len(hdr)+1)
|
||||
for k, v := range hdr {
|
||||
merged[k] = v
|
||||
}
|
||||
merged["Content-Type"] = contentType
|
||||
return f.do(ctx, http.MethodPost, rawURL, body, merged)
|
||||
}
|
||||
|
||||
func (f *Fetcher) do(ctx context.Context, method, rawURL string, body []byte, hdr map[string]string) (*Response, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("webfetch: bad url %q: %w", rawURL, err)
|
||||
@@ -170,10 +205,17 @@ func (f *Fetcher) Get(ctx context.Context, rawURL string) (*Response, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
rdr = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, u.String(), rdr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("User-Agent", f.cfg.UserAgent)
|
||||
req.Header.Set("Accept-Encoding", "identity")
|
||||
|
||||
@@ -190,22 +232,27 @@ func (f *Fetcher) Get(ctx context.Context, rawURL string) (*Response, error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, f.cfg.MaxBytes+1))
|
||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, f.cfg.MaxBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(body)) > f.cfg.MaxBytes {
|
||||
if int64(len(respBody)) > f.cfg.MaxBytes {
|
||||
return nil, fmt.Errorf("%w (%d bytes)", ErrTooLarge, f.cfg.MaxBytes)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return nil, fmt.Errorf("%w: %d", ErrStatus, resp.StatusCode)
|
||||
}
|
||||
return &Response{
|
||||
out := &Response{
|
||||
URL: resp.Request.URL.String(),
|
||||
Status: resp.StatusCode,
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
Body: body,
|
||||
}, nil
|
||||
Body: respBody,
|
||||
Header: map[string]string{},
|
||||
}
|
||||
for k := range resp.Header {
|
||||
out.Header[k] = resp.Header.Get(k)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// checkURL applies the scheme rule and the host lists. The address rule is the
|
||||
|
||||
@@ -3,6 +3,7 @@ package webfetch
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -225,3 +226,74 @@ func TestUserAgentIsSent(t *testing.T) {
|
||||
t.Fatalf("user-agent = %q", ua)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostSendsBodyAndHeaders(t *testing.T) {
|
||||
type seen struct {
|
||||
method, ctype, accept, ua, custom string
|
||||
body []byte
|
||||
}
|
||||
ch := make(chan seen, 1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
ch <- seen{r.Method, r.Header.Get("Content-Type"), r.Header.Get("Accept"),
|
||||
r.Header.Get("User-Agent"), r.Header.Get("X-Thing"), b}
|
||||
w.Header().Set("Mcp-Session-Id", "sess-9")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
f := testFetcher(t, Config{UserAgent: "Maven/test"})
|
||||
resp, err := f.Post(context.Background(), srv.URL, "application/json",
|
||||
[]byte(`{"jsonrpc":"2.0"}`), map[string]string{"Accept": "text/event-stream", "X-Thing": "1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(resp.Body) != `{"ok":true}` {
|
||||
t.Fatalf("body = %q", resp.Body)
|
||||
}
|
||||
if resp.Header["Mcp-Session-Id"] != "sess-9" {
|
||||
t.Fatalf("response headers not surfaced: %+v", resp.Header)
|
||||
}
|
||||
s := <-ch
|
||||
if s.method != http.MethodPost {
|
||||
t.Fatalf("method = %s", s.method)
|
||||
}
|
||||
if string(s.body) != `{"jsonrpc":"2.0"}` {
|
||||
t.Fatalf("request body = %q", s.body)
|
||||
}
|
||||
if s.ctype != "application/json" {
|
||||
t.Fatalf("content-type = %q", s.ctype)
|
||||
}
|
||||
if s.accept != "text/event-stream" || s.custom != "1" {
|
||||
t.Fatalf("caller headers dropped: %+v", s)
|
||||
}
|
||||
if s.ua != "Maven/test" {
|
||||
t.Fatalf("user-agent = %q — a caller must not be able to override it", s.ua)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of routing MCP through webfetch: a POST is guarded exactly
|
||||
// like a GET. A body does not buy a caller a way onto the LAN.
|
||||
func TestPostRefusesPrivateAddress(t *testing.T) {
|
||||
f := New(Config{}) // no AllowPrivate
|
||||
_, err := f.Post(context.Background(), "http://127.0.0.1:9100/mcp", "application/json", []byte(`{}`), nil)
|
||||
if !errors.Is(err, ErrPrivate) {
|
||||
t.Fatalf("error = %v, want ErrPrivate", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostRefusesNonHTTPScheme(t *testing.T) {
|
||||
f := New(Config{})
|
||||
if _, err := f.Post(context.Background(), "file:///etc/passwd", "application/json", nil, nil); !errors.Is(err, ErrScheme) {
|
||||
t.Fatalf("error = %v, want ErrScheme", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostObeysDenylist(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
defer srv.Close()
|
||||
f := testFetcher(t, Config{DenyHosts: []string{"127.0.0.1"}})
|
||||
if _, err := f.Post(context.Background(), srv.URL, "application/json", []byte(`{}`), nil); !errors.Is(err, ErrBlocked) {
|
||||
t.Fatalf("error = %v, want ErrBlocked", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user