From 888c1c67682d45c8da59206f8141ef5b2b0ca720 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 5 Aug 2026 15:28:26 +0400 Subject: [PATCH] the query source that claimed a turn is readable on /chat (V-539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V-539 said SearXNG claims every world question, including invented terms, so Kiwix is never reached. Measured today against the configured instance: seven of eight invented Russian questions now return zero results, and Response.Empty() already passes those to the ZIM. The premise moved with the upstream engine set in three days. The three quality signals the task named were recorded per query and none separate the sets. Token overlap is zero for the one bad claim and also zero for "столица Франции", whose answer is Париж. Empty snippets never fire, because ParseResponse already drops a hit with no text. SearXNG returned no corrections or suggestions even for the query it silently respelled. So no threshold is built: it would cost a real answer to save one invented word. What ships is the second half. The claiming query source crosses the IPC seam on ipc.ChatReply.Source and renders as a badge beside the reply on /chat. It rides the context rather than a return value, because handleText answers every reach through one string and the mic, telegram and the web all share it. Chat now returns ChatReply instead of a bare string. Full -race suite green. --- CLAUDE.md | 9 +++ cmd/mavend/actions_query.go | 4 +- cmd/mavend/querysource.go | 56 +++++++++++++ cmd/mavend/querysource_test.go | 35 +++++++++ cmd/mavend/tick_api.go | 11 ++- cmd/mavweb/chat.html | 2 +- cmd/mavweb/handlers_test.go | 45 +++++++++-- cmd/mavweb/main.go | 15 +++- .../2026-08-05-search-quality-signals.md | 78 +++++++++++++++++++ internal/auth/auth_test.go | 8 +- internal/ipc/api.go | 21 ++++- internal/ipc/client.go | 6 +- internal/ipc/coreapi.go | 2 +- internal/ipc/ipc_test.go | 19 +++-- internal/ipc/server.go | 2 +- internal/ipc/storeapi.go | 4 +- internal/ipc/unimplemented.go | 4 +- 17 files changed, 286 insertions(+), 35 deletions(-) create mode 100644 cmd/mavend/querysource.go create mode 100644 cmd/mavend/querysource_test.go create mode 100644 docs/evals/2026-08-05-search-quality-signals.md diff --git a/CLAUDE.md b/CLAUDE.md index 1a0dbda..90141a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -286,6 +286,15 @@ world questions, so she needs to read external sources. What replaces it: - **In the world, live search leads and the ZIMs are the fallback** (owner's call, 2026-08-02). A self-hosted SearXNG (`search` block) answers first; the Kiwix ZIMs on homesrv answer when the search is empty, unreachable, or the line is down. + `Response.Empty()` is the whole gate and there is no quality threshold in front of it: + the three signals one could read were measured on 2026-08-05 and none of them separate a + real question from an invented one. Token overlap would cost "столица Франции" its + answer, because the answer is Париж and that word is not in the question. See + `docs/evals/2026-08-05-search-quality-signals.md` (V-539). **Which query source claimed + a turn is readable on `/chat`** as a badge beside the reply, carried on + `ipc.ChatReply.Source` and noted by `noteQuerySource` in `cmd/mavend/querysource.go`. It + rides the context, so `handleText` keeps the one string signature the mic, telegram and + the web share. - **External search is allowed and off unless configured**, like the weather and telegram capabilities. The code default is still off. `deploy/mavend.json` now ships a `search` block (owner's call, 2026-08-02), so it is on for this box and deleting the block turns diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index e70dd16..d0a35fb 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -161,8 +161,10 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) // no query-source field, so a wrong answer could not be told from a // wrongly-ordered chain (Vikunja #474). Only the name is logged — // the utterance and the answer are already on the voice lines above - // and below this one. + // and below this one. The same name goes to the turn's sink when the + // caller asked for one, so /chat can show it (V-539). log.Printf("voice: query claimed by source %q", src.name) + noteQuerySource(ctx, src.name) return reply } } diff --git a/cmd/mavend/querysource.go b/cmd/mavend/querysource.go new file mode 100644 index 0000000..209a63e --- /dev/null +++ b/cmd/mavend/querysource.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "sync" +) + +// The query source that claimed a turn was visible in the daemon log and +// nowhere else (V-539). A QA step reading /chat could see a wrong answer but +// not tell a wrong answer from a wrongly ordered chain: "почему небо голубое" +// answered badly reads the same whether search claimed it, the ZIM did, or the +// resident model answered from memory. +// +// It rides the context rather than a return value because handleText answers +// every reach through one string, and threading a second value through the +// whole action dispatch would change a signature the mic, telegram and the web +// all share. The sink is per turn, created by the caller that wants to read it; +// a turn with no sink notes nothing, which is what the mic path does. +type querySourceKey struct{} + +// querySourceSink holds the name of the source that claimed one turn. The mutex +// is there because a query source may fan out to goroutines of its own, not +// because two turns share a sink. +type querySourceSink struct { + mu sync.Mutex + name string +} + +func (s *querySourceSink) note(name string) { + s.mu.Lock() + defer s.mu.Unlock() + s.name = name +} + +// Name is the source that claimed, or empty when nothing did or the turn was +// not a query at all. +func (s *querySourceSink) Name() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.name +} + +// withQuerySourceSink returns a context that collects the claiming source, and +// the sink to read after the turn has answered. +func withQuerySourceSink(ctx context.Context) (context.Context, *querySourceSink) { + sink := &querySourceSink{} + return context.WithValue(ctx, querySourceKey{}, sink), sink +} + +// noteQuerySource records which source claimed the turn. It is a no-op when the +// caller did not ask for one. +func noteQuerySource(ctx context.Context, name string) { + if sink, ok := ctx.Value(querySourceKey{}).(*querySourceSink); ok { + sink.note(name) + } +} diff --git a/cmd/mavend/querysource_test.go b/cmd/mavend/querysource_test.go new file mode 100644 index 0000000..2c46ca0 --- /dev/null +++ b/cmd/mavend/querysource_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "context" + "testing" +) + +func TestQuerySourceSinkCollectsTheClaimingName(t *testing.T) { + ctx, sink := withQuerySourceSink(context.Background()) + if sink.Name() != "" { + t.Fatalf("a fresh sink names a source: %q", sink.Name()) + } + noteQuerySource(ctx, "kiwix") + if got := sink.Name(); got != "kiwix" { + t.Errorf("sink.Name() = %q, want kiwix", got) + } +} + +// A turn with no sink must not panic. The mic path asks for no source, and a +// query source calls noteQuerySource unconditionally. +func TestNoteQuerySourceWithoutASinkIsSilent(t *testing.T) { + noteQuerySource(context.Background(), "search") +} + +// The last source to claim wins, because only one does: actionQuery returns on +// the first claim. This pins that the sink overwrites rather than appends, so a +// second turn on the same context could not read a stale name. +func TestQuerySourceSinkKeepsTheLastNote(t *testing.T) { + ctx, sink := withQuerySourceSink(context.Background()) + noteQuerySource(ctx, "search") + noteQuerySource(ctx, "kiwix") + if got := sink.Name(); got != "kiwix" { + t.Errorf("sink.Name() = %q, want kiwix", got) + } +} diff --git a/cmd/mavend/tick_api.go b/cmd/mavend/tick_api.go index ad733ba..c221912 100644 --- a/cmd/mavend/tick_api.go +++ b/cmd/mavend/tick_api.go @@ -41,11 +41,16 @@ func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent, return d.getEvents(n), nil } -func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (string, error) { +// Chat runs one text turn and reports which query source claimed it. The sink +// rides the context so handleText keeps the one string signature the mic, +// telegram and the web all call it through (V-539). +func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (ipc.ChatReply, error) { if d.chatFn == nil { - return "", errors.New("mavend: chat not available") + return ipc.ChatReply{}, errors.New("mavend: chat not available") } - return d.chatFn(ctx, conversation, text), nil + ctx, sink := withQuerySourceSink(ctx) + reply := d.chatFn(ctx, conversation, text) + return ipc.ChatReply{Reply: reply, Source: sink.Name()}, nil } // MCPServers — the configured MCP servers and their health (Vikunja #251). diff --git a/cmd/mavweb/chat.html b/cmd/mavweb/chat.html index b04f0d4..ac35c0e 100644 --- a/cmd/mavweb/chat.html +++ b/cmd/mavweb/chat.html @@ -4,7 +4,7 @@ {{if .Error}}
{{.Error}}
{{end}}
{{range .Messages}} -
{{if eq .Role "user"}}you{{else}}maven{{end}}: {{.Text}}
+
{{if eq .Role "user"}}you{{else}}maven{{end}}: {{.Text}}{{if .Source}} {{.Source}}{{end}}
{{else}}
diff --git a/cmd/mavweb/handlers_test.go b/cmd/mavweb/handlers_test.go index 45db011..53e6b96 100644 --- a/cmd/mavweb/handlers_test.go +++ b/cmd/mavweb/handlers_test.go @@ -67,8 +67,9 @@ type fakeCore struct { traceErr error // for handleChatAPI tests - chatText string - chatErr error + chatText string + chatSource string + chatErr error // for the MCP section of /tools mcpServers []ipc.MCPServerStatus @@ -79,12 +80,12 @@ 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) (ipc.ChatReply, error) { f.chatText = text if f.chatErr != nil { - return "", f.chatErr + return ipc.ChatReply{}, f.chatErr } - return "поняла", nil + return ipc.ChatReply{Reply: "поняла", Source: f.chatSource}, nil } func (f *fakeCore) EnableTool(_ context.Context, name string, cmd []string, destructive bool, scope string, _ time.Time) error { @@ -1285,3 +1286,37 @@ func TestHandleNotifications_ShowsTheOutbox(t *testing.T) { } } } + +// --- the query source badge (V-539) --- +// +// Which query source claimed a turn was readable in the daemon log and nowhere +// else, so a QA step could not tell a wrong answer from a wrongly ordered +// chain. It now rides the redirect and renders beside the reply. + +func TestHandleChatAPI_CarriesTheClaimingSource(t *testing.T) { + core := &fakeCore{chatSource: "kiwix"} + rr := httptest.NewRecorder() + handleChatAPI(rr, postChat("почему небо голубое"), core, nil, false) + loc := rr.Header().Get("Location") + if !strings.Contains(loc, "s=kiwix") { + t.Errorf("redirect = %q; want the claiming source in it", loc) + } +} + +func TestHandleChatAPI_OmitsTheSourceWhenNothingClaimed(t *testing.T) { + core := &fakeCore{} + rr := httptest.NewRecorder() + handleChatAPI(rr, postChat("запиши что я пил воду"), core, nil, false) + if loc := rr.Header().Get("Location"); strings.Contains(loc, "s=") { + t.Errorf("redirect = %q; a turn no source claimed carries no badge", loc) + } +} + +func TestChatPageRendersTheSourceBadge(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/chat?q=%D1%82%D0%B5%D1%81%D1%82&r=%D0%BE%D1%82%D0%B2%D0%B5%D1%82&s=search", nil) + rr := httptest.NewRecorder() + handleChatPage(rr, req, &fakeCore{}) + if body := rr.Body.String(); !strings.Contains(body, ">search") { + t.Errorf("chat page does not render the source badge; body=%s", body) + } +} diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index 57e4480..a36b70a 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -1627,8 +1627,8 @@ func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if q := r.URL.Query().Get("q"); q != "" { msgs = append(msgs, chatMsg{Role: "user", Text: q}) } - if r := r.URL.Query().Get("r"); r != "" { - msgs = append(msgs, chatMsg{Role: "assistant", Text: r}) + if reply := r.URL.Query().Get("r"); reply != "" { + msgs = append(msgs, chatMsg{Role: "assistant", Text: reply, Source: r.URL.Query().Get("s")}) } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := chatTmpl.Execute(w, struct { @@ -1677,13 +1677,22 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, ses http.Redirect(w, r, "/chat", http.StatusSeeOther) return } - http.Redirect(w, r, "/chat?q="+url.QueryEscape(text)+"&r="+url.QueryEscape(reply), http.StatusSeeOther) + // The claiming query source rides back on the redirect so the page can show + // it. Empty for a turn no source claimed, which is most of them. + dest := "/chat?q=" + url.QueryEscape(text) + "&r=" + url.QueryEscape(reply.Reply) + if reply.Source != "" { + dest += "&s=" + url.QueryEscape(reply.Source) + } + http.Redirect(w, r, dest, http.StatusSeeOther) } // chatMsg — one message in the conversation history. type chatMsg struct { Role string // "user" | "assistant" Text string + // Source — the query source that claimed the turn, shown as a badge beside + // the reply. Empty for a turn no source claimed (V-539). + Source string } func mustMarshal(v any) json.RawMessage { diff --git a/docs/evals/2026-08-05-search-quality-signals.md b/docs/evals/2026-08-05-search-quality-signals.md new file mode 100644 index 0000000..b65cd0b --- /dev/null +++ b/docs/evals/2026-08-05-search-quality-signals.md @@ -0,0 +1,78 @@ +# Does SearXNG claim a question it cannot answer? (V-539) + +Measured 2026-08-05 against the configured instance, `http://127.0.0.1:9563`, +`max_results: 4`, `language: auto`. Sixteen Russian questions: eight real, eight +invented from non-words. The probe read SearXNG's JSON directly, so this measures +the search, not the cascade around it. + +## The premise no longer reproduces + +V-539 was filed on the 2026-08-02 measurement, where SearXNG returned four +results for every query including `зыркабулентный флогистон Мшанского`, and no +`voice: kiwix:` line ever appeared. Today the same shape of query returns +nothing: + +| Query set | Zero results | Four results claimed | +|---|---|---| +| Eight real questions | 0 | 8 | +| Eight invented questions | 7 | 1 | + +`Response.Empty()` is already the gate. Seven of eight invented questions now +pass the turn to the ZIM with no code change at all. What changed is upstream. +Every real answer today comes from `google cse`. It answers a non-word with an +empty result set, where the engine set of three days ago answered with +something. + +## The one that still claims + +`трюмбальная нидроскопия` returned four results, all about a lumbar puncture: + +``` +Люмбальная пункция - адреса и стоимость в больницах в СПб +Пункция спинного мозга - Больница «Шиба +Педиатрический фантом люмбальной пункции новорожденного +``` + +The engine read the invented word as a misspelling of a real one and answered +the real one. That is the whole remaining failure, and it is a near-miss +spelling rather than a catch-all. + +## The three candidate signals do not separate the sets + +V-539 named three signals a quality gate could read. Each was recorded per +query: + +- **No result title shares a token with the query.** Useless. It is true of the + one bad claim, and also true of `столица Франции`, whose four titles are + `Париж`, `Франция`, `Париж — Путеводитель`, `Париж - Море Трэвел`. The right + answer to a capital-city question is the city, which is not a word in the + question. Two more real questions score 3 of 4 rather than 4. +- **Every snippet is empty.** Never fired. Zero empty snippets across all + sixteen queries, real or invented. `ParseResponse` already drops a hit with no + text, so this signal cannot fire by construction. +- **A spelling-suggestion or catch-all engine answered.** Never fired. SearXNG + returned no `corrections` and no `suggestions` for any query, including the one + that silently corrected the spelling itself. + +## Decision: do not build the threshold + +A gate on token overlap would cost `столица Франции` a correct answer to save +one invented word, and the other two signals cannot fire. The task said a wrong +threshold costs a real answer and needs measuring first. It was measured and it +loses. + +What ships instead is the second half of V-539. The claiming query source now +crosses the IPC seam on `ipc.ChatReply.Source`. It renders as a badge beside the +reply on `/chat`. The only evidence before it was a `voice:` log line, which is +why this was hard to judge. The next occurrence is readable off the UI rather +than off the box. + +## Not measured here + +- The cascade. This probe read SearXNG directly. It says nothing about how + `querySearch` phrases what it gets, or whether the resident model turns four + weak snippets into a confident wrong sentence. +- Kiwix. It was healthy on 2026-08-02 and was not re-probed today. +- English questions. The premise was about Russian, where the invented words are. +- Whether the engine set is stable. The whole finding is that it moved in three + days, so this table is a reading of one day. diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index f84b564..569339b 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -348,8 +348,8 @@ func TestGate_IpcServer_ChatAllowedForEnrolledCaller(t *testing.T) { if err != nil { t.Fatalf("Chat: %v", err) } - if reply != "echo: привет" { - t.Fatalf("Chat reply = %q; want %q", reply, "echo: привет") + if reply.Reply != "echo: привет" { + t.Fatalf("Chat reply = %q; want %q", reply.Reply, "echo: привет") } if fake.chats != 1 { t.Fatalf("CoreAPI.Chat calls = %d; want 1", fake.chats) @@ -373,9 +373,9 @@ 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) (ipc.ChatReply, error) { r.chats++ - return "echo: " + text, nil + return ipc.ChatReply{Reply: "echo: " + text}, nil } // mustWriteFactParams — minimal WriteFactReq JSON with only the source field, diff --git a/internal/ipc/api.go b/internal/ipc/api.go index 2726bd0..36ad4ac 100644 --- a/internal/ipc/api.go +++ b/internal/ipc/api.go @@ -214,9 +214,9 @@ type SeedEventReq struct { // failure. Proposed is true only when this seed completed a pattern; the first // three seeds of a run return false with no routine. type SeedEventResp struct { - FactID int64 `json:"fact_id"` - EventID int64 `json:"event_id,omitempty"` - Extracted bool `json:"extracted"` + FactID int64 `json:"fact_id"` + EventID int64 `json:"event_id,omitempty"` + Extracted bool `json:"extracted"` Action string `json:"action,omitempty"` Object string `json:"object,omitempty"` Proposed bool `json:"proposed"` @@ -664,7 +664,20 @@ type chatReq struct { Conversation string `json:"conversation,omitempty"` } type chatResp struct { - Reply string `json:"reply"` + Reply string `json:"reply"` + Source string `json:"source,omitempty"` +} + +// ChatReply — one text turn's answer plus which query source claimed it. +// +// Source is diagnostic and is empty unless the turn was a question a source +// claimed: a fact write, an act or a chat turn names none. It exists because +// the claiming source was readable only in the daemon log, so a QA step could +// not tell a wrong answer from a wrongly ordered chain (V-539). It is not +// authorization and nothing routes on it. +type ChatReply struct { + Reply string + Source string } type proposeToolReq struct { diff --git a/internal/ipc/client.go b/internal/ipc/client.go index 6029f84..1b813ad 100644 --- a/internal/ipc/client.go +++ b/internal/ipc/client.go @@ -640,12 +640,12 @@ 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, conversation, text string) (ChatReply, error) { var r chatResp if err := c.call(ctx, MethodChat, chatReq{Text: text, Conversation: conversation}, &r); err != nil { - return "", err + return ChatReply{}, err } - return r.Reply, nil + return ChatReply{Reply: r.Reply, Source: r.Source}, nil } func (c *Client) TickTrace(ctx context.Context) (TickTrace, error) { diff --git a/internal/ipc/coreapi.go b/internal/ipc/coreapi.go index 8cd65ab..1295fc8 100644 --- a/internal/ipc/coreapi.go +++ b/internal/ipc/coreapi.go @@ -176,7 +176,7 @@ type SystemAPI interface { // 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) + Chat(ctx context.Context, conversation, text string) (ChatReply, error) } // CoreAPI — what core exposes to modules. One Go interface, satisfied by: diff --git a/internal/ipc/ipc_test.go b/internal/ipc/ipc_test.go index 5b8ca2b..87b3c34 100644 --- a/internal/ipc/ipc_test.go +++ b/internal/ipc/ipc_test.go @@ -405,8 +405,17 @@ func TestChatViaClient(t *testing.T) { if err != nil { t.Fatalf("Chat: %v", err) } - if reply != "и тебе привет!" { - t.Fatalf("Chat = %q, want %q", reply, "и тебе привет!") + if reply.Reply != "и тебе привет!" { + t.Fatalf("Chat = %q, want %q", reply.Reply, "и тебе привет!") + } + + // The claiming query source crosses the wire beside the reply (V-539). + sourced, err := cli.Chat(context.Background(), "web", "почему небо голубое") + if err != nil { + t.Fatalf("Chat: %v", err) + } + if sourced.Source != "kiwix" { + t.Fatalf("Chat source = %q, want kiwix", sourced.Source) } } @@ -417,11 +426,11 @@ type chatTestAPI struct { UnimplementedCoreAPI } -func (a *chatTestAPI) Chat(ctx context.Context, _, text string) (string, error) { +func (a *chatTestAPI) Chat(ctx context.Context, _, text string) (ChatReply, error) { if text == "привет" { - return "и тебе привет!", nil + return ChatReply{Reply: "и тебе привет!"}, nil } - return "поговорили.", nil + return ChatReply{Reply: "поговорили.", Source: "kiwix"}, nil } // TestDispatch_UnknownMethod — an unknown method over the wire comes back as diff --git a/internal/ipc/server.go b/internal/ipc/server.go index bd6df2b..eb63b59 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -565,7 +565,7 @@ var methodTable = map[Method]handlerFunc{ }), MethodChat: withParams(func(ctx context.Context, api CoreAPI, p chatReq) (chatResp, error) { reply, err := api.Chat(ctx, p.Conversation, p.Text) - return chatResp{Reply: reply}, err + return chatResp{Reply: reply.Reply, Source: reply.Source}, err }), MethodTickTrace: withoutParams(func(ctx context.Context, api CoreAPI) (TickTrace, error) { return api.TickTrace(ctx) diff --git a/internal/ipc/storeapi.go b/internal/ipc/storeapi.go index ec99b7d..d44f126 100644 --- a/internal/ipc/storeapi.go +++ b/internal/ipc/storeapi.go @@ -257,8 +257,8 @@ func (a *storeAPI) RevertFact(ctx context.Context, key string) (int64, error) { 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) Chat(ctx context.Context, conversation, text string) (ChatReply, error) { + return ChatReply{}, errors.New("store: chat not available via direct store API") } func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) { diff --git a/internal/ipc/unimplemented.go b/internal/ipc/unimplemented.go index 4c245f2..8d4c1d9 100644 --- a/internal/ipc/unimplemented.go +++ b/internal/ipc/unimplemented.go @@ -147,6 +147,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) { - return "", ErrNotImplemented +func (UnimplementedCoreAPI) Chat(ctx context.Context, conversation, text string) (ChatReply, error) { + return ChatReply{}, ErrNotImplemented }