6239eca243
Completes the three in-flight open items and fixes the away-fallthrough bug. Item 7 — passkey step-up (WebAuthn): - internal/webauthn: ES256/P-256 register + assert with real ecdsa signature verification, minimal CBOR/COSE decode, PasskeySession (L2→L3 on assert, decays after TTL). Drop the RS256 offer we can't verify (register-ok/ assert-fail trap). Verify rpIdHash + UP/UV flags in FinishAssertion — UV is the step-up gesture. Round-trip test with negative cases (tampered sig, missing UV, wrong origin). - cmd/mavweb: /auth/passkey enroll+assert page (the only surface that can do a WebAuthn gesture) + the four begin/finish endpoints. Without this the daemon's PasskeySession swap leaves /tools enable permanently blocked. - daemon wires PasskeySession as the auth Session + srv.StepUp; policy gates MethodAssertStepUp at AuthRead. Item 5 — tools page: DisableTool through store/ipc/client/wire; /tools grows a disable action and a link to the passkey page. Lifecycle test. Item 6 — note RAG: PhraseQuery on the phraser (LLM-composed answer over top-k notes, raw-notes fallback); IntentQuery routes through it. Stub returns a deterministic summary. Item 2 — away-fallthrough: on ErrVoiceNoSession the dispatcher now reroutes through the AWAY table (sev3→ntfy, sev4→telegram-repeat-til-ack, sev≤2→drop) instead of silently dropping / mis-routing to the present-list remainder. Covers DispatchNudge + DispatchReminder. 4 tests. Also: re-add ProposeTool to CoreAPI (dropped in a comment rewrite), fix missing imports + a duplicate block left mid-edit, drop dead AssertStepUpFunc, gitignore /mavcaldav. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
335 lines
8.9 KiB
Go
335 lines
8.9 KiB
Go
package phraser
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os/exec"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/delivery"
|
|
"github.com/kami/maven/internal/loop"
|
|
)
|
|
|
|
var listenRE = regexp.MustCompile(`listening on (https?://\S+)`)
|
|
|
|
type LLMPhraser struct {
|
|
cfg Config
|
|
client *http.Client
|
|
port string
|
|
cmd *exec.Cmd
|
|
cancel context.CancelFunc
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
type Config struct {
|
|
ModelPath string
|
|
BinPath string
|
|
Listen string
|
|
NGpuLayers int
|
|
NCtx int
|
|
Timeout time.Duration
|
|
}
|
|
|
|
func DefaultConfig(modelPath string) Config {
|
|
return Config{
|
|
ModelPath: modelPath,
|
|
BinPath: "llama-server",
|
|
Listen: "127.0.0.1:0",
|
|
NGpuLayers: -1,
|
|
NCtx: 2048,
|
|
Timeout: 30 * time.Second,
|
|
}
|
|
}
|
|
|
|
func NewLLMPhraser(ctx context.Context, cfg Config) (*LLMPhraser, error) {
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
p := &LLMPhraser{
|
|
cfg: cfg,
|
|
client: &http.Client{Timeout: cfg.Timeout},
|
|
cancel: cancel,
|
|
}
|
|
if err := p.start(ctx); err != nil {
|
|
cancel()
|
|
return nil, err
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func (p *LLMPhraser) start(ctx context.Context) error {
|
|
args := []string{
|
|
"-m", p.cfg.ModelPath,
|
|
"--host", "127.0.0.1",
|
|
"--port", extractPort(p.cfg.Listen),
|
|
"-c", fmt.Sprintf("%d", p.cfg.NCtx),
|
|
"-ngl", fmt.Sprintf("%d", p.cfg.NGpuLayers),
|
|
"--no-webui",
|
|
}
|
|
cmd := exec.CommandContext(ctx, p.cfg.BinPath, args...)
|
|
// Pdeathsig: the kernel SIGKILLs llama-server the moment mavend dies — by
|
|
// ANY means, including SIGKILL/OOM/panic where our Close() never runs. Without
|
|
// it a hard-killed mavend orphans its llama-server (reparented to init, keeps
|
|
// eating GPU/RAM); repeated dev restarts pile up orphans until the box OOMs.
|
|
// Setpgid isolates it in its own process group so a stray Ctrl-C on the
|
|
// terminal group doesn't half-kill it out from under us. (Linux-only, like
|
|
// the rest of the daemon.)
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGKILL}
|
|
p.cmd = cmd
|
|
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("llm: stderr pipe: %w", err)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
stderr.Close()
|
|
return fmt.Errorf("llm: start: %w", err)
|
|
}
|
|
|
|
portCh := make(chan string, 1)
|
|
errCh := make(chan error, 1)
|
|
p.wg.Add(1)
|
|
go func() {
|
|
defer p.wg.Done()
|
|
buf := make([]byte, 4096)
|
|
var leftover []byte
|
|
for {
|
|
n, err := stderr.Read(buf)
|
|
if n > 0 {
|
|
data := append(leftover, buf[:n]...)
|
|
lines := bytes.Split(data, []byte("\n"))
|
|
for _, line := range lines[:len(lines)-1] {
|
|
if m := listenRE.FindSubmatch(line); len(m) > 1 {
|
|
addr := string(m[1])
|
|
portCh <- addr
|
|
close(portCh)
|
|
}
|
|
}
|
|
leftover = lines[len(lines)-1]
|
|
}
|
|
if err != nil {
|
|
errCh <- err
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case addr := <-portCh:
|
|
p.port = addr
|
|
return nil
|
|
case err := <-errCh:
|
|
_ = cmd.Process.Kill()
|
|
_ = cmd.Wait()
|
|
return fmt.Errorf("llm: server output: %w", err)
|
|
case <-ctx.Done():
|
|
_ = cmd.Process.Kill()
|
|
_ = cmd.Wait()
|
|
return ctx.Err()
|
|
case <-time.After(60 * time.Second):
|
|
_ = cmd.Process.Kill()
|
|
_ = cmd.Wait()
|
|
return fmt.Errorf("llm: server did not start within 60s")
|
|
}
|
|
}
|
|
|
|
func (p *LLMPhraser) Close() error {
|
|
p.cancel()
|
|
if p.cmd != nil && p.cmd.Process != nil {
|
|
_ = p.cmd.Process.Kill()
|
|
_ = p.cmd.Wait() // reap the process — without Wait, the child becomes a zombie
|
|
}
|
|
p.wg.Wait()
|
|
return nil
|
|
}
|
|
|
|
func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) {
|
|
prompt := buildNudgePrompt(c)
|
|
resp, err := p.chat(ctx, prompt)
|
|
if err != nil {
|
|
return delivery.PhrasedNudge{}, err
|
|
}
|
|
body, summary := parsePhrase(resp)
|
|
if body == "" {
|
|
body = fmt.Sprintf("%s — %s", c.Rule.Name, sevLabel(c.Severity))
|
|
}
|
|
if summary == "" {
|
|
summary = c.Rule.Name
|
|
}
|
|
return delivery.PhrasedNudge{Candidate: c, Body: body, Summary: summary}, nil
|
|
}
|
|
|
|
// PhraseQuery prompts the LLM with the user's utterance and matching notes to
|
|
// compose a natural answer. Falls back to "вот что я нашла: <notes>" on any
|
|
// LLM error — better to give the raw data than silence.
|
|
func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error) {
|
|
if len(notes) == 0 {
|
|
return "у меня нет заметок по этому вопросу.", nil
|
|
}
|
|
if len(notes) == 1 {
|
|
notes[0] = strings.TrimSpace(notes[0])
|
|
}
|
|
sys := "You are maven, a self-hosted personal assistant answering from your notes. Answer briefly and naturally in Russian starting with \"вот что я нашла: \". Respond with just the answer text, no JSON wrapper."
|
|
prompt := fmt.Sprintf(
|
|
`The user asks: "%s". Your notes matching the query contain: "%s". Answer them naturally and briefly. If the notes don't answer the question, say so.`,
|
|
utterance, strings.Join(notes, `"; "`),
|
|
)
|
|
resp, err := p.chatWithSystem(ctx, sys, prompt, 256)
|
|
if err != nil {
|
|
if len(notes) == 1 {
|
|
return "вот что я нашла: " + notes[0], nil
|
|
}
|
|
return "вот что я нашла: " + strings.Join(notes, "; "), nil
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error) {
|
|
text := extractReminderText(d.Reminder.Payload)
|
|
if text == "" {
|
|
text = "reminder"
|
|
}
|
|
|
|
prompt := fmt.Sprintf(
|
|
`The user set a reminder: "%s". Rephrase it briefly as a gentle nudge. Respond as JSON: {"body": "...", "summary": "..."}`,
|
|
text,
|
|
)
|
|
resp, err := p.chat(ctx, prompt)
|
|
if err != nil {
|
|
return delivery.PhrasedReminder{}, err
|
|
}
|
|
body, summary := parsePhrase(resp)
|
|
if body == "" {
|
|
body = text
|
|
}
|
|
if summary == "" {
|
|
summary = text
|
|
if len(summary) > 60 {
|
|
summary = summary[:57] + "..."
|
|
}
|
|
}
|
|
return delivery.PhrasedReminder{Decision: d, Body: body, Summary: summary}, nil
|
|
}
|
|
|
|
type chatMsg struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
type chatReq struct {
|
|
Model string `json:"model"`
|
|
Messages []chatMsg `json:"messages"`
|
|
Temperature float64 `json:"temperature"`
|
|
MaxTokens int `json:"max_tokens"`
|
|
}
|
|
|
|
type chatResp struct {
|
|
Choices []struct {
|
|
Message struct {
|
|
Content string `json:"content"`
|
|
} `json:"message"`
|
|
} `json:"choices"`
|
|
}
|
|
|
|
func (p *LLMPhraser) chat(ctx context.Context, userPrompt string) (string, error) {
|
|
return p.chatWithSystem(ctx, systemPrompt(), userPrompt, 256)
|
|
}
|
|
|
|
func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, maxTokens int) (string, error) {
|
|
req := chatReq{
|
|
Messages: []chatMsg{
|
|
{Role: "system", Content: system},
|
|
{Role: "user", Content: user},
|
|
},
|
|
Temperature: 0.7,
|
|
MaxTokens: maxTokens,
|
|
}
|
|
body, err := json.Marshal(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("llm: marshal: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", p.port+"/v1/chat/completions", bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", fmt.Errorf("llm: request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := p.client.Do(httpReq)
|
|
if err != nil {
|
|
return "", fmt.Errorf("llm: post: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("llm: read: %w", err)
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
return "", fmt.Errorf("llm: status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
|
}
|
|
|
|
var cr chatResp
|
|
if err := json.Unmarshal(raw, &cr); err != nil {
|
|
return "", fmt.Errorf("llm: parse: %w", err)
|
|
}
|
|
if len(cr.Choices) == 0 {
|
|
return "", fmt.Errorf("llm: no choices in response")
|
|
}
|
|
return cr.Choices[0].Message.Content, nil
|
|
}
|
|
|
|
func systemPrompt() string {
|
|
return `You are maven, a self-hosted personal assistant. Generate brief, natural nudge messages in the user's language (Russian or English). Respond ONLY with valid JSON: {"body": "full voice message", "summary": "brief away-channel version (<60 chars)"}. body is what the user hears on voice; summary is for push notifications (ntfy/telegram) — minimal, no exfil detail.`
|
|
}
|
|
|
|
func buildNudgePrompt(c loop.Candidate) string {
|
|
var ctxParts []string
|
|
ctxParts = append(ctxParts, fmt.Sprintf("Rule: %s", c.Rule.Name))
|
|
ctxParts = append(ctxParts, fmt.Sprintf("Severity: %s", sevLabel(c.Severity)))
|
|
|
|
if d, ok := c.State.Since(c.Rule.Name); ok {
|
|
ctxParts = append(ctxParts, fmt.Sprintf("Duration since last event: %s", humanDur(d)))
|
|
}
|
|
|
|
return fmt.Sprintf(
|
|
`Generate a nudge message. Context:
|
|
%s
|
|
|
|
Respond as JSON: {"body": "...", "summary": "..."}`,
|
|
strings.Join(ctxParts, "\n"),
|
|
)
|
|
}
|
|
|
|
func parsePhrase(raw string) (body, summary string) {
|
|
cleaned := strings.TrimSpace(raw)
|
|
start := strings.Index(cleaned, "{")
|
|
end := strings.LastIndex(cleaned, "}")
|
|
if start < 0 || end < 0 || end <= start {
|
|
return "", ""
|
|
}
|
|
var parsed struct {
|
|
Body string `json:"body"`
|
|
Summary string `json:"summary"`
|
|
}
|
|
if err := json.Unmarshal([]byte(cleaned[start:end+1]), &parsed); err != nil {
|
|
return "", ""
|
|
}
|
|
return parsed.Body, parsed.Summary
|
|
}
|
|
|
|
func extractPort(listen string) string {
|
|
_, port, _ := strings.Cut(listen, ":")
|
|
if port == "" {
|
|
return "0"
|
|
}
|
|
return port
|
|
}
|