Compare commits

..

8 Commits

Author SHA1 Message Date
claude 947506c7b8 docs: a list is the fourth append-only shape (V-453) 2026-08-04 04:45:41 +04:00
claude 6c67e61962 mavend: cover the spoken list path (V-453) 2026-08-04 04:45:21 +04:00
claude 0990f32808 mavend: the list is reachable from voice (V-453)
An add and a crossing-off run at the top of actionNote, next to task
capture and before the embedding is paid for; the read-back is a query
source sitting beside "tasks", so the recall pass cannot answer "что мне
купить?" from an old note about the shop.

Crossing off one item claims the turn only when the list actually holds
that item, which is what keeps "купил новый ноутбук" a note.

These read h.dataStore rather than the CoreAPI: a list is local to the core
and nothing outside it writes one. The ipc seam is what it grows through
when something outside mavend needs to add to a list.
2026-08-04 04:45:21 +04:00
claude d41878c2b1 router: cover the list parsers and wire the grammars (V-453) 2026-08-04 04:45:13 +04:00
claude e023638135 router: parse list capture, read-back and crossing off (V-453)
Same posture as task capture and for the same reason: the intent enum is a
contract shared with the relabelling prompt, so a list is not an eighth
intent. It is a note-shaped or query-shaped utterance carrying an explicit
marker, and the marker is a lookup.

The markers are deliberately explicit — "молоко закончилось" is an
observation and stays a note. The list tag is matched by stem, because
Russian declines it: "список покупок", "в покупки" and "в покупках" are one
list. ListGrammars puts both halves at stage 0, so an add and a read-back
never depend on the model having a good turn.
2026-08-04 04:45:13 +04:00
claude 0d52344d27 store: cover the list_items shape with tests (V-453) 2026-08-04 04:39:24 +04:00
claude 5bd303788b store: add list_items, the fourth append-only shape (V-453)
A list is a standing set of short strings under a tag. Not a task, because
milk is not work and the prioritiser must not count it as an errand; not a
fact, because it claims nothing. Nothing predicates over it, so two people
adding to the same list at once costs nothing.

Migration #19, plus AddListItem, ListItems, SetListItemStatus and ClearList.
The live-only unique index is the tasks one, per list: молоко twice before
the shop is one row, молоко again after it was crossed off is a new one.
2026-08-04 04:39:24 +04:00
claude afac8fb670 mavend: run the persona checks before she speaks (V-399)
The checks stay in the eval package and the daemon calls three of them:
feminine, address, and a new leaked-reasoning test. No retry — it doubles
the latency on the turn that is already going badly, and on the nudge path
the moment has passed. A failure falls back to the deterministic floor and
is logged with the whole rejected text and counted by check name.

