mavend: give the voice handler a CoreAPI that can serve the day plan
wireVoice runs before the tick loop exists, so it could only be handed the bare store adapter — and that adapter answers DayPlan 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 for every caller on the deployed daemon. main already back-patches the other direction (daemonAPI.chatFn = handler.handleText). This is the same seam in reverse, at both wiring sites. No recursion risk: nothing in the voice path calls api.Chat. With the plan reachable, it recited its reminders as literal JSON. The payload unwrapper existed but was private to the phraser, so the day plan had its own non-unwrapping copy. One owner now, store.ReminderText, with the phraser delegating to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
+30
-6
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user