Merge branch 'fix/g07' into fix/integrated

# Conflicts:
#	internal/ipc/api.go
#	internal/ipc/client.go
#	internal/llm/client.go
This commit is contained in:
kami
2026-08-01 14:36:48 +04:00
39 changed files with 1913 additions and 215 deletions
+24
View File
@@ -413,11 +413,17 @@ type SwapModelReq struct {
// RolledBack is true when the requested model failed to load or would not answer
// and the previous one was put back. In that case the call also returns an error
// — the swap did not happen — and Model names the model still serving.
//
// NoBackend is the other failure and it is not a milder one: the rollback failed
// too, no model is loaded, and every phrasing path is on its template fallback
// with routing on the classifier. It is a separate field from RolledBack because
// the two need opposite words on the page.
type SwapModelResp struct {
Model string `json:"model"`
ModelPath string `json:"model_path"`
BaseURL string `json:"base_url"`
RolledBack bool `json:"rolled_back,omitempty"`
NoBackend bool `json:"no_backend,omitempty"`
TookMs int64 `json:"took_ms"`
}
@@ -433,6 +439,15 @@ type ModelStatusResp struct {
Swappable []string `json:"swappable,omitempty"`
}
// PingResp — the answer to MethodPing. Alive is always true (the reply itself
// is the proof); Locked says whether the daemon is still waiting for a passkey
// assertion, which is the one state where a CoreAPI read cannot tell an
// operator anything.
type PingResp struct {
Alive bool `json:"alive"`
Locked bool `json:"locked"`
}
type listTasksReq struct {
Status string `json:"status"` // "" all | "live" | candidate|open|done|dropped
}
@@ -489,6 +504,10 @@ type kindNReq struct {
Kind string `json:"kind"`
N int `json:"n"`
}
type sourceNReq struct {
Prefix string `json:"prefix"`
N int `json:"n"`
}
type calendarEventsReq struct {
From time.Time `json:"from"`
To time.Time `json:"to"`
@@ -646,6 +665,11 @@ type CoreAPI 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)
// ProposeTool drafts an inert 'proposed' tool scaffold (maven-callable);
// returns whether a new proposal was written. EnableTool fills cmd +
+43 -22
View File
@@ -55,28 +55,30 @@ var ErrAmbiguousOutcome = errors.New("ipc: mutation outcome unknown (connection
// 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,
MethodRecentOutcomes: true,
MethodRecentFacts: true,
MethodRecentActiveFacts: true,
MethodCalendarEvents: true,
MethodRecentNudges: 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,
MethodLatestFact: true,
MethodLatestFactBySource: true,
MethodSince: true,
MethodPresence: true,
MethodListReminders: true,
MethodRecentOutcomes: true,
MethodRecentFacts: true,
MethodRecentActiveFacts: true,
MethodCalendarEvents: true,
MethodRecentNudges: 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 a core socket at path and returns a Client. The module
@@ -392,6 +394,14 @@ func (c *Client) RecentNotes(ctx context.Context, n int) ([]Note, error) {
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 {
@@ -659,5 +669,16 @@ func (c *Client) RevertFact(ctx context.Context, key string) (int64, error) {
return result.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)
+38
View File
@@ -189,6 +189,18 @@ func (a *storeAPI) QueryNotes(ctx context.Context, embedding []float32, k int) (
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 {
@@ -517,6 +529,11 @@ type Server struct {
ListSpeakersFn ListSpeakersFunc
ForgetSpeakerFn ForgetSpeakerFunc
// LockedFn — reports whether the daemon is in locked (pre-unlock) mode.
// Read by MethodPing only. Nil ⇒ not locked, which is what an embedded or
// test Server without the unlock dance is.
LockedFn func() bool
// UnlockFn — unwraps the store encryption key from the wrapped blob using
// the passkey PRF secret, opens the encrypted store, and wires
// the rest of the daemon (voice, loop, delivery). Set by the daemon when
@@ -853,6 +870,16 @@ var methodTable = map[Method]handlerFunc{
}
return out, nil
}),
MethodRecentNotesFromSource: withParams(func(ctx context.Context, api CoreAPI, p sourceNReq) ([]Note, error) {
out, err := api.RecentNotesFromSource(ctx, p.Prefix, p.N)
if err != nil {
return nil, err
}
if out == nil {
out = []Note{}
}
return out, nil
}),
MethodRecentNotes: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]Note, error) {
out, err := api.RecentNotes(ctx, p.N)
if err != nil {
@@ -988,6 +1015,17 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
// directly by the daemon (StepUp / WrapKeyFn / UnlockFn), not store
// state, so they can never be table entries keyed on a CoreAPI method.
switch req.Method {
case MethodPing:
// Deliberately reaches nothing: no store, no CoreAPI, no daemon
// component. That is what makes it answerable in locked mode, and it is
// the whole point — an update that restarts her into locked mode has to
// be able to tell that apart from a daemon that did not come up.
locked := false
if s.LockedFn != nil {
locked = s.LockedFn()
}
return marshalResult(PingResp{Alive: true, Locked: locked}), nil
case MethodAssertStepUp:
if s.StepUp != nil {
return marshalResult(nil), s.StepUp(ctx)
+4
View File
@@ -80,6 +80,10 @@ func (UnimplementedCoreAPI) WriteNote(ctx context.Context, ts time.Time, text st
func (UnimplementedCoreAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) {
return nil, ErrNotImplemented
}
func (UnimplementedCoreAPI) RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error) {
return nil, ErrUnknownMethod
}
func (UnimplementedCoreAPI) RecentNotes(ctx context.Context, n int) ([]Note, error) {
return nil, ErrNotImplemented
}
+36
View File
@@ -139,3 +139,39 @@ func TestLockedCheckDefaultDenies(t *testing.T) {
t.Error("UnlockFn never ran")
}
}
// A locked daemon has to be able to say it is alive. Every CoreAPI method is
// refused before unlock, so a health check built on one of those cannot tell a
// daemon waiting for a passkey apart from a daemon that failed to start. That
// is what turned a good update into the manual-recovery case in
// internal/update. MethodPing reaches no store, so it answers either way.
func TestPingAnswersWhileLocked(t *testing.T) {
_, srv, cli, _ := newServerWithStore(t)
srv.LockedFn = func() bool { return true }
locked := errors.New("daemon locked")
srv.Check = func(_ context.Context, m Method, _ json.RawMessage) error {
switch m {
case MethodAssertStepUp, MethodUnlock, MethodPing:
return nil
default:
return locked
}
}
ctx := context.Background()
p, err := cli.Ping(ctx)
if err != nil {
t.Fatalf("Ping while locked: %v", err)
}
if !p.Alive || !p.Locked {
t.Errorf("Ping = %+v; want alive and locked", p)
}
// And the read it replaces is still refused, which is the whole point.
if _, err := cli.Presence(ctx); err == nil {
t.Error("Presence answered while locked")
}
srv.LockedFn = func() bool { return false }
if p, err := cli.Ping(ctx); err != nil || p.Locked {
t.Errorf("Ping after unlock = %+v, %v; want alive and not locked", p, err)
}
}
+9
View File
@@ -32,6 +32,7 @@ const (
MethodWriteNote Method = "write_note"
MethodQueryNotes Method = "query_notes"
MethodRecentNotes Method = "recent_notes"
MethodRecentNotesFromSource Method = "recent_notes_source"
MethodProposeTool Method = "propose_tool"
MethodEnableTool Method = "enable_tool"
MethodDisableTool Method = "disable_tool"
@@ -65,6 +66,14 @@ const (
MethodListSpeakers Method = "list_speakers"
MethodForgetSpeaker Method = "forget_speaker"
MethodRecentEvents Method = "recent_events"
// MethodPing — liveness, and the only method that answers in locked mode
// without a passkey assertion. It reaches no store, takes no arguments and
// returns whether the daemon is locked, so an operator tool can tell "she is
// up and waiting for a passkey" apart from "she is not there at all".
// Everything else about her state needs the store, and the store needs the
// key.
MethodPing Method = "ping"
)
// Request — one frame from module to core. Params is the JSON-encoded argument