hisgender is deliberately not run: the simulator showed it rejecting
"записала, что ты выпил воды", which is her own correct self-reference.
2026-08-04 04:35:42 +04:00
94 changed files with 3007 additions and 4121 deletions
+2 -5
View File
@@ -229,11 +229,8 @@ world questions, so she needs to read external sources. What replaces it:
## Web UI conventions
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the shell
partial in `cmd/mavweb/shell.html`: a page opens with `{{template "shellTop" "<page-key>"}}`
and closes with `{{template "shellBottom"}}`, and the key marks the active sidebar link.
Every page is its own embedded `.html` file next to `main.go` — no page markup lives in Go,
and the sidebar is data (`sidebarSections`, `pageIcon`) the template renders. No
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the `nav`
partial (`navHTML` in `cmd/mavweb/main.go`, `{{template "nav" "<active-page>"}}`). No
per-page `<style>` beyond true one-offs. Wrap every table in `<div class=scroll>` so wide
data pans on a phone. Local preview + headless screenshot recipe is in `AGENTS.md`.
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"context"
"log"
"strings"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
// Standing lists on the voice path (Vikunja #453).
//
// Three halves, mirroring what task capture already does: an add that runs at
// the top of actionNote, a read-back query source, and a crossing-off that runs
// on the same note path because "всё купил" is note-shaped.
//
// These read h.dataStore rather than the CoreAPI. A list is local to the core
// and nothing outside it writes one: the web UI has no list page, no reach
// files groceries, and the digestion worker does not read the table. When
// something outside mavend needs to add to a list, the ipc seam is what it
// grows through — the intake rules that CaptureTaskReq documents are about
// shared intake, and there is none here yet.
//
// Nothing here speaks unprompted. A list is answered when asked about.
// captureListFromNote claims the turn when the utterance adds to, clears, or
// crosses one item off a list. ("", false) hands the turn back to the note path.
func (h *reactiveHandler) captureListFromNote(ctx context.Context, dec router.Decision) (string, bool) {
if h.dataStore == nil {
return "", false
}
// Clearing is read before removing on purpose: "всё купил" and "купил
// молоко" start with the same word, and only the second one names an item.
if list, ok := router.ParseListClear(dec.Utterance); ok {
n, err := h.dataStore.ClearList(ctx, list, h.now())
if err != nil {
log.Printf("voice: clear list: %v", err)
return "не получилось обновить список.", true
}
if n == 0 {
return "в списке и так ничего не было.", true
}
return "вычеркнула всё, список пустой.", true
}
if cap, ok := router.ParseListRemove(dec.Utterance); ok {
if reply, ok := h.removeListItem(ctx, cap); ok {
return reply, true
}
// Nothing on the list by that name. "купил новый ноутбук" is a note and
// must stay one, so the turn goes back rather than claiming a removal
// that removed nothing.
return "", false
}
cap, ok := router.ParseListCapture(dec.Utterance)
if !ok {
return "", false
}
res, err := h.dataStore.AddListItem(ctx, store.ListItem{
List: cap.List,
Item: cap.Item,
Source: "tap:voice",
CreatedTs: h.now(),
})
if err != nil {
log.Printf("voice: add list item: %v", err)
return "не получилось добавить в список.", true
}
if !res.Created {
return cap.Item + " уже в списке.", true
}
return "добавила в список: " + cap.Item + ".", true
}
// removeListItem crosses one named item off. It reports false when the list
// holds nothing by that name, which is what keeps the marker words from
// swallowing ordinary notes.
func (h *reactiveHandler) removeListItem(ctx context.Context, cap router.ListCapture) (string, bool) {
items, err := h.dataStore.ListItems(ctx, cap.List, "")
if err != nil {
log.Printf("voice: list items: %v", err)
return "", false
}
want := store.NormalizeTaskText(cap.Item)
for _, li := range items {
if store.NormalizeTaskText(li.Item) != want {
continue
}
if err := h.dataStore.SetListItemStatus(ctx, li.ID, store.ListItemDone, h.now()); err != nil {
log.Printf("voice: cross off list item: %v", err)
return "не получилось обновить список.", true
}
return "вычеркнула: " + li.Item + ".", true
}
return "", false
}
// queryList — "что в списке покупок?", "что мне купить?".
//
// A query source, so it sits in querySources and either claims the turn or
// passes it on. It is before the recall sources for the reason every specific
// source is: the notes pass would otherwise answer a list question with
// whatever note is nearest.
func (h *reactiveHandler) queryList(ctx context.Context, t *queryTurn) (string, bool) {
list, ok := router.ParseListQuery(t.dec.Utterance)
if !ok || h.dataStore == nil {
return "", false
}
items, err := h.dataStore.ListItems(ctx, list, "")
if err != nil {
log.Printf("voice: list items: %v", err)
return "не получилось посмотреть список.", true
}
return formatListRU(list, items), true
}
// formatListRU reads a list aloud. One sentence, comma-separated, because a
// shopping list is heard in a shop and a numbered recital is unusable there.
func formatListRU(list string, items []store.ListItem) string {
name := "списке " + listGenitive(list)
if len(items) == 0 {
return "в " + name + " пусто."
}
names := make([]string, 0, len(items))
for _, li := range items {
names = append(names, li.Item)
}
return "в " + name + ": " + strings.Join(names, ", ") + "."
}
// listGenitive puts a list tag into the case "список <…>" needs. Russian
// declines the noun and she must not say "в списке покупки".
func listGenitive(list string) string {
switch list {
case "покупки":
return "покупок"
case "аптека":
return "аптеки"
case "хозяйство":
return "хозяйства"
}
return list
}
+184
View File
@@ -0,0 +1,184 @@
package main
import (
"context"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
func listNow() time.Time { return time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) }
func listHandler(t *testing.T) *reactiveHandler {
t.Helper()
return &reactiveHandler{dataStore: newTestStore(t), now: listNow}
}
func say(t *testing.T, h *reactiveHandler, utterance string) (string, bool) {
t.Helper()
return h.captureListFromNote(context.Background(), router.Decision{
Intent: router.IntentNote, Utterance: utterance,
})
}
func TestListCaptureAddsAndReadsBack(t *testing.T) {
h := listHandler(t)
for _, u := range []string{"добавь в список покупок молоко", "добавь в список хлеб"} {
if reply, ok := say(t, h, u); !ok {
t.Fatalf("%q was not claimed (reply %q)", u, reply)
}
}
if reply, ok := say(t, h, "добавь в список покупок молоко"); !ok || !strings.Contains(reply, "уже") {
t.Errorf("second молоко replied %q, %v; want an already-there answer", reply, ok)
}
answer, ok := h.queryList(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что в списке покупок?"},
})
if !ok {
t.Fatal("the list question was not claimed")
}
if !strings.Contains(answer, "молоко") || !strings.Contains(answer, "хлеб") {
t.Errorf("answer %q; want both items", answer)
}
if strings.Contains(answer, "списке покупки") {
t.Errorf("answer %q declines the list name wrong", answer)
}
}
// An utterance with no list marker is a note and must stay one, whichever half
// of the parser it brushes against.
func TestListCapturePassesOrdinaryNotes(t *testing.T) {
h := listHandler(t)
for _, u := range []string{
"молоко закончилось",
"надо бы съездить в магазин",
"купил новый ноутбук",
"добавь в список покупок",
} {
if reply, ok := say(t, h, u); ok {
t.Errorf("%q was claimed as a list turn: %q", u, reply)
}
}
}
func TestListCrossOffOneItemAndThenAll(t *testing.T) {
h := listHandler(t)
for _, u := range []string{
"добавь в список покупок молоко",
"добавь в список покупок хлеб",
"добавь в список аптеки бинт",
} {
if _, ok := say(t, h, u); !ok {
t.Fatalf("%q was not claimed", u)
}
}
reply, ok := say(t, h, "вычеркни молоко")
if !ok || !strings.Contains(reply, "молоко") {
t.Fatalf("cross off replied %q, %v", reply, ok)
}
open, err := h.dataStore.ListItems(context.Background(), "покупки", "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(open) != 1 || open[0].Item != "хлеб" {
t.Fatalf("open list %+v; want only хлеб", open)
}
if reply, ok := say(t, h, "всё купил"); !ok || !strings.Contains(reply, "пустой") {
t.Errorf("clear replied %q, %v", reply, ok)
}
open, err = h.dataStore.ListItems(context.Background(), "покупки", "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(open) != 0 {
t.Errorf("%d items still open after всё купил", len(open))
}
// The other list is untouched, and it is read back on its own.
answer, ok := h.queryList(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: "покажи список аптеки"},
})
if !ok || !strings.Contains(answer, "бинт") {
t.Errorf("аптека answer %q, %v; want бинт", answer, ok)
}
}
func TestQueryListSaysWhenItIsEmpty(t *testing.T) {
h := listHandler(t)
answer, ok := h.queryList(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что мне купить?"},
})
if !ok {
t.Fatal("the list question was not claimed")
}
if !strings.Contains(answer, "пусто") {
t.Errorf("empty answer %q; want it to say so", answer)
}
if _, ok := h.queryList(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие у меня задачи?"},
}); ok {
t.Error("the list source claimed a task question")
}
}
// Stage 0 answers a list turn without the model: the grammars route it, and the
// action handlers re-parse what the grammar matched.
func TestListGrammarsRouteWithoutTheModel(t *testing.T) {
cases := []struct {
utterance string
want router.Intent
}{
{"добавь в список покупок молоко", router.IntentNote},
{"что в списке покупок?", router.IntentQuery},
{"всё купил", router.IntentNote},
}
for _, c := range cases {
var got router.Intent
claimed := false
for _, g := range router.ListGrammars() {
m := g.Pattern.FindStringSubmatch(c.utterance)
if m == nil {
continue
}
if dec, ok := g.Build(m); ok {
got, claimed = dec.Intent, true
break
}
}
if !claimed {
t.Errorf("no list grammar claimed %q", c.utterance)
continue
}
if got != c.want {
t.Errorf("%q routed to %v; want %v", c.utterance, got, c.want)
}
}
for _, g := range router.ListGrammars() {
m := g.Pattern.FindStringSubmatch("напомни купить молоко завтра")
if m == nil {
continue
}
if _, ok := g.Build(m); ok {
t.Errorf("grammar %s claimed a reminder", g.Name)
}
}
}
func TestListStoreSourceIsVoice(t *testing.T) {
h := listHandler(t)
if _, ok := say(t, h, "добавь в список покупок молоко"); !ok {
t.Fatal("not claimed")
}
items, err := h.dataStore.ListItems(context.Background(), "покупки", "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(items) != 1 || items[0].Source != "tap:voice" {
t.Errorf("stored %+v; want one row from tap:voice", items)
}
if items[0].Status != store.ListItemOpen {
t.Errorf("status %q; want open", items[0].Status)
}
}
+6
View File
@@ -17,6 +17,12 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
if reply, ok := h.captureTaskFromNote(ctx, dec); ok {
return reply
}
// A standing list is neither work nor recall (Vikunja #453). Checked here
// for the same reason and at the same cost: before the embedding is paid
// for, and it passes the turn straight back when no marker matches.
if reply, ok := h.captureListFromNote(ctx, dec); ok {
return reply
}
// embed the note text with the same model the classifier uses, persist
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
// facts — no predicate reads it (spec's two-memory split).
+5 -5
View File
@@ -85,6 +85,11 @@ var querySources = []querySource{
// the money facts the poller wrote, and the notes pass would otherwise
// answer it from whatever he once said about spending. Its matcher needs a
// money noun plus an actual ask, so "я потратил весь день" is untouched.
// Next to "tasks" and for the same reason: "что мне купить?" is a question
// about the shopping list, and the recall pass would otherwise answer it
// from an old note about the shop. Its matcher needs an explicit list
// marker, so "надо бы съездить в магазин" is untouched.
{name: "list", answer: (*reactiveHandler).queryList},
{name: "money", answer: (*reactiveHandler).queryMoney},
// Before the recall sources and before general knowledge: "что нового?" is
// a question about the feeds she reads, and general knowledge would answer
@@ -390,11 +395,6 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
if errors.Is(err, weather.ErrNotConfigured) {
return "погода не настроена.", true
}
if errors.Is(err, weather.ErrLocationUnknown) {
// He named a place and the geocoder does not have it. Saying so beats
// reading out the default city's temperature (Vikunja #421).
return "не знаю такого города — " + loc + ".", true
}
if err != nil {
log.Printf("voice: weather: %v", err)
return "не получилось узнать погоду.", true
+13 -13
View File
@@ -106,11 +106,11 @@ func trimClarifyExpired(s string) string {
// out, and "" when nothing was parked. Call it right after
// resolveClarifyAnswer: a live question is answered there, an expired one is
// only reported here — the words themselves still go on to be routed fresh.
func (h *reactiveHandler) clarifyExpiredNotice(ctx context.Context) string {
func (h *reactiveHandler) clarifyExpiredNotice() string {
if h.clarifyStore == nil {
return ""
}
if !h.clarifyStore.TakeExpired(dialogueIDOf(ctx), h.now()) {
if !h.clarifyStore.TakeExpired(voiceDialogueID, h.now()) {
return ""
}
log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh")
@@ -157,7 +157,7 @@ func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
// askClarify parks the request and returns the question to ask instead of the
// canned "не поняла". Returns ("", false) when there is nothing to ask about, so
// the caller falls back to the canned reply.
func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (string, bool) {
func (h *reactiveHandler) askClarify(dec router.Decision) (string, bool) {
if h.clarifyStore == nil {
return "", false
}
@@ -165,7 +165,7 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (
if !ok {
return "", false
}
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{
Intent: dialogue.Intent(dec.Intent),
Slots: toDialogueSlots(dec.Slots),
Missing: []dialogue.Slot{slot},
@@ -192,7 +192,7 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
if h.clarifyStore == nil {
return "", false
}
q := h.clarifyStore.Get(dialogueIDOf(ctx), h.now())
q := h.clarifyStore.Get(voiceDialogueID, h.now())
if q == nil {
return "", false
}
@@ -206,9 +206,9 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
// would fire at 11:00 saying "напомни" and nothing else.
q.Utterance = foldAnswerIntoUtterance(q.Utterance, merged.Text)
if len(dialogue.StillMissing(q.Missing, merged)) > 0 {
return h.reaskOrGiveUp(ctx, q, merged, text), true
return h.reaskOrGiveUp(q, merged, text), true
}
h.clarifyStore.Delete(dialogueIDOf(ctx))
h.clarifyStore.Delete(voiceDialogueID)
// One gap filled is not the same as a complete request. askClarify parks
// only the first gap, because one question per turn is the rule, but a
@@ -217,7 +217,7 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
// a reminder with no time, which answered "не получилось разобрать время
// напоминания." — an error for a request she never finished asking about.
// Re-enter the loop instead, one question at a time as before.
if reply, asked := h.askRemainingGap(ctx, q, intent, merged); asked {
if reply, asked := h.askRemainingGap(q, intent, merged); asked {
return reply, true
}
@@ -261,7 +261,7 @@ func foldAnswerIntoUtterance(utterance, subject string) string {
// The attempt budget is shared with the re-ask path on purpose. A second gap
// costs a question exactly like a second try at the first one does, so the cap
// still bounds how many times she can speak before acting or letting go.
func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) {
func (h *reactiveHandler) askRemainingGap(q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) {
remaining := dialogue.StillMissing(wantedSlots[intent], merged)
if len(remaining) == 0 {
return "", false
@@ -270,7 +270,7 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
if !ok || !q.CanAsk() {
return "", false
}
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{
Intent: q.Intent,
Slots: merged,
Missing: []dialogue.Slot{remaining[0]},
@@ -287,13 +287,13 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
// reaskOrGiveUp handles an answer that left the gap open: ask the same question
// again while she has attempts left, otherwise say she did not understand and
// let the request go. Never returns "" — a mute give-up reads as "done".
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
func (h *reactiveHandler) reaskOrGiveUp(q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
question := ""
if len(q.Missing) > 0 {
question = clarifyQuestions[q.Missing[0]]
}
if question == "" || !q.CanAsk() {
h.clarifyStore.Delete(dialogueIDOf(ctx))
h.clarifyStore.Delete(voiceDialogueID)
log.Printf("voice: clarify — gave up on %v after %d question(s), answer was %q", q.Missing, q.Attempts, text)
return clarifyGaveUp
}
@@ -302,7 +302,7 @@ func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.Pending
q.Slots = merged
q.Attempts++
q.Asked = h.now()
h.clarifyStore.Put(dialogueIDOf(ctx), q)
h.clarifyStore.Put(voiceDialogueID, q)
log.Printf("voice: clarify — answer %q did not fill %v, asking again (attempt %d)", text, q.Missing, q.Attempts)
return question
}
+21 -70
View File
@@ -81,7 +81,7 @@ func TestClarifyReminderCompletesOnAnswer(t *testing.T) {
ctx := context.Background()
h, st, _ := newClarifyHandler(t)
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
if !asked || question != "Когда?" {
t.Fatalf("expected the time question, got %q asked=%v", question, asked)
}
@@ -112,7 +112,7 @@ func TestClarifyFactCompletesOnAnswer(t *testing.T) {
ctx := context.Background()
h, st, _ := newClarifyHandler(t)
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked {
if _, asked := h.askClarify(clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked {
t.Fatal("a fact with no key should be asked about")
}
if reply, handled := h.resolveClarifyAnswer(ctx, "пил воду"); !handled || reply == clarifyGaveUp {
@@ -128,7 +128,7 @@ func TestClarifyAnswerAfterTTLIsANewRequest(t *testing.T) {
ctx := context.Background()
h, st, now := newClarifyHandler(t)
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
t.Fatal("expected a question")
}
*now = now.Add(clarifyTTL + time.Second)
@@ -147,7 +147,7 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
ctx := context.Background()
h, st, _ := newClarifyHandler(t)
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
t.Fatal("expected a first question")
}
// Two more unclear answers ⇒ two more questions (3 asks in total).
@@ -185,7 +185,7 @@ func TestClarifyMaxAttemptsIsConfigurable(t *testing.T) {
h, _, _ := newClarifyHandler(t)
h.clarifyMaxAttempts = 1
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
t.Fatal("expected a question")
}
if reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю"); !handled || reply != clarifyGaveUp {
@@ -199,7 +199,7 @@ func TestClarifyRestatedAnswerWins(t *testing.T) {
ctx := context.Background()
h, st, _ := newClarifyHandler(t)
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
t.Fatal("expected a question")
}
// First answer parses, but re-park it by hand as if she had asked again:
@@ -232,7 +232,7 @@ func TestClarifiedActOffAllowlistIsStillRefused(t *testing.T) {
h, st, _ := newClarifyHandler(t)
marker := filepath.Join(t.TempDir(), "not-allowed-ran")
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
t.Fatal("an act with no fn should be asked about")
}
reply, handled := h.resolveClarifyAnswer(ctx, "rm "+marker)
@@ -260,7 +260,7 @@ func TestClarifiedDestructiveActStillNeedsConfirm(t *testing.T) {
t.Fatal(err)
}
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
t.Fatal("expected a question")
}
reply, handled := h.resolveClarifyAnswer(ctx, "delete_backups")
@@ -284,7 +284,7 @@ func TestNoQuestionWhenNothingIsMissing(t *testing.T) {
clarifyDec(router.IntentQuery, router.Slots{Text: "ммм"}, "ммм"),
clarifyDec(router.IntentNote, router.Slots{Text: "..."}, "..."),
} {
if question, asked := h.askClarify(context.Background(), dec); asked {
if question, asked := h.askClarify(dec); asked {
t.Fatalf("intent %s should keep the canned reply, got %q", dec.Intent, question)
}
}
@@ -296,29 +296,29 @@ func TestNoQuestionWhenNothingIsMissing(t *testing.T) {
// TestClarifyExpiryIsAnnouncedAndWordsStillRoute — his answer lands after the
// TTL: she must say the old request is gone AND still answer the new words.
func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
ctx := context.Background()
h, _, now := newClarifyHandler(t)
emb := router.NewHashEmbedder(1024)
h.embedder = emb
h.router = buildRouter(emb, h.matcher, 0.55, nil)
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
t.Fatal("expected a question")
}
*now = now.Add(clarifyTTL + time.Second)
reply := h.handleText(ctx, "", "как дела")
reply := h.handleText(ctx, "как дела")
if !isClarifyExpired(reply) {
t.Fatalf("expired question must be announced first, got %q", reply)
}
if trimClarifyExpired(reply) == "" {
t.Fatalf("the new words must still be answered, got only the notice: %q", reply)
}
if h.clarifyStore.Get(textDialogueID, h.now()) != nil {
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
t.Fatal("the expired question must be gone")
}
// The notice is said once, not on every later utterance.
if reply := h.handleText(ctx, "", "как дела"); isClarifyExpired(reply) {
if reply := h.handleText(ctx, "как дела"); isClarifyExpired(reply) {
t.Fatalf("notice repeated on a later turn: %q", reply)
}
}
@@ -340,7 +340,7 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
ctx := context.Background()
h, st, _ := newClarifyHandler(t)
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни"))
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{}, "напомни"))
if !asked || question != "О чём напомнить?" {
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
}
@@ -380,7 +380,7 @@ func TestClarifySecondGapRespectsTheAttemptCap(t *testing.T) {
h, _, _ := newClarifyHandler(t)
h.clarifyMaxAttempts = 1
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked {
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked {
t.Fatal("expected the subject question")
}
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
@@ -440,10 +440,10 @@ func TestClarifyProseHoldsThePersona(t *testing.T) {
// The confirm turn used to return before the notice was even computed, so he
// answered the confirm and never heard that the older request was let go.
func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
ctx := context.Background()
h, _, now := newClarifyHandler(t)
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
t.Fatal("expected a question")
}
// A confirm parked with a longer life than the question, so only the
@@ -451,7 +451,7 @@ func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
h.pending = &pendingAct{fn: "delete_backups", phrase: "удалить бэкапы", expiry: now.Add(time.Hour)}
*now = now.Add(clarifyTTL + time.Second)
reply := h.handleText(ctx, "", "нет")
reply := h.handleText(ctx, "нет")
if !isClarifyExpired(reply) {
t.Fatalf("the expired question must be announced on a confirm turn too, got %q", reply)
}
@@ -461,7 +461,7 @@ func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
if h.pending != nil {
t.Fatal("the confirm must still have been consumed")
}
if h.clarifyStore.Get(textDialogueID, h.now()) != nil {
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
t.Fatal("the expired question must be gone")
}
}
@@ -476,7 +476,7 @@ func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
h, st, _ := newClarifyHandler(t)
at := h.now().Add(2 * time.Hour)
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder,
question, asked := h.askClarify(clarifyDec(router.IntentReminder,
router.Slots{Time: at, HasTime: true}, "напомни в 11"))
if !asked || question != "О чём напомнить?" {
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
@@ -501,52 +501,3 @@ func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
t.Fatalf("the answer clobbered the original request: %q", reminders[0].Payload)
}
}
// TestClarifyIsPerConversation — the parked question belongs to the reach that
// was asked. Before this the clarify store had one global key, so a question
// asked in the web chat and never answered captured the next utterance from
// telegram, or from the mic, and answered it against a request the speaker had
// never made (Vikunja #466).
func TestClarifyIsPerConversation(t *testing.T) {
h, _, _ := newClarifyHandler(t)
web := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
telegram := withDialogueID(context.Background(), dialogueIDFor(sourceText, "telegram:42"))
if _, asked := h.askClarify(web, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
t.Fatal("expected a question on the web conversation")
}
if _, handled := h.resolveClarifyAnswer(telegram, "в 11:00"); handled {
t.Fatal("a question asked on the web must not eat a telegram utterance")
}
if _, handled := h.resolveClarifyAnswer(voiceCtx(), "в 11:00"); handled {
t.Fatal("a question asked on the web must not eat what he says at the mic")
}
if reply, handled := h.resolveClarifyAnswer(web, "в 11:00"); !handled || reply == clarifyGaveUp {
t.Fatalf("the asker's own answer must land, handled=%v reply=%q", handled, reply)
}
}
// voiceCtx — the mic's conversation, which carries no id of its own.
func voiceCtx() context.Context {
return withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
}
// TestARestartExpiresTheParkedQuestion pins the Vikunja #385 decision: the
// question dies with the process, and she does not claim to have let it go —
// the words that follow are routed as a fresh request. Restarting is modelled
// the way the daemon does it, by building a second handler over the same store.
func TestARestartExpiresTheParkedQuestion(t *testing.T) {
h, _, _ := newClarifyHandler(t)
ctx := voiceCtx()
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
t.Fatal("expected a question before the restart")
}
restarted, _, _ := newClarifyHandler(t)
if _, handled := restarted.resolveClarifyAnswer(ctx, "в 11:00"); handled {
t.Fatal("a question parked before the restart must not eat the next utterance")
}
if notice := restarted.clarifyExpiredNotice(ctx); notice != "" {
t.Fatalf("notice = %q, want silence: nothing survived to expire", notice)
}
}
+5 -45
View File
@@ -514,14 +514,11 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
// Resolve the utterance text as an entity reference through Nexus. An
// ambiguous match must stop and clarify — never guess a mutation target.
// The name comes from entityReferenceText, not straight from the Text slot:
// the model transliterates Latin names as it routes (Vikunja #476).
subject := entityReferenceText(dec)
started := h.now()
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, subject, nil)
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, dec.Slots.Text, nil)
if err != nil {
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(dec.Slots.Text)}))
if unauthorizedEcosystemError(err) {
return "экосистема отклоняет доступ, проверь токен."
}
@@ -538,7 +535,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
}
if entityID == "" {
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
map[string]any{"subject": redactSubject(subject)})
map[string]any{"subject": redactSubject(dec.Slots.Text)})
return ""
}
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceOK, started,
@@ -572,21 +569,10 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
}
verbLower := strings.ToLower(verb)
// With no allowlisted fn the verb is a whole phrase ("restart status muzick
// indexer"), which no capability name ever contains. Read it the other way
// round then: the phrase is the haystack and the capability name is what we
// look for in it (Vikunja #476). Only when the fn slot is empty — a matched
// fn is a single verb and containment already means what it says.
loose := !dec.Slots.HasFn
var matches []*hexisclient.Capability
for i, c := range caps {
name := strings.ToLower(c.Name)
hit := strings.Contains(name, verbLower) ||
(c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower))
if loose && name != "" && strings.Contains(verbLower, name) {
hit = true
}
if hit {
if strings.Contains(strings.ToLower(c.Name), verbLower) ||
(c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower)) {
matches = append(matches, &caps[i])
}
}
@@ -646,29 +632,3 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
})
return "команда выполнена для " + displayName + "."
}
// hexisBeforeClarify gives an entity-shaped act one chance at Hexis before she
// asks what to do.
//
// The stage-3 gate thins an act that never matched an allowlisted fn, so
// "перезапусти muzick indexer" was answered with "Что сделать?" and the Hexis
// path was never entered — the capability existed and no utterance could reach
// it (Vikunja #476). Hexis is exactly where an act with no local fn belongs:
// the verb is matched against the capabilities Hexis registers for the entity,
// not against the allowlist.
//
// Narrow on purpose. Only an act, only when the fn slot is still empty, and
// only when Hexis is wired — a box with no ecosystem asks the question it
// always asked. A "" back means Nexus knew no such entity or Hexis had no
// matching capability, and then she asks after all. Authority is unchanged:
// resolution stops on ambiguity and a mutating capability still goes through
// the spoken confirm in handleHexisAct.
func (h *reactiveHandler) hexisBeforeClarify(ctx context.Context, dec router.Decision) string {
if h.ecosystem == nil || h.ecosystem.hexis == nil {
return ""
}
if dec.Intent != router.IntentAct || dec.Slots.HasFn || dec.Slots.Text == "" {
return ""
}
return h.handleHexisAct(ctx, dec)
}
-59
View File
@@ -1,59 +0,0 @@
package main
import (
"regexp"
"strings"
"unicode"
"github.com/kami/maven/internal/router"
)
// latinRun matches a run of Latin-script words — the shape a service, host or
// project name takes in a Russian sentence. Digits, dot, dash and underscore
// ride along because "muzick-indexer" and "nginx.conf" are one name, not two.
var latinRun = regexp.MustCompile(`[A-Za-z][A-Za-z0-9._-]*(?:\s+[A-Za-z][A-Za-z0-9._-]*)*`)
// hasLatin reports whether s carries a Latin letter.
func hasLatin(s string) bool {
for _, r := range s {
if unicode.In(r, unicode.Latin) {
return true
}
}
return false
}
// entityReferenceText is the name Nexus is asked to resolve.
//
// Normally that is the router's Text slot, which is the verb phrase the model
// wrote. But the resident model rewrites a Russian utterance as it routes, and
// on the way it transliterates: "перезапусти muzick indexer" came back as
// "перезагрузить музик индексер" (Vikunja #476). Nexus is then asked for a
// service nobody has ever named, so the act cannot resolve its target even
// with every gate open.
//
// The recovery is deliberately narrow. Only when the utterance holds a Latin
// run and the model's Text holds none has a name certainly been rewritten —
// then the longest Latin run in his own words is the reference. Anything else
// keeps the Text slot, so an English utterance and a Russian entity name are
// both untouched. Un-transliterating the Cyrillic back is not attempted: the
// surface form he said is right there, and guessing at a reverse mapping would
// invent a second name to be wrong about.
func entityReferenceText(dec router.Decision) string {
text := dec.Slots.Text
if hasLatin(text) || !hasLatin(dec.Utterance) {
return text
}
longest := ""
for _, m := range latinRun.FindAllString(dec.Utterance, -1) {
if len(m) > len(longest) {
longest = m
}
}
longest = strings.TrimSpace(longest)
// A single stray letter is not a name.
if len(longest) < 2 {
return text
}
return longest
}
-133
View File
@@ -1,133 +0,0 @@
package main
import (
"context"
"strings"
"testing"
"github.com/kami/maven/internal/router"
)
// TestEntityReferenceText pins when his own words win over the model's.
func TestEntityReferenceText(t *testing.T) {
for _, tc := range []struct {
name string
utterance string
text string
want string
}{
{
name: "the model transliterated the name",
utterance: "перезапусти muzick indexer",
text: "перезагрузить музик индексер",
want: "muzick indexer",
},
{
name: "it kept the name, so nothing to repair",
utterance: "перезапусти muzick indexer",
text: "перезагрузить muzick indexer",
want: "перезагрузить muzick indexer",
},
{
name: "an all-Russian entity name is not a rewrite",
utterance: "перезапусти домашний сервер",
text: "перезагрузить домашний сервер",
want: "перезагрузить домашний сервер",
},
{
name: "an English turn never enters the recovery",
utterance: "restart muzick indexer",
text: "restart muzick indexer",
want: "restart muzick indexer",
},
{
name: "the longest Latin run is the name",
utterance: "а перезапусти-ка nginx на muzick-indexer, пожалуйста",
text: "перезагрузить нгинкс",
want: "muzick-indexer",
},
{
name: "one stray letter is not a name",
utterance: "перезапусти сервер a",
text: "перезагрузить сервер",
want: "перезагрузить сервер",
},
} {
t.Run(tc.name, func(t *testing.T) {
dec := router.Decision{Utterance: tc.utterance, Slots: router.Slots{Text: tc.text}}
if got := entityReferenceText(dec); got != tc.want {
t.Fatalf("entityReferenceText = %q, want %q", got, tc.want)
}
})
}
}
// TestNexusIsAskedForTheNameHeSaid — the defect end to end (Vikunja #476): the
// router hands over a transliterated Text, and Nexus must still be asked about
// the service that exists.
func TestNexusIsAskedForTheNameHeSaid(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
dec := router.Decision{
Utterance: "перезапусти muzick indexer",
Intent: router.IntentAct,
Slots: router.Slots{Text: "перезагрузить музик индексер", Fn: "restart", HasFn: true},
}
h.handleHexisAct(ctx, dec)
reqs := nexus.Requests()
if len(reqs) == 0 {
t.Fatal("nexus was never asked")
}
body := string(reqs[0].Body)
if !strings.Contains(body, "muzick indexer") {
t.Fatalf("nexus resolve body = %s, want the name he said", body)
}
}
// TestAnEntityActReachesHexisInsteadOfAsking — the second half of #476. The
// stage-3 gate thins an act with no allowlisted fn, and that question used to
// be the whole turn, so the Hexis path was unreachable from voice or chat.
func TestAnEntityActReachesHexisInsteadOfAsking(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
dec := router.Decision{
Utterance: "перезапусти muzick indexer",
Intent: router.IntentAct,
Stage: 3,
Clarify: true,
Slots: router.Slots{Text: "restart status muzick indexer"},
}
reply := h.hexisBeforeClarify(ctx, dec)
if reply == "" {
t.Fatal("a resolvable entity act must reach hexis rather than fall through to the question")
}
if hexis.Count("", "/api/v1") == 0 {
t.Fatal("hexis was never contacted")
}
}
// TestClarifyStillAsksWithoutHexis — the narrowing. No ecosystem, no change:
// she asks exactly what she asked before.
func TestClarifyStillAsksWithoutHexis(t *testing.T) {
h, _, _ := newClarifyHandler(t)
dec := router.Decision{
Utterance: "перезапусти muzick indexer",
Intent: router.IntentAct,
Stage: 3,
Clarify: true,
Slots: router.Slots{Text: "перезагрузить музик индексер"},
}
if reply := h.hexisBeforeClarify(context.Background(), dec); reply != "" {
t.Fatalf("no hexis must mean no reply, got %q", reply)
}
if _, asked := h.askClarify(voiceCtx(), dec); !asked {
t.Fatal("she must still ask what to do")
}
}
+3 -50
View File
@@ -1,64 +1,17 @@
package main
import (
"context"
"time"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/router"
)
// voiceDialogueID — the dialogue-session key for the microphone, and the
// clarify key for it too. This is a single-user box (ponytail), so one slot
// suffices; a second speaker would need per-speaker ids, which waits on
// voice-print attribution (see PROGRESS multi-user deferral).
// voiceDialogueID — the single dialogue-session key. This is a single-user box
// (ponytail), so one slot suffices; a second speaker would need per-speaker ids,
// which waits on voice-print attribution (see PROGRESS multi-user deferral).
const voiceDialogueID = "voice"
// textDialogueID — the clarify key for a text turn that named no conversation.
// Separate from the mic: an old client that sends no id still must not answer
// a question she asked out loud.
const textDialogueID = "text"
// dialogueKey — the context key carrying the id of the conversation this turn
// belongs to. It rides the context rather than a parameter for the same reason
// the correlation id does: every step of the turn needs it, most of them only
// to hand to the next one, and threading it by hand would put it in six
// clarify signatures that have nothing else to say about it.
type dialogueKey struct{}
// dialogueIDFor builds the id a turn is held under: the conversation the reach
// named, qualified by the tap it arrived on, or the tap's own fallback when it
// named none.
//
// A parked clarifying question used to be held under voiceDialogueID no matter
// where the turn came from, so one unanswerable question captured the next
// three utterances from anywhere. Three independent curl sessions fed a
// capture attempt that had already failed, and a reminder among them was lost
// (Vikunja #466).
func dialogueIDFor(src turnSource, conversation string) string {
if conversation != "" {
return string(src) + ":" + conversation
}
if src == sourceVoice {
return voiceDialogueID
}
return textDialogueID
}
// withDialogueID tags a turn with that id.
func withDialogueID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, dialogueKey{}, id)
}
// dialogueIDOf reads it back. Falls back to the microphone's slot, which is
// what an unthreaded caller — a test, an internal replay — gets.
func dialogueIDOf(ctx context.Context) string {
if id, ok := ctx.Value(dialogueKey{}).(string); ok && id != "" {
return id
}
return voiceDialogueID
}
// toDialogueSlots and applyDialogueSlots are the only bridge between
// router.Slots and dialogue.Slots. dialogue must not import router (import
// cycle), so the two structs are hand-kept copies and every field has to be
-39
View File
@@ -1,39 +0,0 @@
package main
import (
"strings"
"testing"
"github.com/kami/maven/internal/morning"
)
// TestMorningNudgeBodySeparatesOptional — the one message a routine is allowed
// per day says what was not done, then what he could still do (Vikunja #473).
func TestMorningNudgeBodySeparatesOptional(t *testing.T) {
cand := morning.Candidate{
Routine: morning.Routine{Name: "утро"},
Missing: []morning.Item{
{Key: "meds", Label: "таблетки"},
{Key: "stretch", Label: "растяжка", Optional: true},
},
}
body := morningNudgeBody(cand)
if !strings.Contains(body, "не сделано — таблетки") {
t.Fatalf("the required item must be named as not done: %q", body)
}
if !strings.Contains(body, "если будет время — растяжка") {
t.Fatalf("the optional item must read softer: %q", body)
}
if strings.Contains(body, "не сделано — таблетки, растяжка") {
t.Fatalf("optional must not be folded into the required list: %q", body)
}
// Nothing optional missing: the sentence is what it always was.
only := morning.Candidate{
Routine: morning.Routine{Name: "утро"},
Missing: []morning.Item{{Key: "meds", Label: "таблетки"}},
}
if got, want := morningNudgeBody(only), "утро: не сделано — таблетки"; got != want {
t.Fatalf("morningNudgeBody = %q, want %q", got, want)
}
}
+134
View File
@@ -0,0 +1,134 @@
package main
import (
"context"
"log"
"regexp"
"strings"
"sync"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/phraser/eval"
)
// The persona checks, run before she speaks (Vikunja #399).
//
// RunChecks and RunTalkChecks only ever ran from the eval package, so
// everything the fixtures measured was offline knowledge: we could say "about
// one reply in three is broken" and still ship every one of them. This runs the
// cheap half of that on the live path, and replaces a failing message with the
// deterministic floor.
//
// Which checks: the unambiguous string tests only — feminine self-reference,
// how she addresses him, and a leaked-reasoning test. Not length, which is
// path-specific, and not ontopic, which compares against fragments the fixture
// supplies and runtime does not have. Not hisgender either — see guardSpoken.
//
// No retry. A retry doubles the latency on the exact turn that is already going
// badly, and on the nudge path the moment has passed.
//
// The known cost, written down because it is real: a wrongly flagged good reply
// is replaced by a flatter stub one. That is the right trade — a stub sentence
// is dull, a leaked reasoning trace is broken — but it means these checks can
// no longer be tuned for sensitivity alone.
// checkLeak — the name reported when the model's scaffolding reaches the text.
const checkLeak = "leak"
// leakPatterns — reasoning and protocol that belongs to the model, not to him.
// The resident model is a Thinking variant, so an unclosed reasoning block is
// the failure mode, not a hypothetical (Vikunja #398).
var leakPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)<\s*/?\s*think`),
regexp.MustCompile(`(?i)thinking\s*(process|:)`),
regexp.MustCompile(`(?i)^\s*(assistant|user|system)\s*:`),
// Raw contract JSON: the parser already unwraps a good one, so a body that
// still carries the keys is one it could not read.
regexp.MustCompile(`"(response|mood|body|summary)"\s*:`),
// The persona block quoted back at him.
regexp.MustCompile(`(?i)(ты\s+—?\s*мэйвен|системный промпт|system prompt)`),
}
// checkPersonaLeak reports whether the model's own scaffolding is in the text.
func checkPersonaLeak(body string) (string, bool) {
for _, re := range leakPatterns {
if m := re.FindString(body); m != "" {
return "leaked " + strings.TrimSpace(m), false
}
}
return "", true
}
// personaRejects counts what the guard caught, by check name, so the real
// production rate is knowable rather than inferred from the fixture.
var personaRejects = struct {
mu sync.Mutex
by map[string]int
}{by: map[string]int{}}
func personaRejectCounts() map[string]int {
personaRejects.mu.Lock()
defer personaRejects.mu.Unlock()
out := make(map[string]int, len(personaRejects.by))
for k, v := range personaRejects.by {
out[k] = v
}
return out
}
// guardSpoken checks a phrased message. It returns the failed check and false
// when the message must not be said; path names the caller, for the log.
//
// An empty message passes: the caller already treats that as a failure and
// falls back on its own, and reporting it as a persona breach would put a
// misleading line in the count.
func guardSpoken(path, body string) (string, bool) {
if strings.TrimSpace(body) == "" {
return "", true
}
if detail, ok := checkPersonaLeak(body); !ok {
return rejectSpoken(path, checkLeak, detail, body), false
}
// Feminine and address only. HisGender is not run here: it reads a
// sentence-initial feminine verb with no pronoun — "записала, что ты выпил
// воды" — as a woman being addressed, when it is her own correct
// self-reference. Offline that is a point of score; on this path it would
// replace a good reply with a stub one on every fact she confirms.
for _, r := range []eval.Result{eval.Feminine(body), eval.Address(body)} {
if !r.Pass {
return rejectSpoken(path, r.Name, r.Detail, body), false
}
}
return "", true
}
// rejectSpoken logs what she nearly said and counts it. The whole text, not a
// prefix: the point of the log line is that the failure can be read back later
// and argued with.
func rejectSpoken(path, check, detail, body string) string {
personaRejects.mu.Lock()
personaRejects.by[check]++
personaRejects.mu.Unlock()
log.Printf("persona: %s rejected on %s (%s): %q", path, check, detail, body)
return check
}
// guardNudge checks a phrased nudge and falls back to the deterministic floor
// when it fails. The nudge path, unlike the reply path, cannot ask again: the
// tick has already decided she speaks, so the choice is the floor's wording or
// a broken sentence.
func guardNudge(pn delivery.PhrasedNudge, cand loop.Candidate) delivery.PhrasedNudge {
if _, ok := guardSpoken("nudge", pn.Body); ok {
return pn
}
stub, err := phraser.NewStub().PhraseNudge(context.Background(), cand)
if err != nil {
// The Stub is templates over the candidate and does not fail. If it
// somehow does, the model's text is still what the rule decided to
// say, and saying nothing is the worse outcome.
return pn
}
return stub
}
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"strings"
"testing"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/loop"
)
func TestGuardPassesWhatSheShouldSay(t *testing.T) {
good := []string{
"записала: купить хлеб.",
"поняла, напомню в 11:00.",
"ты не пил воду с утра.",
"я рада, что получилось.",
"",
}
for _, body := range good {
if check, ok := guardSpoken("test", body); !ok {
t.Errorf("guardSpoken(%q) rejected on %s", body, check)
}
}
}
func TestGuardStopsWhatSheShouldNot(t *testing.T) {
bad := []struct {
body string
want string
}{
{"<think>он просил воду</think> попей воды.", checkLeak},
{"Thinking Process: он давно не пил.", checkLeak},
{`{"response": "попей воды", "mood": "neutral"}`, checkLeak},
{"я напомнил тебе про воду.", "feminine"},
{"вы давно не пили воду.", "address"},
}
for _, c := range bad {
check, ok := guardSpoken("test", c.body)
if ok {
t.Errorf("guardSpoken(%q) let it through", c.body)
continue
}
if check != c.want {
t.Errorf("guardSpoken(%q) failed on %s; want %s", c.body, check, c.want)
}
}
}
func TestGuardCountsWhatItCaught(t *testing.T) {
before := personaRejectCounts()[checkLeak]
if _, ok := guardSpoken("test", "<think>…"); ok {
t.Fatal("a leaked reasoning block was let through")
}
if after := personaRejectCounts()[checkLeak]; after != before+1 {
t.Errorf("leak count %d; want %d", after, before+1)
}
}
// TestGuardNudgeFallsBackToTheFloor — a broken nudge is replaced by the
// deterministic wording, not dropped and not retried.
func TestGuardNudgeFallsBackToTheFloor(t *testing.T) {
cand := loop.Candidate{Rule: loop.Rule{Name: "water"}}
bad := delivery.PhrasedNudge{Candidate: cand, Body: "Thinking Process: он не пил.", Mood: "neutral"}
got := guardNudge(bad, cand)
if got.Body == bad.Body {
t.Fatal("the broken nudge was delivered unchanged")
}
if strings.TrimSpace(got.Body) == "" {
t.Fatal("the nudge was dropped rather than re-worded")
}
good := delivery.PhrasedNudge{Candidate: cand, Body: "попей воды.", Mood: "neutral"}
if guardNudge(good, cand).Body != good.Body {
t.Error("a good nudge was replaced")
}
}
-37
View File
@@ -2,14 +2,12 @@ package main
import (
"context"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/tool"
"github.com/kami/maven/internal/voice"
)
@@ -87,38 +85,3 @@ func TestReactiveNotesReminders(t *testing.T) {
}
})
}
// TestSpokenTaskCaptureFilesATask — the whole path, from the utterance to the
// task table. It went dead when the router started claiming the marker as an
// act: capture rides the note intent, so nothing below actionNote was ever
// reached and every capture answered "Что сделать?" (Vikunja #467).
func TestSpokenTaskCaptureFilesATask(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
api := ipc.NewStoreAPI(st)
now := time.Now()
emb := router.NewHashEmbedder(1024)
matcher := tool.NewMatcher(api)
h := &reactiveHandler{
api: api,
embedder: emb,
router: buildRouter(emb, matcher, 0.55, nil),
replier: voice.NewStubReplier(),
now: func() time.Time { return now },
memStore: memory.NewInMemoryStore(),
dataStore: st,
}
reply := h.handleText(ctx, "web", "добавь в задачи купить молоко")
if !strings.Contains(reply, "купить молоко") {
t.Fatalf("capture did not claim the turn: %q", reply)
}
open, err := st.ListTasks(ctx, store.TaskOpen)
if err != nil || len(open) != 1 {
t.Fatalf("task was not filed: tasks=%v err=%v", open, err)
}
// The words he said, not the model's rewrite of them.
if open[0].Text != "купить молоко" {
t.Fatalf("task text was rewritten: %q", open[0].Text)
}
}
+5
View File
@@ -30,5 +30,10 @@ func (r *llmReplier) Reply(d router.Decision) string {
if err != nil || out == "" {
return r.stub.Reply(d)
}
// The persona checks, on the live path (personaguard.go). A reply that
// leaks reasoning or calls him "вы" is worse than a flat one.
if _, ok := guardSpoken("reply", out); !ok {
return r.stub.Reply(d)
}
return out
}
-23
View File
@@ -1043,26 +1043,3 @@ func TestSimulatorRefusesBackwardsSteps(t *testing.T) {
t.Errorf("the clock moved to %s on a refused step, it must stay at 09:00", got)
}
}
// TestSimulatorRoutesWithTheDeployedSeeds — the scenarios must replay against
// the classifier the deploy runs, not an empty one.
//
// They did not. The seed path was relative to the working directory, which is
// cmd/mavend under `go test`, so every file failed to open and the whole
// simulator scored three green scenarios with zero examples loaded (Vikunja
// #465). The count is asserted rather than logged, because a silent zero is
// exactly the failure that hid here for as long as it did.
func TestSimulatorRoutesWithTheDeployedSeeds(t *testing.T) {
cls := router.NewClassifier(router.NewHashEmbedder(1024))
seedClassifier(cls)
total := 0
for _, intent := range cls.Intents() {
total += len(cls.Examples(intent))
}
if total == 0 {
t.Fatalf("no seed examples loaded from %s — the simulator would route on nothing", seedPath())
}
if len(cls.Intents()) != 7 {
t.Fatalf("seeded %d intents, want all 7", len(cls.Intents()))
}
}
+697
View File
@@ -10,17 +10,22 @@ package main
import (
"context"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/kami/maven/internal/calendar"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/morning"
"github.com/kami/maven/internal/pattern"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/routine"
"github.com/kami/maven/internal/store"
@@ -171,6 +176,9 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
t.queueNudge(ctx, cand, state, now)
} else {
pn, err := t.phraser.PhraseNudge(ctx, *cand)
if err == nil {
pn = guardNudge(pn, *cand)
}
if err != nil {
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
} else {
@@ -310,6 +318,597 @@ func (t *tickLoop) repeatPhrase(rule string) (body, summary string) {
return pn.Body, pn.Summary
}
// shouldQueue — true when digest is enabled and the candidate's severity is
// at or below the configured ceiling.
func (t *tickLoop) shouldQueue(cand *loop.Candidate) bool {
return t.digestCfg != nil && t.digestCfg.Enabled &&
cand.Severity <= loop.Severity(t.digestCfg.SeverityCeiling)
}
// queueNudge — phrases the candidate and appends it to the digest queue.
// Deduplicates by rule name: if the same rule is already queued, this is a
// no-op (the first fire within the window is the one that counts).
func (t *tickLoop) queueNudge(ctx context.Context, cand *loop.Candidate, _ loop.State, now time.Time) {
for _, q := range t.digestQ {
if q.Rule == cand.Rule.Name {
return // already queued
}
}
pn, err := t.phraser.PhraseNudge(ctx, *cand)
if err != nil {
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
return
}
t.digestQ = append(t.digestQ, QueuedNudge{
Rule: cand.Rule.Name,
Severity: int(cand.Severity),
Body: pn.Body,
Key: cand.Rule.Name,
QueuedAt: now,
})
t.cachePhrase(pn)
}
// maybeFlush — flushes the digest queue if the window has elapsed since the
// first item or the queue reached MaxItems.
func (t *tickLoop) maybeFlush(ctx context.Context, now time.Time, state loop.State) {
if t.digestCfg == nil || !t.digestCfg.Enabled || len(t.digestQ) == 0 {
return
}
first := t.digestQ[0]
if now.Sub(first.QueuedAt) >= time.Duration(t.digestCfg.Window) ||
len(t.digestQ) >= t.digestCfg.MaxItems {
t.flushDigest(ctx, now, state)
}
}
// flushDigest — concatenates queued nudge bodies into a single digest
// notification and dispatches it. Clears the queue after a successful send.
// The digest uses the max severity among queued items for routing.
func (t *tickLoop) flushDigest(ctx context.Context, now time.Time, state loop.State) {
if len(t.digestQ) == 0 {
return
}
var b strings.Builder
maxSev := 0
for i, q := range t.digestQ {
if i > 0 {
b.WriteString(" · ")
}
b.WriteString(q.Body)
if q.Severity > maxSev {
maxSev = q.Severity
}
}
body := b.String()
summary := fmt.Sprintf("%d pending notifications", len(t.digestQ))
cand := loop.Candidate{
Rule: loop.Rule{
Name: "digest",
Severity: loop.Severity(maxSev),
},
Severity: loop.Severity(maxSev),
State: state,
}
pn := delivery.PhrasedNudge{
Candidate: cand,
Body: body,
Summary: summary,
}
t.cachePhrase(pn)
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
// keep the queue — the next tick's maybeFlush re-attempts.
log.Printf("tick: dispatch digest: %v", err)
return
}
t.digestQ = nil
}
// detectPatterns runs the pattern detector proactively over every
// action+object pair that has ever produced an event, independent of
// whichever fact write (or channel) last touched it (Vikunja #43). This is
// what makes pattern inference actually proactive: it fires on the daemon's
// own schedule reading accumulated history, not only as a side effect of a
// live voice turn.
//
// Idempotence and noise are handled by the store, not here — this function
// is safe to call every tick:
// - Same pattern, tick after tick: detectAndPropose's LookupProposedRoutine
// check plus proposed_routines' UNIQUE(action, object) constraint (with
// CreateProposedRoutine's ON CONFLICT DO NOTHING) mean a pair that
// already has a row — in ANY status — produces no second row and no log
// spam beyond the one line at genuine creation.
// - A DISMISSED proposal must never come back. DismissProposedRoutine flips
// status in place; the row is never deleted. So the same Lookup check
// that stops a duplicate "proposed" also stops a "dismissed" one from
// resurrecting — there is nothing tick-specific to get right here beyond
// calling the same shared path the voice route already used.
//
// By default this only creates a row for the /routines page to show: it does
// not notify, ring, or speak. Detection is not the same act as disturbing him
// about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing
// is opt-in through the pattern_proposals config block — see announceProposal
// for the restraints that apply even then. A proposal only starts producing
// recurring nudges once he accepts it (fireAcceptedRoutines).
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop.State) {
pairs, err := t.store.DistinctEventPairs(ctx)
if err != nil {
log.Printf("tick: distinct event pairs: %v", err)
return
}
announced := false
for _, p := range pairs {
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
if err != nil {
log.Printf("tick: detect pattern %s/%s: %v", p.Action, p.Object, err)
continue
}
if r == nil {
continue // no stable pattern, or already proposed/accepted/dismissed
}
log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
// One announcement per tick at most, whatever the scan turned up. The
// rest are on /routines; they are not lost, they are just not shouted.
// Nor are they queued: the row now exists, so no later tick re-detects
// them and they are never announced. See announceProposal.
if announced {
continue
}
announced = t.announceProposal(ctx, r, now, state)
}
}
// announceProposal offers a freshly inferred routine through the ordinary
// care-delivery path, if announcing is switched on at all. Returns true when
// something was actually sent.
//
// Everything here is restraint. The feature is off unless configured; when on
// it is sev1 (the lowest severity, so quiet hours, away presence and snooze
// all suppress it via loop.Gate exactly like a care nudge); it is spaced by
// proposalCfg.Cooldown across every pair, not per pair; and a suppressed or
// dropped announcement is NOT retried — the cooldown clock advances only on a
// real send, but the proposal row already exists, so the next tick will not
// re-detect it and nothing queues up behind it. A missed announcement means
// he reads it on /routines instead, which is the whole point of the page.
//
// What the cooldown is and is not. detectAndPropose returns non-nil only for a
// newly created row, so a pair gets exactly one chance to be spoken: the tick
// that first proposes it. Combined with one announcement per tick, the first
// tick over a populated history announces one pattern and permanently silences
// every other pattern found in the same pass. That is the intent, not an
// oversight — an inferred routine is not worth a second attempt at his
// attention, and /routines lists all of them. So the cooldown does not drain a
// backlog. It only spaces announcements of genuinely new pairs discovered on
// later ticks. If it should ever become "one per day until each is mentioned",
// that needs a queue rather than this counter.
//
// Cooldown gets its default here as well as in applyDefaults. That is
// deliberate: a tickLoop assembled directly in a test never goes through Load,
// and an unspaced announcer is not what those tests mean to exercise.
//
// The body is the detector's own literal Russian phrasing (pattern.PhraseRoutine
// — "ты заправляешь поилку раз в 7 дней — напоминать?"), not LLM-generated, so
// an inferred routine cannot arrive worded as something Maven never observed.
func (t *tickLoop) announceProposal(ctx context.Context, r *pattern.ProposedRoutine, now time.Time, state loop.State) bool {
if !t.proposalCfg.AnnounceProposals() {
return false
}
cooldown := time.Duration(t.proposalCfg.Cooldown)
if cooldown <= 0 {
cooldown = config.DefaultProposalCooldown
}
if !t.lastProposalAt.IsZero() && now.Sub(t.lastProposalAt) < cooldown {
return false
}
rule := loop.Rule{Name: "proposal:" + r.Action + " " + r.Object, Severity: loop.Sev1}
if !loop.Gate(state, rule) {
return false
}
body := pattern.PhraseRoutine(r)
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
Body: body,
Summary: body,
}
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
if err != nil {
log.Printf("tick: announce proposal %s/%s: %v", r.Action, r.Object, err)
return false
}
if len(sent) == 0 {
return false // routing dropped it — /routines still has it.
}
t.lastProposalAt = now
return true
}
// digestExpiry — how long a gate-suppressed care nudge stays worth
// resurfacing. 24h: these are daily-cadence rules (water/meal/break run on
// hour-scale cooldowns and re-derive from facts that reset every day), so a
// digest entry that outlives one full day is describing a day that's already
// over — "you skipped a break yesterday" said tomorrow evening is noise, not
// news. Bounding at one day also means a digest can never silently span a
// weekend of quiet hours into an unbounded backlog.
const digestExpiry = 24 * time.Hour
// maxDigestSpokenItems — the bundle read-out is capped so "batched, not
// dropped" cannot regress into "she dumps twelve things on me the moment I
// walk in" — a digest that nags in bulk is worse than the drops it replaced.
// Anything beyond the cap is still marked drained (it did get its moment;
// the cap limits WORDS, not whether it counted) and folded into a trailing
// count instead of being spoken in full.
const maxDigestSpokenItems = 3
// enqueueSuppressedDigest scans this tick's trace for care candidates the
// gate blocked for a genuine restraint reason and durably records the
// digest-eligible ones (loop.DigestEligible). Phrasing happens once, here,
// at enqueue time — not re-derived at drain time — the same way queueNudge
// phrases once and caches, so a rule suppressed for hours isn't re-prompting
// the LLM every tick it stays blocked (EnqueueDigestEntry's rule+body dedupe
// makes repeat calls here harmless, but skipping the phrase call entirely
// when a pending entry already exists avoids the LLM round-trip too).
func (t *tickLoop) enqueueSuppressedDigest(ctx context.Context, trace *loop.TickTrace, state loop.State, now time.Time) {
if trace == nil {
return
}
for _, tr := range trace.RuleTraces {
if !tr.PredicateResult || tr.GateResult {
continue // didn't want to fire, or wasn't suppressed
}
if !loop.DigestEligible(tr.Severity, tr.GateBlockedBy) {
continue
}
rule := loop.Rule{Name: tr.RuleName, Severity: tr.Severity}
cand := loop.Candidate{Rule: rule, Severity: tr.Severity, State: state}
pn, err := t.phraser.PhraseNudge(ctx, cand)
if err != nil {
log.Printf("tick: phrase digest candidate %s: %v", tr.RuleName, err)
continue
}
expires := now.Add(digestExpiry)
if _, deduped, err := t.store.EnqueueDigestEntry(ctx, tr.RuleName, int(tr.Severity), pn.Body, now, expires); err != nil {
log.Printf("tick: enqueue digest entry %s: %v", tr.RuleName, err)
} else if deduped {
// same suppressed nudge already pending — nothing new to say.
continue
}
}
}
// expireStaleDigest sweeps entries past their expiry once per tick — cheap
// bookkeeping, mirrors ReconcileStaleDeliveryAttempts's shape.
func (t *tickLoop) expireStaleDigest(ctx context.Context, now time.Time) {
n, err := t.store.ExpireStaleDigestEntries(ctx, now)
if err != nil {
log.Printf("tick: expire stale digest entries: %v", err)
return
}
if n > 0 {
log.Printf("tick: expired %d stale digest entr(y/ies) unspoken", n)
}
}
// maybeDrainDigest speaks the pending digest bundle once the gate's
// suppression reasons have actually cleared — quiet hours over, back from
// away, out of the meeting. Draining while still suppressed would just be a
// second way to nag through quiet hours; the bundle waits for the same "is
// it allowed right now" condition a live nudge already waits for.
func (t *tickLoop) maybeDrainDigest(ctx context.Context, state loop.State, now time.Time) {
if state.QuietHours || state.CalendarBusy || state.Presence == store.Away {
return
}
entries, err := t.store.PendingDigestEntries(ctx, now)
if err != nil {
log.Printf("tick: pending digest entries: %v", err)
return
}
if len(entries) == 0 {
return
}
spoken := entries
extra := 0
if len(spoken) > maxDigestSpokenItems {
spoken = entries[:maxDigestSpokenItems]
extra = len(entries) - maxDigestSpokenItems
}
var b strings.Builder
maxSev := 0
for i, e := range spoken {
if i > 0 {
b.WriteString(" · ")
}
b.WriteString(e.Body)
if e.Severity > maxSev {
maxSev = e.Severity
}
}
if extra > 0 {
fmt.Fprintf(&b, " · и ещё %d", extra)
}
body := b.String()
summary := fmt.Sprintf("%d отложенных уведомлений", len(entries))
cand := loop.Candidate{
Rule: loop.Rule{Name: "digest", Severity: loop.Severity(maxSev)},
Severity: loop.Severity(maxSev),
State: state,
}
pn := delivery.PhrasedNudge{Candidate: cand, Body: body, Summary: summary}
t.cachePhrase(pn)
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
log.Printf("tick: dispatch digest bundle: %v", err)
return // leave entries pending; retried next tick
}
ids := make([]int64, len(entries))
for i, e := range entries {
ids[i] = e.ID
}
if err := t.store.DrainDigestEntries(ctx, ids, now); err != nil {
log.Printf("tick: drain digest entries: %v", err)
}
}
// routinesFromConfig maps the config's routine blocks to the engine type.
// Validation (cron parses, name/body present, severity defaulted) already ran
// in config.Load, so this is a pure field copy.
func routinesFromConfig(rc []config.RoutineConfig) []routine.Routine {
if len(rc) == 0 {
return nil
}
out := make([]routine.Routine, len(rc))
for i, r := range rc {
out[i] = routine.Routine{Name: r.Name, Cron: r.Cron, Body: r.Body, Severity: r.Severity}
}
return out
}
// fireRoutines dispatches the routines whose cron schedule crossed since their
// last fire. Each is delivered as a nudge through the normal routing table
// (ChannelsFor(severity, presence)) with a "routine:"-prefixed rule name so it
// can't collide with a care rule in the feedback autotuner. A dispatch failure
// logs and continues — one bad send must not skip the rest, and routine.Due has
// already advanced the last-fire time so a transient failure drops that fire
// rather than replaying it every tick (a routine is clockwork, not an alarm —
// no repeat-til-ack).
func (t *tickLoop) fireRoutines(ctx context.Context, now time.Time, state loop.State) {
for _, r := range routine.Due(t.routines, t.routineLast, now) {
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{
Rule: loop.Rule{Name: "routine:" + r.Name, Severity: loop.Severity(r.Severity)},
Severity: loop.Severity(r.Severity),
State: state,
},
Body: r.Body,
Summary: r.Body,
}
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
log.Printf("tick: dispatch routine %s: %v", r.Name, err)
}
}
}
// fireAcceptedRoutines nudges about the routines the user accepted, once per
// interval (Vikunja #366). Accepting used to create a single reminder, so a
// non-weekly routine fired once and went quiet forever; the schedule lives in
// the proposed_routines row now and the loop re-reads it every tick.
//
// A routine is a care-class nudge and goes through the restraint gate like any
// other: quiet hours, away presence and snooze all suppress it. Reminders bypass
// that gate; routines must not. A suppressed nudge is NOT marked fired, so it
// goes out on the next tick that the gate allows — one nudge, held, not dropped
// and not repeated.
//
// The body is literal text built from the detected action and object, not
// LLM-phrased, so a routine can't hallucinate. It nudges; it never acts.
func (t *tickLoop) fireAcceptedRoutines(ctx context.Context, now time.Time, state loop.State) {
rows, err := t.store.ListAcceptedRoutines(ctx)
if err != nil {
log.Printf("tick: list accepted routines: %v", err)
return
}
accepted := make([]routine.Accepted, 0, len(rows))
for _, r := range rows {
if r.AcceptedTs == nil {
continue // accepted before the schedule column existed — no clock to start from.
}
accepted = append(accepted, routine.Accepted{
ID: r.ID,
Name: r.Action + " " + r.Object,
IntervalDays: r.IntervalDays,
Accepted: *r.AcceptedTs,
LastFired: r.LastFiredTs,
})
}
for _, a := range routine.DueAccepted(accepted, now) {
rule := loop.Rule{Name: "routine:" + a.Name, Severity: loop.Sev1}
if !loop.Gate(state, rule) {
continue
}
body := "пора: " + a.Name
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
Body: body,
Summary: body,
}
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
if err != nil {
log.Printf("tick: dispatch accepted routine %d: %v", a.ID, err)
continue
}
if len(sent) == 0 {
continue // routing dropped it — leave it due.
}
if err := t.store.MarkRoutineFired(ctx, a.ID, now); err != nil {
log.Printf("tick: mark routine %d fired: %v", a.ID, err)
}
}
}
// fireMorningRoutines checks each configured checklist against today's facts
// and dispatches a nag listing exactly what's still missing, at most once per
// routine per calendar day. Fact reads happen here (not in loop.Gatherer)
// because the item↔fact-key mapping is morning-routine-specific, not a rule
// concern — pulling it into the shared gather path would leak that mapping
// into loop's "rules declare wanted keys" contract. Bodies are literal
// operator text (item labels joined), not LLM-phrased, same rationale as
// cron routines: deterministic, can't hallucinate a checklist item.
func (t *tickLoop) fireMorningRoutines(ctx context.Context, now time.Time, state loop.State) {
if len(t.morningRoutines) == 0 {
return
}
facts := t.gatherMorningFacts(ctx)
for _, cand := range morning.Due(t.morningRoutines, facts, t.morningLast, now) {
labels := make([]string, len(cand.Missing))
for i, it := range cand.Missing {
labels[i] = it.Label
}
body := fmt.Sprintf("%s: не сделано — %s", cand.Routine.Name, strings.Join(labels, ", "))
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{
Rule: loop.Rule{Name: "morning:" + cand.Routine.Name, Severity: loop.Severity(cand.Routine.Severity)},
Severity: loop.Severity(cand.Routine.Severity),
State: state,
},
Body: body,
Summary: body,
}
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
log.Printf("tick: dispatch morning routine %s: %v", cand.Routine.Name, err)
}
}
}
// gatherMorningFacts reads the latest fact for every item's fact_key across
// all configured morning routines. Shared by fireMorningRoutines (nudge
// decision) and morningStatus (read-only query) so the two paths can never
// disagree about what evidence exists.
func (t *tickLoop) gatherMorningFacts(ctx context.Context) map[string]store.Fact {
keys := make(map[string]struct{})
for _, r := range t.morningRoutines {
for _, it := range r.Items {
keys[it.FactKey] = struct{}{}
}
}
facts := make(map[string]store.Fact, len(keys))
for k := range keys {
f, err := t.store.LatestFact(ctx, k)
if err == nil {
facts[k] = f
continue
}
if err != store.ErrNoFact {
log.Printf("tick: morning: latest fact %s: %v", k, err)
}
}
return facts
}
// morningStatus is the read-only "what's missing" query the web UI (and
// eventually a voice query) calls. Pure recompute over the current facts —
// no dedupe/nudge-time gating, unlike fireMorningRoutines: this answers
// "state right now," not "should we nag."
func (t *tickLoop) morningStatus(ctx context.Context, now time.Time) []ipc.MorningRoutineStatus {
if len(t.morningRoutines) == 0 {
return nil
}
facts := t.gatherMorningFacts(ctx)
out := make([]ipc.MorningRoutineStatus, 0, len(t.morningRoutines))
for _, r := range t.morningRoutines {
st := morning.Evaluate(r, facts, now)
done := make(map[string]bool, len(st.Completed))
for _, it := range st.Completed {
done[it.Key] = true
}
items := make([]ipc.MorningRoutineItem, len(r.Items))
for i, it := range r.Items {
items[i] = ipc.MorningRoutineItem{Key: it.Key, Label: it.Label, Done: done[it.Key]}
}
out = append(out, ipc.MorningRoutineStatus{
Name: r.Name,
Active: st.Active,
WindowStart: r.WindowStart,
WindowEnd: r.WindowEnd,
Items: items,
})
}
return out
}
// dayPlan is the read-only "what does today hold" query (Vikunja #128). It is
// the impure half of morning.BuildPlan: it reads the calendar events, the
// pending reminders and the checklist facts, and the pure builder orders them.
//
// It never dispatches. Asking for the plan is a query like any other; the only
// unprompted delivery in maven stays with the morning nudge and the
// dispatcher's policy.
func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
y, m, d := now.Date()
dayStart := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
dayEnd := dayStart.AddDate(0, 0, 1)
var events []morning.PlanEntry
facts, err := t.store.CalendarEvents(ctx, dayStart, dayEnd)
if err != nil {
log.Printf("tick: day plan: calendar events: %v", err)
}
for _, f := range facts {
events = append(events, morning.PlanEntry{
At: f.Ts,
// The plan prints the hour itself, so the "@ 14:00-14:30" tail the
// fact value carries would say it twice.
Text: calendar.FactSummary(f.Value),
Kind: morning.PlanEvent,
// Provenance below a calendar read (an ambient relay, #126) is
// hedged rather than recited as fact.
Uncertain: f.Confidence < 1.0,
})
}
var reminders []morning.PlanEntry
rems, err := t.store.PendingReminders(ctx, dayStart, dayEnd)
if err != nil {
log.Printf("tick: day plan: pending reminders: %v", err)
}
for _, r := range rems {
if r.Status != store.ReminderPending {
continue
}
fire := r.NextFireTs
if fire.IsZero() {
fire = r.FireTs
}
reminders = append(reminders, morning.PlanEntry{
At: fire,
Text: r.Text(),
Kind: morning.PlanReminder,
})
}
var checklistFacts map[string]store.Fact
if len(t.morningRoutines) > 0 {
checklistFacts = t.gatherMorningFacts(ctx)
}
plan := morning.BuildPlan(t.morningRoutines, checklistFacts, events, reminders, now)
out := ipc.DayPlan{Date: plan.Date, Spoken: plan.FormatRU()}
out.Items = make([]ipc.DayPlanItem, len(plan.Items))
for i, it := range plan.Items {
out.Items[i] = ipc.DayPlanItem{
At: it.At,
Text: it.Text,
Kind: string(it.Kind),
Uncertain: it.Uncertain,
}
}
return out
}
// tune — the feedback auto-tuner's impure step. runs on a slow cadence
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
// rule:
@@ -382,3 +981,101 @@ func (t *tickLoop) trace() *loop.TickTrace {
defer t.mu.Unlock()
return t.lastTrace
}
// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
// daemon's in-memory tick trace cache.
type daemonAPI struct {
ipc.CoreAPI
getTrace func() *loop.TickTrace
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
getDayPlan func(ctx context.Context) ipc.DayPlan
chatFn func(ctx context.Context, text string) string
getMCPServers func() []ipc.MCPServerStatus
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) {
if d.chatFn == nil {
return "", errors.New("mavend: chat not available")
}
return d.chatFn(ctx, text), nil
}
// MCPServers — the configured MCP servers and their health (Vikunja #251).
// Empty, not an error, when the mcp block is absent: "not configured" is the
// default state and the web surface renders it as such.
func (d *daemonAPI) MCPServers(ctx context.Context) ([]ipc.MCPServerStatus, error) {
if d.getMCPServers == nil {
return nil, nil
}
return d.getMCPServers(), nil
}
func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
trace := d.getTrace()
if trace == nil {
return ipc.TickTrace{}, nil
}
return toIPCTickTrace(*trace), nil
}
func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) {
if d.getMorningStatus == nil {
return nil, errors.New("mavend: morning status not available")
}
return d.getMorningStatus(ctx), nil
}
func (d *daemonAPI) DayPlan(ctx context.Context) (ipc.DayPlan, error) {
if d.getDayPlan == nil {
return ipc.DayPlan{}, errors.New("mavend: day plan not available")
}
return d.getDayPlan(ctx), nil
}
func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace {
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
for i, r := range t.RuleTraces {
rules[i] = toIPCRuleTrace(r)
}
return ipc.TickTrace{
Now: t.Now,
Winner: t.Winner,
Rules: rules,
}
}
func toIPCRuleTrace(r loop.RuleTrace) ipc.RuleTrace {
return ipc.RuleTrace{
RuleName: r.RuleName,
Severity: int(r.Severity),
PredicateResult: r.PredicateResult,
GateResult: r.GateResult,
GateBlockedBy: r.GateBlockedBy,
GateDetail: toIPCGateDetail(r.GateDetail),
WasSelected: r.WasSelected,
LostTo: r.LostTo,
}
}
func toIPCGateDetail(d loop.GateDetail) ipc.GateDetail {
return ipc.GateDetail{
SnoozeUntil: d.SnoozeUntil,
CooldownUntil: d.CooldownUntil,
QuietHours: d.QuietHours,
CalendarBusy: d.CalendarBusy,
Presence: d.Presence,
InertKeysMissing: d.InertKeysMissing,
}
}
-111
View File
@@ -1,111 +0,0 @@
// mavend/tick_api.go — the daemonAPI read surface over the tick loop.
//
// Split out of tick.go, move-only (Vikunja #422). What mavweb asks the daemon
// for, and the loop-to-ipc conversions those answers need.
package main
import (
"context"
"errors"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/loop"
)
// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
// daemon's in-memory tick trace cache.
type daemonAPI struct {
ipc.CoreAPI
getTrace func() *loop.TickTrace
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
getDayPlan func(ctx context.Context) ipc.DayPlan
chatFn func(ctx context.Context, conversation, 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, conversation, text string) (string, error) {
if d.chatFn == nil {
return "", errors.New("mavend: chat not available")
}
return d.chatFn(ctx, conversation, text), nil
}
// MCPServers — the configured MCP servers and their health (Vikunja #251).
// Empty, not an error, when the mcp block is absent: "not configured" is the
// default state and the web surface renders it as such.
func (d *daemonAPI) MCPServers(ctx context.Context) ([]ipc.MCPServerStatus, error) {
if d.getMCPServers == nil {
return nil, nil
}
return d.getMCPServers(), nil
}
func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
trace := d.getTrace()
if trace == nil {
return ipc.TickTrace{}, nil
}
return toIPCTickTrace(*trace), nil
}
func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) {
if d.getMorningStatus == nil {
return nil, errors.New("mavend: morning status not available")
}
return d.getMorningStatus(ctx), nil
}
func (d *daemonAPI) DayPlan(ctx context.Context) (ipc.DayPlan, error) {
if d.getDayPlan == nil {
return ipc.DayPlan{}, errors.New("mavend: day plan not available")
}
return d.getDayPlan(ctx), nil
}
func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace {
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
for i, r := range t.RuleTraces {
rules[i] = toIPCRuleTrace(r)
}
return ipc.TickTrace{
Now: t.Now,
Winner: t.Winner,
Rules: rules,
}
}
func toIPCRuleTrace(r loop.RuleTrace) ipc.RuleTrace {
return ipc.RuleTrace{
RuleName: r.RuleName,
Severity: int(r.Severity),
PredicateResult: r.PredicateResult,
GateResult: r.GateResult,
GateBlockedBy: r.GateBlockedBy,
GateDetail: toIPCGateDetail(r.GateDetail),
WasSelected: r.WasSelected,
LostTo: r.LostTo,
}
}
func toIPCGateDetail(d loop.GateDetail) ipc.GateDetail {
return ipc.GateDetail{
SnoozeUntil: d.SnoozeUntil,
CooldownUntil: d.CooldownUntil,
QuietHours: d.QuietHours,
CalendarBusy: d.CalendarBusy,
Presence: d.Presence,
InertKeysMissing: d.InertKeysMissing,
}
}
-233
View File
@@ -1,233 +0,0 @@
// mavend/tick_digest.go — the digest queue.
//
// Split out of tick.go, move-only (Vikunja #422). A nudge below the severity
// ceiling is held here instead of spoken, flushed as one batch on the window,
// and drained by hand when he asks. Everything about batching lives here.
package main
import (
"context"
"fmt"
"log"
"strings"
"time"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/store"
)
// shouldQueue — true when digest is enabled and the candidate's severity is
// at or below the configured ceiling.
func (t *tickLoop) shouldQueue(cand *loop.Candidate) bool {
return t.digestCfg != nil && t.digestCfg.Enabled &&
cand.Severity <= loop.Severity(t.digestCfg.SeverityCeiling)
}
// queueNudge — phrases the candidate and appends it to the digest queue.
// Deduplicates by rule name: if the same rule is already queued, this is a
// no-op (the first fire within the window is the one that counts).
func (t *tickLoop) queueNudge(ctx context.Context, cand *loop.Candidate, _ loop.State, now time.Time) {
for _, q := range t.digestQ {
if q.Rule == cand.Rule.Name {
return // already queued
}
}
pn, err := t.phraser.PhraseNudge(ctx, *cand)
if err != nil {
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
return
}
t.digestQ = append(t.digestQ, QueuedNudge{
Rule: cand.Rule.Name,
Severity: int(cand.Severity),
Body: pn.Body,
Key: cand.Rule.Name,
QueuedAt: now,
})
t.cachePhrase(pn)
}
// maybeFlush — flushes the digest queue if the window has elapsed since the
// first item or the queue reached MaxItems.
func (t *tickLoop) maybeFlush(ctx context.Context, now time.Time, state loop.State) {
if t.digestCfg == nil || !t.digestCfg.Enabled || len(t.digestQ) == 0 {
return
}
first := t.digestQ[0]
if now.Sub(first.QueuedAt) >= time.Duration(t.digestCfg.Window) ||
len(t.digestQ) >= t.digestCfg.MaxItems {
t.flushDigest(ctx, now, state)
}
}
// flushDigest — concatenates queued nudge bodies into a single digest
// notification and dispatches it. Clears the queue after a successful send.
// The digest uses the max severity among queued items for routing.
func (t *tickLoop) flushDigest(ctx context.Context, now time.Time, state loop.State) {
if len(t.digestQ) == 0 {
return
}
var b strings.Builder
maxSev := 0
for i, q := range t.digestQ {
if i > 0 {
b.WriteString(" · ")
}
b.WriteString(q.Body)
if q.Severity > maxSev {
maxSev = q.Severity
}
}
body := b.String()
summary := fmt.Sprintf("%d pending notifications", len(t.digestQ))
cand := loop.Candidate{
Rule: loop.Rule{
Name: "digest",
Severity: loop.Severity(maxSev),
},
Severity: loop.Severity(maxSev),
State: state,
}
pn := delivery.PhrasedNudge{
Candidate: cand,
Body: body,
Summary: summary,
}
t.cachePhrase(pn)
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
// keep the queue — the next tick's maybeFlush re-attempts.
log.Printf("tick: dispatch digest: %v", err)
return
}
t.digestQ = nil
}
// digestExpiry — how long a gate-suppressed care nudge stays worth
// resurfacing. 24h: these are daily-cadence rules (water/meal/break run on
// hour-scale cooldowns and re-derive from facts that reset every day), so a
// digest entry that outlives one full day is describing a day that's already
// over — "you skipped a break yesterday" said tomorrow evening is noise, not
// news. Bounding at one day also means a digest can never silently span a
// weekend of quiet hours into an unbounded backlog.
const digestExpiry = 24 * time.Hour
// maxDigestSpokenItems — the bundle read-out is capped so "batched, not
// dropped" cannot regress into "she dumps twelve things on me the moment I
// walk in" — a digest that nags in bulk is worse than the drops it replaced.
// Anything beyond the cap is still marked drained (it did get its moment;
// the cap limits WORDS, not whether it counted) and folded into a trailing
// count instead of being spoken in full.
const maxDigestSpokenItems = 3
// enqueueSuppressedDigest scans this tick's trace for care candidates the
// gate blocked for a genuine restraint reason and durably records the
// digest-eligible ones (loop.DigestEligible). Phrasing happens once, here,
// at enqueue time — not re-derived at drain time — the same way queueNudge
// phrases once and caches, so a rule suppressed for hours isn't re-prompting
// the LLM every tick it stays blocked (EnqueueDigestEntry's rule+body dedupe
// makes repeat calls here harmless, but skipping the phrase call entirely
// when a pending entry already exists avoids the LLM round-trip too).
func (t *tickLoop) enqueueSuppressedDigest(ctx context.Context, trace *loop.TickTrace, state loop.State, now time.Time) {
if trace == nil {
return
}
for _, tr := range trace.RuleTraces {
if !tr.PredicateResult || tr.GateResult {
continue // didn't want to fire, or wasn't suppressed
}
if !loop.DigestEligible(tr.Severity, tr.GateBlockedBy) {
continue
}
rule := loop.Rule{Name: tr.RuleName, Severity: tr.Severity}
cand := loop.Candidate{Rule: rule, Severity: tr.Severity, State: state}
pn, err := t.phraser.PhraseNudge(ctx, cand)
if err != nil {
log.Printf("tick: phrase digest candidate %s: %v", tr.RuleName, err)
continue
}
expires := now.Add(digestExpiry)
if _, deduped, err := t.store.EnqueueDigestEntry(ctx, tr.RuleName, int(tr.Severity), pn.Body, now, expires); err != nil {
log.Printf("tick: enqueue digest entry %s: %v", tr.RuleName, err)
} else if deduped {
// same suppressed nudge already pending — nothing new to say.
continue
}
}
}
// expireStaleDigest sweeps entries past their expiry once per tick — cheap
// bookkeeping, mirrors ReconcileStaleDeliveryAttempts's shape.
func (t *tickLoop) expireStaleDigest(ctx context.Context, now time.Time) {
n, err := t.store.ExpireStaleDigestEntries(ctx, now)
if err != nil {
log.Printf("tick: expire stale digest entries: %v", err)
return
}
if n > 0 {
log.Printf("tick: expired %d stale digest entr(y/ies) unspoken", n)
}
}
// maybeDrainDigest speaks the pending digest bundle once the gate's
// suppression reasons have actually cleared — quiet hours over, back from
// away, out of the meeting. Draining while still suppressed would just be a
// second way to nag through quiet hours; the bundle waits for the same "is
// it allowed right now" condition a live nudge already waits for.
func (t *tickLoop) maybeDrainDigest(ctx context.Context, state loop.State, now time.Time) {
if state.QuietHours || state.CalendarBusy || state.Presence == store.Away {
return
}
entries, err := t.store.PendingDigestEntries(ctx, now)
if err != nil {
log.Printf("tick: pending digest entries: %v", err)
return
}
if len(entries) == 0 {
return
}
spoken := entries
extra := 0
if len(spoken) > maxDigestSpokenItems {
spoken = entries[:maxDigestSpokenItems]
extra = len(entries) - maxDigestSpokenItems
}
var b strings.Builder
maxSev := 0
for i, e := range spoken {
if i > 0 {
b.WriteString(" · ")
}
b.WriteString(e.Body)
if e.Severity > maxSev {
maxSev = e.Severity
}
}
if extra > 0 {
fmt.Fprintf(&b, " · и ещё %d", extra)
}
body := b.String()
summary := fmt.Sprintf("%d отложенных уведомлений", len(entries))
cand := loop.Candidate{
Rule: loop.Rule{Name: "digest", Severity: loop.Severity(maxSev)},
Severity: loop.Severity(maxSev),
State: state,
}
pn := delivery.PhrasedNudge{Candidate: cand, Body: body, Summary: summary}
t.cachePhrase(pn)
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
log.Printf("tick: dispatch digest bundle: %v", err)
return // leave entries pending; retried next tick
}
ids := make([]int64, len(entries))
for i, e := range entries {
ids[i] = e.ID
}
if err := t.store.DrainDigestEntries(ctx, ids, now); err != nil {
log.Printf("tick: drain digest entries: %v", err)
}
}
-197
View File
@@ -1,197 +0,0 @@
// mavend/tick_morning.go — the morning checklist and the day plan.
//
// Split out of tick.go, move-only (Vikunja #422). One window per configured
// routine, nudging once at the end for what is still open, plus the read
// surfaces /morning renders.
package main
import (
"context"
"fmt"
"log"
"strings"
"time"
"github.com/kami/maven/internal/calendar"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/morning"
"github.com/kami/maven/internal/store"
)
// fireMorningRoutines checks each configured checklist against today's facts
// and dispatches a nag listing exactly what's still missing, at most once per
// routine per calendar day. Fact reads happen here (not in loop.Gatherer)
// because the item↔fact-key mapping is morning-routine-specific, not a rule
// concern — pulling it into the shared gather path would leak that mapping
// into loop's "rules declare wanted keys" contract. Bodies are literal
// operator text (item labels joined), not LLM-phrased, same rationale as
// cron routines: deterministic, can't hallucinate a checklist item.
func (t *tickLoop) fireMorningRoutines(ctx context.Context, now time.Time, state loop.State) {
if len(t.morningRoutines) == 0 {
return
}
facts := t.gatherMorningFacts(ctx)
for _, cand := range morning.Due(t.morningRoutines, facts, t.morningLast, now) {
body := morningNudgeBody(cand)
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{
Rule: loop.Rule{Name: "morning:" + cand.Routine.Name, Severity: loop.Severity(cand.Routine.Severity)},
Severity: loop.Severity(cand.Routine.Severity),
State: state,
},
Body: body,
Summary: body,
}
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
log.Printf("tick: dispatch morning routine %s: %v", cand.Routine.Name, err)
}
}
}
// morningNudgeBody words the one message a routine gets per day. Required
// items are what she says was not done; optional ones follow, worded as
// something he could still do rather than something he owes (Vikunja #473).
// Operator text, not phrased by the model, for the same reason it always was:
// a checklist item must not be invented.
func morningNudgeBody(cand morning.Candidate) string {
labels := func(items []morning.Item) string {
out := make([]string, len(items))
for i, it := range items {
out[i] = it.Label
}
return strings.Join(out, ", ")
}
body := fmt.Sprintf("%s: не сделано — %s", cand.Routine.Name, labels(morning.Required(cand.Missing)))
if opt := morning.OptionalOnly(cand.Missing); len(opt) > 0 {
body += fmt.Sprintf(". если будет время — %s", labels(opt))
}
return body
}
// gatherMorningFacts reads the latest fact for every item's fact_key across
// all configured morning routines. Shared by fireMorningRoutines (nudge
// decision) and morningStatus (read-only query) so the two paths can never
// disagree about what evidence exists.
func (t *tickLoop) gatherMorningFacts(ctx context.Context) map[string]store.Fact {
keys := make(map[string]struct{})
for _, r := range t.morningRoutines {
for _, it := range r.Items {
keys[it.FactKey] = struct{}{}
}
}
facts := make(map[string]store.Fact, len(keys))
for k := range keys {
f, err := t.store.LatestFact(ctx, k)
if err == nil {
facts[k] = f
continue
}
if err != store.ErrNoFact {
log.Printf("tick: morning: latest fact %s: %v", k, err)
}
}
return facts
}
// morningStatus is the read-only "what's missing" query the web UI (and
// eventually a voice query) calls. Pure recompute over the current facts —
// no dedupe/nudge-time gating, unlike fireMorningRoutines: this answers
// "state right now," not "should we nag."
func (t *tickLoop) morningStatus(ctx context.Context, now time.Time) []ipc.MorningRoutineStatus {
if len(t.morningRoutines) == 0 {
return nil
}
facts := t.gatherMorningFacts(ctx)
out := make([]ipc.MorningRoutineStatus, 0, len(t.morningRoutines))
for _, r := range t.morningRoutines {
st := morning.Evaluate(r, facts, now)
done := make(map[string]bool, len(st.Completed))
for _, it := range st.Completed {
done[it.Key] = true
}
items := make([]ipc.MorningRoutineItem, len(r.Items))
for i, it := range r.Items {
items[i] = ipc.MorningRoutineItem{Key: it.Key, Label: it.Label, Done: done[it.Key]}
}
out = append(out, ipc.MorningRoutineStatus{
Name: r.Name,
Active: st.Active,
WindowStart: r.WindowStart,
WindowEnd: r.WindowEnd,
Items: items,
})
}
return out
}
// dayPlan is the read-only "what does today hold" query (Vikunja #128). It is
// the impure half of morning.BuildPlan: it reads the calendar events, the
// pending reminders and the checklist facts, and the pure builder orders them.
//
// It never dispatches. Asking for the plan is a query like any other; the only
// unprompted delivery in maven stays with the morning nudge and the
// dispatcher's policy.
func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
y, m, d := now.Date()
dayStart := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
dayEnd := dayStart.AddDate(0, 0, 1)
var events []morning.PlanEntry
facts, err := t.store.CalendarEvents(ctx, dayStart, dayEnd)
if err != nil {
log.Printf("tick: day plan: calendar events: %v", err)
}
for _, f := range facts {
events = append(events, morning.PlanEntry{
At: f.Ts,
// The plan prints the hour itself, so the "@ 14:00-14:30" tail the
// fact value carries would say it twice.
Text: calendar.FactSummary(f.Value),
Kind: morning.PlanEvent,
// Provenance below a calendar read (an ambient relay, #126) is
// hedged rather than recited as fact.
Uncertain: f.Confidence < 1.0,
})
}
var reminders []morning.PlanEntry
rems, err := t.store.PendingReminders(ctx, dayStart, dayEnd)
if err != nil {
log.Printf("tick: day plan: pending reminders: %v", err)
}
for _, r := range rems {
if r.Status != store.ReminderPending {
continue
}
fire := r.NextFireTs
if fire.IsZero() {
fire = r.FireTs
}
reminders = append(reminders, morning.PlanEntry{
At: fire,
Text: r.Text(),
Kind: morning.PlanReminder,
})
}
var checklistFacts map[string]store.Fact
if len(t.morningRoutines) > 0 {
checklistFacts = t.gatherMorningFacts(ctx)
}
plan := morning.BuildPlan(t.morningRoutines, checklistFacts, events, reminders, now)
out := ipc.DayPlan{Date: plan.Date, Spoken: plan.FormatRU()}
out.Items = make([]ipc.DayPlanItem, len(plan.Items))
for i, it := range plan.Items {
out.Items[i] = ipc.DayPlanItem{
At: it.At,
Text: it.Text,
Kind: string(it.Kind),
Uncertain: it.Uncertain,
}
}
return out
}
-234
View File
@@ -1,234 +0,0 @@
// mavend/tick_routines.go — pattern detection and the routines that fire.
//
// Split out of tick.go, move-only (Vikunja #422). Configured routines, the
// routines he accepted on /routines, and the tick-side detector that proposes
// new ones.
package main
import (
"context"
"log"
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/pattern"
"github.com/kami/maven/internal/routine"
)
// detectPatterns runs the pattern detector proactively over every
// action+object pair that has ever produced an event, independent of
// whichever fact write (or channel) last touched it (Vikunja #43). This is
// what makes pattern inference actually proactive: it fires on the daemon's
// own schedule reading accumulated history, not only as a side effect of a
// live voice turn.
//
// Idempotence and noise are handled by the store, not here — this function
// is safe to call every tick:
// - Same pattern, tick after tick: detectAndPropose's LookupProposedRoutine
// check plus proposed_routines' UNIQUE(action, object) constraint (with
// CreateProposedRoutine's ON CONFLICT DO NOTHING) mean a pair that
// already has a row — in ANY status — produces no second row and no log
// spam beyond the one line at genuine creation.
// - A DISMISSED proposal must never come back. DismissProposedRoutine flips
// status in place; the row is never deleted. So the same Lookup check
// that stops a duplicate "proposed" also stops a "dismissed" one from
// resurrecting — there is nothing tick-specific to get right here beyond
// calling the same shared path the voice route already used.
//
// By default this only creates a row for the /routines page to show: it does
// not notify, ring, or speak. Detection is not the same act as disturbing him
// about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing
// is opt-in through the pattern_proposals config block — see announceProposal
// for the restraints that apply even then. A proposal only starts producing
// recurring nudges once he accepts it (fireAcceptedRoutines).
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop.State) {
pairs, err := t.store.DistinctEventPairs(ctx)
if err != nil {
log.Printf("tick: distinct event pairs: %v", err)
return
}
announced := false
for _, p := range pairs {
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
if err != nil {
log.Printf("tick: detect pattern %s/%s: %v", p.Action, p.Object, err)
continue
}
if r == nil {
continue // no stable pattern, or already proposed/accepted/dismissed
}
log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
// One announcement per tick at most, whatever the scan turned up. The
// rest are on /routines; they are not lost, they are just not shouted.
// Nor are they queued: the row now exists, so no later tick re-detects
// them and they are never announced. See announceProposal.
if announced {
continue
}
announced = t.announceProposal(ctx, r, now, state)
}
}
// announceProposal offers a freshly inferred routine through the ordinary
// care-delivery path, if announcing is switched on at all. Returns true when
// something was actually sent.
//
// Everything here is restraint. The feature is off unless configured; when on
// it is sev1 (the lowest severity, so quiet hours, away presence and snooze
// all suppress it via loop.Gate exactly like a care nudge); it is spaced by
// proposalCfg.Cooldown across every pair, not per pair; and a suppressed or
// dropped announcement is NOT retried — the cooldown clock advances only on a
// real send, but the proposal row already exists, so the next tick will not
// re-detect it and nothing queues up behind it. A missed announcement means
// he reads it on /routines instead, which is the whole point of the page.
//
// What the cooldown is and is not. detectAndPropose returns non-nil only for a
// newly created row, so a pair gets exactly one chance to be spoken: the tick
// that first proposes it. Combined with one announcement per tick, the first
// tick over a populated history announces one pattern and permanently silences
// every other pattern found in the same pass. That is the intent, not an
// oversight — an inferred routine is not worth a second attempt at his
// attention, and /routines lists all of them. So the cooldown does not drain a
// backlog. It only spaces announcements of genuinely new pairs discovered on
// later ticks. If it should ever become "one per day until each is mentioned",
// that needs a queue rather than this counter.
//
// Cooldown gets its default here as well as in applyDefaults. That is
// deliberate: a tickLoop assembled directly in a test never goes through Load,
// and an unspaced announcer is not what those tests mean to exercise.
//
// The body is the detector's own literal Russian phrasing (pattern.PhraseRoutine
// — "ты заправляешь поилку раз в 7 дней — напоминать?"), not LLM-generated, so
// an inferred routine cannot arrive worded as something Maven never observed.
func (t *tickLoop) announceProposal(ctx context.Context, r *pattern.ProposedRoutine, now time.Time, state loop.State) bool {
if !t.proposalCfg.AnnounceProposals() {
return false
}
cooldown := time.Duration(t.proposalCfg.Cooldown)
if cooldown <= 0 {
cooldown = config.DefaultProposalCooldown
}
if !t.lastProposalAt.IsZero() && now.Sub(t.lastProposalAt) < cooldown {
return false
}
rule := loop.Rule{Name: "proposal:" + r.Action + " " + r.Object, Severity: loop.Sev1}
if !loop.Gate(state, rule) {
return false
}
body := pattern.PhraseRoutine(r)
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
Body: body,
Summary: body,
}
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
if err != nil {
log.Printf("tick: announce proposal %s/%s: %v", r.Action, r.Object, err)
return false
}
if len(sent) == 0 {
return false // routing dropped it — /routines still has it.
}
t.lastProposalAt = now
return true
}
// routinesFromConfig maps the config's routine blocks to the engine type.
// Validation (cron parses, name/body present, severity defaulted) already ran
// in config.Load, so this is a pure field copy.
func routinesFromConfig(rc []config.RoutineConfig) []routine.Routine {
if len(rc) == 0 {
return nil
}
out := make([]routine.Routine, len(rc))
for i, r := range rc {
out[i] = routine.Routine{Name: r.Name, Cron: r.Cron, Body: r.Body, Severity: r.Severity}
}
return out
}
// fireRoutines dispatches the routines whose cron schedule crossed since their
// last fire. Each is delivered as a nudge through the normal routing table
// (ChannelsFor(severity, presence)) with a "routine:"-prefixed rule name so it
// can't collide with a care rule in the feedback autotuner. A dispatch failure
// logs and continues — one bad send must not skip the rest, and routine.Due has
// already advanced the last-fire time so a transient failure drops that fire
// rather than replaying it every tick (a routine is clockwork, not an alarm —
// no repeat-til-ack).
func (t *tickLoop) fireRoutines(ctx context.Context, now time.Time, state loop.State) {
for _, r := range routine.Due(t.routines, t.routineLast, now) {
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{
Rule: loop.Rule{Name: "routine:" + r.Name, Severity: loop.Severity(r.Severity)},
Severity: loop.Severity(r.Severity),
State: state,
},
Body: r.Body,
Summary: r.Body,
}
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
log.Printf("tick: dispatch routine %s: %v", r.Name, err)
}
}
}
// fireAcceptedRoutines nudges about the routines the user accepted, once per
// interval (Vikunja #366). Accepting used to create a single reminder, so a
// non-weekly routine fired once and went quiet forever; the schedule lives in
// the proposed_routines row now and the loop re-reads it every tick.
//
// A routine is a care-class nudge and goes through the restraint gate like any
// other: quiet hours, away presence and snooze all suppress it. Reminders bypass
// that gate; routines must not. A suppressed nudge is NOT marked fired, so it
// goes out on the next tick that the gate allows — one nudge, held, not dropped
// and not repeated.
//
// The body is literal text built from the detected action and object, not
// LLM-phrased, so a routine can't hallucinate. It nudges; it never acts.
func (t *tickLoop) fireAcceptedRoutines(ctx context.Context, now time.Time, state loop.State) {
rows, err := t.store.ListAcceptedRoutines(ctx)
if err != nil {
log.Printf("tick: list accepted routines: %v", err)
return
}
accepted := make([]routine.Accepted, 0, len(rows))
for _, r := range rows {
if r.AcceptedTs == nil {
continue // accepted before the schedule column existed — no clock to start from.
}
accepted = append(accepted, routine.Accepted{
ID: r.ID,
Name: r.Action + " " + r.Object,
IntervalDays: r.IntervalDays,
Accepted: *r.AcceptedTs,
LastFired: r.LastFiredTs,
})
}
for _, a := range routine.DueAccepted(accepted, now) {
rule := loop.Rule{Name: "routine:" + a.Name, Severity: loop.Sev1}
if !loop.Gate(state, rule) {
continue
}
body := "пора: " + a.Name
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
Body: body,
Summary: body,
}
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
if err != nil {
log.Printf("tick: dispatch accepted routine %d: %v", a.ID, err)
continue
}
if len(sent) == 0 {
continue // routing dropped it — leave it due.
}
if err := t.store.MarkRoutineFired(ctx, a.ID, now); err != nil {
log.Printf("tick: mark routine %d fired: %v", a.ID, err)
}
}
}
+1 -1
View File
@@ -584,7 +584,7 @@ func TestDigestSev4BypassesQueue(t *testing.T) {
ctx := context.Background()
now := refNow()
markPresent(t, st, ctx, now)
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", "down", now); err != nil {
if _, err := st.SetValue(ctx, store.KindSelf, "service_down", "poll:uptimekuma", "down", now); err != nil {
t.Fatalf("seed service_down: %v", err)
}
sink := &fakeSink{}
+4 -7
View File
@@ -220,9 +220,9 @@ func (h *reactiveHandler) upgradeAPI(api ipc.CoreAPI) {
// handleText — the core reactive path without stt/tts. Used by the IPC Chat
// endpoint (and eventually by telegram). Splits out the audio bookends from
// HandlePushToTalk so text channels share the same routing logic.
func (h *reactiveHandler) handleText(ctx context.Context, conversation, text string) string {
func (h *reactiveHandler) handleText(ctx context.Context, text string) string {
log.Printf("voice: handleText: %q", text)
return h.runTurn(withDialogueID(ctx, dialogueIDFor(sourceText, conversation)), text, sourceText)
return h.runTurn(ctx, text, sourceText)
}
// turnSource — which channel this utterance arrived on, in the same provenance
@@ -255,7 +255,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// early. He can be asked a question, walk off, come back and say "да" to a
// confirm that is still parked; computing the notice after that return meant
// he answered the confirm and never heard that the older request was let go.
expiredNotice := h.clarifyExpiredNotice(ctx)
expiredNotice := h.clarifyExpiredNotice()
// 2. confirm turn — if a destructive act is parked, this utterance is its
// y/n answer, not a fresh command. Handled before routing so "да" doesn't
@@ -351,10 +351,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// and park the request (clarify.go); otherwise the replier's canned reply
// stands.
if dec.Clarify {
if reply := h.hexisBeforeClarify(ctx, dec); reply != "" {
return withNotice(expiredNotice, reply)
}
if question, asked := h.askClarify(ctx, dec); asked {
if question, asked := h.askClarify(dec); asked {
return withNotice(expiredNotice, question)
}
}
+7 -37
View File
@@ -238,10 +238,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
// ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) -----
// Store-backed when the daemon passes a store, so a restart mid-conversation
// keeps the thread (Vikunja #363). Sessions past their TTL are dropped on
// load, never revived. Clarify's parked question stays in memory only, and
// that is a decision rather than an omission (Vikunja #385, docs/design.md):
// a restart expires it, so the thread comes back and the open question does
// not.
// load, never revived. Clarify's parked question stays in memory only.
var dialogueSessions *dialogue.SessionStore
if dataStore != nil {
dialogueSessions = dialogue.NewPersistentSessionStore(2*time.Minute, dataStore)
@@ -382,12 +379,8 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
// question and must keep reaching replySystem, while "что у меня сегодня"
// is an agenda question and must not.
grammars = append(grammars, router.AgendaQueryGrammars()...)
grammars = append(grammars, router.ListGrammars()...)
grammars = append(grammars, router.ReminderGrammar())
// Last, and it matches any utterance shape — its Build is the filter. An
// explicit capture marker beats the model, which called it an act and
// rewrote the task text (Vikunja #467). After the rules above because a
// marker never collides with a clock or agenda question.
grammars = append(grammars, router.TaskCaptureGrammar())
return router.New(router.Config{
Grammars: grammars,
Classifier: cls,
@@ -401,34 +394,11 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
})
}
// seedDir is the directory containing intent seed files, relative to the repo
// root. Each file is named <intent>.txt and holds one training example per
// line (blank lines and lines starting with # are ignored).
// seedDir is the directory containing intent seed files. Each file is named
// <intent>.txt and contains one training example per line (blank lines and
// lines starting with # are ignored). Relative to the working directory.
const seedDir = "models/seeds"
// seedPath resolves seedDir against the working directory, walking up until it
// finds it. The daemon runs from the repo root and the first candidate hits.
//
// A test does not: `go test ./cmd/mavend/` runs with the working directory at
// cmd/mavend, so every open failed and the simulator scenarios replayed a whole
// scripted day against a classifier holding zero examples (Vikunja #465). They
// passed, which is the part that matters — a green simulator was not exercising
// the routing the deploy runs, and a regression in the seed set could not have
// shown up there.
//
// Bounded at five levels, so a daemon started somewhere without the seeds logs
// the same failure it always did rather than walking to the filesystem root.
func seedPath() string {
dir := seedDir
for i := 0; i < 5; i++ {
if st, err := os.Stat(dir); err == nil && st.IsDir() {
return dir
}
dir = filepath.Join("..", dir)
}
return seedDir
}
// seedClassifier floors the embedded examples so the cold-boot path
// doesn't return ErrNoIntents. Loads examples from seedDir — one file per
// intent (act.txt, reminder.txt, fact.txt, note.txt, query.txt). When the
@@ -453,11 +423,11 @@ func seedClassifier(c *router.Classifier) {
}
total += n
}
log.Printf("voice: loaded %d seed examples from %s", total, seedPath())
log.Printf("voice: loaded %d seed examples from %s", total, seedDir)
}
func loadSeedFile(c *router.Classifier, intent router.Intent) (int, error) {
path := filepath.Join(seedPath(), string(intent)+".txt")
path := filepath.Join(seedDir, string(intent)+".txt")
f, err := os.Open(path)
if err != nil {
return 0, fmt.Errorf("open %s: %w", path, err)
+33 -45
View File
@@ -1,13 +1,10 @@
// Package main — weatherq.go holds the weather-query keyword helpers: does
// this utterance ask about weather at all, and which place (if any) did he
// name. Both are plain keyword matching, not NLU — extend this file rather
// than voice.go for anything in that shape.
// this utterance ask about weather at all, and which city (if any) did it
// name. Both are plain substring/lookup matching, not NLU — extend this file
// rather than voice.go for anything in that shape.
package main
import (
"regexp"
"strings"
)
import "strings"
// isWeatherQuery returns true if the utterance is about weather.
func isWeatherQuery(u string) bool {
@@ -22,49 +19,40 @@ func isWeatherQuery(u string) bool {
strings.Contains(lower, "temperature")
}
// weatherPlace — the place he named, after "в"/"во"/"in". One or two words,
// letters and dashes only, so "в Нижнем Новгороде" and "in New York" both
// come through whole and "в 5 утра" does not.
var weatherPlace = regexp.MustCompile(`(?i)(?:^|\s)(?:в|во|in)\s+([\p{L}-]+(?:\s+[\p{L}-]+)?)`)
// weatherNonPlaces — words that follow "в" in a weather question and are not
// cities. "какая погода в доме" is the smart-home sensor, not Open-Meteo, and
// "тепло в комнате" is the same question about the same room.
var weatherNonPlaces = map[string]bool{
"доме": true, "квартире": true, "комнате": true, "спальне": true,
"гостиной": true, "кухне": true, "гараже": true, "офисе": true,
"выходные": true, "субботу": true, "воскресенье": true, "понедельник": true,
"вторник": true, "среду": true, "четверг": true, "пятницу": true,
"обед": true, "обеде": true, "утро": true, "утром": true, "вечер": true,
"вечером": true, "ночь": true, "ночью": true, "целом": true, "принципе": true,
// weatherCities — the city names an utterance may name explicitly, as
// lowercase substrings mapped to the provider's spelling. This is a
// convenience for "какая погода в Лондоне", NOT a source of default truth:
// nothing here is used unless he actually said it.
var weatherCities = map[string]string{
"москв": "Moscow",
"moscow": "Moscow",
"питер": "Saint Petersburg",
"spb": "Saint Petersburg",
"петербур": "Saint Petersburg",
"лондон": "London",
"london": "London",
"париж": "Paris",
"paris": "Paris",
"берлин": "Berlin",
"berlin": "Berlin",
"нью-йорк": "New York",
"new york": "New York",
}
// extractWeatherLocation returns the place he named, or the configured default
// extractWeatherLocation returns the city he named, or the configured default
// when he named none. It returns "" when he named none AND no default is
// configured — the caller must then say it does not know.
//
// It used to be a hand-written table of six cities in two spellings each
// (Vikunja #421). Anything outside it — Kazan, Tbilisi — was dropped silently
// and answered for the default location, which reads as a correct answer about
// the wrong place. There is a geocoder behind this now: internal/weather
// already calls Open-Meteo's geocoding endpoint for every lookup, so any place
// it knows is a place he can ask about, and the table bought nothing.
//
// A named place that the geocoder cannot resolve is the caller's problem to
// report, not this function's to hide.
//
// It used to return "Moscow" when he named nothing. That is a made-up answer
// presented as fact. voice.weather.default_location is the only source of an
// unstated location.
// It used to return "Moscow" in that case. That is a made-up answer presented
// as fact: reading out Moscow's temperature to someone who is not in Moscow is
// wrong in exactly the way maven must never be wrong. voice.weather
// .default_location is the only source of an unstated location.
func extractWeatherLocation(u, defaultLoc string) string {
m := weatherPlace.FindStringSubmatch(u)
if m == nil {
return defaultLoc
lower := strings.ToLower(u)
for substr, name := range weatherCities {
if strings.Contains(lower, substr) {
return name
}
}
place := strings.TrimSpace(m[1])
first := strings.ToLower(strings.Fields(place)[0])
if weatherNonPlaces[first] {
return defaultLoc
}
return place
return defaultLoc
}
-33
View File
@@ -1,33 +0,0 @@
package main
import "testing"
// TestExtractWeatherLocation — any place he names comes through, not just the
// six that used to be in a table (Vikunja #421).
func TestExtractWeatherLocation(t *testing.T) {
cases := []struct {
utterance string
def string
want string
}{
// The cities the table had, and the ones it silently dropped.
{"какая погода в Москве", "Berlin", "Москве"},
{"какая погода в Казани", "Berlin", "Казани"},
{"погода в Тбилиси?", "Berlin", "Тбилиси"},
{"what's the weather in New York", "Berlin", "New York"},
{"тепло в Нижнем Новгороде?", "Berlin", "Нижнем Новгороде"},
// He named nothing: the configured default, and nothing at all when
// there is no default.
{"какая сегодня погода", "Berlin", "Berlin"},
{"какая сегодня погода", "", ""},
// "в" followed by something that is not a place stays the default —
// the house sensors and the day words answer elsewhere.
{"тепло в комнате?", "Berlin", "Berlin"},
{"какая погода в выходные", "Berlin", "Berlin"},
}
for _, c := range cases {
if got := extractWeatherLocation(c.utterance, c.def); got != c.want {
t.Errorf("extractWeatherLocation(%q, %q) = %q, want %q", c.utterance, c.def, got, c.want)
}
}
}
+21 -71
View File
@@ -140,10 +140,6 @@ type poller struct {
wgIface string
wgCmd string
// kumaSeen — monitor name → state as of the last poll, so a monitor that
// disappears from the gauge can be marked unknown instead of staying down.
kumaSeen map[string]string
// zen is nil unless a token file was configured — money tracking is a
// capability, off by default like weather and telegram.
zen *zenmoney.Client
@@ -337,96 +333,50 @@ func maxSeverity(a netdataAlarms) string {
return sev
}
// ---- kuma: monitor_status gauge → one fact per monitor ---------------------
// ---- kuma: monitor_status gauge → aggregate service_down -------------------
// Kuma exposes Prometheus text: `monitor_status{...,monitor_name="X"} V` where
// V is 1=up 0=down 2=pending 3=maintenance. We write one fact per monitor,
// keyed `service_down:<monitor name>`, because the nudge has to say WHICH
// service is down. The aggregate this used to write could not, which is why
// the rule shipped disabled.
var (
kumaLine = regexp.MustCompile(`^monitor_status\{([^}]*)\}\s+([0-9.eE+-]+)`)
kumaName = regexp.MustCompile(`monitor_name="([^"]*)"`)
)
// V is 1=up 0=down 2=pending 3=maintenance. We reduce to one aggregate the
// existing ServiceDownRule consumes: "down" if ANY monitor reads 0, else "up".
// Per-service granularity is a later add (a fact per monitor) — the MVP nudge
// only needs "something is down".
var kumaLine = regexp.MustCompile(`^monitor_status\{([^}]*)\}\s+([0-9.eE+-]+)`)
func (p *poller) pollKuma(ctx context.Context, now time.Time) error {
body, err := p.get(ctx, p.kumaURL, p.kumaKey)
if err != nil {
return err
}
states := kumaMonitors(body)
if len(states) == 0 {
down, seen := kumaAnyDown(body)
if !seen {
return fmt.Errorf("no monitor_status metrics (auth/endpoint wrong?)")
}
var firstErr error
for name, val := range states {
if err := p.writeIfChanged(ctx, kumaFactKey(name), kumaSource, val, now); err != nil && firstErr == nil {
firstErr = err // one bad monitor must not blind the rest
}
val := "up"
if down {
val = "down"
}
// A monitor deleted in kuma stops appearing in the gauge, and its last fact
// would otherwise read "down" forever. Mark it unknown, which no rule fires
// on. The seen-set is in memory, so a restart forgets it — harmless, since
// the next poll that still lacks the monitor says nothing new either.
for name := range p.kumaSeen {
if _, still := states[name]; !still {
if err := p.writeIfChanged(ctx, kumaFactKey(name), kumaSource, "unknown", now); err != nil && firstErr == nil {
firstErr = err
}
}
}
p.kumaSeen = states
return firstErr
return p.writeIfChanged(ctx, "service_down", "poll:uptimekuma", val, now)
}
// kumaSource — the provenance the loop rule requires. Written here, checked in
// loop.ServiceDownRule; a poller under any other source cannot fire it.
const kumaSource = "poll:uptimekuma"
// kumaFactKey — the fact key for one monitor. The suffix is the name he hears,
// so it stays as kuma spells it rather than being slugged into something else.
func kumaFactKey(name string) string { return "service_down:" + name }
// kumaMonitors parses kuma's Prometheus text into monitor name → state
// ("up"/"down"/"pending"/"maintenance"). An empty map means no monitor_status
// line matched at all (wrong endpoint, or auth rejected before the body).
// A line with no monitor_name label is skipped: a fact nobody can name is
// exactly the thing this replaced.
func kumaMonitors(body []byte) map[string]string {
out := make(map[string]string)
// kumaAnyDown parses kuma's Prometheus text: down=true if any monitor reads 0
// (pending=2/maintenance=3 are not "down"). seen=false ⇒ no monitor_status
// lines matched at all (wrong endpoint or auth rejected before the body).
func kumaAnyDown(body []byte) (down, seen bool) {
for _, line := range strings.Split(string(body), "\n") {
m := kumaLine.FindStringSubmatch(strings.TrimSpace(line))
if m == nil {
continue
}
nm := kumaName.FindStringSubmatch(m[1])
if nm == nil || strings.TrimSpace(nm[1]) == "" {
continue
}
seen = true
v, err := strconv.ParseFloat(m[2], 64)
if err != nil {
continue
}
out[strings.TrimSpace(nm[1])] = kumaState(v)
}
return out
}
// kumaState — the gauge's four values. pending and maintenance are not "down":
// a monitor paused in kuma should silence that monitor, not page him.
func kumaState(v float64) string {
switch v {
case 0:
return "down"
case 1:
return "up"
case 2:
return "pending"
case 3:
return "maintenance"
default:
return "unknown"
if v == 0 {
down = true
}
}
return down, seen
}
// ---- helpers ---------------------------------------------------------------
+12 -36
View File
@@ -35,46 +35,22 @@ func TestMaxSeverity(t *testing.T) {
}
}
func TestKumaMonitorsNamesEveryOne(t *testing.T) {
func TestKumaAnyDown(t *testing.T) {
cases := []struct {
name string
body string
want map[string]string
body string
down, seen bool
}{
{"empty body", "", map[string]string{}},
{"help line only", `# HELP monitor_status ...`, map[string]string{}},
{
"one up one down",
`monitor_status{monitor_name="web"} 1` + "\n" + `monitor_status{monitor_name="db"} 0`,
map[string]string{"web": "up", "db": "down"},
},
{"maintenance is not down", `monitor_status{monitor_name="mnt"} 3`, map[string]string{"mnt": "maintenance"}},
{"pending is not down", `monitor_status{monitor_name="p"} 2`, map[string]string{"p": "pending"}},
{
"other labels do not hide the name",
`monitor_status{monitor_type="http",monitor_name="ci",monitor_url="x"} 0`,
map[string]string{"ci": "down"},
},
{"a nameless line is skipped", `monitor_status{monitor_type="http"} 0`, map[string]string{}},
{"", false, false},
{`monitor_status{monitor_name="web"} 1`, false, true},
{`monitor_status{monitor_name="web"} 1` + "\n" + `monitor_status{monitor_name="db"} 0`, true, true},
{`monitor_status{monitor_name="mnt"} 3`, false, true}, // maintenance ≠ down
{`# HELP monitor_status ...`, false, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := kumaMonitors([]byte(c.body))
if len(got) != len(c.want) {
t.Fatalf("kumaMonitors = %v, want %v", got, c.want)
}
for k, v := range c.want {
if got[k] != v {
t.Errorf("monitor %q = %q, want %q", k, got[k], v)
}
}
})
}
}
func TestKumaFactKeyCarriesTheName(t *testing.T) {
if got := kumaFactKey("nexus db"); got != "service_down:nexus db" {
t.Errorf("kumaFactKey = %q", got)
down, seen := kumaAnyDown([]byte(c.body))
if down != c.down || seen != c.seen {
t.Errorf("kumaAnyDown(%q) = (%v,%v), want (%v,%v)", c.body, down, seen, c.down, c.seen)
}
}
}
+1 -32
View File
@@ -139,10 +139,7 @@ func TestHandleAmbientIgnoresNonMeetings(t *testing.T) {
}
func TestHandleAmbientAuth(t *testing.T) {
// "завтра" so the meeting is in the future in every zone: the clock in the
// text is a local wall clock and posted_at is a Z instant, so a bare "10:00"
// is already stale on a box east of UTC and stores nothing.
body := `{"title":"Планёрка завтра 10:00","posted_at":"2026-08-03T09:40:00Z"}`
body := `{"title":"Планёрка 10:00","posted_at":"2026-08-03T09:40:00Z"}`
newReq := func(hdr, val string) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(body))
@@ -248,31 +245,3 @@ func TestHandleAmbientBadInput(t *testing.T) {
}
})
}
// The Z-instant defect end to end: a phone posts an RFC 3339 instant in UTC and
// the clock inside the text is his wall clock. On a UTC+4 box a 14:30 standup
// used to be stored at 18:30, and the size of the error was the deploy's offset.
func TestHandleAmbientStoresTheWallClockHeRead(t *testing.T) {
// No zone juggling: the assertion is that the stored wall clock is the one
// he read, whatever zone the box is in. That is false under the old code
// on every box except a UTC one.
core := &ambientCore{}
rr, resp := postAmbient(t, core, ambientTestToken, calendar.Notification{
Package: "com.slack",
Title: "Standup",
Text: "созвон завтра в 14:30",
Posted: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC),
})
if rr.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201: %s", rr.Code, rr.Body)
}
if !strings.HasSuffix(resp.Key, "_Standup") {
t.Errorf("key = %q, want a key naming the meeting", resp.Key)
}
if len(core.writeLog) != 1 {
t.Fatalf("expected 1 fact write, got %d", len(core.writeLog))
}
if got := core.writeLog[0].Value; got != "Standup @ 14:30-15:00" {
t.Errorf("value = %q, want %q", got, "Standup @ 14:30-15:00")
}
}
-24
View File
@@ -1,24 +0,0 @@
{{template "shellTop" "chat"}}
<h1>Chat</h1>
<section class=card>
{{if .Error}}<div class="msg msg-err">{{.Error}}</div>{{end}}
<div class="scroll chat-scroll" id=chatHistory>
{{range .Messages}}
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}</div>
{{else}}
<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-message"/></svg>
<div>start a conversation</div>
</div>
{{end}}
</div>
<form method=post action=/api/chat class=chat-form>
<input type=text name=text class=input-wide placeholder="type a message..." required autofocus>
<button class=btn>send</button>
</form>
</section>
<script>
var ch = document.getElementById('chatHistory');
if(ch) ch.scrollTop = ch.scrollHeight;
</script>
{{template "shellBottom"}}
+2 -36
View File
@@ -54,9 +54,7 @@ type fakeCore struct {
revertErr error
// for handleNotifications tests
nudgesErr error
attempts []ipc.DeliveryAttempt
attemptStatus string
nudgesErr error
// for handleHistory tests
historyFacts []ipc.Fact
@@ -79,7 +77,7 @@ 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) {
f.chatText = text
if f.chatErr != nil {
return "", f.chatErr
@@ -1253,35 +1251,3 @@ func TestHandleWS_AssertedSession_PassesGate(t *testing.T) {
t.Fatalf("status = 403 on an asserted session; body=%s", rr.Body.String())
}
}
func (f *fakeCore) DeliveryAttempts(_ context.Context, status string, _ int) ([]ipc.DeliveryAttempt, error) {
f.attemptStatus = status
return f.attempts, nil
}
// TestHandleNotifications_ShowsTheOutbox — the outbox was written and never
// read, so a dropped or failed send was invisible (Vikunja #390).
func TestHandleNotifications_ShowsTheOutbox(t *testing.T) {
done := time.Date(2026, 8, 4, 9, 0, 30, 0, time.UTC)
core := &fakeCore{
attempts: []ipc.DeliveryAttempt{
{Kind: "nudge", Rule: "care-check", Channel: "telegram", Status: "dropped",
Created: done.Add(-30 * time.Second), Completed: &done},
{Kind: "reminder", ReminderID: 7, Channel: "voice", Status: "pending", Created: done},
},
}
rr := httptest.NewRecorder()
handleNotifications(rr, httptest.NewRequest(http.MethodGet, "/notifications?status=dropped", nil), core)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
}
if core.attemptStatus != "dropped" {
t.Errorf("status filter = %q, want it passed through", core.attemptStatus)
}
body := rr.Body.String()
for _, want := range []string{"care-check", "dropped", "reminder #7", "Delivery outbox"} {
if !strings.Contains(body, want) {
t.Errorf("rendered outbox missing %q", want)
}
}
}
+245 -122
View File
@@ -75,23 +75,6 @@ var morningHTML string
//go:embed events.html
var eventsHTML string
//go:embed tools.html
var toolsHTML string
//go:embed routines.html
var routinesHTML string
//go:embed chat.html
var chatPageHTML string
// shellHTML — the shell partial every page is wrapped in: "shellTop", the
// "sidebar" it calls, and "shellBottom". It used to be two Go string constants
// with the sidebar assembled by a strings.Builder, which is the one piece of
// markup that was still concatenated in Go.
//
//go:embed shell.html
var shellHTML string
// ── Ethos Workstation Shell ──
//
// Two template pieces that wrap every page:
@@ -152,40 +135,74 @@ var sidebarSections = []struct {
},
}
// pageIcon returns the ethos-icons.svg symbol id for the given page. The
// sidebar template wraps it in the <use> reference.
func sidebarActive(url, key string, activeKey string) string {
if key == activeKey {
return `class="active"`
}
return ""
}
// sidebarHTML renders the sidebar navigation given the active page key.
func sidebarHTML(active string) template.HTML {
var b strings.Builder
for _, sec := range sidebarSections {
b.WriteString(`<div class=sidebar-section>`)
b.WriteString(`<div class=sidebar-label>`)
b.WriteString(sec.Label)
b.WriteString(`</div>`)
for _, p := range sec.Pages {
cls := ""
if p.Key == active {
cls = ` class="active"`
}
b.WriteString(`<a href="`)
b.WriteString(p.URL)
b.WriteString(`"`)
b.WriteString(cls)
b.WriteString(`><span class=icon>`)
b.WriteString(pageIcon(p.Key))
b.WriteString(`</span><span>`)
b.WriteString(p.Label)
b.WriteString(`</span></a>`)
}
b.WriteString(`</div>`)
}
return template.HTML(b.String())
}
// pageIcon returns an ethos-icons.svg <use> reference for the given page.
func pageIcon(key string) string {
switch key {
case "dash":
return "i-grid"
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
case "history":
return "i-clock"
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-clock"/></svg>`
case "trace":
return "i-wave"
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
case "notifications":
return "i-bell"
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-bell"/></svg>`
case "tasks":
return "i-grid"
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
case "reminders":
return "i-calendar"
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
case "routines":
return "i-repeat"
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-repeat"/></svg>`
case "morning":
return "i-calendar"
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
case "chat":
return "i-message"
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-message"/></svg>`
case "voice":
return "i-mic"
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-mic"/></svg>`
case "ecosystem":
return "i-grid"
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
case "tools":
return "i-settings"
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-settings"/></svg>`
case "models":
return "i-wave"
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
case "passkey":
return "i-lock"
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-lock"/></svg>`
default:
return "i-search"
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-search"/></svg>`
}
}
@@ -225,12 +242,63 @@ func pageTitle(key string) string {
}
}
// shellTopHTML opens the shell and renders the top bar + sidebar.
// Usage: {{template "shellTop" "<page-key>"}}
const shellTopHTML = `{{define "shellTop"}}<!doctype html><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name=theme-color content="#14110D">
<link rel=manifest href=/manifest.json>
<title>maven · {{pageTitle .}}</title>
<link rel=stylesheet href=/ui.css>
<div class=shell data-app=maven>
<header class=topbar>
<div class=breadcrumbs>
<span class=current>{{pageTitle .}}</span>
</div>
<div class=topbar-actions>
<div class=search-trigger onclick="window.__openSearch()" role=button tabindex=0>
<svg class=icon width="13" height="13"><use href="/ethos-icons.svg#i-search"/></svg>
Search
<span class=kbd-hint>Ctrl+/</span>
</div>
<button class=icon-btn onclick="window.__openPalette()" title="Command Palette (Ctrl+K)" aria-label="Command Palette">
<svg class=icon width="15" height="15"><use href="/ethos-icons.svg#i-grid"/></svg>
</button>
<span class=conn-status>
<span class="dot online" id=connDot></span>
</span>
</div>
</header>
<div class=shell-body>
<aside class=sidebar>
{{sidebarHTML .}}
</aside>
<main class=content>
{{end}}`
// shellBottomHTML closes the content area, inspector, and shell.
// Usage: {{template "shellBottom"}}
const shellBottomHTML = `{{define "shellBottom"}}
</main>
<aside class=inspector id=inspector>
<div class=inspector-inner>
<div class=inspector-header>
<span id=inspectorTitle>Details</span>
<button class=inspector-close onclick="closeInspector()" aria-label="Close inspector">&times;</button>
</div>
<div class=inspector-body id=inspectorBody></div>
</div>
</aside>
</div>
</div>
<script src=/mavweb.js></script>
{{end}}`
// shellFuncs returns the FuncMap shared by every server-rendered page template.
func shellFuncs() template.FuncMap {
return template.FuncMap{
"pageTitle": pageTitle,
"pageIcon": pageIcon,
"sidebarSections": func() any { return sidebarSections },
"pageTitle": pageTitle,
"sidebarHTML": sidebarHTML,
"ago": func(t time.Time) string {
if t.IsZero() {
return "never"
@@ -245,21 +313,21 @@ func shellFuncs() template.FuncMap {
// a small fetch loop refreshes the tables in place. html/template escapes the
// user text in facts/nudges. Read-only: browses the append-only store via
// CoreAPI, never writes — the store IS the audit trail, this just shows it.
var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shellHTML + dashHTML))
var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shellTopHTML + dashHTML + shellBottomHTML))
// ecosystemTmpl — read-only view of the Nexus/Praxis/Hexis siblings, whose only
// human surface is here (they ship no web UI of their own).
var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellHTML + ecosystemHTML))
var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellTopHTML + ecosystemHTML + shellBottomHTML))
// 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(shellHTML + eventsHTML))
var eventsTmpl = template.Must(template.New("events").Funcs(shellFuncs()).Parse(shellTopHTML + eventsHTML + shellBottomHTML))
// morningTmpl — read-only view of today's checklist state per configured
// morning routine (internal/morning). Same shape as trace.html: a plain
// server-rendered page, refreshed on reload — no live-update loop, since
// checklist state changes on the scale of minutes, not seconds.
var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellHTML + morningHTML))
var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellTopHTML + morningHTML + shellBottomHTML))
func noCache(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -679,23 +747,107 @@ var toolsTmpl = template.Must(template.New("tools").Funcs(func() template.FuncMa
m := shellFuncs()
m["join"] = strings.Join
return m
}()).Parse(shellHTML + toolsHTML))
}()).Parse(shellTopHTML + toolsHTML + shellBottomHTML))
var historyTmpl = template.Must(template.New("history").Funcs(shellFuncs()).Parse(shellHTML + historyHTML))
const toolsHTML = `{{template "shellTop" "tools"}}
<h1>Tools</h1>
<p class=hint>enabling requires step-up — <a href=/auth/passkey>assert a passkey</a> first.</p>
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
<section class=card>
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
{{if .Proposed}}<p class=hint>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable. A row in an <code>mcp:</code> scope came from an MCP server and already knows what it calls — check the command, then enable.</p>
<div class=scroll><table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
{{range .Proposed}}<tr>
<td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
<td><form method=post action=/tools>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
<input type=hidden name=action value=enable>
<input type=text name=cmd class=input-wide placeholder="systemctl restart" value="{{join .Cmd " "}}" required>
<label><input type=checkbox name=destructive {{if .Destructive}}checked{{end}}> destructive</label>
<button class=btn>enable</button></form>
<form method=post action=/tools class=inline-form>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=action value=dismiss>
<button class="btn btn-muted">dismiss</button></form></td>
</tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-search"/></svg>
<div>no proposed tools</div>
<div class=hint>maven will propose tools here when she needs help running an action</div>
</div>{{end}}
</section>
<section class=card>
<h2 class=card-title>enabled <span class=badge>{{len .Enabled}}</span></h2>
{{if .Enabled}}<div class=scroll><table><tr><th>name</th><th>scope</th><th>command</th><th></th><th></th></tr>
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td><code>{{join .Cmd " "}}</code></td>
<td>{{if .Destructive}}<span class=red>destructive</span>{{end}}</td>
<td><form method=post action=/tools class=inline-form>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
<input type=hidden name=action value=disable>
<button class=btn>disable</button></form></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 tools enabled</div>
<div class=hint>enable proposed tools above, or ask maven to configure one</div>
</div>{{end}}
</section>
<section class=card>
<h2 class=card-title>MCP servers <span class=badge>{{len .MCP}}</span></h2>
{{if .MCP}}<p class=hint>servers she connects OUT to. Their tools appear above as proposals — a configured server is a place she may look, not a capability she has. A <code>stdio</code> target is a process on this box; an <code>http</code> one on a loopback or LAN address is inside the network, so treat its tools accordingly.</p>
<div class=scroll><table><tr><th>name</th><th>transport</th><th>target</th><th>state</th><th>tools</th></tr>
{{range .MCP}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Transport}}</span></td><td><code>{{.Target}}</code></td>
<td>{{if .Connected}}connected{{if .Server}}{{.Server}}{{end}}{{else}}<span class=red>down</span>{{if .Err}}{{.Err}}{{end}}{{end}}</td>
<td>{{.Tools}}</td></tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
<div>no MCP servers configured</div>
<div class=hint>add an <code>mcp.servers</code> block to mavend.json to let her use an external tool server</div>
</div>{{end}}
</section>
{{template "shellBottom"}}`
var notificationsTmpl = template.Must(template.New("notifications").Funcs(shellFuncs()).Parse(shellHTML + notificationsHTML))
var remindersTmpl = template.Must(template.New("reminders").Funcs(shellFuncs()).Parse(shellHTML + remindersHTML))
var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Parse(shellHTML + passkeyPageHTML))
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellHTML + voiceHTML))
var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellHTML + tasksHTML))
// routinesTmpl — the proposed-routine review surface. One row per thing maven
// routinesHTML — proposed routine review surface. One row per thing maven
// noticed, in her words, with at most two actions: accept or dismiss.
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellHTML + routinesHTML))
const routinesHTML = `{{template "shellTop" "routines"}}
<h1>Routines</h1>
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
<section class=card>
<h2 class=card-title>noticed <span class=badge>{{len .Proposed}}</span></h2>
{{if .Proposed}}<div class=scroll><table><tr><th>maven noticed</th><th>when</th><th></th><th></th></tr>
{{range .Proposed}}<tr>
<td>{{.Phrase}}</td><td class=muted>{{.Noticed}}</td>
<td><form method=post action=/routines class=inline-form>
<input type=hidden name=id value="{{.ID}}">
<input type=hidden name=action value=accept>
<button class=btn>accept</button></form></td>
<td><form method=post action=/routines class=inline-form>
<input type=hidden name=id value="{{.ID}}">
<input type=hidden name=action value=dismiss>
<button class="btn btn-muted">dismiss</button></form></td>
</tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-wave"/></svg>
<div>no proposed routines</div>
<div class=hint>maven will propose routines here when she detects a recurring pattern</div>
</div>{{end}}
</section>
{{template "shellBottom"}}`
var historyTmpl = template.Must(template.New("history").Funcs(shellFuncs()).Parse(shellTopHTML + historyHTML + shellBottomHTML))
var notificationsTmpl = template.Must(template.New("notifications").Funcs(shellFuncs()).Parse(shellTopHTML + notificationsHTML + shellBottomHTML))
var remindersTmpl = template.Must(template.New("reminders").Funcs(shellFuncs()).Parse(shellTopHTML + remindersHTML + shellBottomHTML))
var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Parse(shellTopHTML + passkeyPageHTML + shellBottomHTML))
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellTopHTML + voiceHTML + shellBottomHTML))
var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellTopHTML + tasksHTML + shellBottomHTML))
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellTopHTML + routinesHTML + shellBottomHTML))
var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap {
m := shellFuncs()
@@ -707,7 +859,7 @@ var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMa
}
m["join"] = strings.Join
return m
}()).Parse(shellHTML + traceHTML))
}()).Parse(shellTopHTML + traceHTML + shellBottomHTML))
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if core == nil {
@@ -741,59 +893,12 @@ func handleNotifications(w http.ResponseWriter, r *http.Request, core ipc.CoreAP
http.Error(w, "notifications error: "+err.Error(), http.StatusBadGateway)
return
}
// The outbox, on the page that already answers "what did she send".
// A failed or dropped attempt is why she went quiet, and until now it was
// recorded and unreadable (Vikunja #390). Filter with ?status=dropped.
status := r.URL.Query().Get("status")
attempts, err := core.DeliveryAttempts(ctx, status, 50)
if err != nil {
// The nudge list is still worth showing, so this is a note on the page
// rather than a dead page.
log.Printf("notifications: delivery attempts: %v", err)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := notificationsTmpl.Execute(w, map[string]any{
"Nudges": nudges,
"Attempts": deliveryRows(attempts),
"Status": status,
}); err != nil {
if err := notificationsTmpl.Execute(w, map[string]any{"Nudges": nudges}); err != nil {
log.Printf("notifications template: %v", err)
}
}
// deliveryRow is one outbox line, with every timestamp already formatted so
// the template holds no date logic — same shape as taskRow.
type deliveryRow struct {
Kind string
Target string
Channel string
Status string
Created string
Completed string
}
func deliveryRows(as []ipc.DeliveryAttempt) []deliveryRow {
out := make([]deliveryRow, 0, len(as))
for _, a := range as {
target := a.Rule
if target == "" && a.ReminderID != 0 {
target = "reminder #" + strconv.FormatInt(a.ReminderID, 10)
}
row := deliveryRow{
Kind: a.Kind,
Target: target,
Channel: a.Channel,
Status: a.Status,
Created: a.Created.Format("02.01 15:04"),
}
if a.Completed != nil {
row.Completed = a.Completed.Format("15:04")
}
out = append(out, row)
}
return out
}
func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if core == nil {
http.Error(w, "reminders disabled (no -core)", http.StatusServiceUnavailable)
@@ -1020,10 +1125,9 @@ func fmtTaskDate(t *time.Time) string {
return t.Local().Format("02 Jan")
}
// routineView is one line on the page: what maven noticed, in her words, and
// how long ago she noticed it. A view model, not a database row — the template
// never formats an interval or a timestamp itself.
type routineView struct {
// routineRow is one line on the page: what maven noticed, in her words, and
// how long ago she noticed it.
type routineRow struct {
ID int64
Phrase string
Noticed string
@@ -1085,23 +1189,23 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, se
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := routinesTmpl.Execute(w, struct {
Msg string
Proposed []routineView
}{msg, toRoutineViews(proposed)}); err != nil {
Proposed []routineRow
}{msg, routineRows(proposed)}); err != nil {
log.Printf("routines render: %v", err)
}
}
// toRoutineViews turns the wire rows into view models. The phrase comes from
// routineRows turns the wire rows into display rows. The phrase comes from
// pattern.PhraseRoutine so the page says the same thing maven's voice says.
func toRoutineViews(rs []ipc.ProposedRoutine) []routineView {
out := make([]routineView, 0, len(rs))
func routineRows(rs []ipc.ProposedRoutine) []routineRow {
out := make([]routineRow, 0, len(rs))
for _, r := range rs {
p := pattern.ProposedRoutine{Action: r.Action, Object: r.Object, IntervalDays: r.IntervalDays}
noticed := "just now"
if r.CreatedTs > 0 {
noticed = time.Since(time.UnixMilli(r.CreatedTs)).Round(time.Minute).String() + " ago"
}
out = append(out, routineView{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed})
out = append(out, routineRow{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed})
}
return out
}
@@ -1498,7 +1602,32 @@ func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session
// chatTmpl — plain text conversation interface. No JS: form POSTs to /api/chat
// and the handler redirects back to /chat with the response.
var chatTmpl = template.Must(template.New("chat").Funcs(shellFuncs()).Parse(shellHTML + chatPageHTML))
var chatTmpl = template.Must(template.New("chat").Funcs(shellFuncs()).Parse(shellTopHTML + chatPageHTML + shellBottomHTML))
const chatPageHTML = `{{template "shellTop" "chat"}}
<h1>Chat</h1>
<section class=card>
{{if .Error}}<div class="msg msg-err">{{.Error}}</div>{{end}}
<div class="scroll chat-scroll" id=chatHistory>
{{range .Messages}}
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}</div>
{{else}}
<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-message"/></svg>
<div>start a conversation</div>
</div>
{{end}}
</div>
<form method=post action=/api/chat class=chat-form>
<input type=text name=text class=input-wide placeholder="type a message..." required autofocus>
<button class=btn>send</button>
</form>
</section>
<script>
var ch = document.getElementById('chatHistory');
if(ch) ch.scrollTop = ch.scrollHeight;
</script>
{{template "shellBottom"}}`
// handleChatPage renders the chat conversation page.
func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
@@ -1549,13 +1678,7 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, ses
http.Redirect(w, r, "/chat", http.StatusSeeOther)
return
}
// One conversation id for the whole web chat, and a different one from
// telegram or the mic. A parked question belongs to the reach that was
// asked; before this, a clarify nobody answered on the web ate the next
// utterance spoken at the mic (Vikunja #466). This server has no
// per-browser session, so every browser tab is the same conversation —
// which is right for a single-owner box.
reply, err := core.Chat(r.Context(), "web", text)
reply, err := core.Chat(r.Context(), text)
if err != nil {
log.Printf("chat api: %v", err)
http.Redirect(w, r, "/chat", http.StatusSeeOther)
+1 -1
View File
@@ -31,7 +31,7 @@ type modelController interface {
SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error)
}
var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(shellHTML + modelsHTML))
var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(shellTopHTML + modelsHTML + shellBottomHTML))
const modelsHTML = `{{template "shellTop" "models"}}
<h1>Resident model</h1>
-22
View File
@@ -14,27 +14,5 @@
<div>no notifications yet</div>
<div class=hint>check back later or ask maven a question</div>
</div>{{end}}
<h2>Delivery outbox</h2>
<p class=hint>
every send is recorded before it leaves, so a failure is visible rather than silent.
<a href="/notifications">all</a> ·
<a href="/notifications?status=dropped">dropped</a> ·
<a href="/notifications?status=failed">failed</a> ·
<a href="/notifications?status=pending">pending</a> ·
<a href="/notifications?status=unknown">unknown</a>
</p>
{{if .Attempts}}<div class=scroll><table>
<tr><th>started</th><th>kind</th><th>rule</th><th>channel</th><th>status</th><th>finished</th></tr>
{{range .Attempts}}<tr>
<td class=hint>{{.Created}}</td>
<td>{{.Kind}}</td>
<td class=key>{{.Target}}</td>
<td><span class=badge>{{.Channel}}</span></td>
<td class={{.Status}}>{{.Status}}</td>
<td class=hint>{{.Completed}}</td>
</tr>{{end}}</table></div>
{{else}}<div class=empty>
<div>no delivery attempts{{if .Status}} with status {{.Status}}{{end}}</div>
</div>{{end}}
{{template "shellBottom"}}
</html>
-24
View File
@@ -1,24 +0,0 @@
{{template "shellTop" "routines"}}
<h1>Routines</h1>
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
<section class=card>
<h2 class=card-title>noticed <span class=badge>{{len .Proposed}}</span></h2>
{{if .Proposed}}<div class=scroll><table><tr><th>maven noticed</th><th>when</th><th></th><th></th></tr>
{{range .Proposed}}<tr>
<td>{{.Phrase}}</td><td class=muted>{{.Noticed}}</td>
<td><form method=post action=/routines class=inline-form>
<input type=hidden name=id value="{{.ID}}">
<input type=hidden name=action value=accept>
<button class=btn>accept</button></form></td>
<td><form method=post action=/routines class=inline-form>
<input type=hidden name=id value="{{.ID}}">
<input type=hidden name=action value=dismiss>
<button class="btn btn-muted">dismiss</button></form></td>
</tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-wave"/></svg>
<div>no proposed routines</div>
<div class=hint>maven will propose routines here when she detects a recurring pattern</div>
</div>{{end}}
</section>
{{template "shellBottom"}}
-49
View File
@@ -1,49 +0,0 @@
{{define "shellTop"}}<!doctype html><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name=theme-color content="#14110D">
<link rel=manifest href=/manifest.json>
<title>maven · {{pageTitle .}}</title>
<link rel=stylesheet href=/ui.css>
<div class=shell data-app=maven>
<header class=topbar>
<div class=breadcrumbs>
<span class=current>{{pageTitle .}}</span>
</div>
<div class=topbar-actions>
<div class=search-trigger onclick="window.__openSearch()" role=button tabindex=0>
<svg class=icon width="13" height="13"><use href="/ethos-icons.svg#i-search"/></svg>
Search
<span class=kbd-hint>Ctrl+/</span>
</div>
<button class=icon-btn onclick="window.__openPalette()" title="Command Palette (Ctrl+K)" aria-label="Command Palette">
<svg class=icon width="15" height="15"><use href="/ethos-icons.svg#i-grid"/></svg>
</button>
<span class=conn-status>
<span class="dot online" id=connDot></span>
</span>
</div>
</header>
<div class=shell-body>
<aside class=sidebar>
{{template "sidebar" .}}
</aside>
<main class=content>
{{end}}{{/* sidebar — the section list, with the active page's link marked. The dot
argument is the page key the page passed to shellTop. */}}{{define "sidebar"}}{{$active := .}}{{range sidebarSections}}<div class=sidebar-section><div class=sidebar-label>{{.Label}}</div>
{{- range .Pages}}<a href="{{.URL}}"{{if eq .Key $active}} class="active"{{end}}><span class=icon><svg class=icon width="14" height="14"><use href="/ethos-icons.svg#{{pageIcon .Key}}"/></svg></span><span>{{.Label}}</span></a>
{{- end}}</div>
{{end}}{{end}}{{define "shellBottom"}}
</main>
<aside class=inspector id=inspector>
<div class=inspector-inner>
<div class=inspector-header>
<span id=inspectorTitle>Details</span>
<button class=inspector-close onclick="closeInspector()" aria-label="Close inspector">&times;</button>
</div>
<div class=inspector-body id=inspectorBody></div>
</div>
</aside>
</div>
</div>
<script src=/mavweb.js></script>
{{end}}
-58
View File
@@ -1,58 +0,0 @@
{{template "shellTop" "tools"}}
<h1>Tools</h1>
<p class=hint>enabling requires step-up — <a href=/auth/passkey>assert a passkey</a> first.</p>
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
<section class=card>
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
{{if .Proposed}}<p class=hint>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable. A row in an <code>mcp:</code> scope came from an MCP server and already knows what it calls — check the command, then enable.</p>
<div class=scroll><table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
{{range .Proposed}}<tr>
<td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
<td><form method=post action=/tools>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
<input type=hidden name=action value=enable>
<input type=text name=cmd class=input-wide placeholder="systemctl restart" value="{{join .Cmd " "}}" required>
<label><input type=checkbox name=destructive {{if .Destructive}}checked{{end}}> destructive</label>
<button class=btn>enable</button></form>
<form method=post action=/tools class=inline-form>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=action value=dismiss>
<button class="btn btn-muted">dismiss</button></form></td>
</tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-search"/></svg>
<div>no proposed tools</div>
<div class=hint>maven will propose tools here when she needs help running an action</div>
</div>{{end}}
</section>
<section class=card>
<h2 class=card-title>enabled <span class=badge>{{len .Enabled}}</span></h2>
{{if .Enabled}}<div class=scroll><table><tr><th>name</th><th>scope</th><th>command</th><th></th><th></th></tr>
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td><code>{{join .Cmd " "}}</code></td>
<td>{{if .Destructive}}<span class=red>destructive</span>{{end}}</td>
<td><form method=post action=/tools class=inline-form>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
<input type=hidden name=action value=disable>
<button class=btn>disable</button></form></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 tools enabled</div>
<div class=hint>enable proposed tools above, or ask maven to configure one</div>
</div>{{end}}
</section>
<section class=card>
<h2 class=card-title>MCP servers <span class=badge>{{len .MCP}}</span></h2>
{{if .MCP}}<p class=hint>servers she connects OUT to. Their tools appear above as proposals — a configured server is a place she may look, not a capability she has. A <code>stdio</code> target is a process on this box; an <code>http</code> one on a loopback or LAN address is inside the network, so treat its tools accordingly.</p>
<div class=scroll><table><tr><th>name</th><th>transport</th><th>target</th><th>state</th><th>tools</th></tr>
{{range .MCP}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Transport}}</span></td><td><code>{{.Target}}</code></td>
<td>{{if .Connected}}connected{{if .Server}} — {{.Server}}{{end}}{{else}}<span class=red>down</span>{{if .Err}} — {{.Err}}{{end}}{{end}}</td>
<td>{{.Tools}}</td></tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
<div>no MCP servers configured</div>
<div class=hint>add an <code>mcp.servers</code> block to mavend.json to let her use an external tool server</div>
</div>{{end}}
</section>
{{template "shellBottom"}}
+5 -5
View File
@@ -8,12 +8,12 @@
"//disabled_rules": [
"Nudge rules that are not wired at all. Names come from loop.DefaultRules:",
"water, meal, break, service_down, netdata_critical.",
"service_down is back on: mavpoll now writes one fact per kuma monitor",
"(service_down:<name>), so the nudge names the service and pausing a monitor",
"in kuma silences that monitor. It is also edge-triggered, so a service that",
"stays down is one nudge, not one every fifteen minutes."
"service_down is off because it cannot say WHICH service — mavpoll folds the",
"whole kuma gauge into one boolean, so the nudge is always the generic 'a",
"service on homesrv is down'. Nothing to act on, every fifteen minutes.",
"Turn it back on once Vikunja #444 lands a fact per monitor."
],
"disabled_rules": [],
"disabled_rules": ["service_down"],
"phraser": {
"model_path": "/opt/maven/models/llm/qwen3/Qwen3-1.7B-UD-Q4_K_XL.gguf",
+25 -47
View File
@@ -223,30 +223,6 @@ Not alternatives — layers:
Router contract: `[{"intent":<enum>, key?, value?, text?, verb?}, ...]` over
7 intents (`fact, reminder, note, query, act, chat, system`).
#### A restart expires a parked question
Decided 2026-08-04 (Vikunja #385). The follow-up dialogue session survives a
restart; the clarify question parked behind it does not, and neither do the
three yes/no confirms in `voice.go`. `ClarifyStore` stays in memory.
Three reasons, in the order they settle it:
- The clock stops meaning anything. A parked question carries a 90s TTL and an
attempt count. A restart is a gap of unknown length, so a restored question is
either already dead or pretending to be young.
- Restoring the question restores the request behind it. He asked for something,
she asked back, and then the daemon went away. Acting on that minutes later,
against words he has probably given up on, is the misroute the stage 3 gate
exists to avoid.
- She does not announce it either. The expiry notice needs to know a question
was parked, and knowing that across a restart means storing it. One sentence,
in the rare window where he speaks within 90s of a restart, does not pay for a
marker that outlives the thing it describes. His next words route fresh, which
is the correct answer with or without the notice.
So the notice stays what it is: the in-process TTL case, where she really did
wait and really did let go.
### save-where — the two-memory routing axis
One discriminator: **does the loop evaluate a predicate against it?**
@@ -389,29 +365,6 @@ lives in `source`; rules trust provenance.
`source=poll:healthcheck`. A compromised poller must not be able to forge a
trigger.
#### A fact per monitor, not an aggregate
mavpoll writes one fact per kuma monitor, keyed `service_down:<monitor name>`.
It used to fold the whole gauge into a single boolean, and the nudge could then
only say that something on homesrv was down. That is not something he can act
on, so the rule shipped disabled.
Three things follow from the split:
- The key set is no longer known at wiring time. A rule declares
`WantPrefixes` and the gatherer resolves the family per tick, which is the
only prefix read in the loop.
- Pausing a monitor in kuma silences that monitor. Under the aggregate it
silenced nothing, because some other monitor kept the boolean at "down".
- A monitor deleted in kuma would keep its last fact reading "down" forever, so
mavpoll marks a vanished monitor "unknown". No rule fires on "unknown".
The rule is also edge-triggered: it fires on a transition it has not already
nudged about (`State.NudgedSince`). A polled fact is written only when the
value changes, but the predicate reads the current value, so without the edge
check a service that stays down qualifies on every tick and cooldown is the
only brake.
### Presence — concrete scoring
**Combiner — noisy-OR, not weighted sum.** These are independent-ish positive
@@ -677,6 +630,31 @@ add a new principle; it applied the existing one at smaller and smaller scope.
---
## A list is the fourth shape
Facts, notes and tasks were the three append-only shapes. `list_items` is the
fourth (Vikunja #453): an item, a status, and a list tag.
It is not a task. Milk is not work, nothing prioritises it, and the ranker must
not start counting groceries as outstanding errands. It is not a fact either,
because it claims nothing about the world. What it is, is a set that grows and
shrinks.
The property that makes the separate table worth it: no predicate reads a list.
Nothing ranks it, nothing nudges about it, the digestion worker ignores it. So
two people adding to the same list at once cost nothing — there is no order to
disagree about and no lifecycle past crossed-off.
The unique index is the tasks one, per list, and live rows only. Saying "молоко"
twice before the shop is one line; saying it again next week, after the last one
was crossed off, is a new line.
Spoken, it is four turns: add, read back, cross one item off, cross the lot off.
All four are matched deterministically in `internal/router/list.go` and all four
run at stage 0, because an add and a read-back are cheap and should not depend on
the resident model having a good turn. Crossing one item off claims the turn only
when the list holds that item, which is what keeps "купил новый ноутбук" a note.
## Calendar
Integration with **Radicale** (self-hosted CalDAV), not Nextcloud. Scope is
+3 -3
View File
@@ -311,7 +311,7 @@ func TestGate_IpcServer_CheckWiredThroughSocket(t *testing.T) {
if fake.writes != 0 {
t.Errorf("auth refused but CoreAPI was called %d time(s); refused calls must not reach CoreAPI", fake.writes)
}
_, err = cli.Chat(context.Background(), "web", "привет")
_, err = cli.Chat(context.Background(), "привет")
if !errors.Is(err, ipc.ErrForbidden) {
t.Errorf("wire: chat from unenrolled uid = %v; want ipc.ErrForbidden", err)
}
@@ -344,7 +344,7 @@ func TestGate_IpcServer_ChatAllowedForEnrolledCaller(t *testing.T) {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { _ = cli.Close() })
reply, err := cli.Chat(context.Background(), "web", "привет")
reply, err := cli.Chat(context.Background(), "привет")
if err != nil {
t.Fatalf("Chat: %v", err)
}
@@ -373,7 +373,7 @@ func (r *recordingAPI) WriteFact(_ context.Context, _ ipc.WriteFactReq) (int64,
return int64(r.writes), nil
}
func (r *recordingAPI) Chat(_ context.Context, _, text string) (string, error) {
func (r *recordingAPI) Chat(_ context.Context, text string) (string, error) {
r.chats++
return "echo: " + text, nil
}
+2 -24
View File
@@ -68,28 +68,7 @@ var dayWords = map[string]int{
// word ("завтра", "tomorrow") when the notification carries one, and the result
// is refused if it lands more than ambientPastGrace in the past. A bare start
// time gets DefaultReminderDuration.
//
// The clock reading is a wall clock in the daemon's local zone. See
// EventFromNotificationIn.
func EventFromNotification(n Notification) (Event, bool) {
return EventFromNotificationIn(n, time.Local)
}
// EventFromNotificationIn is EventFromNotification against an explicit zone.
//
// "созвон в 14:30" carries no zone, and nobody writing a phone notification
// means 14:30Z. The wall clock used to be resolved against Posted's own zone,
// and a phone posts an RFC 3339 instant ending in Z, so every ambient meeting
// on this box landed four hours late. The size of the error is the deploy's UTC
// offset, which is why it is invisible on a UTC box and wrong everywhere else.
//
// Posted stays an instant in its own zone: it says when the phone showed the
// notification, and the past-grace check compares instants. Only the day and
// the wall clock are read in loc.
func EventFromNotificationIn(n Notification, loc *time.Location) (Event, bool) {
if loc == nil {
loc = time.Local
}
if n.Posted.IsZero() {
return Event{}, false
}
@@ -103,9 +82,8 @@ func EventFromNotificationIn(n Notification, loc *time.Location) (Event, bool) {
return Event{}, false
}
// The day is Posted's LOCAL day: a notification posted at 23:30Z saying
// "завтра в 09:00" is already tomorrow where he is standing.
y, m, d := n.Posted.In(loc).AddDate(0, 0, dayOffset(line)).Date()
y, m, d := n.Posted.AddDate(0, 0, dayOffset(line)).Date()
loc := n.Posted.Location()
s := time.Date(y, m, d, start.hour, start.min, 0, 0, loc)
// Too far in the past to be the meeting this notification is about. The day
// was inferred, so the honest reading is that the inference was wrong.
+10 -59
View File
@@ -74,12 +74,12 @@ func TestEventFromNotification(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ev, ok := EventFromNotificationIn(Notification{
ev, ok := EventFromNotification(Notification{
Package: "com.google.android.gm",
Title: tt.title,
Text: tt.text,
Posted: posted,
}, posted.Location())
})
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
}
@@ -98,8 +98,8 @@ func TestEventFromNotification(t *testing.T) {
if !ev.End.After(ev.Start) {
t.Errorf("end %v must be after start %v", ev.End, ev.Start)
}
// The event lands on the day the phone showed it, in the zone it
// was resolved against — never shifted into UTC.
// The event lands on the day the phone showed it, in the phone's
// location — not shifted into UTC.
if ev.Start.Location() != posted.Location() {
t.Errorf("location = %v, want %v", ev.Start.Location(), posted.Location())
}
@@ -162,10 +162,10 @@ func TestEventFromNotificationDayWords(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ev, ok := EventFromNotificationIn(Notification{
ev, ok := EventFromNotification(Notification{
Package: "com.google.android.calendar",
Title: tt.title, Text: tt.text, Posted: tt.posted,
}, tt.posted.Location())
})
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
}
@@ -189,10 +189,10 @@ func TestEventFromNotificationDayWords(t *testing.T) {
// summary makes "Завтра Планёрка" the name of the meeting, and FactKey folds
// that into the key.
func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) {
ev, ok := EventFromNotificationIn(Notification{
ev, ok := EventFromNotification(Notification{
Title: "Завтра Планёрка 09:00",
Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC),
}, time.UTC)
})
if !ok {
t.Fatal("expected an event")
}
@@ -209,10 +209,10 @@ func TestEventFromNotificationNeedsPostedAt(t *testing.T) {
// An ambient event must never be indistinguishable from a calendar read.
func TestAmbientEventsAreStoredAtReducedConfidence(t *testing.T) {
ev, ok := EventFromNotificationIn(Notification{
ev, ok := EventFromNotification(Notification{
Title: "Планёрка 10:00-10:30",
Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC),
}, time.UTC)
})
if !ok {
t.Fatal("expected an event")
}
@@ -237,52 +237,3 @@ func TestStripClock(t *testing.T) {
}
}
}
// The defect this file's zone handling exists for: a phone posts an RFC 3339
// instant ending in Z, and "созвон в 14:30" used to be resolved against that
// Z, so on a UTC+4 box the meeting was stored at 18:30. A wall clock in a
// notification is local by construction.
func TestNotificationClockIsLocalNotUTC(t *testing.T) {
samara := time.FixedZone("+04", 4*3600)
n := Notification{
Package: "com.slack",
Title: "Standup",
Text: "созвон в 14:30",
Posted: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC), // 13:00 local
}
ev, ok := EventFromNotificationIn(n, samara)
if !ok {
t.Fatal("expected an event")
}
if got := ev.Start.Format("15:04"); got != "14:30" {
t.Errorf("start = %s local, want 14:30", got)
}
if got := ev.Start.UTC().Format("15:04"); got != "10:30" {
t.Errorf("start = %sZ, want 10:30Z (14:30 at UTC+4)", got)
}
if ev.Start.Location() != samara {
t.Errorf("location = %v, want %v", ev.Start.Location(), samara)
}
// The stored key is the local day, so it files under the day he lived.
if want, got := "calendar_event_20260802_Standup", FactKeyIn(ev, samara); got != want {
t.Errorf("FactKey = %q, want %q", got, want)
}
}
// A notification posted late in the UTC evening is already the next day where
// he is standing. The day must come from the local clock, not from Posted's.
func TestNotificationDayIsTheLocalDay(t *testing.T) {
samara := time.FixedZone("+04", 4*3600)
n := Notification{
Title: "Планёрка 09:00",
Text: "завтра",
Posted: time.Date(2026, 8, 2, 21, 0, 0, 0, time.UTC), // 03-08 01:00 local
}
ev, ok := EventFromNotificationIn(n, samara)
if !ok {
t.Fatal("expected an event")
}
if y, m, d := ev.Start.Date(); y != 2026 || m != time.August || d != 4 {
t.Errorf("date = %d-%02d-%02d, want 2026-08-04 (tomorrow, locally)", y, m, d)
}
}
+5 -9
View File
@@ -149,11 +149,10 @@ type Config struct {
//
// Rules are code, not config (see loop.DefaultRules), and that stays true:
// this only subtracts. It exists because a rule can be right in principle
// and useless in practice. service_down was the case that forced it: it
// could not name the service it was nudging about, so being told "a service
// on homesrv is down" every fifteen minutes was noise with no action
// attached. That is fixed — one fact per kuma monitor — and the rule ships
// enabled again. The escape hatch stays.
// and useless in practice — kuma's service_down cannot name the service it
// is nudging about (Vikunja #444), so being told "a service on homesrv is
// down" every fifteen minutes is noise with no action attached. Turning it
// off beats learning to ignore her.
//
// A disabled rule is never gathered for, never evaluated, and never
// delivered on any channel. Unknown names are ignored, so removing a rule
@@ -602,9 +601,6 @@ type MorningRoutineItemConfig struct {
Key string `json:"key"`
FactKey string `json:"fact_key"`
Label string `json:"label"`
// Optional — this one being skipped does not earn a nudge. Default false,
// so a routine written before 04-08-2026 keeps behaving as it did.
Optional bool `json:"optional,omitempty"`
}
// QuietHoursConfig — a recurring daily quiet-window. Times are local to the
@@ -1738,7 +1734,7 @@ func morningRoutinesFromConfig(mc []MorningRoutineConfig) []morning.Routine {
for i, r := range mc {
items := make([]morning.Item, len(r.Items))
for j, it := range r.Items {
items[j] = morning.Item{Key: it.Key, FactKey: it.FactKey, Label: it.Label, Optional: it.Optional}
items[j] = morning.Item{Key: it.Key, FactKey: it.FactKey, Label: it.Label}
}
weekdays := make([]time.Weekday, len(r.Weekdays))
for j, w := range r.Weekdays {
-8
View File
@@ -57,14 +57,6 @@ func (q *PendingQuestion) CanAsk() bool {
// ClarifyStore holds the parked questions. Same shape and locking as
// SessionStore: keyed by dialogue id, expired entries dropped on read.
//
// Memory only, deliberately, unlike SessionStore — a restart expires every
// parked question and she does not announce that it happened (Vikunja #385,
// written down in docs/design.md). The 90s TTL and the attempt count measure a
// pause in one conversation, and a restart is a gap of unknown length, so a
// restored question would either be dead already or lying about its age. His
// next words route fresh, which is the right answer with or without a notice.
// Do not give this store a persister without re-arguing that.
type ClarifyStore struct {
mu sync.RWMutex
questions map[string]*PendingQuestion
+122 -26
View File
@@ -60,19 +60,6 @@ type Nudge struct {
OutcomeTs *int64 `json:"outcome_ts,omitempty"`
}
// DeliveryAttempt — one row of the delivery outbox. Times are formatted by the
// reader; Completed is nil while the attempt is still pending.
type DeliveryAttempt struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
Rule string `json:"rule,omitempty"`
ReminderID int64 `json:"reminder_id,omitempty"`
Channel string `json:"channel"`
Status string `json:"status"`
Created time.Time `json:"created"`
Completed *time.Time `json:"completed,omitempty"`
}
// Note — a recall/preference item; ranked by embedding cosine on query.
// Score is set by QueryNotes (0 on the write path).
type Note struct {
@@ -534,12 +521,6 @@ type outcomesReq struct {
type nReq struct {
N int `json:"n"`
}
// deliveryAttemptsReq — the outbox read. Status is empty for every status.
type deliveryAttemptsReq struct {
Status string `json:"status,omitempty"`
N int `json:"n"`
}
type kindNReq struct {
Kind string `json:"kind"`
N int `json:"n"`
@@ -612,14 +593,8 @@ type MCPServerStatus struct {
}
// chatReq / chatResp — text chat round-trip for the IPC Chat method.
//
// Conversation names the thread this utterance belongs to: a mavweb session, a
// telegram chat. It is opaque to the daemon and only has to be stable for one
// conversation and distinct across them. Empty is allowed and means "the
// unattributed text tap", which is what an old client sends.
type chatReq struct {
Text string `json:"text"`
Conversation string `json:"conversation,omitempty"`
Text string `json:"text"`
}
type chatResp struct {
Reply string `json:"reply"`
@@ -678,6 +653,127 @@ type acceptProposedRoutineReq struct {
ID int64 `json:"id"`
}
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
// - the in-process store adapter (server.go storeAPI) — used by the daemon
// for modules that live in-process for now (router, delivery) and by tests,
// - the socket-backed server's dispatcher (which delegates to a CoreAPI),
// - the client proxy (client.go) — same interface, over the wire.
//
// So a module imports ipc, holds a CoreAPI, and is agnostic to whether it's
// been wired in-process (tests / daemon-embedded) or socketed (full topology).
// That swappability is the seam the auth layer will insert into without
// touching the module code.
type CoreAPI interface {
WriteFact(ctx context.Context, req WriteFactReq) (int64, error)
LatestFact(ctx context.Context, key string) (Fact, error)
LatestFactBySource(ctx context.Context, key, source string) (Fact, error)
Since(ctx context.Context, key string, now time.Time) (time.Duration, error)
Presence(ctx context.Context) (Presence, error)
CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error)
MarkReminder(ctx context.Context, id int64, status string) error
ListReminders(ctx context.Context, n int) ([]Reminder, error)
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error
RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error)
RecentFacts(ctx context.Context, n int) ([]Fact, error)
RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error)
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
// RecentEcosystemTraces reads the ecosystem call log, which lives in its
// own table so machine-rate traces never crowd out human-rate facts.
RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error)
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error)
RecentNotes(ctx context.Context, n int) ([]Note, error)
// RecentNotesFromSource — the newest n notes whose source starts with
// prefix. Notes Maven read rather than heard (rss:, crawl:) are excluded
// from recall, so this is the only way to reach them, and it keeps the feed
// answer from being crowded out of a fixed window by his own notes.
RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error)
// ProposeTool drafts an inert 'proposed' tool scaffold (maven-callable);
// returns whether a new proposal was written. EnableTool fills cmd +
// destructive and flips status to 'enabled'. DisableTool reverts an
// enabled tool back to proposed (it stays in the store, won't run).
// Enable/DisableTool gate at AuthStepUp (allowlist mutation, human-only);
// ProposeTool is maven-callable (no step-up — she has no passkey).
// LookupTool/ListTools read them.
// scope defaults to "homelab" when empty.
ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error)
EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error
DisableTool(ctx context.Context, name string) error
DeleteTool(ctx context.Context, name string) error
LookupTool(ctx context.Context, name string) (Tool, error)
ListTools(ctx context.Context, status string) ([]Tool, error)
RevertFact(ctx context.Context, key string) (int64, error)
// ListProposedRoutines returns proposed routines, newest first.
ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error)
// DismissProposedRoutine flips a proposed routine to 'dismissed'.
DismissProposedRoutine(ctx context.Context, id int64) error
// AcceptProposedRoutine flips a proposed routine to 'accepted'. The tick
// loop takes the schedule from there — no reminder is created (Vikunja #366).
AcceptProposedRoutine(ctx context.Context, id int64) error
// CaptureTask records a task. See CaptureTaskReq — this is the single
// intake seam for the voice path, the web form and the future email
// extractor. Idempotent per live normalised text; the response says
// whether a row was actually created.
CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error)
// ListTasks returns tasks in one status, newest first. "" is every row,
// "live" is candidate + open (outstanding work).
ListTasks(ctx context.Context, status string) ([]Task, error)
// SetTaskStatus moves a task forward once: candidate→open|dropped,
// open→done|dropped. Any other move is refused.
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error
// TickTrace returns the most recent tick's rule trace. The daemon caches
// this after every tick; the store adapter returns an error (trace is not
// persisted — it's a daemon-level cache).
TickTrace(ctx context.Context) (TickTrace, error)
// MorningStatus returns each configured morning routine's current
// checklist state (see internal/morning): active today/now, which items
// are done, which are still missing. The store adapter returns an error
// (morning routines are daemon-config, not persisted) — same shape as
// TickTrace.
MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error)
// MCPServers reports the configured MCP servers and their health
// (Vikunja #251). Read-only introspection for /tools — there is no
// "call this tool" method on purpose: an MCP tool runs through the same
// allowlist, confirm turn and act path as any other tool, and a second
// mutation path would be a second thing to get wrong. Empty when the
// mcp config block is absent, which is the default.
MCPServers(ctx context.Context) ([]MCPServerStatus, error)
// DayPlan returns today's ordered plan — calendar events, pending
// reminders and any morning checklist still outstanding (see
// internal/morning.BuildPlan) — plus the spoken RU rendering of it.
// Read-only: asking for the plan never dispatches or schedules anything.
// The store adapter returns an error (the plan needs the daemon's routine
// config) — same shape as TickTrace and MorningStatus.
DayPlan(ctx context.Context) (DayPlan, error)
// Chat routes a text utterance through the reactive handler's core path
// (router → dialogue → action → replier) and returns the reply text.
// No audio or stt/tts — for text channels (mavweb, telegram).
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.
+2 -11
View File
@@ -68,7 +68,6 @@ var readOnlyMethods = map[Method]bool{
MethodRecentActiveFacts: true,
MethodCalendarEvents: true,
MethodRecentNudges: true,
MethodDeliveryAttempts: true,
MethodRecentEcoTraces: true,
MethodQueryNotes: true,
MethodRecentNotes: true,
@@ -374,14 +373,6 @@ func (c *Client) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemT
return out, nil
}
func (c *Client) DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) {
var out []DeliveryAttempt
if err := c.call(ctx, MethodDeliveryAttempts, deliveryAttemptsReq{Status: status, N: n}, &out); err != nil {
return nil, err
}
return out, nil
}
func (c *Client) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
var out []Nudge
if err := c.call(ctx, MethodRecentNudges, nReq{N: n}, &out); err != nil {
@@ -632,9 +623,9 @@ func (c *Client) AcceptProposedRoutine(ctx context.Context, id int64) error {
return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id}, nil)
}
func (c *Client) Chat(ctx context.Context, conversation, text string) (string, error) {
func (c *Client) Chat(ctx context.Context, text string) (string, error) {
var r chatResp
if err := c.call(ctx, MethodChat, chatReq{Text: text, Conversation: conversation}, &r); err != nil {
if err := c.call(ctx, MethodChat, chatReq{Text: text}, &r); err != nil {
return "", err
}
return r.Reply, nil
-194
View File
@@ -1,194 +0,0 @@
package ipc
import (
"context"
"time"
)
// CoreAPI and the eight domain interfaces it composes (Vikunja #408).
//
// It used to be one flat block of forty methods, and the cost of that shape
// was paid three times over: every method added an arm to the dispatcher, a
// stub to the daemon's locked API, and a stub to every test double. Two of
// those three are already gone — the dispatcher is a table (methodTable in
// server.go), and UnimplementedCoreAPI took the padding out of the doubles and
// out of lockedAPI, which no longer exists.
//
// What was left is the interface itself, and this file is that half. CoreAPI
// is unchanged as a type: the same forty methods, in the same order, so the
// wire contract, the client proxy and the store adapter are all untouched.
// What it gains is a named seam per domain, so a caller that only reads facts
// can say FactAPI and a reader can see which cluster a method belongs to
// without counting lines.
//
// Add a method to the domain it belongs to, not to CoreAPI.
// FactAPI — the fact store: write, read the current value, read history, and
// undo. Presence sits here because it is a hysteresis view over presence
// facts, not a store of its own.
type FactAPI interface {
WriteFact(ctx context.Context, req WriteFactReq) (int64, error)
LatestFact(ctx context.Context, key string) (Fact, error)
LatestFactBySource(ctx context.Context, key, source string) (Fact, error)
Since(ctx context.Context, key string, now time.Time) (time.Duration, error)
Presence(ctx context.Context) (Presence, error)
RecentFacts(ctx context.Context, n int) ([]Fact, error)
RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error)
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
RevertFact(ctx context.Context, key string) (int64, error)
}
// ReminderAPI — scheduled sends the owner asked for.
type ReminderAPI interface {
CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error)
MarkReminder(ctx context.Context, id int64, status string) error
ListReminders(ctx context.Context, n int) ([]Reminder, error)
}
// NudgeAPI — proactive sends Maven proposed, their outcomes, and the outbox
// they went out through.
type NudgeAPI interface {
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error
RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error)
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
// DeliveryAttempts reads the outbox, newest first. An empty status means
// every status (Vikunja #390).
DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error)
}
// NoteAPI — free text he captured, plus the embedded recall over it.
type NoteAPI interface {
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error)
RecentNotes(ctx context.Context, n int) ([]Note, error)
// RecentNotesFromSource — the newest n notes whose source starts with
// prefix. Notes Maven read rather than heard (rss:, crawl:) are excluded
// from recall, so this is the only way to reach them, and it keeps the feed
// answer from being crowded out of a fixed window by his own notes.
RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error)
}
// ToolAPI — the capability allowlist and its read side.
//
// ProposeTool drafts an inert 'proposed' tool scaffold (maven-callable);
// returns whether a new proposal was written. EnableTool fills cmd +
// destructive and flips status to 'enabled'. DisableTool reverts an
// enabled tool back to proposed (it stays in the store, won't run).
// Enable/DisableTool gate at AuthStepUp (allowlist mutation, human-only);
// ProposeTool is maven-callable (no step-up — she has no passkey).
// LookupTool/ListTools read them.
// scope defaults to "homelab" when empty.
type ToolAPI interface {
ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error)
EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error
DisableTool(ctx context.Context, name string) error
DeleteTool(ctx context.Context, name string) error
LookupTool(ctx context.Context, name string) (Tool, error)
ListTools(ctx context.Context, status string) ([]Tool, 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)
}
// RoutineAPI — the shapes of his day: routines Maven noticed and proposed, the
// morning checklist, and today's plan.
//
// MorningStatus and DayPlan are daemon-computed rather than stored, so the
// store adapter returns an error for both — the same shape as TickTrace.
type RoutineAPI interface {
// ListProposedRoutines returns proposed routines, newest first.
ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error)
// DismissProposedRoutine flips a proposed routine to 'dismissed'.
DismissProposedRoutine(ctx context.Context, id int64) error
// AcceptProposedRoutine flips a proposed routine to 'accepted'. The tick
// loop takes the schedule from there — no reminder is created (Vikunja #366).
AcceptProposedRoutine(ctx context.Context, id int64) error
// MorningStatus returns each configured morning routine's current
// checklist state (see internal/morning): active today/now, which items
// are done, which are still missing.
MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error)
// DayPlan returns today's ordered plan — calendar events, pending
// reminders and any morning checklist still outstanding (see
// internal/morning.BuildPlan) — plus the spoken RU rendering of it.
// Read-only: asking for the plan never dispatches or schedules anything.
DayPlan(ctx context.Context) (DayPlan, error)
}
// TaskAPI — outstanding work, whatever surface it arrived from.
type TaskAPI interface {
// CaptureTask records a task. See CaptureTaskReq — this is the single
// intake seam for the voice path, the web form and the future email
// extractor. Idempotent per live normalised text; the response says
// whether a row was actually created.
CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error)
// ListTasks returns tasks in one status, newest first. "" is every row,
// "live" is candidate + open (outstanding work).
ListTasks(ctx context.Context, status string) ([]Task, error)
// SetTaskStatus moves a task forward once: candidate→open|dropped,
// open→done|dropped. Any other move is refused.
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error
}
// SystemAPI — what the daemon knows about itself, plus the one method that
// runs a whole turn.
type SystemAPI interface {
// TickTrace returns the most recent tick's rule trace. The daemon caches
// this after every tick; the store adapter returns an error (trace is not
// persisted — it's a daemon-level cache).
TickTrace(ctx context.Context) (TickTrace, error)
// RecentEcosystemTraces reads the ecosystem call log, which lives in its
// own table so machine-rate traces never crowd out human-rate facts.
RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, 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)
// Chat routes a text utterance through the reactive handler's core path
// (router → dialogue → action → replier) and returns the reply text.
// No audio or stt/tts — for text channels (mavweb, telegram).
//
// conversation names the thread. A parked clarifying question is held per
// conversation, so an unanswered question on one reach cannot eat the next
// utterance from another (Vikunja #466). Empty means the unattributed text
// tap and is still one conversation of its own, separate from the mic.
Chat(ctx context.Context, conversation, text string) (string, error)
}
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
// - the in-process store adapter (storeapi.go) — used by the daemon
// for modules that live in-process for now (router, delivery) and by tests,
// - the socket-backed server's dispatcher (which delegates to a CoreAPI),
// - the client proxy (client.go) — same interface, over the wire.
//
// So a module imports ipc, holds a CoreAPI, and is agnostic to whether it's
// been wired in-process (tests / daemon-embedded) or socketed (full topology).
// That swappability is the seam the auth layer inserts into without touching
// the module code.
type CoreAPI interface {
FactAPI
ReminderAPI
NudgeAPI
NoteAPI
ToolAPI
RoutineAPI
TaskAPI
SystemAPI
}
-7
View File
@@ -36,13 +36,6 @@ const maxFrame = 4 << 20
// (we read the length but not the body), so the caller must close it.
var ErrFrameTooLarge = errors.New("ipc: frame too large")
// The framing is hand-rolled and it stays that way (Vikunja #410): ninety
// lines, readable on the wire with socat, and every standard replacement
// brings schema machinery this boundary does not want. The condition is that
// it is defended by tests rather than inherited untested — truncated header,
// truncated body, partial reads, frame boundaries and a non-JSON body all
// live in frame_test.go.
//
// writeFrame encodes v as JSON and frames it as a 4-byte big-endian length
// prefix + body. length-prefixed JSON (not a tighter binary schema) is the
// deferred-but-picked wire format: debuggable with `socat`/`nc`, trivial to
-142
View File
@@ -1,142 +0,0 @@
package ipc
import (
"bytes"
"encoding/binary"
"errors"
"io"
"testing"
)
// The framing is hand-rolled, and it is the protocol all nine daemons depend
// on, so a bug in it is a bug everywhere (Vikunja #410). The review asked
// whether to replace it with something standard. It stays: length-prefixed
// JSON over a unix socket is ninety lines, it is readable with socat, and the
// alternatives (net/rpc, gRPC, a codec library) all buy schema machinery this
// boundary does not want. What was wrong was inheriting it untested. These
// are the paths a real socket produces that the round-trip test never does.
// A short header is not EOF. EOF means the peer closed cleanly between
// frames, which the server treats as a normal disconnect; a header that stops
// halfway is a truncated frame and must be reported as an error, or a peer
// that dies mid-write looks like one that hung up politely.
func TestReadFrameTruncatedHeader(t *testing.T) {
var v any
err := readFrame(bytes.NewReader([]byte{0, 0, 4}), &v)
if err == nil {
t.Fatal("a three-byte header must fail")
}
if errors.Is(err, io.EOF) {
t.Fatalf("err = %v, want a truncation error, not EOF", err)
}
}
// A header promising more body than follows. Same reasoning: the frame never
// arrived, so it must not decode into a zero value the caller then trusts.
func TestReadFrameTruncatedBody(t *testing.T) {
var buf bytes.Buffer
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], 32)
buf.Write(hdr[:])
buf.WriteString(`{"m":"pi`)
var got map[string]string
if err := readFrame(&buf, &got); err == nil {
t.Fatal("a body shorter than its prefix must fail")
}
if len(got) != 0 {
t.Errorf("decoded %v from a truncated frame", got)
}
}
// Nothing at all is EOF, and only this is.
func TestReadFrameEmptyIsEOF(t *testing.T) {
var v any
if err := readFrame(bytes.NewReader(nil), &v); !errors.Is(err, io.EOF) {
t.Fatalf("err = %v, want io.EOF", err)
}
}
// byteAtATime returns one byte per Read, which is what a socket is allowed to
// do and what a bytes.Reader never does. readFrame uses io.ReadFull for both
// the header and the body; this is the test that would fail if either turned
// into a bare Read.
type byteAtATime struct {
b []byte
i int
}
func (r *byteAtATime) Read(p []byte) (int, error) {
if r.i >= len(r.b) {
return 0, io.EOF
}
if len(p) == 0 {
return 0, nil
}
p[0] = r.b[r.i]
r.i++
return 1, nil
}
func TestReadFrameReassemblesPartialReads(t *testing.T) {
type payload struct {
Msg string `json:"m"`
N int `json:"n"`
}
want := payload{Msg: "привет", N: 7}
var buf bytes.Buffer
if err := writeFrame(&buf, want); err != nil {
t.Fatalf("writeFrame: %v", err)
}
var got payload
if err := readFrame(&byteAtATime{b: buf.Bytes()}, &got); err != nil {
t.Fatalf("readFrame: %v", err)
}
if got != want {
t.Fatalf("got %+v, want %+v", got, want)
}
}
// Two frames written back to back must come back as two frames. A reader that
// consumed more than one frame's body would desynchronize the connection, and
// the symptom would be a reply attributed to the wrong request.
func TestReadFrameStopsAtTheFrameBoundary(t *testing.T) {
var buf bytes.Buffer
for _, m := range []string{"first", "second"} {
if err := writeFrame(&buf, map[string]string{"m": m}); err != nil {
t.Fatalf("writeFrame: %v", err)
}
}
r := &byteAtATime{b: buf.Bytes()}
for _, want := range []string{"first", "second"} {
var got map[string]string
if err := readFrame(r, &got); err != nil {
t.Fatalf("readFrame(%s): %v", want, err)
}
if got["m"] != want {
t.Fatalf("got %q, want %q", got["m"], want)
}
}
var extra map[string]string
if err := readFrame(r, &extra); !errors.Is(err, io.EOF) {
t.Fatalf("after two frames: err = %v, want io.EOF", err)
}
}
// A body that is not JSON is an error, not a zero value. The peer is either
// broken or not speaking this protocol; either way the caller must not read
// on as though it decoded.
func TestReadFrameRejectsNonJSONBody(t *testing.T) {
var buf bytes.Buffer
var hdr [4]byte
body := []byte("not json at all")
binary.BigEndian.PutUint32(hdr[:], uint32(len(body)))
buf.Write(hdr[:])
buf.Write(body)
var got map[string]string
if err := readFrame(&buf, &got); err == nil {
t.Fatal("a non-JSON body must fail")
}
}
+2 -2
View File
@@ -401,7 +401,7 @@ func TestChatViaClient(t *testing.T) {
}
t.Cleanup(func() { _ = cli.Close() })
reply, err := cli.Chat(context.Background(), "web", "привет")
reply, err := cli.Chat(context.Background(), "привет")
if err != nil {
t.Fatalf("Chat: %v", err)
}
@@ -417,7 +417,7 @@ type chatTestAPI struct {
UnimplementedCoreAPI
}
func (a *chatTestAPI) Chat(ctx context.Context, _, text string) (string, error) {
func (a *chatTestAPI) Chat(ctx context.Context, text string) (string, error) {
if text == "привет" {
return "и тебе привет!", nil
}
-148
View File
@@ -1,148 +0,0 @@
package ipc
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"strings"
"testing"
"github.com/kami/maven/internal/store"
)
// mapErr turns a store sentinel into its wire twin so a module can errors.Is
// without importing internal/store. The design is right and the failure mode is
// quiet: add a sentinel to store, forget the switch, and the client gets an
// untyped error that no caller can branch on. These two tests are the alarm.
// mapErrPairs — every store sentinel that has a wire twin, and the twin.
var mapErrPairs = []struct {
name string // the store identifier, for the coverage test below
from error
wants error
}{
{"ErrNoFact", store.ErrNoFact, ErrNoFact},
{"ErrConfidence", store.ErrConfidence, ErrConfidence},
{"ErrVoidsMissing", store.ErrVoidsMissing, ErrVoidsMissing},
{"ErrNudgeNotFound", store.ErrNudgeNotFound, ErrNudgeNotFound},
{"ErrNudgeOutcome", store.ErrNudgeOutcome, ErrNudgeOutcome},
{"ErrReminderNotFound", store.ErrReminderNotFound, ErrReminderNotFound},
{"ErrReminderState", store.ErrReminderState, ErrReminderState},
{"ErrToolNotFound", store.ErrToolNotFound, ErrToolNotFound},
}
// unmappedStoreErrors — store sentinels that deliberately have no wire twin,
// each with the reason it stays store-side. A new sentinel is in neither list
// and fails TestMapErrCoversEveryStoreSentinel, which is the point: whether a
// module can branch on an error is a decision, not a default.
var unmappedStoreErrors = map[string]string{
"ErrKeyLen": "unlock path — the key never crosses CoreAPI",
"ErrDecrypt": "unlock path — the key never crosses CoreAPI",
"ErrToolCmd": "write-side validation of an allowlist mutation; the caller is the owner at a step-up, not a module branching on the verdict",
"ErrProposedRoutineNotFound": "no module branches on a routine id that vanished; accept and dismiss are owner clicks",
"ErrProposedRoutineExists": "the propose path already reports 'nothing new' through its bool return",
"ErrRoutineStatus": "an unknown status is a caller bug, not a state a module recovers from",
"ErrTaskNotFound": "the task surfaces re-list rather than branch",
"ErrTaskEmpty": "input validation — the surface refuses empty text before it gets here",
"ErrTaskStatus": "an illegal status move is a caller bug; the surface offers only legal ones",
}
// The mapping itself, through a wrap, because every real caller wraps.
func TestMapErrMapsEveryPair(t *testing.T) {
for _, p := range mapErrPairs {
got := mapErr(fmt.Errorf("storeapi: %w", p.from))
if got != p.wants {
t.Errorf("mapErr(store.%s) = %v, want %v", p.name, got, p.wants)
}
}
}
// Anything mapErr does not recognise passes through untouched. A module that
// cannot branch on an error must still see the original text.
func TestMapErrPassesUnknownThrough(t *testing.T) {
if mapErr(nil) != nil {
t.Error("mapErr(nil) must stay nil")
}
own := fmt.Errorf("socket closed")
if got := mapErr(own); got != own {
t.Errorf("mapErr(%v) = %v, want the same error back", own, got)
}
}
// The parity half: every exported sentinel in internal/store is either mapped
// or listed with a reason. Read off the source, so a sentinel added in a file
// this package never touches still trips it.
func TestMapErrCoversEveryStoreSentinel(t *testing.T) {
mapped := map[string]bool{}
for _, p := range mapErrPairs {
mapped[p.name] = true
}
for _, name := range storeSentinelNames(t) {
if mapped[name] || unmappedStoreErrors[name] != "" {
continue
}
t.Errorf("store.%s is a new sentinel with no verdict: add it to mapErr and mapErrPairs, "+
"or to unmappedStoreErrors with the reason a module cannot branch on it", name)
}
}
// storeSentinelNames reads internal/store for exported package-level error
// values: `var ErrX = errors.New(...)`, inside a block or on its own.
func storeSentinelNames(t *testing.T) []string {
t.Helper()
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, "../store", func(fi fs.FileInfo) bool {
return !strings.HasSuffix(fi.Name(), "_test.go")
}, 0)
if err != nil {
t.Fatalf("parse internal/store: %v", err)
}
var out []string
for _, pkg := range pkgs {
for _, f := range pkg.Files {
for _, d := range f.Decls {
gd, ok := d.(*ast.GenDecl)
if !ok || gd.Tok != token.VAR {
continue
}
for _, spec := range gd.Specs {
vs, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
for i, n := range vs.Names {
if !strings.HasPrefix(n.Name, "Err") || !n.IsExported() {
continue
}
if i < len(vs.Values) && isErrorsNew(vs.Values[i]) {
out = append(out, n.Name)
}
}
}
}
}
}
if len(out) < len(mapErrPairs) {
t.Fatalf("found %d sentinels in internal/store, fewer than the %d already mapped — the scan is broken, not the store", len(out), len(mapErrPairs))
}
return out
}
func isErrorsNew(e ast.Expr) bool {
call, ok := e.(*ast.CallExpr)
if !ok {
return false
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "New" {
return false
}
id, ok := sel.X.(*ast.Ident)
return ok && id.Name == "errors"
}
+424 -11
View File
@@ -2,7 +2,9 @@ package ipc
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"net"
@@ -11,9 +13,430 @@ import (
"time"
"github.com/kami/maven/internal/netaddr"
"github.com/kami/maven/internal/store"
"golang.org/x/sys/unix"
)
// storeAPI — adapts *store.Store to CoreAPI. The daemon constructs one of
// these inside the core process; the socket Server calls it through the
// CoreAPI interface, so over-the-wire and in-process callers behave
// identically. The translation here is the only place store sentinels cross
// the wire: store.ErrNoFact becomes ipc.ErrNoFact, etc. — keeping the module
// view of errors stable regardless of transport.
type storeAPI struct {
s *store.Store
}
// NewStoreAPI wraps a *store.Store as a CoreAPI. The store is the sqlcipher-
// unlocked handle held ONLY in core's address space; this adapter never
// returns it to a caller — core mediates.
func NewStoreAPI(s *store.Store) CoreAPI { return &storeAPI{s: s} }
func (a *storeAPI) WriteFact(ctx context.Context, req WriteFactReq) (int64, error) {
var voids sql.NullInt64
if req.VoidsID != nil {
voids = sql.NullInt64{Int64: *req.VoidsID, Valid: true}
}
id, err := a.s.WriteFactAboutSubject(ctx, req.Ts, store.FactKind(req.Kind), req.Key, req.Subject, req.Value, req.Source, req.Confidence, voids)
return id, mapErr(err)
}
func (a *storeAPI) LatestFact(ctx context.Context, key string) (Fact, error) {
f, err := a.s.LatestFact(ctx, key)
if err != nil {
return Fact{}, mapErr(err)
}
return toFact(f), nil
}
func (a *storeAPI) LatestFactBySource(ctx context.Context, key, source string) (Fact, error) {
f, err := a.s.LatestFactBySource(ctx, key, source)
if err != nil {
return Fact{}, mapErr(err)
}
return toFact(f), nil
}
func (a *storeAPI) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) {
d, err := a.s.Since(ctx, key, now)
return d, mapErr(err)
}
func (a *storeAPI) Presence(ctx context.Context) (Presence, error) {
b, score, upd, err := a.s.LoadPresenceState(ctx)
if err != nil {
return Presence{}, fmt.Errorf("ipc: load presence: %w", err)
}
return Presence{Bucket: Bucket(b), Score: score, Updated: upd}, nil
}
func (a *storeAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
id, err := a.s.CreateReminder(ctx, fire, payload, cron)
return id, mapErr(err)
}
func (a *storeAPI) MarkReminder(ctx context.Context, id int64, status string) error {
return mapErr(a.s.MarkReminder(ctx, id, status))
}
func (a *storeAPI) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
rs, err := a.s.ListReminders(ctx, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Reminder, len(rs))
for i, r := range rs {
out[i] = toReminder(r)
}
return out, nil
}
func (a *storeAPI) RescheduleReminder(ctx context.Context, id int64, now time.Time) error {
return mapErr(a.s.RescheduleReminder(ctx, id, now))
}
func (a *storeAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) {
id, err := a.s.RecordNudge(ctx, rule, channel, message, ts)
return id, mapErr(err)
}
func (a *storeAPI) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error {
return mapErr(a.s.ResolveNudge(ctx, id, outcome, ts))
}
func (a *storeAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) {
out, err := a.s.RecentOutcomes(ctx, rule, n)
return out, mapErr(err)
}
func (a *storeAPI) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
fs, err := a.s.RecentFacts(ctx, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Fact, len(fs))
for i, f := range fs {
out[i] = toFact(f)
}
return out, nil
}
func (a *storeAPI) RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error) {
fs, err := a.s.RecentActiveFactsByKind(ctx, store.FactKind(kind), n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Fact, len(fs))
for i, f := range fs {
out[i] = toFact(f)
}
return out, nil
}
func (a *storeAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
fs, err := a.s.CalendarEvents(ctx, from, to)
if err != nil {
return nil, mapErr(err)
}
out := make([]Fact, len(fs))
for i, f := range fs {
out[i] = toFact(f)
}
return out, nil
}
func (a *storeAPI) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error) {
trs, err := a.s.RecentEcosystemTraces(ctx, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]EcosystemTrace, len(trs))
for i, tr := range trs {
out[i] = EcosystemTrace{
ID: tr.ID, Ts: tr.Ts, Service: tr.Service, Operation: tr.Operation,
Status: tr.Status, DurationMs: tr.DurationMs, CorrelationID: tr.CorrelationID,
CausationID: tr.CausationID, HTTPStatus: tr.HTTPStatus, Fields: tr.Fields,
}
}
return out, nil
}
func (a *storeAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
ns, err := a.s.RecentNudges(ctx, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Nudge, len(ns))
for i, ng := range ns {
out[i] = toNudge(ng)
}
return out, nil
}
func (a *storeAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
id, err := a.s.WriteNote(ctx, ts, text, embedding, source)
return id, mapErr(err)
}
func (a *storeAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) {
ns, err := a.s.QueryNotes(ctx, embedding, k)
if err != nil {
return nil, mapErr(err)
}
out := make([]Note, len(ns))
for i, n := range ns {
out[i] = toNote(n)
}
return out, nil
}
func (a *storeAPI) RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error) {
ns, err := a.s.RecentNotesFromSource(ctx, prefix, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Note, len(ns))
for i, note := range ns {
out[i] = toNote(note)
}
return out, nil
}
func (a *storeAPI) RecentNotes(ctx context.Context, n int) ([]Note, error) {
ns, err := a.s.RecentNotes(ctx, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Note, len(ns))
for i, note := range ns {
out[i] = toNote(note)
}
return out, nil
}
func (a *storeAPI) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
ok, err := a.s.ProposeTool(ctx, name, utterance, scope, ts)
return ok, mapErr(err)
}
func (a *storeAPI) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error {
return mapErr(a.s.EnableTool(ctx, name, cmd, destructive, scope, ts))
}
func (a *storeAPI) DisableTool(ctx context.Context, name string) error {
return mapErr(a.s.DisableTool(ctx, name))
}
func (a *storeAPI) LookupTool(ctx context.Context, name string) (Tool, error) {
t, err := a.s.LookupTool(ctx, name)
if err != nil {
return Tool{}, mapErr(err)
}
return toTool(t), nil
}
func (a *storeAPI) RevertFact(ctx context.Context, key string) (int64, error) {
_, newID, err := a.s.VoidLatestFact(ctx, key, "feedback", time.Now())
return newID, mapErr(err)
}
func (a *storeAPI) Chat(ctx context.Context, text string) (string, error) {
return "", errors.New("store: chat not available via direct store API")
}
func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) {
return TickTrace{}, errors.New("store: tick trace not available via direct store API")
}
func (a *storeAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
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) {
return DayPlan{}, errors.New("store: day plan not available via direct store API")
}
func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error) {
ts, err := a.s.ListTools(ctx, status)
if err != nil {
return nil, mapErr(err)
}
out := make([]Tool, len(ts))
for i, t := range ts {
out[i] = toTool(t)
}
return out, nil
}
func (a *storeAPI) DeleteTool(ctx context.Context, name string) error {
return mapErr(a.s.DeleteTool(ctx, name))
}
func (a *storeAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
res, err := a.s.CaptureTask(ctx, store.Task{
CreatedTs: req.Ts,
Text: req.Text,
Source: req.Source,
Evidence: req.Evidence,
ExternalID: req.ExternalID,
Status: req.Status,
Due: req.Due,
Weight: req.Weight,
})
if err != nil {
return CaptureTaskResp{}, mapErr(err)
}
return CaptureTaskResp{ID: res.ID, Created: res.Created, Promoted: res.Promoted}, nil
}
func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
ts, err := a.s.ListTasks(ctx, status)
if err != nil {
return nil, mapErr(err)
}
out := make([]Task, len(ts))
for i, t := range ts {
out[i] = Task{
ID: t.ID,
CreatedTs: t.CreatedTs,
Text: t.Text,
Source: t.Source,
Evidence: t.Evidence,
ExternalID: t.ExternalID,
Status: t.Status,
Due: t.Due,
Weight: t.Weight,
Resolved: t.ResolvedTs,
ResolvedBy: t.ResolvedBy,
}
}
return out, nil
}
func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
return mapErr(a.s.SetTaskStatus(ctx, id, status, ts, by))
}
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
rs, err := a.s.ListProposedRoutines(ctx)
if err != nil {
return nil, mapErr(err)
}
out := make([]ProposedRoutine, len(rs))
for i, r := range rs {
out[i] = ProposedRoutine{
ID: r.ID,
Action: r.Action,
Object: r.Object,
IntervalDays: r.IntervalDays,
Status: r.Status,
CreatedTs: r.CreatedTs.UnixMilli(),
}
if r.ReminderID != nil {
out[i].ReminderID = r.ReminderID
}
}
return out, nil
}
func (a *storeAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
return mapErr(a.s.DismissProposedRoutine(ctx, id))
}
func (a *storeAPI) AcceptProposedRoutine(ctx context.Context, id int64) error {
return mapErr(a.s.AcceptProposedRoutine(ctx, id, time.Now().UTC()))
}
func toTool(t store.Tool) Tool {
return Tool{
Name: t.Name, Scope: t.Scope, Cmd: t.Cmd, Destructive: t.Destructive,
Status: t.Status, Utterance: t.Utterance, Created: t.CreatedTs, Updated: t.UpdatedTs,
}
}
func toReminder(r store.Reminder) Reminder {
return Reminder{
ID: r.ID,
CreatedTs: r.CreatedTs,
FireTs: r.FireTs,
NextFireTs: r.NextFireTs,
Payload: r.Payload,
Status: r.Status,
Cron: r.Cron,
}
}
func toNote(n store.Note) Note {
return Note{ID: n.ID, Ts: n.Ts, Text: n.Text, Source: n.Source, Score: n.Score}
}
func toNudge(n store.Nudge) Nudge {
out := Nudge{
ID: n.ID, Ts: n.Ts, Rule: n.Rule, Channel: n.Channel,
Message: n.Message, Outcome: n.Outcome,
}
if n.OutcomeTs.Valid {
v := n.OutcomeTs.Int64
out.OutcomeTs = &v
}
return out
}
func toFact(f store.Fact) Fact {
out := Fact{
ID: f.ID,
Ts: f.Ts,
Kind: string(f.Kind),
Key: f.Key,
Value: f.Value,
Source: f.Source,
Confidence: f.Confidence,
}
if f.VoidsID.Valid {
v := f.VoidsID.Int64
out.VoidsID = &v
}
return out
}
// mapErr — store sentinel ↔ ipc sentinel. An unrecognized store error is
// wrapped but not mapped (server-side dispatch surfaces it as codeInternal,
// keeping internal text off the wire except to the daemon log).
func mapErr(err error) error {
if err == nil {
return nil
}
switch {
case errors.Is(err, store.ErrNoFact):
return ErrNoFact
case errors.Is(err, store.ErrConfidence):
return ErrConfidence
case errors.Is(err, store.ErrVoidsMissing):
return ErrVoidsMissing
case errors.Is(err, store.ErrNudgeNotFound):
return ErrNudgeNotFound
case errors.Is(err, store.ErrNudgeOutcome):
return ErrNudgeOutcome
case errors.Is(err, store.ErrReminderNotFound):
return ErrReminderNotFound
case errors.Is(err, store.ErrReminderState):
return ErrReminderState
case errors.Is(err, store.ErrToolNotFound):
return ErrToolNotFound
}
return err
}
// Server — the core side of the boundary. Listens on a unix domain socket,
// accepts module connections, frames requests to a CoreAPI and responses back.
// One Server per daemon process; concurrent connections are handled in their
@@ -440,16 +863,6 @@ var methodTable = map[Method]handlerFunc{
}
return out, nil
}),
MethodDeliveryAttempts: withParams(func(ctx context.Context, api CoreAPI, p deliveryAttemptsReq) ([]DeliveryAttempt, error) {
out, err := api.DeliveryAttempts(ctx, p.Status, p.N)
if err != nil {
return nil, err
}
if out == nil {
out = []DeliveryAttempt{}
}
return out, nil
}),
MethodRecentNudges: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]Nudge, error) {
out, err := api.RecentNudges(ctx, p.N)
if err != nil {
@@ -561,7 +974,7 @@ var methodTable = map[Method]handlerFunc{
return map[string]int64{"new_id": newID}, nil
}),
MethodChat: withParams(func(ctx context.Context, api CoreAPI, p chatReq) (chatResp, error) {
reply, err := api.Chat(ctx, p.Conversation, p.Text)
reply, err := api.Chat(ctx, p.Text)
return chatResp{Reply: reply}, err
}),
MethodTickTrace: withoutParams(func(ctx context.Context, api CoreAPI) (TickTrace, error) {
-455
View File
@@ -1,455 +0,0 @@
// ipc/storeapi.go — the sqlite-backed CoreAPI.
//
// Split out of server.go, move-only (Vikunja #423). server.go was two
// unrelated things: this adapter, and the dispatcher that calls it over the
// socket. Nothing here knows there is a wire.
package ipc
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/kami/maven/internal/store"
)
// storeAPI — adapts *store.Store to CoreAPI. The daemon constructs one of
// these inside the core process; the socket Server calls it through the
// CoreAPI interface, so over-the-wire and in-process callers behave
// identically. The translation here is the only place store sentinels cross
// the wire: store.ErrNoFact becomes ipc.ErrNoFact, etc. — keeping the module
// view of errors stable regardless of transport.
type storeAPI struct {
s *store.Store
}
// NewStoreAPI wraps a *store.Store as a CoreAPI. The store is the sqlcipher-
// unlocked handle held ONLY in core's address space; this adapter never
// returns it to a caller — core mediates.
func NewStoreAPI(s *store.Store) CoreAPI { return &storeAPI{s: s} }
func (a *storeAPI) WriteFact(ctx context.Context, req WriteFactReq) (int64, error) {
var voids sql.NullInt64
if req.VoidsID != nil {
voids = sql.NullInt64{Int64: *req.VoidsID, Valid: true}
}
id, err := a.s.WriteFactAboutSubject(ctx, req.Ts, store.FactKind(req.Kind), req.Key, req.Subject, req.Value, req.Source, req.Confidence, voids)
return id, mapErr(err)
}
func (a *storeAPI) LatestFact(ctx context.Context, key string) (Fact, error) {
f, err := a.s.LatestFact(ctx, key)
if err != nil {
return Fact{}, mapErr(err)
}
return toFact(f), nil
}
func (a *storeAPI) LatestFactBySource(ctx context.Context, key, source string) (Fact, error) {
f, err := a.s.LatestFactBySource(ctx, key, source)
if err != nil {
return Fact{}, mapErr(err)
}
return toFact(f), nil
}
func (a *storeAPI) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) {
d, err := a.s.Since(ctx, key, now)
return d, mapErr(err)
}
func (a *storeAPI) Presence(ctx context.Context) (Presence, error) {
b, score, upd, err := a.s.LoadPresenceState(ctx)
if err != nil {
return Presence{}, fmt.Errorf("ipc: load presence: %w", err)
}
return Presence{Bucket: Bucket(b), Score: score, Updated: upd}, nil
}
func (a *storeAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
id, err := a.s.CreateReminder(ctx, fire, payload, cron)
return id, mapErr(err)
}
func (a *storeAPI) MarkReminder(ctx context.Context, id int64, status string) error {
return mapErr(a.s.MarkReminder(ctx, id, status))
}
func (a *storeAPI) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
rs, err := a.s.ListReminders(ctx, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Reminder, len(rs))
for i, r := range rs {
out[i] = toReminder(r)
}
return out, nil
}
func (a *storeAPI) RescheduleReminder(ctx context.Context, id int64, now time.Time) error {
return mapErr(a.s.RescheduleReminder(ctx, id, now))
}
func (a *storeAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) {
id, err := a.s.RecordNudge(ctx, rule, channel, message, ts)
return id, mapErr(err)
}
func (a *storeAPI) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error {
return mapErr(a.s.ResolveNudge(ctx, id, outcome, ts))
}
func (a *storeAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) {
out, err := a.s.RecentOutcomes(ctx, rule, n)
return out, mapErr(err)
}
func (a *storeAPI) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
fs, err := a.s.RecentFacts(ctx, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Fact, len(fs))
for i, f := range fs {
out[i] = toFact(f)
}
return out, nil
}
func (a *storeAPI) RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error) {
fs, err := a.s.RecentActiveFactsByKind(ctx, store.FactKind(kind), n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Fact, len(fs))
for i, f := range fs {
out[i] = toFact(f)
}
return out, nil
}
func (a *storeAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
fs, err := a.s.CalendarEvents(ctx, from, to)
if err != nil {
return nil, mapErr(err)
}
out := make([]Fact, len(fs))
for i, f := range fs {
out[i] = toFact(f)
}
return out, nil
}
func (a *storeAPI) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error) {
trs, err := a.s.RecentEcosystemTraces(ctx, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]EcosystemTrace, len(trs))
for i, tr := range trs {
out[i] = EcosystemTrace{
ID: tr.ID, Ts: tr.Ts, Service: tr.Service, Operation: tr.Operation,
Status: tr.Status, DurationMs: tr.DurationMs, CorrelationID: tr.CorrelationID,
CausationID: tr.CausationID, HTTPStatus: tr.HTTPStatus, Fields: tr.Fields,
}
}
return out, nil
}
func (a *storeAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
ns, err := a.s.RecentNudges(ctx, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Nudge, len(ns))
for i, ng := range ns {
out[i] = toNudge(ng)
}
return out, nil
}
func (a *storeAPI) DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) {
as, err := a.s.ListDeliveryAttempts(ctx, status, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]DeliveryAttempt, len(as))
for i, at := range as {
out[i] = DeliveryAttempt{
ID: at.ID, Kind: at.Kind, Rule: at.Rule, ReminderID: at.ReminderID,
Channel: at.Channel, Status: at.Status, Created: at.Created,
}
if at.HasComplete {
t := at.Completed
out[i].Completed = &t
}
}
return out, nil
}
func (a *storeAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
id, err := a.s.WriteNote(ctx, ts, text, embedding, source)
return id, mapErr(err)
}
func (a *storeAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) {
ns, err := a.s.QueryNotes(ctx, embedding, k)
if err != nil {
return nil, mapErr(err)
}
out := make([]Note, len(ns))
for i, n := range ns {
out[i] = toNote(n)
}
return out, nil
}
func (a *storeAPI) RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error) {
ns, err := a.s.RecentNotesFromSource(ctx, prefix, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Note, len(ns))
for i, note := range ns {
out[i] = toNote(note)
}
return out, nil
}
func (a *storeAPI) RecentNotes(ctx context.Context, n int) ([]Note, error) {
ns, err := a.s.RecentNotes(ctx, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Note, len(ns))
for i, note := range ns {
out[i] = toNote(note)
}
return out, nil
}
func (a *storeAPI) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
ok, err := a.s.ProposeTool(ctx, name, utterance, scope, ts)
return ok, mapErr(err)
}
func (a *storeAPI) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error {
return mapErr(a.s.EnableTool(ctx, name, cmd, destructive, scope, ts))
}
func (a *storeAPI) DisableTool(ctx context.Context, name string) error {
return mapErr(a.s.DisableTool(ctx, name))
}
func (a *storeAPI) LookupTool(ctx context.Context, name string) (Tool, error) {
t, err := a.s.LookupTool(ctx, name)
if err != nil {
return Tool{}, mapErr(err)
}
return toTool(t), nil
}
func (a *storeAPI) RevertFact(ctx context.Context, key string) (int64, error) {
_, newID, err := a.s.VoidLatestFact(ctx, key, "feedback", time.Now())
return newID, mapErr(err)
}
func (a *storeAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
return "", errors.New("store: chat not available via direct store API")
}
func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) {
return TickTrace{}, errors.New("store: tick trace not available via direct store API")
}
func (a *storeAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
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) {
return DayPlan{}, errors.New("store: day plan not available via direct store API")
}
func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error) {
ts, err := a.s.ListTools(ctx, status)
if err != nil {
return nil, mapErr(err)
}
out := make([]Tool, len(ts))
for i, t := range ts {
out[i] = toTool(t)
}
return out, nil
}
func (a *storeAPI) DeleteTool(ctx context.Context, name string) error {
return mapErr(a.s.DeleteTool(ctx, name))
}
func (a *storeAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
res, err := a.s.CaptureTask(ctx, store.Task{
CreatedTs: req.Ts,
Text: req.Text,
Source: req.Source,
Evidence: req.Evidence,
ExternalID: req.ExternalID,
Status: req.Status,
Due: req.Due,
Weight: req.Weight,
})
if err != nil {
return CaptureTaskResp{}, mapErr(err)
}
return CaptureTaskResp{ID: res.ID, Created: res.Created, Promoted: res.Promoted}, nil
}
func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
ts, err := a.s.ListTasks(ctx, status)
if err != nil {
return nil, mapErr(err)
}
out := make([]Task, len(ts))
for i, t := range ts {
out[i] = Task{
ID: t.ID,
CreatedTs: t.CreatedTs,
Text: t.Text,
Source: t.Source,
Evidence: t.Evidence,
ExternalID: t.ExternalID,
Status: t.Status,
Due: t.Due,
Weight: t.Weight,
Resolved: t.ResolvedTs,
ResolvedBy: t.ResolvedBy,
}
}
return out, nil
}
func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
return mapErr(a.s.SetTaskStatus(ctx, id, status, ts, by))
}
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
rs, err := a.s.ListProposedRoutines(ctx)
if err != nil {
return nil, mapErr(err)
}
out := make([]ProposedRoutine, len(rs))
for i, r := range rs {
out[i] = ProposedRoutine{
ID: r.ID,
Action: r.Action,
Object: r.Object,
IntervalDays: r.IntervalDays,
Status: string(r.Status),
CreatedTs: r.CreatedTs.UnixMilli(),
}
if r.ReminderID != nil {
out[i].ReminderID = r.ReminderID
}
}
return out, nil
}
func (a *storeAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
return mapErr(a.s.DismissProposedRoutine(ctx, id))
}
func (a *storeAPI) AcceptProposedRoutine(ctx context.Context, id int64) error {
return mapErr(a.s.AcceptProposedRoutine(ctx, id, time.Now().UTC()))
}
func toTool(t store.Tool) Tool {
return Tool{
Name: t.Name, Scope: t.Scope, Cmd: t.Cmd, Destructive: t.Destructive,
Status: t.Status, Utterance: t.Utterance, Created: t.CreatedTs, Updated: t.UpdatedTs,
}
}
func toReminder(r store.Reminder) Reminder {
return Reminder{
ID: r.ID,
CreatedTs: r.CreatedTs,
FireTs: r.FireTs,
NextFireTs: r.NextFireTs,
Payload: r.Payload,
Status: r.Status,
Cron: r.Cron,
}
}
func toNote(n store.Note) Note {
return Note{ID: n.ID, Ts: n.Ts, Text: n.Text, Source: n.Source, Score: n.Score}
}
func toNudge(n store.Nudge) Nudge {
out := Nudge{
ID: n.ID, Ts: n.Ts, Rule: n.Rule, Channel: n.Channel,
Message: n.Message, Outcome: n.Outcome,
}
if n.OutcomeTs.Valid {
v := n.OutcomeTs.Int64
out.OutcomeTs = &v
}
return out
}
func toFact(f store.Fact) Fact {
out := Fact{
ID: f.ID,
Ts: f.Ts,
Kind: string(f.Kind),
Key: f.Key,
Value: f.Value,
Source: f.Source,
Confidence: f.Confidence,
}
if f.VoidsID.Valid {
v := f.VoidsID.Int64
out.VoidsID = &v
}
return out
}
// mapErr — store sentinel ↔ ipc sentinel. An unrecognized store error is
// wrapped but not mapped (server-side dispatch surfaces it as codeInternal,
// keeping internal text off the wire except to the daemon log).
func mapErr(err error) error {
if err == nil {
return nil
}
switch {
case errors.Is(err, store.ErrNoFact):
return ErrNoFact
case errors.Is(err, store.ErrConfidence):
return ErrConfidence
case errors.Is(err, store.ErrVoidsMissing):
return ErrVoidsMissing
case errors.Is(err, store.ErrNudgeNotFound):
return ErrNudgeNotFound
case errors.Is(err, store.ErrNudgeOutcome):
return ErrNudgeOutcome
case errors.Is(err, store.ErrReminderNotFound):
return ErrReminderNotFound
case errors.Is(err, store.ErrReminderState):
return ErrReminderState
case errors.Is(err, store.ErrToolNotFound):
return ErrToolNotFound
}
return err
}
+1 -4
View File
@@ -68,9 +68,6 @@ func (UnimplementedCoreAPI) RecentActiveFactsByKind(ctx context.Context, kind st
func (UnimplementedCoreAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
return nil, ErrNotImplemented
}
func (UnimplementedCoreAPI) DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) {
return nil, ErrNotImplemented
}
func (UnimplementedCoreAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
return nil, ErrNotImplemented
}
@@ -144,6 +141,6 @@ func (UnimplementedCoreAPI) MCPServers(ctx context.Context) ([]MCPServerStatus,
func (UnimplementedCoreAPI) DayPlan(ctx context.Context) (DayPlan, error) {
return DayPlan{}, ErrNotImplemented
}
func (UnimplementedCoreAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
func (UnimplementedCoreAPI) Chat(ctx context.Context, text string) (string, error) {
return "", ErrNotImplemented
}
-1
View File
@@ -28,7 +28,6 @@ const (
MethodRecentActiveFacts Method = "recent_active_facts_by_kind"
MethodCalendarEvents Method = "calendar_events"
MethodRecentNudges Method = "recent_nudges"
MethodDeliveryAttempts Method = "delivery_attempts"
MethodRecentEcoTraces Method = "recent_ecosystem_traces"
MethodWriteNote Method = "write_note"
MethodQueryNotes Method = "query_notes"
+3 -3
View File
@@ -103,7 +103,7 @@ func TestExplainGate_PresenceAway(t *testing.T) {
func TestExplainGate_PresenceAwayOpsBypass(t *testing.T) {
now := refTime()
s := State{Now: now, Presence: store.Away,
Facts: map[string]store.Fact{"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute))},
Facts: map[string]store.Fact{"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute))},
}
r := ServiceDownRule() // Sev4 ops
passed, blocked, d := ExplainGate(s, r)
@@ -259,8 +259,8 @@ func TestExplainTick_WinnerRecorded(t *testing.T) {
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
},
}
cand, trace := ExplainTick(s, DefaultRules())
+6 -6
View File
@@ -198,12 +198,12 @@ func TestTickNeverDogpilesAndPicksLoudest(t *testing.T) {
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{
"water": ago("water", "tap:water", `"250ml"`, 5*time.Hour),
"meal": ago("meal", "voice", `"lunch"`, 8*time.Hour),
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
"break": ago("break", "voice", `"walk"`, 3*time.Hour),
"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute),
"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute),
"water": ago("water", "tap:water", `"250ml"`, 5*time.Hour),
"meal": ago("meal", "voice", `"lunch"`, 8*time.Hour),
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
"break": ago("break", "voice", `"walk"`, 3*time.Hour),
"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute),
"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute),
},
}
// sanity: every rule really does want to fire, so the pick is a real choice.
-14
View File
@@ -91,20 +91,6 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto
return State{}, nil, err
}
// prefix families — the keys a rule cannot name at wiring time (one fact
// per kuma monitor). Loaded into the same map; State.FactsUnder reads them.
for _, r := range g.rules {
for _, p := range r.WantPrefixes {
fam, err := g.store.LatestFactsByPrefix(ctx, p)
if err != nil {
return State{}, nil, err
}
for _, f := range fam {
facts[f.Key] = f
}
}
}
// last nudge per rule + cooldown-until derived from the active cooldown.
// "active" = the feedback tuner's persisted base if one exists, else the
// rule's static Base. LatestFactBySource is the trust-by-provenance read
-47
View File
@@ -1,47 +0,0 @@
package loop
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/kami/maven/internal/store"
)
// The gatherer is the only impure piece, and a rule over a prefix has no keys
// to declare at wiring time. This is the end of that path: mavpoll's per-monitor
// facts reach the snapshot, and the rule fires on the one that is down.
func TestGatherStateLoadsPrefixFamilies(t *testing.T) {
ctx := context.Background()
s, err := store.Open(ctx, filepath.Join(t.TempDir(), "loop_test.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
now := time.Now().UTC().Truncate(time.Millisecond)
for key, val := range map[string]string{
"service_down:db": "down",
"service_down:web": "up",
} {
if _, err := s.SetValue(ctx, store.KindEnv, key, ServiceDownSource, val, now.Add(-time.Minute)); err != nil {
t.Fatalf("SetValue %s: %v", key, err)
}
}
rules := []Rule{ServiceDownRule()}
st, _, err := NewGatherer(s, rules).GatherState(ctx, now)
if err != nil {
t.Fatalf("GatherState: %v", err)
}
if _, ok := st.Facts["service_down:db"]; !ok {
t.Fatalf("prefix family not gathered: %v", st.Facts)
}
if got := DownServices(st); len(got) != 1 || got[0] != "db" {
t.Fatalf("DownServices = %v, want [db]", got)
}
if !rules[0].Predicate(st) {
t.Fatal("the rule must fire on a gathered per-monitor fact")
}
}
+4 -4
View File
@@ -82,7 +82,7 @@ func TestTickOpsHardSurvivesAwayAndQuiet(t *testing.T) {
Presence: store.Away,
QuietHours: true,
Facts: map[string]store.Fact{
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
},
}
got := Tick(s, DefaultRules())
@@ -99,7 +99,7 @@ func TestTickServiceSourceTrustRefusesForgedTrigger(t *testing.T) {
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{
"service_down:db": factAt("service_down:db", "ambient", `"down"`, now.Add(-1*time.Minute)),
"service_down": factAt("service_down", "ambient", `"down"`, now.Add(-1*time.Minute)),
},
}
if got := Tick(s, DefaultRules()); got != nil {
@@ -115,8 +115,8 @@ func TestTickOneNudgePerTickMaxSeverityWins(t *testing.T) {
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
},
}
got := Tick(s, DefaultRules())
+13 -55
View File
@@ -28,13 +28,6 @@ type Rule struct {
// that check itself, leave this empty. Otherwise set to the key(s) the rule
// needs and the gate will skip the rule when any are missing.
InertWhenNoData []string
// WantPrefixes — key prefixes whose whole family the gatherer must load.
// InertWhenNoData names keys that exist at wiring time; a rule over a key
// set that is only known at read time (one fact per kuma monitor) declares
// the prefix here instead. Prefixes never make a rule inert: an empty
// family is the predicate's own "no data" case.
WantPrefixes []string
}
// Cooldown — tunable bounded by the envelope so a weird week (auto-tuned) can't
@@ -105,58 +98,23 @@ func BreakRule() Rule {
}
}
// ServiceDownPrefix — mavpoll writes one fact per kuma monitor under this
// prefix, `service_down:<monitor name>`. The suffix is the name he hears.
const ServiceDownPrefix = "service_down:"
// ServiceDownSource — kuma is the source of truth for service up/down. The
// rule is provenance-scoped: a poller writing under a different source cannot
// forge the trigger.
const ServiceDownSource = "poll:uptimekuma"
// DownServices — the monitors currently reading "down", by name, in key order.
//
// Pure, and the rule and the phraser both call it, so the message can never
// name a service the predicate did not fire on.
func DownServices(s State) []string {
var out []string
for _, f := range s.FactsUnder(ServiceDownPrefix) {
if f.Source == ServiceDownSource && f.Value == `"down"` {
out = append(out, strings.TrimPrefix(f.Key, ServiceDownPrefix))
}
}
return out
}
// ServiceDownRule — sev4 ops hard: at least one kuma monitor reads "down".
//
// It used to read one aggregate `service_down` fact, which is why it was
// disabled in deploy: the nudge could say that something on homesrv was down
// but never which thing. Per-monitor facts fix that, and pausing a monitor in
// kuma now silences that monitor rather than nothing.
//
// Edge-triggered — see State.NudgedSince. Without it a service that stays down
// for a day qualifies on every tick and cooldown alone is the only brake.
// ServiceDownRule — sev4 ops hard: the `service_down` aggregate fact reads
// "down". Source must be poll:uptimekuma — kuma is the source of truth for
// service up/down (mavpoll writes this key). The predicate is provenance-scoped:
// a compromised poller writing under a different source can't forge the trigger.
func ServiceDownRule() Rule {
return Rule{
Name: "service_down",
Severity: Sev4,
Cooldown: Cooldown{Base: 15 * time.Minute, Min: 5 * time.Minute, Max: 1 * time.Hour},
WantPrefixes: []string{ServiceDownPrefix},
Name: "service_down",
Severity: Sev4,
Cooldown: Cooldown{Base: 15 * time.Minute, Min: 5 * time.Minute, Max: 1 * time.Hour},
InertWhenNoData: []string{"service_down"},
Predicate: func(s State) bool {
var newest time.Time
for _, f := range s.FactsUnder(ServiceDownPrefix) {
if f.Source != ServiceDownSource || f.Value != `"down"` {
continue
}
if f.Ts.After(newest) {
newest = f.Ts
}
f, ok := s.Fact("service_down")
if !ok || f.Ts.IsZero() {
return false
}
if newest.IsZero() {
return false // nothing down, or no data at all → shut up
}
return !s.NudgedSince("service_down", newest)
// value is json `"down"`; trivial check keyed off source provenance.
return f.Source == "poll:uptimekuma" && f.Value == `"down"`
},
}
}
+15 -80
View File
@@ -193,13 +193,13 @@ func TestOpsRulePredicates(t *testing.T) {
{
name: "service_down fires on a kuma down fact",
rule: ServiceDownRule(),
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute)},
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute)},
want: true,
},
{
name: "service_down quiet when kuma says up",
rule: ServiceDownRule(),
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `"up"`, time.Minute)},
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `"up"`, time.Minute)},
want: false,
},
{
@@ -211,38 +211,38 @@ func TestOpsRulePredicates(t *testing.T) {
{
name: "service_down quiet on a zero-timestamp fact",
rule: ServiceDownRule(),
facts: map[string]store.Fact{"service_down:db": {Key: "service_down:db", Source: "poll:uptimekuma", Value: `"down"`}},
facts: map[string]store.Fact{"service_down": {Key: "service_down", Source: "poll:uptimekuma", Value: `"down"`}},
want: false,
},
// forgery attempts — right value, wrong writer.
{
name: "service_down refuses a forgery from the netdata poller",
rule: ServiceDownRule(),
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:netdata", `"down"`, time.Minute)},
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:netdata", `"down"`, time.Minute)},
want: false,
},
{
name: "service_down refuses a forgery from ambient audio",
rule: ServiceDownRule(),
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "ambient:other", `"down"`, time.Minute)},
facts: map[string]store.Fact{"service_down": ago("service_down", "ambient:other", `"down"`, time.Minute)},
want: false,
},
{
name: "service_down refuses a forgery from the user's own voice",
rule: ServiceDownRule(),
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "voice", `"down"`, time.Minute)},
facts: map[string]store.Fact{"service_down": ago("service_down", "voice", `"down"`, time.Minute)},
want: false,
},
{
name: "service_down refuses a source that only looks like kuma",
rule: ServiceDownRule(),
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma-staging", `"down"`, time.Minute)},
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma-staging", `"down"`, time.Minute)},
want: false,
},
{
name: "service_down refuses an unquoted down value",
rule: ServiceDownRule(),
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `down`, time.Minute)},
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `down`, time.Minute)},
want: false,
},
@@ -312,11 +312,9 @@ func TestOpsRulePredicates(t *testing.T) {
// a second no-data backstop, so a rule that forgets it loses the safety net
// even if its predicate happens to check.
func TestDefaultRulesDeclareInertKeys(t *testing.T) {
// A rule over a key set that only exists at read time declares a prefix
// instead — the gatherer still needs to be told what to load.
for _, r := range DefaultRules() {
if len(r.InertWhenNoData) == 0 && len(r.WantPrefixes) == 0 {
t.Errorf("rule %q declares neither InertWhenNoData keys nor WantPrefixes", r.Name)
if len(r.InertWhenNoData) == 0 {
t.Errorf("rule %q declares no InertWhenNoData keys", r.Name)
}
}
}
@@ -379,11 +377,11 @@ func TestDefaultRuleCooldownsAreBounded(t *testing.T) {
// hidden state, no clock reads.
func TestPredicatesArePure(t *testing.T) {
s := stateWith(map[string]store.Fact{
"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour),
"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour),
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute),
"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour),
"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour),
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute),
})
for _, r := range DefaultRules() {
first := r.Predicate(s)
@@ -436,66 +434,3 @@ func TestRulesExceptEmptyKeepsEverything(t *testing.T) {
t.Errorf("rules = %v, dropped = %v", ruleNames(rules), dropped)
}
}
// ---------------------------- per-monitor service_down -----------------------
// The rule must name what fired on it, and the phraser reads the same helper,
// so a service that is up can never be spoken as down.
func TestDownServicesNamesOnlyTheDownOnes(t *testing.T) {
s := State{
Now: refTime(),
Facts: map[string]store.Fact{
"service_down:web": ago("service_down:web", ServiceDownSource, `"up"`, time.Minute),
"service_down:db": ago("service_down:db", ServiceDownSource, `"down"`, time.Minute),
"service_down:vault": ago("service_down:vault", ServiceDownSource, `"down"`, time.Minute),
"service_down:paused": ago("service_down:paused", ServiceDownSource, `"maintenance"`, time.Minute),
"service_down:forged": ago("service_down:forged", "voice", `"down"`, time.Minute),
"service_down:missing": ago("service_down:missing", ServiceDownSource, `"unknown"`, time.Minute),
},
}
got := DownServices(s)
want := []string{"db", "vault"} // key order, so speech is stable
if len(got) != len(want) {
t.Fatalf("DownServices = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("DownServices = %v, want %v", got, want)
}
}
}
// A monitor paused in kuma must silence that monitor. Before per-monitor facts
// the aggregate stayed "down" and pausing achieved nothing.
func TestPausedMonitorSilencesOnlyItself(t *testing.T) {
base := map[string]store.Fact{
"service_down:db": ago("service_down:db", ServiceDownSource, `"maintenance"`, time.Minute),
"service_down:web": ago("service_down:web", ServiceDownSource, `"down"`, time.Minute),
}
if !ServiceDownRule().Predicate(State{Now: refTime(), Facts: base}) {
t.Fatal("web is still down, the rule must fire")
}
delete(base, "service_down:web")
if ServiceDownRule().Predicate(State{Now: refTime(), Facts: base}) {
t.Fatal("only a paused monitor is left, the rule must be quiet")
}
}
// Edge-triggered: he is told once per transition. A service that stays down
// for a day used to qualify on every tick, with cooldown as the only brake.
func TestServiceDownFiresOncePerTransition(t *testing.T) {
down := ago("service_down:db", ServiceDownSource, `"down"`, time.Hour)
s := State{Now: refTime(), Facts: map[string]store.Fact{"service_down:db": down}}
if !ServiceDownRule().Predicate(s) {
t.Fatal("first sight of the transition must fire")
}
s.LastNudge = map[string]store.Nudge{"service_down": {Ts: down.Ts.Add(time.Minute)}}
if ServiceDownRule().Predicate(s) {
t.Fatal("already told about this transition, must be quiet")
}
// A second service goes down after that nudge — a new edge, so it fires.
s.Facts["service_down:web"] = ago("service_down:web", ServiceDownSource, `"down"`, time.Minute)
if !ServiceDownRule().Predicate(s) {
t.Fatal("a later transition must fire again")
}
}
-28
View File
@@ -23,8 +23,6 @@
package loop
import (
"sort"
"strings"
"time"
"github.com/kami/maven/internal/store"
@@ -97,32 +95,6 @@ func (s State) Fact(key string) (store.Fact, bool) {
return f, true
}
// FactsUnder returns every gathered fact whose key starts with prefix, ordered
// by key so a caller that names them speaks them in a stable order. Facts with
// a zero Ts are skipped, the same "no data" rule Fact applies.
func (s State) FactsUnder(prefix string) []store.Fact {
var out []store.Fact
for k, f := range s.Facts {
if strings.HasPrefix(k, prefix) && !f.Ts.IsZero() {
out = append(out, f)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
return out
}
// NudgedSince reports whether rule already sent a nudge at or after ts.
//
// It is what makes a rule edge-triggered. A polled fact is written only when
// the value changes, so its Ts is the moment the service went down — but the
// predicate reads the current value, so a service that stays down keeps
// qualifying forever and cooldown alone only slows the repetition. Asking
// whether he was already told about THIS transition stops it.
func (s State) NudgedSince(rule string, ts time.Time) bool {
n, ok := s.LastNudge[rule]
return ok && !n.Ts.Before(ts)
}
// Since returns the duration since the latest fact for key, or (0,false).
// "false" ⇒ no data ⇒ shuts up when uncertain.
func (s State) Since(key string) (time.Duration, bool) {
-35
View File
@@ -90,44 +90,9 @@ func Load() (Fixture, error) {
if len(f.Cases) == 0 {
return Fixture{}, fmt.Errorf("fixture has no cases")
}
if err := checkIDs(f); err != nil {
return Fixture{}, err
}
return f, nil
}
// checkIDs refuses a fixture where a case note and a filler note share an id.
//
// Every case is scored over its own notes plus the whole filler set, and the
// two stores disagree about what a repeated id means: the sqlite store upserts
// on it, the in-memory store appends. So one collision makes a case score
// differently on the two backends, and it reads as an embedder or gate
// difference, which is the one thing this harness exists to measure (Vikunja
// #386). It was dodged once by hand during #373 by renaming two ids.
//
// Checked in Load rather than in the test, so every caller of the fixture is
// covered and not only the one that remembers to look.
func checkIDs(f Fixture) error {
filler := make(map[string]bool, len(f.Filler))
for _, n := range f.Filler {
if n.ID == "" {
return fmt.Errorf("filler note with an empty id")
}
if filler[n.ID] {
return fmt.Errorf("duplicate filler note id %q", n.ID)
}
filler[n.ID] = true
}
for _, c := range f.Cases {
for _, n := range c.Notes {
if filler[n.ID] {
return fmt.Errorf("case %s: note id %q collides with a filler note", c.ID, n.ID)
}
}
}
return nil
}
// NewStore builds an empty store for one case, plus a function to release it.
// A factory rather than a store because every case needs a clean index — notes
// from case A must not be visible to case B's query.
@@ -337,28 +337,3 @@ func marginSweep(t *testing.T, emb router.Embedder, f Fixture) string {
}
return b.String()
}
// TestFillerIDCollisionIsRefused — the guard that keeps a fixture edit from
// looking like a backend difference (Vikunja #386).
func TestFillerIDCollisionIsRefused(t *testing.T) {
f := Fixture{
SchemaVersion: SchemaVersion,
Cases: []Case{{ID: "ru-001", Notes: []StoredNote{{ID: "f1", Text: "..."}}}},
Filler: []StoredNote{{ID: "f1", Text: "..."}},
}
if err := checkIDs(f); err == nil {
t.Fatal("a case note reusing a filler id must be refused")
}
f.Filler = append(f.Filler, StoredNote{ID: "f1", Text: "..."})
if err := checkIDs(Fixture{SchemaVersion: SchemaVersion, Filler: f.Filler}); err == nil {
t.Fatal("a duplicate filler id must be refused")
}
ok := Fixture{
SchemaVersion: SchemaVersion,
Cases: []Case{{ID: "ru-001", Notes: []StoredNote{{ID: "n1", Text: "..."}}}},
Filler: []StoredNote{{ID: "f1", Text: "..."}},
}
if err := checkIDs(ok); err != nil {
t.Fatalf("a clean fixture must pass: %v", err)
}
}
+2 -43
View File
@@ -30,18 +30,6 @@ type Item struct {
Key string
FactKey string
Label string // RU text surfaced when this item is still missing.
// Optional — a missing one is not worth a nudge on its own.
//
// Every item was implicitly required until 04-08-2026, because there was
// no field, so a skipped stretch read exactly like skipped medication and
// #280's first behaviour could not hold (Vikunja #473). A checklist where
// everything is mandatory is a checklist he learns to ignore.
//
// It changes two things and nothing else: an all-optional routine never
// nudges, and a nudge that does fire names the optional stragglers after
// the required ones, in softer words. Evidence, the window and the day
// plan treat both kinds alike — a missing optional item is still missing.
Optional bool
}
// Routine — one daily checklist. WindowStart/WindowEnd are "HH:MM" local
@@ -72,37 +60,12 @@ type Status struct {
}
// Candidate — a routine that's due for its one-per-day nag: the window has
// reached NudgeAt and at least one REQUIRED item is still unevidenced. Missing
// carries the optional stragglers too, so the one message she is allowed per
// day per routine can mention them; they never cause it.
// reached NudgeAt and at least one item is still unevidenced.
type Candidate struct {
Routine Routine
Missing []Item
}
// Required reports the missing items that are not optional. The nudge fires on
// these; the rest ride along.
func Required(missing []Item) []Item {
var out []Item
for _, it := range missing {
if !it.Optional {
out = append(out, it)
}
}
return out
}
// OptionalOnly is the other half of Required.
func OptionalOnly(missing []Item) []Item {
var out []Item
for _, it := range missing {
if it.Optional {
out = append(out, it)
}
}
return out
}
// Validate reports the first structural problem with a routine set: missing
// name/items, an unparseable HH:MM, an inverted window, a duplicate item key
// within a routine, or an out-of-range weekday. Called at config load so a
@@ -228,11 +191,7 @@ func Due(routines []Routine, facts map[string]store.Fact, last map[string]time.T
missing = append(missing, it)
}
}
// A day where only the optional items were skipped is a fine day, and
// nagging about it is what teaches him to stop listening (Vikunja
// #473). The optional ones still travel in Missing so the message can
// mention them when it is being sent anyway.
if len(Required(missing)) == 0 {
if len(missing) == 0 {
continue
}
if prev, seen := last[r.Name]; seen && sameDay(prev, now) {
-37
View File
@@ -182,40 +182,3 @@ func TestDueRespectsExplicitNudgeAt(t *testing.T) {
t.Fatalf("expected candidate at explicit nudge_at, got %d", len(out))
}
}
// TestOptionalItemsDoNotEarnANudge — behaviour 1 of #280, which could not hold
// while every item was implicitly required (Vikunja #473).
func TestOptionalItemsDoNotEarnANudge(t *testing.T) {
r := Routine{
Name: "утро",
WindowStart: "07:00",
WindowEnd: "10:00",
Items: []Item{
{Key: "meds", FactKey: "meds", Label: "таблетки"},
{Key: "stretch", FactKey: "stretch", Label: "растяжка", Optional: true},
},
}
now := time.Date(2026, 8, 4, 10, 0, 0, 0, time.UTC)
took := map[string]store.Fact{"meds": {Key: "meds", Ts: now.Add(-2 * time.Hour)}}
// Only the stretch was skipped: nothing to say.
if due := Due([]Routine{r}, took, map[string]time.Time{}, now); len(due) != 0 {
t.Fatalf("an optional item alone must not nudge, got %+v", due)
}
// The medication was skipped: she says so, and mentions the stretch too.
due := Due([]Routine{r}, map[string]store.Fact{}, map[string]time.Time{}, now)
if len(due) != 1 {
t.Fatalf("a missing required item must nudge, got %+v", due)
}
if got := Required(due[0].Missing); len(got) != 1 || got[0].Key != "meds" {
t.Fatalf("Required = %+v, want the meds item alone", got)
}
if got := OptionalOnly(due[0].Missing); len(got) != 1 || got[0].Key != "stretch" {
t.Fatalf("OptionalOnly = %+v, want the stretch item alone", got)
}
// The window still reports it as missing — optional is not invisible.
st := Evaluate(r, map[string]store.Fact{}, now.Add(-time.Hour))
if len(st.Missing) != 2 {
t.Fatalf("Evaluate must still list both, got %+v", st.Missing)
}
}
+2 -24
View File
@@ -53,31 +53,9 @@ const MinOnPatternFraction = 0.7
// a repeat. False negatives cost one more observation and nothing else.
const MinEvents = 4
// MinIntervalDays — the fastest rhythm that may be called a routine. Two
// hours.
//
// Without a floor, four taps of the same key minutes apart give intervals near
// 0.002 days. They all sit inside the ±50% band by construction, so the
// detector proposed a routine and PhraseRoutine worded it as "каждый день"
// (Vikunja #468). The damage outlives the mistake: UNIQUE(action, object)
// means dismissing the bogus proposal burns that pair permanently, so the real
// routine behind it can never be proposed again.
//
// Two hours rather than a day, because a genuine habit can run several times a
// day — meals, water, a break. Anything faster than that is not a habit she
// should be proposing to remind him about; the loop rules already cover that
// range, and they are rules, not guesses. It is checked against the median, so
// one quick repeat inside a real rhythm still counts.
//
// The other half of this is that hand-QA of the detector was unsafe: seeding a
// pattern the obvious way, four chat turns in a row, poisoned the very pair
// being tested.
const MinIntervalDays = 2.0 / 24.0
// Detect checks whether a sequence of events for the same action+object
// forms a stable recurring pattern. Returns a ProposedRoutine when:
// - At least MinEvents events exist (≥3 intervals)
// - The median interval is at least MinIntervalDays
// - At least MinOnPatternFraction of the intervals sit within
// MaxIntervalRatio of the median interval
//
@@ -110,8 +88,8 @@ func Detect(events []Event) (*ProposedRoutine, error) {
}
center := medianFloat(intervals)
if center <= 0 || center < MinIntervalDays {
return nil, nil // a burst, not a rhythm — see MinIntervalDays
if center <= 0 {
return nil, nil
}
// Keep the intervals that sit inside the band around the median. The
-43
View File
@@ -216,46 +216,3 @@ func TestDetectMedianBandNotExtremes(t *testing.T) {
})
}
}
// A burst is not a habit. Four taps of the same key minutes apart give
// intervals near 0.002 days, all inside the ±50% band by construction, so the
// detector called it a daily routine (Vikunja #468). Dismissing that proposal
// burns the action+object pair permanently, which also made hand-QA of the
// detector unsafe.
func TestDetectRejectsABurst(t *testing.T) {
base := time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC)
var events []Event
for i := 0; i < 4; i++ {
events = append(events, Event{
Action: "refill", Object: "cat_water",
Ts: base.Add(time.Duration(i) * 7 * time.Minute),
})
}
r, err := Detect(events)
if err != nil {
t.Fatalf("Detect: %v", err)
}
if r != nil {
t.Fatalf("four taps minutes apart proposed a routine every %.3f days", r.IntervalDays)
}
}
// The floor is two hours, not a day: a habit that runs several times a day is
// still a habit.
func TestDetectKeepsASeveralTimesADayHabit(t *testing.T) {
base := time.Date(2026, 8, 4, 8, 0, 0, 0, time.UTC)
var events []Event
for i := 0; i < 5; i++ {
events = append(events, Event{
Action: "drink", Object: "water",
Ts: base.Add(time.Duration(i) * 4 * time.Hour),
})
}
r, err := Detect(events)
if err != nil {
t.Fatalf("Detect: %v", err)
}
if r == nil {
t.Fatal("a four-hour rhythm over five events is a habit, got nil")
}
}
+15 -29
View File
@@ -67,6 +67,19 @@ func RunChecks(c Case, body, mood string) []Result {
}
}
// Feminine, HisGender and Address expose three checks one at a time, so the
// daemon can run them on a phrased message before he hears it (Vikunja #399).
// Only these three: they are unambiguous string tests with nothing to compare
// against, while length is path-specific and ontopic needs the fixture's
// expected fragments, which do not exist at runtime.
func Feminine(body string) Result { return checkFeminine(body) }
// HisGender — see checkHisGender.
func HisGender(body string) Result { return checkHisGender(body) }
// Address — see checkAddress.
func Address(body string) Result { return checkAddress(body) }
func checkMood(mood string) Result {
if Moods[mood] {
return Result{CheckMood, true, ""}
@@ -173,23 +186,12 @@ func checkFeminine(body string) Result {
// Second pass: self-reference with the pronoun dropped — "напомнил тебе",
// "проверил за тебя". A masculine past-tense verb whose object is HIM can
// only be her speaking about herself.
//
// Two guards, both from a false positive on the talk fixture: "ты заплатил
// за домен до марта" scored as her drift and cost the run a point it had
// earned (Vikunja #462). He is male, so a past-tense verb governed by "ты"
// must be masculine. And a bare "за" is not evidence of anything — "за
// домен" is a price, "за тебя" is her doing something on his behalf — so it
// only counts when he is the one it points at.
for i, w := range words {
if !masculinePast(w) || i+1 >= len(words) || governedByYou(words, i) {
if !masculinePast(w) || i+1 >= len(words) {
continue
}
next := words[i+1]
aboutHim := next == "тебе" || next == "тебя"
if next == "за" && i+2 < len(words) && (words[i+2] == "тебя" || words[i+2] == "тебе") {
aboutHim = true
}
if aboutHim {
if next == "тебе" || next == "тебя" || next == "за" {
return Result{CheckFeminine, false,
fmt.Sprintf("masculine self-reference %q before %q", w, next)}
}
@@ -663,19 +665,3 @@ func checkEllipsis(body string) Result {
}
return Result{CheckEllipsis, true, ""}
}
// governedByYou reports whether "ты" stands close enough in front of the verb
// at index i to be its subject. Three words, the same window checkFeminine's
// first pass uses after "я", and it stops at a first-person pronoun so "ты
// просил, я напомнил" still trips.
func governedByYou(words []string, i int) bool {
for j := i - 1; j >= 0 && j >= i-3; j-- {
switch words[j] {
case "ты":
return true
case "я":
return false
}
}
return false
}
-6
View File
@@ -106,12 +106,6 @@ func TestChecksCatchWhatTheyClaim(t *testing.T) {
{"masculine predicative", "я должен сказать: попей воды.", CheckFeminine},
// The other direction: HE is male, so second-person masculine is right.
{"second person masculine ok", "ты не пил воду четыре часа.", ""},
// The recorded false positive: "заплатил" sits before "за", and the
// second pass read that as her dropping the pronoun. The subject is
// "ты" and he is male, so the reply is right (Vikunja #462).
{"second person masculine before за", "ты заплатил за домен до марта, а воду пить всё равно надо.", ""},
// The same shape she really does get wrong still trips.
{"masculine on his behalf", "проверил за тебя — воды не было четыре часа.", CheckFeminine},
// The real observed failure: she addressed him as a woman.
{"feminine second person", "ты давно не отдыхала — попей воды.", CheckHisGender},
{"feminine second person no dash", "ты пила воду четыре часа назад.", CheckHisGender},
-8
View File
@@ -975,9 +975,6 @@ var fallbackNudges = map[string]string{
}
func fallbackNudge(c loop.Candidate) string {
if down := loop.DownServices(c.State); len(down) > 0 {
return "Не отвечает: " + strings.Join(down, ", ") + "."
}
if s, ok := fallbackNudges[c.Rule.Name]; ok {
return s
}
@@ -993,11 +990,6 @@ func buildNudgePrompt(c loop.Candidate) string {
if f, ok := c.State.Facts[c.Rule.Name]; ok && f.Key != "" && f.Key != c.Rule.Name {
ctxParts = append(ctxParts, "Что именно: "+f.Key)
}
if down := loop.DownServices(c.State); len(down) > 0 {
// The names come from the same helper the rule fired on, so the model
// is never handed a service that is actually up.
ctxParts = append(ctxParts, "Какие сервисы лежат: "+strings.Join(down, ", "))
}
if d, ok := c.State.Since(c.Rule.Name); ok {
ctxParts = append(ctxParts, "Прошло: "+ruDur(d))
}
+9 -14
View File
@@ -142,21 +142,16 @@ func phraseNudge(c loop.Candidate) (body, summary string) {
}
return body, "take a break"
case "service_down":
// One fact per kuma monitor, so the nudge names the service. The rule
// and this share loop.DownServices, so the message cannot name a
// service the predicate did not fire on.
down := loop.DownServices(c.State)
switch len(down) {
case 0:
return "a service on homesrv is down — check journalctl.", "service down on homesrv"
case 1:
return fmt.Sprintf("%s on homesrv is down — check journalctl.", down[0]),
fmt.Sprintf("%s down on homesrv", down[0])
default:
list := strings.Join(down, ", ")
return fmt.Sprintf("%s on homesrv are down — check journalctl.", list),
fmt.Sprintf("%d services down on homesrv", len(down))
// the fact value is json `"down"`; the key carries the service name.
body = "a service on homesrv is down — check journalctl."
summary = "service down on homesrv"
if f, ok := c.State.Fact("service_down"); ok {
if f.Key != "" && f.Key != "service_down" {
body = fmt.Sprintf("%s on homesrv is down — check journalctl.", f.Key)
summary = fmt.Sprintf("%s down on homesrv", f.Key)
}
}
return body, summary
default:
// generic: name the rule + severity; the LLM impl replaces this with
// a prompted phrase. the Stub never editorializes beyond the rule name.
+7 -29
View File
@@ -78,14 +78,12 @@ func TestPhraseNudgeBreakDeskDuration(t *testing.T) {
}
func TestPhraseNudgeServiceDownNamedService(t *testing.T) {
// one fact per kuma monitor → the phrase names the monitor that is down.
// a service_down fact whose Key is the specific service name the phrase
// names the service, not just "service down".
now := time.Now().UTC()
st := loop.State{
Now: now,
Facts: map[string]store.Fact{
"service_down:nginx": {Key: "service_down:nginx", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`},
"service_down:db": {Key: "service_down:db", Ts: now, Source: loop.ServiceDownSource, Value: `"up"`},
},
Now: now,
Facts: map[string]store.Fact{"service_down": {Key: "nginx", Ts: now, Source: "poll:healthcheck", Value: `"down"`}},
}
c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st}
pn, _ := NewStub().PhraseNudge(context.Background(), c)
@@ -98,12 +96,12 @@ func TestPhraseNudgeServiceDownNamedService(t *testing.T) {
}
func TestPhraseNudgeServiceDownGenericKey(t *testing.T) {
// the old aggregate key, still in the store from before the per-monitor
// facts landed → the generic phrase, never a phantom "service_down down".
// the rule key itself ("service_down") rather than a specific service →
// the generic phrase, not a phantom "service_down down on homesrv".
now := time.Now().UTC()
st := loop.State{
Now: now,
Facts: map[string]store.Fact{"service_down": {Key: "service_down", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`}},
Facts: map[string]store.Fact{"service_down": {Key: "service_down", Ts: now, Source: "poll:healthcheck", Value: `"down"`}},
}
c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st}
pn, _ := NewStub().PhraseNudge(context.Background(), c)
@@ -112,26 +110,6 @@ func TestPhraseNudgeServiceDownGenericKey(t *testing.T) {
}
}
// Two monitors down at once must both be named — he needs to know the blast
// radius, and "a service is down" was the whole defect being fixed here.
func TestPhraseNudgeServiceDownNamesEveryDownMonitor(t *testing.T) {
now := time.Now().UTC()
st := loop.State{
Now: now,
Facts: map[string]store.Fact{
"service_down:nginx": {Key: "service_down:nginx", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`},
"service_down:db": {Key: "service_down:db", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`},
},
}
c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st}
pn, _ := NewStub().PhraseNudge(context.Background(), c)
for _, want := range []string{"nginx", "db"} {
if !strings.Contains(pn.Body, want) {
t.Fatalf("body should name %q, got %q", want, pn.Body)
}
}
}
func TestPhraseNudgeUnknownRuleFallsBack(t *testing.T) {
// a rule without a dedicated template — generic fallback names the rule +
// severity gist. never empty.
-1
View File
@@ -71,7 +71,6 @@
{ "id": "ru-note-003", "utterance": "заметка про настройку vlan на свитче", "lang": "ru", "intent": "note", "tags": ["homelab"] },
{ "id": "ru-note-004", "utterance": "запиши идею: гидропоника на балконе", "lang": "ru", "intent": "note" },
{ "id": "ru-note-005", "utterance": "запиши что сосед просил номер электрика", "lang": "ru", "intent": "note" },
{ "id": "ru-note-006", "utterance": "добавь в задачи купить молоко", "lang": "ru", "intent": "note", "tags": ["capture"], "note": "an explicit capture marker — the model called it an act and rewrote the payload (Vikunja #467), stage 0 claims it" },
{ "id": "en-note-001", "utterance": "note: rotate the kuma api key", "lang": "en", "intent": "note", "tags": ["homelab"] },
{ "id": "ru-sys-001", "utterance": "сколько сейчас времени в киеве", "lang": "ru", "intent": "system", "tags": ["time"] },
+247
View File
@@ -0,0 +1,247 @@
package router
import (
"regexp"
"strings"
)
// Standing lists, matched deterministically (Vikunja #453).
//
// Same posture as task capture in task.go and for the same reason: the intent
// enum is a contract shared with the relabelling prompt, so a list is not an
// eighth intent. It is a note-shaped or query-shaped utterance carrying an
// explicit marker, and the marker is a lookup.
//
// The markers are deliberately explicit. "молоко закончилось" is an
// observation about the world and belongs in a note; only an instruction to
// put something on a list puts it there.
// listStems — the lists he can name, by the stem every case form shares.
// Russian declines the tag ("список покупок", "в покупки", "в покупках"), so
// matching a stem is what makes those the same list.
var listStems = []struct{ stem, list string }{
{"покуп", "покупки"},
{"продукт", "покупки"},
{"магазин", "покупки"},
{"аптек", "аптека"},
{"хозяйств", "хозяйство"},
{"shopping", "покупки"},
{"groceries", "покупки"},
{"pharmacy", "аптека"},
}
// listCapturePrefixes — an instruction to add to a list. Longest match wins.
var listCapturePrefixes = []string{
"добавь в список",
"добавь в покупки",
"добавь к покупкам",
"запиши в список",
"внеси в список",
"положи в список",
"в список покупок",
"add to the list",
"add to my list",
"add to the shopping list",
"put on the list",
}
// listQueryPrefixes — an ask to read a list back.
var listQueryPrefixes = []string{
"что в списке",
"что в покупках",
"что мне купить",
"что нужно купить",
"что надо купить",
"покажи список",
"прочитай список",
"список покупок",
"мой список",
"what is on the list",
"what's on the list",
"read me the list",
"show me the list",
"shopping list",
}
// listClearPhrases — the whole list is got. One sentence, one turn.
var listClearPhrases = []string{
"всё купил",
"все купил",
"всё взял",
"все взял",
"очисти список",
"очисти покупки",
"список пустой",
"got everything",
"clear the list",
}
// listRemovePrefixes — one item off the list.
var listRemovePrefixes = []string{
"вычеркни",
"убери из списка",
"убери со списка",
"купил",
"взял",
"cross off",
"remove from the list",
}
// listTrimCut — punctuation and connectives to strip off a parsed remainder.
const listTrimCut = " .,;:!?—-"
// ListCapture — a parsed list instruction: which list, and the item.
type ListCapture struct {
List string
Item string
}
// ParseListCapture reports whether an utterance puts something on a list, and
// returns the list tag and the item. A marker with nothing usable after it is
// not a capture: there is no item in "добавь в список покупок".
func ParseListCapture(text string) (ListCapture, bool) {
rest, ok := afterLongestPrefix(text, listCapturePrefixes)
if !ok {
return ListCapture{}, false
}
list, rest := takeListTag(rest)
rest = strings.Trim(rest, listTrimCut)
if rest == "" {
return ListCapture{}, false
}
return ListCapture{List: list, Item: rest}, true
}
// ParseListQuery reports whether an utterance asks for a list, and which one.
func ParseListQuery(text string) (string, bool) {
rest, ok := afterLongestPrefix(text, listQueryPrefixes)
if !ok {
return "", false
}
list, _ := takeListTag(rest)
return list, true
}
// ParseListClear reports whether an utterance crosses off a whole list.
func ParseListClear(text string) (string, bool) {
lower := strings.ToLower(strings.Trim(strings.TrimSpace(text), listTrimCut))
for _, p := range listClearPhrases {
if lower == p || strings.HasPrefix(lower, p+" ") {
list, _ := takeListTag(strings.TrimSpace(lower[len(p):]))
return list, true
}
}
return "", false
}
// ParseListRemove reports whether an utterance takes one named item off a
// list, and returns the list and the item.
//
// The item is required. "купил" on its own is him reporting he shopped, which
// ParseListClear reads first, and it must not fall through to here and remove
// nothing while sounding like it did.
func ParseListRemove(text string) (ListCapture, bool) {
rest, ok := afterLongestPrefix(text, listRemovePrefixes)
if !ok {
return ListCapture{}, false
}
list, rest := takeListTag(rest)
rest = strings.Trim(rest, listTrimCut)
for _, lead := range []string{"из списка ", "со списка ", "из ", "from the list "} {
rest = strings.TrimPrefix(rest, lead)
}
rest = strings.Trim(rest, listTrimCut)
if rest == "" {
return ListCapture{}, false
}
return ListCapture{List: list, Item: rest}, true
}
// afterLongestPrefix matches the longest prefix in the table and returns what
// follows it, trimmed. Lowercasing does not change the byte length of Russian
// or English letters, so the index carries over to the original text.
func afterLongestPrefix(text string, prefixes []string) (string, bool) {
trimmed := strings.TrimSpace(text)
lower := strings.ToLower(trimmed)
best := ""
for _, p := range prefixes {
if strings.HasPrefix(lower, p) && len(p) > len(best) {
best = p
}
}
if best == "" {
return "", false
}
return strings.Trim(trimmed[len(best):], listTrimCut), true
}
// takeListTag reads a list name off the front of the remainder and returns the
// list plus what is left. A remainder naming no list is the default list, and
// nothing is consumed — "добавь в список молоко" names no list and the item is
// молоко.
func takeListTag(rest string) (string, string) {
fields := strings.Fields(rest)
if len(fields) == 0 {
return "покупки", ""
}
head := strings.ToLower(strings.Trim(fields[0], listTrimCut))
// "в список покупок" leaves "покупок"; "в списке" leaves nothing.
if head == "список" || head == "списке" || head == "списка" || head == "list" {
fields = fields[1:]
if len(fields) == 0 {
return "покупки", ""
}
head = strings.ToLower(strings.Trim(fields[0], listTrimCut))
}
for _, s := range listStems {
if strings.HasPrefix(head, s.stem) {
return s.list, strings.Join(fields[1:], " ")
}
}
return "покупки", strings.Join(fields, " ")
}
// ListGrammars — stage 0 for the list (Vikunja #453).
//
// Both patterns match everything and the Build functions are the real filter,
// the shape the wake-word act grammar already uses: the parsers above are the
// definition of a list utterance and duplicating them as regexps would give
// two answers to one question.
//
// Why stage 0 at all: an add and a read-back are deterministic and cheap, and
// leaving them to the model means "добавь в список покупок молоко" lands as an
// act or a fact on the turns the model has a bad day. The action handlers still
// re-parse, so a list turn that arrives by any other route still works.
func ListGrammars() []Grammar {
anything := regexp.MustCompile(`(?s)^(.*)$`)
return []Grammar{
{
Name: "list-query",
Pattern: anything,
Build: func(m []string) (Decision, bool) {
if _, ok := ParseListQuery(m[1]); !ok {
return Decision{}, false
}
return Decision{Stage: 0, Intent: IntentQuery, Confidence: 1.0}, true
},
},
{
Name: "list-capture",
Pattern: anything,
Build: func(m []string) (Decision, bool) {
text := m[1]
_, add := ParseListCapture(text)
_, clear := ParseListClear(text)
if !add && !clear {
return Decision{}, false
}
return Decision{
Stage: 0,
Intent: IntentNote,
Confidence: 1.0,
Slots: Slots{Text: strings.TrimSpace(text)},
}, true
},
},
}
}
+89
View File
@@ -0,0 +1,89 @@
package router
import "testing"
func TestParseListCaptureReadsListAndItem(t *testing.T) {
cases := []struct {
utterance string
list string
item string
}{
{"добавь в список покупок молоко", "покупки", "молоко"},
{"добавь в список молоко", "покупки", "молоко"},
{"Добавь в покупки хлеб и яйца", "покупки", "хлеб и яйца"},
{"запиши в список аптеки бинт", "аптека", "бинт"},
{"добавь в список хозяйства лампочки.", "хозяйство", "лампочки"},
{"add to the shopping list milk", "покупки", "milk"},
}
for _, c := range cases {
got, ok := ParseListCapture(c.utterance)
if !ok {
t.Errorf("ParseListCapture(%q) did not claim it", c.utterance)
continue
}
if got.List != c.list || got.Item != c.item {
t.Errorf("ParseListCapture(%q) = %+v; want list %q item %q", c.utterance, got, c.list, c.item)
}
}
}
// A marker with no item is not a capture, and an utterance that only mentions
// shopping is not one either.
func TestParseListCapturePasses(t *testing.T) {
for _, u := range []string{
"добавь в список покупок",
"добавь в список",
"молоко закончилось",
"надо бы съездить в магазин",
"добавь в задачи купить молоко",
} {
if got, ok := ParseListCapture(u); ok {
t.Errorf("ParseListCapture(%q) claimed it as %+v", u, got)
}
}
}
func TestParseListQueryNamesTheList(t *testing.T) {
cases := []struct{ utterance, list string }{
{"что в списке покупок?", "покупки"},
{"что в списке", "покупки"},
{"что мне купить", "покупки"},
{"покажи список аптеки", "аптека"},
{"what's on the list", "покупки"},
}
for _, c := range cases {
list, ok := ParseListQuery(c.utterance)
if !ok {
t.Errorf("ParseListQuery(%q) did not claim it", c.utterance)
continue
}
if list != c.list {
t.Errorf("ParseListQuery(%q) = %q; want %q", c.utterance, list, c.list)
}
}
if _, ok := ParseListQuery("какие у меня задачи"); ok {
t.Error("ParseListQuery claimed a task question")
}
}
func TestParseListClearAndRemove(t *testing.T) {
if list, ok := ParseListClear("всё купил"); !ok || list != "покупки" {
t.Errorf("ParseListClear = %q, %v; want покупки, true", list, ok)
}
if list, ok := ParseListClear("очисти список аптеки"); !ok || list != "аптека" {
t.Errorf("ParseListClear = %q, %v; want аптека, true", list, ok)
}
if _, ok := ParseListClear("купил молоко"); ok {
t.Error("ParseListClear claimed a single item")
}
got, ok := ParseListRemove("вычеркни молоко")
if !ok || got.Item != "молоко" || got.List != "покупки" {
t.Errorf("ParseListRemove = %+v, %v; want молоко on покупки", got, ok)
}
if got, ok := ParseListRemove("убери из списка аптеки бинт"); !ok || got.Item != "бинт" || got.List != "аптека" {
t.Errorf("ParseListRemove = %+v, %v; want бинт on аптека", got, ok)
}
if _, ok := ParseListRemove("вычеркни"); ok {
t.Error("ParseListRemove claimed a marker with no item")
}
}
+1 -48
View File
@@ -1,9 +1,6 @@
package router
import (
"regexp"
"strings"
)
import "strings"
// Task capture and task listing, matched deterministically (Vikunja #130).
//
@@ -204,47 +201,3 @@ func IsTaskListQuery(text string) bool {
}
return false
}
// TaskCaptureGrammar — stage 0 for an explicit capture marker, so the resident
// model never sees it (Vikunja #467).
//
// Capture was built to ride the note intent, deliberately: #130 said no eighth
// intent, and while the classifier was routing, a note-shaped utterance with a
// marker in it reached actionNote and captureTaskFromNote claimed it there. The
// router pre-empted that. Measured 2026-08-02: "добавь в задачи купить молоко"
// routed act, so captureTaskFromNote was never consulted, the act arm found no
// allowlisted fn, and the gate asked "Что сделать?". Every capture utterance
// tried filed nothing.
//
// The model also rewrote the payload on the way — "купить молоко" came back as
// "сделать покупку молока". A task must read as the words he said, which is a
// second reason to answer this before the model rather than to prompt around
// it.
//
// The marker list is data (task_phrases.json) and the parse strips urgency, so
// the pattern here matches any utterance and the decision is ParseTaskCapture's
// to make — same shape as the wake-word act grammar, which also matches broadly
// and refuses in Build. Intent stays note: the daemon's note path is where
// capture lives, and nothing about the contract with the model changes.
func TaskCaptureGrammar() Grammar {
return Grammar{
Name: "task-capture",
Pattern: regexp.MustCompile(`(?s)^\s*(.+)$`),
Build: func(m []string) (Decision, bool) {
c, ok := ParseTaskCapture(m[1])
if !ok {
return Decision{}, false // not a capture — fall through
}
return Decision{
Stage: 0,
Intent: IntentNote,
Confidence: 1.0,
// The capture text, not the raw utterance: it is what the
// clarify gate reads as the payload. captureTaskFromNote
// re-parses the utterance itself, so the task text comes from
// the same place either way.
Slots: Slots{Text: c.Text},
}, true
},
}
}
-3
View File
@@ -27,9 +27,6 @@
"добавь в список",
"добавь задачу",
"запиши в задачи",
"запиши в список дел",
"запиши в список задач",
"запиши в список",
"запиши задачу",
"новая задача",
"поставь задачу",
-34
View File
@@ -85,37 +85,3 @@ func TestIsTaskListQuery(t *testing.T) {
}
}
}
// TestTaskCaptureGrammarClaimsTheMarker — the capture marker is answered at
// stage 0, so the model never gets to call it an act (Vikunja #467).
func TestTaskCaptureGrammarClaimsTheMarker(t *testing.T) {
g := TaskCaptureGrammar()
captures := map[string]string{
"добавь в задачи купить молоко": "купить молоко",
"запиши в список дел купить хлеб": "купить хлеб",
"поставь задачу вынести мусор": "вынести мусор",
"добавь в задачи срочно оплатить дом": "оплатить дом",
}
for in, want := range captures {
m := g.Pattern.FindStringSubmatch(in)
if m == nil {
t.Fatalf("%q did not match the grammar pattern", in)
}
d, ok := g.Build(m)
if !ok {
t.Fatalf("%q must be claimed as a capture", in)
}
if d.Intent != IntentNote || d.Slots.Text != want {
t.Errorf("%q → intent=%s text=%q, want note/%q", in, d.Intent, d.Slots.Text, want)
}
}
// Everything without a marker falls through, including a marker with no
// task after it and a question about the list.
for _, in := range []string{"надо бы поспать", "добавь в задачи", "какие у меня задачи?", "перезапусти nginx"} {
if m := g.Pattern.FindStringSubmatch(in); m != nil {
if _, ok := g.Build(m); ok {
t.Errorf("%q must fall through to the cascade", in)
}
}
}
}
-65
View File
@@ -87,68 +87,3 @@ func (s *Store) ReconcileStaleDeliveryAttempts(ctx context.Context, now time.Tim
}
return int(n), nil
}
// DeliveryAttempt — one row of the outbox, as a reader sees it.
type DeliveryAttempt struct {
ID int64
Kind string // nudge|reminder
Rule string // set for nudges
ReminderID int64 // set for reminders
Channel string
Status string // one of the Delivery* constants
Created time.Time
Completed time.Time // zero while pending
HasComplete bool
}
// ListDeliveryAttempts returns recent attempts, newest first. An empty status
// means every status; anything else filters on it.
//
// The table was write-only until 04-08-2026: rows were recorded and nothing
// could read them, so the tests for #368 and #370 had to reach past the store
// into store.DB, which is the tell (Vikunja #390). A durable record nobody can
// read answers no question, and "why did Maven go quiet" is supposed to be a
// query rather than a mystery.
//
// Status is the filter that earns its place, because the two questions actually
// asked are "what got dropped" and "what is still pending". Neither is
// answerable by reading the whole list on a busy day.
func (s *Store) ListDeliveryAttempts(ctx context.Context, status string, limit int) ([]DeliveryAttempt, error) {
if limit <= 0 {
limit = 50
}
q := `SELECT id, kind, rule, reminder_id, channel, status, created_ts, completed_ts
FROM delivery_attempts`
args := []any{}
if status != "" {
q += ` WHERE status = ?`
args = append(args, status)
}
q += ` ORDER BY created_ts DESC, id DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("list delivery attempts: %w", err)
}
defer rows.Close()
var out []DeliveryAttempt
for rows.Next() {
var a DeliveryAttempt
var created int64
var completed *int64
if err := rows.Scan(&a.ID, &a.Kind, &a.Rule, &a.ReminderID, &a.Channel, &a.Status, &created, &completed); err != nil {
return nil, fmt.Errorf("list delivery attempts: scan: %w", err)
}
a.Created = time.UnixMilli(created)
if completed != nil {
a.Completed, a.HasComplete = time.UnixMilli(*completed), true
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list delivery attempts: %w", err)
}
return out, nil
}
-50
View File
@@ -32,53 +32,3 @@ func TestDroppedDeliveryAttemptRoundTrips(t *testing.T) {
t.Fatalf("status: want %q, got %q", DeliveryDropped, status)
}
}
// TestListDeliveryAttempts — the read path the outbox lacked until #390. The
// two questions it must answer are "what was dropped" and "what is pending".
func TestListDeliveryAttempts(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
base := time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC)
sent, err := s.BeginDeliveryAttempt(ctx, "nudge", "water", 0, "telegram", "h1", base)
if err != nil {
t.Fatal(err)
}
if err := s.CompleteDeliveryAttempt(ctx, sent, DeliverySent, base.Add(time.Second)); err != nil {
t.Fatal(err)
}
dropped, err := s.BeginDeliveryAttempt(ctx, "nudge", "care", 0, "telegram", "h2", base.Add(time.Minute))
if err != nil {
t.Fatal(err)
}
if err := s.CompleteDeliveryAttempt(ctx, dropped, DeliveryDropped, base.Add(time.Minute)); err != nil {
t.Fatal(err)
}
if _, err := s.BeginDeliveryAttempt(ctx, "reminder", "", 7, "voice", "h3", base.Add(2*time.Minute)); err != nil {
t.Fatal(err)
}
all, err := s.ListDeliveryAttempts(ctx, "", 10)
if err != nil || len(all) != 3 {
t.Fatalf("ListDeliveryAttempts = %d rows, err=%v, want 3", len(all), err)
}
// Newest first.
if all[0].Kind != "reminder" || all[0].ReminderID != 7 {
t.Fatalf("newest row is %+v, want the reminder", all[0])
}
if all[0].HasComplete {
t.Fatalf("a pending row must have no completion time: %+v", all[0])
}
if !all[2].HasComplete || !all[2].Completed.Equal(base.Add(time.Second)) {
t.Fatalf("completed row lost its time: %+v", all[2])
}
only, err := s.ListDeliveryAttempts(ctx, DeliveryDropped, 10)
if err != nil || len(only) != 1 || only[0].Rule != "care" {
t.Fatalf("dropped filter = %+v, err=%v", only, err)
}
pending, err := s.ListDeliveryAttempts(ctx, DeliveryPending, 10)
if err != nil || len(pending) != 1 || pending[0].Kind != "reminder" {
t.Fatalf("pending filter = %+v, err=%v", pending, err)
}
}
-35
View File
@@ -254,41 +254,6 @@ func (s *Store) LatestFactBySource(ctx context.Context, key, source string) (Fac
return scanFact(row)
}
// LatestFactsByPrefix — the latest non-voided fact for every key that starts
// with prefix, newest-per-key, ordered by key.
//
// The loop's gatherer loads the keys its rules declare, which works while the
// key set is static. Kuma's monitors are not: one fact per monitor means the
// keys are only known once the gauge is read, so the rule declares the prefix
// and this read resolves it per tick. `_` and `%` are escaped — a monitor name
// is user text and must not act as a LIKE wildcard.
func (s *Store) LatestFactsByPrefix(ctx context.Context, prefix string) ([]Fact, error) {
esc := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(prefix)
rows, err := s.db.QueryContext(ctx, `
SELECT id, ts, kind, key, value, source, confidence, voids_id
FROM facts f
WHERE key LIKE ? ESCAPE '\'
AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
AND id = (SELECT id FROM facts g
WHERE g.key = f.key
AND g.id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
ORDER BY g.ts DESC, g.id DESC LIMIT 1)
ORDER BY key`, esc+"%")
if err != nil {
return nil, fmt.Errorf("facts by prefix %q: %w", prefix, err)
}
defer rows.Close()
var out []Fact
for rows.Next() {
f, err := scanFact(rows)
if err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
// Since returns how long ago the latest non-voided fact for key landed, or
// (0, ErrNoFact). Implements the `since(key)==null → don't fire` guard from
// the spec — silence on no-data is "shuts up when uncertain".
-62
View File
@@ -1,62 +0,0 @@
package store
import (
"context"
"testing"
"time"
)
// One fact per kuma monitor means the loop cannot name its keys at wiring time,
// so it asks for the family by prefix. The read must return the newest row per
// key and stop at the prefix boundary.
func TestLatestFactsByPrefix(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Millisecond)
write := func(key, val string, at time.Time) int64 {
id, err := s.SetValue(ctx, KindEnv, key, "poll:uptimekuma", val, at)
if err != nil {
t.Fatalf("SetValue %s: %v", key, err)
}
return id
}
write("service_down:db", "up", now.Add(-2*time.Hour))
write("service_down:db", "down", now.Add(-time.Hour)) // newer wins
write("service_down:web", "up", now.Add(-time.Hour))
write("service_downtime", "irrelevant", now) // no colon, not in the family
write("water", "250ml", now)
got, err := s.LatestFactsByPrefix(ctx, "service_down:")
if err != nil {
t.Fatalf("LatestFactsByPrefix: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d facts, want 2: %+v", len(got), got)
}
if got[0].Key != "service_down:db" || got[0].Value != `"down"` {
t.Errorf("first = %s=%s, want the newest db row", got[0].Key, got[0].Value)
}
if got[1].Key != "service_down:web" {
t.Errorf("second = %s, want service_down:web", got[1].Key)
}
}
// A monitor name is user text. An underscore in it must match itself, not act
// as a LIKE wildcard and drag in every other monitor.
func TestLatestFactsByPrefixEscapesWildcards(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Millisecond)
for _, k := range []string{"a_b:one", "axb:two"} {
if _, err := s.SetValue(ctx, KindEnv, k, "poll:uptimekuma", "down", now); err != nil {
t.Fatalf("SetValue %s: %v", k, err)
}
}
got, err := s.LatestFactsByPrefix(ctx, "a_b:")
if err != nil {
t.Fatalf("LatestFactsByPrefix: %v", err)
}
if len(got) != 1 || got[0].Key != "a_b:one" {
t.Fatalf("got %+v, want only a_b:one", got)
}
}
+194
View File
@@ -0,0 +1,194 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
// List items — the fourth append-only shape (Vikunja #453).
//
// A list is a standing set of short strings under a tag: покупки, аптека,
// хозяйство. It is not work and it is not a claim about the world, which is
// why it is neither a task nor a fact. Nothing here is prioritised, nothing
// nudges about it, and the digestion worker does not read it. The only two
// things a list does are grow and shrink.
//
// The consequence that made it worth a table: because no predicate touches a
// list item, several people adding to the same list at once cost nothing. There
// is no ranking to disagree about and no lifecycle beyond crossed-off.
const (
// ListItemOpen — on the list.
ListItemOpen = "open"
// ListItemDone — bought, taken, crossed off.
ListItemDone = "done"
// ListItemDropped — removed without being got.
ListItemDropped = "dropped"
)
// DefaultList — the list a capture lands on when he names none. Almost every
// spoken list item is groceries, and asking "в какой список?" for the common
// case would be a nag.
const DefaultList = "покупки"
// ListItem — one line on one list.
type ListItem struct {
ID int64
CreatedTs time.Time
List string
Item string
Source string
Status string
ResolvedTs *time.Time
}
var (
ErrListItemNotFound = errors.New("store: list item not found")
ErrListItemEmpty = errors.New("store: list item is empty")
ErrListItemStatus = errors.New("store: invalid list item status")
)
// NormalizeListName folds a list tag to its dedupe form. Lists are named out
// loud, so "Покупки" and "покупки " are the same list.
func NormalizeListName(s string) string {
n := NormalizeTaskText(s)
if n == "" {
return DefaultList
}
return n
}
// AddListItem puts an item on a list, or returns the existing row when the same
// item is already on it. Created says which happened, so the caller can say
// "уже есть" instead of pretending it wrote something.
func (s *Store) AddListItem(ctx context.Context, li ListItem) (CaptureResult, error) {
item := strings.TrimSpace(li.Item)
if item == "" {
return CaptureResult{}, ErrListItemEmpty
}
list := NormalizeListName(li.List)
norm := NormalizeTaskText(item)
created := li.CreatedTs
if created.IsZero() {
created = time.Now()
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO list_items (created_ts, list, item, norm, source, status)
VALUES (?,?,?,?,?,?)
ON CONFLICT DO NOTHING`,
created.UnixMilli(), list, item, norm, li.Source, ListItemOpen)
if err != nil {
return CaptureResult{}, fmt.Errorf("add list item: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return CaptureResult{}, fmt.Errorf("add list item: rows affected: %w", err)
}
if n > 0 {
id, err := res.LastInsertId()
if err != nil {
return CaptureResult{}, fmt.Errorf("add list item: last insert id: %w", err)
}
return CaptureResult{ID: id, Created: true}, nil
}
var id int64
err = s.db.QueryRowContext(ctx,
`SELECT id FROM list_items WHERE list = ? AND norm = ? AND status = ?`,
list, norm, ListItemOpen).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return CaptureResult{}, ErrListItemNotFound
}
if err != nil {
return CaptureResult{}, fmt.Errorf("add list item: lookup: %w", err)
}
return CaptureResult{ID: id}, nil
}
// ListItems reads one list in the order it was added. An empty status reads the
// open items, which is what reading the list aloud means.
func (s *Store) ListItems(ctx context.Context, list, status string) ([]ListItem, error) {
if status == "" {
status = ListItemOpen
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, created_ts, list, item, source, status, resolved_ts
FROM list_items WHERE list = ? AND status = ?
ORDER BY created_ts, id`,
NormalizeListName(list), status)
if err != nil {
return nil, fmt.Errorf("list items: %w", err)
}
defer rows.Close()
var out []ListItem
for rows.Next() {
var (
li ListItem
created int64
resolved sql.NullInt64
)
if err := rows.Scan(&li.ID, &created, &li.List, &li.Item, &li.Source, &li.Status, &resolved); err != nil {
return nil, fmt.Errorf("list items: scan: %w", err)
}
li.CreatedTs = time.UnixMilli(created)
if resolved.Valid {
t := time.UnixMilli(resolved.Int64)
li.ResolvedTs = &t
}
out = append(out, li)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list items: %w", err)
}
return out, nil
}
// SetListItemStatus crosses an item off, or removes it. Moving an item that is
// already resolved is not an error — crossing off twice is the same list.
func (s *Store) SetListItemStatus(ctx context.Context, id int64, status string, at time.Time) error {
if status != ListItemOpen && status != ListItemDone && status != ListItemDropped {
return fmt.Errorf("%w: %q", ErrListItemStatus, status)
}
var resolved sql.NullInt64
if status != ListItemOpen {
if at.IsZero() {
at = time.Now()
}
resolved = sql.NullInt64{Int64: at.UnixMilli(), Valid: true}
}
res, err := s.db.ExecContext(ctx,
`UPDATE list_items SET status = ?, resolved_ts = ? WHERE id = ?`,
status, resolved, id)
if err != nil {
return fmt.Errorf("set list item status: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("set list item status: rows affected: %w", err)
}
if n == 0 {
return ErrListItemNotFound
}
return nil
}
// ClearList crosses off every open item on a list and reports how many. This is
// "всё купил", which is one sentence and must not become one turn per item.
func (s *Store) ClearList(ctx context.Context, list string, at time.Time) (int, error) {
if at.IsZero() {
at = time.Now()
}
res, err := s.db.ExecContext(ctx,
`UPDATE list_items SET status = ?, resolved_ts = ? WHERE list = ? AND status = ?`,
ListItemDone, at.UnixMilli(), NormalizeListName(list), ListItemOpen)
if err != nil {
return 0, fmt.Errorf("clear list: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("clear list: rows affected: %w", err)
}
return int(n), nil
}
+149
View File
@@ -0,0 +1,149 @@
package store
import (
"context"
"errors"
"testing"
"time"
)
var listNow = time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
func TestAddListItemDedupesTheOpenList(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
first, err := s.AddListItem(ctx, ListItem{Item: "молоко", Source: "tap:voice", CreatedTs: listNow})
if err != nil {
t.Fatalf("add: %v", err)
}
if !first.Created {
t.Fatal("the first молоко did not create a row")
}
again, err := s.AddListItem(ctx, ListItem{Item: " Молоко ", Source: "tap:voice", CreatedTs: listNow})
if err != nil {
t.Fatalf("add again: %v", err)
}
if again.Created {
t.Error("молоко was added twice")
}
if again.ID != first.ID {
t.Errorf("second add points at %d; want the existing %d", again.ID, first.ID)
}
if _, err := s.AddListItem(ctx, ListItem{Item: " "}); !errors.Is(err, ErrListItemEmpty) {
t.Errorf("empty item: %v; want ErrListItemEmpty", err)
}
}
// A crossed-off item does not block the next one: buying milk again next week
// is a new line, the way saying an errand again is a new task.
func TestCrossedOffItemComesBack(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
first, err := s.AddListItem(ctx, ListItem{Item: "молоко", CreatedTs: listNow})
if err != nil {
t.Fatalf("add: %v", err)
}
if err := s.SetListItemStatus(ctx, first.ID, ListItemDone, listNow); err != nil {
t.Fatalf("cross off: %v", err)
}
next, err := s.AddListItem(ctx, ListItem{Item: "молоко", CreatedTs: listNow.Add(time.Hour)})
if err != nil {
t.Fatalf("add after: %v", err)
}
if !next.Created || next.ID == first.ID {
t.Errorf("second молоко reused row %d; want a new one", next.ID)
}
open, err := s.ListItems(ctx, "", "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(open) != 1 || open[0].ID != next.ID {
t.Errorf("open list %+v; want only the new row", open)
}
}
// Lists are separate stores under one table: the same word on two lists is two
// items, and reading one never reads the other.
func TestListsDoNotSeeEachOther(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
if _, err := s.AddListItem(ctx, ListItem{List: "покупки", Item: "вода", CreatedTs: listNow}); err != nil {
t.Fatalf("add: %v", err)
}
if _, err := s.AddListItem(ctx, ListItem{List: "Аптека", Item: "вода", CreatedTs: listNow}); err != nil {
t.Fatalf("add: %v", err)
}
for _, c := range []struct{ list, want string }{
{"покупки", "покупки"},
{"аптека", "аптека"},
{"", "покупки"},
} {
got, err := s.ListItems(ctx, c.list, "")
if err != nil {
t.Fatalf("list %q: %v", c.list, err)
}
if len(got) != 1 {
t.Fatalf("list %q has %d items; want 1", c.list, len(got))
}
if got[0].List != c.want {
t.Errorf("list %q returned tag %q; want %q", c.list, got[0].List, c.want)
}
}
}
func TestClearListCrossesOffEverythingOpen(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
for _, item := range []string{"молоко", "хлеб", "яйца"} {
if _, err := s.AddListItem(ctx, ListItem{Item: item, CreatedTs: listNow}); err != nil {
t.Fatalf("add %s: %v", item, err)
}
}
if _, err := s.AddListItem(ctx, ListItem{List: "аптека", Item: "бинт", CreatedTs: listNow}); err != nil {
t.Fatalf("add: %v", err)
}
n, err := s.ClearList(ctx, "покупки", listNow)
if err != nil {
t.Fatalf("clear: %v", err)
}
if n != 3 {
t.Errorf("cleared %d; want 3", n)
}
left, err := s.ListItems(ctx, "покупки", "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(left) != 0 {
t.Errorf("%d items still open; want none", len(left))
}
done, err := s.ListItems(ctx, "покупки", ListItemDone)
if err != nil {
t.Fatalf("list done: %v", err)
}
if len(done) != 3 || done[0].ResolvedTs == nil {
t.Errorf("done list %+v; want 3 rows carrying a resolved time", done)
}
other, err := s.ListItems(ctx, "аптека", "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(other) != 1 {
t.Error("clearing покупки touched аптека")
}
}
func TestSetListItemStatusRejectsWhatIsNotAStatus(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
if err := s.SetListItemStatus(ctx, 1, "куплено", listNow); !errors.Is(err, ErrListItemStatus) {
t.Errorf("bad status: %v; want ErrListItemStatus", err)
}
if err := s.SetListItemStatus(ctx, 999, ListItemDone, listNow); !errors.Is(err, ErrListItemNotFound) {
t.Errorf("missing row: %v; want ErrListItemNotFound", err)
}
}
+20 -28
View File
@@ -219,35 +219,27 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
// event, and the old rows would otherwise be recited as extra meetings.
// The filter is exact — it keeps any key whose summary part still has a
// letter or a digit in it.
// #19 — unstick the routines accepted before the fire-forever fix
// (Vikunja #377, follow-up to #366). Accepting used to leave accepted_ts
// NULL and a live one-shot reminder behind, and the tick loop skips a row
// with no accepted_ts, so every non-weekly routine accepted before that fix
// has been silent ever since.
`CREATE TABLE IF NOT EXISTS list_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_ts INTEGER NOT NULL,
list TEXT NOT NULL,
item TEXT NOT NULL,
norm TEXT NOT NULL,
source TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','done','dropped')),
resolved_ts INTEGER
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_list_items_live ON list_items (list, norm) WHERE status = 'open';
CREATE INDEX IF NOT EXISTS idx_list_items_list ON list_items (list, status, created_ts);`,
// #19 — standing lists (Vikunja #453). The fourth append-only shape, after
// facts, notes and tasks, and the reason it is its own table rather than a
// tag on tasks: milk on the shopping list is not work. Nothing prioritises
// it, nothing nudges about it, and the prioritiser must not start counting
// groceries as outstanding errands.
//
// Three statements, in this order, per stuck row: adopt created_ts as the
// acceptance time, cancel the reminder that is still holding the schedule,
// then let go of it. Cancelling before clearing matters — clearing first
// loses the only pointer to the reminder and leaves it to fire on its own.
//
// created_ts rather than a fresh timestamp because a migration has no
// clock, and because the first interval should be measured from when he
// said yes. A routine whose interval has already elapsed nudges on the next
// tick, which is what being unstuck looks like.
//
// Weekly rows are included deliberately. Theirs was the case that kept
// working, because the cron reminder reschedules itself — so leaving them
// alone would give them both a cron reminder and a tick-loop schedule for
// one habit, and he would hear it twice.
`UPDATE reminders
SET status = 'cancelled'
WHERE status = 'pending'
AND id IN (SELECT reminder_id FROM proposed_routines
WHERE status = 'accepted' AND accepted_ts IS NULL AND reminder_id IS NOT NULL);
UPDATE proposed_routines
SET accepted_ts = created_ts, reminder_id = NULL
WHERE status = 'accepted' AND accepted_ts IS NULL;`,
// The live-only unique index is the tasks one, per list: saying "молоко"
// twice before the shop keeps one row, saying it again next week after the
// last one was crossed off writes a new one.
}
// migrate applies every migration with a number greater than the DB's current
-67
View File
@@ -3,7 +3,6 @@ package store
import (
"context"
"testing"
"time"
)
func userVersion(t *testing.T, s *Store) int {
@@ -81,69 +80,3 @@ func TestCollapsedCalendarKeysAreDropped(t *testing.T) {
t.Fatalf("%d calendar rows left, want the 2 that identify their event", got)
}
}
// TestStuckRoutinesAreBackfilled — routines accepted before the fire-forever
// fix have accepted_ts NULL and a live reminder, so the tick loop skips them
// and they have been silent ever since (Vikunja #377). The migration touches
// live reminders, which is why it is tested against a real store.
func TestStuckRoutinesAreBackfilled(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
created := time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC)
rem, err := s.CreateReminder(ctx, created.Add(time.Hour), "полить цветы", "")
if err != nil {
t.Fatal(err)
}
healthy, err := s.CreateReminder(ctx, created.Add(2*time.Hour), "не трогать", "")
if err != nil {
t.Fatal(err)
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts, reminder_id, accepted_ts)
VALUES ('water', 'plants', 7, 'accepted', ?, ?, NULL)`,
created.UnixMilli(), rem); err != nil {
t.Fatal(err)
}
// An already-healthy accepted row, and a still-open proposal: neither is
// this migration's business.
if _, err := s.db.ExecContext(ctx,
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts, accepted_ts)
VALUES ('feed', 'cat', 1, 'accepted', ?, ?)`,
created.UnixMilli(), created.UnixMilli()); err != nil {
t.Fatal(err)
}
if _, err := s.db.ExecContext(ctx, migrations[18]); err != nil {
t.Fatalf("migration 19: %v", err)
}
accepted, err := s.ListAcceptedRoutines(ctx)
if err != nil || len(accepted) != 2 {
t.Fatalf("ListAcceptedRoutines = %d rows, err=%v, want 2", len(accepted), err)
}
stuck := accepted[0]
if stuck.Object != "plants" {
stuck = accepted[1]
}
if stuck.AcceptedTs == nil || !stuck.AcceptedTs.Equal(created) {
t.Fatalf("accepted_ts = %v, want the creation time", stuck.AcceptedTs)
}
if stuck.ReminderID != nil {
t.Fatalf("reminder_id = %v, want it let go", stuck.ReminderID)
}
// The reminder it was holding is cancelled, and nothing else is.
var status string
if err := s.db.QueryRowContext(ctx, `SELECT status FROM reminders WHERE id = ?`, rem).Scan(&status); err != nil {
t.Fatal(err)
}
if status != ReminderCancelled {
t.Fatalf("linked reminder status = %q, want cancelled", status)
}
if err := s.db.QueryRowContext(ctx, `SELECT status FROM reminders WHERE id = ?`, healthy).Scan(&status); err != nil {
t.Fatal(err)
}
if status != "pending" {
t.Fatalf("unrelated reminder status = %q, want it untouched", status)
}
}
+8 -47
View File
@@ -8,37 +8,14 @@ import (
"time"
)
// RoutineStatus — the state a proposal is in. A defined type, not a bare
// string, because the legal set used to live in a comment: nothing caught a
// typo at compile time, nothing enumerated the set for a test, and a bad value
// surfaced as a /routines row that neither accepts nor dismisses (Vikunja #46,
// #410).
type RoutineStatus string
// The three states a proposal can be in. A proposal starts proposed and moves
// once, either way, and never moves again.
// The three states a proposal can be in. A proposal starts 'proposed' and
// moves once, either way, and never moves again.
const (
RoutineProposed RoutineStatus = "proposed"
RoutineAccepted RoutineStatus = "accepted"
RoutineDismissed RoutineStatus = "dismissed"
RoutineProposed = "proposed"
RoutineAccepted = "accepted"
RoutineDismissed = "dismissed"
)
// RoutineStatuses — the legal set, and the single source of truth a test can
// range over. Adding a state means adding it here.
var RoutineStatuses = []RoutineStatus{RoutineProposed, RoutineAccepted, RoutineDismissed}
// Valid reports whether s is one of RoutineStatuses.
func (s RoutineStatus) Valid() bool {
for _, v := range RoutineStatuses {
if s == v {
return true
}
}
return false
}
func (s RoutineStatus) String() string { return string(s) }
// ProposedRoutine — a detected pattern the system wants to nudge about on a
// repeating interval. Status 'proposed' means awaiting human confirmation;
// 'accepted' means the human confirmed and the tick loop now owns the schedule;
@@ -53,7 +30,7 @@ type ProposedRoutine struct {
Action string
Object string
IntervalDays float64
Status RoutineStatus
Status string // proposed | accepted | dismissed
CreatedTs time.Time
ReminderID *int64
AcceptedTs *time.Time
@@ -63,7 +40,6 @@ type ProposedRoutine struct {
var (
ErrProposedRoutineNotFound = errors.New("store: proposed routine not found")
ErrProposedRoutineExists = errors.New("store: proposed routine already exists for this action+object")
ErrRoutineStatus = errors.New("store: unknown routine status")
)
// CreateProposedRoutine inserts a new proposed routine. Returns
@@ -75,16 +51,6 @@ var (
// keep finding the pattern, and every re-propose is refused here. Maven is not
// a nag.
//
// The object stays a local string. It is not resolved against Nexus and it
// carries no canonical entity ref (asked on the PR 4 review, decided here,
// Vikunja #410). Nexus owns identity for things the ecosystem acts on, and
// nothing acts on a routine object: it is the word he used, replayed back to
// him in a nudge, and compared only against itself for the UNIQUE key. Two
// spellings of the same watering can are two routines, and that is the right
// answer when the point is to say the sentence he would say. Canonical refs
// arrive here only if a routine ever drives a Hexis call, which is Vikunja
// #272, not this.
//
// Vikunja #43: this is called both from the voice fact-write path (for the
// immediate spoken confirmation) and from the digestion tick's proactive
// scan (cmd/mavend/tick.go's detectPatterns, via patterns.go's
@@ -136,13 +102,8 @@ func (s *Store) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, er
}
// ListProposedRoutinesByStatus returns routines in one status, newest first.
// An empty status returns every row; an unknown one is refused rather than
// silently answering with nothing, since a typo and a genuinely empty state
// read the same otherwise.
func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status RoutineStatus) ([]ProposedRoutine, error) {
if status != "" && !status.Valid() {
return nil, fmt.Errorf("%w: %q", ErrRoutineStatus, status)
}
// An empty status returns every row.
func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status string) ([]ProposedRoutine, error) {
q := `SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts
FROM proposed_routines`
var args []any
+1 -57
View File
@@ -235,7 +235,7 @@ func TestListProposedRoutinesByStatus(t *testing.T) {
}
cases := []struct {
status RoutineStatus
status string
want int
}{
{RoutineProposed, 0},
@@ -266,59 +266,3 @@ func TestLookupMissingProposedRoutine(t *testing.T) {
t.Fatal("want nil for missing routine")
}
}
// Every legal status must survive the database. The status column is written
// by three different UPDATE statements with the value spelled inline, so a
// constant that drifts from its SQL is exactly the failure this catches: the
// row would come back in a state no Go code compares equal to.
func TestRoutineStatusRoundTrip(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
now := time.Now().UTC()
write := map[RoutineStatus]func(id int64) error{
RoutineProposed: func(int64) error { return nil },
RoutineAccepted: func(id int64) error { return s.AcceptProposedRoutine(ctx, id, now) },
RoutineDismissed: func(id int64) error { return s.DismissProposedRoutine(ctx, id) },
}
for _, want := range RoutineStatuses {
if !want.Valid() {
t.Fatalf("%q is in RoutineStatuses but not Valid()", want)
}
object := "object-" + want.String()
id, err := s.CreateProposedRoutine(ctx, "полить", object, 3, now)
if err != nil {
t.Fatalf("CreateProposedRoutine(%s): %v", want, err)
}
if err := write[want](id); err != nil {
t.Fatalf("move to %s: %v", want, err)
}
got, err := s.LookupProposedRoutine(ctx, "полить", object)
if err != nil || got == nil {
t.Fatalf("LookupProposedRoutine(%s): %v", want, err)
}
if got.Status != want {
t.Errorf("status = %q, want %q", got.Status, want)
}
list, err := s.ListProposedRoutinesByStatus(ctx, want)
if err != nil {
t.Fatalf("ListProposedRoutinesByStatus(%s): %v", want, err)
}
if len(list) != 1 {
t.Errorf("status %s: listed %d rows, want 1", want, len(list))
}
}
}
// A typo used to read as "nothing is in that state", which is the same answer
// a correct query gives on an empty table.
func TestListByStatusRefusesAnUnknownStatus(t *testing.T) {
s := newTestStore(t)
if _, err := s.ListProposedRoutinesByStatus(context.Background(), "accpeted"); !errors.Is(err, ErrRoutineStatus) {
t.Fatalf("err = %v, want ErrRoutineStatus", err)
}
if RoutineStatus("accpeted").Valid() {
t.Error("a typo must not be Valid()")
}
}
+1 -53
View File
@@ -97,59 +97,7 @@ func (p *OpenMeteoProvider) CurrentWeather(ctx context.Context, location string)
}, nil
}
// locationCandidates — the spellings to try for a place taken out of a spoken
// sentence, in order. He says "какая погода в Казани", so the word arrives in
// the prepositional case and the geocoder wants the nominative (Vikunja #421).
//
// Two cheap reversals cover most of what he says: a final "е" is usually a
// nominative "а" (Москве → Москва) or nothing at all (Лондоне → Лондон), and a
// final "и" is usually a soft sign (Казани → Казань). Indeclinable names —
// Тбилиси, Сочи, Осло — are already nominative and the first candidate answers.
//
// Nothing here is a guess about the weather: a wrong candidate finds no city
// and the caller says so. It only decides which strings are worth asking about.
func locationCandidates(location string) []string {
out := []string{location}
add := func(s string) {
if s == "" || s == location {
return
}
for _, seen := range out {
if seen == s {
return
}
}
out = append(out, s)
}
r := []rune(location)
if len(r) < 4 {
return out
}
stem := string(r[:len(r)-1])
switch r[len(r)-1] {
case 'е', 'Е':
add(stem + "а")
add(stem)
case 'и', 'И':
add(stem + "ь")
add(stem)
case 'у', 'У', 'ю', 'Ю':
add(stem + "а")
}
return out
}
func (p *OpenMeteoProvider) geocode(ctx context.Context, location string) (lat, lon float64, name string, err error) {
for _, cand := range locationCandidates(location) {
lat, lon, name, err = p.geocodeOne(ctx, cand)
if err == nil {
return lat, lon, name, nil
}
}
return 0, 0, "", err
}
func (p *OpenMeteoProvider) geocodeOne(ctx context.Context, location string) (lat, lon float64, name string, err error) {
u := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=1&language=ru&format=json", url.QueryEscape(location))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
@@ -173,7 +121,7 @@ func (p *OpenMeteoProvider) geocodeOne(ctx context.Context, location string) (la
}
if len(geo.Results) == 0 {
return 0, 0, "", fmt.Errorf("%w: %q", ErrLocationUnknown, location)
return 0, 0, "", fmt.Errorf("location %q not found", location)
}
r := geo.Results[0]
-26
View File
@@ -83,29 +83,3 @@ func TestStubProvider(t *testing.T) {
t.Fatalf("StubProvider: want ErrNotConfigured, got %v", err)
}
}
// TestLocationCandidates — he speaks the prepositional case and the geocoder
// wants the nominative (Vikunja #421).
func TestLocationCandidates(t *testing.T) {
cases := map[string][]string{
"Москве": {"Москве", "Москва", "Москв"},
"Казани": {"Казани", "Казань", "Казан"},
"Лондоне": {"Лондоне", "Лондона", "Лондон"},
"Тбилиси": {"Тбилиси", "Тбились", "Тбилис"},
"Berlin": {"Berlin"},
"Уфе": {"Уфе"}, // too short to strip — asked as spoken
}
for in, want := range cases {
got := locationCandidates(in)
if len(got) != len(want) {
t.Errorf("locationCandidates(%q) = %v, want %v", in, got, want)
continue
}
for i := range got {
if got[i] != want[i] {
t.Errorf("locationCandidates(%q) = %v, want %v", in, got, want)
break
}
}
}
}
-7
View File
@@ -7,13 +7,6 @@ import (
var ErrNotConfigured = errors.New("weather: not configured")
// ErrLocationUnknown — the geocoder has no such place. A named city that does
// not resolve must read differently from a provider outage: one is "I do not
// know that place", the other is "I could not reach the service", and
// answering for the default location instead is the defect this replaces
// (Vikunja #421).
var ErrLocationUnknown = errors.New("weather: location not found")
type Weather struct {
Location string `json:"location"`
Temperature float64 `json:"temperature"`