package ipc import ( "context" "encoding/json" "errors" "fmt" "net" "sync" "time" "github.com/kami/maven/internal/netaddr" ) // Client — the module side of the boundary. Wraps a connection to core and // satisfies CoreAPI, so a module imports ipc, holds a CoreAPI, and is // agnostic to whether it's been wired in-process (tests / daemon-embedded), // over a local unix socket, or over tcp to another host. The swappability is // the seam auth will insert into without touching module code. // // One Client ⇒ one conn ⇒ one concurrent request at a time. A module that // wants parallel requests opens one Client per goroutine; the store is the // bottleneck anyway (single writer), so pipelining buys nothing here and a // per-Client lock keeps frame interleaving impossible by construction. type Client struct { conn net.Conn path string // the address as configured, kept for errors and logs addr netaddr.Addr // parsed, so a dropped conn can be re-dialed (core restart) mu sync.Mutex // one request at a time, so a frame and its reply pair up // connMu guards the conn field alone, and is held only across an assignment // or a read. It exists so Close and the cancellation watchdog can reach the // connection without waiting for the call that is holding c.mu (V-638). connMu sync.Mutex } // defaultCallTimeout bounds a call whose context carries no deadline. It is // the same 120s internal/voice/client.go settles on: long enough for a model // call on a cold resident model, short enough that a daemon which stopped // answering does not park the caller forever. const defaultCallTimeout = 120 * time.Second // errWriteLost marks a conn drop while sending the request frame: the request // never reached the server (or the server never saw a complete frame), so // retrying is always safe regardless of method — nothing was applied to // retry twice. var errWriteLost = errors.New("ipc: connection lost before request sent") // errReadLost marks a conn drop while waiting for the reply: the request was // sent and may have already been applied server-side before the connection // died (core restart mid-request, crash after commit but before reply, etc). // Retrying here can double-apply a mutation, which ECOSYSTEM-SPEC.md's // "never retry an unknown outcome" invariant forbids. call() only auto-retries // this for read-only methods (idempotent by construction); a mutation method // returns ErrAmbiguousOutcome instead so the caller can decide — the outcome // genuinely is unknown, not safely retryable and not safely reported as failed. var errReadLost = errors.New("ipc: connection lost awaiting reply") // ErrAmbiguousOutcome is returned when a mutation's request may or may not // have been applied server-side (the connection dropped after the request was // sent, before the reply arrived). Callers must not blindly retry — the retry // itself could double-apply. Surface this to the user/operator rather than // silently treating it as either success or failure. var ErrAmbiguousOutcome = errors.New("ipc: mutation outcome unknown (connection lost awaiting reply)") // readOnlyMethods are safe to retry on an ambiguous (post-send) connection // loss: replaying a read cannot double-apply anything. Every method not // listed here is treated as a mutation for retry purposes — being // conservative (refusing to retry) is the safe default for a method added // here by omission. var readOnlyMethods = map[Method]bool{ MethodLatestFact: true, MethodLatestFactBySource: true, MethodSince: true, MethodPresence: true, MethodListReminders: true, MethodListPendingReminders: true, MethodRecentOutcomes: true, MethodRecentFacts: true, MethodRecentActiveFacts: true, MethodCalendarEvents: true, MethodRecentNudges: true, MethodDeliveryAttempts: true, MethodRecentEcoTraces: true, MethodQueryNotes: true, MethodRecentNotes: true, MethodLookupTool: true, MethodListTools: true, MethodListProposedRoutines: true, MethodListTasks: true, MethodTickTrace: true, MethodMorningStatus: true, MethodMCPServers: true, MethodDayPlan: true, MethodRecentEvents: true, MethodRecentNotesFromSource: true, MethodPing: true, } // Dial connects to core at path and returns a Client. The module owns its // Client lifecycle; Close on shutdown. // // path is a netaddr seam address: a bare path is the unix socket it has // always been, and "tcp://host:port?token=..." reaches a core on another // host. See internal/netaddr. func Dial(path string) (*Client, error) { addr, err := netaddr.Parse(path) if err != nil { return nil, err } c, err := netaddr.Dial(addr) if err != nil { return nil, fmt.Errorf("ipc: dial %s: %w", addr, err) } return &Client{conn: c, path: path, addr: addr}, nil } // Close closes the connection out from under a call in flight, on purpose: a // shutdown must not wait out a parked read. It takes connMu and never c.mu, so // it cannot block behind the call it is interrupting. // // The lock is taken and released by hand, around the two field accesses and // nothing else. The socket close happens outside it, because a close on a tcp // conn can block and connMu is on the path of every call. func (c *Client) Close() error { c.connMu.Lock() conn := c.conn c.conn = nil c.connMu.Unlock() if conn == nil { return nil } return conn.Close() } // DialWait is Dial with patience: it retries with capped backoff until the // socket is reachable or timeout elapses. Core loads models on boot and may // come up after its modules (compose depends_on orders container start, not // socket readiness), so a module that Dial'd once would crash-loop on a cold // start. Every core-dialing module should use this instead of Dial. Mid-life // core restarts are handled separately by the Client's own redial-on-drop. func DialWait(path string, timeout time.Duration) (*Client, error) { deadline := time.Now().Add(timeout) delay := 200 * time.Millisecond for { c, err := Dial(path) if err == nil { return c, nil } if time.Now().After(deadline) { return nil, err } time.Sleep(delay) if delay < 2*time.Second { delay *= 2 } } } // call — the single request/response engine. Serialized by c.mu so a frame // and its reply always pair up; no interleaving to disambiguate. A wire // RpcError is rehydrated into the matching package sentinel (errors.Is works // the same as the in-process path — the boundary is transparent to callers). func (c *Client) call(ctx context.Context, m Method, params, result any) error { c.mu.Lock() defer c.mu.Unlock() // Honor ctx cancellation by closing the conn — a half-sent frame would // desync the stream; tearing down is the clean recovery. A fresh Dial // is the module's responsibility on the next call (modules are long-lived // processes; a dropped conn is recoverable, not fatal). select { case <-ctx.Done(): c.drop() return ctx.Err() default: } var raw json.RawMessage if params != nil { b, err := json.Marshal(params) if err != nil { return fmt.Errorf("ipc: marshal params: %w", err) } raw = b } var resp Response err := c.roundtrip(ctx, m, raw, &resp) switch { case errors.Is(err, errWriteLost): // The request never left; a duplicate send can't double-apply. // Redial (roundtrip re-dials on a nil conn) and retry exactly once. // Not when the caller has given up — a retry would only be a second // frame nobody is waiting for. if ctx.Err() == nil { err = c.roundtrip(ctx, m, raw, &resp) } case errors.Is(err, errReadLost): if readOnlyMethods[m] { if ctx.Err() != nil { // The caller cancelled the read it was waiting for. Nothing // was applied, so this is the cancellation and not an // ambiguity. return ctx.Err() } // A duplicate read can't double-apply either — safe to replay. err = c.roundtrip(ctx, m, raw, &resp) } else { // The mutation may have already committed server-side. Do not // retry: report the ambiguity instead of guessing. return fmt.Errorf("%w: %v", ErrAmbiguousOutcome, err) } } if err != nil { return err } if resp.Error != nil { return hydrate(resp.Error) } if result == nil { return nil } // "null" body into a pointer is valid (sets the zero value); marshal a // RawMessage directly to avoid extra encode/decode churn. return json.Unmarshal(resp.Result, result) } // roundtrip sends one request and reads its reply on c.conn, lazily (re)dialing // if the conn is nil (fresh Client or a prior drop). A write-phase (or dial) // failure is wrapped in errWriteLost (always safe to retry); a read-phase // failure is wrapped in errReadLost (ambiguous — call() only retries it for // read-only methods). Either way a failed conn is dropped so the next call // re-dials clean. Caller holds c.mu. // // The connection carries a deadline derived from ctx, falling back to // defaultCallTimeout, and a watchdog closes it if ctx is cancelled mid-call // (V-638). Before that a daemon which stopped answering parked the caller for // as long as the socket stayed open. The watchdog closes the conn rather than // calling drop, because drop wants c.mu and the caller is holding it — the // closed socket fails the read, and roundtrip drops it on the way out. func (c *Client) roundtrip(ctx context.Context, m Method, raw json.RawMessage, resp *Response) error { conn := c.currentConn() if conn == nil { dialed, err := netaddr.Dial(c.addr) if err != nil { return fmt.Errorf("%w: dial %s: %v", errWriteLost, c.addr, err) } c.setConn(dialed) conn = dialed } if dl, ok := ctx.Deadline(); ok { _ = conn.SetDeadline(dl) } else { _ = conn.SetDeadline(time.Now().Add(defaultCallTimeout)) } defer conn.SetDeadline(time.Time{}) // The watchdog and the end of the call race by construction: a cancellation // landing just as the reply arrives can close a conn this call is already // done with, and c.conn would still point at the closed socket. So a call // whose context ended does not leave the conn behind for the next one, // whichever of the two got there first. done := make(chan struct{}) defer func() { close(done) if ctx.Err() != nil { c.drop() } }() go func() { select { case <-ctx.Done(): _ = conn.Close() case <-done: } }() if err := writeFrame(conn, Request{Method: m, Params: raw}); err != nil { c.drop() return fmt.Errorf("%w: %v", errWriteLost, err) } if err := readFrame(conn, resp); err != nil { c.drop() return fmt.Errorf("%w: %v", errReadLost, err) } return nil } // drop closes and forgets the current conn so the next call re-dials. func (c *Client) drop() { c.connMu.Lock() defer c.connMu.Unlock() if c.conn != nil { _ = c.conn.Close() c.conn = nil } } func (c *Client) currentConn() net.Conn { c.connMu.Lock() defer c.connMu.Unlock() return c.conn } func (c *Client) setConn(conn net.Conn) { c.connMu.Lock() defer c.connMu.Unlock() c.conn = conn } // hydrate rehydrates a wire RpcError into the matching package sentinel. The // code↔sentinel table is the only place the wire "knows" about errors; keep it // in sync with codeOf in wire.go. func hydrate(e *RpcError) error { switch e.Code { case codeNoFact: return fmt.Errorf("%w: %s", ErrNoFact, e.Message) case codeConfidence: return fmt.Errorf("%w: %s", ErrConfidence, e.Message) case codeVoidsMissing: return fmt.Errorf("%w: %s", ErrVoidsMissing, e.Message) case codeNudgeNotFound: return fmt.Errorf("%w: %s", ErrNudgeNotFound, e.Message) case codeNudgeOutcome: return fmt.Errorf("%w: %s", ErrNudgeOutcome, e.Message) case codeReminderMissing: return fmt.Errorf("%w: %s", ErrReminderNotFound, e.Message) case codeReminderState: return fmt.Errorf("%w: %s", ErrReminderState, e.Message) case codeReminderInFlight: return fmt.Errorf("%w: %s", ErrReminderInFlight, e.Message) case codeToolNotFound: return fmt.Errorf("%w: %s", ErrToolNotFound, e.Message) case codeNoSuchTrace: return fmt.Errorf("%w: %s", ErrNoSuchTrace, e.Message) case codeUnknownMethod: return fmt.Errorf("%w: %s", ErrUnknownMethod, e.Message) case codeBadParams: return fmt.Errorf("%w: %s", ErrBadParams, e.Message) case codeForbidden: return fmt.Errorf("%w: %s", ErrForbidden, e.Message) default: return errors.New(e.Error()) } } // CoreAPI implementation on *Client. Each method is a thin call() shim; the // shape mirrors the CoreAPI interface 1:1 so the embedded-doc intent (module // holds a CoreAPI, transport-agnostic) reads straight off the signatures. func (c *Client) WriteFact(ctx context.Context, req WriteFactReq) (int64, error) { var r idResp if err := c.call(ctx, MethodWriteFact, req, &r); err != nil { return 0, err } return r.ID, nil } func (c *Client) LatestFact(ctx context.Context, key string) (Fact, error) { var f Fact if err := c.call(ctx, MethodLatestFact, keyReq{Key: key}, &f); err != nil { return Fact{}, err } return f, nil } func (c *Client) LatestFactBySource(ctx context.Context, key, source string) (Fact, error) { var f Fact if err := c.call(ctx, MethodLatestFactBySource, keySourceReq{Key: key, Source: source}, &f); err != nil { return Fact{}, err } return f, nil } func (c *Client) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) { var r sinceResp if err := c.call(ctx, MethodSince, sinceReq{Key: key, Now: now}, &r); err != nil { return 0, err } return r.Dur, nil } func (c *Client) Presence(ctx context.Context) (Presence, error) { var p Presence if err := c.call(ctx, MethodPresence, nil, &p); err != nil { return Presence{}, err } return p, nil } func (c *Client) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) { var r idResp if err := c.call(ctx, MethodCreateReminder, createReminderReq{Fire: fire, Payload: payload, Cron: cron}, &r); err != nil { return 0, err } return r.ID, nil } func (c *Client) MarkReminder(ctx context.Context, id int64, status string) error { return c.call(ctx, MethodMarkReminder, markReminderReq{ID: id, Status: status}, nil) } func (c *Client) CancelReminder(ctx context.Context, id int64) error { return c.call(ctx, MethodCancelReminder, idReq{ID: id}, nil) } func (c *Client) ListReminders(ctx context.Context, n int) ([]Reminder, error) { var out []Reminder if err := c.call(ctx, MethodListReminders, nReq{N: n}, &out); err != nil { return nil, err } return out, nil } func (c *Client) ListPendingReminders(ctx context.Context, n int) ([]Reminder, error) { var out []Reminder if err := c.call(ctx, MethodListPendingReminders, nReq{N: n}, &out); err != nil { return nil, err } return out, nil } func (c *Client) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) { var r idResp if err := c.call(ctx, MethodRecordNudge, recordNudgeReq{Rule: rule, Channel: channel, Message: message, Ts: ts}, &r); err != nil { return 0, err } return r.ID, nil } func (c *Client) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error { return c.call(ctx, MethodResolveNudge, resolveNudgeReq{ID: id, Outcome: outcome, Ts: ts}, nil) } func (c *Client) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) { var out []string if err := c.call(ctx, MethodRecentOutcomes, outcomesReq{Rule: rule, N: n}, &out); err != nil { return nil, err } return out, nil } func (c *Client) RecentFacts(ctx context.Context, n int) ([]Fact, error) { var out []Fact if err := c.call(ctx, MethodRecentFacts, nReq{N: n}, &out); err != nil { return nil, err } return out, nil } func (c *Client) RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error) { var out []Fact if err := c.call(ctx, MethodRecentActiveFacts, kindNReq{Kind: kind, N: n}, &out); err != nil { return nil, err } return out, nil } func (c *Client) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) { var out []Fact if err := c.call(ctx, MethodCalendarEvents, calendarEventsReq{From: from, To: to}, &out); err != nil { return nil, err } return out, nil } func (c *Client) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error) { var out []EcosystemTrace if err := c.call(ctx, MethodRecentEcoTraces, nReq{N: n}, &out); err != nil { return nil, err } 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 { return nil, err } return out, nil } func (c *Client) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) { var r idResp if err := c.call(ctx, MethodWriteNote, writeNoteReq{Ts: ts, Text: text, Embedding: embedding, Source: source}, &r); err != nil { return 0, err } return r.ID, nil } func (c *Client) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) { var out []Note if err := c.call(ctx, MethodQueryNotes, queryNotesReq{Embedding: embedding, K: k}, &out); err != nil { return nil, err } return out, nil } func (c *Client) RecentNotes(ctx context.Context, n int) ([]Note, error) { var out []Note if err := c.call(ctx, MethodRecentNotes, nReq{N: n}, &out); err != nil { return nil, err } return out, nil } func (c *Client) RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error) { var out []Note if err := c.call(ctx, MethodRecentNotesFromSource, sourceNReq{Prefix: prefix, N: n}, &out); err != nil { return nil, err } return out, nil } func (c *Client) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) { var r proposeToolResp if err := c.call(ctx, MethodProposeTool, proposeToolReq{Name: name, Scope: scope, Utterance: utterance, Ts: ts}, &r); err != nil { return false, err } return r.Proposed, nil } func (c *Client) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error { return c.call(ctx, MethodEnableTool, enableToolReq{Name: name, Scope: scope, Cmd: cmd, Destructive: destructive, Ts: ts}, nil) } func (c *Client) DisableTool(ctx context.Context, name string) error { return c.call(ctx, MethodDisableTool, disableToolReq{Name: name}, nil) } func (c *Client) AssertStepUp(ctx context.Context) error { return c.call(ctx, MethodAssertStepUp, nil, nil) } // StoreEncryptionKey wraps the daemon's at-rest key under secret, the 32-byte // WebAuthn PRF output for the asserted credential. // // explicit marks an operator-requested write. False means "write it only if // there is nothing there yet": a blob already on disk is left alone, because // rewriting it on every assertion is how an attacker-chosen PRF value, or a // second authenticator, replaces the one thing that opens the database. func (c *Client) StoreEncryptionKey(ctx context.Context, secret []byte, explicit bool) error { return c.call(ctx, MethodStoreEncryptionKey, storeEncryptionKeyReq{Secret: secret, Explicit: explicit}, nil) } // Unlock hands the daemon the PRF secret so it can unwrap its at-rest key and // open the store. Refused unless a passkey assertion was verified first. func (c *Client) Unlock(ctx context.Context, secret []byte) error { return c.call(ctx, MethodUnlock, unlockReq{Secret: secret}, nil) } func (c *Client) LookupTool(ctx context.Context, name string) (Tool, error) { var t Tool if err := c.call(ctx, MethodLookupTool, lookupToolReq{Name: name}, &t); err != nil { return Tool{}, err } return t, nil } func (c *Client) ListTools(ctx context.Context, status string) ([]Tool, error) { var r listToolsResp if err := c.call(ctx, MethodListTools, listToolsReq{Status: status}, &r); err != nil { return nil, err } return r.Tools, nil } func (c *Client) DeleteTool(ctx context.Context, name string) error { return c.call(ctx, MethodDeleteTool, disableToolReq{Name: name}, nil) } func (c *Client) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) { var r listProposedRoutinesResp if err := c.call(ctx, MethodListProposedRoutines, nil, &r); err != nil { return nil, err } return r.Routines, nil } func (c *Client) SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error) { var r SeedEventResp if err := c.call(ctx, MethodSeedEvent, req, &r); err != nil { return SeedEventResp{}, err } return r, nil } func (c *Client) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) { var r CaptureTaskResp if err := c.call(ctx, MethodCaptureTask, req, &r); err != nil { return CaptureTaskResp{}, err } return r, nil } func (c *Client) ListTasks(ctx context.Context, status string) ([]Task, error) { var r listTasksResp if err := c.call(ctx, MethodListTasks, listTasksReq{Status: status}, &r); err != nil { return nil, err } return r.Tasks, nil } func (c *Client) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error { return c.call(ctx, MethodSetTaskStatus, setTaskStatusReq{ID: id, Status: status, Ts: ts, By: by}, nil) } func (c *Client) ResolveEntity(ctx context.Context, query string, types []string) (EntityRef, error) { var r resolveEntityResp if err := c.call(ctx, MethodResolveEntity, resolveEntityReq{Query: query, Types: types}, &r); err != nil { return EntityRef{}, err } return r.Ref, nil } func (c *Client) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error { return c.call(ctx, MethodEditTask, editTaskReq{ID: id, Text: text, Due: due, Weight: weight}, nil) } func (c *Client) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error { return c.call(ctx, MethodSetTaskFields, setTaskFieldsReq{ID: id, DoneWhen: doneWhen, BlockedOn: blockedOn}, nil) } // IngestMail hands one fetched message to core for extraction. ErrUnknownMethod // means core has no email block configured — the caller should stop asking, not // retry. func (c *Client) IngestMail(ctx context.Context, req IngestMailReq) (IngestMailResp, error) { var r IngestMailResp if err := c.call(ctx, MethodIngestMail, req, &r); err != nil { return IngestMailResp{}, err } return r, nil } // DescribeImage hands one image to core to look at (Vikunja #252). // ErrUnknownMethod means core has no media store or vision is off — the caller // should stop asking, not retry. A response with an ID and an empty Description // means the bytes were stored but nothing could describe them yet, which is the // expected state on a box with no vision model on disk. func (c *Client) DescribeImage(ctx context.Context, req DescribeImageReq) (DescribeImageResp, error) { var r DescribeImageResp if err := c.call(ctx, MethodDescribeImage, req, &r); err != nil { return DescribeImageResp{}, err } return r, nil } // CaptureStart begins recording a meeting (Vikunja #253). ErrUnknownMethod // means the operator has not enabled capture — the caller should say so and stop // asking, not retry. func (c *Client) CaptureStart(ctx context.Context, req CaptureStartReq) (CaptureStartResp, error) { var r CaptureStartResp if err := c.call(ctx, MethodCaptureStart, req, &r); err != nil { return CaptureStartResp{}, err } return r, nil } // CaptureAppend hands one chunk of audio to the running session. An error means // the frame was not kept: either nothing is being recorded, or the session hit // its time limit. Either way the client stops sending. func (c *Client) CaptureAppend(ctx context.Context, req CaptureAppendReq) (CaptureAppendResp, error) { var r CaptureAppendResp if err := c.call(ctx, MethodCaptureAppend, req, &r); err != nil { return CaptureAppendResp{}, err } return r, nil } // CaptureStop ends the session. It transcribes the whole recording before // answering, so pass a context with room; the summary is written afterwards by // the daemon and is usually absent from the response. Set Discard to throw the // recording away instead. Token comes from CaptureStart. func (c *Client) CaptureStop(ctx context.Context, req CaptureStopReq) (CaptureStopResp, error) { var r CaptureStopResp if err := c.call(ctx, MethodCaptureStop, req, &r); err != nil { return CaptureStopResp{}, err } return r, nil } // CaptureStatus reports the running session, if any. func (c *Client) CaptureStatus(ctx context.Context) (CaptureStatusResp, error) { var r CaptureStatusResp if err := c.call(ctx, MethodCaptureStatus, nil, &r); err != nil { return CaptureStatusResp{}, err } return r, nil } // EnrollSpeaker registers a voice from several deliberately recorded samples // (Vikunja #255). ErrUnknownMethod means no speaker block is configured, which // is the default: on an unconfigured box there is no way to take a voiceprint. func (c *Client) EnrollSpeaker(ctx context.Context, req EnrollSpeakerReq) (EnrollSpeakerResp, error) { var r EnrollSpeakerResp if err := c.call(ctx, MethodEnrollSpeaker, req, &r); err != nil { return EnrollSpeakerResp{}, err } return r, nil } // ListSpeakers reports who is enrolled. The voiceprints themselves stay in // core. Enabled is false when profiles exist but no embedding model is wired, // so a surface can say "enrolled, not recognising" rather than implying Maven // knows who is talking. func (c *Client) ListSpeakers(ctx context.Context) (ListSpeakersResp, error) { var r ListSpeakersResp if err := c.call(ctx, MethodListSpeakers, nil, &r); err != nil { return ListSpeakersResp{}, err } return r, nil } // ForgetSpeaker deletes one voiceprint. func (c *Client) ForgetSpeaker(ctx context.Context, id string) error { return c.call(ctx, MethodForgetSpeaker, ForgetSpeakerReq{ID: id}, nil) } // SwapModel asks core to load another resident model (Vikunja #250). // ErrUnknownMethod means core has no phraser.swap_models allowlist configured; // ErrForbidden means the path is not on it, or step-up was not asserted. A // non-nil error with RolledBack set means nothing changed — the old model is // still serving. func (c *Client) SwapModel(ctx context.Context, req SwapModelReq) (SwapModelResp, error) { var r SwapModelResp if err := c.call(ctx, MethodSwapModel, req, &r); err != nil { return SwapModelResp{}, err } return r, nil } // ModelStatus reports the resident model and the swap allowlist. Read-only. func (c *Client) ModelStatus(ctx context.Context) (ModelStatusResp, error) { var r ModelStatusResp if err := c.call(ctx, MethodModelStatus, nil, &r); err != nil { return ModelStatusResp{}, err } return r, nil } func (c *Client) DismissProposedRoutine(ctx context.Context, id int64) error { return c.call(ctx, MethodDismissProposedRoutine, idReq{ID: id}, nil) } func (c *Client) AcceptProposedRoutine(ctx context.Context, id int64) error { return c.call(ctx, MethodAcceptProposedRoutine, idReq{ID: id}, nil) } 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 ChatReply{}, err } return ChatReply{Reply: r.Reply, Source: r.Source, TraceID: r.TraceID}, nil } func (c *Client) CorrectTurn(ctx context.Context, traceID int64, shouldBe string) error { return c.call(ctx, MethodCorrectTurn, correctTurnReq{TraceID: traceID, ShouldBe: shouldBe}, nil) } func (c *Client) TickTrace(ctx context.Context) (TickTrace, error) { var t TickTrace if err := c.call(ctx, MethodTickTrace, nil, &t); err != nil { return TickTrace{}, err } return t, nil } func (c *Client) TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error) { var d []TurnDecision if err := c.call(ctx, MethodTurnDecisions, nReq{N: n}, &d); err != nil { return nil, err } return d, nil } func (c *Client) RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error) { var e []IntakeEvent if err := c.call(ctx, MethodRecentEvents, nReq{N: n}, &e); err != nil { return nil, err } return e, nil } func (c *Client) MCPServers(ctx context.Context) ([]MCPServerStatus, error) { var s []MCPServerStatus if err := c.call(ctx, MethodMCPServers, nil, &s); err != nil { return nil, err } return s, nil } func (c *Client) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) { var s []MorningRoutineStatus if err := c.call(ctx, MethodMorningStatus, nil, &s); err != nil { return nil, err } return s, nil } func (c *Client) DayPlan(ctx context.Context) (DayPlan, error) { var p DayPlan if err := c.call(ctx, MethodDayPlan, nil, &p); err != nil { return DayPlan{}, err } return p, nil } func (c *Client) RevertFact(ctx context.Context, key string) (int64, error) { var r revertResp if err := c.call(ctx, MethodRevertFact, revertReq{Key: key}, &r); err != nil { return 0, err } return r.NewID, nil } // Ping asks whether the daemon is there, and whether it is locked. It is not a // CoreAPI method: it touches no store, so it answers before the passkey // assertion that every other read waits for. func (c *Client) Ping(ctx context.Context) (PingResp, error) { var r PingResp if err := c.call(ctx, MethodPing, nil, &r); err != nil { return PingResp{}, err } return r, nil } // Compile-time check: *Client satisfies CoreAPI. var _ CoreAPI = (*Client)(nil)