// Package hub implements the project side of the Hub contract // (central_ai_llm_master/docs/ask-contract.md) and the outbound notify call. // // The contract is one-directional by design: the Hub asks a project about its // own data and the project answers. A project never learns the Hub's data model // and the Hub never learns the project's. The project endpoints are // GET /capabilities and POST /ask (read only); POST /notify is the // project-to-Hub direction. // // Phase 7 (FR-12). Answers are computed from SQLite; /ask performs no writes. package hub import ( "context" "encoding/json" "time" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/store" ) // ProjectName is the value the Hub matches in routing.md. const ProjectName = "email_agent" // Capabilities is the GET /capabilities reply. The Hub reads it to route a // question to this project. Keep the topic list short and plain; it holds no // private data. type Capabilities struct { Project string `json:"project"` Topics []string `json:"topics"` People []string `json:"people"` } // DefaultCapabilities returns the topics this service can answer. "email", // "inbox", "mail", and "message" match the routing.md topic map. Read/unread is // deliberately absent: the service does not track read state. func DefaultCapabilities() Capabilities { return Capabilities{ Project: ProjectName, Topics: []string{"email", "inbox", "mail", "message"}, People: []string{"sam"}, } } // AskRequest is the POST /ask body (ask-contract.md). type AskRequest struct { Question string `json:"question"` Asker string `json:"asker"` Person string `json:"person,omitempty"` Source string `json:"source,omitempty"` } // AskResponse is the POST /ask reply. A nil Answer means the project cannot // answer and Reason explains why. The shape is frozen by the contract. type AskResponse struct { Answer *string `json:"answer"` Data map[string]any `json:"data,omitempty"` Expires string `json:"expires,omitempty"` Reason string `json:"reason,omitempty"` } // answerText is a small helper so the pointer handling stays in one place. func answerText(s string) *string { return &s } // cannotAnswer builds the contract's "cannot answer" reply. func cannotAnswer(reason string) AskResponse { return AskResponse{Answer: nil, Reason: reason} } // expiresIn formats an RFC3339 expiry used by data-bearing answers. An inbox // answer goes stale quickly because mail arrives and is triaged. func expiresIn(now time.Time, d time.Duration) string { return now.UTC().Add(d).Format(time.RFC3339) } // NotifyRequest is the project-to-Hub POST /notify body (ask-contract.md). The // Hub may use it to speak or notify. type NotifyRequest struct { From string `json:"from"` Level string `json:"level"` Message string `json:"message"` Person string `json:"person,omitempty"` } // jsonBody marshals a notify payload, kept small and deterministic for tests. func jsonBody(v any) ([]byte, error) { return json.Marshal(v) } // Reader is the read-only data surface the answerer uses. *store.Store // satisfies it; tests can supply a fake. Keeping it an interface makes the // read-only guarantee explicit and easy to assert. type Reader interface { CountByCategory(ctx context.Context, folder string) ([]store.CategoryCount, error) MessagesByCategory(ctx context.Context, folder string, categories []string, limit int) ([]store.MessageListItem, error) }