diff --git a/cmd/mavend/dayplan_test.go b/cmd/mavend/dayplan_test.go index 2ae4e8a..0fd511c 100644 --- a/cmd/mavend/dayplan_test.go +++ b/cmd/mavend/dayplan_test.go @@ -351,3 +351,31 @@ func TestTickDayPlanReadsTheStore(t *testing.T) { t.Errorf("a reminder for next year is not today's plan: %q", plan.Spoken) } } + +// TestHandlerUpgradesToTheDaemonAPI — wireVoice runs before the tick loop +// exists, so the handler starts with the bare store adapter, and that adapter +// refuses DayPlan ("not available via direct store API"). main back-patches +// the real one in. Without the patch every "какие у меня планы на сегодня" +// answered "не получилось собрать план" on the deployed daemon, 01-08-2026. +func TestHandlerUpgradesToTheDaemonAPI(t *testing.T) { + h := &reactiveHandler{api: ipc.NewStoreAPI(nil), now: planDay} + if _, err := h.api.DayPlan(context.Background()); err == nil { + t.Fatal("the bare store adapter served a day plan; this test is measuring nothing") + } + + want := samplePlan() + h.upgradeAPI(&daemonAPI{ + CoreAPI: ipc.UnimplementedCoreAPI{}, + getDayPlan: func(context.Context) ipc.DayPlan { return want }, + }) + + reply, ok := h.queryDayPlan(context.Background(), &queryTurn{ + dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие у меня планы на сегодня?"}, + }) + if !ok { + t.Fatal("queryDayPlan passed on a plan question") + } + if reply != want.Spoken { + t.Fatalf("reply = %q, want the assembled plan", reply) + } +} diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index 52cc464..892330d 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -341,6 +341,9 @@ func run(args []string) error { if voiceW != nil && voiceW.handler != nil { api := coreAPI.(*daemonAPI) api.chatFn = voiceW.handler.handleText + // And the reverse: the handler was wired with the bare store + // adapter, which cannot serve the day plan. See upgradeAPI. + voiceW.handler.upgradeAPI(api) } if voiceW != nil && voiceW.mcp != nil { coreAPI.(*daemonAPI).getMCPServers = voiceW.mcp.status @@ -603,6 +606,7 @@ func run(args []string) error { } if voiceW != nil && voiceW.handler != nil { newAPI.chatFn = voiceW.handler.handleText + voiceW.handler.upgradeAPI(newAPI) } srv.SetAPI(newAPI) srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check diff --git a/cmd/mavend/tick.go b/cmd/mavend/tick.go index 7135d56..8926a47 100644 --- a/cmd/mavend/tick.go +++ b/cmd/mavend/tick.go @@ -882,7 +882,7 @@ func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan { } reminders = append(reminders, morning.PlanEntry{ At: fire, - Text: strings.TrimSpace(r.Payload), + Text: r.Text(), Kind: morning.PlanReminder, }) } diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index e449815..3e697d9 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -76,12 +76,15 @@ type reactiveHandler struct { tts tts.Synthesizer router *router.Router embedder router.Embedder // reused for note write/query (same model as the classifier) - api ipc.CoreAPI - tools *tool.Executor - matcher *tool.Matcher - phraser phraser.Phraser - replier voice.Replier - now func() time.Time + // api — the CoreAPI the handler reads and writes through. Wired with the + // bare store adapter and UPGRADED by main once the daemonAPI exists; see + // upgradeAPI. + api ipc.CoreAPI + tools *tool.Executor + matcher *tool.Matcher + phraser phraser.Phraser + replier voice.Replier + now func() time.Time // crawler reads a web page he names out loud (queryWeb). nil ⇒ on-demand // page reading is off, which is the default: no `crawl` block, no fetch. @@ -183,6 +186,27 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo return h.reply(ctx, replyText, nil) } +// upgradeAPI points the handler at the daemon's own CoreAPI once main has +// built it. +// +// Wiring order forces this. wireVoice runs before the tick loop exists, so it +// can only be handed the bare store adapter — and that adapter answers DayPlan +// (and TickTrace, and MorningStatus) with "not available via direct store +// API", because a day plan is assembled by the tick loop and is not a table to +// read. So queryDayPlan, which the query chain reaches for "какие у меня планы +// на сегодня", failed on the deployed daemon for every caller. main already +// back-patches the other direction (daemonAPI.chatFn = handler.handleText); +// this is the same seam in reverse. +// +// Safe against the obvious loop: nothing in the voice path calls api.Chat, so +// pointing the handler at an API whose Chat IS the handler cannot recurse. +func (h *reactiveHandler) upgradeAPI(api ipc.CoreAPI) { + if h == nil || api == nil { + return + } + h.api = api +} + // 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. diff --git a/internal/phraser/phraser.go b/internal/phraser/phraser.go index 09db1f8..a15f7bb 100644 --- a/internal/phraser/phraser.go +++ b/internal/phraser/phraser.go @@ -24,7 +24,6 @@ package phraser import ( "context" - "encoding/json" "fmt" "strings" "time" @@ -32,6 +31,7 @@ import ( "github.com/kami/maven/internal/delivery" "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/loop" + "github.com/kami/maven/internal/store" ) // Phraser — the seam the daemon wires. one method per delivery path (nudge @@ -161,22 +161,11 @@ func phraseNudge(c loop.Candidate) (body, summary string) { } } -// extractReminderText — the reminder payload is raw JSON; the router's -// reminder slot extraction owns the shape. the conventional field is "text". -// fall back to the raw payload if it isn't JSON or lacks the field — the user -// said it, it's the user's words. -func extractReminderText(payload string) string { - var m map[string]any - if err := json.Unmarshal([]byte(payload), &m); err == nil { - if t, ok := m["text"].(string); ok && t != "" { - return t - } - if t, ok := m["text"]; ok { - return fmt.Sprintf("%v", t) - } - } - return strings.TrimSpace(payload) -} +// extractReminderText — the reminder payload is raw JSON and store.ReminderText +// owns the unwrapping. It used to be a second copy of that logic here, which is +// how the day plan came to recite a reminder as its literal JSON: the copies +// were never going to be kept in step. +func extractReminderText(payload string) string { return store.ReminderText(payload) } // humanDur — round a duration to the coarsest sensible unit for speech. // "4h12m" → "4 hours"; "92m" → "1h32m" → "an hour and a half". keep it simple: diff --git a/internal/store/reminders.go b/internal/store/reminders.go index e3788c5..fefa7bb 100644 --- a/internal/store/reminders.go +++ b/internal/store/reminders.go @@ -3,8 +3,10 @@ package store import ( "context" "database/sql" + "encoding/json" "errors" "fmt" + "strings" "time" "github.com/robfig/cron/v3" @@ -26,6 +28,33 @@ type Reminder struct { Collapsed []Reminder } +// Text — what the user actually asked for, out of the raw-JSON payload. +// +// The router's reminder slot extraction owns the payload shape and the +// conventional field is "text". A payload that is not JSON, or that lacks the +// field, is returned as-is: he said it, so they are his words, and showing +// them beats showing nothing. +// +// Here rather than in a caller because there is more than one caller and they +// disagreed. The phraser unwrapped the payload; the day plan did not, so +// "какие у меня планы на сегодня" recited a reminder as the literal string +// {"text":"..."} on the deployed daemon, 01-08-2026. +func (r Reminder) Text() string { return ReminderText(r.Payload) } + +// ReminderText — Reminder.Text for callers holding a bare payload string. +func ReminderText(payload string) string { + var m map[string]any + if err := json.Unmarshal([]byte(payload), &m); err == nil { + if t, ok := m["text"].(string); ok && t != "" { + return t + } + if t, ok := m["text"]; ok { + return fmt.Sprintf("%v", t) + } + } + return strings.TrimSpace(payload) +} + // Reminder lifecycle states. Named for the same reason DigestStatus is: a // caller filtering on the string literal "pending" is one typo away from a // filter that silently matches nothing.