Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8fcb404be | |||
| dc4c5b7841 | |||
| 33e53ee897 | |||
| 45b5e16eff | |||
| 4eca20bd94 | |||
| fed33a4e16 | |||
| 62cc072f8c | |||
| 7c7bd8ceeb | |||
| aa1a26532c | |||
| d92349ca6e | |||
| 8d5e357b57 | |||
| 95ae900a58 | |||
| be066a4b04 | |||
| ad074cea31 | |||
| 2c1b0eede0 |
@@ -8,6 +8,7 @@
|
|||||||
/mavcaldav
|
/mavcaldav
|
||||||
/mavwaked
|
/mavwaked
|
||||||
/mavmaild
|
/mavmaild
|
||||||
|
/mavupdate
|
||||||
|
|
||||||
# Certs (private keys, don't commit)
|
# Certs (private keys, don't commit)
|
||||||
certs/
|
certs/
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
|
|||||||
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
|
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
|
||||||
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
|
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
|
||||||
|
|
||||||
.PHONY: all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go eval-router eval-recall eval-phrasing eval-models
|
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go eval-router eval-recall eval-phrasing eval-models
|
||||||
|
|
||||||
all: build
|
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:
|
build-stt:
|
||||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||||
@@ -53,6 +53,12 @@ build-caldav:
|
|||||||
build-mail:
|
build-mail:
|
||||||
$(GO) build $(GOFLAGS) -o mavmaild ./cmd/mavmaild/
|
$(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
|
run-web: build-web
|
||||||
./mavweb -addr :9200 -voice 127.0.0.1:9100
|
./mavweb -addr :9200 -voice 127.0.0.1:9100
|
||||||
|
|
||||||
@@ -85,6 +91,14 @@ vet:
|
|||||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||||
$(GO) vet ./internal/... ./cmd/...
|
$(GO) vet ./internal/... ./cmd/...
|
||||||
|
|
||||||
|
# simulate — replay every scripted day under cmd/mavend/testdata/scenarios
|
||||||
|
# through the real router, store, tick loop and intake journal, on a fake clock
|
||||||
|
# (Vikunja #284). Verbose so the transcript of each scenario lands in the
|
||||||
|
# terminal. Also runs as part of `make test`; this target is for reading it.
|
||||||
|
simulate:
|
||||||
|
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||||
|
$(GO) test -v -count=1 -run TestSimulator ./cmd/mavend/
|
||||||
|
|
||||||
test: fmt-check vet
|
test: fmt-check vet
|
||||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||||
$(GO) test -race -coverprofile=coverage.out ./internal/... ./cmd/...
|
$(GO) test -race -coverprofile=coverage.out ./internal/... ./cmd/...
|
||||||
@@ -133,6 +147,17 @@ eval-models:
|
|||||||
MAVEN_LLM_URL="$(MAVEN_LLM_URL)" $(GO) test -v -count=1 -timeout 60m \
|
MAVEN_LLM_URL="$(MAVEN_LLM_URL)" $(GO) test -v -count=1 -timeout 60m \
|
||||||
-run TestLLMRouterBaseline ./internal/router/eval/
|
-run TestLLMRouterBaseline ./internal/router/eval/
|
||||||
|
|
||||||
|
# stt-fixtures — regenerate the golden STT audio in cmd/mavsttd/testdata from
|
||||||
|
# the piper voices (#288). The committed WAVs are synthesised, never recorded,
|
||||||
|
# so this is the only way they should ever change. TestGoldenAudioTranscription
|
||||||
|
# then scores them against ggml-small; it self-skips when the model is absent.
|
||||||
|
stt-fixtures:
|
||||||
|
./scripts/gen-stt-fixtures.sh
|
||||||
|
|
||||||
|
test-stt-golden:
|
||||||
|
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||||
|
$(GO) test -v -count=1 -run TestGolden ./cmd/mavsttd/
|
||||||
|
|
||||||
run-stt: build-stt
|
run-stt: build-stt
|
||||||
LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||||
./mavsttd -socket /tmp/maven/stt.sock -model $(WHISPER_MODEL)
|
./mavsttd -socket /tmp/maven/stt.sock -model $(WHISPER_MODEL)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/mcp"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/tool"
|
"github.com/kami/maven/internal/tool"
|
||||||
)
|
)
|
||||||
@@ -52,6 +53,12 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
|||||||
return "выполнить «" + phrase + "»? скажи «да» или «нет»."
|
return "выполнить «" + phrase + "»? скажи «да» или «нет»."
|
||||||
case errors.Is(err, tool.ErrNotEnabled):
|
case errors.Is(err, tool.ErrNotEnabled):
|
||||||
return h.proposeGap(ctx, dec)
|
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)
|
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
|
||||||
if out != "" {
|
if out != "" {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/crawl"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/memory"
|
"github.com/kami/maven/internal/memory"
|
||||||
"github.com/kami/maven/internal/morning"
|
"github.com/kami/maven/internal/morning"
|
||||||
@@ -73,11 +74,30 @@ var querySources = []querySource{
|
|||||||
// it by inventing news. Its matcher needs a feed noun plus an ask, so
|
// it by inventing news. Its matcher needs a feed noun plus an ask, so
|
||||||
// "у меня новая лента в инстаграме" is untouched.
|
// "у меня новая лента в инстаграме" is untouched.
|
||||||
{"feeds", (*reactiveHandler).queryFeeds},
|
{"feeds", (*reactiveHandler).queryFeeds},
|
||||||
|
// Before "calendar" and before the recall sources: "что включено дома?" is
|
||||||
|
// a question about the house, and the notes pass would otherwise answer it
|
||||||
|
// from whatever he once said about the lights. Its matcher needs a house
|
||||||
|
// marker plus an ask plus a device word, and it bails out on weather
|
||||||
|
// wording, so "какая температура на улице?" still reaches the weather
|
||||||
|
// source.
|
||||||
|
{"home", (*reactiveHandler).queryHome},
|
||||||
|
// Next to "home" and for the same reason: "какие устройства в сети?" is a
|
||||||
|
// question about the LAN, and the recall pass would otherwise answer it
|
||||||
|
// from an old note about the router. Its matcher needs a network word plus
|
||||||
|
// an ask plus a device noun, so "интернет не работает" is untouched.
|
||||||
|
{"network", (*reactiveHandler).queryNetwork},
|
||||||
{"calendar", (*reactiveHandler).queryCalendar},
|
{"calendar", (*reactiveHandler).queryCalendar},
|
||||||
{"weather", (*reactiveHandler).queryWeather},
|
{"weather", (*reactiveHandler).queryWeather},
|
||||||
{"embed", (*reactiveHandler).queryEmbed},
|
{"embed", (*reactiveHandler).queryEmbed},
|
||||||
{"memory", (*reactiveHandler).queryMemory},
|
{"memory", (*reactiveHandler).queryMemory},
|
||||||
{"notes", (*reactiveHandler).queryNotes},
|
{"notes", (*reactiveHandler).queryNotes},
|
||||||
|
// LAST before the model answers from memory, and that position is the whole
|
||||||
|
// design (Vikunja #259): local sources first. The model, his own notes and
|
||||||
|
// facts, and — once internal/kiwix is wired into this chain — the offline
|
||||||
|
// ZIMs all get their turn before anything touches the network. This source
|
||||||
|
// only claims a turn where he named a URL out loud, so it never competes
|
||||||
|
// with a local answer.
|
||||||
|
{"web", (*reactiveHandler).queryWeb},
|
||||||
{"general-knowledge", (*reactiveHandler).queryGeneral},
|
{"general-knowledge", (*reactiveHandler).queryGeneral},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +287,39 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
|||||||
return f.FormatEntries(entries, date), true
|
return f.FormatEntries(entries, date), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// queryHome answers a question about the house. Read-only by construction: it
|
||||||
|
// calls States and nothing else, so there is no confirm turn here — the only
|
||||||
|
// way to CHANGE something is an enabled allowlist row through tool.Executor.
|
||||||
|
func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
if !isHomeQuery(t.dec.Utterance) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if h.home == nil {
|
||||||
|
// Claim the turn rather than fall through: "дом не подключён" is true,
|
||||||
|
// and letting general knowledge answer would be an invented house.
|
||||||
|
return "дом не подключён — я его не вижу.", true
|
||||||
|
}
|
||||||
|
ctxH, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return h.home.homeSummary(ctxH)
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryNetwork answers a question about the LAN with a bounded scan. There is
|
||||||
|
// no confirm turn because nothing is changed, and no way to widen the range
|
||||||
|
// because Scan takes no target — the utterance selects the question, never the
|
||||||
|
// subnet.
|
||||||
|
func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
if !isNetworkQuery(t.dec.Utterance) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if h.netscan == nil {
|
||||||
|
// Claim the turn: "сканирование не настроено" is true, and general
|
||||||
|
// knowledge would answer with an invented list of devices.
|
||||||
|
return "сканирование сети не настроено.", true
|
||||||
|
}
|
||||||
|
return h.netscan.scanSummary(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
|
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
if !isWeatherQuery(t.dec.Utterance) {
|
if !isWeatherQuery(t.dec.Utterance) {
|
||||||
return "", false
|
return "", false
|
||||||
@@ -371,6 +424,57 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
|||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// webPageContextRunes — how much of a fetched page is handed to the phraser.
|
||||||
|
// Less than the crawler keeps: the rest of the 4096-token window belongs to the
|
||||||
|
// prompt, the persona block and the reply.
|
||||||
|
const webPageContextRunes = 1500
|
||||||
|
|
||||||
|
// queryWeb — "посмотри https://example.org/x — что там?" (Vikunja #259).
|
||||||
|
//
|
||||||
|
// It claims a turn ONLY when he named a URL, which is what keeps a fallback from
|
||||||
|
// becoming a habit: no URL, no fetch, and the model answers from what is local.
|
||||||
|
// What leaves the box is the URL and nothing else — no note, no fact, no history
|
||||||
|
// travels with it.
|
||||||
|
func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
link, ok := router.FirstURL(t.dec.Utterance)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if h.crawler == nil {
|
||||||
|
// Claim rather than fall through: he asked about a specific page, and
|
||||||
|
// letting the model answer from the URL's spelling alone is how a small
|
||||||
|
// model invents a page's contents.
|
||||||
|
return "я не читаю страницы — это не настроено.", true
|
||||||
|
}
|
||||||
|
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
page, err := h.crawler.Page(ctxFetch, link)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, crawl.ErrRobots) {
|
||||||
|
return "эта страница закрыта для чтения — robots.txt не разрешает.", true
|
||||||
|
}
|
||||||
|
log.Printf("voice: web: %v", err)
|
||||||
|
return "не получилось прочитать страницу.", true
|
||||||
|
}
|
||||||
|
if page.Text == "" {
|
||||||
|
return "страница открылась, но читать там нечего.", true
|
||||||
|
}
|
||||||
|
// The page is handed to the phraser the same way a note is: as context for
|
||||||
|
// the question he actually asked. She answers the question, she does not
|
||||||
|
// recite the page.
|
||||||
|
snippet := page.Title + "\n" + crawl.TrimRunes(page.Text, webPageContextRunes)
|
||||||
|
reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{snippet})
|
||||||
|
if perr != nil {
|
||||||
|
log.Printf("voice: web: phrase: %v", perr)
|
||||||
|
}
|
||||||
|
if reply == "" {
|
||||||
|
// No phraser (or it failed): read back the top of the page rather than
|
||||||
|
// pretend the fetch did not happen.
|
||||||
|
return "вот что на странице: " + crawl.TrimRunes(page.Text, 300), true
|
||||||
|
}
|
||||||
|
return reply, true
|
||||||
|
}
|
||||||
|
|
||||||
// queryGeneral — general knowledge from the phraser, the last source before
|
// queryGeneral — general knowledge from the phraser, the last source before
|
||||||
// giving up. It always claims: either the model answers or Maven says she
|
// giving up. It always claims: either the model answers or Maven says she
|
||||||
// doesn't know.
|
// doesn't know.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
"github.com/kami/maven/internal/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
func randBytes(t *testing.T, n int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
||||||
|
t.Fatalf("rand: %v", err)
|
||||||
|
}
|
||||||
|
b[0] |= 1
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonLockStartsLockedAndFlips(t *testing.T) {
|
||||||
|
dl := newDaemonLock(true)
|
||||||
|
if !dl.isLocked() {
|
||||||
|
t.Fatal("newDaemonLock(true) is not locked")
|
||||||
|
}
|
||||||
|
dl.unlock(nil)
|
||||||
|
if dl.isLocked() {
|
||||||
|
t.Fatal("still locked after unlock")
|
||||||
|
}
|
||||||
|
if newDaemonLock(false).isLocked() {
|
||||||
|
t.Fatal("newDaemonLock(false) reports locked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeStore must be safe on a daemon that never unlocked and safe twice —
|
||||||
|
// shutdown runs it unconditionally.
|
||||||
|
func TestDaemonLockCloseStoreIsSafeWhenNeverUnlocked(t *testing.T) {
|
||||||
|
dl := newDaemonLock(true)
|
||||||
|
if err := dl.closeStore(); err != nil {
|
||||||
|
t.Fatalf("closeStore with no store: %v", err)
|
||||||
|
}
|
||||||
|
if err := dl.closeStore(); err != nil {
|
||||||
|
t.Fatalf("second closeStore: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The data-loss bug: in locked mode the store is opened on an IPC goroutine
|
||||||
|
// inside UnlockFn, and shutdown runs on main. Without the handoff nothing
|
||||||
|
// calls Close, and Close is what re-encrypts the tmpfs working copy back over
|
||||||
|
// the ciphertext file — so every write of a cold-started session vanished.
|
||||||
|
func TestDaemonLockSealsTheStoreOpenedAfterUnlock(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(dir, "maven.db")
|
||||||
|
tmpfs := filepath.Join(dir, "work")
|
||||||
|
key := randBytes(t, 32)
|
||||||
|
// Store.Close zeroes the key slice it was handed (encState.key is the
|
||||||
|
// caller's backing array), so the next boot needs its own copy — exactly
|
||||||
|
// as mavend keeps envKeyBytes separate from the config's key.
|
||||||
|
nextBoot := bytes.Clone(key)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Cold start: locked, no store.
|
||||||
|
dl := newDaemonLock(true)
|
||||||
|
|
||||||
|
// ... unlock arrives, opens the store and hands it over.
|
||||||
|
st, err := store.OpenEncrypted(ctx, dbPath, tmpfs, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenEncrypted: %v", err)
|
||||||
|
}
|
||||||
|
dl.unlock(st)
|
||||||
|
if _, err := st.WriteNote(ctx, time.Now(), "заметка после холодного старта", nil, "test"); err != nil {
|
||||||
|
t.Fatalf("WriteNote: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown.
|
||||||
|
if err := dl.closeStore(); err != nil {
|
||||||
|
t.Fatalf("closeStore: %v", err)
|
||||||
|
}
|
||||||
|
if err := dl.closeStore(); err != nil {
|
||||||
|
t.Fatalf("second closeStore after a real store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next boot with the same key must see the write.
|
||||||
|
st2, err := store.OpenEncrypted(ctx, dbPath, tmpfs, nextBoot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopen: %v", err)
|
||||||
|
}
|
||||||
|
defer st2.Close()
|
||||||
|
notes, err := st2.RecentNotes(ctx, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RecentNotes: %v", err)
|
||||||
|
}
|
||||||
|
if len(notes) != 1 {
|
||||||
|
t.Fatalf("got %d notes after a cold-started session, want 1 — the session was lost", len(notes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point of the wrapped blob: what sits in the state dir must not let
|
||||||
|
// anyone open the database. Nothing written there may contain the key, and the
|
||||||
|
// ciphertext must not be readable with a wrong one.
|
||||||
|
func TestColdStartLeavesNoPlaintextKeyOnDisk(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(dir, "maven.db")
|
||||||
|
tmpfs := filepath.Join(dir, "work")
|
||||||
|
wrappedPath := filepath.Join(dir, "db_key.wrapped")
|
||||||
|
key := randBytes(t, 32)
|
||||||
|
secret := randBytes(t, 32)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
blob, err := webauthn.WrapKey(key, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WrapKey: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(wrappedPath, blob, 0o600); err != nil {
|
||||||
|
t.Fatalf("write wrapped key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := store.OpenEncrypted(ctx, dbPath, tmpfs, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenEncrypted: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.WriteNote(ctx, time.Now(), "секрет", nil, "test"); err != nil {
|
||||||
|
t.Fatalf("WriteNote: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.Close(); err != nil {
|
||||||
|
t.Fatalf("Close: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk everything in the state dir; none of it may contain the key.
|
||||||
|
err = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, rerr := os.ReadFile(p)
|
||||||
|
if rerr != nil {
|
||||||
|
return nil // unreadable is not a leak
|
||||||
|
}
|
||||||
|
if bytes.Contains(b, key) {
|
||||||
|
t.Errorf("%s contains the plaintext encryption key", p)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("walk: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wrapped file must have owner-only permissions.
|
||||||
|
fi, err := os.Stat(wrappedPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat: %v", err)
|
||||||
|
}
|
||||||
|
if perm := fi.Mode().Perm(); perm != 0o600 {
|
||||||
|
t.Errorf("wrapped key file mode = %o, want 600", perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A wrong passkey must not open the store.
|
||||||
|
if _, _, err := webauthn.UnwrapKey(blob, randBytes(t, 32)); err == nil {
|
||||||
|
t.Fatal("a wrong PRF secret unwrapped the key")
|
||||||
|
}
|
||||||
|
if _, err := store.OpenEncrypted(ctx, dbPath, filepath.Join(dir, "work2"), randBytes(t, 32)); err == nil {
|
||||||
|
t.Fatal("the encrypted store opened under a wrong key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the right one round-trips back to a readable database.
|
||||||
|
got, version, err := webauthn.UnwrapKey(blob, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UnwrapKey: %v", err)
|
||||||
|
}
|
||||||
|
if version != webauthn.BlobV2 {
|
||||||
|
t.Errorf("blob version = %v, want v2", version)
|
||||||
|
}
|
||||||
|
st2, err := store.OpenEncrypted(ctx, dbPath, tmpfs, got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopen with the unwrapped key: %v", err)
|
||||||
|
}
|
||||||
|
defer st2.Close()
|
||||||
|
notes, err := st2.RecentNotes(ctx, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RecentNotes: %v", err)
|
||||||
|
}
|
||||||
|
if len(notes) != 1 {
|
||||||
|
t.Fatalf("got %d notes, want 1", len(notes))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
// mavend/crawls.go — the driver for reading web pages (Vikunja #259,
|
||||||
|
// docs/plans/14-web-crawler.md). The crawler is pure and lives in
|
||||||
|
// internal/crawl; this is the impure half: the guarded fetcher, a ticker for the
|
||||||
|
// scheduled watches, and the fact-backed dedup hashes.
|
||||||
|
//
|
||||||
|
// Two paths, one config block, both off unless configured:
|
||||||
|
//
|
||||||
|
// - ON DEMAND — he names a URL out loud and she reads it. That is the
|
||||||
|
// `queryWeb` source in actions_query.go, LAST in the chain: after his
|
||||||
|
// memory, after the notes, and (once Kiwix is wired into the chain) after
|
||||||
|
// the local ZIMs. A local read costs nothing and leaks nothing; a fetch puts
|
||||||
|
// a URL in someone's log, so it goes last.
|
||||||
|
// - SCHEDULED — a watched page is re-read on its interval, and a page whose
|
||||||
|
// text changed is written as a note. It does NOT announce itself. Same rule
|
||||||
|
// as the feed poller: notes, never nudges.
|
||||||
|
//
|
||||||
|
// Only the URL goes out. Nothing here reads a note, a fact, the persona block or
|
||||||
|
// the history, and internal/crawl has no access to the store at all.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/crawl"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/webfetch"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newCrawler builds the crawler from the `crawl` block, or returns nil when
|
||||||
|
// there is none. Every caller checks for nil, and nil means no page is ever
|
||||||
|
// fetched.
|
||||||
|
func newCrawler(cfg *config.Config) *crawl.Crawler {
|
||||||
|
if cfg.Crawl == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cc := cfg.Crawl
|
||||||
|
|
||||||
|
hosts := append([]string(nil), cc.AllowHosts...)
|
||||||
|
// A watched page's own host is always reachable; otherwise an allowlist and
|
||||||
|
// a watch list would have to be kept in sync by hand.
|
||||||
|
for _, w := range cc.Watches {
|
||||||
|
if u, err := url.Parse(w.URL); err == nil && u.Hostname() != "" {
|
||||||
|
hosts = append(hosts, u.Hostname())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An allowlist plus on-demand is a contradiction worth logging rather than
|
||||||
|
// silently resolving: he asked for arbitrary pages AND for a fixed list.
|
||||||
|
// The allowlist wins, because it is the narrower instruction.
|
||||||
|
if len(hosts) > 0 && cc.OnDemand && len(cc.AllowHosts) > 0 {
|
||||||
|
log.Printf("crawl: allow_hosts is set, so on-demand reading is limited to those hosts")
|
||||||
|
}
|
||||||
|
ua := cc.UserAgent
|
||||||
|
if ua == "" {
|
||||||
|
ua = webfetch.DefaultUserAgent
|
||||||
|
}
|
||||||
|
fetcher := webfetch.New(webfetch.Config{
|
||||||
|
AllowHosts: hosts,
|
||||||
|
DenyHosts: cc.DenyHosts,
|
||||||
|
Timeout: time.Duration(cc.Timeout),
|
||||||
|
MaxBytes: cc.MaxBytes,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
// The user-agent handed to the crawler is the one the fetcher sends: obeying
|
||||||
|
// robots rules written for a different name would be a lie.
|
||||||
|
return crawl.New(&crawlFetcher{f: fetcher}, crawl.Config{
|
||||||
|
UserAgent: ua,
|
||||||
|
MaxRunes: cc.MaxRunes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// onDemandCrawler returns a crawler for the answer path, or nil when on-demand
|
||||||
|
// reading is off. The scheduled watches can be on while this is off: reading a
|
||||||
|
// fixed list of pages on a timer and reading whatever URL is in an utterance are
|
||||||
|
// different permissions, and the config keeps them separate.
|
||||||
|
func onDemandCrawler(cfg *config.Config) *crawl.Crawler {
|
||||||
|
if cfg.Crawl == nil || !cfg.Crawl.OnDemand {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return newCrawler(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// crawlWorker — ticker + watcher for the scheduled half.
|
||||||
|
type crawlWorker struct {
|
||||||
|
watcher *crawl.Watcher
|
||||||
|
interval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// crawlTickInterval — how often the worker asks what is due. Per-watch cadence
|
||||||
|
// is the watcher's business.
|
||||||
|
const crawlTickInterval = 15 * time.Minute
|
||||||
|
|
||||||
|
// newCrawlWorker wires the scheduled crawls, or nil when nothing is watched.
|
||||||
|
func newCrawlWorker(c *crawl.Crawler, api ipc.CoreAPI, emb router.Embedder, cfg *config.Config) *crawlWorker {
|
||||||
|
if c == nil || cfg.Crawl == nil || len(cfg.Crawl.Watches) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
watches := make([]crawl.WatchConfig, 0, len(cfg.Crawl.Watches))
|
||||||
|
for _, w := range cfg.Crawl.Watches {
|
||||||
|
watches = append(watches, crawl.WatchConfig{
|
||||||
|
Name: w.Name,
|
||||||
|
URL: w.URL,
|
||||||
|
Interval: time.Duration(w.Interval),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
watcher := crawl.NewWatcher(c, watches, api, &factHashes{api: api},
|
||||||
|
crawlEmbedder(emb), time.Duration(cfg.Crawl.Interval))
|
||||||
|
if watcher == nil {
|
||||||
|
log.Printf("crawl: configured but nothing watchable — scheduled crawls disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Printf("crawl: watching %d page(s), checking what is due every %s", len(watches), crawlTickInterval)
|
||||||
|
return &crawlWorker{watcher: watcher, interval: crawlTickInterval}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run checks what is due until ctx is canceled. The first round runs
|
||||||
|
// immediately; it writes notes only, so an early round startles nobody.
|
||||||
|
func (w *crawlWorker) run(ctx context.Context) {
|
||||||
|
w.watcher.CheckDue(ctx, time.Now())
|
||||||
|
t := time.NewTicker(w.interval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case now := <-t.C:
|
||||||
|
w.watcher.CheckDue(ctx, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// crawlFetcher adapts webfetch to crawl.Fetcher, which is the seam that keeps
|
||||||
|
// net/http out of the crawler package.
|
||||||
|
type crawlFetcher struct{ f *webfetch.Fetcher }
|
||||||
|
|
||||||
|
func (a *crawlFetcher) Get(ctx context.Context, u string) (*crawl.Response, error) {
|
||||||
|
resp, err := a.f.Get(ctx, u)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &crawl.Response{URL: resp.URL, ContentType: resp.ContentType, Body: resp.Body}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// factHashes stores each watch's last content hash as a config fact, so a
|
||||||
|
// restart does not re-note an unchanged page. Same mechanism the feed reader
|
||||||
|
// uses for its marks, and inspectable on /dash.
|
||||||
|
type factHashes struct{ api ipc.CoreAPI }
|
||||||
|
|
||||||
|
func hashKey(name string) string { return "crawl:hash:" + name }
|
||||||
|
|
||||||
|
func (h *factHashes) LastHash(ctx context.Context, name string) (string, error) {
|
||||||
|
f, err := h.api.LatestFact(ctx, hashKey(name))
|
||||||
|
if err != nil {
|
||||||
|
// No hash yet is not an error: the watcher treats "" as "never read".
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return f.Value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *factHashes) SetHash(ctx context.Context, name, hash string) error {
|
||||||
|
_, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||||
|
Ts: time.Now(),
|
||||||
|
Kind: "config",
|
||||||
|
Key: hashKey(name),
|
||||||
|
Value: hash,
|
||||||
|
Source: "poll:crawl",
|
||||||
|
Confidence: 1.0,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// crawlEmbedder adapts router.Embedder for the watcher, embedding with
|
||||||
|
// EmbedPassage (a page is text being searched FOR, and the e5 embedder is
|
||||||
|
// asymmetric).
|
||||||
|
func crawlEmbedder(emb router.Embedder) crawl.Embedder {
|
||||||
|
if emb == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return passageEmbedder{emb}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/crawl"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/voice"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The default config reads nothing. This is the whole "off unless configured"
|
||||||
|
// contract for the crawler, asserted at the wiring level rather than trusted.
|
||||||
|
func TestCrawlOffByDefault(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
if c := newCrawler(cfg); c != nil {
|
||||||
|
t.Error("newCrawler with no crawl block returned a crawler")
|
||||||
|
}
|
||||||
|
if c := onDemandCrawler(cfg); c != nil {
|
||||||
|
t.Error("onDemandCrawler with no crawl block returned a crawler")
|
||||||
|
}
|
||||||
|
if w := newCrawlWorker(nil, nil, nil, cfg); w != nil {
|
||||||
|
t.Error("newCrawlWorker with no crawl block returned a worker")
|
||||||
|
}
|
||||||
|
// Watches configured but on_demand off ⇒ the answer path still reads
|
||||||
|
// nothing: a timer over a fixed list is not permission for arbitrary URLs.
|
||||||
|
withWatch := &config.Config{Crawl: &config.CrawlConfig{
|
||||||
|
Watches: []config.CrawlWatchConfig{{Name: "p", URL: "https://example.org/p"}},
|
||||||
|
}}
|
||||||
|
if c := onDemandCrawler(withWatch); c != nil {
|
||||||
|
t.Error("onDemandCrawler honoured a watch list as on-demand permission")
|
||||||
|
}
|
||||||
|
if c := newCrawler(withWatch); c == nil {
|
||||||
|
t.Error("newCrawler returned nil for a configured watch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wired fetcher must refuse a private address, because the crawler on this
|
||||||
|
// box sits one hop from the whole homelab. Same guard the webfetch tests cover;
|
||||||
|
// this asserts the daemon actually wires it.
|
||||||
|
func TestCrawlerRefusesPrivateAddress(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
w.Write([]byte("<html><body>secret</body></html>"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := newCrawler(&config.Config{Crawl: &config.CrawlConfig{OnDemand: true}})
|
||||||
|
if c == nil {
|
||||||
|
t.Fatal("newCrawler returned nil for an on-demand config")
|
||||||
|
}
|
||||||
|
if _, err := c.Page(context.Background(), srv.URL); err == nil {
|
||||||
|
t.Fatalf("reading %s succeeded; a loopback address must be refused", srv.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFactHashesRoundTrip(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := newTestStore(t)
|
||||||
|
h := &factHashes{api: ipc.NewStoreAPI(st)}
|
||||||
|
|
||||||
|
got, err := h.LastHash(ctx, "page")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LastHash on a fresh store: %v", err)
|
||||||
|
}
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("LastHash = %q, want empty for a never-read page", got)
|
||||||
|
}
|
||||||
|
if err := h.SetHash(ctx, "page", "deadbeef"); err != nil {
|
||||||
|
t.Fatalf("SetHash: %v", err)
|
||||||
|
}
|
||||||
|
got, err = h.LastHash(ctx, "page")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LastHash: %v", err)
|
||||||
|
}
|
||||||
|
if got != "deadbeef" {
|
||||||
|
t.Errorf("LastHash = %q, want deadbeef", got)
|
||||||
|
}
|
||||||
|
if key := hashKey("page"); key != "crawl:hash:page" {
|
||||||
|
t.Errorf("hashKey = %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubCrawlFetcher serves one fixed page to every URL, so queryWeb can be
|
||||||
|
// exercised without a network or an allowlist.
|
||||||
|
type stubCrawlFetcher struct{ body, ctype string }
|
||||||
|
|
||||||
|
func (s *stubCrawlFetcher) Get(_ context.Context, u string) (*crawl.Response, error) {
|
||||||
|
ct := s.ctype
|
||||||
|
if ct == "" {
|
||||||
|
ct = "text/html"
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(u, "/robots.txt") {
|
||||||
|
return &crawl.Response{URL: u, ContentType: "text/plain", Body: []byte("")}, nil
|
||||||
|
}
|
||||||
|
return &crawl.Response{URL: u, ContentType: ct, Body: []byte(s.body)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWebHandler(c *crawl.Crawler) *reactiveHandler {
|
||||||
|
return &reactiveHandler{
|
||||||
|
replier: voice.NewStubReplier(),
|
||||||
|
phraser: phraser.NewStub(),
|
||||||
|
crawler: c,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func askWeb(h *reactiveHandler, q string) (string, bool) {
|
||||||
|
return h.queryWeb(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryWebPassesWithoutAURL(t *testing.T) {
|
||||||
|
h := buildWebHandler(crawl.New(&stubCrawlFetcher{body: "<html><body>x</body></html>"}, crawl.Config{}))
|
||||||
|
if reply, ok := askWeb(h, "почему небо синее?"); ok {
|
||||||
|
t.Errorf("the web source claimed a question with no URL: %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not configured is said out loud rather than falling through, so a small model
|
||||||
|
// never invents a page's contents from its URL.
|
||||||
|
func TestQueryWebSaysWhenNotConfigured(t *testing.T) {
|
||||||
|
h := buildWebHandler(nil)
|
||||||
|
reply, ok := askWeb(h, "посмотри https://example.org/page")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the web source did not claim a question with a URL")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "не настроено") {
|
||||||
|
t.Errorf("reply = %q, want the not-configured answer", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryWebReadsThePage(t *testing.T) {
|
||||||
|
h := buildWebHandler(crawl.New(&stubCrawlFetcher{
|
||||||
|
body: "<html><head><title>Заголовок</title></head><body><p>текст страницы</p></body></html>",
|
||||||
|
}, crawl.Config{}))
|
||||||
|
reply, ok := askWeb(h, "посмотри https://example.org/page — что там?")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the web source did not claim a question with a URL")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "текст страницы") {
|
||||||
|
t.Errorf("reply = %q, want the page text read back", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryWebRefusesNonHTML(t *testing.T) {
|
||||||
|
h := buildWebHandler(crawl.New(&stubCrawlFetcher{
|
||||||
|
body: "\x00\x01binary", ctype: "application/octet-stream",
|
||||||
|
}, crawl.Config{}))
|
||||||
|
reply, ok := askWeb(h, "почитай https://example.org/blob.bin")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the web source did not claim a question with a URL")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "не получилось") {
|
||||||
|
t.Errorf("reply = %q, want the read-failed answer", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// robots.txt is honoured on the answer path too, and she says so instead of
|
||||||
|
// reporting a generic failure.
|
||||||
|
func TestQueryWebObeysRobots(t *testing.T) {
|
||||||
|
h := buildWebHandler(crawl.New(&robotsDenyFetcher{}, crawl.Config{}))
|
||||||
|
reply, ok := askWeb(h, "посмотри https://example.org/private")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the web source did not claim a question with a URL")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "robots.txt") {
|
||||||
|
t.Errorf("reply = %q, want the robots answer", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type robotsDenyFetcher struct{}
|
||||||
|
|
||||||
|
func (robotsDenyFetcher) Get(_ context.Context, u string) (*crawl.Response, error) {
|
||||||
|
if strings.HasSuffix(u, "/robots.txt") {
|
||||||
|
return &crawl.Response{URL: u, ContentType: "text/plain",
|
||||||
|
Body: []byte("User-agent: *\nDisallow: /private\n")}, nil
|
||||||
|
}
|
||||||
|
return &crawl.Response{URL: u, ContentType: "text/html", Body: []byte("<html>nope</html>")}, nil
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/rss"
|
"github.com/kami/maven/internal/rss"
|
||||||
|
"github.com/kami/maven/internal/stt"
|
||||||
"github.com/kami/maven/internal/webfetch"
|
"github.com/kami/maven/internal/webfetch"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -116,6 +117,17 @@ func embedderOf(w *voiceWiring) router.Embedder {
|
|||||||
return w.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
|
// feedFetcher adapts webfetch to rss.Fetcher — the pure package names the two
|
||||||
// fields it needs and stays free of net/http.
|
// fields it needs and stays free of net/http.
|
||||||
type feedFetcher struct{ f *webfetch.Fetcher }
|
type feedFetcher struct{ f *webfetch.Fetcher }
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
// mavend/intake.go — the unified event intake envelope, wired (Vikunja #283).
|
||||||
|
//
|
||||||
|
// internal/event defines the envelope and the bounded in-memory journal. This
|
||||||
|
// file is the one place that FILLS it, and the reason it is one place is worth
|
||||||
|
// stating, because the alternative was eight patches:
|
||||||
|
//
|
||||||
|
// Every intake path in Maven already converges on three writes, and all three
|
||||||
|
// are ipc.CoreAPI methods —
|
||||||
|
//
|
||||||
|
// WriteFact ← POST /api/ambient, mavcaldav, mavpoll's zenmoney + wg reads,
|
||||||
|
// /api/signal presence probes, the RSS/crawl watermarks
|
||||||
|
// WriteNote ← the RSS poller, the page crawler, meeting transcripts,
|
||||||
|
// image descriptions
|
||||||
|
// CaptureTask ← the voice path, the web form, and the mail reader
|
||||||
|
//
|
||||||
|
// — so decorating that ONE interface with a publish covers the lot without a
|
||||||
|
// caller knowing about events at all. cmd/mavmaild, cmd/mavcaldav, cmd/mavpoll,
|
||||||
|
// cmd/mavweb and the in-core feed/crawl/capture/vision workers are unchanged:
|
||||||
|
// they call the same interface they always called, and it now also narrates.
|
||||||
|
//
|
||||||
|
// The exception is cmd/mavend/mail.go, which reaches past the interface to
|
||||||
|
// st.CaptureTask directly. It publishes explicitly; see mailIntake.ingest.
|
||||||
|
//
|
||||||
|
// # Production behaviour when nobody is watching
|
||||||
|
//
|
||||||
|
// A nil *event.Bus makes Publish a no-op, and newIntakeAPI with a nil bus
|
||||||
|
// returns the wrapped API unchanged, so there is not even a decorator on the
|
||||||
|
// call path. The journal is memory-only and is never consulted by the tick
|
||||||
|
// loop, the router, or delivery — nothing Maven says depends on it. It is a
|
||||||
|
// read surface (`/events`, `recent_events`) and an observation seam for the
|
||||||
|
// simulator.
|
||||||
|
//
|
||||||
|
// # What is deliberately NOT here
|
||||||
|
//
|
||||||
|
// No dispatch. An event is a report that something arrived, never an
|
||||||
|
// instruction to speak: "a feed item appeared" becoming a notification is the
|
||||||
|
// nag this repo refuses. Digestion may one day read the journal; it will still
|
||||||
|
// go through internal/loop's rules and the severity/presence routing table.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/event"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newEventBus builds the journal, or returns nil when the operator turned it
|
||||||
|
// off (a negative config.intake_journal). nil is the "behave exactly as before"
|
||||||
|
// value all the way down: no decorator, no ring, no /events rows.
|
||||||
|
func newEventBus(cfg *config.Config) *event.Bus {
|
||||||
|
if cfg == nil || cfg.IntakeJournal < 0 {
|
||||||
|
log.Printf("intake journal: off (intake_journal < 0)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n := cfg.IntakeJournal
|
||||||
|
if n == 0 {
|
||||||
|
n = config.DefaultIntakeJournal
|
||||||
|
}
|
||||||
|
log.Printf("intake journal: keeping the last %d intake events in memory", n)
|
||||||
|
return event.NewBus(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// intakeEventsFn is the daemonAPI.getEvents closure: the bus's ring rendered as
|
||||||
|
// the wire type. Returns nil for a nil bus, which the daemonAPI reports as an
|
||||||
|
// empty journal rather than an error.
|
||||||
|
func intakeEventsFn(bus *event.Bus) func(n int) []ipc.IntakeEvent {
|
||||||
|
if bus == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return func(n int) []ipc.IntakeEvent {
|
||||||
|
evs := bus.Recent(n)
|
||||||
|
out := make([]ipc.IntakeEvent, 0, len(evs))
|
||||||
|
for _, e := range evs {
|
||||||
|
out = append(out, ipc.IntakeEvent{
|
||||||
|
Source: e.Source,
|
||||||
|
Kind: e.Kind,
|
||||||
|
EntityIDs: e.EntityIDs,
|
||||||
|
Title: e.Title,
|
||||||
|
Body: e.Body,
|
||||||
|
Priority: e.Priority,
|
||||||
|
OccurredAt: e.OccurredAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// intakeAPI decorates a CoreAPI, publishing one envelope per successful
|
||||||
|
// intake write. Embedding the interface means every other method passes
|
||||||
|
// through untouched, and a new CoreAPI method is inherited rather than
|
||||||
|
// silently dropped.
|
||||||
|
type intakeAPI struct {
|
||||||
|
ipc.CoreAPI
|
||||||
|
bus *event.Bus
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// newIntakeAPI wraps api so its intake writes are journalled. A nil bus
|
||||||
|
// returns api itself — no decorator, no allocation, no behaviour change.
|
||||||
|
func newIntakeAPI(api ipc.CoreAPI, bus *event.Bus, now func() time.Time) ipc.CoreAPI {
|
||||||
|
if bus == nil || api == nil {
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
if now == nil {
|
||||||
|
now = time.Now
|
||||||
|
}
|
||||||
|
return &intakeAPI{CoreAPI: api, bus: bus, now: now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFact journals the fact after it lands. Order matters: an event is a
|
||||||
|
// report of something that HAPPENED, so a failed write publishes nothing.
|
||||||
|
func (a *intakeAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64, error) {
|
||||||
|
id, err := a.CoreAPI.WriteFact(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
// OccurredAt is req.Ts, not now: mavpoll's wg read carries the handshake
|
||||||
|
// instant and the ambient path carries the meeting's start. Flattening
|
||||||
|
// those to notice-time would make the journal lie about when things
|
||||||
|
// happened, which is the one thing it is for.
|
||||||
|
a.bus.Publish(event.Event{
|
||||||
|
Source: req.Source,
|
||||||
|
Kind: event.SourceKind(req.Source, event.KindFact),
|
||||||
|
Title: req.Key,
|
||||||
|
Body: req.Value,
|
||||||
|
Priority: factPriority(req),
|
||||||
|
OccurredAt: req.Ts,
|
||||||
|
EntityIDs: entityIDs(req.Subject),
|
||||||
|
}, a.now())
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteNote journals a note. This is the RSS and crawler path, and also the
|
||||||
|
// meeting transcript and image description paths, which write their derived
|
||||||
|
// text as ordinary notes.
|
||||||
|
func (a *intakeAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
|
||||||
|
id, err := a.CoreAPI.WriteNote(ctx, ts, text, embedding, source)
|
||||||
|
if err != nil {
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
title, body := splitFirstLine(text)
|
||||||
|
a.bus.Publish(event.Event{
|
||||||
|
Source: source,
|
||||||
|
Kind: event.SourceKind(source, event.KindNote),
|
||||||
|
Title: title,
|
||||||
|
Body: body,
|
||||||
|
Priority: event.PriorityLow,
|
||||||
|
OccurredAt: ts,
|
||||||
|
}, a.now())
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureTask journals a captured task, but only when a row was actually
|
||||||
|
// created. CaptureTask dedupes on normalised text among live rows, so a
|
||||||
|
// mailbox re-read after a restart must not refill the journal with tasks that
|
||||||
|
// were already there.
|
||||||
|
func (a *intakeAPI) CaptureTask(ctx context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
|
||||||
|
resp, err := a.CoreAPI.CaptureTask(ctx, req)
|
||||||
|
if err != nil || !resp.Created {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
a.bus.Publish(publishableTask(store.Task{
|
||||||
|
CreatedTs: req.Ts,
|
||||||
|
Text: req.Text,
|
||||||
|
Source: req.Source,
|
||||||
|
Evidence: req.Evidence,
|
||||||
|
Status: req.Status,
|
||||||
|
Due: req.Due,
|
||||||
|
}, a.now()), a.now())
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishableTask is the task→envelope shape, shared with mail.go, which
|
||||||
|
// captures through the store directly rather than through the interface.
|
||||||
|
//
|
||||||
|
// Priority is high for a candidate with a due date and normal otherwise. That
|
||||||
|
// is the only place this file makes a judgement, and it is a display hint on a
|
||||||
|
// review page — nothing routes on it.
|
||||||
|
func publishableTask(t store.Task, now time.Time) event.Event {
|
||||||
|
occurred := t.CreatedTs
|
||||||
|
if occurred.IsZero() {
|
||||||
|
occurred = now
|
||||||
|
}
|
||||||
|
prio := event.PriorityNormal
|
||||||
|
if t.Due != nil {
|
||||||
|
prio = event.PriorityHigh
|
||||||
|
}
|
||||||
|
return event.Event{
|
||||||
|
Source: t.Source,
|
||||||
|
Kind: event.KindTask,
|
||||||
|
Title: t.Text,
|
||||||
|
Body: t.Evidence,
|
||||||
|
Priority: prio,
|
||||||
|
OccurredAt: occurred,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// factPriority is the attention hint for a fact write. Deliberately crude:
|
||||||
|
// a low-confidence inference (the ambient notification path writes below 1.0)
|
||||||
|
// is worth less attention than a read he or a credentialled poller made, and
|
||||||
|
// nothing else is distinguishable from here.
|
||||||
|
func factPriority(req ipc.WriteFactReq) string {
|
||||||
|
if req.Confidence > 0 && req.Confidence < 1.0 {
|
||||||
|
return event.PriorityLow
|
||||||
|
}
|
||||||
|
return event.PriorityNormal
|
||||||
|
}
|
||||||
|
|
||||||
|
// entityIDs turns a fact's free-text Subject into the EntityIDs slot when it
|
||||||
|
// already looks resolved. Intake runs BEFORE the fact enrichment worker
|
||||||
|
// resolves a subject against Nexus, so this is almost always empty — the slot
|
||||||
|
// exists for the paths that do know (the ecosystem acts), not for guessing.
|
||||||
|
func entityIDs(subject string) []string {
|
||||||
|
subject = strings.TrimSpace(subject)
|
||||||
|
if subject == "" || !strings.HasPrefix(subject, "entity:") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{strings.TrimPrefix(subject, "entity:")}
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitFirstLine renders a note as title + body. Feed and crawl notes are
|
||||||
|
// written "headline\nsummary\nlink", so the first line is already the title.
|
||||||
|
func splitFirstLine(text string) (title, body string) {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if i := strings.IndexByte(text, '\n'); i >= 0 {
|
||||||
|
return strings.TrimSpace(text[:i]), strings.TrimSpace(text[i+1:])
|
||||||
|
}
|
||||||
|
return text, ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/event"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
var intakeNow = time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
func intakeClock() time.Time { return intakeNow }
|
||||||
|
|
||||||
|
// failingAPI wraps the store adapter, failing the three intake writes on
|
||||||
|
// demand, so the "a failed write publishes nothing" invariant is testable.
|
||||||
|
type failingAPI struct {
|
||||||
|
ipc.CoreAPI
|
||||||
|
fail bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *failingAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64, error) {
|
||||||
|
if f.fail {
|
||||||
|
return 0, errors.New("injected")
|
||||||
|
}
|
||||||
|
return f.CoreAPI.WriteFact(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newIntakeTestAPI(t *testing.T) (ipc.CoreAPI, *event.Bus) {
|
||||||
|
t.Helper()
|
||||||
|
st := newTestStore(t)
|
||||||
|
bus := event.NewBus(32)
|
||||||
|
return newIntakeAPI(ipc.NewStoreAPI(st), bus, intakeClock), bus
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntakeAPIWithoutBusIsTheBareAPI(t *testing.T) {
|
||||||
|
// The adoption invariant: with the journal off there is not even a
|
||||||
|
// decorator on the intake path, so production behaves exactly as before.
|
||||||
|
st := newTestStore(t)
|
||||||
|
bare := ipc.NewStoreAPI(st)
|
||||||
|
if got := newIntakeAPI(bare, nil, intakeClock); got != ipc.CoreAPI(bare) {
|
||||||
|
t.Errorf("newIntakeAPI with a nil bus returned a wrapper, want the bare API")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewEventBusOffWhenNegative(t *testing.T) {
|
||||||
|
if b := newEventBus(&config.Config{IntakeJournal: -1}); b != nil {
|
||||||
|
t.Error("intake_journal = -1 still built a bus")
|
||||||
|
}
|
||||||
|
if b := newEventBus(&config.Config{IntakeJournal: 4}); b == nil {
|
||||||
|
t.Error("intake_journal = 4 built no bus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntakeJournalsAFactWrite(t *testing.T) {
|
||||||
|
api, bus := newIntakeTestAPI(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
// The ambient path's shape: an env fact below full confidence, timestamped
|
||||||
|
// at the meeting's start rather than at notice time.
|
||||||
|
start := intakeNow.Add(2 * time.Hour)
|
||||||
|
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||||
|
Ts: start, Kind: "env", Key: "calendar_event_20260801_планёрка",
|
||||||
|
Value: "10:00-11:00 планёрка", Source: "ambient:notif", Confidence: 0.6,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("WriteFact: %v", err)
|
||||||
|
}
|
||||||
|
got := bus.Recent(0)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("journal has %d entries, want 1", len(got))
|
||||||
|
}
|
||||||
|
e := got[0]
|
||||||
|
if e.Source != "ambient:notif" || e.Kind != event.KindFact {
|
||||||
|
t.Errorf("source/kind = %q/%q", e.Source, e.Kind)
|
||||||
|
}
|
||||||
|
if e.Title != "calendar_event_20260801_планёрка" {
|
||||||
|
t.Errorf("title = %q, want the fact key", e.Title)
|
||||||
|
}
|
||||||
|
if !e.OccurredAt.Equal(start) {
|
||||||
|
t.Errorf("occurred_at = %v, want the fact's Ts %v — the journal must not flatten intake to notice time", e.OccurredAt, start)
|
||||||
|
}
|
||||||
|
if e.Priority != event.PriorityLow {
|
||||||
|
t.Errorf("priority = %q, want %q for a sub-1.0 confidence read", e.Priority, event.PriorityLow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntakeDoesNotJournalAFailedWrite(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
bus := event.NewBus(8)
|
||||||
|
api := newIntakeAPI(&failingAPI{CoreAPI: ipc.NewStoreAPI(st), fail: true}, bus, intakeClock)
|
||||||
|
if _, err := api.WriteFact(context.Background(), ipc.WriteFactReq{
|
||||||
|
Ts: intakeNow, Kind: "env", Key: "k", Value: "v", Source: "poll:zenmoney", Confidence: 1,
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("expected the injected error")
|
||||||
|
}
|
||||||
|
if bus.Len() != 0 {
|
||||||
|
t.Errorf("journal has %d entries after a failed write, want 0 — an event reports something that happened", bus.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntakeJournalsANoteAsTitlePlusBody(t *testing.T) {
|
||||||
|
api, bus := newIntakeTestAPI(t)
|
||||||
|
// The RSS shape: "headline\nsummary\nlink".
|
||||||
|
if _, err := api.WriteNote(context.Background(), intakeNow,
|
||||||
|
"Вышло ядро 6.19\nкраткое содержание\nhttps://example.org/a", nil, "rss:tech"); err != nil {
|
||||||
|
t.Fatalf("WriteNote: %v", err)
|
||||||
|
}
|
||||||
|
got := bus.Recent(1)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("journal has %d entries, want 1", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Title != "Вышло ядро 6.19" {
|
||||||
|
t.Errorf("title = %q, want the headline", got[0].Title)
|
||||||
|
}
|
||||||
|
if got[0].Kind != event.KindNote {
|
||||||
|
t.Errorf("kind = %q, want %q", got[0].Kind, event.KindNote)
|
||||||
|
}
|
||||||
|
if got[0].Body == "" {
|
||||||
|
t.Error("body is empty, want the rest of the note")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntakeJournalsOnlyCreatedTasks(t *testing.T) {
|
||||||
|
api, bus := newIntakeTestAPI(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
req := ipc.CaptureTaskReq{Text: "оплатить интернет", Source: "email:inbox", Status: "candidate", Ts: intakeNow}
|
||||||
|
if _, err := api.CaptureTask(ctx, req); err != nil {
|
||||||
|
t.Fatalf("CaptureTask: %v", err)
|
||||||
|
}
|
||||||
|
// Same text again: CaptureTask dedupes among live rows, and a re-read of a
|
||||||
|
// mailbox must not refill the journal.
|
||||||
|
resp, err := api.CaptureTask(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CaptureTask (repeat): %v", err)
|
||||||
|
}
|
||||||
|
if resp.Created {
|
||||||
|
t.Fatal("store did not dedupe; the test cannot check what it means to")
|
||||||
|
}
|
||||||
|
if bus.Len() != 1 {
|
||||||
|
t.Errorf("journal has %d entries, want 1 — a deduped capture must not publish", bus.Len())
|
||||||
|
}
|
||||||
|
if got := bus.Recent(1)[0]; got.Kind != event.KindTask || got.Title != "оплатить интернет" {
|
||||||
|
t.Errorf("entry = %+v, want the captured task", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntakeEventsFnRendersNewestFirst(t *testing.T) {
|
||||||
|
api, bus := newIntakeTestAPI(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, key := range []string{"a", "b", "c"} {
|
||||||
|
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||||
|
Ts: intakeNow, Kind: "env", Key: key, Value: "1", Source: "poll:zenmoney", Confidence: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("WriteFact %s: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn := intakeEventsFn(bus)
|
||||||
|
got := fn(2)
|
||||||
|
if len(got) != 2 || got[0].Title != "c" || got[1].Title != "b" {
|
||||||
|
t.Errorf("intakeEventsFn(2) = %+v, want the two newest, newest first", got)
|
||||||
|
}
|
||||||
|
if intakeEventsFn(nil) != nil {
|
||||||
|
t.Error("intakeEventsFn(nil) returned a closure, want nil so daemonAPI reports an empty journal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonAPIRecentEventsEmptyWithoutABus(t *testing.T) {
|
||||||
|
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}}
|
||||||
|
got, err := d.RecentEvents(context.Background(), 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RecentEvents with no journal errored: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("got %d events, want none", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-6
@@ -26,8 +26,8 @@ import (
|
|||||||
|
|
||||||
"github.com/kami/maven/internal/config"
|
"github.com/kami/maven/internal/config"
|
||||||
"github.com/kami/maven/internal/email"
|
"github.com/kami/maven/internal/email"
|
||||||
|
"github.com/kami/maven/internal/event"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/llm"
|
|
||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
@@ -43,6 +43,11 @@ type mailIntake struct {
|
|||||||
ex *email.Extractor
|
ex *email.Extractor
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
// bus — the unified intake journal (Vikunja #283). This path captures
|
||||||
|
// through the store directly rather than through ipc.CoreAPI, so the
|
||||||
|
// decorator in intake.go does not see it and the publish is explicit here.
|
||||||
|
// nil is a working no-op.
|
||||||
|
bus *event.Bus
|
||||||
}
|
}
|
||||||
|
|
||||||
// newMailIntake returns nil when mail ingestion must not be available, which is
|
// newMailIntake returns nil when mail ingestion must not be available, which is
|
||||||
@@ -53,7 +58,7 @@ type mailIntake struct {
|
|||||||
// no keyword fallback: "the subject line became a task" is not extraction,
|
// no keyword fallback: "the subject line became a task" is not extraction,
|
||||||
// it is a mailbox rendered as a to-do list, and it would fill the review
|
// it is a mailbox rendered as a to-do list, and it would fill the review
|
||||||
// page faster than he could clear it.
|
// page faster than he could clear it.
|
||||||
func newMailIntake(st *store.Store, phr phraser.Phraser, cfg *config.Config) *mailIntake {
|
func newMailIntake(st *store.Store, phr phraser.Phraser, cfg *config.Config, bus *event.Bus) *mailIntake {
|
||||||
if cfg.Email == nil {
|
if cfg.Email == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -66,9 +71,9 @@ func newMailIntake(st *store.Store, phr phraser.Phraser, cfg *config.Config) *ma
|
|||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
timeout = config.DefaultEmailTimeout
|
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)
|
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}
|
return &mailIntake{st: st, ex: ex, timeout: timeout, now: time.Now, bus: bus}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ingest handles one ipc.MethodIngestMail call.
|
// ingest handles one ipc.MethodIngestMail call.
|
||||||
@@ -129,6 +134,10 @@ func (m *mailIntake) ingest(ctx context.Context, req ipc.IngestMailReq) (ipc.Ing
|
|||||||
resp.TaskIDs = append(resp.TaskIDs, id)
|
resp.TaskIDs = append(resp.TaskIDs, id)
|
||||||
if created {
|
if created {
|
||||||
resp.Created++
|
resp.Created++
|
||||||
|
// Only a row that was actually created. CaptureTask dedupes on
|
||||||
|
// normalised text among live rows, so a mailbox re-read after a
|
||||||
|
// restart must not refill the journal with tasks already in it.
|
||||||
|
m.bus.Publish(publishableTask(t, now), now)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Counts only: the log line names the mailbox and the UID, never the subject,
|
// Counts only: the log line names the mailbox and the UID, never the subject,
|
||||||
@@ -140,8 +149,8 @@ func (m *mailIntake) ingest(ctx context.Context, req ipc.IngestMailReq) (ipc.Ing
|
|||||||
// wireMailIntake installs the IPC hook, or leaves it nil so the method reports
|
// wireMailIntake installs the IPC hook, or leaves it nil so the method reports
|
||||||
// ErrUnknownMethod. Called on both startup paths (unlocked boot and passkey
|
// ErrUnknownMethod. Called on both startup paths (unlocked boot and passkey
|
||||||
// unlock) so mail behaves the same either way.
|
// unlock) so mail behaves the same either way.
|
||||||
func wireMailIntake(srv *ipc.Server, st *store.Store, phr phraser.Phraser, cfg *config.Config) {
|
func wireMailIntake(srv *ipc.Server, st *store.Store, phr phraser.Phraser, cfg *config.Config, bus *event.Bus) {
|
||||||
mi := newMailIntake(st, phr, cfg)
|
mi := newMailIntake(st, phr, cfg, bus)
|
||||||
if mi == nil {
|
if mi == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,12 +171,12 @@ func TestIngestTruncatesEvidence(t *testing.T) {
|
|||||||
// exist at all.
|
// exist at all.
|
||||||
func TestNewMailIntakeOffWithoutConfig(t *testing.T) {
|
func TestNewMailIntakeOffWithoutConfig(t *testing.T) {
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
if mi := newMailIntake(st, nil, &config.Config{}); mi != nil {
|
if mi := newMailIntake(st, nil, &config.Config{}, nil); mi != nil {
|
||||||
t.Error("no email block must mean no mail intake")
|
t.Error("no email block must mean no mail intake")
|
||||||
}
|
}
|
||||||
// Configured but with a non-LLM phraser: still off — there is no fallback
|
// Configured but with a non-LLM phraser: still off — there is no fallback
|
||||||
// extraction, by design.
|
// extraction, by design.
|
||||||
if mi := newMailIntake(st, nil, &config.Config{Email: &config.EmailConfig{}}); mi != nil {
|
if mi := newMailIntake(st, nil, &config.Config{Email: &config.EmailConfig{}}, nil); mi != nil {
|
||||||
t.Error("without a llama-server phraser there is nothing to extract with")
|
t.Error("without a llama-server phraser there is nothing to extract with")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+153
-22
@@ -66,12 +66,19 @@ import (
|
|||||||
|
|
||||||
var errLocked = errors.New("mavend: daemon locked — complete passkey assertion first")
|
var errLocked = errors.New("mavend: daemon locked — complete passkey assertion first")
|
||||||
|
|
||||||
// daemonLock tracks whether the daemon is in locked (pre-unlock) mode.
|
// daemonLock tracks whether the daemon is in locked (pre-unlock) mode, and
|
||||||
// In locked mode, all CoreAPI methods return errLocked. The unlock path
|
// owns the store handle the unlock path creates.
|
||||||
// replaces the CoreAPI with the real store adapter and flips the flag.
|
//
|
||||||
|
// The store matters here because of who runs when. In locked mode there is no
|
||||||
|
// store at boot; one is opened inside UnlockFn, on an IPC goroutine, minutes
|
||||||
|
// or days later. Shutdown runs on the main goroutine. Without a handoff the
|
||||||
|
// main goroutine has nothing to close, and store.Close is what re-encrypts
|
||||||
|
// the tmpfs working copy back over the ciphertext file — so a daemon that
|
||||||
|
// cold-started lost every write of that session, silently, on the next boot.
|
||||||
type daemonLock struct {
|
type daemonLock struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
locked bool
|
locked bool
|
||||||
|
st *store.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDaemonLock(locked bool) *daemonLock {
|
func newDaemonLock(locked bool) *daemonLock {
|
||||||
@@ -84,10 +91,25 @@ func (l *daemonLock) isLocked() bool {
|
|||||||
return l.locked
|
return l.locked
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *daemonLock) unlock() {
|
// unlock flips the flag and takes ownership of the store opened by UnlockFn.
|
||||||
|
func (l *daemonLock) unlock(st *store.Store) {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
l.locked = false
|
l.locked = false
|
||||||
|
l.st = st
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeStore seals the store the unlock path opened, if any. Safe to call
|
||||||
|
// when the daemon never unlocked, and safe to call twice.
|
||||||
|
func (l *daemonLock) closeStore() error {
|
||||||
|
l.mu.Lock()
|
||||||
|
st := l.st
|
||||||
|
l.st = nil
|
||||||
|
l.mu.Unlock()
|
||||||
|
if st == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return st.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -155,6 +177,14 @@ func run(args []string) error {
|
|||||||
return fmt.Errorf("open store: %w", err)
|
return fmt.Errorf("open store: %w", err)
|
||||||
}
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
} else {
|
||||||
|
// Locked boot: the store does not exist yet. Seal whatever UnlockFn
|
||||||
|
// opened, at shutdown, on this goroutine.
|
||||||
|
defer func() {
|
||||||
|
if err := dl.closeStore(); err != nil {
|
||||||
|
log.Printf("mavend: seal store on shutdown: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- daemon components (only wired when unlocked) -----
|
// ----- daemon components (only wired when unlocked) -----
|
||||||
@@ -171,8 +201,19 @@ func run(args []string) error {
|
|||||||
factWorker *factEnrichmentWorker
|
factWorker *factEnrichmentWorker
|
||||||
evalWorker *memoryEvalWorker // nil ⇒ memory evaluation off (the default)
|
evalWorker *memoryEvalWorker // nil ⇒ memory evaluation off (the default)
|
||||||
feedWkr *feedWorker // nil ⇒ no feed is read (the default)
|
feedWkr *feedWorker // nil ⇒ no feed is read (the default)
|
||||||
|
crawlWkr *crawlWorker // nil ⇒ no page is watched (the default)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The unified intake journal (Vikunja #283). Built before anything else
|
||||||
|
// that holds a CoreAPI, because intakeAPI wraps that one interface and
|
||||||
|
// every intake path in the daemon reaches its sink through it. nil (the
|
||||||
|
// operator set intake_journal negative) means no decorator at all.
|
||||||
|
evBus := newEventBus(cfg)
|
||||||
|
// coreFor is what every in-process holder of a CoreAPI now takes, instead
|
||||||
|
// of a bare ipc.NewStoreAPI(st). Identical behaviour plus one published
|
||||||
|
// envelope per successful intake write.
|
||||||
|
coreFor := func() ipc.CoreAPI { return newIntakeAPI(ipc.NewStoreAPI(st), evBus, time.Now) }
|
||||||
|
|
||||||
if !locked {
|
if !locked {
|
||||||
rules = loop.DefaultRules()
|
rules = loop.DefaultRules()
|
||||||
gatherer = loop.NewGatherer(st, rules)
|
gatherer = loop.NewGatherer(st, rules)
|
||||||
@@ -216,7 +257,7 @@ func run(args []string) error {
|
|||||||
eco = wireEcosystem(cfg)
|
eco = wireEcosystem(cfg)
|
||||||
|
|
||||||
// voice
|
// voice
|
||||||
voiceW, err = wireVoice(cfg, ipc.NewStoreAPI(st), phr, st.VectorMemory(), st, eco)
|
voiceW, err = wireVoice(cfg, coreFor(), phr, st.VectorMemory(), st, eco)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("wire voice: %w", err)
|
return fmt.Errorf("wire voice: %w", err)
|
||||||
}
|
}
|
||||||
@@ -266,18 +307,23 @@ func run(args []string) error {
|
|||||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
||||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||||
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||||
feedWkr = newFeedWorker(ipc.NewStoreAPI(st), embedderOf(voiceW), cfg)
|
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
|
||||||
|
crawlWkr = newCrawlWorker(newCrawler(cfg), coreFor(), embedderOf(voiceW), cfg)
|
||||||
|
|
||||||
coreAPI = &daemonAPI{
|
coreAPI = &daemonAPI{
|
||||||
CoreAPI: ipc.NewStoreAPI(st),
|
CoreAPI: coreFor(),
|
||||||
getTrace: tl.trace,
|
getTrace: tl.trace,
|
||||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||||
|
getEvents: intakeEventsFn(evBus),
|
||||||
}
|
}
|
||||||
if voiceW != nil && voiceW.handler != nil {
|
if voiceW != nil && voiceW.handler != nil {
|
||||||
api := coreAPI.(*daemonAPI)
|
api := coreAPI.(*daemonAPI)
|
||||||
api.chatFn = voiceW.handler.handleText
|
api.chatFn = voiceW.handler.handleText
|
||||||
}
|
}
|
||||||
|
if voiceW != nil && voiceW.mcp != nil {
|
||||||
|
coreAPI.(*daemonAPI).getMCPServers = voiceW.mcp.status
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// locked mode: no real store yet, so there's no meaningful CoreAPI to
|
// 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
|
// serve. srv.Check below is the actual guard — every CoreAPI call is
|
||||||
@@ -326,15 +372,31 @@ func run(args []string) error {
|
|||||||
// configured and there is a llama-server to extract with, in which case
|
// configured and there is a llama-server to extract with, in which case
|
||||||
// ipc.MethodIngestMail reports ErrUnknownMethod.
|
// ipc.MethodIngestMail reports ErrUnknownMethod.
|
||||||
if !locked {
|
if !locked {
|
||||||
wireMailIntake(srv, st, phr, cfg)
|
wireMailIntake(srv, st, phr, cfg, evBus)
|
||||||
|
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
|
// WrapKeyFn — wraps the env key under the passkey PRF secret and persists
|
||||||
// persists the wrapped blob. Only wired when the daemon has the key in
|
// the wrapped blob. Only wired when the daemon has the key in memory (env
|
||||||
// memory (env key mode). Called by mavweb after passkey enrollment.
|
// key mode). Called by mavweb after passkey enrollment.
|
||||||
|
//
|
||||||
|
// webauthn.WrapKey refuses anything that is not a 32-byte PRF output, so
|
||||||
|
// an authenticator without PRF support produces no wrapped file at all
|
||||||
|
// rather than a file that looks protected and is not.
|
||||||
if envKeyBytes != nil {
|
if envKeyBytes != nil {
|
||||||
srv.WrapKeyFn = func(ctx context.Context, publicKey []byte) error {
|
srv.WrapKeyFn = func(ctx context.Context, secret []byte) error {
|
||||||
blob, err := webauthn.WrapKey(envKeyBytes, publicKey)
|
blob, err := webauthn.WrapKey(envKeyBytes, secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("wrap encryption key: %w", err)
|
return fmt.Errorf("wrap encryption key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -350,20 +412,42 @@ func run(args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnlockFn — cold-start unlock: unwraps the encryption key from the wrapped
|
// UnlockFn — cold-start unlock: unwraps the encryption key from the
|
||||||
// blob using the passkey credential public key, opens the store, wires all
|
// wrapped blob using the passkey PRF secret, opens the store, wires all
|
||||||
// daemon components, and replaces the locked API.
|
// daemon components, and replaces the locked API.
|
||||||
if locked {
|
if locked {
|
||||||
srv.UnlockFn = func(ctx context.Context, publicKey []byte) error {
|
var unlockMu sync.Mutex
|
||||||
|
srv.UnlockFn = func(ctx context.Context, secret []byte) error {
|
||||||
|
// One unlock at a time, and never a second one. Without this a
|
||||||
|
// concurrent pair of Unlock calls would each open a store and
|
||||||
|
// wire a full daemon, and the loser's goroutines would run
|
||||||
|
// against a store nobody closes.
|
||||||
|
unlockMu.Lock()
|
||||||
|
defer unlockMu.Unlock()
|
||||||
|
if !dl.isLocked() {
|
||||||
|
return nil // already unlocked; the caller does not need to know
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wire cannot authenticate its caller — the socket is
|
||||||
|
// same-uid — so the unlock path requires a passkey assertion
|
||||||
|
// that mavweb verified cryptographically first. Without this,
|
||||||
|
// MethodUnlock is reachable by anything on the box.
|
||||||
|
if !passkeySess.IsStepUp() {
|
||||||
|
return errors.New("unlock: no verified passkey assertion (assert first)")
|
||||||
|
}
|
||||||
|
|
||||||
wp := *wrappedKeyPath
|
wp := *wrappedKeyPath
|
||||||
blob, err := os.ReadFile(wp)
|
blob, err := os.ReadFile(wp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read wrapped key: %w", err)
|
return fmt.Errorf("read wrapped key: %w", err)
|
||||||
}
|
}
|
||||||
key, err := webauthn.UnwrapKey(blob, publicKey)
|
key, version, err := webauthn.UnwrapKey(blob, secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unwrap key: %w", err)
|
return fmt.Errorf("unwrap key: %w", err)
|
||||||
}
|
}
|
||||||
|
if version == webauthn.BlobV1 {
|
||||||
|
log.Printf("SECURITY: %s was unwrapped from a %s blob. The wrapping key is derived from the credential PUBLIC key, which mavweb also writes to its passkeys.json — anyone holding both files can recover the database key with no authenticator. Re-enroll the passkey on an authenticator that supports the PRF extension to rewrite it as v2.", wp, version)
|
||||||
|
}
|
||||||
// Open the store with the unwrapped key.
|
// Open the store with the unwrapped key.
|
||||||
st, err = store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, key)
|
st, err = store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -409,7 +493,7 @@ func run(args []string) error {
|
|||||||
|
|
||||||
eco = wireEcosystem(cfg)
|
eco = wireEcosystem(cfg)
|
||||||
|
|
||||||
voiceW, err = wireVoice(cfg, ipc.NewStoreAPI(st), phr, st.VectorMemory(), st, eco)
|
voiceW, err = wireVoice(cfg, coreFor(), phr, st.VectorMemory(), st, eco)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("wire voice: %w", err)
|
return fmt.Errorf("wire voice: %w", err)
|
||||||
}
|
}
|
||||||
@@ -453,21 +537,30 @@ func run(args []string) error {
|
|||||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
||||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||||
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||||
feedWkr = newFeedWorker(ipc.NewStoreAPI(st), embedderOf(voiceW), cfg)
|
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
|
||||||
|
crawlWkr = newCrawlWorker(newCrawler(cfg), coreFor(), embedderOf(voiceW), cfg)
|
||||||
|
|
||||||
// Swap the CoreAPI from the locked placeholder to the real store adapter.
|
// Swap the CoreAPI from the locked placeholder to the real store adapter.
|
||||||
newAPI := &daemonAPI{
|
newAPI := &daemonAPI{
|
||||||
CoreAPI: ipc.NewStoreAPI(st),
|
CoreAPI: coreFor(),
|
||||||
getTrace: tl.trace,
|
getTrace: tl.trace,
|
||||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||||
|
getEvents: intakeEventsFn(evBus),
|
||||||
}
|
}
|
||||||
if voiceW != nil && voiceW.handler != nil {
|
if voiceW != nil && voiceW.handler != nil {
|
||||||
newAPI.chatFn = voiceW.handler.handleText
|
newAPI.chatFn = voiceW.handler.handleText
|
||||||
}
|
}
|
||||||
srv.SetAPI(newAPI)
|
srv.SetAPI(newAPI)
|
||||||
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
|
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
|
||||||
wireMailIntake(srv, st, phr, cfg)
|
wireMailIntake(srv, st, phr, cfg, evBus)
|
||||||
|
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.
|
// Start voice server.
|
||||||
if voiceW != nil {
|
if voiceW != nil {
|
||||||
@@ -506,7 +599,24 @@ func run(args []string) error {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
dl.unlock()
|
// Start the watched-page crawls (nil unless configured).
|
||||||
|
if crawlWkr != nil {
|
||||||
|
go func() {
|
||||||
|
crawlWkr.run(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep MCP connections alive (nil unless configured).
|
||||||
|
if voiceW != nil && voiceW.mcp != nil {
|
||||||
|
go voiceW.mcp.run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-enumerate the house for new devices (nil unless configured).
|
||||||
|
if voiceW != nil && voiceW.home != nil {
|
||||||
|
go voiceW.home.run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
dl.unlock(st)
|
||||||
log.Printf("mavend: unlocked via passkey assertion")
|
log.Printf("mavend: unlocked via passkey assertion")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -558,6 +668,27 @@ func run(args []string) error {
|
|||||||
feedWkr.run(ctx)
|
feedWkr.run(ctx)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
if crawlWkr != nil {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
crawlWkr.run(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
if voiceW != nil && voiceW.mcp != nil {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
voiceW.mcp.run(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
if voiceW != nil && voiceW.home != nil {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
voiceW.home.run(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<-ctx.Done()
|
<-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"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/config"
|
"github.com/kami/maven/internal/config"
|
||||||
"github.com/kami/maven/internal/llm"
|
|
||||||
"github.com/kami/maven/internal/memeval"
|
"github.com/kami/maven/internal/memeval"
|
||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/store"
|
"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
|
// A generous per-request timeout: this is a long prompt to a Thinking model
|
||||||
// and nobody is waiting on the answer.
|
// 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{
|
ev := memeval.NewEvaluator(st, st, client, memeval.Config{
|
||||||
MaxItems: cfg.MemoryEval.MaxItems,
|
MaxItems: cfg.MemoryEval.MaxItems,
|
||||||
MinConfidence: cfg.MemoryEval.MinConfidence,
|
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,142 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/netscan"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scanBudget — the whole spoken scan, end to end. A voice turn that takes
|
||||||
|
// longer than this has already failed as a turn, so the scan returns whatever
|
||||||
|
// it found rather than keeping him waiting.
|
||||||
|
const scanBudget = 20 * time.Second
|
||||||
|
|
||||||
|
// scanReadOut — how many hosts she names out loud. The rest are a count: a
|
||||||
|
// spoken list of twenty IP addresses is not an answer.
|
||||||
|
const scanReadOut = 6
|
||||||
|
|
||||||
|
// netWiring — the LAN scanner, when the `netscan` block is enabled. nil ⇒ Maven
|
||||||
|
// never puts a discovery packet on the network.
|
||||||
|
//
|
||||||
|
// Unlike the house, a scan is a READ, so it is a query source rather than an
|
||||||
|
// act: there is no allowlist row and no confirm turn, because nothing changes.
|
||||||
|
// What makes that safe is that the range is not an argument — see
|
||||||
|
// internal/netscan's package comment.
|
||||||
|
type netWiring struct {
|
||||||
|
scanner *netscan.Scanner
|
||||||
|
subnets []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// wireNetScan builds the scanner. nil unless the block is enabled and valid.
|
||||||
|
func wireNetScan(cfg *config.Config) *netWiring {
|
||||||
|
nc, ok := cfg.NetScanner()
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := netscan.Validate(nc); err != nil {
|
||||||
|
// config.validate already ran this, so reaching here is a programming
|
||||||
|
// error rather than a config one. Not fatal: the scanner off is a
|
||||||
|
// working Maven.
|
||||||
|
log.Printf("netscan: not wired: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &netWiring{scanner: netscan.New(nc), subnets: nc.Subnets}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanSummary answers "какие устройства в сети?" in one line.
|
||||||
|
func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
||||||
|
if w == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, scanBudget)
|
||||||
|
defer cancel()
|
||||||
|
hosts, err := w.scanner.Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("netscan: scan: %v", err)
|
||||||
|
return "не получилось просканировать сеть.", true
|
||||||
|
}
|
||||||
|
if len(hosts) == 0 {
|
||||||
|
return "в сети никого не нашла.", true
|
||||||
|
}
|
||||||
|
shown := hosts
|
||||||
|
if len(shown) > scanReadOut {
|
||||||
|
shown = shown[:scanReadOut]
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(shown))
|
||||||
|
for _, h := range shown {
|
||||||
|
s := h.Addr
|
||||||
|
if len(h.Ports) > 0 {
|
||||||
|
ps := make([]string, 0, len(h.Ports))
|
||||||
|
for _, p := range h.Ports {
|
||||||
|
ps = append(ps, fmt.Sprintf("%d", p))
|
||||||
|
}
|
||||||
|
s += " (" + strings.Join(ps, ", ") + ")"
|
||||||
|
}
|
||||||
|
parts = append(parts, s)
|
||||||
|
}
|
||||||
|
out := fmt.Sprintf("нашла %d %s: %s", len(hosts), hostWord(len(hosts)), strings.Join(parts, "; "))
|
||||||
|
if len(hosts) > len(shown) {
|
||||||
|
out += fmt.Sprintf(" и ещё %d", len(hosts)-len(shown))
|
||||||
|
}
|
||||||
|
return out + ".", true
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostWord — Russian counts inflect the noun: 1 устройство, 2-4 устройства,
|
||||||
|
// 5+ устройств, and the teens are all the last form.
|
||||||
|
func hostWord(n int) string {
|
||||||
|
if n%100 >= 11 && n%100 <= 14 {
|
||||||
|
return "устройств"
|
||||||
|
}
|
||||||
|
switch n % 10 {
|
||||||
|
case 1:
|
||||||
|
return "устройство"
|
||||||
|
case 2, 3, 4:
|
||||||
|
return "устройства"
|
||||||
|
default:
|
||||||
|
return "устройств"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNetworkQuery recognises a question about the LAN, narrowly. It needs a
|
||||||
|
// network word AND an ask: "интернет не работает" is a complaint, not a request
|
||||||
|
// to scan, and a scan she runs unasked is exactly the noisy behaviour the
|
||||||
|
// bounds exist to prevent.
|
||||||
|
func isNetworkQuery(u string) bool {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(u))
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
network := false
|
||||||
|
for _, w := range []string{"в сети", "в сетке", "сеть", "сети", "локальн", "wifi", "wi-fi", "вайфай"} {
|
||||||
|
if strings.Contains(s, w) {
|
||||||
|
network = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !network {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// An explicit ask to scan, or a phrase that can only be about the LAN.
|
||||||
|
// "кто в сети" carries no device noun but means nothing else.
|
||||||
|
for _, w := range []string{"просканируй", "сканируй", "скан", "просканир", "кто в сети", "кто в сетке"} {
|
||||||
|
if strings.Contains(s, w) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ask := strings.Contains(s, "?") || homeWord(s, "какие") || homeWord(s, "кто") ||
|
||||||
|
homeWord(s, "что") || homeWord(s, "сколько") || strings.Contains(s, "покажи")
|
||||||
|
if !ask {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, w := range []string{"устройств", "хост", "компьютер", "машин", "адрес"} {
|
||||||
|
if strings.Contains(s, w) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
||||||
|
for name, cfg := range map[string]*config.Config{
|
||||||
|
"no block": {},
|
||||||
|
"written but dark": {NetScan: &config.NetScanConfig{
|
||||||
|
Subnets: []string{"192.168.1.0/24"},
|
||||||
|
}},
|
||||||
|
"enabled but nothing to scan": {NetScan: &config.NetScanConfig{Enabled: true}},
|
||||||
|
"enabled but public": {NetScan: &config.NetScanConfig{
|
||||||
|
Subnets: []string{"8.8.8.0/24"}, Enabled: true,
|
||||||
|
}},
|
||||||
|
"enabled but far too wide": {NetScan: &config.NetScanConfig{
|
||||||
|
Subnets: []string{"10.0.0.0/8"}, Enabled: true,
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if w := wireNetScan(cfg); w != nil {
|
||||||
|
t.Fatal("the scanner must not wire for this config")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var w *netWiring
|
||||||
|
if _, ok := w.scanSummary(context.Background()); ok {
|
||||||
|
t.Fatal("a nil wiring must not claim a query")
|
||||||
|
}
|
||||||
|
|
||||||
|
ok := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
|
||||||
|
Subnets: []string{"192.168.1.0/24"}, Enabled: true,
|
||||||
|
}})
|
||||||
|
if ok == nil {
|
||||||
|
t.Fatal("a valid enabled block should wire")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A loopback /32 with nothing listening on the scanned port: the summary must
|
||||||
|
// come back honest rather than inventing a host. This also exercises the real
|
||||||
|
// dialer end to end without touching anything outside this box.
|
||||||
|
func TestScanSummaryOnAnEmptyRange(t *testing.T) {
|
||||||
|
w := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
|
||||||
|
// Port 1 on loopback: nothing listens and the connection is refused
|
||||||
|
// immediately, so the scan is fast and touches only this machine.
|
||||||
|
Subnets: []string{"127.0.0.1/32"}, Ports: []int{1}, Rate: 1000, Enabled: true,
|
||||||
|
}})
|
||||||
|
if w == nil {
|
||||||
|
t.Fatal("wireNetScan returned nil")
|
||||||
|
}
|
||||||
|
out, claimed := w.scanSummary(context.Background())
|
||||||
|
if !claimed {
|
||||||
|
t.Fatal("the summary did not claim the turn")
|
||||||
|
}
|
||||||
|
if out == "" {
|
||||||
|
t.Fatal("empty summary")
|
||||||
|
}
|
||||||
|
// Persona: feminine self-reference, informal address, no pet names.
|
||||||
|
low := strings.ToLower(out)
|
||||||
|
for _, bad := range []string{"нашёл", "не смог ", "вы ", "ваш", "милый", "дорогой"} {
|
||||||
|
if strings.Contains(low, bad) {
|
||||||
|
t.Errorf("persona violation %q in %q", bad, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostWordAgreesWithTheCount(t *testing.T) {
|
||||||
|
for n, want := range map[int]string{
|
||||||
|
1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
|
||||||
|
11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
|
||||||
|
25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
|
||||||
|
} {
|
||||||
|
if got := hostWord(n); got != want {
|
||||||
|
t.Errorf("hostWord(%d) = %q, want %q", n, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsNetworkQuery(t *testing.T) {
|
||||||
|
yes := []string{
|
||||||
|
"какие устройства в сети?",
|
||||||
|
"кто в сети?",
|
||||||
|
"просканируй сеть",
|
||||||
|
"покажи устройства в локальной сети",
|
||||||
|
"сколько машин в сети",
|
||||||
|
}
|
||||||
|
no := []string{
|
||||||
|
"",
|
||||||
|
"интернет не работает",
|
||||||
|
"сеть какая-то медленная",
|
||||||
|
"я в сети инстаграма",
|
||||||
|
"что включено дома?",
|
||||||
|
"напомни оплатить интернет",
|
||||||
|
}
|
||||||
|
for _, u := range yes {
|
||||||
|
if !isNetworkQuery(u) {
|
||||||
|
t.Errorf("isNetworkQuery(%q) = false, want true", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, u := range no {
|
||||||
|
if isNetworkQuery(u) {
|
||||||
|
t.Errorf("isNetworkQuery(%q) = true, want false", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,798 @@
|
|||||||
|
// mavend/simulator_test.go — the replayable full-system simulator
|
||||||
|
// (Vikunja #284, 20-07-2026-BACKLOG.md item 7).
|
||||||
|
//
|
||||||
|
// # What it is
|
||||||
|
//
|
||||||
|
// A scripted day, replayed through the real mavend code paths, with every
|
||||||
|
// boundary faked and the clock under the scenario's control. A scenario is a
|
||||||
|
// JSON file in testdata/scenarios; the harness reads it, builds a world, walks
|
||||||
|
// the steps in order, and asserts on what actually happened:
|
||||||
|
//
|
||||||
|
// what Maven SAID — the reply text of every utterance
|
||||||
|
// what was SENT — every delivery.Sendable the dispatcher emitted
|
||||||
|
// what ARRIVED — the unified intake journal from #283
|
||||||
|
// what TOOLS were called — the recorded requests against fake Praxis/Nexis/Hexis
|
||||||
|
// what did NOT happen — expect_no_send / expect_no_call, first-class
|
||||||
|
//
|
||||||
|
// The last one is the point. Maven's hard constraints are mostly negative —
|
||||||
|
// not a nag, not autonomous, nothing executed without confirmation — and a
|
||||||
|
// harness that can only assert on things that happened cannot test any of
|
||||||
|
// them. "Nothing was sent" is an assertion here, not an absence of one.
|
||||||
|
//
|
||||||
|
// # Determinism
|
||||||
|
//
|
||||||
|
// No time.Now() runs inside a replay. The scenario names a start instant, each
|
||||||
|
// step names a wall-clock offset from it, and the harness advances a fakeClock
|
||||||
|
// to that offset before running the step. Every clock reader in the world —
|
||||||
|
// the handler's `now`, the tick loop's `tick(ctx, now)`, the intake journal's
|
||||||
|
// publish stamp — is wired to that clock. Two runs of the same file produce
|
||||||
|
// the same transcript, and a scenario about 08:35 does not behave differently
|
||||||
|
// at 03:00 in CI.
|
||||||
|
//
|
||||||
|
// The tick is driven by the scenario, not by a ticker: tick() already takes
|
||||||
|
// `now` as an argument, so the only thing the daemon's ticker contributed was
|
||||||
|
// wall-clock timing, which is exactly what a replay must not have.
|
||||||
|
//
|
||||||
|
// # Why this shape and not a binary
|
||||||
|
//
|
||||||
|
// Vikunja #288 (golden-audio STT) deferred its tier-2 "audio → STT → router →
|
||||||
|
// phraser" scenarios to this task, and asked that they reuse a fixture format
|
||||||
|
// rather than inventing a third. A scenario here can name a WAV from
|
||||||
|
// cmd/mavsttd/testdata and the harness will feed it through the STT seam. As a
|
||||||
|
// test it runs under `make test` on every change, which a separate binary
|
||||||
|
// would not.
|
||||||
|
//
|
||||||
|
// # Production is untouched
|
||||||
|
//
|
||||||
|
// Every file this task adds is a _test.go file or testdata. There is no
|
||||||
|
// simulator in the daemon, no flag, no config key, and no code path that
|
||||||
|
// checks whether a simulation is running. The seams it uses — stt.Transcriber,
|
||||||
|
// tts.Synthesizer, router.Completer, delivery.Sink, ipc.CoreAPI, the
|
||||||
|
// event.Bus from #283 — all already existed for the production wiring.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/audio"
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
|
"github.com/kami/maven/internal/event"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/llm"
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
"github.com/kami/maven/internal/tool"
|
||||||
|
"github.com/kami/maven/internal/voice"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scenario format
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// scenario — one scripted day. schema_version matches the convention already
|
||||||
|
// set by testdata/system_safety_scenarios.json.
|
||||||
|
type scenario struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
|
||||||
|
// Start — the instant the day begins, RFC3339. Every step offset is
|
||||||
|
// relative to it, and nothing in the run reads a real clock.
|
||||||
|
Start string `json:"start"`
|
||||||
|
|
||||||
|
// Script — what the resident model answers. The world has no llama-server;
|
||||||
|
// see scriptedLLM for how an entry is chosen.
|
||||||
|
Script []scriptEntry `json:"script,omitempty"`
|
||||||
|
|
||||||
|
// Praxis / Nexus / Hexis — canned bodies for the ecosystem fakes. Absent ⇒
|
||||||
|
// that service is not wired at all, which is the default box.
|
||||||
|
Praxis string `json:"praxis_attention,omitempty"`
|
||||||
|
Nexus string `json:"nexus_resolve,omitempty"`
|
||||||
|
Hexis string `json:"hexis_capabilities,omitempty"`
|
||||||
|
|
||||||
|
Steps []step `json:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// scriptEntry — one canned model answer. Match is a substring of the user
|
||||||
|
// message; the first entry whose Match is contained in it wins, and an entry
|
||||||
|
// with an empty Match is the catch-all.
|
||||||
|
//
|
||||||
|
// Route and Reply are separate because the same model serves both contracts
|
||||||
|
// (CLAUDE.md, "LLM output contract"): a grammar-constrained call is a routing
|
||||||
|
// call and gets Route, an unconstrained one is a phrasing call and gets Reply.
|
||||||
|
type scriptEntry struct {
|
||||||
|
Match string `json:"match"`
|
||||||
|
Route string `json:"route,omitempty"`
|
||||||
|
Reply string `json:"reply,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// step — one scripted moment. At is "HH:MM" or "HH:MM:SS", interpreted in the
|
||||||
|
// start instant's location; the clock is advanced to it before the step runs.
|
||||||
|
//
|
||||||
|
// A step does exactly one thing (say / audio / signal / fact / tick / arrive)
|
||||||
|
// and then asserts. Assertions are evaluated against everything recorded since
|
||||||
|
// the run began, except expect_no_send and expect_no_call, which are scoped to
|
||||||
|
// this step — "nothing was sent because of THIS" is the useful question.
|
||||||
|
type step struct {
|
||||||
|
At string `json:"at"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
|
||||||
|
// --- stimuli (at most one per step) ---
|
||||||
|
|
||||||
|
// Say — an utterance, as text, through the same runTurn the IPC chat path
|
||||||
|
// uses.
|
||||||
|
Say string `json:"say,omitempty"`
|
||||||
|
|
||||||
|
// Audio — a WAV under cmd/mavsttd/testdata, fed through the STT seam. This
|
||||||
|
// is #288's deferred tier 2. The harness uses the deterministic stt stub
|
||||||
|
// unless a real transcriber is available, so the assertion a scenario can
|
||||||
|
// make about an audio step is about the PIPELINE, not about whisper's
|
||||||
|
// accuracy — that is what cmd/mavsttd/golden_test.go is for.
|
||||||
|
Audio string `json:"audio,omitempty"`
|
||||||
|
|
||||||
|
// Signal — a presence/world fact arriving from a poller or /api/signal.
|
||||||
|
Signal *signalStep `json:"signal,omitempty"`
|
||||||
|
|
||||||
|
// Arrive — an intake write from a module: an ambient notification, a feed
|
||||||
|
// item, a mail candidate. Goes through the same decorated ipc.CoreAPI the
|
||||||
|
// daemon gives those callers, so it lands in the journal exactly as it
|
||||||
|
// would in production.
|
||||||
|
Arrive *arriveStep `json:"arrive,omitempty"`
|
||||||
|
|
||||||
|
// Tick — run one iteration of the proactive loop at this instant.
|
||||||
|
Tick bool `json:"tick,omitempty"`
|
||||||
|
|
||||||
|
// Fault — make every ecosystem fake answer with this HTTP status from now
|
||||||
|
// on. The degraded-mode lever; ClearFault puts them back.
|
||||||
|
Fault int `json:"fault,omitempty"`
|
||||||
|
ClearFault bool `json:"clear_fault,omitempty"`
|
||||||
|
|
||||||
|
// --- assertions ---
|
||||||
|
|
||||||
|
ExpectReply []string `json:"expect_reply_contains,omitempty"`
|
||||||
|
ExpectNotReply []string `json:"expect_reply_lacks,omitempty"`
|
||||||
|
ExpectSent []string `json:"expect_sent_contains,omitempty"`
|
||||||
|
ExpectNoSend bool `json:"expect_no_send,omitempty"`
|
||||||
|
ExpectCalled []string `json:"expect_called,omitempty"`
|
||||||
|
ExpectNotCalled []string `json:"expect_not_called,omitempty"`
|
||||||
|
ExpectEvents []string `json:"expect_events,omitempty"`
|
||||||
|
ExpectNoEvents bool `json:"expect_no_events,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type signalStep struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Kind string `json:"kind,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type arriveStep struct {
|
||||||
|
// Note / Fact / Task — exactly one. Each mirrors the intake seam its real
|
||||||
|
// caller uses.
|
||||||
|
Note *arriveNote `json:"note,omitempty"`
|
||||||
|
Fact *signalStep `json:"fact,omitempty"`
|
||||||
|
Task *arriveTask `json:"task,omitempty"`
|
||||||
|
AsOf string `json:"as_of,omitempty"` // "HH:MM" — OccurredAt, when it differs from the step time
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type arriveNote struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type arriveTask struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Evidence string `json:"evidence,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The world
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// simWorld — every faked boundary plus the real components between them.
|
||||||
|
type simWorld struct {
|
||||||
|
t *testing.T
|
||||||
|
clock *fakeClock
|
||||||
|
loc *time.Location
|
||||||
|
start time.Time
|
||||||
|
|
||||||
|
store *store.Store
|
||||||
|
api ipc.CoreAPI // the intake-decorated adapter, same as the daemon builds
|
||||||
|
bus *event.Bus
|
||||||
|
handler *reactiveHandler
|
||||||
|
tick *tickLoop
|
||||||
|
sink *recordingSink
|
||||||
|
llm *scriptedLLM
|
||||||
|
|
||||||
|
praxis *fakeServer
|
||||||
|
nexus *fakeServer
|
||||||
|
hexis *fakeServer
|
||||||
|
|
||||||
|
// transcript — everything that happened, in order. Printed on failure so a
|
||||||
|
// broken scenario is diagnosable without a debugger.
|
||||||
|
transcript []string
|
||||||
|
replies []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordingSink captures every send, mutex-guarded (the tick loop dispatches
|
||||||
|
// from its own goroutine in production and the race detector is on here).
|
||||||
|
type recordingSink struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
sends []delivery.Sendable
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *recordingSink) Send(_ context.Context, d delivery.Sendable) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.sends = append(s.sends, d)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *recordingSink) all() []delivery.Sendable {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := make([]delivery.Sendable, len(s.sends))
|
||||||
|
copy(out, s.sends)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *recordingSink) count() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return len(s.sends)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scriptedLLM stands in for llama-server on BOTH contracts the resident model
|
||||||
|
// serves: grammar-constrained routing and unconstrained phrasing.
|
||||||
|
//
|
||||||
|
// It is not a stub that ignores its input — a scenario that scripts an answer
|
||||||
|
// for "что я пропустил" and gets asked something else must fail, not silently
|
||||||
|
// return the wrong intent. An unmatched call returns an error, and the router
|
||||||
|
// then falls through to the classifier cascade exactly as it does in
|
||||||
|
// production when llama-server is unreachable. That fall-through is itself
|
||||||
|
// worth exercising: it is the failure floor CLAUDE.md refuses to let rot.
|
||||||
|
type scriptedLLM struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entries []scriptEntry
|
||||||
|
calls []llm.Req
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *scriptedLLM) Complete(_ context.Context, r llm.Req) (string, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.calls = append(s.calls, r)
|
||||||
|
routing := r.Grammar != ""
|
||||||
|
for _, e := range s.entries {
|
||||||
|
if e.Match != "" && !strings.Contains(strings.ToLower(r.User), strings.ToLower(e.Match)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if routing && e.Route != "" {
|
||||||
|
return e.Route, nil
|
||||||
|
}
|
||||||
|
if !routing && e.Reply != "" {
|
||||||
|
return e.Reply, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("simulator: no scripted %s answer for %q",
|
||||||
|
map[bool]string{true: "route", false: "reply"}[routing], truncateRunes(r.User, 60))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Building the world
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func newSimWorld(t *testing.T, sc scenario) *simWorld {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
start, err := time.Parse(time.RFC3339, sc.Start)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("scenario %q: bad start %q: %v", sc.Name, sc.Start, err)
|
||||||
|
}
|
||||||
|
clock := newFakeClock(start)
|
||||||
|
|
||||||
|
st := newTestStore(t)
|
||||||
|
bus := event.NewBus(512)
|
||||||
|
// The same decorator the daemon wires, on the same clock: intake in a
|
||||||
|
// replay is journalled exactly as it is in production.
|
||||||
|
api := newIntakeAPI(ipc.NewStoreAPI(st), bus, clock.Now)
|
||||||
|
|
||||||
|
sink := &recordingSink{}
|
||||||
|
rules := loop.DefaultRules()
|
||||||
|
gatherer := loop.NewGatherer(st, rules)
|
||||||
|
dispatcher := delivery.NewDispatcher(delivery.Config{
|
||||||
|
Voice: sink, Ntfy: sink, Telegram: sink, Nudges: st, Reminders: st,
|
||||||
|
})
|
||||||
|
tl := newTickLoop(st, gatherer, dispatcher, phraser.NewStub(), rules,
|
||||||
|
time.Minute, 5*time.Minute, 0, nil, nil, nil, nil)
|
||||||
|
|
||||||
|
scripted := &scriptedLLM{entries: sc.Script}
|
||||||
|
|
||||||
|
w := &simWorld{
|
||||||
|
t: t, clock: clock, loc: start.Location(), start: start,
|
||||||
|
store: st, api: api, bus: bus, tick: tl, sink: sink, llm: scripted,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ecosystem fakes, wired only when the scenario supplies a body — a box
|
||||||
|
// with no praxis block has no praxis client, and a scenario must be able to
|
||||||
|
// reproduce that.
|
||||||
|
eco := &ecosystemWiring{}
|
||||||
|
if sc.Praxis != "" {
|
||||||
|
w.praxis = newFakePraxis(t, sc.Praxis)
|
||||||
|
eco.praxis = newPraxisClient(w.praxis.URL)
|
||||||
|
}
|
||||||
|
if sc.Nexus != "" {
|
||||||
|
w.nexus = newFakeNexus(t, sc.Nexus)
|
||||||
|
}
|
||||||
|
if sc.Hexis != "" {
|
||||||
|
w.hexis = newFakeHexis(t, sc.Hexis, fixtureHexisExecuted("exec_1", "completed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The router: the same cascade the daemon builds — stage-0 grammars, the
|
||||||
|
// LLM router on the scripted model, the classifier underneath. Keeping the
|
||||||
|
// classifier in is deliberate; it is the failure floor, and a scenario that
|
||||||
|
// scripts no route for an utterance exercises it.
|
||||||
|
emb := router.NewHashEmbedder(1024)
|
||||||
|
matcher := tool.NewMatcher(nil)
|
||||||
|
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted))
|
||||||
|
|
||||||
|
w.handler = &reactiveHandler{
|
||||||
|
stt: simTranscriber{},
|
||||||
|
tts: simSynthesizer{},
|
||||||
|
router: rtr,
|
||||||
|
embedder: emb,
|
||||||
|
api: api,
|
||||||
|
matcher: matcher,
|
||||||
|
phraser: phraser.NewStub(),
|
||||||
|
replier: newLLMReplier(scripted, nil),
|
||||||
|
now: clock.Now,
|
||||||
|
memStore: st.VectorMemory(),
|
||||||
|
dataStore: st,
|
||||||
|
queryMinScore: config.DefaultQueryMinScore,
|
||||||
|
queryMinMargin: config.DefaultQueryMinMargin,
|
||||||
|
timeParser: router.StubDateTimeParser{},
|
||||||
|
dialogueSessions: dialogue.NewSessionStore(time.Hour),
|
||||||
|
clarifyStore: dialogue.NewClarifyStore(time.Hour),
|
||||||
|
clarifyMaxAttempts: dialogue.DefaultMaxAttempts,
|
||||||
|
ecosystem: eco,
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// simTranscriber — the STT seam. Deterministic by construction: it returns the
|
||||||
|
// text the harness parked for this step, so the pipeline under test is
|
||||||
|
// "audio arrives → a turn runs", not "whisper heard correctly". Transcription
|
||||||
|
// accuracy is cmd/mavsttd/golden_test.go's job (#288 tier 1), and duplicating
|
||||||
|
// it here would make every scenario depend on a 500 MB model.
|
||||||
|
type simTranscriber struct{ text string }
|
||||||
|
|
||||||
|
func (s simTranscriber) Transcribe(_ context.Context, _ audio.Audio) (string, float64, error) {
|
||||||
|
return s.text, 1.0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// simSynthesizer — the TTS seam. A scenario asserts on what Maven SAID, which
|
||||||
|
// is the reply text; the waveform is not the artefact under test.
|
||||||
|
type simSynthesizer struct{}
|
||||||
|
|
||||||
|
func (simSynthesizer) Synthesize(_ context.Context, _ string) (audio.Audio, error) {
|
||||||
|
return audio.Audio{Format: audio.PCM16kMono}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Running
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (w *simWorld) logf(format string, args ...any) {
|
||||||
|
w.transcript = append(w.transcript,
|
||||||
|
fmt.Sprintf("%s %s", w.clock.Now().In(w.loc).Format("15:04:05"), fmt.Sprintf(format, args...)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// dump prints the whole transcript. Called on any failure — a scenario that
|
||||||
|
// broke on step 7 is unreadable without the six steps before it.
|
||||||
|
func (w *simWorld) dump() {
|
||||||
|
w.t.Logf("--- replay transcript ---\n%s", strings.Join(w.transcript, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// advanceTo moves the clock to the step's offset. Time only ever moves
|
||||||
|
// FORWARD: a scenario with steps out of order is a bug in the scenario, and
|
||||||
|
// silently reordering it would hide the bug.
|
||||||
|
func (w *simWorld) advanceTo(at string) {
|
||||||
|
w.t.Helper()
|
||||||
|
if at == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := w.timeOf(at)
|
||||||
|
now := w.clock.Now()
|
||||||
|
if target.Before(now) {
|
||||||
|
w.t.Fatalf("step at %s goes backwards from %s — scenario steps must be in order",
|
||||||
|
at, now.In(w.loc).Format("15:04:05"))
|
||||||
|
}
|
||||||
|
w.clock.Advance(target.Sub(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
// timeOf resolves an "HH:MM" or "HH:MM:SS" step offset against the scenario's
|
||||||
|
// start day and location.
|
||||||
|
func (w *simWorld) timeOf(at string) time.Time {
|
||||||
|
w.t.Helper()
|
||||||
|
layout := "15:04"
|
||||||
|
if strings.Count(at, ":") == 2 {
|
||||||
|
layout = "15:04:05"
|
||||||
|
}
|
||||||
|
hm, err := time.Parse(layout, at)
|
||||||
|
if err != nil {
|
||||||
|
w.t.Fatalf("bad step time %q: %v", at, err)
|
||||||
|
}
|
||||||
|
return time.Date(w.start.Year(), w.start.Month(), w.start.Day(),
|
||||||
|
hm.Hour(), hm.Minute(), hm.Second(), 0, w.loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *simWorld) run(sc scenario) {
|
||||||
|
ctx := context.Background()
|
||||||
|
for i, s := range sc.Steps {
|
||||||
|
w.advanceTo(s.At)
|
||||||
|
if s.Note != "" {
|
||||||
|
w.logf("# %s", s.Note)
|
||||||
|
}
|
||||||
|
sendsBefore := w.sink.count()
|
||||||
|
callsBefore := w.callCount()
|
||||||
|
eventsBefore := w.bus.Len()
|
||||||
|
|
||||||
|
w.stimulate(ctx, s)
|
||||||
|
w.assert(i, s, sendsBefore, callsBefore, eventsBefore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *simWorld) stimulate(ctx context.Context, s step) {
|
||||||
|
if s.Fault != 0 || s.ClearFault {
|
||||||
|
for _, fs := range []*fakeServer{w.praxis, w.nexus, w.hexis} {
|
||||||
|
if fs != nil {
|
||||||
|
fs.SetFault(s.Fault)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.logf("fault=%d on every ecosystem fake", s.Fault)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case s.Say != "":
|
||||||
|
reply := w.handler.runTurn(ctx, s.Say)
|
||||||
|
w.replies = append(w.replies, reply)
|
||||||
|
w.logf("он: %s", s.Say)
|
||||||
|
w.logf("она: %s", reply)
|
||||||
|
|
||||||
|
case s.Audio != "":
|
||||||
|
text := w.audioText(s.Audio)
|
||||||
|
// Swap in a transcriber parked with this step's text, then run the same
|
||||||
|
// push-to-talk entry point the voice client calls.
|
||||||
|
w.handler.stt = simTranscriber{text: text}
|
||||||
|
resp, err := w.handler.HandlePushToTalk(ctx, voicePTT(), 0)
|
||||||
|
if err != nil {
|
||||||
|
w.t.Fatalf("push-to-talk on %s: %v", s.Audio, err)
|
||||||
|
}
|
||||||
|
w.replies = append(w.replies, resp.ReplyText)
|
||||||
|
w.logf("[wav %s → %q]", filepath.Base(s.Audio), text)
|
||||||
|
w.logf("она: %s", resp.ReplyText)
|
||||||
|
|
||||||
|
case s.Signal != nil:
|
||||||
|
w.write(ctx, *s.Signal, w.clock.Now())
|
||||||
|
w.logf("сигнал: %s=%s (%s)", s.Signal.Key, s.Signal.Value, s.Signal.Source)
|
||||||
|
|
||||||
|
case s.Arrive != nil:
|
||||||
|
w.arrive(ctx, *s.Arrive)
|
||||||
|
|
||||||
|
case s.Tick:
|
||||||
|
w.tick.tick(ctx, w.clock.Now())
|
||||||
|
w.logf("tick")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *simWorld) write(ctx context.Context, sig signalStep, ts time.Time) {
|
||||||
|
w.t.Helper()
|
||||||
|
kind := sig.Kind
|
||||||
|
if kind == "" {
|
||||||
|
kind = "env"
|
||||||
|
}
|
||||||
|
if _, err := w.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||||
|
Ts: ts, Kind: kind, Key: sig.Key, Value: sig.Value, Source: sig.Source, Confidence: 1.0,
|
||||||
|
}); err != nil {
|
||||||
|
w.t.Fatalf("write fact %s: %v", sig.Key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *simWorld) arrive(ctx context.Context, a arriveStep) {
|
||||||
|
w.t.Helper()
|
||||||
|
// AsOf is when the thing HAPPENED, which for a feed item or a relayed
|
||||||
|
// notification is usually earlier than when Maven heard about it. It does
|
||||||
|
// not move the clock — only the timestamp on the row and the envelope.
|
||||||
|
ts := w.clock.Now()
|
||||||
|
if a.AsOf != "" {
|
||||||
|
ts = w.timeOf(a.AsOf)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case a.Fact != nil:
|
||||||
|
f := *a.Fact
|
||||||
|
if f.Source == "" {
|
||||||
|
f.Source = a.Source
|
||||||
|
}
|
||||||
|
w.write(ctx, f, ts)
|
||||||
|
w.logf("пришло: факт %s=%s (%s)", f.Key, f.Value, f.Source)
|
||||||
|
case a.Note != nil:
|
||||||
|
if _, err := w.api.WriteNote(ctx, ts, a.Note.Text, nil, a.Source); err != nil {
|
||||||
|
w.t.Fatalf("write note from %s: %v", a.Source, err)
|
||||||
|
}
|
||||||
|
w.logf("пришло: заметка от %s — %s", a.Source, truncateRunes(a.Note.Text, 60))
|
||||||
|
case a.Task != nil:
|
||||||
|
status := a.Task.Status
|
||||||
|
if status == "" {
|
||||||
|
status = store.TaskCandidate
|
||||||
|
}
|
||||||
|
if _, err := w.api.CaptureTask(ctx, ipc.CaptureTaskReq{
|
||||||
|
Text: a.Task.Text, Source: a.Source, Evidence: a.Task.Evidence, Status: status, Ts: ts,
|
||||||
|
}); err != nil {
|
||||||
|
w.t.Fatalf("capture task from %s: %v", a.Source, err)
|
||||||
|
}
|
||||||
|
w.logf("пришло: задача от %s — %s", a.Source, a.Task.Text)
|
||||||
|
default:
|
||||||
|
w.t.Fatalf("arrive step from %s carries nothing", a.Source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// audioText resolves a scenario's WAV reference to the text the fixture is
|
||||||
|
// known to contain, by reading cmd/mavsttd's golden manifest (#288's format,
|
||||||
|
// reused rather than duplicated). An unknown reference fails the scenario
|
||||||
|
// rather than quietly transcribing to "".
|
||||||
|
func (w *simWorld) audioText(ref string) string {
|
||||||
|
w.t.Helper()
|
||||||
|
manifest := filepath.Join("..", "mavsttd", "testdata", "golden_v1.json")
|
||||||
|
raw, err := os.ReadFile(manifest)
|
||||||
|
if err != nil {
|
||||||
|
w.t.Fatalf("audio step %q: reading %s: %v", ref, manifest, err)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Cases []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
WAV string `json:"wav"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"cases"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
w.t.Fatalf("audio step %q: parsing %s: %v", ref, manifest, err)
|
||||||
|
}
|
||||||
|
for _, c := range m.Cases {
|
||||||
|
if c.Name == ref || c.WAV == ref {
|
||||||
|
return c.Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.t.Fatalf("audio step %q: no such case in %s", ref, manifest)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func voicePTT() voice.PushToTalkReq {
|
||||||
|
return voice.PushToTalkReq{Audio: audio.Audio{Format: audio.PCM16kMono}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// callCount — how many requests every wired ecosystem fake has seen.
|
||||||
|
func (w *simWorld) callCount() int {
|
||||||
|
n := 0
|
||||||
|
for _, fs := range []*fakeServer{w.praxis, w.nexus, w.hexis} {
|
||||||
|
if fs != nil {
|
||||||
|
n += len(fs.Requests())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *simWorld) callPaths() []string {
|
||||||
|
var out []string
|
||||||
|
for _, fs := range []*fakeServer{w.praxis, w.nexus, w.hexis} {
|
||||||
|
if fs == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, r := range fs.Requests() {
|
||||||
|
out = append(out, r.Method+" "+r.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Assertions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (w *simWorld) assert(i int, s step, sendsBefore, callsBefore, eventsBefore int) {
|
||||||
|
w.t.Helper()
|
||||||
|
where := fmt.Sprintf("step %d (%s)", i+1, s.At)
|
||||||
|
if s.Note != "" {
|
||||||
|
where += " " + s.Note
|
||||||
|
}
|
||||||
|
fail := func(format string, args ...any) {
|
||||||
|
w.dump()
|
||||||
|
w.t.Errorf("%s: %s", where, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
lastReply := ""
|
||||||
|
if len(w.replies) > 0 {
|
||||||
|
lastReply = w.replies[len(w.replies)-1]
|
||||||
|
}
|
||||||
|
for _, want := range s.ExpectReply {
|
||||||
|
if !containsFold(lastReply, want) {
|
||||||
|
fail("reply %q does not contain %q", lastReply, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, unwanted := range s.ExpectNotReply {
|
||||||
|
if containsFold(lastReply, unwanted) {
|
||||||
|
fail("reply %q contains %q and must not", lastReply, unwanted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sent := w.sink.all()
|
||||||
|
for _, want := range s.ExpectSent {
|
||||||
|
if !anyContains(sendableTexts(sent), want) {
|
||||||
|
fail("nothing sent mentions %q; sent so far: %v", want, sendableTexts(sent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Scoped to this step on purpose: "nothing was sent BECAUSE OF THIS" is the
|
||||||
|
// question a not-a-nag constraint asks.
|
||||||
|
if s.ExpectNoSend && len(sent) > sendsBefore {
|
||||||
|
fail("expected nothing to be sent, got %v", sendableTexts(sent[sendsBefore:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
paths := w.callPaths()
|
||||||
|
for _, want := range s.ExpectCalled {
|
||||||
|
if !anyContains(paths, want) {
|
||||||
|
fail("no ecosystem call matches %q; calls so far: %v", want, paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, unwanted := range s.ExpectNotCalled {
|
||||||
|
if anyContains(paths[callsBefore:], unwanted) {
|
||||||
|
fail("an ecosystem call matched %q and must not have: %v", unwanted, paths[callsBefore:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
evs := w.bus.Recent(0)
|
||||||
|
for _, want := range s.ExpectEvents {
|
||||||
|
if !anyContains(eventLines(evs), want) {
|
||||||
|
fail("no intake event matches %q; journal: %v", want, eventLines(evs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.ExpectNoEvents && w.bus.Len() > eventsBefore {
|
||||||
|
fail("expected nothing to arrive, journal grew to %d", w.bus.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendableTexts(sends []delivery.Sendable) []string {
|
||||||
|
out := make([]string, 0, len(sends))
|
||||||
|
for _, s := range sends {
|
||||||
|
out = append(out, fmt.Sprintf("[%s] %s", s.RuleName, s.Body))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func eventLines(evs []event.Event) []string {
|
||||||
|
out := make([]string, 0, len(evs))
|
||||||
|
for _, e := range evs {
|
||||||
|
out = append(out, fmt.Sprintf("%s/%s %s %s", e.Source, e.Kind, e.Title, e.Body))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsFold(hay, needle string) bool {
|
||||||
|
return strings.Contains(strings.ToLower(hay), strings.ToLower(needle))
|
||||||
|
}
|
||||||
|
|
||||||
|
func anyContains(hay []string, needle string) bool {
|
||||||
|
for _, h := range hay {
|
||||||
|
if containsFold(h, needle) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The test
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const scenarioDir = "testdata/scenarios"
|
||||||
|
|
||||||
|
// TestSimulatorScenarios replays every scenario file. Adding a scenario is
|
||||||
|
// adding a JSON file — no Go change, which is the property that makes this
|
||||||
|
// cheap enough to actually use.
|
||||||
|
func TestSimulatorScenarios(t *testing.T) {
|
||||||
|
entries, err := os.ReadDir(scenarioDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading %s: %v", scenarioDir, err)
|
||||||
|
}
|
||||||
|
var ran int
|
||||||
|
for _, ent := range entries {
|
||||||
|
if ent.IsDir() || !strings.HasSuffix(ent.Name(), ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ran++
|
||||||
|
name := strings.TrimSuffix(ent.Name(), ".json")
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
sc := loadScenario(t, filepath.Join(scenarioDir, ent.Name()))
|
||||||
|
w := newSimWorld(t, sc)
|
||||||
|
w.run(sc)
|
||||||
|
if testing.Verbose() {
|
||||||
|
w.dump()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if ran == 0 {
|
||||||
|
t.Fatalf("no scenarios in %s — the harness would pass vacuously", scenarioDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadScenario(t *testing.T, path string) scenario {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading %s: %v", path, err)
|
||||||
|
}
|
||||||
|
var sc scenario
|
||||||
|
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||||
|
dec.DisallowUnknownFields() // a typo'd assertion key must fail, not be ignored
|
||||||
|
if err := dec.Decode(&sc); err != nil {
|
||||||
|
t.Fatalf("parsing %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if sc.SchemaVersion != 1 {
|
||||||
|
t.Fatalf("%s: schema_version = %d, want 1", path, sc.SchemaVersion)
|
||||||
|
}
|
||||||
|
if sc.Name == "" || sc.Start == "" || len(sc.Steps) == 0 {
|
||||||
|
t.Fatalf("%s: a scenario needs a name, a start and at least one step", path)
|
||||||
|
}
|
||||||
|
return sc
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSimulatorIsDeterministic replays one scenario twice and requires an
|
||||||
|
// identical transcript. This is the property the whole task rests on: if a
|
||||||
|
// time.Now() creeps into a replayed path, two runs diverge and this fails.
|
||||||
|
func TestSimulatorIsDeterministic(t *testing.T) {
|
||||||
|
path := filepath.Join(scenarioDir, "morning_missed.json")
|
||||||
|
sc := loadScenario(t, path)
|
||||||
|
|
||||||
|
transcriptOf := func() string {
|
||||||
|
w := newSimWorld(t, sc)
|
||||||
|
w.run(sc)
|
||||||
|
return strings.Join(w.transcript, "\n")
|
||||||
|
}
|
||||||
|
first := transcriptOf()
|
||||||
|
second := transcriptOf()
|
||||||
|
if first != second {
|
||||||
|
t.Errorf("two replays of the same scenario diverged:\n--- first ---\n%s\n--- second ---\n%s", first, second)
|
||||||
|
}
|
||||||
|
// And the transcript's own timestamps must be the scenario's, not today's.
|
||||||
|
if strings.Contains(first, time.Now().Format("15:04")) && !strings.Contains(sc.Start, time.Now().Format("15:04")) {
|
||||||
|
t.Error("transcript carries the wall clock — something in the replay path read time.Now()")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSimulatorRefusesBackwardsSteps guards the one scenario-authoring mistake
|
||||||
|
// that would silently produce a meaningless run.
|
||||||
|
func TestSimulatorRefusesBackwardsSteps(t *testing.T) {
|
||||||
|
// Not table-driven through run() because advanceTo calls t.Fatalf; this
|
||||||
|
// checks the ordering arithmetic directly.
|
||||||
|
sc := scenario{SchemaVersion: 1, Name: "x", Start: "2026-08-01T08:30:00+03:00",
|
||||||
|
Steps: []step{{At: "09:00"}}}
|
||||||
|
w := newSimWorld(t, sc)
|
||||||
|
w.advanceTo("09:00")
|
||||||
|
if got := w.clock.Now().In(w.loc).Format("15:04"); got != "09:00" {
|
||||||
|
t.Fatalf("clock at %s after advancing to 09:00", got)
|
||||||
|
}
|
||||||
|
w.advanceTo("09:30")
|
||||||
|
if got := w.clock.Now().In(w.loc).Format("15:04"); got != "09:30" {
|
||||||
|
t.Fatalf("clock at %s after advancing to 09:30", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/smarthome"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// homeWiring — the Home Assistant client, when the `smarthome` block is present
|
||||||
|
// AND enabled. nil ⇒ the house is not wired, nothing was proposed, and an
|
||||||
|
// allowlist row that happens to look like a house row refuses to run.
|
||||||
|
//
|
||||||
|
// It lives on the voice wiring for the same reason MCP does: a house control IS
|
||||||
|
// an act. It goes through tool.Executor, the enabled allowlist and the confirm
|
||||||
|
// turn, all of which only exist on the voice/chat path.
|
||||||
|
type homeWiring struct {
|
||||||
|
client *smarthome.Client
|
||||||
|
st *store.Store
|
||||||
|
refresh time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// wireSmartHome builds the client and proposes what it found. It never fails
|
||||||
|
// the daemon: an instance that is down at boot is logged and retried, because
|
||||||
|
// Maven starting is not contingent on someone else's process.
|
||||||
|
func wireSmartHome(cfg *config.Config, st *store.Store) *homeWiring {
|
||||||
|
hc, ok := cfg.SmartHomeClient()
|
||||||
|
if !ok || st == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := smarthome.Validate(hc); err != nil {
|
||||||
|
// config.validate already ran this, so reaching here is a programming
|
||||||
|
// error rather than a config one. Still not fatal: the house off is a
|
||||||
|
// working Maven.
|
||||||
|
log.Printf("smarthome: not wired: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
w := &homeWiring{
|
||||||
|
client: smarthome.NewClient(hc),
|
||||||
|
st: st,
|
||||||
|
refresh: time.Duration(cfg.SmartHome.Refresh),
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
w.propose(ctx)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// caller is the tool.HomeCaller seam.
|
||||||
|
func (w *homeWiring) caller() *smarthome.Client {
|
||||||
|
if w == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return w.client
|
||||||
|
}
|
||||||
|
|
||||||
|
// propose writes a 'proposed' allowlist row for every controllable device. It
|
||||||
|
// does NOT enable anything: a reachable house is a place Maven may look, not a
|
||||||
|
// set of switches she may flip. Kami enables what he wants on /tools, behind
|
||||||
|
// step-up, which is the same gate a shell tool goes through.
|
||||||
|
//
|
||||||
|
// Sensors are read but never proposed — there is nothing to call on them.
|
||||||
|
func (w *homeWiring) propose(ctx context.Context) {
|
||||||
|
if w == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ents, err := w.client.States(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("smarthome: read states: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
fresh, devices := 0, 0
|
||||||
|
for _, e := range ents {
|
||||||
|
svcs := smarthome.Services(e.Domain)
|
||||||
|
if len(svcs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
devices++
|
||||||
|
for _, s := range svcs {
|
||||||
|
name := smarthome.LocalName(e.ID, s.Verb)
|
||||||
|
provenance := "дом: " + s.Name + " → " + e.Name + " (" + e.ID + ")"
|
||||||
|
ok, err := w.st.ProposeSmartHomeTool(ctx, name, smarthome.Scope(e.Domain),
|
||||||
|
smarthome.Cmd(e.ID, s.Name), provenance, now)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("smarthome: propose %s: %v", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
fresh++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("smarthome: %d entities, %d controllable", len(ents), devices)
|
||||||
|
if fresh > 0 {
|
||||||
|
log.Printf("smarthome: %d new device proposal(s) waiting on /tools", fresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run re-enumerates the house and picks up devices that appeared, until ctx is
|
||||||
|
// canceled.
|
||||||
|
func (w *homeWiring) run(ctx context.Context) {
|
||||||
|
if w == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
iv := w.refresh
|
||||||
|
if iv <= 0 {
|
||||||
|
iv = config.DefaultSmartHomeRefresh
|
||||||
|
}
|
||||||
|
t := time.NewTicker(iv)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
w.propose(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// homeSummary answers "что дома?" — a read of the current entity states, one
|
||||||
|
// short line. Read-only: it can never call a service, so it needs no confirm
|
||||||
|
// and no allowlist row.
|
||||||
|
func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
|
||||||
|
if w == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
ents, err := w.client.States(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("smarthome: summary: %v", err)
|
||||||
|
return "не смогла достучаться до дома.", true
|
||||||
|
}
|
||||||
|
if len(ents) == 0 {
|
||||||
|
return "дом ничего не отдаёт.", true
|
||||||
|
}
|
||||||
|
var on []string
|
||||||
|
var sensors []string
|
||||||
|
for _, e := range ents {
|
||||||
|
switch {
|
||||||
|
case e.Domain == "sensor" || e.Domain == "binary_sensor":
|
||||||
|
if len(sensors) < 3 && e.State != "" && e.State != "unavailable" {
|
||||||
|
sensors = append(sensors, e.Name+" "+e.State+e.Unit)
|
||||||
|
}
|
||||||
|
case e.State == "on" || e.State == "open" || e.State == "unlocked":
|
||||||
|
on = append(on, e.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
if len(on) > 0 {
|
||||||
|
if len(on) > 5 {
|
||||||
|
on = on[:5]
|
||||||
|
}
|
||||||
|
parts = append(parts, "включено: "+strings.Join(on, ", "))
|
||||||
|
} else {
|
||||||
|
parts = append(parts, "всё выключено")
|
||||||
|
}
|
||||||
|
if len(sensors) > 0 {
|
||||||
|
parts = append(parts, strings.Join(sensors, ", "))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "; ") + ".", true
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHomeQuery recognises a question about the house, narrowly. "дома" on its
|
||||||
|
// own is not enough — "я дома" is a fact, not a question — so it takes a house
|
||||||
|
// marker AND an ask AND either a device word or the word "включ…". Weather
|
||||||
|
// wording bails out first: "какая температура на улице?" belongs to the weather
|
||||||
|
// source, and both questions contain "температура".
|
||||||
|
func isHomeQuery(u string) bool {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(u))
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, w := range []string{"погод", "на улице", "прогноз"} {
|
||||||
|
if strings.Contains(s, w) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, phrase := range []string{"что включено", "что выключено", "умный дом", "что в доме включено"} {
|
||||||
|
if strings.Contains(s, phrase) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
house := homeWord(s, "дома") || strings.Contains(s, "в доме") || strings.Contains(s, "в квартире")
|
||||||
|
if !house {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ask := strings.Contains(s, "?") || homeWord(s, "что") || homeWord(s, "какая") ||
|
||||||
|
homeWord(s, "какой") || homeWord(s, "сколько")
|
||||||
|
if !ask {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, w := range []string{"свет", "лампа", "лампы", "розетк", "датчик", "температур", "включ", "выключ"} {
|
||||||
|
if strings.Contains(s, w) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// homeWord — whole-token membership, so "дома" does not fire on "домашний".
|
||||||
|
// Punctuation is trimmed off each token because a spoken question arrives with
|
||||||
|
// a question mark glued to the last word.
|
||||||
|
func homeWord(s, w string) bool {
|
||||||
|
for _, tok := range strings.Fields(s) {
|
||||||
|
if strings.Trim(tok, ".,!?;:") == w {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const haStatesFixture = `[
|
||||||
|
{"entity_id":"light.living_room","state":"on","attributes":{"friendly_name":"Гостиная"}},
|
||||||
|
{"entity_id":"switch.kettle","state":"off","attributes":{"friendly_name":"Чайник"}},
|
||||||
|
{"entity_id":"sensor.bedroom_temp","state":"22.5","attributes":{"friendly_name":"Спальня","unit_of_measurement":"°C"}}
|
||||||
|
]`
|
||||||
|
|
||||||
|
func TestWireSmartHomeOffUnlessEnabled(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
for name, cfg := range map[string]*config.Config{
|
||||||
|
"no block": {},
|
||||||
|
"written but dark": {SmartHome: &config.SmartHomeConfig{
|
||||||
|
URL: "http://ha.lan:8123", Token: "t",
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if w := wireSmartHome(cfg, st); w != nil {
|
||||||
|
t.Fatal("the house must be off unless the block is enabled")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// nil wiring must be safe everywhere it is reachable.
|
||||||
|
var w *homeWiring
|
||||||
|
w.propose(context.Background())
|
||||||
|
w.run(context.Background())
|
||||||
|
if w.caller() != nil {
|
||||||
|
t.Fatal("a nil wiring must have no caller")
|
||||||
|
}
|
||||||
|
if _, ok := w.homeSummary(context.Background()); ok {
|
||||||
|
t.Fatal("a nil wiring must not claim a query")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unreachable instance must not stop the daemon and must propose nothing.
|
||||||
|
func TestWireSmartHomeUnreachableIsNotFatal(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||||
|
// Port 1 on loopback: nothing listens, and it fails fast.
|
||||||
|
URL: "http://127.0.0.1:1", Token: "t", Enabled: true,
|
||||||
|
}}, st)
|
||||||
|
if w == nil {
|
||||||
|
t.Fatal("a configured house should still wire")
|
||||||
|
}
|
||||||
|
tools, err := st.ListTools(context.Background(), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(tools) != 0 {
|
||||||
|
t.Fatalf("an instance that never answered must propose nothing, got %+v", tools)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discovery proposes one row per controllable service, always destructive,
|
||||||
|
// always 'proposed'. A sensor gets no row: there is nothing to call on it.
|
||||||
|
func TestProposeOnlyProposesControllableDevices(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(haStatesFixture))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
st := newTestStore(t)
|
||||||
|
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||||
|
URL: srv.URL, Token: "t", Enabled: true,
|
||||||
|
}}, st)
|
||||||
|
if w == nil {
|
||||||
|
t.Fatal("wireSmartHome returned nil for an enabled, reachable house")
|
||||||
|
}
|
||||||
|
|
||||||
|
tools, err := st.ListTools(context.Background(), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := map[string]bool{}
|
||||||
|
for _, tl := range tools {
|
||||||
|
got[tl.Name] = true
|
||||||
|
if tl.Status != "proposed" {
|
||||||
|
t.Errorf("%s status = %q: discovery must never enable", tl.Name, tl.Status)
|
||||||
|
}
|
||||||
|
if !tl.Destructive {
|
||||||
|
t.Errorf("%s is not destructive: every house control needs the confirm turn", tl.Name)
|
||||||
|
}
|
||||||
|
if len(tl.Cmd) == 0 || tl.Cmd[0] != "smarthome" {
|
||||||
|
t.Errorf("%s cmd = %v", tl.Name, tl.Cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"home_light_living_room_on", "home_light_living_room_off",
|
||||||
|
"home_switch_kettle_on", "home_switch_kettle_off",
|
||||||
|
} {
|
||||||
|
if !got[want] {
|
||||||
|
t.Errorf("missing proposal %q (have %v)", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tools) != 4 {
|
||||||
|
t.Fatalf("got %d rows, want 4 — the sensor must not be proposed: %+v", len(tools), tools)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second pass must be idempotent: re-discovery duplicates nothing and
|
||||||
|
// never rewrites a row Kami already enabled.
|
||||||
|
if err := st.EnableTool(context.Background(), "home_switch_kettle_on",
|
||||||
|
[]string{"smarthome", "switch.kettle", "turn_on"}, true, "smarthome:switch", time.Now()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.propose(context.Background())
|
||||||
|
again, err := st.ListTools(context.Background(), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(again) != 4 {
|
||||||
|
t.Fatalf("re-discovery duplicated rows: %d", len(again))
|
||||||
|
}
|
||||||
|
for _, tl := range again {
|
||||||
|
if tl.Name == "home_switch_kettle_on" && tl.Status != "enabled" {
|
||||||
|
t.Errorf("re-discovery un-enabled a device he had enabled: %q", tl.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHomeSummaryReadsState(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(haStatesFixture))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||||
|
URL: srv.URL, Token: "t", Enabled: true,
|
||||||
|
}}, newTestStore(t))
|
||||||
|
out, ok := w.homeSummary(context.Background())
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("summary did not claim the turn")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "Гостиная") {
|
||||||
|
t.Errorf("the lamp that is on should be named: %q", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "Чайник") {
|
||||||
|
t.Errorf("a device that is off should not be listed as on: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "22.5") {
|
||||||
|
t.Errorf("the sensor reading should be there: %q", out)
|
||||||
|
}
|
||||||
|
// Persona: no masculine self-reference, no "вы", no pet names.
|
||||||
|
for _, bad := range []string{"рад ", "готов ", "вы ", "ваш", "милый", "дорогой"} {
|
||||||
|
if strings.Contains(strings.ToLower(out), bad) {
|
||||||
|
t.Errorf("persona violation %q in %q", bad, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsHomeQuery(t *testing.T) {
|
||||||
|
yes := []string{
|
||||||
|
"что включено дома?",
|
||||||
|
"что выключено",
|
||||||
|
"какой свет горит дома",
|
||||||
|
"свет в доме включен?",
|
||||||
|
"какая температура в квартире?",
|
||||||
|
"покажи умный дом",
|
||||||
|
}
|
||||||
|
no := []string{
|
||||||
|
"",
|
||||||
|
"я дома",
|
||||||
|
"буду дома в семь",
|
||||||
|
"какая погода дома", // weather wording wins
|
||||||
|
"какая температура на улице?",
|
||||||
|
"домашние дела", // "дома" must not fire on "домашние"
|
||||||
|
"что мне нужно сделать?",
|
||||||
|
"напомни выключить чайник в семь", // a reminder, not a house read
|
||||||
|
}
|
||||||
|
for _, u := range yes {
|
||||||
|
if !isHomeQuery(u) {
|
||||||
|
t.Errorf("isHomeQuery(%q) = false, want true", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, u := range no {
|
||||||
|
if isHomeQuery(u) {
|
||||||
|
t.Errorf("isHomeQuery(%q) = true, want false", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"name": "evening_degraded",
|
||||||
|
"description": "The tier-2 pipeline case #288 deferred here, plus degraded mode. A golden WAV goes in at the microphone end and comes out as a written fact, and then the ecosystem starts answering 503 and the proactive loop has to stay quiet instead of falling over. The audio step asserts the PIPELINE — mic to STT seam to router to store to TTS — not whisper's accuracy; cmd/mavsttd/golden_test.go owns accuracy.",
|
||||||
|
"start": "2026-08-01T21:00:00+03:00",
|
||||||
|
"praxis_attention": "[{\"id\":\"item_1\",\"title\":\"medicine not taken\",\"importance\":3.0,\"rule\":\"evening_medicine\"}]",
|
||||||
|
"script": [
|
||||||
|
{
|
||||||
|
"match": "выпил воды",
|
||||||
|
"route": "[{\"intent\":\"fact\",\"key\":\"water\",\"value\":\"выпил\"}]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": "записала факт: water",
|
||||||
|
"reply": "{\"response\":\"Записала, что ты выпил воды.\",\"mood\":\"neutral\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": "",
|
||||||
|
"route": "[{\"intent\":\"chat\",\"text\":\"привет\"}]",
|
||||||
|
"reply": "{\"response\":\"Я рада тебя слышать.\",\"mood\":\"happy\"}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"at": "21:00",
|
||||||
|
"note": "he speaks. The whole voice path runs: push-to-talk, the STT seam parked with the golden transcript, the real router, the real store write, the phrasing contract.",
|
||||||
|
"audio": "ru_fact",
|
||||||
|
"expect_reply_contains": ["записала"],
|
||||||
|
"expect_reply_lacks": ["записал,", "милый", "ваш"],
|
||||||
|
"expect_events": ["water"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "21:05",
|
||||||
|
"note": "a healthy tick with him just having spoken stays silent",
|
||||||
|
"tick": true,
|
||||||
|
"expect_no_send": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "21:10",
|
||||||
|
"note": "the ecosystem goes down",
|
||||||
|
"fault": 503
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "21:15",
|
||||||
|
"note": "a tick against a dead ecosystem must degrade, not send half a thought",
|
||||||
|
"tick": true,
|
||||||
|
"expect_no_send": true,
|
||||||
|
"expect_no_events": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "21:20",
|
||||||
|
"note": "intake keeps working while the ecosystem is down — a write does not depend on it",
|
||||||
|
"arrive": {
|
||||||
|
"source": "rss:tech",
|
||||||
|
"note": { "text": "Патч 6.19.1 [tech]\nисправления\nhttps://example.org/b" }
|
||||||
|
},
|
||||||
|
"expect_events": ["rss:tech"],
|
||||||
|
"expect_no_send": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "21:25",
|
||||||
|
"note": "recovery",
|
||||||
|
"clear_fault": true,
|
||||||
|
"tick": true,
|
||||||
|
"expect_no_send": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"name": "morning_missed",
|
||||||
|
"description": "The scenario from Vikunja #284's description, replayed. He appears at 08:30, things arrive through the morning while he is at the desk, and at 08:50 he asks what he missed. The assertions are as much about what did NOT happen — nothing was sent at him unprompted — as about what she said.",
|
||||||
|
"start": "2026-08-01T08:30:00+03:00",
|
||||||
|
"praxis_attention": "[{\"id\":\"item_1\",\"title\":\"medicine not taken\",\"importance\":3.0,\"rule\":\"morning_medicine\"}]",
|
||||||
|
"script": [
|
||||||
|
{
|
||||||
|
"match": "выпил воды",
|
||||||
|
"route": "[{\"intent\":\"fact\",\"key\":\"water\",\"value\":\"выпил\"}]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": "записала факт: water",
|
||||||
|
"reply": "{\"response\":\"Записала, что ты выпил воды.\",\"mood\":\"neutral\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": "что я пропустил",
|
||||||
|
"route": "[{\"intent\":\"query\",\"text\":\"что я пропустил\"}]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": "",
|
||||||
|
"route": "[{\"intent\":\"chat\",\"text\":\"привет\"}]",
|
||||||
|
"reply": "{\"response\":\"Я рада тебя слышать.\",\"mood\":\"happy\"}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"at": "08:30",
|
||||||
|
"note": "he appears at the desk",
|
||||||
|
"signal": { "key": "desk_active", "value": "true", "source": "infer:hyprland" },
|
||||||
|
"expect_events": ["infer:hyprland"],
|
||||||
|
"expect_no_send": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "08:32",
|
||||||
|
"note": "a feed item arrives, published half an hour ago",
|
||||||
|
"arrive": {
|
||||||
|
"source": "rss:tech",
|
||||||
|
"as_of": "08:02",
|
||||||
|
"note": { "text": "Вышло ядро 6.19 [tech]\nкраткое содержание\nhttps://example.org/a" }
|
||||||
|
},
|
||||||
|
"expect_events": ["rss:tech"],
|
||||||
|
"expect_no_send": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "08:35",
|
||||||
|
"note": "the mail reader extracts a candidate — a candidate is never spoken",
|
||||||
|
"arrive": {
|
||||||
|
"source": "email:inbox",
|
||||||
|
"task": { "text": "продлить домен", "evidence": "Домен истекает через 7 дней" }
|
||||||
|
},
|
||||||
|
"expect_events": ["email:inbox", "продлить домен"],
|
||||||
|
"expect_no_send": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "08:40",
|
||||||
|
"note": "the work calendar signal — a relayed notification, below full confidence",
|
||||||
|
"arrive": {
|
||||||
|
"source": "ambient:notif",
|
||||||
|
"fact": {
|
||||||
|
"key": "calendar_event_20260801_планёрка",
|
||||||
|
"value": "10:00-11:00 планёрка"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"expect_events": ["ambient:notif", "планёрка"],
|
||||||
|
"expect_no_send": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "08:45",
|
||||||
|
"note": "a tick with him present and nothing wrong must stay silent",
|
||||||
|
"tick": true,
|
||||||
|
"expect_no_send": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "08:50",
|
||||||
|
"note": "he asks. The query path answers from local recall only: nothing stored clears the score gate, so she refuses rather than inventing a morning summary, and the replier is never reached. That refusal is the no-hallucination floor and this step pins it.",
|
||||||
|
"say": "что я пропустил?",
|
||||||
|
"expect_reply_contains": ["не знаю"],
|
||||||
|
"expect_reply_lacks": ["рад ", "милый", "ваш"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "08:55",
|
||||||
|
"note": "stating a fact writes it and says so, in the feminine",
|
||||||
|
"say": "я выпил воды",
|
||||||
|
"expect_reply_contains": ["записала"],
|
||||||
|
"expect_reply_lacks": ["записал,", "милый"],
|
||||||
|
"expect_events": ["water"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "09:00",
|
||||||
|
"note": "a second tick, still nothing unprompted",
|
||||||
|
"tick": true,
|
||||||
|
"expect_no_send": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -941,6 +941,19 @@ type daemonAPI struct {
|
|||||||
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
||||||
getDayPlan func(ctx context.Context) ipc.DayPlan
|
getDayPlan func(ctx context.Context) ipc.DayPlan
|
||||||
chatFn func(ctx context.Context, text string) string
|
chatFn func(ctx context.Context, text string) string
|
||||||
|
getMCPServers func() []ipc.MCPServerStatus
|
||||||
|
getEvents func(n int) []ipc.IntakeEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentEvents — the unified intake journal (Vikunja #283). Empty, not an
|
||||||
|
// error, when no bus was wired: "nothing has arrived" and "the journal is off"
|
||||||
|
// look the same to a reader on purpose, because neither is a fault and the
|
||||||
|
// page renders both as an empty table.
|
||||||
|
func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent, error) {
|
||||||
|
if d.getEvents == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return d.getEvents(n), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *daemonAPI) Chat(ctx context.Context, text string) (string, error) {
|
func (d *daemonAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||||
@@ -950,6 +963,16 @@ func (d *daemonAPI) Chat(ctx context.Context, text string) (string, error) {
|
|||||||
return d.chatFn(ctx, text), nil
|
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) {
|
func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
|
||||||
trace := d.getTrace()
|
trace := d.getTrace()
|
||||||
if trace == nil {
|
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
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/audio"
|
"github.com/kami/maven/internal/audio"
|
||||||
|
"github.com/kami/maven/internal/crawl"
|
||||||
"github.com/kami/maven/internal/dialogue"
|
"github.com/kami/maven/internal/dialogue"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/memory"
|
"github.com/kami/maven/internal/memory"
|
||||||
@@ -82,11 +83,26 @@ type reactiveHandler struct {
|
|||||||
replier voice.Replier
|
replier voice.Replier
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
|
||||||
|
// crawler reads a web page he names out loud (queryWeb). nil ⇒ on-demand
|
||||||
|
// page reading is off, which is the default: no `crawl` block, no fetch.
|
||||||
|
crawler *crawl.Crawler
|
||||||
|
|
||||||
// feedsOn — whether any RSS feed is configured (config.Feeds). It changes
|
// feedsOn — whether any RSS feed is configured (config.Feeds). It changes
|
||||||
// only what she SAYS when asked and nothing is there: "ленты не настроены"
|
// only what she SAYS when asked and nothing is there: "ленты не настроены"
|
||||||
// instead of "ничего нового", which are different truths.
|
// instead of "ничего нового", which are different truths.
|
||||||
feedsOn bool
|
feedsOn bool
|
||||||
|
|
||||||
|
// home — the Home Assistant client (Vikunja #256). nil ⇒ the house is not
|
||||||
|
// configured, which is the default: no `smarthome` block, no reads, no
|
||||||
|
// switches. Control does not go through this field — it goes through the
|
||||||
|
// act allowlist and tool.Executor, like every other mutating act.
|
||||||
|
home *homeWiring
|
||||||
|
|
||||||
|
// netscan — the LAN scanner (Vikunja #257). nil ⇒ off, which is the
|
||||||
|
// default. A scan is a read, so it has no allowlist row; what keeps it
|
||||||
|
// safe is that its range comes from config and from nowhere else.
|
||||||
|
netscan *netWiring
|
||||||
|
|
||||||
weatherProvider weather.Provider
|
weatherProvider weather.Provider
|
||||||
weatherLocation string // default location for weather queries
|
weatherLocation string // default location for weather queries
|
||||||
|
|
||||||
|
|||||||
+55
-12
@@ -40,6 +40,22 @@ type voiceWiring struct {
|
|||||||
// mavsttd / mavttsd don't keep a stale conn into a restarting daemon.
|
// mavsttd / mavttsd don't keep a stale conn into a restarting daemon.
|
||||||
sttClient *worker.Client
|
sttClient *worker.Client
|
||||||
ttsClient *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
|
||||||
|
// home — the Home Assistant client, nil unless the `smarthome` block is
|
||||||
|
// enabled (Vikunja #256). Its devices land in the same allowlist as every
|
||||||
|
// other act, so nothing else here has to know about it.
|
||||||
|
home *homeWiring
|
||||||
|
// netscan — the LAN scanner, nil unless the `netscan` block is enabled
|
||||||
|
// (Vikunja #257).
|
||||||
|
netscan *netWiring
|
||||||
}
|
}
|
||||||
|
|
||||||
// close releases the listener + worker conns. Safe to call on nil (when
|
// close releases the listener + worker conns. Safe to call on nil (when
|
||||||
@@ -60,6 +76,7 @@ func (w *voiceWiring) close() {
|
|||||||
if w.ttsClient != nil {
|
if w.ttsClient != nil {
|
||||||
_ = w.ttsClient.Close()
|
_ = w.ttsClient.Close()
|
||||||
}
|
}
|
||||||
|
w.mcp.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// wireVoice builds the audio path from cfg + a CoreAPI + a router. Returns
|
// wireVoice builds the audio path from cfg + a CoreAPI + a router. Returns
|
||||||
@@ -87,6 +104,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
} else {
|
} else {
|
||||||
transcriber = stt.NewStub()
|
transcriber = stt.NewStub()
|
||||||
}
|
}
|
||||||
|
w.transcriber = transcriber
|
||||||
|
|
||||||
// ----- tts (Stub in-process OR Remote) -----
|
// ----- tts (Stub in-process OR Remote) -----
|
||||||
var synthesizer tts.Synthesizer
|
var synthesizer tts.Synthesizer
|
||||||
@@ -131,6 +149,24 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
// daemon restart.
|
// daemon restart.
|
||||||
seedTools(coreAPI, cfg.Voice.Tools)
|
seedTools(coreAPI, cfg.Voice.Tools)
|
||||||
exec := tool.NewExecutor(coreAPI, time.Duration(cfg.Voice.ToolTimeout))
|
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())
|
||||||
|
}
|
||||||
|
// The house (Vikunja #256): same story as MCP. Discovery PROPOSES a row per
|
||||||
|
// controllable device, always destructive, and Kami enables the ones he
|
||||||
|
// wants on /tools. Off unless the `smarthome` block is enabled.
|
||||||
|
w.home = wireSmartHome(cfg, dataStore)
|
||||||
|
if w.home != nil {
|
||||||
|
exec = exec.WithHome(w.home.caller())
|
||||||
|
}
|
||||||
|
// The LAN scanner (Vikunja #257): a read, bounded to the configured
|
||||||
|
// subnets and rate-limited. Off unless the `netscan` block is enabled.
|
||||||
|
w.netscan = wireNetScan(cfg)
|
||||||
matcher := tool.NewMatcher(coreAPI)
|
matcher := tool.NewMatcher(coreAPI)
|
||||||
|
|
||||||
// ----- weather provider (Open-Meteo when configured, Stub otherwise) -----
|
// ----- weather provider (Open-Meteo when configured, Stub otherwise) -----
|
||||||
@@ -148,7 +184,9 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
// The replier uses the same llama-server as the phraser.
|
// The replier uses the same llama-server as the phraser.
|
||||||
var llmClient *llm.Client
|
var llmClient *llm.Client
|
||||||
if lp, ok := phr.(*phraser.LLMPhraser); ok {
|
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) -----
|
// ----- router (the cascade; floor examples seed the classifier) -----
|
||||||
// The act matcher's allowlist is exactly the enabled tool names — the
|
// The act matcher's allowlist is exactly the enabled tool names — the
|
||||||
@@ -201,17 +239,22 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
|
|
||||||
// ----- the handler (the reactive path; closes over stt / tts / router / coreAPI / memory) -----
|
// ----- the handler (the reactive path; closes over stt / tts / router / coreAPI / memory) -----
|
||||||
h := &reactiveHandler{
|
h := &reactiveHandler{
|
||||||
stt: transcriber,
|
stt: transcriber,
|
||||||
tts: synthesizer,
|
tts: synthesizer,
|
||||||
router: rtr,
|
router: rtr,
|
||||||
embedder: emb,
|
embedder: emb,
|
||||||
api: coreAPI,
|
api: coreAPI,
|
||||||
tools: exec,
|
tools: exec,
|
||||||
matcher: matcher,
|
matcher: matcher,
|
||||||
replier: replier,
|
replier: replier,
|
||||||
phraser: phr,
|
phraser: phr,
|
||||||
now: time.Now,
|
now: time.Now,
|
||||||
feedsOn: cfg.Feeds != nil,
|
feedsOn: cfg.Feeds != nil,
|
||||||
|
home: w.home,
|
||||||
|
netscan: w.netscan,
|
||||||
|
// nil unless `crawl.on_demand` is on: reading a page he names is a
|
||||||
|
// capability, and capabilities are off unless configured.
|
||||||
|
crawler: onDemandCrawler(cfg),
|
||||||
weatherProvider: weatherProvider,
|
weatherProvider: weatherProvider,
|
||||||
weatherLocation: weatherLocation,
|
weatherLocation: weatherLocation,
|
||||||
memStore: memStore,
|
memStore: memStore,
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Golden-audio STT tests (Vikunja #288).
|
||||||
|
//
|
||||||
|
// These push real audio through the real whisper.cpp binding, so a bad model
|
||||||
|
// path, a wrong language hint, a broken resample or a regressed silence gate
|
||||||
|
// is caught by `make test` rather than by the owner talking to a daemon that
|
||||||
|
// mishears him.
|
||||||
|
//
|
||||||
|
// The fixtures are piper-synthesised, not recorded — see
|
||||||
|
// scripts/gen-stt-fixtures.sh. Nothing of the owner's voice is committed, and
|
||||||
|
// any fixture can be rebuilt from the script plus a voice model.
|
||||||
|
//
|
||||||
|
// Matching is deliberately tolerant. Golden transcripts are model-dependent:
|
||||||
|
// swapping ggml-small for a different whisper build moves punctuation, casing
|
||||||
|
// and the odd word ending, and an exact-string assertion would turn every
|
||||||
|
// model swap into a fixture rewrite. Each case therefore asserts two things —
|
||||||
|
// the words that carry the intent are present, and the word error rate
|
||||||
|
// against the reference stays under a per-case ceiling.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/audio"
|
||||||
|
"github.com/kami/maven/internal/worker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// goldenModelPath — the whisper model the golden tests run against. Same file
|
||||||
|
// the Makefile's run-stt target uses. Overridable so a box that keeps its
|
||||||
|
// models elsewhere can still run these.
|
||||||
|
func goldenModelPath() string {
|
||||||
|
if p := os.Getenv("MAVEN_WHISPER_MODEL"); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return filepath.Join("..", "..", "models", "stt", "ggml-small.bin")
|
||||||
|
}
|
||||||
|
|
||||||
|
type goldenCase struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
WAV string `json:"wav"`
|
||||||
|
Lang string `json:"lang"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Keywords []string `json:"keywords"`
|
||||||
|
MaxWER float64 `json:"max_wer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type goldenManifest struct {
|
||||||
|
Cases []goldenCase `json:"cases"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadGoldenManifest(t *testing.T) goldenManifest {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(filepath.Join("testdata", "golden_v1.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read golden manifest: %v", err)
|
||||||
|
}
|
||||||
|
var m goldenManifest
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatalf("parse golden manifest: %v", err)
|
||||||
|
}
|
||||||
|
if len(m.Cases) == 0 {
|
||||||
|
t.Fatal("golden manifest has no cases")
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeTranscript lowercases, drops punctuation, folds the Russian ё onto
|
||||||
|
// е (whisper is inconsistent about it and the router does not care), and
|
||||||
|
// collapses whitespace. Everything the comparison does happens on this form.
|
||||||
|
func normalizeTranscript(s string) []string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToLower(s) {
|
||||||
|
switch {
|
||||||
|
case r == 'ё':
|
||||||
|
b.WriteRune('е')
|
||||||
|
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
||||||
|
b.WriteRune(r)
|
||||||
|
default:
|
||||||
|
b.WriteRune(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Fields(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordErrorRate is the Levenshtein distance between two word sequences,
|
||||||
|
// divided by the length of the reference. 0 means identical; it can exceed 1
|
||||||
|
// when the hypothesis is much longer than the reference.
|
||||||
|
func wordErrorRate(ref, hyp []string) float64 {
|
||||||
|
if len(ref) == 0 {
|
||||||
|
if len(hyp) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
prev := make([]int, len(hyp)+1)
|
||||||
|
cur := make([]int, len(hyp)+1)
|
||||||
|
for j := range prev {
|
||||||
|
prev[j] = j
|
||||||
|
}
|
||||||
|
for i := 1; i <= len(ref); i++ {
|
||||||
|
cur[0] = i
|
||||||
|
for j := 1; j <= len(hyp); j++ {
|
||||||
|
cost := 1
|
||||||
|
if ref[i-1] == hyp[j-1] {
|
||||||
|
cost = 0
|
||||||
|
}
|
||||||
|
cur[j] = min(prev[j]+1, min(cur[j-1]+1, prev[j-1]+cost))
|
||||||
|
}
|
||||||
|
prev, cur = cur, prev
|
||||||
|
}
|
||||||
|
return float64(prev[len(hyp)]) / float64(len(ref))
|
||||||
|
}
|
||||||
|
|
||||||
|
// missingKeywords returns the keywords absent from the hypothesis. A keyword
|
||||||
|
// matches on prefix, so a different case ending ("воды" vs "воду") does not
|
||||||
|
// fail the assertion — the router's stage-0 grammar is stem-shaped too.
|
||||||
|
func missingKeywords(keywords []string, hyp []string) []string {
|
||||||
|
var missing []string
|
||||||
|
for _, kw := range keywords {
|
||||||
|
want := normalizeTranscript(kw)
|
||||||
|
if len(want) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !containsSeq(hyp, want) {
|
||||||
|
missing = append(missing, kw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsSeq(hyp, want []string) bool {
|
||||||
|
for i := 0; i+len(want) <= len(hyp); i++ {
|
||||||
|
ok := true
|
||||||
|
for j, w := range want {
|
||||||
|
// Prefix match, so inflection differences pass but
|
||||||
|
// distinct words do not.
|
||||||
|
if !looseWordMatch(hyp[i+j], w) {
|
||||||
|
ok = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func looseWordMatch(got, want string) bool {
|
||||||
|
if got == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
g, w := []rune(got), []rune(want)
|
||||||
|
n := len(w) - 1
|
||||||
|
if len(w) > 6 {
|
||||||
|
n = len(w) - 2
|
||||||
|
}
|
||||||
|
// Words of three runes or fewer have no room for a safe prefix: require
|
||||||
|
// an exact match rather than letting "час" pass for "часть".
|
||||||
|
if n < 3 || len(g) < n {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return string(g[:n]) == string(w[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the model-backed test -------------------------------------------------
|
||||||
|
|
||||||
|
func TestGoldenAudioTranscription(t *testing.T) {
|
||||||
|
m := loadGoldenManifest(t)
|
||||||
|
|
||||||
|
model := goldenModelPath()
|
||||||
|
if _, err := os.Stat(model); err != nil {
|
||||||
|
t.Skipf("whisper model %s absent (%v) — set MAVEN_WHISPER_MODEL or see AGENTS.md", model, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same gate thresholds as mavsttd's defaults, so a regression in the
|
||||||
|
// silence gate shows up here as an empty transcript.
|
||||||
|
h, err := newWhisperHandler(model, 300, 0.01)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load whisper model %s: %v", model, err)
|
||||||
|
}
|
||||||
|
defer h.Close()
|
||||||
|
|
||||||
|
for _, c := range m.Cases {
|
||||||
|
t.Run(c.Name, func(t *testing.T) {
|
||||||
|
path := filepath.Join("testdata", c.WAV)
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("fixture %s absent (%v) — run scripts/gen-stt-fixtures.sh", path, err)
|
||||||
|
}
|
||||||
|
format, pcm, err := audio.PCMFromWAV(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s is not canonical 16k mono PCM: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.Transcribe(context.Background(), worker.TranscribeReq{
|
||||||
|
Audio: audio.Audio{Format: format, Bytes: pcm},
|
||||||
|
Lang: c.Lang,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("transcribe %s: %v", c.WAV, err)
|
||||||
|
}
|
||||||
|
t.Logf("%s → %q (confidence %.3f)", c.WAV, resp.Text, resp.Confidence)
|
||||||
|
|
||||||
|
if strings.TrimSpace(resp.Text) == "" {
|
||||||
|
t.Fatalf("%s transcribed to empty text — the silence gate ate real speech", c.WAV)
|
||||||
|
}
|
||||||
|
if resp.Confidence <= 0 {
|
||||||
|
t.Errorf("%s: confidence %v, want > 0", c.WAV, resp.Confidence)
|
||||||
|
}
|
||||||
|
|
||||||
|
hyp := normalizeTranscript(resp.Text)
|
||||||
|
ref := normalizeTranscript(c.Text)
|
||||||
|
|
||||||
|
if missing := missingKeywords(c.Keywords, hyp); len(missing) > 0 {
|
||||||
|
t.Errorf("%s: missing keywords %v in %q", c.WAV, missing, resp.Text)
|
||||||
|
}
|
||||||
|
if wer := wordErrorRate(ref, hyp); wer > c.MaxWER {
|
||||||
|
t.Errorf("%s: WER %.2f > %.2f\n want: %q\n got: %q", c.WAV, wer, c.MaxWER, c.Text, resp.Text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGoldenFixturesAreCanonical checks the committed audio without needing a
|
||||||
|
// model, so a fixture regenerated at the wrong sample rate fails on every box.
|
||||||
|
func TestGoldenFixturesAreCanonical(t *testing.T) {
|
||||||
|
m := loadGoldenManifest(t)
|
||||||
|
for _, c := range m.Cases {
|
||||||
|
path := filepath.Join("testdata", c.WAV)
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("fixture %s missing: %v", path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
format, pcm, err := audio.PCMFromWAV(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s: %v", path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !format.IsValid() {
|
||||||
|
t.Errorf("%s: format %+v is not canonical", path, format)
|
||||||
|
}
|
||||||
|
a := audio.Audio{Format: format, Bytes: pcm}
|
||||||
|
if d := a.Duration(); d < 0.5 || d > 10 {
|
||||||
|
t.Errorf("%s: duration %.2fs outside the sane 0.5–10s fixture range", path, d)
|
||||||
|
}
|
||||||
|
// The fixture must clear mavsttd's own silence gate, otherwise the
|
||||||
|
// model test below would be asserting on a gated empty string.
|
||||||
|
if reason := gateReason(pcmToF32(pcm), whisperSampleRate, 300, 0.01); reason != "" {
|
||||||
|
t.Errorf("%s: would be gated as %s", path, reason)
|
||||||
|
}
|
||||||
|
if len(c.Keywords) == 0 {
|
||||||
|
t.Errorf("%s: manifest case has no keywords", c.Name)
|
||||||
|
}
|
||||||
|
if c.MaxWER <= 0 || c.MaxWER > 1 {
|
||||||
|
t.Errorf("%s: max_wer %v outside (0,1]", c.Name, c.MaxWER)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pcmToF32(b []byte) []float32 {
|
||||||
|
out := make([]float32, len(b)/2)
|
||||||
|
for i := range out {
|
||||||
|
s := int16(b[i*2]) | int16(b[i*2+1])<<8
|
||||||
|
out[i] = float32(s) / 32768.0
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- matcher unit tests (no model, no fixtures) ----------------------------
|
||||||
|
|
||||||
|
func TestNormalizeTranscript(t *testing.T) {
|
||||||
|
got := normalizeTranscript(" Ещё, Раз... ")
|
||||||
|
want := []string{"еще", "раз"}
|
||||||
|
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||||
|
t.Fatalf("normalizeTranscript = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWordErrorRate(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
ref, hyp string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"identical", "напомни мне через час", "Напомни мне через час.", 0},
|
||||||
|
{"one substitution", "напомни мне через час", "напомни мне через день", 0.25},
|
||||||
|
{"one deletion", "напомни мне через час", "напомни мне час", 0.25},
|
||||||
|
{"empty hypothesis", "напомни мне", "", 1},
|
||||||
|
{"both empty", "", "", 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := wordErrorRate(normalizeTranscript(c.ref), normalizeTranscript(c.hyp))
|
||||||
|
if got != c.want {
|
||||||
|
t.Fatalf("WER = %v, want %v", got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMissingKeywords(t *testing.T) {
|
||||||
|
hyp := normalizeTranscript("Отметь, что я выпил воду.")
|
||||||
|
if got := missingKeywords([]string{"воды", "отметь"}, hyp); len(got) != 0 {
|
||||||
|
t.Fatalf("missingKeywords = %v, want none (inflection must not fail the match)", got)
|
||||||
|
}
|
||||||
|
if got := missingKeywords([]string{"календарю"}, hyp); len(got) != 1 {
|
||||||
|
t.Fatalf("missingKeywords = %v, want the absent keyword reported", got)
|
||||||
|
}
|
||||||
|
// A short word must match exactly — no 4-rune prefix shortcut that would
|
||||||
|
// let "час" pass for "часть".
|
||||||
|
hyp2 := normalizeTranscript("через час")
|
||||||
|
if got := missingKeywords([]string{"часть"}, hyp2); len(got) != 1 {
|
||||||
|
t.Fatalf("missingKeywords = %v, want %q reported missing", got, "часть")
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
BIN
Binary file not shown.
Vendored
+37
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"note": "Golden STT fixtures. Audio is piper-synthesised, not recorded — see scripts/gen-stt-fixtures.sh. Regenerate with that script; do not hand-edit `wav`.",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "ru_reminder",
|
||||||
|
"wav": "ru_reminder.wav",
|
||||||
|
"lang": "ru",
|
||||||
|
"text": "напомни мне через час позвонить маме",
|
||||||
|
"keywords": ["напомни", "час", "позвонить"],
|
||||||
|
"max_wer": 0.34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ru_fact",
|
||||||
|
"wav": "ru_fact.wav",
|
||||||
|
"lang": "ru",
|
||||||
|
"text": "отметь что я выпил воды",
|
||||||
|
"keywords": ["отметь", "воды"],
|
||||||
|
"max_wer": 0.34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ru_query",
|
||||||
|
"wav": "ru_query.wav",
|
||||||
|
"lang": "ru",
|
||||||
|
"text": "что у меня сегодня по календарю",
|
||||||
|
"keywords": ["сегодня", "календарю"],
|
||||||
|
"max_wer": 0.34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "en_act",
|
||||||
|
"wav": "en_act.wav",
|
||||||
|
"lang": "en",
|
||||||
|
"text": "restart the web server and check the disk space",
|
||||||
|
"keywords": ["restart", "server", "disk"],
|
||||||
|
"max_wer": 0.34
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -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)
|
||||||
|
}
|
||||||
+33
-92
@@ -12,8 +12,15 @@
|
|||||||
// (30ms frames, 16kHz PCM) matches silero-vad's input interface exactly, so
|
// (30ms frames, 16kHz PCM) matches silero-vad's input interface exactly, so
|
||||||
// swapping energy-threshold for ONNX-inference is a local change in vad.go.
|
// swapping energy-threshold for ONNX-inference is a local change in vad.go.
|
||||||
//
|
//
|
||||||
|
// While a reply is playing the capture side is muted (half-duplex): without
|
||||||
|
// it, Maven's own voice comes back in through the mic and she answers
|
||||||
|
// herself. -barge-in punches one hole in that gate — sustained energy well
|
||||||
|
// above the speaker's leak level cuts playback so he can talk over her. It is
|
||||||
|
// off by default because the threshold is room-specific; see playback.go.
|
||||||
|
//
|
||||||
// usage:
|
// usage:
|
||||||
// mavwaked # default ALSA device, 127.0.0.1:9100
|
// mavwaked # default ALSA device, 127.0.0.1:9100
|
||||||
|
// mavwaked -barge-in # let him interrupt her mid-reply
|
||||||
// mavwaked -device hw:1,0 -addr 10.42.0.1:9100
|
// mavwaked -device hw:1,0 -addr 10.42.0.1:9100
|
||||||
// mavwaked -test file.wav # read from file, no arecord
|
// mavwaked -test file.wav # read from file, no arecord
|
||||||
package main
|
package main
|
||||||
@@ -60,6 +67,9 @@ func run(args []string) error {
|
|||||||
silenceMs := flag.Int("silence-ms", defaultSilenceMs, "silence ms to end utterance")
|
silenceMs := flag.Int("silence-ms", defaultSilenceMs, "silence ms to end utterance")
|
||||||
maxMs := flag.Int("max-ms", defaultMaxMs, "max utterance ms")
|
maxMs := flag.Int("max-ms", defaultMaxMs, "max utterance ms")
|
||||||
testFile := flag.String("test", "", "read PCM from file instead of arecord (testing only)")
|
testFile := flag.String("test", "", "read PCM from file instead of arecord (testing only)")
|
||||||
|
bargeIn := flag.Bool("barge-in", false, "cut Maven off when he talks over her (needs a room-tuned -barge-in-rms)")
|
||||||
|
bargeRMS := flag.Int("barge-in-rms", defaultBargeRMS, "RMS x10000 a frame must clear to count as barge-in")
|
||||||
|
bargeFrames := flag.Int("barge-in-frames", defaultBargeFrames, "consecutive frames over -barge-in-rms before playback is cut")
|
||||||
flag.CommandLine.Parse(args)
|
flag.CommandLine.Parse(args)
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
||||||
@@ -117,14 +127,21 @@ func run(args []string) error {
|
|||||||
|
|
||||||
defer src.Close()
|
defer src.Close()
|
||||||
|
|
||||||
return captureLoop(ctx, src, vad, vc, *lang)
|
var barge bargeInConfig
|
||||||
|
if *bargeIn {
|
||||||
|
barge = bargeInConfig{RMS: float64(*bargeRMS) / 10000.0, Frames: *bargeFrames}
|
||||||
|
log.Printf("mavwaked: barge-in on (rms %.4f x %d frames)", barge.RMS, barge.Frames)
|
||||||
|
}
|
||||||
|
sess := newSession(vad, newAplayPlayer(), &voiceSender{vc: vc}, *lang, barge)
|
||||||
|
|
||||||
|
return captureLoop(ctx, src, sess)
|
||||||
}
|
}
|
||||||
|
|
||||||
// captureLoop reads PCM from src, runs VAD, and sends complete utterances to
|
// captureLoop reads PCM from src and hands whole frames to the session.
|
||||||
// the voice server. Returns when ctx is done or src is exhausted.
|
// Returns when ctx is done or src is exhausted.
|
||||||
func captureLoop(ctx context.Context, src io.Reader, vad *VAD, vc *voice.Client, lang string) error {
|
func captureLoop(ctx context.Context, src io.Reader, sess *session) error {
|
||||||
br := bufio.NewReaderSize(src, defaultReadSize)
|
br := bufio.NewReaderSize(src, defaultReadSize)
|
||||||
frameBytes := vad.FrameSamples() * 2 // 480 samples × 2 bytes = 960 bytes per 30ms
|
frameBytes := sess.vad.FrameSamples() * 2 // 480 samples × 2 bytes = 960 bytes per 30ms
|
||||||
|
|
||||||
log.Printf("mavwaked: capture loop starting (frame=%d bytes, %dms)",
|
log.Printf("mavwaked: capture loop starting (frame=%d bytes, %dms)",
|
||||||
frameBytes, defaultFrameMs)
|
frameBytes, defaultFrameMs)
|
||||||
@@ -147,7 +164,7 @@ func captureLoop(ctx context.Context, src io.Reader, vad *VAD, vc *voice.Client,
|
|||||||
// Flush partial frame.
|
// Flush partial frame.
|
||||||
partial = append(partial, buf[:n]...)
|
partial = append(partial, buf[:n]...)
|
||||||
if len(partial) >= frameBytes {
|
if len(partial) >= frameBytes {
|
||||||
if err := processFrame(partial[:frameBytes], vad, vc, lang); err != nil {
|
if err := sess.feed(ctx, partial[:frameBytes]); err != nil {
|
||||||
log.Printf("mavwaked: process frame: %v", err)
|
log.Printf("mavwaked: process frame: %v", err)
|
||||||
}
|
}
|
||||||
partial = partial[frameBytes:]
|
partial = partial[frameBytes:]
|
||||||
@@ -165,107 +182,31 @@ func captureLoop(ctx context.Context, src io.Reader, vad *VAD, vc *voice.Client,
|
|||||||
partial = nil
|
partial = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := processFrame(full, vad, vc, lang); err != nil {
|
if err := sess.feed(ctx, full); err != nil {
|
||||||
log.Printf("mavwaked: process frame: %v", err)
|
log.Printf("mavwaked: process frame: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// processFrame feeds one 30ms PCM frame to the VAD and sends any completed
|
// voiceSender is the production utteranceSender: one PushToTalk round-trip
|
||||||
// utterance to the voice server.
|
// over the voice wire. SurfaceVoice (not the default SurfacePCClient that
|
||||||
func processFrame(frame []byte, vad *VAD, vc *voice.Client, lang string) error {
|
// c.PushToTalk uses) caps everything at L0, which is what makes an accidental
|
||||||
samples := PCMToI16(frame)
|
// VAD trigger safe.
|
||||||
utt, state := vad.Feed(samples)
|
type voiceSender struct{ vc *voice.Client }
|
||||||
|
|
||||||
if state == StateSpeech {
|
func (s *voiceSender) Send(ctx context.Context, utt audio.Audio, lang string) (audio.Audio, error) {
|
||||||
// Speech is in progress; nothing to send yet.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if utt.Bytes == nil {
|
|
||||||
// Still in silence, or short speech that didn't trigger.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// We have a complete utterance — send it to the voice server.
|
|
||||||
return sendUtterance(context.Background(), utt, vc, lang)
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendUtterance sends audio to the voice server and plays the reply.
|
|
||||||
func sendUtterance(ctx context.Context, utt audio.Audio, vc *voice.Client, lang string) error {
|
|
||||||
dur := utt.Duration()
|
|
||||||
log.Printf("mavwaked: utterance complete (%.2fs, %d bytes), sending...",
|
|
||||||
dur, len(utt.Bytes))
|
|
||||||
|
|
||||||
// Use SendRequest directly so we can set SurfaceVoice instead of the
|
|
||||||
// default SurfacePCClient that c.PushToTalk uses.
|
|
||||||
var resp voice.PushToTalkResp
|
var resp voice.PushToTalkResp
|
||||||
err := vc.SendRequest(ctx, voice.MethodPushToTalk, voice.PushToTalkReq{
|
err := s.vc.SendRequest(ctx, voice.MethodPushToTalk, voice.PushToTalkReq{
|
||||||
Audio: utt,
|
Audio: utt,
|
||||||
Lang: lang,
|
Lang: lang,
|
||||||
Surface: voice.SurfaceVoice,
|
Surface: voice.SurfaceVoice,
|
||||||
}, &resp)
|
}, &resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("push-to-talk: %w", err)
|
return audio.Audio{}, fmt.Errorf("push-to-talk: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("mavwaked: reply: %q (%.2fs audio)", resp.ReplyText, resp.ReplyAudio.Duration())
|
log.Printf("mavwaked: reply: %q (%.2fs audio)", resp.ReplyText, resp.ReplyAudio.Duration())
|
||||||
|
|
||||||
// Play the reply audio.
|
|
||||||
if len(resp.ReplyAudio.Bytes) > 0 {
|
|
||||||
go playAudio(resp.ReplyAudio)
|
|
||||||
} else {
|
|
||||||
log.Printf("mavwaked: empty reply audio (text only)")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(resp.RoutedChannels) > 0 {
|
if len(resp.RoutedChannels) > 0 {
|
||||||
log.Printf("mavwaked: also routed to: %v", resp.RoutedChannels)
|
log.Printf("mavwaked: also routed to: %v", resp.RoutedChannels)
|
||||||
}
|
}
|
||||||
|
return resp.ReplyAudio, nil
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// playAudio pipes PCM audio to aplay(1) for playback. Runs in a goroutine.
|
|
||||||
func playAudio(a audio.Audio) {
|
|
||||||
// Build WAV header for aplay (or pipe raw PCM with the right format flags).
|
|
||||||
cmd := exec.Command("aplay",
|
|
||||||
"-f", "S16_LE",
|
|
||||||
"-r", fmt.Sprintf("%d", a.Format.SampleRate),
|
|
||||||
"-c", fmt.Sprintf("%d", a.Format.Channels),
|
|
||||||
"-t", "raw",
|
|
||||||
)
|
|
||||||
|
|
||||||
stdin, err := cmd.StdinPipe()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("mavwaked: aplay stdin pipe: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
log.Printf("mavwaked: start aplay: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write audio to aplay's stdin.
|
|
||||||
if _, err := stdin.Write(a.Bytes); err != nil {
|
|
||||||
log.Printf("mavwaked: write to aplay: %v", err)
|
|
||||||
}
|
|
||||||
_ = stdin.Close()
|
|
||||||
|
|
||||||
// Wait for playback to finish (with a timeout).
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
done <- cmd.Wait()
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("mavwaked: aplay: %v", err)
|
|
||||||
}
|
|
||||||
case <-time.After(30 * time.Second):
|
|
||||||
log.Printf("mavwaked: aplay timeout, killing")
|
|
||||||
_ = cmd.Process.Kill()
|
|
||||||
<-done
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Reply playback, and the half-duplex gate around it (Vikunja #287).
|
||||||
|
//
|
||||||
|
// Before this, playback was `go playAudio(reply)` — fire and forget, with no
|
||||||
|
// handle on the running aplay. Two things fell out of that, and both are
|
||||||
|
// audible:
|
||||||
|
//
|
||||||
|
// 1. Self-trigger. The capture loop keeps feeding the VAD while the speaker
|
||||||
|
// is playing, so Maven's own reply comes back in through the mic, trips
|
||||||
|
// the VAD, and is sent to the daemon as a fresh utterance. She answers
|
||||||
|
// herself. There is no acoustic echo canceller in this pipeline, so the
|
||||||
|
// only correct fix is half-duplex: while she is speaking, the capture
|
||||||
|
// side is muted.
|
||||||
|
//
|
||||||
|
// 2. No barge-in. Talking over her did nothing — there was nothing to
|
||||||
|
// cancel, because nobody held the process handle.
|
||||||
|
//
|
||||||
|
// The two are the same mechanism seen from opposite sides, so they live
|
||||||
|
// together here. Echo suppression is unconditional (it fixes a bug). Barge-in
|
||||||
|
// is off unless -barge-in is passed, because it needs a room-specific energy
|
||||||
|
// threshold: with no echo canceller, the only way to tell "he is talking over
|
||||||
|
// her" from "the mic is hearing her" is that he is louder, and how much
|
||||||
|
// louder depends on where the mic sits relative to the speaker.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/audio"
|
||||||
|
)
|
||||||
|
|
||||||
|
// player plays one reply at a time and can be cut off mid-utterance.
|
||||||
|
type player interface {
|
||||||
|
// Play starts playback of a, replacing anything already playing, and
|
||||||
|
// returns immediately.
|
||||||
|
Play(a audio.Audio)
|
||||||
|
// Stop ends playback now. A no-op when nothing is playing.
|
||||||
|
Stop()
|
||||||
|
// Playing reports whether audio is currently going out of the speaker.
|
||||||
|
Playing() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// aplayPlayer pipes raw PCM to aplay(1). Stop kills the child, which is what
|
||||||
|
// makes barge-in instant rather than "instant at the end of the sentence".
|
||||||
|
type aplayPlayer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
cmd *exec.Cmd
|
||||||
|
playing bool
|
||||||
|
// gen rises on every Play/Stop so a finishing playback cannot clear the
|
||||||
|
// playing flag of the one that replaced it.
|
||||||
|
gen uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAplayPlayer() *aplayPlayer { return &aplayPlayer{} }
|
||||||
|
|
||||||
|
func (p *aplayPlayer) Play(a audio.Audio) {
|
||||||
|
if len(a.Bytes) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.Stop()
|
||||||
|
|
||||||
|
cmd := exec.Command("aplay",
|
||||||
|
"-f", "S16_LE",
|
||||||
|
"-r", strconv.Itoa(a.Format.SampleRate),
|
||||||
|
"-c", strconv.Itoa(a.Format.Channels),
|
||||||
|
"-t", "raw",
|
||||||
|
)
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("mavwaked: aplay stdin pipe: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
log.Printf("mavwaked: start aplay: %v", err)
|
||||||
|
_ = stdin.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
p.gen++
|
||||||
|
gen := p.gen
|
||||||
|
p.cmd = cmd
|
||||||
|
p.playing = true
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if _, err := stdin.Write(a.Bytes); err != nil {
|
||||||
|
// Broken pipe is the expected outcome of Stop().
|
||||||
|
log.Printf("mavwaked: write to aplay: %v", err)
|
||||||
|
}
|
||||||
|
_ = stdin.Close()
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- cmd.Wait() }()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("mavwaked: aplay: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
log.Printf("mavwaked: aplay timeout, killing")
|
||||||
|
if pr := cmd.Process; pr != nil {
|
||||||
|
_ = pr.Kill()
|
||||||
|
}
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
if p.gen == gen {
|
||||||
|
p.playing = false
|
||||||
|
p.cmd = nil
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *aplayPlayer) Stop() {
|
||||||
|
p.mu.Lock()
|
||||||
|
cmd := p.cmd
|
||||||
|
if cmd != nil {
|
||||||
|
p.gen++
|
||||||
|
p.playing = false
|
||||||
|
p.cmd = nil
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
if cmd != nil && cmd.Process != nil {
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *aplayPlayer) Playing() bool {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return p.playing
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/audio"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The real player must be safe to poke when nothing is playing — the capture
|
||||||
|
// loop calls Playing() on every 30ms frame, and Stop() lands on an idle
|
||||||
|
// player whenever a barge-in races the end of a reply. Neither may need
|
||||||
|
// aplay(1) to be installed.
|
||||||
|
func TestAplayPlayerIdleIsSafe(t *testing.T) {
|
||||||
|
p := newAplayPlayer()
|
||||||
|
if p.Playing() {
|
||||||
|
t.Fatal("a fresh player reports playing")
|
||||||
|
}
|
||||||
|
p.Stop()
|
||||||
|
p.Stop()
|
||||||
|
if p.Playing() {
|
||||||
|
t.Fatal("playing after Stop on an idle player")
|
||||||
|
}
|
||||||
|
// Empty audio is a text-only turn: nothing to play, no process to spawn.
|
||||||
|
p.Play(audio.Audio{Format: audio.PCM16kMono})
|
||||||
|
if p.Playing() {
|
||||||
|
t.Fatal("empty audio started playback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAplayPlayerSatisfiesPlayer(t *testing.T) {
|
||||||
|
var _ player = newAplayPlayer()
|
||||||
|
var _ player = &fakePlayer{}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The capture session: what happens to one 30ms frame, given whether Maven is
|
||||||
|
// currently speaking. Split out of main.go's processFrame so the decision is
|
||||||
|
// testable without a mic, a speaker, or a daemon (Vikunja #287).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/audio"
|
||||||
|
)
|
||||||
|
|
||||||
|
// utteranceSender ships one complete utterance to the voice server and
|
||||||
|
// returns the reply audio to play. The real one round-trips over the voice
|
||||||
|
// wire; tests substitute a recorder.
|
||||||
|
type utteranceSender interface {
|
||||||
|
Send(ctx context.Context, utt audio.Audio, lang string) (audio.Audio, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bargeInConfig holds the two numbers barge-in needs. Zero Frames disables
|
||||||
|
// barge-in entirely — the half-duplex gate still runs.
|
||||||
|
type bargeInConfig struct {
|
||||||
|
// RMS is the normalised energy a frame must exceed to count as him
|
||||||
|
// talking over her rather than the mic hearing her. It is deliberately
|
||||||
|
// far above the VAD's own floor: the speaker leaks into the mic at
|
||||||
|
// roughly ambient level, a person talking at the mic does not.
|
||||||
|
RMS float64
|
||||||
|
// Frames is how many consecutive frames must clear RMS before playback
|
||||||
|
// is cut. One loud frame is a door closing; five in a row is a voice.
|
||||||
|
Frames int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether barge-in should be attempted at all.
|
||||||
|
func (c bargeInConfig) Enabled() bool { return c.Frames > 0 && c.RMS > 0 }
|
||||||
|
|
||||||
|
// session is the per-client capture state machine.
|
||||||
|
type session struct {
|
||||||
|
vad *VAD
|
||||||
|
player player
|
||||||
|
sender utteranceSender
|
||||||
|
lang string
|
||||||
|
barge bargeInConfig
|
||||||
|
|
||||||
|
// loudFrames counts consecutive over-threshold frames seen while she is
|
||||||
|
// speaking. Reset whenever a frame falls back under the threshold, and
|
||||||
|
// whenever playback ends.
|
||||||
|
loudFrames int
|
||||||
|
|
||||||
|
// counters, read by tests and logged on the way out.
|
||||||
|
suppressed int // frames dropped because she was speaking
|
||||||
|
bargeIns int // times playback was cut because he spoke over her
|
||||||
|
sent int // utterances shipped to the daemon
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSession(vad *VAD, p player, s utteranceSender, lang string, barge bargeInConfig) *session {
|
||||||
|
return &session{vad: vad, player: p, sender: s, lang: lang, barge: barge}
|
||||||
|
}
|
||||||
|
|
||||||
|
// feed processes one 30ms PCM frame.
|
||||||
|
//
|
||||||
|
// While the player is running the capture side is muted: the VAD is not fed
|
||||||
|
// and no utterance can be produced, so Maven's own reply cannot come back in
|
||||||
|
// as a new command. The one thing that gets through is barge-in — sustained
|
||||||
|
// energy well above the speaker's leak level cuts playback, and capture
|
||||||
|
// resumes on the very next frame with a clean VAD.
|
||||||
|
func (s *session) feed(ctx context.Context, frame []byte) error {
|
||||||
|
if s.player.Playing() {
|
||||||
|
s.suppressed++
|
||||||
|
if !s.barge.Enabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if frameRMS(PCMToI16(frame)) < s.barge.RMS {
|
||||||
|
s.loudFrames = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.loudFrames++
|
||||||
|
if s.loudFrames < s.barge.Frames {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// He is talking over her. Cut her off, drop the VAD state that
|
||||||
|
// accumulated from the echo, and start listening for real.
|
||||||
|
s.player.Stop()
|
||||||
|
s.bargeIns++
|
||||||
|
s.loudFrames = 0
|
||||||
|
s.vad.Reset()
|
||||||
|
log.Printf("mavwaked: barge-in — stopped playback")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not speaking. If we just stopped, make sure no echo-era state leaks
|
||||||
|
// into the next utterance.
|
||||||
|
if s.loudFrames != 0 {
|
||||||
|
s.loudFrames = 0
|
||||||
|
s.vad.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
utt, state := s.vad.Feed(PCMToI16(frame))
|
||||||
|
if state == StateSpeech || utt.Bytes == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.dispatch(ctx, utt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatch ships a complete utterance and plays whatever comes back.
|
||||||
|
func (s *session) dispatch(ctx context.Context, utt audio.Audio) error {
|
||||||
|
log.Printf("mavwaked: utterance complete (%.2fs, %d bytes), sending...", utt.Duration(), len(utt.Bytes))
|
||||||
|
reply, err := s.sender.Send(ctx, utt, s.lang)
|
||||||
|
s.sent++
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(reply.Bytes) == 0 {
|
||||||
|
log.Printf("mavwaked: empty reply audio (text only)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// The VAD has been accumulating from the buffered mic stream while the
|
||||||
|
// round-trip blocked. None of it is a command — reset before the
|
||||||
|
// speaker opens, so the first post-reply frame starts clean.
|
||||||
|
s.vad.Reset()
|
||||||
|
s.player.Play(reply)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/audio"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakePlayer records Play/Stop instead of shelling out to aplay.
|
||||||
|
type fakePlayer struct {
|
||||||
|
playing bool
|
||||||
|
plays int
|
||||||
|
stops int
|
||||||
|
last audio.Audio
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *fakePlayer) Play(a audio.Audio) { p.playing = true; p.plays++; p.last = a }
|
||||||
|
func (p *fakePlayer) Stop() { p.playing = false; p.stops++ }
|
||||||
|
func (p *fakePlayer) Playing() bool { return p.playing }
|
||||||
|
|
||||||
|
// fakeSender records what was shipped and hands back a canned reply.
|
||||||
|
type fakeSender struct {
|
||||||
|
sent []audio.Audio
|
||||||
|
reply audio.Audio
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeSender) Send(_ context.Context, utt audio.Audio, _ string) (audio.Audio, error) {
|
||||||
|
s.sent = append(s.sent, utt)
|
||||||
|
return s.reply, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func replyAudio() audio.Audio {
|
||||||
|
return audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 16000)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// frameAt returns a 30ms frame whose RMS is approximately rms.
|
||||||
|
func frameAt(rms float64) []byte {
|
||||||
|
amp := rms * math.Sqrt2 * 32768
|
||||||
|
f := make([]int16, frameSamples)
|
||||||
|
for i := range f {
|
||||||
|
f[i] = int16(amp * math.Sin(2*math.Pi*440*float64(i)/16000))
|
||||||
|
}
|
||||||
|
return pcmBytes(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
func silentBytes() []byte { return make([]byte, frameSamples*2) }
|
||||||
|
|
||||||
|
// newTestSession wires a session with fakes and a default VAD.
|
||||||
|
func newTestSession(barge bargeInConfig) (*session, *fakePlayer, *fakeSender) {
|
||||||
|
p := &fakePlayer{}
|
||||||
|
s := &fakeSender{reply: replyAudio()}
|
||||||
|
return newSession(NewVAD(0, 0, 0, 0), p, s, "ru", barge), p, s
|
||||||
|
}
|
||||||
|
|
||||||
|
// speakThenPause drives a full utterance through the session: enough loud
|
||||||
|
// frames to trigger, then enough silence to end it.
|
||||||
|
func speakThenPause(t *testing.T, sess *session) {
|
||||||
|
t.Helper()
|
||||||
|
speechFrames := (defaultSpeechMs + defaultFrameMs - 1) / defaultFrameMs
|
||||||
|
silenceFrames := (defaultSilenceMs+defaultFrameMs-1)/defaultFrameMs + 2
|
||||||
|
loud := frameAt(0.35)
|
||||||
|
for i := 0; i < speechFrames+5; i++ {
|
||||||
|
if err := sess.feed(context.Background(), loud); err != nil {
|
||||||
|
t.Fatalf("feed loud frame %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i < silenceFrames; i++ {
|
||||||
|
if err := sess.feed(context.Background(), silentBytes()); err != nil {
|
||||||
|
t.Fatalf("feed silent frame %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionSendsUtteranceAndPlaysReply(t *testing.T) {
|
||||||
|
sess, p, snd := newTestSession(bargeInConfig{})
|
||||||
|
speakThenPause(t, sess)
|
||||||
|
|
||||||
|
if len(snd.sent) != 1 {
|
||||||
|
t.Fatalf("sent %d utterances, want 1", len(snd.sent))
|
||||||
|
}
|
||||||
|
if snd.sent[0].Format != audio.PCM16kMono {
|
||||||
|
t.Errorf("utterance format = %+v, want canonical", snd.sent[0].Format)
|
||||||
|
}
|
||||||
|
if p.plays != 1 {
|
||||||
|
t.Errorf("plays = %d, want 1", p.plays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bug this whole file exists for: while the speaker is running, the mic
|
||||||
|
// hears Maven and the old code shipped that back as a fresh command.
|
||||||
|
func TestSessionDoesNotHearItselfWhilePlaying(t *testing.T) {
|
||||||
|
sess, p, snd := newTestSession(bargeInConfig{})
|
||||||
|
speakThenPause(t, sess)
|
||||||
|
if !p.Playing() {
|
||||||
|
t.Fatal("expected playback to be running after the reply")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed a long stretch of loud audio — Maven's own voice coming back in.
|
||||||
|
base := sess.suppressed
|
||||||
|
loud := frameAt(0.35)
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
if err := sess.feed(context.Background(), loud); err != nil {
|
||||||
|
t.Fatalf("feed echo frame %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(snd.sent) != 1 {
|
||||||
|
t.Fatalf("sent %d utterances, want 1 — her own reply was captured as a command", len(snd.sent))
|
||||||
|
}
|
||||||
|
if got := sess.suppressed - base; got != 200 {
|
||||||
|
t.Errorf("suppressed %d of the 200 echo frames, want all of them", got)
|
||||||
|
}
|
||||||
|
if p.stops != 0 {
|
||||||
|
t.Errorf("stops = %d, want 0 — barge-in is off, nothing should cut her off", p.stops)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With barge-in off, no amount of noise stops playback.
|
||||||
|
func TestSessionBargeInDisabledByDefault(t *testing.T) {
|
||||||
|
sess, p, _ := newTestSession(bargeInConfig{})
|
||||||
|
if sess.barge.Enabled() {
|
||||||
|
t.Fatal("zero bargeInConfig must be disabled")
|
||||||
|
}
|
||||||
|
speakThenPause(t, sess)
|
||||||
|
veryLoud := frameAt(0.6)
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
_ = sess.feed(context.Background(), veryLoud)
|
||||||
|
}
|
||||||
|
if p.stops != 0 || sess.bargeIns != 0 {
|
||||||
|
t.Fatalf("stops = %d, bargeIns = %d, want 0 with barge-in off", p.stops, sess.bargeIns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionBargeInCutsPlayback(t *testing.T) {
|
||||||
|
barge := bargeInConfig{RMS: 0.12, Frames: 5}
|
||||||
|
sess, p, _ := newTestSession(barge)
|
||||||
|
speakThenPause(t, sess)
|
||||||
|
if !p.Playing() {
|
||||||
|
t.Fatal("expected playback after the reply")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Four loud frames must not be enough — a door closing is not a voice.
|
||||||
|
veryLoud := frameAt(0.35)
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
_ = sess.feed(context.Background(), veryLoud)
|
||||||
|
}
|
||||||
|
if p.stops != 0 {
|
||||||
|
t.Fatalf("playback cut after 4 frames, want it to hold until %d", barge.Frames)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fifth cuts her off.
|
||||||
|
_ = sess.feed(context.Background(), veryLoud)
|
||||||
|
if p.stops != 1 || sess.bargeIns != 1 {
|
||||||
|
t.Fatalf("stops = %d, bargeIns = %d, want 1 and 1", p.stops, sess.bargeIns)
|
||||||
|
}
|
||||||
|
if p.Playing() {
|
||||||
|
t.Fatal("still playing after barge-in")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A burst that falls back under the threshold resets the counter, so noise
|
||||||
|
// spread over a whole reply never accumulates into a false barge-in.
|
||||||
|
func TestSessionBargeInNeedsConsecutiveFrames(t *testing.T) {
|
||||||
|
sess, p, _ := newTestSession(bargeInConfig{RMS: 0.12, Frames: 5})
|
||||||
|
speakThenPause(t, sess)
|
||||||
|
|
||||||
|
veryLoud := frameAt(0.35)
|
||||||
|
quiet := frameAt(0.02)
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
_ = sess.feed(context.Background(), veryLoud)
|
||||||
|
_ = sess.feed(context.Background(), veryLoud)
|
||||||
|
_ = sess.feed(context.Background(), quiet)
|
||||||
|
}
|
||||||
|
if p.stops != 0 || sess.bargeIns != 0 {
|
||||||
|
t.Fatalf("stops = %d, bargeIns = %d, want 0 — two-frame bursts must not accumulate", p.stops, sess.bargeIns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speaker leak sits near the room floor; it must never reach the barge-in bar.
|
||||||
|
func TestSessionEchoLevelAudioNeverBargesIn(t *testing.T) {
|
||||||
|
sess, p, _ := newTestSession(bargeInConfig{RMS: 0.12, Frames: 5})
|
||||||
|
speakThenPause(t, sess)
|
||||||
|
|
||||||
|
base := sess.suppressed
|
||||||
|
leak := frameAt(0.05) // loud enough for the VAD, far under the barge bar
|
||||||
|
for i := 0; i < 300; i++ {
|
||||||
|
_ = sess.feed(context.Background(), leak)
|
||||||
|
}
|
||||||
|
if p.stops != 0 {
|
||||||
|
t.Fatalf("stops = %d, want 0 — speaker leak must not read as barge-in", p.stops)
|
||||||
|
}
|
||||||
|
if got := sess.suppressed - base; got != 300 {
|
||||||
|
t.Errorf("suppressed %d of the 300 leak frames, want all of them", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After barge-in the VAD must start clean, so the interrupting speech is
|
||||||
|
// captured as a whole utterance rather than joined onto echo state.
|
||||||
|
func TestSessionCapturesTheInterruptingUtterance(t *testing.T) {
|
||||||
|
sess, p, snd := newTestSession(bargeInConfig{RMS: 0.12, Frames: 5})
|
||||||
|
speakThenPause(t, sess)
|
||||||
|
|
||||||
|
veryLoud := frameAt(0.35)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
_ = sess.feed(context.Background(), veryLoud)
|
||||||
|
}
|
||||||
|
if p.stops != 1 {
|
||||||
|
t.Fatalf("expected barge-in, stops = %d", p.stops)
|
||||||
|
}
|
||||||
|
|
||||||
|
// He keeps talking; that is a new command.
|
||||||
|
speakThenPause(t, sess)
|
||||||
|
if len(snd.sent) != 2 {
|
||||||
|
t.Fatalf("sent %d utterances, want 2 — the interruption itself must be heard", len(snd.sent))
|
||||||
|
}
|
||||||
|
if p.plays != 2 {
|
||||||
|
t.Errorf("plays = %d, want 2", p.plays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failed round-trip must surface as an error and must not start playback.
|
||||||
|
func TestSessionSendErrorDoesNotPlay(t *testing.T) {
|
||||||
|
p := &fakePlayer{}
|
||||||
|
snd := &fakeSender{err: errors.New("boom")}
|
||||||
|
sess := newSession(NewVAD(0, 0, 0, 0), p, snd, "ru", bargeInConfig{})
|
||||||
|
|
||||||
|
speechFrames := (defaultSpeechMs + defaultFrameMs - 1) / defaultFrameMs
|
||||||
|
silenceFrames := (defaultSilenceMs+defaultFrameMs-1)/defaultFrameMs + 2
|
||||||
|
loud := frameAt(0.35)
|
||||||
|
var lastErr error
|
||||||
|
for i := 0; i < speechFrames+5; i++ {
|
||||||
|
_ = sess.feed(context.Background(), loud)
|
||||||
|
}
|
||||||
|
for i := 0; i < silenceFrames; i++ {
|
||||||
|
if err := sess.feed(context.Background(), silentBytes()); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastErr == nil {
|
||||||
|
t.Fatal("send error was swallowed")
|
||||||
|
}
|
||||||
|
if p.plays != 0 || p.Playing() {
|
||||||
|
t.Fatalf("plays = %d, playing = %v, want no playback on a failed round-trip", p.plays, p.Playing())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty reply (text-only turn) must leave the capture side open.
|
||||||
|
func TestSessionEmptyReplyLeavesCaptureOpen(t *testing.T) {
|
||||||
|
p := &fakePlayer{}
|
||||||
|
snd := &fakeSender{reply: audio.Audio{Format: audio.PCM16kMono}}
|
||||||
|
sess := newSession(NewVAD(0, 0, 0, 0), p, snd, "ru", bargeInConfig{})
|
||||||
|
|
||||||
|
speakThenPause(t, sess)
|
||||||
|
if p.plays != 0 {
|
||||||
|
t.Fatalf("plays = %d, want 0 for an empty reply", p.plays)
|
||||||
|
}
|
||||||
|
speakThenPause(t, sess)
|
||||||
|
if len(snd.sent) != 2 {
|
||||||
|
t.Fatalf("sent %d, want 2 — capture must stay open when there is no audio reply", len(snd.sent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBargeInConfigEnabled(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
c bargeInConfig
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{bargeInConfig{}, false},
|
||||||
|
{bargeInConfig{RMS: 0.12}, false},
|
||||||
|
{bargeInConfig{Frames: 5}, false},
|
||||||
|
{bargeInConfig{RMS: 0.12, Frames: 5}, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := tc.c.Enabled(); got != tc.want {
|
||||||
|
t.Errorf("%+v.Enabled() = %v, want %v", tc.c, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,15 @@ const (
|
|||||||
defaultSilenceMs = 800 // silence hold before declaring end-of-utterance
|
defaultSilenceMs = 800 // silence hold before declaring end-of-utterance
|
||||||
defaultMaxMs = 10000 // cap single utterance at 10s
|
defaultMaxMs = 10000 // cap single utterance at 10s
|
||||||
defaultMinRMS = 0.01 // RMS floor (same as mavsttd)
|
defaultMinRMS = 0.01 // RMS floor (same as mavsttd)
|
||||||
|
|
||||||
|
// Barge-in thresholds. Only used when -barge-in is passed. The RMS is
|
||||||
|
// x10000 like -min-rms, and sits an order of magnitude above the VAD's
|
||||||
|
// own floor on purpose: with no acoustic echo canceller, a frame only
|
||||||
|
// counts as "he is talking over her" if it is far louder than what the
|
||||||
|
// speaker leaks back into the mic. 5 frames is 150ms — long enough that
|
||||||
|
// a door or a cough does not cut her off mid-sentence.
|
||||||
|
defaultBargeRMS = 1200 // 0.12 normalised RMS
|
||||||
|
defaultBargeFrames = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
// frameSamples — samples per 30ms frame at 16kHz.
|
// frameSamples — samples per 30ms frame at 16kHz.
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{{template "shellTop" "events"}}
|
||||||
|
<h1>Intake</h1>
|
||||||
|
<div class=hint>Everything that arrived, newest first — a relayed notification, a mail candidate, a feed
|
||||||
|
item, a changed page, a spend, a presence probe. One envelope per write; the durable row is still the
|
||||||
|
fact, note or task itself. Held in memory only, so a restart empties this.</div>
|
||||||
|
{{if .Err}}<div class=hint>journal unavailable: {{.Err}}</div>{{end}}
|
||||||
|
{{if and (not .Events) (not .Err)}}
|
||||||
|
<div class=hint>nothing has arrived yet</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .Events}}
|
||||||
|
<div class=scroll><table class=mono>
|
||||||
|
<tr><th>when<th>source<th>kind<th>pri<th>what<th>detail</tr>
|
||||||
|
{{range .Events}}<tr>
|
||||||
|
<td>{{.OccurredAt.Format "02.01 15:04:05"}}</td>
|
||||||
|
<td class=gray>{{.Source}}</td>
|
||||||
|
<td class=gray>{{.Kind}}</td>
|
||||||
|
<td class=gray>{{.Priority}}</td>
|
||||||
|
<td>{{.Title}}</td>
|
||||||
|
<td class=gray>{{.Body}}</td>
|
||||||
|
</tr>{{end}}
|
||||||
|
</table></div>
|
||||||
|
{{end}}
|
||||||
|
{{template "shellBottom"}}
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// eventsCore serves a canned intake journal. Embedding
|
||||||
|
// ipc.UnimplementedCoreAPI means any other call fails loudly.
|
||||||
|
type eventsCore struct {
|
||||||
|
ipc.UnimplementedCoreAPI
|
||||||
|
events []ipc.IntakeEvent
|
||||||
|
err error
|
||||||
|
gotN int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *eventsCore) RecentEvents(_ context.Context, n int) ([]ipc.IntakeEvent, error) {
|
||||||
|
c.gotN = n
|
||||||
|
return c.events, c.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEvents(t *testing.T, core ipc.CoreAPI) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handleEvents(w, httptest.NewRequest(http.MethodGet, "/events", nil), core)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventsPageRendersTheJournal(t *testing.T) {
|
||||||
|
core := &eventsCore{events: []ipc.IntakeEvent{
|
||||||
|
{Source: "rss:tech", Kind: "note", Title: "Вышло ядро 6.19", Priority: "low",
|
||||||
|
OccurredAt: time.Date(2026, 8, 1, 7, 15, 0, 0, time.UTC)},
|
||||||
|
{Source: "ambient:notif", Kind: "fact", Title: "calendar_event_20260801_планёрка",
|
||||||
|
Body: "10:00-11:00 планёрка", Priority: "low",
|
||||||
|
OccurredAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)},
|
||||||
|
}}
|
||||||
|
w := getEvents(t, core)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
for _, want := range []string{"rss:tech", "Вышло ядро 6.19", "ambient:notif", "10:00-11:00 планёрка", "01.08 10:00:00"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("page does not mention %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if core.gotN != eventsPageLimit {
|
||||||
|
t.Errorf("asked core for %d events, want %d", core.gotN, eventsPageLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventsPageSaysNothingArrived(t *testing.T) {
|
||||||
|
w := getEvents(t, &eventsCore{})
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "nothing has arrived yet") {
|
||||||
|
t.Error("empty journal did not render the empty-state line")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventsPageReportsAReadFailure(t *testing.T) {
|
||||||
|
// An unreachable journal must say so rather than render an empty table,
|
||||||
|
// which would imply nothing arrived.
|
||||||
|
w := getEvents(t, &eventsCore{err: errors.New("core is down")})
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 with the error rendered", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "journal unavailable") || !strings.Contains(body, "core is down") {
|
||||||
|
t.Errorf("page did not report the read failure: %s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "nothing has arrived yet") {
|
||||||
|
t.Error("a failed read rendered as an empty journal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventsPageWithoutCore(t *testing.T) {
|
||||||
|
w := getEvents(t, nil)
|
||||||
|
if w.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("status = %d, want 503", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventsPageEscapesIntakeText(t *testing.T) {
|
||||||
|
// Titles come from outside — a feed headline, a notification. They are shown
|
||||||
|
// on a page and must never be able to inject markup into it.
|
||||||
|
core := &eventsCore{events: []ipc.IntakeEvent{{
|
||||||
|
Source: "rss:x", Kind: "note", Priority: "low",
|
||||||
|
Title: `<script>alert(1)</script>`,
|
||||||
|
OccurredAt: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC),
|
||||||
|
}}}
|
||||||
|
body := getEvents(t, core).Body.String()
|
||||||
|
if strings.Contains(body, "<script>alert(1)</script>") {
|
||||||
|
t.Error("intake title was not escaped")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "<script>") {
|
||||||
|
t.Error("intake title is missing from the page entirely")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,14 @@ type fakeCore struct {
|
|||||||
// for handleChatAPI tests
|
// for handleChatAPI tests
|
||||||
chatText string
|
chatText string
|
||||||
chatErr error
|
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) {
|
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, "привет")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+78
-4
@@ -71,6 +71,9 @@ var ecosystemHTML string
|
|||||||
//go:embed morning.html
|
//go:embed morning.html
|
||||||
var morningHTML string
|
var morningHTML string
|
||||||
|
|
||||||
|
//go:embed events.html
|
||||||
|
var eventsHTML string
|
||||||
|
|
||||||
// ── Ethos Workstation Shell ──
|
// ── Ethos Workstation Shell ──
|
||||||
//
|
//
|
||||||
// Two template pieces that wrap every page:
|
// Two template pieces that wrap every page:
|
||||||
@@ -105,6 +108,7 @@ var sidebarSections = []struct {
|
|||||||
{Label: "Reminders", URL: "/reminders", Key: "reminders"},
|
{Label: "Reminders", URL: "/reminders", Key: "reminders"},
|
||||||
{Label: "Routines", URL: "/routines", Key: "routines"},
|
{Label: "Routines", URL: "/routines", Key: "routines"},
|
||||||
{Label: "Morning", URL: "/morning", Key: "morning"},
|
{Label: "Morning", URL: "/morning", Key: "morning"},
|
||||||
|
{Label: "Intake", URL: "/events", Key: "events"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -124,6 +128,7 @@ var sidebarSections = []struct {
|
|||||||
Label: "Settings",
|
Label: "Settings",
|
||||||
Pages: []struct{ Label, URL, Key string }{
|
Pages: []struct{ Label, URL, Key string }{
|
||||||
{Label: "Tools", URL: "/tools", Key: "tools"},
|
{Label: "Tools", URL: "/tools", Key: "tools"},
|
||||||
|
{Label: "Model", URL: "/models", Key: "models"},
|
||||||
{Label: "Passkey", URL: "/auth/passkey", Key: "passkey"},
|
{Label: "Passkey", URL: "/auth/passkey", Key: "passkey"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -191,6 +196,8 @@ func pageIcon(key string) string {
|
|||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
||||||
case "tools":
|
case "tools":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-settings"/></svg>`
|
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":
|
case "passkey":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-lock"/></svg>`
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-lock"/></svg>`
|
||||||
default:
|
default:
|
||||||
@@ -225,6 +232,8 @@ func pageTitle(key string) string {
|
|||||||
return "Ecosystem"
|
return "Ecosystem"
|
||||||
case "tools":
|
case "tools":
|
||||||
return "Tools"
|
return "Tools"
|
||||||
|
case "models":
|
||||||
|
return "Resident Model"
|
||||||
case "passkey":
|
case "passkey":
|
||||||
return "Passkey"
|
return "Passkey"
|
||||||
default:
|
default:
|
||||||
@@ -313,6 +322,10 @@ var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).
|
|||||||
// morning routine (internal/morning). Same shape as trace.html: a plain
|
// morning routine (internal/morning). Same shape as trace.html: a plain
|
||||||
// server-rendered page, refreshed on reload — no live-update loop, since
|
// server-rendered page, refreshed on reload — no live-update loop, since
|
||||||
// checklist state changes on the scale of minutes, not seconds.
|
// checklist state changes on the scale of minutes, not seconds.
|
||||||
|
// eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same
|
||||||
|
// shape as trace.html and morning.html: server-rendered, refreshed on reload.
|
||||||
|
var eventsTmpl = template.Must(template.New("events").Funcs(shellFuncs()).Parse(shellTopHTML + eventsHTML + shellBottomHTML))
|
||||||
|
|
||||||
var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellTopHTML + morningHTML + shellBottomHTML))
|
var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellTopHTML + morningHTML + shellBottomHTML))
|
||||||
|
|
||||||
func noCache(h http.Handler) http.Handler {
|
func noCache(h http.Handler) http.Handler {
|
||||||
@@ -425,6 +438,9 @@ func main() {
|
|||||||
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleMorning(w, r, core)
|
handleMorning(w, r, core)
|
||||||
})
|
})
|
||||||
|
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handleEvents(w, r, core)
|
||||||
|
})
|
||||||
ecoURLsCfg := ecoURLs{nexus: *nexusURL, praxis: *praxisURL, hexis: *hexisURL}
|
ecoURLsCfg := ecoURLs{nexus: *nexusURL, praxis: *praxisURL, hexis: *hexisURL}
|
||||||
mux.HandleFunc("/ecosystem", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/ecosystem", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleEcosystem(w, r, ecoURLsCfg)
|
handleEcosystem(w, r, ecoURLsCfg)
|
||||||
@@ -479,6 +495,12 @@ func main() {
|
|||||||
mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleRoutines(w, r, core, stepUpSession, *requireStepUp)
|
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):
|
// State-changing routes on this server, and their gate (Vikunja #317):
|
||||||
//
|
//
|
||||||
@@ -681,7 +703,7 @@ const toolsHTML = `{{template "shellTop" "tools"}}
|
|||||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||||
<section class=card>
|
<section class=card>
|
||||||
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
|
<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>
|
<div class=scroll><table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
|
||||||
{{range .Proposed}}<tr>
|
{{range .Proposed}}<tr>
|
||||||
<td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
|
<td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
|
||||||
@@ -689,8 +711,8 @@ const toolsHTML = `{{template "shellTop" "tools"}}
|
|||||||
<input type=hidden name=name value="{{.Name}}">
|
<input type=hidden name=name value="{{.Name}}">
|
||||||
<input type=hidden name=scope value="{{.Scope}}">
|
<input type=hidden name=scope value="{{.Scope}}">
|
||||||
<input type=hidden name=action value=enable>
|
<input type=hidden name=action value=enable>
|
||||||
<input type=text name=cmd class=input-wide placeholder="systemctl restart" required>
|
<input type=text name=cmd class=input-wide placeholder="systemctl restart" value="{{join .Cmd " "}}" required>
|
||||||
<label><input type=checkbox name=destructive> destructive</label>
|
<label><input type=checkbox name=destructive {{if .Destructive}}checked{{end}}> destructive</label>
|
||||||
<button class=btn>enable</button></form>
|
<button class=btn>enable</button></form>
|
||||||
<form method=post action=/tools class=inline-form>
|
<form method=post action=/tools class=inline-form>
|
||||||
<input type=hidden name=name value="{{.Name}}">
|
<input type=hidden name=name value="{{.Name}}">
|
||||||
@@ -719,6 +741,19 @@ const toolsHTML = `{{template "shellTop" "tools"}}
|
|||||||
<div class=hint>enable proposed tools above, or ask maven to configure one</div>
|
<div class=hint>enable proposed tools above, or ask maven to configure one</div>
|
||||||
</div>{{end}}
|
</div>{{end}}
|
||||||
</section>
|
</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"}}`
|
{{template "shellBottom"}}`
|
||||||
|
|
||||||
// routinesHTML — proposed routine review surface. One row per thing maven
|
// routinesHTML — proposed routine review surface. One row per thing maven
|
||||||
@@ -1182,6 +1217,37 @@ type morningView struct {
|
|||||||
Routines []ipc.MorningRoutineStatus
|
Routines []ipc.MorningRoutineStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eventsView — what /events renders. Err is set instead of Events when the
|
||||||
|
// core could not serve the journal, so the page says why rather than showing an
|
||||||
|
// empty intake and implying nothing arrived.
|
||||||
|
type eventsView struct {
|
||||||
|
Events []ipc.IntakeEvent
|
||||||
|
Err string
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventsPageLimit — how many envelopes the page shows. The ring holds more; a
|
||||||
|
// page is for scanning what just happened, not for archaeology.
|
||||||
|
const eventsPageLimit = 200
|
||||||
|
|
||||||
|
func handleEvents(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||||
|
if core == nil {
|
||||||
|
http.Error(w, "intake journal disabled (no -core)", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var view eventsView
|
||||||
|
evs, err := core.RecentEvents(r.Context(), eventsPageLimit)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("events: %v", err)
|
||||||
|
view.Err = err.Error()
|
||||||
|
} else {
|
||||||
|
view.Events = evs
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := eventsTmpl.Execute(w, view); err != nil {
|
||||||
|
log.Printf("events render: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func handleVoice(w http.ResponseWriter, r *http.Request) {
|
func handleVoice(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := voiceTmpl.Execute(w, nil); err != nil {
|
if err := voiceTmpl.Execute(w, nil); err != nil {
|
||||||
@@ -1309,12 +1375,20 @@ func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sessi
|
|||||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||||
return
|
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")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := toolsTmpl.Execute(w, struct {
|
if err := toolsTmpl.Execute(w, struct {
|
||||||
Msg string
|
Msg string
|
||||||
Proposed []ipc.Tool
|
Proposed []ipc.Tool
|
||||||
Enabled []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)
|
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" }
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
const prfTestOrigin = "https://maven.test"
|
||||||
|
const prfTestRPID = "maven.test"
|
||||||
|
|
||||||
|
// fakeKeyIPC stands in for the mavend socket and records exactly what secret
|
||||||
|
// each call received — the point of the whole test file is that it is the PRF
|
||||||
|
// output and never the credential public key.
|
||||||
|
type fakeKeyIPC struct {
|
||||||
|
unlockSecret []byte
|
||||||
|
wrapSecret []byte
|
||||||
|
unlockCalls int
|
||||||
|
wrapCalls int
|
||||||
|
unlockErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeKeyIPC) Unlock(_ context.Context, secret []byte) error {
|
||||||
|
f.unlockCalls++
|
||||||
|
f.unlockSecret = bytes.Clone(secret)
|
||||||
|
return f.unlockErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeKeyIPC) StoreEncryptionKey(_ context.Context, secret []byte) error {
|
||||||
|
f.wrapCalls++
|
||||||
|
f.wrapSecret = bytes.Clone(secret)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func b64u(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||||
|
|
||||||
|
// prfAuthenticator is a minimal software authenticator: a P-256 key plus the
|
||||||
|
// COSE encoding of its public half.
|
||||||
|
type prfAuthenticator struct {
|
||||||
|
key *ecdsa.PrivateKey
|
||||||
|
credID []byte
|
||||||
|
cose []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPRFAuthenticator(t *testing.T) *prfAuthenticator {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate key: %v", err)
|
||||||
|
}
|
||||||
|
x := key.PublicKey.X.FillBytes(make([]byte, 32))
|
||||||
|
y := key.PublicKey.Y.FillBytes(make([]byte, 32))
|
||||||
|
// COSE_Key: {1: 2 (EC2), 3: -7 (ES256), -1: 1 (P-256), -2: x, -3: y}
|
||||||
|
var c []byte
|
||||||
|
c = append(c, 0xa5) // map(5)
|
||||||
|
c = append(c, 0x01, 0x02) // 1: 2
|
||||||
|
c = append(c, 0x03, 0x26) // 3: -7
|
||||||
|
c = append(c, 0x20, 0x01) // -1: 1
|
||||||
|
c = append(c, 0x21, 0x58, 0x20) // -2: bytes(32)
|
||||||
|
c = append(c, x...)
|
||||||
|
c = append(c, 0x22, 0x58, 0x20) // -3: bytes(32)
|
||||||
|
c = append(c, y...)
|
||||||
|
return &prfAuthenticator{key: key, credID: []byte("prf-cred"), cose: c}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *prfAuthenticator) authData(flags byte, counter uint32, attested bool) []byte {
|
||||||
|
h := sha256.Sum256([]byte(prfTestRPID))
|
||||||
|
d := append([]byte{}, h[:]...)
|
||||||
|
d = append(d, flags)
|
||||||
|
cb := make([]byte, 4)
|
||||||
|
binary.BigEndian.PutUint32(cb, counter)
|
||||||
|
d = append(d, cb...)
|
||||||
|
if attested {
|
||||||
|
d = append(d, make([]byte, 16)...) // aaguid
|
||||||
|
l := make([]byte, 2)
|
||||||
|
binary.BigEndian.PutUint16(l, uint16(len(a.credID)))
|
||||||
|
d = append(d, l...)
|
||||||
|
d = append(d, a.credID...)
|
||||||
|
d = append(d, a.cose...)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientDataJSON(typ, challenge string) []byte {
|
||||||
|
b, _ := json.Marshal(map[string]string{"type": typ, "challenge": challenge, "origin": prfTestOrigin})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// register drives POST /register/finish with a valid attestation.
|
||||||
|
func (a *prfAuthenticator) register(t *testing.T, h *PasskeyHandle) {
|
||||||
|
t.Helper()
|
||||||
|
_, chal, err := h.rp.CreationOptions([]byte("u"), "user")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreationOptions: %v", err)
|
||||||
|
}
|
||||||
|
// {"fmt":"none","attStmt":{},"authData":<bytes>}
|
||||||
|
att := []byte{0xa3}
|
||||||
|
att = append(att, 0x63, 'f', 'm', 't', 0x64, 'n', 'o', 'n', 'e')
|
||||||
|
att = append(att, 0x67, 'a', 't', 't', 'S', 't', 'm', 't', 0xa0)
|
||||||
|
ad := a.authData(1<<6|0x05, 0, true)
|
||||||
|
att = append(att, 0x68, 'a', 'u', 't', 'h', 'D', 'a', 't', 'a')
|
||||||
|
att = append(att, 0x59, byte(len(ad)>>8), byte(len(ad)))
|
||||||
|
att = append(att, ad...)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"challenge": chal,
|
||||||
|
"credential": map[string]any{
|
||||||
|
"id": b64u(a.credID),
|
||||||
|
"type": "public-key",
|
||||||
|
"response": map[string]any{
|
||||||
|
"clientDataJSON": b64u(clientDataJSON("webauthn.create", chal)),
|
||||||
|
"attestationObject": b64u(att),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.RegisterFinish(w, httptest.NewRequest(http.MethodPost, "/auth/webauthn/register/finish", bytes.NewReader(body)))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("RegisterFinish: %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assert drives POST /assert/finish with a valid assertion and the given
|
||||||
|
// base64url PRF result.
|
||||||
|
func (a *prfAuthenticator) assert(t *testing.T, h *PasskeyHandle, prf string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
_, chal, err := h.rp.AssertionOptions()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AssertionOptions: %v", err)
|
||||||
|
}
|
||||||
|
ad := a.authData(0x05, 7, false)
|
||||||
|
cdj := clientDataJSON("webauthn.get", chal)
|
||||||
|
hash := sha256.Sum256(cdj)
|
||||||
|
sig, err := ecdsa.SignASN1(rand.Reader, a.key, append(append([]byte{}, ad...), hash[:]...))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sign: %v", err)
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"challenge": chal,
|
||||||
|
"prf": prf,
|
||||||
|
"credential": map[string]any{
|
||||||
|
"id": b64u(a.credID),
|
||||||
|
"type": "public-key",
|
||||||
|
"response": map[string]any{
|
||||||
|
"clientDataJSON": b64u(cdj),
|
||||||
|
"authenticatorData": b64u(ad),
|
||||||
|
"signature": b64u(sig),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.AssertFinish(w, httptest.NewRequest(http.MethodPost, "/auth/webauthn/assert/finish", bytes.NewReader(body)))
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPRFHandle(t *testing.T, key *fakeKeyIPC) *PasskeyHandle {
|
||||||
|
t.Helper()
|
||||||
|
store, err := newCredentialStore(filepath.Join(t.TempDir(), "passkeys.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("credential store: %v", err)
|
||||||
|
}
|
||||||
|
return &PasskeyHandle{
|
||||||
|
rp: webauthn.NewRP(webauthn.Config{Origin: prfTestOrigin, RPID: prfTestRPID, RPName: "maven"}),
|
||||||
|
encryptFn: key,
|
||||||
|
store: store,
|
||||||
|
session: webauthn.NewPasskeySession(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fix for Vikunja #14: what goes over IPC is the PRF secret from the
|
||||||
|
// authenticator, not the credential public key sitting in passkeys.json.
|
||||||
|
func TestAssertSendsPRFSecretNotPublicKey(t *testing.T) {
|
||||||
|
key := &fakeKeyIPC{}
|
||||||
|
h := newPRFHandle(t, key)
|
||||||
|
auth := newPRFAuthenticator(t)
|
||||||
|
auth.register(t, h)
|
||||||
|
|
||||||
|
// Enrolment must not wrap anything: create() yields no PRF result.
|
||||||
|
if key.wrapCalls != 0 || key.unlockCalls != 0 {
|
||||||
|
t.Fatalf("registration touched the key IPC (wrap=%d unlock=%d)", key.wrapCalls, key.unlockCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
secret := make([]byte, 32)
|
||||||
|
for i := range secret {
|
||||||
|
secret[i] = byte(i + 1)
|
||||||
|
}
|
||||||
|
if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if key.unlockCalls != 1 || key.wrapCalls != 1 {
|
||||||
|
t.Fatalf("unlock=%d wrap=%d, want 1 and 1", key.unlockCalls, key.wrapCalls)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(key.unlockSecret, secret) {
|
||||||
|
t.Errorf("Unlock got %x, want the PRF secret %x", key.unlockSecret, secret)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(key.wrapSecret, secret) {
|
||||||
|
t.Errorf("StoreEncryptionKey got %x, want the PRF secret %x", key.wrapSecret, secret)
|
||||||
|
}
|
||||||
|
// And explicitly: not the credential public key.
|
||||||
|
pub, _, err := h.store.Lookup(b64u(auth.credID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup: %v", err)
|
||||||
|
}
|
||||||
|
if bytes.Equal(key.unlockSecret, pub) {
|
||||||
|
t.Fatal("the credential public key was sent as the unlock secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An authenticator without PRF must produce no unlock attempt at all — the
|
||||||
|
// assertion still succeeds (step-up works), but cold-start unlock stays off
|
||||||
|
// rather than falling back to something weaker.
|
||||||
|
func TestAssertWithoutPRFDoesNotUnlock(t *testing.T) {
|
||||||
|
for _, prf := range []string{"", "!!!not-base64!!!", b64u(make([]byte, 32)), b64u(make([]byte, 16))} {
|
||||||
|
key := &fakeKeyIPC{}
|
||||||
|
h := newPRFHandle(t, key)
|
||||||
|
auth := newPRFAuthenticator(t)
|
||||||
|
auth.register(t, h)
|
||||||
|
|
||||||
|
w := auth.assert(t, h, prf)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("prf=%q: AssertFinish %d %s", prf, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if key.unlockCalls != 0 || key.wrapCalls != 0 {
|
||||||
|
t.Errorf("prf=%q: unlock=%d wrap=%d, want no key IPC at all", prf, key.unlockCalls, key.wrapCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failed unlock must not fail the assertion: step-up is independently valid,
|
||||||
|
// and a locked daemon degrades rather than breaking the login.
|
||||||
|
func TestAssertSucceedsWhenUnlockFails(t *testing.T) {
|
||||||
|
key := &fakeKeyIPC{unlockErr: errors.New("wrong credential")}
|
||||||
|
h := newPRFHandle(t, key)
|
||||||
|
auth := newPRFAuthenticator(t)
|
||||||
|
auth.register(t, h)
|
||||||
|
|
||||||
|
secret := bytes.Repeat([]byte{3}, 32)
|
||||||
|
if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if key.unlockCalls != 1 {
|
||||||
|
t.Errorf("unlock attempted %d times, want 1", key.unlockCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A forged assertion must never reach the unlock path.
|
||||||
|
func TestForgedAssertionNeverUnlocks(t *testing.T) {
|
||||||
|
key := &fakeKeyIPC{}
|
||||||
|
h := newPRFHandle(t, key)
|
||||||
|
auth := newPRFAuthenticator(t)
|
||||||
|
auth.register(t, h)
|
||||||
|
|
||||||
|
// A different key signing over the same credential id.
|
||||||
|
attacker := newPRFAuthenticator(t)
|
||||||
|
attacker.credID = auth.credID
|
||||||
|
w := attacker.assert(t, h, b64u(bytes.Repeat([]byte{4}, 32)))
|
||||||
|
if w.Code == http.StatusOK {
|
||||||
|
t.Fatal("an assertion signed by the wrong key was accepted")
|
||||||
|
}
|
||||||
|
if key.unlockCalls != 0 || key.wrapCalls != 0 {
|
||||||
|
t.Fatalf("a forged assertion reached the key IPC (unlock=%d wrap=%d)", key.unlockCalls, key.wrapCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The browser side is the only place the PRF result exists. If the page stops
|
||||||
|
// asking for it or stops reading it back, cold-start unlock silently dies with
|
||||||
|
// nothing failing, so the page source is asserted directly.
|
||||||
|
func TestPasskeyPageRequestsAndPostsPRF(t *testing.T) {
|
||||||
|
for _, want := range []string{
|
||||||
|
"getClientExtensionResults",
|
||||||
|
"ext.prf.results.first",
|
||||||
|
"body:JSON.stringify({challenge,prf,",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(passkeyPageHTML, want) {
|
||||||
|
t.Errorf("the passkey page no longer contains %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
-33
@@ -23,8 +23,8 @@ type assertIPC interface {
|
|||||||
// is *ipc.Client; in-process CoreAPI adapters do not implement it. When nil,
|
// is *ipc.Client; in-process CoreAPI adapters do not implement it. When nil,
|
||||||
// StoreEncryptionKey and Unlock are silently skipped.
|
// StoreEncryptionKey and Unlock are silently skipped.
|
||||||
type keyIPC interface {
|
type keyIPC interface {
|
||||||
StoreEncryptionKey(ctx context.Context, publicKey []byte) error
|
StoreEncryptionKey(ctx context.Context, secret []byte) error
|
||||||
Unlock(ctx context.Context, publicKey []byte) error
|
Unlock(ctx context.Context, secret []byte) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// PasskeyHandle holds the WebAuthn relying party, a local in-memory credential
|
// PasskeyHandle holds the WebAuthn relying party, a local in-memory credential
|
||||||
@@ -102,17 +102,31 @@ async function enroll(){try{
|
|||||||
const r=await fetch('/auth/webauthn/register/finish',{method:'POST',headers:{'content-type':'application/json'},
|
const r=await fetch('/auth/webauthn/register/finish',{method:'POST',headers:{'content-type':'application/json'},
|
||||||
body:JSON.stringify({challenge,credential:{id:c.id,type:c.type,response:{
|
body:JSON.stringify({challenge,credential:{id:c.id,type:c.type,response:{
|
||||||
clientDataJSON:b64u(c.response.clientDataJSON),attestationObject:b64u(c.response.attestationObject)}}})});
|
clientDataJSON:b64u(c.response.clientDataJSON),attestationObject:b64u(c.response.attestationObject)}}})});
|
||||||
say(r.ok?'enrolled ✓':'enroll failed: '+await r.text(),r.ok);
|
if(!r.ok){say('enroll failed: '+await r.text(),false);return;}
|
||||||
|
// The wrapped key can only be written from an assertion: PRF results are
|
||||||
|
// not produced at create() time on most authenticators. Enrolment reports
|
||||||
|
// whether PRF is available at all so he is not told cold-start works when
|
||||||
|
// it cannot.
|
||||||
|
const ext=c.getClientExtensionResults?c.getClientExtensionResults():{};
|
||||||
|
const prfOK=!!(ext.prf&&ext.prf.enabled);
|
||||||
|
say(prfOK?'enrolled ✓ — now assert once to write the cold-start key':
|
||||||
|
'enrolled ✓ — but this authenticator has no PRF: cold-start unlock unavailable',true);
|
||||||
}catch(e){say('enroll error: '+e,false);}}
|
}catch(e){say('enroll error: '+e,false);}}
|
||||||
async function assert(){try{
|
async function assert(){try{
|
||||||
const {challenge,options}=await (await fetch('/auth/webauthn/assert/begin')).json();
|
const {challenge,options}=await (await fetch('/auth/webauthn/assert/begin')).json();
|
||||||
options.challenge=ub64(options.challenge);
|
options.challenge=ub64(options.challenge);
|
||||||
const c=await navigator.credentials.get({publicKey:options});
|
const c=await navigator.credentials.get({publicKey:options});
|
||||||
|
// The PRF result is the cold-start secret. It never touches localStorage
|
||||||
|
// and is posted once, over the same request as the assertion.
|
||||||
|
const ext=c.getClientExtensionResults?c.getClientExtensionResults():{};
|
||||||
|
const prf=ext.prf&&ext.prf.results&&ext.prf.results.first?b64u(ext.prf.results.first):'';
|
||||||
const r=await fetch('/auth/webauthn/assert/finish',{method:'POST',headers:{'content-type':'application/json'},
|
const r=await fetch('/auth/webauthn/assert/finish',{method:'POST',headers:{'content-type':'application/json'},
|
||||||
body:JSON.stringify({challenge,credential:{id:c.id,type:c.type,response:{
|
body:JSON.stringify({challenge,prf,credential:{id:c.id,type:c.type,response:{
|
||||||
clientDataJSON:b64u(c.response.clientDataJSON),authenticatorData:b64u(c.response.authenticatorData),
|
clientDataJSON:b64u(c.response.clientDataJSON),authenticatorData:b64u(c.response.authenticatorData),
|
||||||
signature:b64u(c.response.signature)}}})});
|
signature:b64u(c.response.signature)}}})});
|
||||||
say(r.ok?'stepped up ✓ — enable tools now':'assert failed: '+await r.text(),r.ok);
|
if(!r.ok){say('assert failed: '+await r.text(),false);return;}
|
||||||
|
say(prf?'stepped up ✓ — enable tools now':
|
||||||
|
'stepped up ✓ — no PRF from this authenticator, so cold-start unlock stayed unavailable',true);
|
||||||
}catch(e){say('assert error: '+e,false);}}
|
}catch(e){say('assert error: '+e,false);}}
|
||||||
</script>`
|
</script>`
|
||||||
|
|
||||||
@@ -140,9 +154,7 @@ func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var enrolledPublicKey []byte
|
|
||||||
save := func(id string, publicKey []byte, _ []byte, _ string) error {
|
save := func(id string, publicKey []byte, _ []byte, _ string) error {
|
||||||
enrolledPublicKey = publicKey
|
|
||||||
return h.store.Save(id, publicKey)
|
return h.store.Save(id, publicKey)
|
||||||
}
|
}
|
||||||
credID, err := h.rp.FinishRegistration(save, body.Challenge, body.Credential)
|
credID, err := h.rp.FinishRegistration(save, body.Challenge, body.Credential)
|
||||||
@@ -153,19 +165,15 @@ func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
log.Printf("webauthn: registered credential %s", credID)
|
log.Printf("webauthn: registered credential %s", credID)
|
||||||
|
|
||||||
// If mavend is reachable and supports key wrapping, store the encryption
|
// Note what does NOT happen here: the encryption key is not wrapped at
|
||||||
// key wrapped with this credential's public key — enables cold-start unlock.
|
// enrolment. Wrapping needs the authenticator's PRF output, and create()
|
||||||
if h.encryptFn != nil && enrolledPublicKey != nil {
|
// does not produce one on most authenticators — it only reports whether
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
// the extension is supported. The wrapped key is written on the first
|
||||||
defer cancel()
|
// assertion instead (see AssertFinish).
|
||||||
if err := h.encryptFn.StoreEncryptionKey(ctx, enrolledPublicKey); err != nil {
|
//
|
||||||
log.Printf("webauthn: store encryption key: %v", err)
|
// This used to wrap the key under the credential *public* key, which is
|
||||||
// Non-fatal: enrollment still succeeded, the wrapped key can be
|
// written to passkeys.json next to the wrapped blob. See the header of
|
||||||
// created later via the same endpoint.
|
// internal/webauthn/keywrap.go.
|
||||||
} else {
|
|
||||||
log.Printf("webauthn: encryption key wrapped with credential %s", credID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{"credential_id": credID})
|
json.NewEncoder(w).Encode(map[string]string{"credential_id": credID})
|
||||||
}
|
}
|
||||||
@@ -189,6 +197,11 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
|
|||||||
var body struct {
|
var body struct {
|
||||||
Challenge string `json:"challenge"`
|
Challenge string `json:"challenge"`
|
||||||
Credential map[string]any `json:"credential"`
|
Credential map[string]any `json:"credential"`
|
||||||
|
// PRF is the base64url WebAuthn PRF output the browser read out of
|
||||||
|
// getClientExtensionResults(). Empty when the authenticator has no
|
||||||
|
// PRF extension: cold-start unlock is then unavailable and we say so
|
||||||
|
// rather than falling back to something weaker.
|
||||||
|
PRF string `json:"prf"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
||||||
@@ -222,26 +235,32 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the daemon is locked (cold-start), send the credential's public key
|
// Cold-start unlock and key wrapping, both keyed on the PRF secret that
|
||||||
// over IPC so mavend can unwrap its encryption key and open the store.
|
// this assertion just produced. The secret is used here and dropped; it is
|
||||||
// The public key comes from the local credential store (it was stored
|
// never stored on this side.
|
||||||
// during enrollment). Non-fatal: if IPC doesn't support Unlock or the
|
//
|
||||||
// daemon is already unlocked, the call is a no-op on the server side.
|
// Order matters: unlock first (if the daemon is locked there is nothing to
|
||||||
|
// wrap yet), then re-wrap, which writes the blob on the first assertion
|
||||||
|
// after enrolment and is a harmless rewrite afterwards. Both are
|
||||||
|
// best-effort — the assertion itself is valid either way.
|
||||||
if h.encryptFn != nil {
|
if h.encryptFn != nil {
|
||||||
publicKey, _, err := h.store.Lookup(credID)
|
secret, err := webauthn.DecodePRFResult(body.PRF)
|
||||||
if err == nil && publicKey != nil {
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
log.Printf("webauthn: no usable PRF secret from credential %s: %v", credID, err)
|
||||||
|
default:
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := h.encryptFn.Unlock(ctx, publicKey); err != nil {
|
if err := h.encryptFn.Unlock(ctx, secret); err != nil {
|
||||||
log.Printf("webauthn: unlock via credential %s: %v", credID, err)
|
log.Printf("webauthn: unlock via credential %s: %v", credID, err)
|
||||||
// Non-fatal: assertion succeeded; if the daemon stays locked
|
|
||||||
// the user will see errors on subsequent pages, but the
|
|
||||||
// assertion itself is valid.
|
|
||||||
} else {
|
} else {
|
||||||
log.Printf("webauthn: daemon unlocked via credential %s", credID)
|
log.Printf("webauthn: daemon unlocked via credential %s", credID)
|
||||||
}
|
}
|
||||||
} else if err != nil {
|
if err := h.encryptFn.StoreEncryptionKey(ctx, secret); err != nil {
|
||||||
log.Printf("webauthn: lookup credential %s for unlock: %v", credID, err)
|
log.Printf("webauthn: wrap encryption key: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("webauthn: encryption key wrapped for credential %s", credID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,36 @@ What it does and does not do:
|
|||||||
- how far each feed was read is stored as a config fact `rss:latest:<name>`, so
|
- how far each feed was read is stored as a config fact `rss:latest:<name>`, so
|
||||||
a restart does not re-note yesterday's headlines.
|
a restart does not re-note yesterday's headlines.
|
||||||
|
|
||||||
|
### Reading a page (`crawl`, also off by default)
|
||||||
|
|
||||||
|
There is no `crawl` block either, so no page is fetched. Two halves, separately
|
||||||
|
switched:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"crawl": {
|
||||||
|
"on_demand": true,
|
||||||
|
"interval": "6h",
|
||||||
|
"max_runes": 4000,
|
||||||
|
"watches": [
|
||||||
|
{ "name": "changelog", "url": "https://example.org/changelog", "interval": "12h" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `on_demand` lets her read a page he names in the utterance: "посмотри
|
||||||
|
https://example.org/x — что там?". The page becomes context for his question,
|
||||||
|
and only the URL leaves the box. Without a URL nothing is fetched, so this is
|
||||||
|
a fallback and not a habit;
|
||||||
|
- `watches` re-reads a fixed list on its interval and writes a note when the
|
||||||
|
text changed. Like the feeds, it announces nothing;
|
||||||
|
- the answer path sits **last** in the query chain, behind his memory, his notes
|
||||||
|
and (once wired) the local Kiwix ZIMs. A local read costs nothing;
|
||||||
|
- `robots.txt` is fetched first and obeyed with no override; a `Disallow` is a
|
||||||
|
refusal she says out loud. `Crawl-delay` is honoured;
|
||||||
|
- same guarded fetcher as the feeds: allowlist/denylist, no private addresses,
|
||||||
|
size cap, redirect cap, timeout, one request per host per second;
|
||||||
|
- dedup state is the config fact `crawl:hash:<name>`.
|
||||||
|
|
||||||
## Not yet verified / host-dependent
|
## Not yet verified / host-dependent
|
||||||
|
|
||||||
This stack is correct-by-construction but has **not been build-tested here**
|
This stack is correct-by-construction but has **not been build-tested here**
|
||||||
@@ -84,3 +114,49 @@ build on the target host, most likely in one of these:
|
|||||||
work fine over the core socket.
|
work fine over the core socket.
|
||||||
- **netdata** — `mavpoll` reaches it via `host.docker.internal`; adjust if
|
- **netdata** — `mavpoll` reaches it via `host.docker.internal`; adjust if
|
||||||
netdata runs elsewhere.
|
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,40 @@
|
|||||||
"cooldown": "24h"
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"smarthome": {
|
||||||
|
"provider": "homeassistant",
|
||||||
|
"url": "http://192.168.1.50:8123",
|
||||||
|
"token": "${HA_TOKEN}",
|
||||||
|
"domains": ["light", "switch", "sensor"],
|
||||||
|
"max_entities": 40,
|
||||||
|
"timeout": "10s",
|
||||||
|
"refresh": "15m",
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
|
||||||
|
"netscan": {
|
||||||
|
"subnets": ["192.168.1.0/24"],
|
||||||
|
"ports": [22, 80, 443, 8080],
|
||||||
|
"timeout": "400ms",
|
||||||
|
"rate": 50,
|
||||||
|
"max_hosts": 256,
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
|
||||||
"nexus": { "url": "http://nexus:9740" },
|
"nexus": { "url": "http://nexus:9740" },
|
||||||
"praxis": { "url": "http://praxis:8989" },
|
"praxis": { "url": "http://praxis:8989" },
|
||||||
"hexis": { "url": "http://hexis:9741" },
|
"hexis": { "url": "http://hexis:9741" },
|
||||||
|
|||||||
+101
-22
@@ -1,27 +1,106 @@
|
|||||||
# Plan: Vision — Image Understanding Capability
|
# 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:**
|
**Status (2026-08-01):** intake, storage, config seam and the provider are shipped. The
|
||||||
- Vision model backend is configurable: local multimodal LLM (e.g., LLaVA, Qwen-VL via `llama-server` mmproj) or remote API
|
describing half is **BLOCKED on a model download** — see "What is blocked" below.
|
||||||
- `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
|
|
||||||
|
|
||||||
**Scope:**
|
## What shipped
|
||||||
- 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
|
|
||||||
|
|
||||||
**Steps:**
|
| Piece | Where |
|
||||||
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
|
| Blob store (content-addressed, retention-pruned) | `internal/media/store.go` |
|
||||||
3. Implement `RemoteProvider` — calls an OpenAI-compatible vision API endpoint, reuses `internal/llm.Client`
|
| Image decode / flatten / downscale / JPEG | `internal/media/image.go` |
|
||||||
4. Create `internal/vision/processor.go` — image preprocessing (resize, format conversion to JPEG/PNG, base64 encoding)
|
| `Provider` seam + `Disabled` floor + `LocalProvider` | `internal/vision/vision.go` |
|
||||||
5. Wire vision into `cmd/mavend/voice.go:reactiveHandler` — detect vision intent from router (new `IntentVision` or a `Slots.HasImage` flag)
|
| Store-then-describe orchestration, re-runnable | `internal/vision/intake.go` |
|
||||||
6. Add IPC method `MethodDescribeImage` for programmatic access (mavweb upload, telegram bot)
|
| Config blocks `media` and `vision` | `internal/config/config.go` |
|
||||||
7. Add vision config block to `config.Config` and wire in `cmd/mavend/main.go`
|
| IPC method `describe_image` (`AuthRead`) | `internal/ipc/{wire,api,client,server}.go`, `internal/auth/policy.go` |
|
||||||
8. Test with a local multimodal model: send an image via mavweb, verify description and text extraction
|
| 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:**
|
**Status (2026-08-01):** the recorder, the storage, the chunked transcription, the map-reduce
|
||||||
- `internal/audio/capture.go` — remote microphone capture client (receives PCM stream from workpc over WebSocket or the existing voice TCP protocol)
|
summariser, the config seam and the four IPC methods are shipped and tested. What is not
|
||||||
- `internal/stt/` — streaming transcription (uses existing `stt.Transcriber` interface, extended with streaming support)
|
shipped is the workpc-side microphone agent and the router intent — see "Still open".
|
||||||
- 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
|
|
||||||
|
|
||||||
**Scope:**
|
## What shipped
|
||||||
- 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
|
|
||||||
|
|
||||||
**Steps:**
|
| Piece | Where |
|
||||||
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)
|
| Session state machine: start / append / stop / abort / status | `internal/capture/capture.go` |
|
||||||
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
|
| Map-reduce summarisation against `n_ctx` 4096 | `internal/capture/summarize.go` |
|
||||||
4. Add `IntentCapture` to `internal/router/intent.go` — slots: `Action` ("start"/"stop"/"status"), `Duration`
|
| Audio blobs in the shared store, pruned by `media.retention` | `internal/media` (from #252) |
|
||||||
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`
|
| Config block `capture`, off by default | `internal/config/config.go` |
|
||||||
6. Add capture config to `voice` block in `config.Config` — `{capture_enabled, capture_timeout}`
|
| IPC `capture_start` / `capture_append` / `capture_stop` / `capture_status` | `internal/ipc/{wire,api,client,server}.go` |
|
||||||
7. Test with a recorded WAV file — simulate a meeting, verify transcription + summary note is created
|
| 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
|
# 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:**
|
**Status (2026-08-01, Vikunja #255):** the enrolment half is shipped. The recognising half is
|
||||||
- Speaker embedding extractor (e.g., ECAPA-TDNN or a simple MFCC + GMM) runs on incoming voice PCM before STT
|
**BLOCKED on a model download** — there is no speaker-embedding model on this box, and one
|
||||||
- Embedding is compared against enrolled speaker profiles (stored as vectors in the `memory_vectors` table alongside semantic memory)
|
was not invented to fill the gap. See "Blocked, and on what" below.
|
||||||
- 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>")
|
|
||||||
|
|
||||||
**Scope:**
|
## What shipped
|
||||||
- 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
|
|
||||||
|
|
||||||
**Steps:**
|
| Piece | Where | State |
|
||||||
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)`
|
| `Recognizer` — identify, list, get, forget | `internal/speaker/recognizer.go` | done; `Identify` answers `ErrDisabled` until a model exists |
|
||||||
3. Create `internal/speaker/store.go` — speaker profile CRUD via `store.MemoryStore`: `Insert("speaker:<id>", embedding, meta)`, `Search(embedding, k)`
|
| Enrolment — several samples, averaged, re-normalised | `internal/speaker/enroll.go` | done |
|
||||||
4. Create `internal/speaker/enroll.go` — enrollment flow: capture N seconds of audio, extract embedding, prompt for name via TTS + STT round-trip
|
| Profile shape, id validation, cosine similarity | `internal/speaker/speaker.go` | done |
|
||||||
5. Wire into `cmd/mavend/voice.go:HandlePushToTalk` — run speaker ID on the PCM before STT; pass speaker ID through `context.Context` to `applyAction`
|
| Profile storage as `speaker:<id>` vectors | `internal/memory` `Catalog` + `internal/store/memory.go` | done, no schema migration |
|
||||||
6. Tag all voice-written facts/notes with speaker ID — `Source` becomes `tap:voice:speaker:<id>` or metadata field
|
| Config block, off by default | `internal/config` `SpeakerConfig` | done |
|
||||||
7. Add IPC methods `MethodEnrollSpeaker`, `MethodListSpeakers`, `MethodRemoveSpeaker`
|
| `enroll_speaker` / `list_speakers` / `forget_speaker` | `internal/ipc` | done, absent unless configured |
|
||||||
8. Add speaker config block to `voice` in `config.Config` — `{speaker_recognition: true, model_path}`
|
| Authority rows | `internal/auth/policy.go` | done — enrol step-up, forget write, list read |
|
||||||
9. Test with 2+ recorded voice samples — verify correct identification and rejection of unknown speakers
|
| 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.
|
||||||
|
|||||||
@@ -28,3 +28,41 @@
|
|||||||
7. Add IPC methods `MethodTriggerCrawl(name)`, `MethodListCrawls`, `MethodGetCrawlResult(name)`
|
7. Add IPC methods `MethodTriggerCrawl(name)`, `MethodListCrawls`, `MethodGetCrawlResult(name)`
|
||||||
8. Add `crawls` block to `config.Config` and `deploy/mavend.json`
|
8. Add `crawls` block to `config.Config` and `deploy/mavend.json`
|
||||||
9. Test with a static HTML page — verify extraction matches expected values, verify scheduling fires correctly
|
9. Test with a static HTML page — verify extraction matches expected values, verify scheduling fires correctly
|
||||||
|
|
||||||
|
## Shipped 2026-08-01 (#259)
|
||||||
|
|
||||||
|
Built as `internal/crawl` (pure: robots, extraction, watcher) plus
|
||||||
|
`cmd/mavend/crawls.go` (fetcher, ticker, dedup facts), on top of the guarded
|
||||||
|
`internal/webfetch` door added with the feed reader (#258). Off unless
|
||||||
|
configured, in two separately-switched halves: `crawl.on_demand` for a URL he
|
||||||
|
names, `crawl.watches` for a scheduled re-read.
|
||||||
|
|
||||||
|
**Limits are code, not documentation** (`internal/webfetch`, tested one test per
|
||||||
|
limit): host allowlist/denylist, no private addresses (loopback, RFC1918 —
|
||||||
|
hence the LAN and the `10.42.0.0/24` wg range —, link-local incl. cloud
|
||||||
|
metadata, CGNAT, v6 ULA) enforced in the dialer's `Control` hook so DNS
|
||||||
|
rebinding and every redirect hop are covered, response size cap, redirect cap,
|
||||||
|
timeout, one request per host per second. `robots.txt` is fetched first, cached
|
||||||
|
per host, and a `Disallow` is refused with no override.
|
||||||
|
|
||||||
|
Deliberate deviations from the plan above:
|
||||||
|
|
||||||
|
- **No CSS selectors and no LLM structured extraction** (steps 2). The output is
|
||||||
|
plaintext handed to the phraser as context for the question he asked. A 1.7B
|
||||||
|
extracting a JSON price table from 4000 runes is a worse bet than reading, and
|
||||||
|
`goquery` is not vendored.
|
||||||
|
- **No `crawl` act verb and no new IPC methods** (steps 5, 7). Reading a page is
|
||||||
|
a query source (`queryWeb` in `actions_query.go`, last in the chain, behind
|
||||||
|
Kiwix once that is wired), not an action he commands. Nothing needs a new wire
|
||||||
|
method to work.
|
||||||
|
- **Notes, not facts.** A page's text is not a fact about him. Only the dedup
|
||||||
|
hash is a fact (`crawl:hash:<name>`, kind `config`, source `poll:crawl`).
|
||||||
|
- **Nothing is dispatched.** A changed page writes a note; it does not nudge.
|
||||||
|
Not a nag.
|
||||||
|
- **No `/tools` crawl history page.** The notes and the hash facts are already
|
||||||
|
visible on `/dash`.
|
||||||
|
|
||||||
|
**No new dependency.** The vendored tree has no `x/net/html`, no `goquery` and
|
||||||
|
no `temoto/robotstxt`, so robots parsing and HTML-to-text are stdlib
|
||||||
|
(`regexp`, `html`) — RE2 has no backreferences, hence the `pairsRE` builder in
|
||||||
|
`extract.go`.
|
||||||
|
|||||||
@@ -393,3 +393,82 @@ func mustWriteFactParams(source string) []byte {
|
|||||||
}
|
}
|
||||||
return b
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+63
-1
@@ -53,6 +53,44 @@ func Requirement(m ipc.Method) Authority {
|
|||||||
// asserted — never a module or the voice/chat path. maven can propose
|
// asserted — never a module or the voice/chat path. maven can propose
|
||||||
// (MethodProposeTool, no step-up: she has no passkey) but never en/disable.
|
// (MethodProposeTool, no step-up: she has no passkey) but never en/disable.
|
||||||
return AuthStepUp
|
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:
|
case ipc.MethodWriteFact:
|
||||||
return AuthWrite
|
return AuthWrite
|
||||||
case ipc.MethodAssertStepUp:
|
case ipc.MethodAssertStepUp:
|
||||||
@@ -79,7 +117,31 @@ func Requirement(m ipc.Method) Authority {
|
|||||||
// produce: candidate tasks and nothing else. It cannot write a fact, set a
|
// 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
|
// 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.
|
// 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,
|
||||||
|
// The unified intake journal (Vikunja #283). AuthRead, and listed
|
||||||
|
// explicitly rather than inherited so the reasoning is on the record: it
|
||||||
|
// reports what already arrived — sources, keys, note headlines — which is
|
||||||
|
// the same material RecentFacts and RecentNotes already return at this
|
||||||
|
// rung. It writes nothing, and it holds nothing a fact read does not.
|
||||||
|
ipc.MethodRecentEvents:
|
||||||
return AuthRead
|
return AuthRead
|
||||||
}
|
}
|
||||||
// Unknown method ⇒ AuthRead, but ipc.dispatch returns ErrUnknownMethod
|
// 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
|
||||||
|
}
|
||||||
+593
-1
@@ -18,11 +18,16 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/delivery/ntfysink"
|
"github.com/kami/maven/internal/delivery/ntfysink"
|
||||||
"github.com/kami/maven/internal/delivery/telegramsink"
|
"github.com/kami/maven/internal/delivery/telegramsink"
|
||||||
|
"github.com/kami/maven/internal/mcp"
|
||||||
"github.com/kami/maven/internal/morning"
|
"github.com/kami/maven/internal/morning"
|
||||||
|
"github.com/kami/maven/internal/netscan"
|
||||||
|
"github.com/kami/maven/internal/smarthome"
|
||||||
|
"github.com/kami/maven/internal/update"
|
||||||
"github.com/robfig/cron/v3"
|
"github.com/robfig/cron/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,6 +113,16 @@ type Config struct {
|
|||||||
// calls its /v1/chat/completions endpoint to phrase nudges and reminders.
|
// calls its /v1/chat/completions endpoint to phrase nudges and reminders.
|
||||||
Phraser *PhraserConfig `json:"phraser,omitempty"`
|
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
|
// Voice — the client↔core surface + the stt/tts modules the daemon
|
||||||
// wires. nil ⇒ the daemon doesn't wire voice: the TCP listener stays
|
// wires. nil ⇒ the daemon doesn't wire voice: the TCP listener stays
|
||||||
// down, the dispatcher's Voice slot stays nil (the routing table's
|
// down, the dispatcher's Voice slot stays nil (the routing table's
|
||||||
@@ -156,11 +171,29 @@ type Config struct {
|
|||||||
// live in the reader (cmd/mavmaild), never here.
|
// live in the reader (cmd/mavmaild), never here.
|
||||||
Email *EmailConfig `json:"email,omitempty"`
|
Email *EmailConfig `json:"email,omitempty"`
|
||||||
|
|
||||||
|
// IntakeJournal — how many entries the unified intake journal keeps
|
||||||
|
// (Vikunja #283): one envelope per thing that arrived, whatever direction it
|
||||||
|
// came from. Absent ⇒ DefaultIntakeJournal. A NEGATIVE value turns the
|
||||||
|
// journal off entirely, and then there is no decorator on the intake path at
|
||||||
|
// all.
|
||||||
|
//
|
||||||
|
// Not gated behind an "off unless configured" block like feeds or telegram,
|
||||||
|
// and the distinction is the one CLAUDE.md draws: that rule exists for
|
||||||
|
// capabilities that reach OUT — a fetch, a send, a third party. This reaches
|
||||||
|
// nowhere. It is a bounded in-memory log of writes core already performed,
|
||||||
|
// it is read only by /events and the simulator, and nothing Maven says
|
||||||
|
// depends on it.
|
||||||
|
IntakeJournal int `json:"intake_journal,omitempty"`
|
||||||
|
|
||||||
// Feeds — RSS/Atom feed reading (Vikunja #258). nil / absent ⇒ no feed is
|
// Feeds — RSS/Atom feed reading (Vikunja #258). nil / absent ⇒ no feed is
|
||||||
// ever fetched: reading the outside world is off unless configured, like
|
// ever fetched: reading the outside world is off unless configured, like
|
||||||
// the weather and telegram. See FeedsConfig.
|
// the weather and telegram. See FeedsConfig.
|
||||||
Feeds *FeedsConfig `json:"feeds,omitempty"`
|
Feeds *FeedsConfig `json:"feeds,omitempty"`
|
||||||
|
|
||||||
|
// Crawl — reading a web page (Vikunja #259). nil / absent ⇒ Maven never
|
||||||
|
// fetches a page: not on request, not on a schedule. See CrawlConfig.
|
||||||
|
Crawl *CrawlConfig `json:"crawl,omitempty"`
|
||||||
|
|
||||||
// Praxis — the ecosystem attention-state service. When configured, maven
|
// Praxis — the ecosystem attention-state service. When configured, maven
|
||||||
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
|
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
|
||||||
// Maven never touches Praxis's database directly (ecosystem invariant: no
|
// Maven never touches Praxis's database directly (ecosystem invariant: no
|
||||||
@@ -176,6 +209,252 @@ type Config struct {
|
|||||||
// discovers and executes capabilities through Hexis for ecosystem actions.
|
// discovers and executes capabilities through Hexis for ecosystem actions.
|
||||||
// nil ⇒ no capability-aware routing.
|
// nil ⇒ no capability-aware routing.
|
||||||
Hexis *HexisConfig `json:"hexis,omitempty"`
|
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"`
|
||||||
|
|
||||||
|
// SmartHome — the Home Assistant instance (Vikunja #256). nil / absent /
|
||||||
|
// disabled ⇒ Maven neither reads the house nor touches it, and no house row
|
||||||
|
// exists in the act allowlist. See SmartHomeConfig.
|
||||||
|
SmartHome *SmartHomeConfig `json:"smarthome,omitempty"`
|
||||||
|
|
||||||
|
// NetScan — the LAN scanner (Vikunja #257). nil / absent / disabled ⇒
|
||||||
|
// Maven never puts a packet on the network looking for hosts. See
|
||||||
|
// NetScanConfig.
|
||||||
|
NetScan *NetScanConfig `json:"netscan,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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SmartHomeConfig — the Home Assistant block (Vikunja #256). Dark until
|
||||||
|
// `"enabled": true`, and even then a discovered device is only ever PROPOSED
|
||||||
|
// into the act allowlist: Kami enables it on /tools, behind step-up, exactly as
|
||||||
|
// he would a shell tool. Finding a switch on the network is not the same as
|
||||||
|
// being allowed to flip it.
|
||||||
|
type SmartHomeConfig struct {
|
||||||
|
// Provider — only "homeassistant" is implemented. MQTT / Zigbee2MQTT are
|
||||||
|
// not: Home Assistant already fronts them, and a broker client is a
|
||||||
|
// dependency this vendored module tree cannot take on tonight.
|
||||||
|
Provider string `json:"provider,omitempty"`
|
||||||
|
|
||||||
|
// URL — the instance base, "http://192.168.1.50:8123".
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
|
||||||
|
// Token — a long-lived access token. Use ${HA_TOKEN} and keep the value in
|
||||||
|
// the gitignored env file, like the telegram credentials.
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
|
||||||
|
// Domains — entity domains to take. Empty ⇒ the controllable domains
|
||||||
|
// (light, switch, fan, cover, lock) plus sensor and binary_sensor for
|
||||||
|
// reads. Narrow it when the instance is large: a tool name the 1.7B
|
||||||
|
// half-remembers is a wrong act.
|
||||||
|
Domains []string `json:"domains,omitempty"`
|
||||||
|
|
||||||
|
// MaxEntities — cap on the proposal catalogue. 0 ⇒ 40.
|
||||||
|
MaxEntities int `json:"max_entities,omitempty"`
|
||||||
|
|
||||||
|
// Timeout — per-call budget. 0 ⇒ 10s.
|
||||||
|
Timeout Duration `json:"timeout,omitempty"`
|
||||||
|
|
||||||
|
// Refresh — how often the entity list is re-read and new devices proposed.
|
||||||
|
// 0 ⇒ 15m. Discovery is idempotent, so this only ever adds rows.
|
||||||
|
Refresh Duration `json:"refresh,omitempty"`
|
||||||
|
|
||||||
|
// Enabled — false (the default) keeps a written block dark, so it can be
|
||||||
|
// reviewed before the house is wired to a voice.
|
||||||
|
Enabled bool `json:"enabled,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SmartHomeClient maps the config block onto the smarthome package's own type.
|
||||||
|
// Returns ok=false when nothing is configured or it is disabled, so validation
|
||||||
|
// and daemon wiring cannot drift on the mapping.
|
||||||
|
func (c *Config) SmartHomeClient() (smarthome.Config, bool) {
|
||||||
|
if c.SmartHome == nil || !c.SmartHome.Enabled {
|
||||||
|
return smarthome.Config{}, false
|
||||||
|
}
|
||||||
|
return smarthome.Config{
|
||||||
|
URL: c.SmartHome.URL,
|
||||||
|
Token: c.SmartHome.Token,
|
||||||
|
Domains: c.SmartHome.Domains,
|
||||||
|
MaxEntities: c.SmartHome.MaxEntities,
|
||||||
|
Timeout: time.Duration(c.SmartHome.Timeout),
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetScanConfig — the LAN scanner block (Vikunja #257). Dark until
|
||||||
|
// `"enabled": true`.
|
||||||
|
//
|
||||||
|
// The important field is Subnets, and it is the ONLY source of a scan target.
|
||||||
|
// Nothing an utterance, a router or a scanned host says can widen or move the
|
||||||
|
// range: internal/netscan.Scanner.Scan takes no target argument at all. Each
|
||||||
|
// subnet must be private and no larger than netscan.MaxPrefixHosts addresses
|
||||||
|
// (a /22), enforced at config load rather than at the first spoken scan.
|
||||||
|
type NetScanConfig struct {
|
||||||
|
// Subnets — CIDRs to scan, "192.168.1.0/24".
|
||||||
|
Subnets []string `json:"subnets,omitempty"`
|
||||||
|
|
||||||
|
// Ports — TCP ports to try per host. Empty ⇒ 22, 80, 443, 8080.
|
||||||
|
Ports []int `json:"ports,omitempty"`
|
||||||
|
|
||||||
|
// Timeout — per-connection budget. 0 ⇒ 400ms.
|
||||||
|
Timeout Duration `json:"timeout,omitempty"`
|
||||||
|
|
||||||
|
// Rate — connections per second across the whole scan. 0 ⇒ 50. Low on
|
||||||
|
// purpose: a scan should look like background traffic, not a portscan.
|
||||||
|
Rate int `json:"rate,omitempty"`
|
||||||
|
|
||||||
|
// MaxHosts — cap on addresses probed per scan. 0 ⇒ 256.
|
||||||
|
MaxHosts int `json:"max_hosts,omitempty"`
|
||||||
|
|
||||||
|
// Enabled — false (the default) keeps a written block dark.
|
||||||
|
Enabled bool `json:"enabled,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetScanner maps the config block onto the netscan package's own type.
|
||||||
|
// ok=false when absent or disabled, so validation and daemon wiring cannot
|
||||||
|
// drift on the mapping.
|
||||||
|
func (c *Config) NetScanner() (netscan.Config, bool) {
|
||||||
|
if c.NetScan == nil || !c.NetScan.Enabled {
|
||||||
|
return netscan.Config{}, false
|
||||||
|
}
|
||||||
|
return netscan.Config{
|
||||||
|
Subnets: c.NetScan.Subnets,
|
||||||
|
Ports: c.NetScan.Ports,
|
||||||
|
Timeout: time.Duration(c.NetScan.Timeout),
|
||||||
|
Rate: c.NetScan.Rate,
|
||||||
|
MaxHosts: c.NetScan.MaxHosts,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
// PraxisConfig — maven's connection to the Praxis attention service.
|
||||||
@@ -345,6 +624,171 @@ type VoiceConfig struct {
|
|||||||
ToolTimeout Duration `json:"tool_timeout,omitempty"`
|
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.
|
// WeatherConfig configures the weather provider for voice queries.
|
||||||
type WeatherConfig struct {
|
type WeatherConfig struct {
|
||||||
Provider string `json:"provider,omitempty"` // "open-meteo" or "" → stub
|
Provider string `json:"provider,omitempty"` // "open-meteo" or "" → stub
|
||||||
@@ -454,6 +898,62 @@ type FeedSourceConfig struct {
|
|||||||
Exclude []string `json:"exclude,omitempty"` // drop items containing any of these
|
Exclude []string `json:"exclude,omitempty"` // drop items containing any of these
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CrawlConfig — the web crawler (Vikunja #259, docs/plans/14-web-crawler.md).
|
||||||
|
//
|
||||||
|
// Absent ⇒ off, and off means no page is ever fetched. Present with neither
|
||||||
|
// `on_demand` nor a `watches` entry is also off: there would be nothing to do.
|
||||||
|
//
|
||||||
|
// The crawler is the LAST place an answer is looked for, behind the model, his
|
||||||
|
// own memory and the local Kiwix ZIMs. That ordering lives in the query-source
|
||||||
|
// chain (cmd/mavend/actions_query.go), not here, but it is the reason this block
|
||||||
|
// is small: it is a fallback, not a search engine.
|
||||||
|
//
|
||||||
|
// Only the URL leaves the box. His notes, facts, persona block and history are
|
||||||
|
// never part of a request — the crawler package cannot even read the store.
|
||||||
|
type CrawlConfig struct {
|
||||||
|
// OnDemand — may he ask her to read a page he names out loud
|
||||||
|
// ("посмотри https://… — что там пишут?"). false ⇒ the on-demand answer
|
||||||
|
// source stays off and only the watches below run.
|
||||||
|
OnDemand bool `json:"on_demand,omitempty"`
|
||||||
|
|
||||||
|
// Watches — pages re-read on a schedule. A page whose text changed is
|
||||||
|
// written as a note (source "crawl:<name>"); nothing is announced.
|
||||||
|
Watches []CrawlWatchConfig `json:"watches,omitempty"`
|
||||||
|
|
||||||
|
// Interval — default watch cadence. 0 ⇒ crawl.DefaultWatchInterval (6h).
|
||||||
|
Interval Duration `json:"interval,omitempty"`
|
||||||
|
|
||||||
|
// AllowHosts — when set, the ONLY hosts the crawler may reach (subdomains
|
||||||
|
// included). Watched pages' own hosts are added automatically. Setting this
|
||||||
|
// is how "she may read the arch wiki and nothing else" is expressed.
|
||||||
|
AllowHosts []string `json:"allow_hosts,omitempty"`
|
||||||
|
|
||||||
|
// DenyHosts — never reachable, checked first. Private addresses do not need
|
||||||
|
// to be listed: they are refused unconditionally (see internal/webfetch).
|
||||||
|
DenyHosts []string `json:"deny_hosts,omitempty"`
|
||||||
|
|
||||||
|
// UserAgent — sent on every request AND matched against robots.txt groups.
|
||||||
|
// Empty ⇒ webfetch.DefaultUserAgent.
|
||||||
|
UserAgent string `json:"user_agent,omitempty"`
|
||||||
|
|
||||||
|
// Timeout — per-request budget. 0 ⇒ webfetch.DefaultTimeout.
|
||||||
|
Timeout Duration `json:"timeout,omitempty"`
|
||||||
|
|
||||||
|
// MaxBytes — response size cap. 0 ⇒ webfetch.DefaultMaxBytes (2 MiB).
|
||||||
|
MaxBytes int64 `json:"max_bytes,omitempty"`
|
||||||
|
|
||||||
|
// MaxRunes — how much extracted text is kept. 0 ⇒ crawl.DefaultMaxRunes
|
||||||
|
// (4000), which is what fits a 4096-token context alongside a prompt.
|
||||||
|
MaxRunes int `json:"max_runes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CrawlWatchConfig — one page kept an eye on.
|
||||||
|
type CrawlWatchConfig struct {
|
||||||
|
Name string `json:"name"` // note source is "crawl:<name>"
|
||||||
|
URL string `json:"url"`
|
||||||
|
Interval Duration `json:"interval,omitempty"` // 0 ⇒ CrawlConfig.Interval
|
||||||
|
}
|
||||||
|
|
||||||
// MemoryEvalConfig — the background memory-evaluation loop (Vikunja #248).
|
// MemoryEvalConfig — the background memory-evaluation loop (Vikunja #248).
|
||||||
// Absent ⇒ off, like every other capability that costs something the owner did
|
// Absent ⇒ off, like every other capability that costs something the owner did
|
||||||
// not ask for. Each evaluation is a full LLM round-trip on the one resident
|
// not ask for. Each evaluation is a full LLM round-trip on the one resident
|
||||||
@@ -496,6 +996,11 @@ type EmailConfig struct {
|
|||||||
// DefaultEmailTimeout — extraction budget per message.
|
// DefaultEmailTimeout — extraction budget per message.
|
||||||
const DefaultEmailTimeout = 2 * time.Minute
|
const DefaultEmailTimeout = 2 * time.Minute
|
||||||
|
|
||||||
|
// DefaultSmartHomeRefresh — how often the house is re-enumerated for new
|
||||||
|
// devices. Slow on purpose: discovery only adds proposals, and a flat does not
|
||||||
|
// grow a new lamp every minute.
|
||||||
|
const DefaultSmartHomeRefresh = 15 * time.Minute
|
||||||
|
|
||||||
// PhraserConfig — the LLM-backed phraser seam. The daemon spawns llama-server
|
// PhraserConfig — the LLM-backed phraser seam. The daemon spawns llama-server
|
||||||
// as a managed subprocess and sends chat-completion requests to phrase nudge
|
// as a managed subprocess and sends chat-completion requests to phrase nudge
|
||||||
// and reminder messages. nil ⇒ the template-based Stub is used instead.
|
// and reminder messages. nil ⇒ the template-based Stub is used instead.
|
||||||
@@ -519,6 +1024,21 @@ type PhraserConfig struct {
|
|||||||
// persona and invented units). Chat, query and reminder phrasing always go
|
// persona and invented units). Chat, query and reminder phrasing always go
|
||||||
// through the model regardless. See phraser.Config.LLMNudges.
|
// through the model regardless. See phraser.Config.LLMNudges.
|
||||||
LLMNudges bool `json:"llm_nudges,omitempty"`
|
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
|
// EmbedderConfig — paths for the ONNX multilingual embedder. The daemon
|
||||||
@@ -579,7 +1099,11 @@ const (
|
|||||||
DefaultRepeatInterval = 5 * time.Minute
|
DefaultRepeatInterval = 5 * time.Minute
|
||||||
DefaultAutotuneInterval = 10 * time.Minute
|
DefaultAutotuneInterval = 10 * time.Minute
|
||||||
DefaultRouterThreshold = 0.55
|
DefaultRouterThreshold = 0.55
|
||||||
DefaultQueryMinScore = 0.55
|
// DefaultIntakeJournal — entries kept in the unified intake journal
|
||||||
|
// (Vikunja #283). A busy day is a few hundred intake writes, so this is
|
||||||
|
// roughly "today and yesterday" at a few hundred KB of memory.
|
||||||
|
DefaultIntakeJournal = 512
|
||||||
|
DefaultQueryMinScore = 0.55
|
||||||
// Read off the margin sweep in internal/memory/recalleval on the e5
|
// Read off the margin sweep in internal/memory/recalleval on the e5
|
||||||
// embedder: 0.008 answers 68% of real questions (down from 72%) and cuts
|
// embedder: 0.008 answers 68% of real questions (down from 72%) and cuts
|
||||||
// false recall from 5/5 to 1/5. Every larger delta costs real recall
|
// false recall from 5/5 to 1/5. Every larger delta costs real recall
|
||||||
@@ -629,6 +1153,9 @@ func Load(path string) (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) applyDefaults() {
|
func (c *Config) applyDefaults() {
|
||||||
|
if c.IntakeJournal == 0 {
|
||||||
|
c.IntakeJournal = DefaultIntakeJournal
|
||||||
|
}
|
||||||
if c.TickInterval == 0 {
|
if c.TickInterval == 0 {
|
||||||
c.TickInterval = Duration(DefaultTickInterval)
|
c.TickInterval = Duration(DefaultTickInterval)
|
||||||
}
|
}
|
||||||
@@ -697,6 +1224,32 @@ func (c *Config) applyDefaults() {
|
|||||||
c.Feeds = nil
|
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 house: a block that is not enabled is the same as no
|
||||||
|
// block at all, so "off" stays in one place.
|
||||||
|
if c.SmartHome != nil && !c.SmartHome.Enabled {
|
||||||
|
c.SmartHome = nil
|
||||||
|
}
|
||||||
|
if c.SmartHome != nil && c.SmartHome.Refresh <= 0 {
|
||||||
|
c.SmartHome.Refresh = Duration(DefaultSmartHomeRefresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same rule for the scanner.
|
||||||
|
if c.NetScan != nil && !c.NetScan.Enabled {
|
||||||
|
c.NetScan = 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 {
|
||||||
|
c.Crawl = nil
|
||||||
|
}
|
||||||
|
|
||||||
if c.Voice != nil {
|
if c.Voice != nil {
|
||||||
if c.Voice.RouterThreshold <= 0 {
|
if c.Voice.RouterThreshold <= 0 {
|
||||||
c.Voice.RouterThreshold = DefaultRouterThreshold
|
c.Voice.RouterThreshold = DefaultRouterThreshold
|
||||||
@@ -754,6 +1307,22 @@ func (c *Config) validate() error {
|
|||||||
if c.Phraser.ModelPath == "" {
|
if c.Phraser.ModelPath == "" {
|
||||||
return errors.New("phraser.model_path is required")
|
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 != nil && c.Voice.Enabled {
|
||||||
if c.Voice.Bind == "" {
|
if c.Voice.Bind == "" {
|
||||||
@@ -779,6 +1348,29 @@ func (c *Config) validate() error {
|
|||||||
return fmt.Errorf("routine %q: bad cron %q: %w", r.Name, r.Cron, err)
|
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
|
||||||
|
}
|
||||||
|
// Same for the house: a missing token or a bare hostname fails at startup,
|
||||||
|
// not at the first "выключи свет".
|
||||||
|
if hc, ok := c.SmartHomeClient(); ok {
|
||||||
|
if p := c.SmartHome.Provider; p != "" && p != "homeassistant" {
|
||||||
|
return fmt.Errorf("smarthome: provider %q: only \"homeassistant\" is implemented", p)
|
||||||
|
}
|
||||||
|
if err := smarthome.Validate(hc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A scanner pointed at the public internet, or at a /8, fails here rather
|
||||||
|
// than after the packets have already left.
|
||||||
|
if nc, ok := c.NetScanner(); ok {
|
||||||
|
if err := netscan.Validate(nc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(c.MorningRoutines) > 0 {
|
if len(c.MorningRoutines) > 0 {
|
||||||
if err := morning.Validate(morningRoutinesFromConfig(c.MorningRoutines)); err != nil {
|
if err := morning.Validate(morningRoutinesFromConfig(c.MorningRoutines)); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -293,3 +293,76 @@ func TestPatternProposalNotifyDefaultsOff(t *testing.T) {
|
|||||||
t.Errorf("cooldown = %v, want 6h", c.PatternProposals.Cooldown)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// Package crawl reads a web page: fetch, robots check, HTML to text.
|
||||||
|
//
|
||||||
|
// It is the LAST place Maven looks for an answer, and that ordering is the whole
|
||||||
|
// design. "Never phones home" is deprecated, but what replaced it puts local
|
||||||
|
// sources first: the resident model, then his own memory, then the Kiwix ZIMs on
|
||||||
|
// the box (internal/kiwix), and only then the network. A local read costs
|
||||||
|
// nothing and leaks nothing; a fetch costs a round-trip and puts a URL in
|
||||||
|
// someone's access log. So this package exists to be the fallback, not the
|
||||||
|
// front door — see the querySources chain in cmd/mavend/actions_query.go for
|
||||||
|
// where it actually sits.
|
||||||
|
//
|
||||||
|
// What never leaves the box: his notes, his facts, the persona block, the
|
||||||
|
// conversation history. Only the URL is requested and, for the on-demand path,
|
||||||
|
// only because he said it out loud. Nothing here reads the store.
|
||||||
|
//
|
||||||
|
// The limits are not in this package — they are in internal/webfetch, which is
|
||||||
|
// the only way anything here touches a socket: http(s) only, host allow/deny,
|
||||||
|
// private-address refusal, size cap, redirect cap, per-host rate limit. What
|
||||||
|
// this package adds is politeness (robots.txt) and dedup.
|
||||||
|
package crawl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Errors callers distinguish.
|
||||||
|
var (
|
||||||
|
ErrRobots = errors.New("crawl: robots.txt disallows this path")
|
||||||
|
ErrNotHTML = errors.New("crawl: response is not html or text")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fetcher is the guarded HTTP door (internal/webfetch adapted by the daemon). An
|
||||||
|
// interface so this package constructs no http.Client of its own and can be
|
||||||
|
// tested without a network.
|
||||||
|
type Fetcher interface {
|
||||||
|
Get(ctx context.Context, url string) (*Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response is the minimum a crawl needs from a fetch.
|
||||||
|
type Response struct {
|
||||||
|
URL string
|
||||||
|
ContentType string
|
||||||
|
Body []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config — crawler knobs.
|
||||||
|
type Config struct {
|
||||||
|
// UserAgent is the name matched against robots.txt groups. It must be the
|
||||||
|
// same string the fetcher sends, or Maven would be claiming one identity
|
||||||
|
// and obeying the rules for another.
|
||||||
|
UserAgent string
|
||||||
|
// MaxRunes caps extracted text. 0 ⇒ DefaultMaxRunes.
|
||||||
|
MaxRunes int
|
||||||
|
// RobotsTTL — how long a parsed robots.txt is trusted. 0 ⇒ 1h.
|
||||||
|
RobotsTTL time.Duration
|
||||||
|
// Now is injectable for tests. nil ⇒ time.Now.
|
||||||
|
Now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crawler fetches and extracts pages. Safe for concurrent use.
|
||||||
|
type Crawler struct {
|
||||||
|
fetch Fetcher
|
||||||
|
cfg Config
|
||||||
|
robots *robotsCache
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a crawler. Returns nil when there is no fetcher, which is how the
|
||||||
|
// daemon expresses "crawling is off unless configured".
|
||||||
|
func New(fetch Fetcher, cfg Config) *Crawler {
|
||||||
|
if fetch == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cfg.UserAgent == "" {
|
||||||
|
cfg.UserAgent = "Maven"
|
||||||
|
}
|
||||||
|
if cfg.MaxRunes <= 0 {
|
||||||
|
cfg.MaxRunes = DefaultMaxRunes
|
||||||
|
}
|
||||||
|
if cfg.RobotsTTL <= 0 {
|
||||||
|
cfg.RobotsTTL = time.Hour
|
||||||
|
}
|
||||||
|
if cfg.Now == nil {
|
||||||
|
cfg.Now = time.Now
|
||||||
|
}
|
||||||
|
return &Crawler{fetch: fetch, cfg: cfg, robots: newRobotsCache(cfg.RobotsTTL)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page fetches rawURL and returns its text. It checks robots.txt first and
|
||||||
|
// refuses a disallowed path with ErrRobots — there is no override.
|
||||||
|
func (c *Crawler) Page(ctx context.Context, rawURL string) (Page, error) {
|
||||||
|
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||||
|
if err != nil {
|
||||||
|
return Page{}, fmt.Errorf("crawl: bad url %q: %w", rawURL, err)
|
||||||
|
}
|
||||||
|
ok, err := c.allowed(ctx, u)
|
||||||
|
if err != nil {
|
||||||
|
return Page{}, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return Page{}, fmt.Errorf("%w: %s", ErrRobots, u.Path)
|
||||||
|
}
|
||||||
|
resp, err := c.fetch.Get(ctx, u.String())
|
||||||
|
if err != nil {
|
||||||
|
return Page{}, err
|
||||||
|
}
|
||||||
|
// A PDF or an image is bytes Maven cannot read; saying so beats storing
|
||||||
|
// binary garbage as a "note".
|
||||||
|
ct := strings.ToLower(resp.ContentType)
|
||||||
|
if ct != "" && !strings.Contains(ct, "html") && !strings.Contains(ct, "text/") &&
|
||||||
|
!strings.Contains(ct, "xml") && !strings.Contains(ct, "json") {
|
||||||
|
return Page{}, fmt.Errorf("%w: %s", ErrNotHTML, resp.ContentType)
|
||||||
|
}
|
||||||
|
return Extract(resp.URL, resp.Body, c.cfg.MaxRunes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowed consults robots.txt for u's host, reading it at most once per TTL.
|
||||||
|
//
|
||||||
|
// A robots.txt that cannot be fetched (404, a timeout, a blocked host) means
|
||||||
|
// allow, per the standard. The one thing that is NOT fail-open is an explicit
|
||||||
|
// Disallow.
|
||||||
|
func (c *Crawler) allowed(ctx context.Context, u *url.URL) (bool, error) {
|
||||||
|
host := u.Host
|
||||||
|
now := c.cfg.Now()
|
||||||
|
rules, ok := c.robots.get(host, now)
|
||||||
|
if !ok {
|
||||||
|
robotsURL := u.Scheme + "://" + host + "/robots.txt"
|
||||||
|
resp, err := c.fetch.Get(ctx, robotsURL)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
// Note what is NOT swallowed: a refusal from the guarded fetcher.
|
||||||
|
// If webfetch says this host is denied or private, the page fetch
|
||||||
|
// would fail the same way, and reporting the real reason beats
|
||||||
|
// reporting a robots verdict we never got.
|
||||||
|
if isFatalFetchError(err) {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
rules = Rules{}
|
||||||
|
default:
|
||||||
|
rules = ParseRobots(string(resp.Body), c.cfg.UserAgent)
|
||||||
|
}
|
||||||
|
c.robots.put(host, rules, now)
|
||||||
|
}
|
||||||
|
path := u.EscapedPath()
|
||||||
|
if u.RawQuery != "" {
|
||||||
|
path += "?" + u.RawQuery
|
||||||
|
}
|
||||||
|
return rules.Allowed(path), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isFatalFetchError — a fetch failure that means "this host is off limits"
|
||||||
|
// rather than "there is no robots.txt here". The sentinel set is webfetch's, but
|
||||||
|
// this package must not import it (the interface exists precisely so it does
|
||||||
|
// not), so the check is on the message. Ugly and honest: the alternative is a
|
||||||
|
// dependency inversion for two strings.
|
||||||
|
func isFatalFetchError(err error) bool {
|
||||||
|
s := err.Error()
|
||||||
|
return strings.Contains(s, "not allowed") || strings.Contains(s, "private address") ||
|
||||||
|
strings.Contains(s, "only http and https")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash is the dedup key for a crawl result: the sha256 of the extracted text,
|
||||||
|
// hex, first 16 chars. Text and not raw HTML, because a page whose only change
|
||||||
|
// is a rotating ad slot or a CSRF token has not changed.
|
||||||
|
func Hash(text string) string {
|
||||||
|
sum := sha256.Sum256([]byte(strings.TrimSpace(text)))
|
||||||
|
return hex.EncodeToString(sum[:])[:16]
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package crawl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeFetcher serves canned pages by URL and counts requests, so a test can
|
||||||
|
// assert that robots.txt was read once and that a refusal never reached the page.
|
||||||
|
type fakeFetcher struct {
|
||||||
|
pages map[string]Response
|
||||||
|
err error
|
||||||
|
calls []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeFetcher) Get(_ context.Context, u string) (*Response, error) {
|
||||||
|
f.calls = append(f.calls, u)
|
||||||
|
if f.err != nil {
|
||||||
|
return nil, f.err
|
||||||
|
}
|
||||||
|
r, ok := f.pages[u]
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("http 404")
|
||||||
|
}
|
||||||
|
if r.URL == "" {
|
||||||
|
r.URL = u
|
||||||
|
}
|
||||||
|
if r.ContentType == "" {
|
||||||
|
r.ContentType = "text/html; charset=utf-8"
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlPage = `<html><head><title>Почему небо синее</title>
|
||||||
|
<style>body{color:red}</style><script>track()</script></head>
|
||||||
|
<body><nav>меню</nav><h1>Небо</h1>
|
||||||
|
<p>Свет рассеивается на молекулах воздуха.</p>
|
||||||
|
<p>Короткие волны рассеиваются сильнее.</p>
|
||||||
|
<footer>© 2026</footer></body></html>`
|
||||||
|
|
||||||
|
func newTestCrawler(f *fakeFetcher) *Crawler {
|
||||||
|
return New(f, Config{UserAgent: "Maven/1.0", Now: func() time.Time { return time.Unix(0, 0) }})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPageExtractsText(t *testing.T) {
|
||||||
|
f := &fakeFetcher{pages: map[string]Response{
|
||||||
|
"https://example.org/sky": {Body: []byte(htmlPage)},
|
||||||
|
}}
|
||||||
|
page, err := newTestCrawler(f).Page(context.Background(), "https://example.org/sky")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if page.Title != "Почему небо синее" {
|
||||||
|
t.Errorf("title = %q", page.Title)
|
||||||
|
}
|
||||||
|
if !strings.Contains(page.Text, "Свет рассеивается") {
|
||||||
|
t.Errorf("body text missing: %q", page.Text)
|
||||||
|
}
|
||||||
|
for _, junk := range []string{"track()", "color:red", "меню", "© 2026"} {
|
||||||
|
if strings.Contains(page.Text, junk) {
|
||||||
|
t.Errorf("%q survived extraction: %q", junk, page.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRobotsIsCheckedAndObeyed(t *testing.T) {
|
||||||
|
f := &fakeFetcher{pages: map[string]Response{
|
||||||
|
"https://example.org/robots.txt": {Body: []byte("User-agent: *\nDisallow: /secret\n"), ContentType: "text/plain"},
|
||||||
|
"https://example.org/secret/x": {Body: []byte(htmlPage)},
|
||||||
|
"https://example.org/open": {Body: []byte(htmlPage)},
|
||||||
|
}}
|
||||||
|
c := newTestCrawler(f)
|
||||||
|
if _, err := c.Page(context.Background(), "https://example.org/secret/x"); !errors.Is(err, ErrRobots) {
|
||||||
|
t.Fatalf("error = %v, want ErrRobots", err)
|
||||||
|
}
|
||||||
|
for _, u := range f.calls {
|
||||||
|
if strings.Contains(u, "/secret") {
|
||||||
|
t.Fatal("the disallowed page was fetched anyway")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := c.Page(context.Background(), "https://example.org/open"); err != nil {
|
||||||
|
t.Fatalf("allowed page: %v", err)
|
||||||
|
}
|
||||||
|
// robots.txt was read once for the host, not once per page.
|
||||||
|
robotsReads := 0
|
||||||
|
for _, u := range f.calls {
|
||||||
|
if strings.HasSuffix(u, "/robots.txt") {
|
||||||
|
robotsReads++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if robotsReads != 1 {
|
||||||
|
t.Fatalf("robots.txt read %d times, want 1", robotsReads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No robots.txt means allow — that is the standard, and the alternative makes
|
||||||
|
// most of the web unreadable.
|
||||||
|
func TestMissingRobotsAllows(t *testing.T) {
|
||||||
|
f := &fakeFetcher{pages: map[string]Response{
|
||||||
|
"https://example.org/page": {Body: []byte(htmlPage)},
|
||||||
|
}}
|
||||||
|
if _, err := newTestCrawler(f).Page(context.Background(), "https://example.org/page"); err != nil {
|
||||||
|
t.Fatalf("err = %v, want the page", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refusal from the guarded fetcher must surface as itself, not be laundered
|
||||||
|
// into "no robots.txt, go ahead".
|
||||||
|
func TestFetcherRefusalIsNotSwallowed(t *testing.T) {
|
||||||
|
f := &fakeFetcher{err: errors.New("webfetch: refusing to connect to a private address: 127.0.0.1")}
|
||||||
|
_, err := newTestCrawler(f).Page(context.Background(), "http://127.0.0.1:9100/mcp")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "private address") {
|
||||||
|
t.Fatalf("error = %v, want the fetcher's refusal", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNonTextIsRefused(t *testing.T) {
|
||||||
|
f := &fakeFetcher{pages: map[string]Response{
|
||||||
|
"https://example.org/f.pdf": {Body: []byte("%PDF-1.7"), ContentType: "application/pdf"},
|
||||||
|
}}
|
||||||
|
if _, err := newTestCrawler(f).Page(context.Background(), "https://example.org/f.pdf"); !errors.Is(err, ErrNotHTML) {
|
||||||
|
t.Fatalf("error = %v, want ErrNotHTML", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaxRunesCapsText(t *testing.T) {
|
||||||
|
long := "<html><body><p>" + strings.Repeat("привет ", 2000) + "</p></body></html>"
|
||||||
|
f := &fakeFetcher{pages: map[string]Response{"https://example.org/l": {Body: []byte(long)}}}
|
||||||
|
c := New(f, Config{MaxRunes: 50})
|
||||||
|
page, err := c.Page(context.Background(), "https://example.org/l")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := len([]rune(page.Text)); n > 51 {
|
||||||
|
t.Fatalf("text = %d runes, want the 50-rune cap", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewWithoutFetcherIsNil(t *testing.T) {
|
||||||
|
if New(nil, Config{}) != nil {
|
||||||
|
t.Fatal("a crawler with no fetcher must be nil — crawling is off unless configured")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashIgnoresNothingButText(t *testing.T) {
|
||||||
|
if Hash("a") == Hash("b") {
|
||||||
|
t.Fatal("different text hashed the same")
|
||||||
|
}
|
||||||
|
if Hash(" same \n") != Hash("same") {
|
||||||
|
t.Fatal("surrounding whitespace changed the hash")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package crawl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTML → text, with a regexp and no tokenizer.
|
||||||
|
//
|
||||||
|
// golang.org/x/net/html is not vendored and the network is not assumed, so this
|
||||||
|
// is stdlib. That is less of a compromise than it sounds: the unit of context
|
||||||
|
// here is a few hundred words for a 4096-token model to read, exactly like the
|
||||||
|
// Kiwix snippet, so what matters is dropping script/style/nav noise and keeping
|
||||||
|
// paragraph boundaries. A DOM would buy correctness on malformed markup that is
|
||||||
|
// then thrown away by truncation anyway.
|
||||||
|
//
|
||||||
|
// What this deliberately does NOT do: run JavaScript, follow links, or extract
|
||||||
|
// structured fields with CSS selectors or an LLM prompt. The plan's step 2 asked
|
||||||
|
// for the last of those; see docs/plans/14-web-crawler.md for why it was left
|
||||||
|
// out for now.
|
||||||
|
|
||||||
|
var (
|
||||||
|
// RE2 has no backreferences, so each tag pair is spelled out rather than
|
||||||
|
// captured and matched against itself.
|
||||||
|
dropRE = regexp.MustCompile(pairsRE("script", "style", "noscript", "svg", "head", "nav", "footer", "form"))
|
||||||
|
titleRE = regexp.MustCompile(`(?is)<title\b[^>]*>(.*?)</title>`)
|
||||||
|
h1RE = regexp.MustCompile(`(?is)<h1\b[^>]*>(.*?)</h1>`)
|
||||||
|
// Block-level tags become newlines so paragraphs survive as paragraphs.
|
||||||
|
blockRE = regexp.MustCompile(`(?is)</?(p|div|br|li|tr|h[1-6]|section|article|blockquote|pre)\b[^>]*>`)
|
||||||
|
tagRE = regexp.MustCompile(`(?s)<[^>]*>`)
|
||||||
|
commentRE = regexp.MustCompile(`(?s)<!--.*?-->`)
|
||||||
|
spaceRE = regexp.MustCompile(`[ \t\f\v]+`)
|
||||||
|
blankRE = regexp.MustCompile(`\n{2,}`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// pairsRE builds `(?is)<tag …>…</tag>|…` for the given tags.
|
||||||
|
func pairsRE(tags ...string) string {
|
||||||
|
parts := make([]string, 0, len(tags))
|
||||||
|
for _, t := range tags {
|
||||||
|
parts = append(parts, `<`+t+`\b[^>]*>.*?</`+t+`>`)
|
||||||
|
}
|
||||||
|
return `(?is)` + strings.Join(parts, "|")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page is an extracted page.
|
||||||
|
type Page struct {
|
||||||
|
URL string
|
||||||
|
Title string
|
||||||
|
Text string // plain text, paragraphs separated by single newlines
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract turns a fetched HTML document into a Page. maxRunes caps the text (0 ⇒
|
||||||
|
// DefaultMaxRunes); the cap is on runes, not bytes, because a Russian page cut
|
||||||
|
// at a byte boundary ends in half a letter.
|
||||||
|
func Extract(url string, body []byte, maxRunes int) Page {
|
||||||
|
if maxRunes <= 0 {
|
||||||
|
maxRunes = DefaultMaxRunes
|
||||||
|
}
|
||||||
|
s := string(body)
|
||||||
|
s = commentRE.ReplaceAllString(s, " ")
|
||||||
|
|
||||||
|
title := firstGroup(titleRE, s)
|
||||||
|
if title == "" {
|
||||||
|
title = firstGroup(h1RE, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
s = dropRE.ReplaceAllString(s, "\n")
|
||||||
|
s = blockRE.ReplaceAllString(s, "\n")
|
||||||
|
s = tagRE.ReplaceAllString(s, " ")
|
||||||
|
s = html.UnescapeString(s)
|
||||||
|
s = spaceRE.ReplaceAllString(s, " ")
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
for _, l := range strings.Split(s, "\n") {
|
||||||
|
if l = strings.TrimSpace(l); l != "" {
|
||||||
|
lines = append(lines, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text := blankRE.ReplaceAllString(strings.Join(lines, "\n"), "\n")
|
||||||
|
|
||||||
|
return Page{URL: url, Title: title, Text: TrimRunes(text, maxRunes)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultMaxRunes — how much of a page is kept. ~4000 runes is a long answer's
|
||||||
|
// worth of context and still leaves room in a 4096-token window for the prompt
|
||||||
|
// and the reply.
|
||||||
|
const DefaultMaxRunes = 4000
|
||||||
|
|
||||||
|
func firstGroup(re *regexp.Regexp, s string) string {
|
||||||
|
m := re.FindStringSubmatch(s)
|
||||||
|
if len(m) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
t := tagRE.ReplaceAllString(m[1], " ")
|
||||||
|
return strings.TrimSpace(strings.Join(strings.Fields(html.UnescapeString(t)), " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrimRunes cuts s to at most max runes, on a rune boundary.
|
||||||
|
func TrimRunes(s string, max int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(r[:max])) + "…"
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package crawl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// robots.txt, parsed the small way: no wildcards beyond the two the standard
|
||||||
|
// actually defines (`*` inside a path and `$` at the end), no sitemaps, no
|
||||||
|
// crawl-delay-per-agent gymnastics. A personal assistant reading a handful of
|
||||||
|
// pages does not need a spec-complete implementation; it needs to not be rude,
|
||||||
|
// and to be auditable in one sitting.
|
||||||
|
//
|
||||||
|
// Two rules worth stating because they are choices, not accidents:
|
||||||
|
//
|
||||||
|
// - a missing or unreadable robots.txt means ALLOW. That is what the standard
|
||||||
|
// says (404 ⇒ unrestricted), and the alternative would make a site that
|
||||||
|
// simply has no robots.txt unreadable;
|
||||||
|
// - an explicit Disallow means REFUSE, and Maven does not offer an override.
|
||||||
|
// There is no "but he asked me to" flag: the page is not read.
|
||||||
|
|
||||||
|
// Rules is a parsed robots.txt for one user-agent.
|
||||||
|
type Rules struct {
|
||||||
|
allow []string
|
||||||
|
disallow []string
|
||||||
|
// Delay is Crawl-delay in seconds when the group named one, 0 otherwise.
|
||||||
|
// The fetcher's own per-host rate limit is the floor; this can only make
|
||||||
|
// Maven slower, never faster.
|
||||||
|
Delay time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseRobots reads robots.txt and returns the rules that apply to agent.
|
||||||
|
//
|
||||||
|
// Group selection follows the standard: the most specific matching group wins,
|
||||||
|
// which here means an exact user-agent match beats `*`. Lines that are neither
|
||||||
|
// are ignored rather than guessed at.
|
||||||
|
func ParseRobots(body string, agent string) Rules {
|
||||||
|
agent = strings.ToLower(agent)
|
||||||
|
|
||||||
|
type group struct {
|
||||||
|
agents []string
|
||||||
|
allow []string
|
||||||
|
disallow []string
|
||||||
|
delay time.Duration
|
||||||
|
}
|
||||||
|
var groups []group
|
||||||
|
var cur *group
|
||||||
|
// startNew tracks whether the next User-agent line opens a new group or
|
||||||
|
// joins the current one: consecutive User-agent lines share their rules.
|
||||||
|
startNew := true
|
||||||
|
|
||||||
|
for _, raw := range strings.Split(body, "\n") {
|
||||||
|
line := raw
|
||||||
|
if i := strings.IndexByte(line, '#'); i >= 0 {
|
||||||
|
line = line[:i]
|
||||||
|
}
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, val, ok := strings.Cut(line, ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key = strings.ToLower(strings.TrimSpace(key))
|
||||||
|
val = strings.TrimSpace(val)
|
||||||
|
|
||||||
|
switch key {
|
||||||
|
case "user-agent":
|
||||||
|
if startNew || cur == nil {
|
||||||
|
groups = append(groups, group{})
|
||||||
|
cur = &groups[len(groups)-1]
|
||||||
|
startNew = false
|
||||||
|
}
|
||||||
|
cur.agents = append(cur.agents, strings.ToLower(val))
|
||||||
|
case "disallow":
|
||||||
|
if cur == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
startNew = true
|
||||||
|
// "Disallow:" with an empty value allows everything, and is not a
|
||||||
|
// path rule at all.
|
||||||
|
if val != "" {
|
||||||
|
cur.disallow = append(cur.disallow, val)
|
||||||
|
}
|
||||||
|
case "allow":
|
||||||
|
if cur == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
startNew = true
|
||||||
|
if val != "" {
|
||||||
|
cur.allow = append(cur.allow, val)
|
||||||
|
}
|
||||||
|
case "crawl-delay":
|
||||||
|
if cur == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
startNew = true
|
||||||
|
if d, err := time.ParseDuration(val + "s"); err == nil && d > 0 {
|
||||||
|
cur.delay = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var star, exact *group
|
||||||
|
for i := range groups {
|
||||||
|
for _, a := range groups[i].agents {
|
||||||
|
if a == "*" && star == nil {
|
||||||
|
star = &groups[i]
|
||||||
|
}
|
||||||
|
// A robots.txt names "maven", we send "Maven/1.0 (…)": match on
|
||||||
|
// prefix, which is how every crawler reads this field.
|
||||||
|
if a != "*" && a != "" && strings.HasPrefix(agent, a) {
|
||||||
|
exact = &groups[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g := exact
|
||||||
|
if g == nil {
|
||||||
|
g = star
|
||||||
|
}
|
||||||
|
if g == nil {
|
||||||
|
return Rules{}
|
||||||
|
}
|
||||||
|
return Rules{allow: g.allow, disallow: g.disallow, Delay: g.delay}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowed reports whether path may be fetched. Longest matching rule wins, and
|
||||||
|
// Allow beats Disallow at equal length — the standard's tie-break, and the one
|
||||||
|
// that makes "Disallow: /" plus "Allow: /public" mean what it looks like.
|
||||||
|
func (r Rules) Allowed(path string) bool {
|
||||||
|
if path == "" {
|
||||||
|
path = "/"
|
||||||
|
}
|
||||||
|
best, allowed := -1, true
|
||||||
|
for _, p := range r.disallow {
|
||||||
|
if n, ok := matchPath(p, path); ok && n > best {
|
||||||
|
best, allowed = n, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range r.allow {
|
||||||
|
if n, ok := matchPath(p, path); ok && n >= best {
|
||||||
|
best, allowed = n, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchPath applies a robots path pattern and returns the pattern's length as
|
||||||
|
// the specificity score. `*` matches any run of characters, `$` anchors the end.
|
||||||
|
// A pattern is a PREFIX match otherwise, which is what "Disallow: /admin" means.
|
||||||
|
func matchPath(pattern, path string) (int, bool) {
|
||||||
|
score := len(pattern)
|
||||||
|
re, err := robotsRegexp(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return score, re.MatchString(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// robotsRegexp turns a robots path pattern into an anchored-at-the-start
|
||||||
|
// regexp. Everything but `*` and a trailing `$` is a literal, so the pattern is
|
||||||
|
// quoted first and the two metacharacters are put back afterwards.
|
||||||
|
func robotsRegexp(pattern string) (*regexp.Regexp, error) {
|
||||||
|
end := ""
|
||||||
|
if strings.HasSuffix(pattern, "$") {
|
||||||
|
pattern = strings.TrimSuffix(pattern, "$")
|
||||||
|
end = "$"
|
||||||
|
}
|
||||||
|
parts := strings.Split(pattern, "*")
|
||||||
|
for i, p := range parts {
|
||||||
|
parts[i] = regexp.QuoteMeta(p)
|
||||||
|
}
|
||||||
|
return regexp.Compile("^" + strings.Join(parts, ".*") + end)
|
||||||
|
}
|
||||||
|
|
||||||
|
// robotsCache holds parsed rules per host so a crawl of ten pages on one site
|
||||||
|
// reads robots.txt once. TTL because a site may change its mind, and a daemon
|
||||||
|
// that runs for weeks would otherwise never notice.
|
||||||
|
type robotsCache struct {
|
||||||
|
ttl time.Duration
|
||||||
|
mu sync.Mutex
|
||||||
|
m map[string]robotsEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type robotsEntry struct {
|
||||||
|
rules Rules
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRobotsCache(ttl time.Duration) *robotsCache {
|
||||||
|
return &robotsCache{ttl: ttl, m: map[string]robotsEntry{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *robotsCache) get(host string, now time.Time) (Rules, bool) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
e, ok := c.m[host]
|
||||||
|
if !ok || now.Sub(e.at) > c.ttl {
|
||||||
|
return Rules{}, false
|
||||||
|
}
|
||||||
|
return e.rules, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *robotsCache) put(host string, r Rules, now time.Time) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.m[host] = robotsEntry{rules: r, at: now}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package crawl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const robotsBody = `# a comment
|
||||||
|
User-agent: *
|
||||||
|
Disallow: /private
|
||||||
|
Disallow: /tmp/
|
||||||
|
Crawl-delay: 5
|
||||||
|
|
||||||
|
User-agent: Maven
|
||||||
|
Disallow: /
|
||||||
|
Allow: /public
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestParseRobotsPicksTheMostSpecificGroup(t *testing.T) {
|
||||||
|
// The Maven group applies to us even though we send a longer UA string.
|
||||||
|
r := ParseRobots(robotsBody, "Maven/1.0 (self-hosted personal assistant)")
|
||||||
|
if r.Allowed("/anything") {
|
||||||
|
t.Error("Disallow: / in our own group was ignored")
|
||||||
|
}
|
||||||
|
if !r.Allowed("/public/page") {
|
||||||
|
t.Error("Allow: /public must beat the shorter Disallow: /")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A different agent falls into the * group.
|
||||||
|
star := ParseRobots(robotsBody, "SomeoneElse/2")
|
||||||
|
if !star.Allowed("/anything") {
|
||||||
|
t.Error("the * group disallows nothing but /private and /tmp/")
|
||||||
|
}
|
||||||
|
if star.Allowed("/private/x") || star.Allowed("/tmp/") {
|
||||||
|
t.Error("the * group's disallows were not applied")
|
||||||
|
}
|
||||||
|
if star.Delay != 5*time.Second {
|
||||||
|
t.Errorf("crawl-delay = %v, want 5s", star.Delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRobotsEmptyMeansAllowAll(t *testing.T) {
|
||||||
|
for _, body := range []string{"", "# nothing here\n", "User-agent: *\nDisallow:\n"} {
|
||||||
|
if !ParseRobots(body, "Maven").Allowed("/whatever") {
|
||||||
|
t.Errorf("body %q must allow everything", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRobotsWildcards(t *testing.T) {
|
||||||
|
r := ParseRobots("User-agent: *\nDisallow: /*.pdf$\nDisallow: /a/*/secret\n", "Maven")
|
||||||
|
if r.Allowed("/docs/manual.pdf") {
|
||||||
|
t.Error("*.pdf$ did not match")
|
||||||
|
}
|
||||||
|
if !r.Allowed("/docs/manual.pdf.html") {
|
||||||
|
t.Error("$ must anchor at the end")
|
||||||
|
}
|
||||||
|
if r.Allowed("/a/b/secret") {
|
||||||
|
t.Error("/a/*/secret did not match")
|
||||||
|
}
|
||||||
|
if !r.Allowed("/a/b/public") {
|
||||||
|
t.Error("unrelated path was refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consecutive User-agent lines share one group, which is common in the wild.
|
||||||
|
func TestRobotsSharedGroup(t *testing.T) {
|
||||||
|
r := ParseRobots("User-agent: Googlebot\nUser-agent: Maven\nDisallow: /x\n", "Maven/1.0")
|
||||||
|
if r.Allowed("/x/y") {
|
||||||
|
t.Fatal("a shared group's rules were not applied to the second agent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRobotsCacheTTL(t *testing.T) {
|
||||||
|
c := newRobotsCache(time.Minute)
|
||||||
|
now := time.Now()
|
||||||
|
c.put("example.com", ParseRobots("User-agent: *\nDisallow: /\n", "Maven"), now)
|
||||||
|
if _, ok := c.get("example.com", now.Add(30*time.Second)); !ok {
|
||||||
|
t.Error("a fresh entry must be served from cache")
|
||||||
|
}
|
||||||
|
if _, ok := c.get("example.com", now.Add(2*time.Minute)); ok {
|
||||||
|
t.Error("an expired entry must be re-read")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package crawl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scheduled crawls: a page is re-read on an interval, and when its TEXT changed
|
||||||
|
// the new text is written as a note. Nothing is dispatched — same rule as the
|
||||||
|
// feed poller (Vikunja #258). A page that announced its own change would be a
|
||||||
|
// nag, and "the docs page changed" is not worth interrupting anyone for.
|
||||||
|
//
|
||||||
|
// Dedup is by content hash, so a page that re-renders identically writes nothing
|
||||||
|
// and a rotating ad slot does not count as news.
|
||||||
|
|
||||||
|
// WatchConfig — one page to keep an eye on.
|
||||||
|
type WatchConfig struct {
|
||||||
|
Name string // note source is "crawl:<Name>"
|
||||||
|
URL string // http(s), guarded by the fetcher
|
||||||
|
Interval time.Duration // 0 ⇒ Watcher's default
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notes is core's note-writing half (same shape as ipc.CoreAPI's method).
|
||||||
|
type Notes interface {
|
||||||
|
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hashes remembers the last text hash per watch, durably, so a restart does not
|
||||||
|
// re-note an unchanged page. The daemon backs this with config facts
|
||||||
|
// ("crawl:hash:<name>").
|
||||||
|
type Hashes interface {
|
||||||
|
LastHash(ctx context.Context, name string) (string, error)
|
||||||
|
SetHash(ctx context.Context, name, hash string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embedder embeds a note on its way into the store. nil ⇒ no vector.
|
||||||
|
type Embedder interface {
|
||||||
|
Embed(ctx context.Context, text string) ([]float32, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultWatchInterval — pages change slowly, and every check is a request in
|
||||||
|
// someone's log.
|
||||||
|
const DefaultWatchInterval = 6 * time.Hour
|
||||||
|
|
||||||
|
// Watcher re-reads watched pages on their interval.
|
||||||
|
type Watcher struct {
|
||||||
|
c *Crawler
|
||||||
|
watches []WatchConfig
|
||||||
|
notes Notes
|
||||||
|
hashes Hashes
|
||||||
|
embed Embedder
|
||||||
|
interval time.Duration
|
||||||
|
nextDue map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWatcher wires the scheduled half, or returns nil when there is nothing to
|
||||||
|
// watch. Callers check for nil: no watches, no goroutine, no request.
|
||||||
|
func NewWatcher(c *Crawler, watches []WatchConfig, notes Notes, hashes Hashes, embed Embedder, defaultInterval time.Duration) *Watcher {
|
||||||
|
if c == nil || notes == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var valid []WatchConfig
|
||||||
|
for _, w := range watches {
|
||||||
|
if strings.TrimSpace(w.Name) == "" || strings.TrimSpace(w.URL) == "" {
|
||||||
|
log.Printf("crawl: skipping a watch with no name or no url")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
valid = append(valid, w)
|
||||||
|
}
|
||||||
|
if len(valid) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if defaultInterval <= 0 {
|
||||||
|
defaultInterval = DefaultWatchInterval
|
||||||
|
}
|
||||||
|
return &Watcher{
|
||||||
|
c: c, watches: valid, notes: notes, hashes: hashes, embed: embed,
|
||||||
|
interval: defaultInterval, nextDue: map[string]time.Time{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watches returns the configured watches.
|
||||||
|
func (w *Watcher) Watches() []WatchConfig { return w.watches }
|
||||||
|
|
||||||
|
// CheckDue re-reads every watch whose interval elapsed and returns how many
|
||||||
|
// notes were written. Errors are logged per watch, never returned: one dead page
|
||||||
|
// must not stop the others.
|
||||||
|
func (w *Watcher) CheckDue(ctx context.Context, now time.Time) int {
|
||||||
|
written := 0
|
||||||
|
for _, watch := range w.watches {
|
||||||
|
if due, ok := w.nextDue[watch.Name]; ok && now.Before(due) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
interval := watch.Interval
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = w.interval
|
||||||
|
}
|
||||||
|
w.nextDue[watch.Name] = now.Add(interval)
|
||||||
|
changed, err := w.Check(ctx, watch, now)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("crawl: watch %s: %v", watch.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
log.Printf("crawl: watch %s: page changed, noted", watch.Name)
|
||||||
|
written++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return written
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check re-reads one watch now and reports whether it wrote a note.
|
||||||
|
func (w *Watcher) Check(ctx context.Context, watch WatchConfig, now time.Time) (bool, error) {
|
||||||
|
page, err := w.c.Page(ctx, watch.URL)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
// Title included: a page whose headline changed has changed.
|
||||||
|
h := Hash(page.Title + "\n" + page.Text)
|
||||||
|
if w.hashes != nil {
|
||||||
|
prev, err := w.hashes.LastHash(ctx, watch.Name)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("crawl: watch %s: read hash: %v", watch.Name, err)
|
||||||
|
}
|
||||||
|
if prev == h {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text := NoteText(watch, page)
|
||||||
|
var vec []float32
|
||||||
|
if w.embed != nil {
|
||||||
|
v, err := w.embed.Embed(ctx, text)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("crawl: watch %s: embed: %v", watch.Name, err)
|
||||||
|
} else {
|
||||||
|
vec = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := w.notes.WriteNote(ctx, now, text, vec, SourceFor(watch.Name)); err != nil {
|
||||||
|
return false, fmt.Errorf("write note: %w", err)
|
||||||
|
}
|
||||||
|
if w.hashes != nil {
|
||||||
|
if err := w.hashes.SetHash(ctx, watch.Name, h); err != nil {
|
||||||
|
log.Printf("crawl: watch %s: save hash: %v", watch.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceFor is the note source for a watch, and SourcePrefix is what the answer
|
||||||
|
// path matches to recognise one.
|
||||||
|
func SourceFor(name string) string { return SourcePrefix + name }
|
||||||
|
|
||||||
|
// SourcePrefix — provenance for anything read off the network on a schedule.
|
||||||
|
const SourcePrefix = "crawl:"
|
||||||
|
|
||||||
|
// noteRunes — how much of a watched page goes into a note. Shorter than what the
|
||||||
|
// on-demand path reads: a note is a record of a change, not an archive.
|
||||||
|
const noteRunes = 800
|
||||||
|
|
||||||
|
// NoteText renders a watched page as a note body.
|
||||||
|
func NoteText(watch WatchConfig, page Page) string {
|
||||||
|
var b strings.Builder
|
||||||
|
if page.Title != "" {
|
||||||
|
b.WriteString(page.Title)
|
||||||
|
} else {
|
||||||
|
b.WriteString(watch.Name)
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(TrimRunes(page.Text, noteRunes))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(watch.URL)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package crawl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type note struct {
|
||||||
|
text string
|
||||||
|
source string
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeNotes struct{ notes []note }
|
||||||
|
|
||||||
|
func (n *fakeNotes) WriteNote(_ context.Context, _ time.Time, text string, _ []float32, source string) (int64, error) {
|
||||||
|
n.notes = append(n.notes, note{text, source})
|
||||||
|
return int64(len(n.notes)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeHashes struct{ m map[string]string }
|
||||||
|
|
||||||
|
func newHashes() *fakeHashes { return &fakeHashes{m: map[string]string{}} }
|
||||||
|
func (f *fakeHashes) LastHash(_ context.Context, name string) (string, error) {
|
||||||
|
return f.m[name], nil
|
||||||
|
}
|
||||||
|
func (f *fakeHashes) SetHash(_ context.Context, name, h string) error { f.m[name] = h; return nil }
|
||||||
|
|
||||||
|
var t0 = time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
func TestWatchNotesAChangedPage(t *testing.T) {
|
||||||
|
f := &fakeFetcher{pages: map[string]Response{
|
||||||
|
"https://example.org/docs": {Body: []byte(htmlPage)},
|
||||||
|
}}
|
||||||
|
notes := &fakeNotes{}
|
||||||
|
hashes := newHashes()
|
||||||
|
w := NewWatcher(newTestCrawler(f), []WatchConfig{{Name: "docs", URL: "https://example.org/docs"}},
|
||||||
|
notes, hashes, nil, time.Hour)
|
||||||
|
if w == nil {
|
||||||
|
t.Fatal("NewWatcher returned nil for a configured watch")
|
||||||
|
}
|
||||||
|
if n := w.CheckDue(context.Background(), t0); n != 1 {
|
||||||
|
t.Fatalf("first check wrote %d notes, want 1", n)
|
||||||
|
}
|
||||||
|
if notes.notes[0].source != "crawl:docs" {
|
||||||
|
t.Errorf("source = %q, want crawl:docs", notes.notes[0].source)
|
||||||
|
}
|
||||||
|
if !strings.Contains(notes.notes[0].text, "https://example.org/docs") {
|
||||||
|
t.Errorf("note does not carry the url: %q", notes.notes[0].text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unchanged page, interval elapsed: nothing written.
|
||||||
|
if n := w.CheckDue(context.Background(), t0.Add(2*time.Hour)); n != 0 {
|
||||||
|
t.Fatalf("an unchanged page wrote %d notes", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changed page: one note.
|
||||||
|
f.pages["https://example.org/docs"] = Response{Body: []byte(strings.Replace(htmlPage, "синее", "серое", 1))}
|
||||||
|
if n := w.CheckDue(context.Background(), t0.Add(4*time.Hour)); n != 1 {
|
||||||
|
t.Fatalf("a changed page wrote %d notes, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatchIntervalIsRespected(t *testing.T) {
|
||||||
|
f := &fakeFetcher{pages: map[string]Response{"https://example.org/d": {Body: []byte(htmlPage)}}}
|
||||||
|
w := NewWatcher(newTestCrawler(f), []WatchConfig{{Name: "d", URL: "https://example.org/d", Interval: time.Hour}},
|
||||||
|
&fakeNotes{}, newHashes(), nil, 0)
|
||||||
|
w.CheckDue(context.Background(), t0)
|
||||||
|
before := len(f.calls)
|
||||||
|
w.CheckDue(context.Background(), t0.Add(time.Minute))
|
||||||
|
if len(f.calls) != before {
|
||||||
|
t.Fatal("the page was re-read inside its interval")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hash is durable so a restart does not re-note an unchanged page.
|
||||||
|
func TestWatchHashSurvivesRestart(t *testing.T) {
|
||||||
|
f := &fakeFetcher{pages: map[string]Response{"https://example.org/d": {Body: []byte(htmlPage)}}}
|
||||||
|
hashes := newHashes()
|
||||||
|
watches := []WatchConfig{{Name: "d", URL: "https://example.org/d"}}
|
||||||
|
NewWatcher(newTestCrawler(f), watches, &fakeNotes{}, hashes, nil, time.Hour).CheckDue(context.Background(), t0)
|
||||||
|
|
||||||
|
notes2 := &fakeNotes{}
|
||||||
|
NewWatcher(newTestCrawler(f), watches, notes2, hashes, nil, time.Hour).CheckDue(context.Background(), t0.Add(time.Hour))
|
||||||
|
if len(notes2.notes) != 0 {
|
||||||
|
t.Fatalf("a fresh watcher re-noted an unchanged page: %q", notes2.notes[0].text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatchDeadPageDoesNotStopTheOthers(t *testing.T) {
|
||||||
|
f := &fakeFetcher{pages: map[string]Response{"https://example.org/live": {Body: []byte(htmlPage)}}}
|
||||||
|
notes := &fakeNotes{}
|
||||||
|
w := NewWatcher(newTestCrawler(f), []WatchConfig{
|
||||||
|
{Name: "dead", URL: "https://example.org/gone"},
|
||||||
|
{Name: "live", URL: "https://example.org/live"},
|
||||||
|
}, notes, newHashes(), nil, time.Hour)
|
||||||
|
if n := w.CheckDue(context.Background(), t0); n != 1 {
|
||||||
|
t.Fatalf("wrote %d notes, want 1 (the live page)", n)
|
||||||
|
}
|
||||||
|
if notes.notes[0].source != "crawl:live" {
|
||||||
|
t.Fatalf("source = %q", notes.notes[0].source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoWatchesMeansNoWatcher(t *testing.T) {
|
||||||
|
c := newTestCrawler(&fakeFetcher{})
|
||||||
|
if NewWatcher(c, nil, &fakeNotes{}, nil, nil, 0) != nil {
|
||||||
|
t.Fatal("no watches must mean no watcher")
|
||||||
|
}
|
||||||
|
if NewWatcher(nil, []WatchConfig{{Name: "a", URL: "u"}}, &fakeNotes{}, nil, nil, 0) != nil {
|
||||||
|
t.Fatal("no crawler must mean no watcher")
|
||||||
|
}
|
||||||
|
if NewWatcher(c, []WatchConfig{{Name: "", URL: ""}}, &fakeNotes{}, nil, nil, 0) != nil {
|
||||||
|
t.Fatal("a watch with no name or url is not a configuration")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package event
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bus — the in-memory intake journal: a bounded ring of recent Events plus
|
||||||
|
// zero or more subscribers.
|
||||||
|
//
|
||||||
|
// Two properties are load-bearing, both about not changing production
|
||||||
|
// behaviour when nobody is watching:
|
||||||
|
//
|
||||||
|
// - A nil *Bus is a working no-op. Publish on nil returns immediately, so
|
||||||
|
// an intake path can call b.Publish(...) unconditionally and a daemon that
|
||||||
|
// never built a bus behaves exactly as it did before. This is what let
|
||||||
|
// eight callers adopt the envelope without a config flag each.
|
||||||
|
// - Publish never blocks on a subscriber and never propagates a panic from
|
||||||
|
// one. Intake is on the request path of POST /api/ambient and of every
|
||||||
|
// fact write; a slow or broken observer must not be able to stall or kill
|
||||||
|
// a write that already succeeded.
|
||||||
|
//
|
||||||
|
// The ring is bounded because it is memory that nothing prunes otherwise. Its
|
||||||
|
// contents are a window, not a record: the durable consequence of an event is
|
||||||
|
// the fact, note or task the intake path wrote.
|
||||||
|
type Bus struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
ring []Event // len == cap once full; oldest at (next % cap)
|
||||||
|
next int
|
||||||
|
n int
|
||||||
|
subs []func(Event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultCapacity — how many recent events a bus keeps. A busy day is a few
|
||||||
|
// hundred intake events (a feed poll is one per new item), so this is roughly
|
||||||
|
// "today and yesterday" at a few hundred KB.
|
||||||
|
const DefaultCapacity = 512
|
||||||
|
|
||||||
|
// NewBus returns a bus keeping the last capacity events. capacity <= 0 uses
|
||||||
|
// DefaultCapacity.
|
||||||
|
func NewBus(capacity int) *Bus {
|
||||||
|
if capacity <= 0 {
|
||||||
|
capacity = DefaultCapacity
|
||||||
|
}
|
||||||
|
return &Bus{ring: make([]Event, capacity)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish normalizes e, drops it if it is not Valid, appends it to the ring and
|
||||||
|
// hands it to every subscriber. Safe on a nil receiver and safe from any
|
||||||
|
// goroutine.
|
||||||
|
//
|
||||||
|
// now is passed in rather than read from the clock: the whole point of #284's
|
||||||
|
// replay is that no time.Now() sits inside a path a scenario drives.
|
||||||
|
func (b *Bus) Publish(e Event, now time.Time) {
|
||||||
|
if b == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e = e.Normalize(now)
|
||||||
|
if !e.Valid() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
b.ring[b.next] = e
|
||||||
|
b.next = (b.next + 1) % len(b.ring)
|
||||||
|
if b.n < len(b.ring) {
|
||||||
|
b.n++
|
||||||
|
}
|
||||||
|
subs := make([]func(Event), len(b.subs))
|
||||||
|
copy(subs, b.subs)
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
for _, fn := range subs {
|
||||||
|
notify(fn, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// notify calls one subscriber, swallowing a panic. A test double or a page
|
||||||
|
// renderer must not be able to take down a daemon from the intake path.
|
||||||
|
func notify(fn func(Event), e Event) {
|
||||||
|
defer func() { _ = recover() }()
|
||||||
|
fn(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe registers fn to be called for every subsequent event, in publish
|
||||||
|
// order. There is no unsubscribe: subscribers are wired at startup and live as
|
||||||
|
// long as the daemon. Safe on a nil receiver (the subscription is dropped,
|
||||||
|
// which is the honest outcome when there is no bus to subscribe to).
|
||||||
|
func (b *Bus) Subscribe(fn func(Event)) {
|
||||||
|
if b == nil || fn == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
b.subs = append(b.subs, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent returns up to limit events, newest first. limit <= 0 returns
|
||||||
|
// everything held. Safe on a nil receiver (returns nil).
|
||||||
|
func (b *Bus) Recent(limit int) []Event {
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
if b.n == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > b.n {
|
||||||
|
limit = b.n
|
||||||
|
}
|
||||||
|
out := make([]Event, 0, limit)
|
||||||
|
// next points one past the newest; walk backwards.
|
||||||
|
for i := 0; i < limit; i++ {
|
||||||
|
idx := (b.next - 1 - i + len(b.ring)*2) % len(b.ring)
|
||||||
|
out = append(out, b.ring[idx])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len reports how many events the ring currently holds. Safe on nil.
|
||||||
|
func (b *Bus) Len() int {
|
||||||
|
if b == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return b.n
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
// Package event is the unified intake envelope (Vikunja #283,
|
||||||
|
// 20-07-2026-BACKLOG.md item 1).
|
||||||
|
//
|
||||||
|
// # The problem it solves
|
||||||
|
//
|
||||||
|
// Things arrive at Maven from a lot of directions: a relayed Android
|
||||||
|
// notification (POST /api/ambient), a mail the reader extracted candidates
|
||||||
|
// from (ingest_mail), an RSS item, a changed page the crawler noticed, a
|
||||||
|
// zenmoney spend, a CalDAV event, a wg handshake that means he is home, a
|
||||||
|
// photo he sent, a meeting she was asked to record. Each of those grew its own
|
||||||
|
// shape, its own storage decision and its own log line. Nothing could answer
|
||||||
|
// "what came in today, from where" without reading eight packages.
|
||||||
|
//
|
||||||
|
// An Event is that answer: one flat, source-agnostic description of "something
|
||||||
|
// arrived". It is deliberately NOT a new storage layer and NOT a new transport.
|
||||||
|
// Every intake path keeps writing exactly what it wrote before — a fact, a
|
||||||
|
// note, a candidate task — and additionally describes what it did as an Event.
|
||||||
|
// The envelope is a VIEW over intake, not a replacement for it, which is why
|
||||||
|
// adopting it did not require touching eight callers.
|
||||||
|
//
|
||||||
|
// # What it is not
|
||||||
|
//
|
||||||
|
// - Not a command. An Event is a report of something that happened; nothing
|
||||||
|
// in Maven executes one. Digestion may read them; it may not be driven by
|
||||||
|
// an event alone, because "a thing arrived" is not "a thing must be said".
|
||||||
|
// - Not durable. The bus is a bounded in-memory ring. An event's durable
|
||||||
|
// consequence is the fact/note/task the intake path already wrote; the
|
||||||
|
// envelope is the recent-history window on top. A restart losing the ring
|
||||||
|
// loses nothing that mattered.
|
||||||
|
// - Not a secret store. Body carries what the intake path was already willing
|
||||||
|
// to log or store. Nothing puts a mail body, an IMAP password or a
|
||||||
|
// voiceprint in here, and callers must keep it that way.
|
||||||
|
package event
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event — one thing that arrived, normalized.
|
||||||
|
//
|
||||||
|
// The field set is the one recorded in the backlog, and it is intentionally
|
||||||
|
// small: anything source-specific goes in Payload, so adding a source never
|
||||||
|
// widens the struct and never breaks a reader.
|
||||||
|
type Event struct {
|
||||||
|
// Source — provenance, in the facts vocabulary already used across the
|
||||||
|
// repo: "ambient:notif", "caldav:personal", "poll:zenmoney", "rss:<feed>",
|
||||||
|
// "crawl:<watch>", "email:<mailbox>", "infer:wg", "tap:voice". Same string
|
||||||
|
// the fact or note was written under, so an event and its row can be
|
||||||
|
// matched up by eye.
|
||||||
|
Source string `json:"source"`
|
||||||
|
|
||||||
|
// Kind — what sort of thing arrived, from the closed set below. This is the
|
||||||
|
// field digestion switches on; Source is for provenance and display.
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
|
||||||
|
// EntityIDs — Nexus entity ids this event is about, when the intake path
|
||||||
|
// knew any. Usually empty: most intake happens before enrichment resolves a
|
||||||
|
// subject to an entity.
|
||||||
|
EntityIDs []string `json:"entity_ids,omitempty"`
|
||||||
|
|
||||||
|
// Title — one short line, safe to show on a page. For a fact it is the key,
|
||||||
|
// for a note the first line, for a task the task text.
|
||||||
|
Title string `json:"title"`
|
||||||
|
|
||||||
|
// Body — optional detail, already truncated by the caller.
|
||||||
|
Body string `json:"body,omitempty"`
|
||||||
|
|
||||||
|
// Priority — one of PriorityLow / PriorityNormal / PriorityHigh. It is a
|
||||||
|
// hint about attention, not a delivery instruction: nothing here decides
|
||||||
|
// whether Maven speaks. That stays with internal/loop and internal/delivery,
|
||||||
|
// where the severity/presence routing table lives.
|
||||||
|
Priority string `json:"priority"`
|
||||||
|
|
||||||
|
// OccurredAt — when the thing happened, NOT when Maven noticed it. A wg
|
||||||
|
// handshake carries the handshake instant; an RSS item carries its publish
|
||||||
|
// time. Intake paths already make this distinction when writing facts, and
|
||||||
|
// the envelope must not flatten it.
|
||||||
|
OccurredAt time.Time `json:"occurred_at"`
|
||||||
|
|
||||||
|
// Payload — source-specific extra, opaque here. Optional.
|
||||||
|
Payload json.RawMessage `json:"payload,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kinds. Closed set: a reader may switch on these exhaustively. A new intake
|
||||||
|
// path picks the closest existing kind before it adds one — the point of the
|
||||||
|
// envelope is that digestion has a small stable input.
|
||||||
|
const (
|
||||||
|
// KindFact — something was written to the facts table: a calendar read, a
|
||||||
|
// zenmoney window, a presence probe, a crawler watermark.
|
||||||
|
KindFact = "fact"
|
||||||
|
|
||||||
|
// KindNote — something was written to the notes table: an RSS item, a
|
||||||
|
// changed page, a meeting transcript, an image description.
|
||||||
|
KindNote = "note"
|
||||||
|
|
||||||
|
// KindTask — a candidate task was captured: the mail reader, the web form,
|
||||||
|
// the voice path.
|
||||||
|
KindTask = "task"
|
||||||
|
|
||||||
|
// KindMessage — an inbound message on a reach channel. Nothing produces
|
||||||
|
// this yet (telegram is send-only today); the kind exists so the bridge,
|
||||||
|
// when it lands, is a constructor and not a schema change.
|
||||||
|
KindMessage = "message"
|
||||||
|
|
||||||
|
// KindHealth — a service or probe reported its own state.
|
||||||
|
KindHealth = "health"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Priorities.
|
||||||
|
const (
|
||||||
|
PriorityLow = "low"
|
||||||
|
PriorityNormal = "normal"
|
||||||
|
PriorityHigh = "high"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TitleMaxRunes / BodyMaxRunes bound what an envelope carries. The ring is
|
||||||
|
// in memory and served to a web page; a 40 KB crawled article has no business
|
||||||
|
// in either. Cut on a rune boundary — most of this text is Russian and half a
|
||||||
|
// cyrillic letter is a broken line.
|
||||||
|
const (
|
||||||
|
TitleMaxRunes = 120
|
||||||
|
BodyMaxRunes = 400
|
||||||
|
)
|
||||||
|
|
||||||
|
// Normalize returns e with its fields put in range: whitespace collapsed out
|
||||||
|
// of Title, Title and Body truncated, an unknown or empty Priority forced to
|
||||||
|
// PriorityNormal, and a zero OccurredAt filled from now.
|
||||||
|
//
|
||||||
|
// It takes now as a parameter rather than reading the clock, so the whole
|
||||||
|
// package stays pure and the simulator (Vikunja #284) can replay intake against
|
||||||
|
// a scripted clock.
|
||||||
|
func (e Event) Normalize(now time.Time) Event {
|
||||||
|
e.Title = truncateRunes(strings.Join(strings.Fields(e.Title), " "), TitleMaxRunes)
|
||||||
|
e.Body = truncateRunes(strings.TrimSpace(e.Body), BodyMaxRunes)
|
||||||
|
if !validPriority(e.Priority) {
|
||||||
|
e.Priority = PriorityNormal
|
||||||
|
}
|
||||||
|
if e.Kind == "" {
|
||||||
|
e.Kind = KindFact
|
||||||
|
}
|
||||||
|
if e.OccurredAt.IsZero() {
|
||||||
|
e.OccurredAt = now
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid reports whether e carries the minimum a reader can rely on: a source,
|
||||||
|
// a known kind, a title and a time. The bus drops anything that fails — an
|
||||||
|
// envelope with no provenance is worse than no envelope, because it looks like
|
||||||
|
// evidence.
|
||||||
|
func (e Event) Valid() bool {
|
||||||
|
return e.Source != "" && validKind(e.Kind) && e.Title != "" && !e.OccurredAt.IsZero()
|
||||||
|
}
|
||||||
|
|
||||||
|
func validKind(k string) bool {
|
||||||
|
switch k {
|
||||||
|
case KindFact, KindNote, KindTask, KindMessage, KindHealth:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func validPriority(p string) bool {
|
||||||
|
switch p {
|
||||||
|
case PriorityLow, PriorityNormal, PriorityHigh:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateRunes cuts s to n runes, marking the cut.
|
||||||
|
func truncateRunes(s string, n int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(r[:n]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceKind guesses the Kind for a source string when the caller has not said
|
||||||
|
// otherwise. It exists so the one intake decorator in cmd/mavend does not need
|
||||||
|
// a switch per writer: the source prefix already tells you what arrived.
|
||||||
|
//
|
||||||
|
// Unknown prefixes get fallback, which is what the caller was going to write
|
||||||
|
// anyway (a WriteFact call knows it is a fact).
|
||||||
|
func SourceKind(source, fallback string) string {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(source, "rss:"), strings.HasPrefix(source, "crawl:"):
|
||||||
|
return KindNote
|
||||||
|
case strings.HasPrefix(source, "email:"):
|
||||||
|
return KindTask
|
||||||
|
case strings.HasPrefix(source, "probe:"), strings.HasPrefix(source, "health:"):
|
||||||
|
return KindHealth
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package event
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var testNow = time.Date(2026, 8, 1, 9, 30, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
func TestNormalizeFillsDefaults(t *testing.T) {
|
||||||
|
got := Event{Source: "poll:zenmoney", Title: " spent today "}.Normalize(testNow)
|
||||||
|
if got.Title != "spent today" {
|
||||||
|
t.Errorf("title = %q, want collapsed whitespace", got.Title)
|
||||||
|
}
|
||||||
|
if got.Priority != PriorityNormal {
|
||||||
|
t.Errorf("priority = %q, want %q", got.Priority, PriorityNormal)
|
||||||
|
}
|
||||||
|
if got.Kind != KindFact {
|
||||||
|
t.Errorf("kind = %q, want %q", got.Kind, KindFact)
|
||||||
|
}
|
||||||
|
if !got.OccurredAt.Equal(testNow) {
|
||||||
|
t.Errorf("occurred_at = %v, want %v", got.OccurredAt, testNow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeKeepsRealOccurredAt(t *testing.T) {
|
||||||
|
// A wg handshake carries the handshake instant, not "now". Flattening that
|
||||||
|
// would make every intake look like it happened at notice time.
|
||||||
|
real := testNow.Add(-3 * time.Hour)
|
||||||
|
got := Event{Source: "infer:wg", Title: "wg_handshake", OccurredAt: real}.Normalize(testNow)
|
||||||
|
if !got.OccurredAt.Equal(real) {
|
||||||
|
t.Errorf("occurred_at = %v, want the supplied %v", got.OccurredAt, real)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeTruncatesOnRuneBoundary(t *testing.T) {
|
||||||
|
long := strings.Repeat("я", TitleMaxRunes+50)
|
||||||
|
got := Event{Source: "rss:x", Title: long}.Normalize(testNow)
|
||||||
|
r := []rune(got.Title)
|
||||||
|
if len(r) != TitleMaxRunes+1 { // +1 for the ellipsis marker
|
||||||
|
t.Fatalf("title runes = %d, want %d", len(r), TitleMaxRunes+1)
|
||||||
|
}
|
||||||
|
if r[len(r)-1] != '…' {
|
||||||
|
t.Errorf("truncated title does not mark the cut: %q", string(r[len(r)-3:]))
|
||||||
|
}
|
||||||
|
for _, c := range r[:TitleMaxRunes] {
|
||||||
|
if c != 'я' {
|
||||||
|
t.Fatalf("truncation broke a rune: got %q", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeRejectsUnknownPriority(t *testing.T) {
|
||||||
|
got := Event{Source: "s", Title: "t", Priority: "URGENT!!"}.Normalize(testNow)
|
||||||
|
if got.Priority != PriorityNormal {
|
||||||
|
t.Errorf("priority = %q, want %q", got.Priority, PriorityNormal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValid(t *testing.T) {
|
||||||
|
base := Event{Source: "rss:tech", Kind: KindNote, Title: "заголовок", OccurredAt: testNow}
|
||||||
|
if !base.Valid() {
|
||||||
|
t.Fatal("well-formed event reported invalid")
|
||||||
|
}
|
||||||
|
for name, mut := range map[string]func(Event) Event{
|
||||||
|
"no source": func(e Event) Event { e.Source = ""; return e },
|
||||||
|
"no title": func(e Event) Event { e.Title = ""; return e },
|
||||||
|
"no time": func(e Event) Event { e.OccurredAt = time.Time{}; return e },
|
||||||
|
"bad kind": func(e Event) Event { e.Kind = "whatever"; return e },
|
||||||
|
} {
|
||||||
|
if mut(base).Valid() {
|
||||||
|
t.Errorf("%s: reported valid", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourceKind(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"rss:tech": KindNote,
|
||||||
|
"crawl:kernel": KindNote,
|
||||||
|
"email:inbox": KindTask,
|
||||||
|
"probe:netdata": KindHealth,
|
||||||
|
"ambient:notif": KindFact,
|
||||||
|
"tap:voice": KindFact,
|
||||||
|
}
|
||||||
|
for src, want := range cases {
|
||||||
|
if got := SourceKind(src, KindFact); got != want {
|
||||||
|
t.Errorf("SourceKind(%q) = %q, want %q", src, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBusNilIsANoOp(t *testing.T) {
|
||||||
|
// The whole adoption story depends on this: an intake path calls Publish
|
||||||
|
// unconditionally, and a daemon with no bus behaves as it did before.
|
||||||
|
var b *Bus
|
||||||
|
b.Publish(Event{Source: "s", Kind: KindFact, Title: "t"}, testNow)
|
||||||
|
b.Subscribe(func(Event) { t.Error("nil bus delivered to a subscriber") })
|
||||||
|
if got := b.Recent(10); got != nil {
|
||||||
|
t.Errorf("Recent on nil bus = %v, want nil", got)
|
||||||
|
}
|
||||||
|
if got := b.Len(); got != 0 {
|
||||||
|
t.Errorf("Len on nil bus = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBusRecentIsNewestFirst(t *testing.T) {
|
||||||
|
b := NewBus(8)
|
||||||
|
for _, title := range []string{"one", "two", "three"} {
|
||||||
|
b.Publish(Event{Source: "rss:t", Kind: KindNote, Title: title}, testNow)
|
||||||
|
}
|
||||||
|
got := b.Recent(0)
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("len = %d, want 3", len(got))
|
||||||
|
}
|
||||||
|
want := []string{"three", "two", "one"}
|
||||||
|
for i, w := range want {
|
||||||
|
if got[i].Title != w {
|
||||||
|
t.Errorf("Recent()[%d] = %q, want %q", i, got[i].Title, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lim := b.Recent(2); len(lim) != 2 || lim[0].Title != "three" {
|
||||||
|
t.Errorf("Recent(2) = %v, want the two newest", lim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBusRingEvicts(t *testing.T) {
|
||||||
|
b := NewBus(3)
|
||||||
|
for _, title := range []string{"a", "b", "c", "d", "e"} {
|
||||||
|
b.Publish(Event{Source: "s", Kind: KindFact, Title: title}, testNow)
|
||||||
|
}
|
||||||
|
if b.Len() != 3 {
|
||||||
|
t.Fatalf("Len = %d, want the capacity 3", b.Len())
|
||||||
|
}
|
||||||
|
got := b.Recent(0)
|
||||||
|
want := []string{"e", "d", "c"}
|
||||||
|
for i, w := range want {
|
||||||
|
if got[i].Title != w {
|
||||||
|
t.Errorf("Recent()[%d] = %q, want %q", i, got[i].Title, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBusDropsInvalid(t *testing.T) {
|
||||||
|
b := NewBus(4)
|
||||||
|
b.Publish(Event{Kind: KindFact, Title: "no source"}, testNow)
|
||||||
|
b.Publish(Event{Source: "s", Kind: KindFact}, testNow)
|
||||||
|
if b.Len() != 0 {
|
||||||
|
t.Errorf("Len = %d, want 0 — an envelope with no provenance must not be kept", b.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBusSubscriberPanicDoesNotBreakIntake(t *testing.T) {
|
||||||
|
b := NewBus(4)
|
||||||
|
var seen int
|
||||||
|
b.Subscribe(func(Event) { panic("observer is broken") })
|
||||||
|
b.Subscribe(func(Event) { seen++ })
|
||||||
|
b.Publish(Event{Source: "s", Kind: KindFact, Title: "t"}, testNow)
|
||||||
|
if seen != 1 {
|
||||||
|
t.Errorf("healthy subscriber called %d times, want 1", seen)
|
||||||
|
}
|
||||||
|
if b.Len() != 1 {
|
||||||
|
t.Errorf("event not recorded despite a panicking subscriber")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBusConcurrentPublish(t *testing.T) {
|
||||||
|
b := NewBus(256)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := 0; j < 10; j++ {
|
||||||
|
b.Publish(Event{Source: "s", Kind: KindFact, Title: "t"}, testNow)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if b.Len() != 160 {
|
||||||
|
t.Errorf("Len = %d, want 160", b.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
+271
-6
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/audio"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DTOs — wire-level data. Decoupled from internal/store so the protocol is
|
// DTOs — wire-level data. Decoupled from internal/store so the protocol is
|
||||||
@@ -176,6 +178,218 @@ type IngestMailResp struct {
|
|||||||
Skipped bool `json:"skipped,omitempty"`
|
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 {
|
type listTasksReq struct {
|
||||||
Status string `json:"status"` // "" all | "live" | candidate|open|done|dropped
|
Status string `json:"status"` // "" all | "live" | candidate|open|done|dropped
|
||||||
}
|
}
|
||||||
@@ -275,6 +489,19 @@ type Tool struct {
|
|||||||
Updated time.Time `json:"updated"`
|
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.
|
// chatReq / chatResp — text chat round-trip for the IPC Chat method.
|
||||||
type chatReq struct {
|
type chatReq struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
@@ -413,6 +640,14 @@ type CoreAPI interface {
|
|||||||
// TickTrace.
|
// TickTrace.
|
||||||
MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error)
|
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
|
// DayPlan returns today's ordered plan — calendar events, pending
|
||||||
// reminders and any morning checklist still outstanding (see
|
// reminders and any morning checklist still outstanding (see
|
||||||
// internal/morning.BuildPlan) — plus the spoken RU rendering of it.
|
// internal/morning.BuildPlan) — plus the spoken RU rendering of it.
|
||||||
@@ -425,6 +660,31 @@ type CoreAPI interface {
|
|||||||
// (router → dialogue → action → replier) and returns the reply text.
|
// (router → dialogue → action → replier) and returns the reply text.
|
||||||
// No audio or stt/tts — for text channels (mavweb, telegram).
|
// No audio or stt/tts — for text channels (mavweb, telegram).
|
||||||
Chat(ctx context.Context, text string) (string, error)
|
Chat(ctx context.Context, text string) (string, error)
|
||||||
|
|
||||||
|
// RecentEvents returns the daemon's unified intake journal, newest first
|
||||||
|
// (Vikunja #283) — one envelope per thing that arrived, whatever direction
|
||||||
|
// it came from: a relayed notification, a mail candidate, a feed item, a
|
||||||
|
// changed page, a spend, a presence probe.
|
||||||
|
//
|
||||||
|
// Read-only and daemon-cached, the same shape as TickTrace and DayPlan:
|
||||||
|
// the store adapter returns an error, because the journal is a bounded
|
||||||
|
// in-memory ring and not a table. Its contents are a window over intake,
|
||||||
|
// never the durable record — that is still the fact, note or task the
|
||||||
|
// intake path wrote.
|
||||||
|
RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IntakeEvent — one entry of the unified intake journal on the wire. Mirrors
|
||||||
|
// event.Event field for field; the ipc package does not import internal/event
|
||||||
|
// so the wire shape stays independent of the in-process type.
|
||||||
|
type IntakeEvent struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
EntityIDs []string `json:"entity_ids,omitempty"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body,omitempty"`
|
||||||
|
Priority string `json:"priority"`
|
||||||
|
OccurredAt time.Time `json:"occurred_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Rule trace / explanation DTOs ---
|
// --- Rule trace / explanation DTOs ---
|
||||||
@@ -494,17 +754,22 @@ type DayPlan struct {
|
|||||||
Spoken string `json:"spoken"`
|
Spoken string `json:"spoken"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// storeEncryptionKeyReq — passkey credential public key for wrapping the store
|
// storeEncryptionKeyReq — the passkey-derived secret used to wrap the store
|
||||||
// encryption key at enrollment time. Called by mavweb after RegisterFinish.
|
// encryption key at enrollment time. Called by mavweb after RegisterFinish.
|
||||||
|
//
|
||||||
|
// Secret is the 32-byte WebAuthn PRF output, NOT the credential public key.
|
||||||
|
// The field used to carry the public key and that was the bug: a public key
|
||||||
|
// sits in passkeys.json next to the wrapped blob, so the blob protected
|
||||||
|
// nothing. See internal/webauthn/keywrap.go.
|
||||||
type storeEncryptionKeyReq struct {
|
type storeEncryptionKeyReq struct {
|
||||||
PublicKey []byte `json:"public_key"`
|
Secret []byte `json:"secret"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// unlockReq — passkey credential public key for unwrapping the store
|
// unlockReq — the passkey-derived secret for unwrapping the store encryption
|
||||||
// encryption key at cold-start. mavend reads the wrapped blob from its own
|
// key at cold-start. mavend reads the wrapped blob from its own configured
|
||||||
// configured path; the public key is the other half needed for unwrapping.
|
// path; this is the other half. Same PRF-output contract as above.
|
||||||
type unlockReq struct {
|
type unlockReq struct {
|
||||||
PublicKey []byte `json:"public_key"`
|
Secret []byte `json:"secret"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrToolNotFound — no tool row with this name (re-exported store sentinel for
|
// ErrToolNotFound — no tool row with this name (re-exported store sentinel for
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+131
-4
@@ -72,7 +72,9 @@ var readOnlyMethods = map[Method]bool{
|
|||||||
MethodListTasks: true,
|
MethodListTasks: true,
|
||||||
MethodTickTrace: true,
|
MethodTickTrace: true,
|
||||||
MethodMorningStatus: true,
|
MethodMorningStatus: true,
|
||||||
|
MethodMCPServers: true,
|
||||||
MethodDayPlan: true,
|
MethodDayPlan: true,
|
||||||
|
MethodRecentEvents: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dial connects to a core socket at path and returns a Client. The module
|
// Dial connects to a core socket at path and returns a Client. The module
|
||||||
@@ -392,12 +394,16 @@ func (c *Client) AssertStepUp(ctx context.Context) error {
|
|||||||
return c.call(ctx, MethodAssertStepUp, nil, nil)
|
return c.call(ctx, MethodAssertStepUp, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) StoreEncryptionKey(ctx context.Context, publicKey []byte) error {
|
// StoreEncryptionKey wraps the daemon's at-rest key under secret, the 32-byte
|
||||||
return c.call(ctx, MethodStoreEncryptionKey, storeEncryptionKeyReq{PublicKey: publicKey}, nil)
|
// WebAuthn PRF output for the freshly enrolled credential.
|
||||||
|
func (c *Client) StoreEncryptionKey(ctx context.Context, secret []byte) error {
|
||||||
|
return c.call(ctx, MethodStoreEncryptionKey, storeEncryptionKeyReq{Secret: secret}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Unlock(ctx context.Context, publicKey []byte) error {
|
// Unlock hands the daemon the PRF secret so it can unwrap its at-rest key and
|
||||||
return c.call(ctx, MethodUnlock, unlockReq{PublicKey: publicKey}, nil)
|
// open the store. Refused unless a passkey assertion was verified first.
|
||||||
|
func (c *Client) Unlock(ctx context.Context, secret []byte) error {
|
||||||
|
return c.call(ctx, MethodUnlock, unlockReq{Secret: secret}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) LookupTool(ctx context.Context, name string) (Tool, error) {
|
func (c *Client) LookupTool(ctx context.Context, name string) (Tool, error) {
|
||||||
@@ -459,6 +465,111 @@ func (c *Client) IngestMail(ctx context.Context, req IngestMailReq) (IngestMailR
|
|||||||
return r, nil
|
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 {
|
func (c *Client) DismissProposedRoutine(ctx context.Context, id int64) error {
|
||||||
return c.call(ctx, MethodDismissProposedRoutine, dismissProposedRoutineReq{ID: id}, nil)
|
return c.call(ctx, MethodDismissProposedRoutine, dismissProposedRoutineReq{ID: id}, nil)
|
||||||
}
|
}
|
||||||
@@ -483,6 +594,22 @@ func (c *Client) TickTrace(ctx context.Context) (TickTrace, error) {
|
|||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error) {
|
||||||
|
var e []IntakeEvent
|
||||||
|
if err := c.call(ctx, MethodRecentEvents, nReq{N: n}, &e); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return e, 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) {
|
func (c *Client) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
|
||||||
var s []MorningRoutineStatus
|
var s []MorningRoutineStatus
|
||||||
if err := c.call(ctx, MethodMorningStatus, nil, &s); err != nil {
|
if err := c.call(ctx, MethodMorningStatus, nil, &s); err != nil {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
@@ -598,3 +599,44 @@ func TestIngestMail_Hook(t *testing.T) {
|
|||||||
t.Errorf("req across the wire = %+v", got)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+239
-14
@@ -211,6 +211,16 @@ func (a *storeAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, e
|
|||||||
return nil, errors.New("store: morning status not available via direct store API")
|
return nil, errors.New("store: morning status not available via direct store API")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecentEvents — same shape as TickTrace: the intake journal is a bounded ring
|
||||||
|
// in the daemon's memory, not a table, so a bare store cannot serve it.
|
||||||
|
func (a *storeAPI) RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error) {
|
||||||
|
return nil, errors.New("store: intake events 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) {
|
func (a *storeAPI) DayPlan(ctx context.Context) (DayPlan, error) {
|
||||||
return DayPlan{}, errors.New("store: day plan not available via direct store API")
|
return DayPlan{}, errors.New("store: day plan not available via direct store API")
|
||||||
}
|
}
|
||||||
@@ -416,8 +426,8 @@ type Server struct {
|
|||||||
// MethodAssertStepUp returns ErrUnknownMethod (same as pre-stepup floor).
|
// MethodAssertStepUp returns ErrUnknownMethod (same as pre-stepup floor).
|
||||||
StepUp StepUpFunc
|
StepUp StepUpFunc
|
||||||
|
|
||||||
// WrapKeyFn — wraps the in-memory store encryption key with a passkey
|
// WrapKeyFn — wraps the in-memory store encryption key under the passkey
|
||||||
// credential public key (HKDF-AESGCM) and writes the wrapped blob to disk.
|
// PRF secret (HKDF-AESGCM) and writes the wrapped blob to disk.
|
||||||
// Set by the daemon; nil ⇒ MethodStoreEncryptionKey returns ErrUnknownMethod.
|
// Set by the daemon; nil ⇒ MethodStoreEncryptionKey returns ErrUnknownMethod.
|
||||||
WrapKeyFn WrapKeyFunc
|
WrapKeyFn WrapKeyFunc
|
||||||
|
|
||||||
@@ -432,8 +442,52 @@ type Server struct {
|
|||||||
// every CoreAPI implementation has to carry.
|
// every CoreAPI implementation has to carry.
|
||||||
IngestMailFn IngestMailFunc
|
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
|
// UnlockFn — unwraps the store encryption key from the wrapped blob using
|
||||||
// the passkey credential public key, opens the encrypted store, and wires
|
// the passkey PRF secret, opens the encrypted store, and wires
|
||||||
// the rest of the daemon (voice, loop, delivery). Set by the daemon when
|
// the rest of the daemon (voice, loop, delivery). Set by the daemon when
|
||||||
// in locked mode; nil ⇒ MethodUnlock returns ErrUnknownMethod.
|
// in locked mode; nil ⇒ MethodUnlock returns ErrUnknownMethod.
|
||||||
UnlockFn UnlockFunc
|
UnlockFn UnlockFunc
|
||||||
@@ -442,17 +496,39 @@ type Server struct {
|
|||||||
// absolute ts supplied by callers, so this isn't load-bearing for live ops.
|
// absolute ts supplied by callers, so this isn't load-bearing for live ops.
|
||||||
}
|
}
|
||||||
|
|
||||||
// WrapKeyFunc — wraps the store encryption key with the given credential
|
// WrapKeyFunc — wraps the store encryption key under the passkey-derived
|
||||||
// public key and persists the wrapped blob.
|
// secret (a 32-byte WebAuthn PRF output) and persists the wrapped blob.
|
||||||
type WrapKeyFunc func(ctx context.Context, publicKey []byte) error
|
type WrapKeyFunc func(ctx context.Context, secret []byte) error
|
||||||
|
|
||||||
// UnlockFunc — unwraps the store encryption key using the given credential
|
// UnlockFunc — unwraps the store encryption key using the passkey-derived
|
||||||
// public key and completes daemon initialization.
|
// secret and completes daemon initialization.
|
||||||
type UnlockFunc func(ctx context.Context, publicKey []byte) error
|
type UnlockFunc func(ctx context.Context, secret []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.
|
// IngestMailFunc — core-side mail extraction. Returns what was captured.
|
||||||
type IngestMailFunc func(ctx context.Context, req IngestMailReq) (IngestMailResp, error)
|
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
|
// CheckFunc — the auth hook signature. Wired by the daemon (auth.Gate.Check
|
||||||
// satisfies this); dispatch calls it once per request after param-unmarshal
|
// satisfies this); dispatch calls it once per request after param-unmarshal
|
||||||
// independence (it gets the raw params, may unmarshal what it needs — ipc
|
// independence (it gets the raw params, may unmarshal what it needs — ipc
|
||||||
@@ -620,9 +696,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)
|
// 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.
|
// is still honored on the very next request with no extra plumbing here.
|
||||||
//
|
//
|
||||||
// MethodAssertStepUp, MethodStoreEncryptionKey, MethodUnlock and
|
// MethodAssertStepUp, MethodStoreEncryptionKey, MethodUnlock,
|
||||||
// MethodIngestMail are NOT in this table: they bypass CoreAPI entirely
|
// MethodIngestMail, MethodSwapModel, MethodModelStatus,
|
||||||
// (s.StepUp / s.WrapKeyFn / s.UnlockFn / s.IngestMailFn), so dispatch
|
// 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.
|
// special-cases them before consulting the table.
|
||||||
var methodTable = map[Method]handlerFunc{
|
var methodTable = map[Method]handlerFunc{
|
||||||
MethodWriteFact: withParams(func(ctx context.Context, api CoreAPI, p WriteFactReq) (idResp, error) {
|
MethodWriteFact: withParams(func(ctx context.Context, api CoreAPI, p WriteFactReq) (idResp, error) {
|
||||||
@@ -812,6 +890,26 @@ var methodTable = map[Method]handlerFunc{
|
|||||||
MethodMorningStatus: withoutParams(func(ctx context.Context, api CoreAPI) ([]MorningRoutineStatus, error) {
|
MethodMorningStatus: withoutParams(func(ctx context.Context, api CoreAPI) ([]MorningRoutineStatus, error) {
|
||||||
return api.MorningStatus(ctx)
|
return api.MorningStatus(ctx)
|
||||||
}),
|
}),
|
||||||
|
MethodRecentEvents: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]IntakeEvent, error) {
|
||||||
|
out, err := api.RecentEvents(ctx, p.N)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = []IntakeEvent{}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}),
|
||||||
|
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
|
// dispatch unmarshals params for req.Method and calls the matching CoreAPI
|
||||||
@@ -847,7 +945,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
|
|||||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return marshalResult(nil), s.WrapKeyFn(ctx, p.PublicKey)
|
return marshalResult(nil), s.WrapKeyFn(ctx, p.Secret)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||||
|
|
||||||
@@ -857,7 +955,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
|
|||||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return marshalResult(nil), s.UnlockFn(ctx, p.PublicKey)
|
return marshalResult(nil), s.UnlockFn(ctx, p.Secret)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||||
|
|
||||||
@@ -874,6 +972,133 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
|
|||||||
return marshalResult(resp), nil
|
return marshalResult(resp), nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
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]
|
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,12 @@ func (UnimplementedCoreAPI) TickTrace(ctx context.Context) (TickTrace, error) {
|
|||||||
func (UnimplementedCoreAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
|
func (UnimplementedCoreAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
|
||||||
return nil, ErrNotImplemented
|
return nil, ErrNotImplemented
|
||||||
}
|
}
|
||||||
|
func (UnimplementedCoreAPI) RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error) {
|
||||||
|
return nil, ErrNotImplemented
|
||||||
|
}
|
||||||
|
func (UnimplementedCoreAPI) MCPServers(ctx context.Context) ([]MCPServerStatus, error) {
|
||||||
|
return nil, ErrNotImplemented
|
||||||
|
}
|
||||||
func (UnimplementedCoreAPI) DayPlan(ctx context.Context) (DayPlan, error) {
|
func (UnimplementedCoreAPI) DayPlan(ctx context.Context) (DayPlan, error) {
|
||||||
return DayPlan{}, ErrNotImplemented
|
return DayPlan{}, ErrNotImplemented
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package ipc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The wire must carry the PRF secret, not the credential public key. This is
|
||||||
|
// the field rename that fixes Vikunja #14: a v1 deployment sent "public_key",
|
||||||
|
// and the value it sent was in passkeys.json next to the wrapped blob.
|
||||||
|
func TestUnlockWireCarriesSecret(t *testing.T) {
|
||||||
|
secret := bytes.Repeat([]byte{7}, 32)
|
||||||
|
for _, p := range []any{unlockReq{Secret: secret}, storeEncryptionKeyReq{Secret: secret}} {
|
||||||
|
b, err := json.Marshal(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal %T: %v", p, err)
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(b, &m); err != nil {
|
||||||
|
t.Fatalf("unmarshal %T: %v", p, err)
|
||||||
|
}
|
||||||
|
if _, ok := m["secret"]; !ok {
|
||||||
|
t.Errorf("%T has no \"secret\" field: %s", p, b)
|
||||||
|
}
|
||||||
|
if _, ok := m["public_key"]; ok {
|
||||||
|
t.Errorf("%T still sends \"public_key\": %s", p, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The secret must reach the daemon hook byte-for-byte through the socket.
|
||||||
|
func TestUnlockDeliversSecretToHook(t *testing.T) {
|
||||||
|
_, srv, cli, _ := newServerWithStore(t)
|
||||||
|
|
||||||
|
secret := make([]byte, 32)
|
||||||
|
for i := range secret {
|
||||||
|
secret[i] = byte(i + 1)
|
||||||
|
}
|
||||||
|
var gotUnlock, gotWrap []byte
|
||||||
|
srv.UnlockFn = func(_ context.Context, s []byte) error { gotUnlock = bytes.Clone(s); return nil }
|
||||||
|
srv.WrapKeyFn = func(_ context.Context, s []byte) error { gotWrap = bytes.Clone(s); return nil }
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := cli.Unlock(ctx, secret); err != nil {
|
||||||
|
t.Fatalf("Unlock: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(gotUnlock, secret) {
|
||||||
|
t.Errorf("UnlockFn got %x, want %x", gotUnlock, secret)
|
||||||
|
}
|
||||||
|
if err := cli.StoreEncryptionKey(ctx, secret); err != nil {
|
||||||
|
t.Fatalf("StoreEncryptionKey: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(gotWrap, secret) {
|
||||||
|
t.Errorf("WrapKeyFn got %x, want %x", gotWrap, secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refusal from the daemon hook — a wrong passkey, or no prior assertion —
|
||||||
|
// must surface to the caller as an error, never be swallowed into success.
|
||||||
|
func TestUnlockPropagatesRefusal(t *testing.T) {
|
||||||
|
_, srv, cli, _ := newServerWithStore(t)
|
||||||
|
srv.UnlockFn = func(context.Context, []byte) error {
|
||||||
|
return errors.New("unlock: no verified passkey assertion (assert first)")
|
||||||
|
}
|
||||||
|
if err := cli.Unlock(context.Background(), bytes.Repeat([]byte{9}, 32)); err == nil {
|
||||||
|
t.Fatal("a refused unlock reported success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without the hooks wired — the normal, unencrypted deployment — both methods
|
||||||
|
// answer ErrUnknownMethod rather than pretending to have done something.
|
||||||
|
func TestUnlockUnwiredIsUnknownMethod(t *testing.T) {
|
||||||
|
_, _, cli, _ := newServerWithStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := cli.Unlock(ctx, bytes.Repeat([]byte{1}, 32)); err == nil {
|
||||||
|
t.Error("Unlock succeeded with no UnlockFn wired")
|
||||||
|
}
|
||||||
|
if err := cli.StoreEncryptionKey(ctx, bytes.Repeat([]byte{1}, 32)); err == nil {
|
||||||
|
t.Error("StoreEncryptionKey succeeded with no WrapKeyFn wired")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locked mode: Server.Check is the whole authorization surface, and it must
|
||||||
|
// default-deny everything except the two methods the unlock flow needs.
|
||||||
|
func TestLockedCheckDefaultDenies(t *testing.T) {
|
||||||
|
_, srv, cli, _ := newServerWithStore(t)
|
||||||
|
|
||||||
|
locked := errors.New("locked")
|
||||||
|
srv.Check = func(_ context.Context, m Method, _ json.RawMessage) error {
|
||||||
|
switch m {
|
||||||
|
case MethodAssertStepUp, MethodUnlock:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return locked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unlocked := false
|
||||||
|
srv.UnlockFn = func(context.Context, []byte) error { unlocked = true; return nil }
|
||||||
|
srv.StepUp = func(context.Context) error { return nil }
|
||||||
|
srv.WrapKeyFn = func(context.Context, []byte) error { return nil }
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
// A store method must be refused while locked.
|
||||||
|
if _, err := cli.RecentNotes(ctx, 5); err == nil {
|
||||||
|
t.Error("a store read went through while locked")
|
||||||
|
}
|
||||||
|
// Key wrapping is NOT on the allowlist: a locked daemon has no key to wrap.
|
||||||
|
if err := cli.StoreEncryptionKey(ctx, bytes.Repeat([]byte{2}, 32)); err == nil {
|
||||||
|
t.Error("StoreEncryptionKey was allowed while locked")
|
||||||
|
}
|
||||||
|
// The unlock flow itself must still work.
|
||||||
|
if err := cli.AssertStepUp(ctx); err != nil {
|
||||||
|
t.Errorf("AssertStepUp refused while locked: %v", err)
|
||||||
|
}
|
||||||
|
if err := cli.Unlock(ctx, bytes.Repeat([]byte{3}, 32)); err != nil {
|
||||||
|
t.Errorf("Unlock refused while locked: %v", err)
|
||||||
|
}
|
||||||
|
if !unlocked {
|
||||||
|
t.Error("UnlockFn never ran")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,12 +45,24 @@ const (
|
|||||||
MethodRevertFact Method = "revert_fact"
|
MethodRevertFact Method = "revert_fact"
|
||||||
MethodTickTrace Method = "tick_trace"
|
MethodTickTrace Method = "tick_trace"
|
||||||
MethodMorningStatus Method = "morning_status"
|
MethodMorningStatus Method = "morning_status"
|
||||||
|
MethodMCPServers Method = "mcp_servers"
|
||||||
MethodDayPlan Method = "day_plan"
|
MethodDayPlan Method = "day_plan"
|
||||||
MethodChat Method = "chat"
|
MethodChat Method = "chat"
|
||||||
MethodCaptureTask Method = "capture_task"
|
MethodCaptureTask Method = "capture_task"
|
||||||
MethodListTasks Method = "list_tasks"
|
MethodListTasks Method = "list_tasks"
|
||||||
MethodSetTaskStatus Method = "set_task_status"
|
MethodSetTaskStatus Method = "set_task_status"
|
||||||
MethodIngestMail Method = "ingest_mail"
|
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"
|
||||||
|
MethodRecentEvents Method = "recent_events"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Request — one frame from module to core. Params is the JSON-encoded argument
|
// Request — one frame from module to core. Params is the JSON-encoded argument
|
||||||
|
|||||||
+27
-1
@@ -10,10 +10,17 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
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
|
base string
|
||||||
http *http.Client
|
http *http.Client
|
||||||
}
|
}
|
||||||
@@ -22,6 +29,25 @@ func New(baseURL string, timeout time.Duration) *Client {
|
|||||||
return &Client{base: baseURL, http: &http.Client{Timeout: timeout}}
|
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 {
|
type Req struct {
|
||||||
System string
|
System string
|
||||||
User string
|
User string
|
||||||
@@ -63,7 +89,7 @@ func (c *Client) Complete(ctx context.Context, r Req) (string, error) {
|
|||||||
RepeatPenalty: r.RepeatPenalty,
|
RepeatPenalty: r.RepeatPenalty,
|
||||||
Stop: r.Stop,
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,3 +57,37 @@ func TestComplete(t *testing.T) {
|
|||||||
t.Errorf("got %q, want %q", got, "ok")
|
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]
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user