package main import ( "bytes" "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "io" "log" "net/http" "time" hexisclient "github.com/kami/hexis/pkg/client" "github.com/kami/maven/internal/config" ) type nexusClient struct { baseURL string httpClient *http.Client } func newNexusClient(url string) *nexusClient { return &nexusClient{ baseURL: url, httpClient: &http.Client{Timeout: 10 * time.Second}, } } type nexusEntity struct { ID string `json:"id"` Type string `json:"type"` DisplayName string `json:"display_name"` Key string `json:"key,omitempty"` State string `json:"state,omitempty"` } type nexusCandidate struct { EntityID string `json:"entity_id"` DisplayName string `json:"display_name"` Type string `json:"type"` Score float64 `json:"score"` Evidence string `json:"evidence"` } type nexusResolveResult struct { Status string `json:"status"` Entity *nexusEntity `json:"entity,omitempty"` Score float64 `json:"score,omitempty"` Candidates []nexusCandidate `json:"candidates,omitempty"` // Flat fields per ECOSYSTEM-SPEC.md ยง1.5's documented resolve response // shape. Nexus emits both this and the nested Entity above; normalize // into Entity in UnmarshalJSON so callers only ever look at one place. EntityID string `json:"entity_id,omitempty"` EntityType string `json:"entity_type,omitempty"` DisplayName string `json:"display_name,omitempty"` } func (r *nexusResolveResult) UnmarshalJSON(data []byte) error { type alias nexusResolveResult var a alias if err := json.Unmarshal(data, &a); err != nil { return err } *r = nexusResolveResult(a) if r.Entity == nil && r.EntityID != "" { r.Entity = &nexusEntity{ID: r.EntityID, Type: r.EntityType, DisplayName: r.DisplayName} } return nil } func (c *nexusClient) Resolve(ctx context.Context, query string, types []string) (*nexusResolveResult, error) { body := map[string]any{"query": query} if len(types) > 0 { body["types"] = types } data, _ := json.Marshal(body) req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/resolve", bytes.NewReader(data)) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("do request: %w", err) } defer resp.Body.Close() bodyBytes, _ := io.ReadAll(resp.Body) if resp.StatusCode != 200 { return nil, fmt.Errorf("nexus: %s", http.StatusText(resp.StatusCode)) } var result nexusResolveResult if err := json.Unmarshal(bodyBytes, &result); err != nil { return nil, fmt.Errorf("decode: %w", err) } return &result, nil } func (c *nexusClient) Health(ctx context.Context) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/health", nil) if err != nil { return err } resp, err := c.httpClient.Do(req) if err != nil { return err } resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("nexus health: %s", http.StatusText(resp.StatusCode)) } return nil } // praxisClient talks to the Praxis HTTP tools API. Maven must not open Praxis's // SQLite store directly (ecosystem invariant: no component reads another's DB), // so attention/changes/lifecycle all go over this HTTP contract against praxisd. type praxisClient struct { baseURL string httpClient *http.Client } func newPraxisClient(url string) *praxisClient { return &praxisClient{ baseURL: url, httpClient: &http.Client{Timeout: 10 * time.Second}, } } // getJSON performs a GET and decodes the JSON body into out. func (c *praxisClient) getJSON(ctx context.Context, path string, out any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) if err != nil { return err } resp, err := c.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("praxis: %s", http.StatusText(resp.StatusCode)) } return json.NewDecoder(resp.Body).Decode(out) } func (c *praxisClient) ListAttention(ctx context.Context, limit int) ([]map[string]any, error) { var out []map[string]any err := c.getJSON(ctx, fmt.Sprintf("/api/v1/tools/attention?limit=%d", limit), &out) return out, err } func (c *praxisClient) ListChanges(ctx context.Context, limit int) ([]map[string]any, error) { var out []map[string]any err := c.getJSON(ctx, fmt.Sprintf("/api/v1/tools/changes?limit=%d", limit), &out) return out, err } // ecosystemWiring holds the ecosystem service clients. type ecosystemWiring struct { nexus *nexusClient hexis *hexisclient.Client praxis *praxisClient } func wireEcosystem(cfg *config.Config) *ecosystemWiring { w := &ecosystemWiring{} // Nexus identity service if cfg.Nexus != nil && cfg.Nexus.URL != "" { w.nexus = newNexusClient(cfg.Nexus.URL) log.Printf("ecosystem: nexus at %s", cfg.Nexus.URL) } else { log.Printf("ecosystem: nexus not configured") } // Hexis capability service if cfg.Hexis != nil && cfg.Hexis.URL != "" { w.hexis = hexisclient.New(cfg.Hexis.URL) log.Printf("ecosystem: hexis at %s", cfg.Hexis.URL) } else { log.Printf("ecosystem: hexis not configured") } // Praxis attention service (HTTP tools API โ€” never the DB directly) if cfg.Praxis != nil && cfg.Praxis.URL != "" { w.praxis = newPraxisClient(cfg.Praxis.URL) log.Printf("ecosystem: praxis at %s", cfg.Praxis.URL) } else { log.Printf("ecosystem: praxis not configured") } return w } // resolveEntityReference extracts and resolves an entity name from utterance text. // Returns the canonical entity ID on a confident resolve. On an ambiguous match it // returns candidate display names so the caller can ask for clarification rather // than silently guessing (ecosystem invariant: ambiguity blocks mutation). func (w *ecosystemWiring) resolveEntityReference(ctx context.Context, text string, entityTypes []string) (entityID string, displayName string, ambiguous []string, err error) { if w == nil || w.nexus == nil { return "", "", nil, nil } result, err := w.nexus.Resolve(ctx, text, entityTypes) if err != nil { log.Printf("ecosystem: nexus resolve error: %v", err) return "", "", nil, err } if result.Status == "resolved" && result.Entity != nil { return result.Entity.ID, result.Entity.DisplayName, nil, nil } if result.Status == "ambiguous" { names := make([]string, 0, len(result.Candidates)) for _, c := range result.Candidates { names = append(names, c.DisplayName) } log.Printf("ecosystem: ambiguous entity '%s' โ€” %d candidates", text, len(names)) return "", "", names, nil } return "", "", nil, nil } // discoverCapabilities returns Hexis capabilities applicable to an entity. // A non-nil error means Hexis could not be reached or refused the request โ€” // distinct from a nil error with zero capabilities, which means Hexis is // healthy and genuinely has nothing registered for this entity. Callers must // not conflate the two: a dependency failure must not silently read as "no // capabilities" and fall through to unrelated local execution. func (w *ecosystemWiring) discoverCapabilities(ctx context.Context, entityID string) ([]hexisclient.Capability, error) { if w == nil || w.hexis == nil || entityID == "" { return nil, nil } caps, err := w.hexis.Capabilities(ctx, entityID) if err != nil { log.Printf("ecosystem: hexis capabilities error: %v", err) return nil, err } return caps, nil } // executeCapability runs a Hexis capability, tagging the request with a // correlation ID so the call is traceable across services. Returns the // correlation ID alongside the outcome. func (w *ecosystemWiring) executeCapability(ctx context.Context, capabilityID, targetEntityID string, args map[string]any) (correlationID string, err error) { if w == nil || w.hexis == nil { return "", fmt.Errorf("hexis not configured") } correlationID = newCorrelationID() req := hexisclient.ExecuteRequest{ CapabilityID: capabilityID, TargetEntityID: targetEntityID, Arguments: args, RequestedBy: map[string]string{"system": "maven", "actor": "user"}, Origin: map[string]string{"source": "voice"}, CorrelationID: correlationID, } exec, err := w.hexis.Execute(ctx, req) if err != nil { return correlationID, fmt.Errorf("execute: %w", err) } if exec.Status == "succeeded" { return correlationID, nil } if exec.Error != "" { return correlationID, fmt.Errorf("execution failed: %s", exec.Error) } return correlationID, fmt.Errorf("execution status: %s", exec.Status) } // newCorrelationID returns a short unique ID for cross-service call tracing. func newCorrelationID() string { var b [8]byte if _, err := rand.Read(b[:]); err != nil { return fmt.Sprintf("cor-%d", time.Now().UnixNano()) } return "cor-" + hex.EncodeToString(b[:]) }