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 { 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() return fmt.Errorf("llm: server output: %w", err) case <-ctx.Done(): _ = cmd.Process.Kill() return ctx.Err() case <-time.After(60 * time.Second): _ = cmd.Process.Kill() 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.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 } 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) { req := chatReq{ Messages: []chatMsg{ {Role: "system", Content: systemPrompt()}, {Role: "user", Content: userPrompt}, }, Temperature: 0.7, MaxTokens: 256, } 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 }