package main import ( "context" "encoding/json" "fmt" "log" "strings" hexisclient "github.com/kami/hexis/pkg/client" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/router" ) // handlePraxisAct — dispatches ecosystem tool acts through the Praxis tools API. // Returns "" when the act is not a Praxis verb (the caller falls through to the // system command executor). Returns a reply string otherwise. func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decision) string { if h.ecosystem == nil || h.ecosystem.praxis == nil { return "" } px := h.ecosystem.praxis fn := dec.Slots.Fn // Map verbs and Russian aliases to Praxis tool calls. // Each case: if the verb matches, call the tool and return a user-facing reply. switch fn { case "list_attention", "attention", "внимание", "что требует внимания", "что нового": items, err := px.ListAttention(ctx, 20) if err != nil { log.Printf("ecosystem: praxis attention: %v", err) return "не могу сейчас узнать, что требует внимания." } if len(items) == 0 { return "ничего не требует внимания." } h.recordPraxisTrace(ctx, "list_attention", map[string]any{"count": len(items)}) var parts []string for _, item := range items { title, _ := item["title"].(string) // importance arrives as JSON number ⇒ float64 over the HTTP contract. importance, _ := item["importance"].(float64) rule, _ := item["rule"].(string) s := title if importance > 0 { s += fmt.Sprintf(" (важность %d", int(importance)) if rule != "" { s += ": " + rule } s += ")" } parts = append(parts, s) // Speaking an item surfaces it, it does not acknowledge it // (ECOSYSTEM-SPEC.md §2.3: surfaced != acknowledged). Best-effort: // a failed surface call must not block delivering the digest. if id, ok := item["id"].(string); ok && id != "" { if _, err := px.Surface(ctx, id); err != nil { log.Printf("ecosystem: praxis surface %s: %v", id, err) } } } return "требует внимания: " + strings.Join(parts, "; ") case "acknowledge_item", "принято", "понял", "поняла": id := dec.Slots.Value if id == "" { return "какой пункт отметить принятым?" } if _, err := px.Acknowledge(ctx, id); err != nil { log.Printf("ecosystem: praxis acknowledge %s: %v", id, err) return "не получилось отметить принятым." } h.recordPraxisTrace(ctx, "acknowledge", map[string]any{"item_id": id}) return "принято." case "resolve_item", "сделано", "готово", "решено": id := dec.Slots.Value if id == "" { return "какой пункт отметить сделанным?" } if _, err := px.Resolve(ctx, id); err != nil { log.Printf("ecosystem: praxis resolve %s: %v", id, err) return "не получилось отметить сделанным." } h.recordPraxisTrace(ctx, "resolve", map[string]any{"item_id": id}) return "отмечено как сделано." case "ignore_item", "игнорировать", "неважно": id := dec.Slots.Value if id == "" { return "какой пункт игнорировать?" } if _, err := px.Ignore(ctx, id); err != nil { log.Printf("ecosystem: praxis ignore %s: %v", id, err) return "не получилось проигнорировать." } h.recordPraxisTrace(ctx, "ignore", map[string]any{"item_id": id}) return "проигнорировано." case "pin_item", "закрепить": id := dec.Slots.Value if id == "" { return "какой пункт закрепить?" } if _, err := px.Pin(ctx, id, true); err != nil { log.Printf("ecosystem: praxis pin %s: %v", id, err) return "не получилось закрепить." } h.recordPraxisTrace(ctx, "pin", map[string]any{"item_id": id}) return "закреплено." case "list_changes", "changes", "изменения", "что изменилось": changes, err := px.ListChanges(ctx, 20) if err != nil { log.Printf("ecosystem: praxis changes: %v", err) return "не могу сейчас узнать об изменениях." } if len(changes) == 0 { return "нет изменений." } h.recordPraxisTrace(ctx, "list_changes", map[string]any{"count": len(changes)}) var parts []string for _, c := range changes { title, _ := c["title"].(string) typ, _ := c["change_type"].(string) parts = append(parts, fmt.Sprintf("%s (%s)", title, typ)) } return "изменения: " + strings.Join(parts, "; ") default: // Not a Praxis verb — let the caller fall through. return "" } } // recordPraxisTrace — writes a fact recording a cross-service ecosystem call. // The fact is stored with source "praxis:trace" so the proactive loop can // reference it and the dashboard can display recent ecosystem activity. func (h *reactiveHandler) recordPraxisTrace(ctx context.Context, operation string, details map[string]any) { now := h.now() value := operation if len(details) > 0 { if b, err := json.Marshal(details); err == nil { value = operation + " " + string(b) } } _, _ = h.api.WriteFact(ctx, ipc.WriteFactReq{ Ts: now, Kind: "system", Key: "praxis:" + operation, Value: value, Source: "praxis:trace", Confidence: 1.0, }) } // handleHexisAct — resolves entity references through Nexus and executes // matching capabilities through Hexis. Returns a reply string when handled, // or "" to fall through to the system command executor. func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decision) string { if h.ecosystem == nil { return "" } // Resolve the utterance text as an entity reference through Nexus. An // ambiguous match must stop and clarify — never guess a mutation target. entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, dec.Slots.Text, nil) if err != nil { // A genuine Nexus dependency failure, not "no such entity" — stop here // and report degradation rather than silently falling through to the // local command executor (ECOSYSTEM-SPEC.md: services degrade // independently, never a silent all-clear). return "экосистема недоступна, попробуй ещё раз." } if len(ambiguous) > 0 { return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?" } if entityID == "" { return "" } // Discover Hexis capabilities for this entity. A resolved entity with a // genuine Hexis failure must not be treated as "no capabilities" and // fall through to unrelated local execution. caps, err := h.ecosystem.discoverCapabilities(ctx, entityID) if err != nil { return "экосистема недоступна, попробуй ещё раз." } if len(caps) == 0 { return "" } // Match the user's verb to a capability by name/description. Collect all // matches: more than one is itself ambiguous, so we ask rather than pick // the first (ecosystem invariant: no arbitrary target for mutation). verb := dec.Slots.Fn if verb == "" { verb = dec.Slots.Text } verbLower := strings.ToLower(verb) var matches []*hexisclient.Capability for i, c := range caps { if strings.Contains(strings.ToLower(c.Name), verbLower) || (c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower)) { matches = append(matches, &caps[i]) } } if len(matches) == 0 { return "" } if len(matches) > 1 { var names []string for _, m := range matches { names = append(names, m.Name) } return "какую команду для " + displayName + ": " + strings.Join(names, ", ") + "?" } matched := matches[0] // Read-only capabilities run immediately; mutating ones are parked for an // explicit spoken confirm bound to this capability + target. if !matched.ReadOnly { h.mu.Lock() h.pendingHexis = &pendingHexisExec{ capabilityID: matched.ID, capName: matched.Name, entityID: entityID, displayName: displayName, expiry: h.now().Add(confirmTTL), } h.mu.Unlock() return "выполнить «" + matched.Name + "» для " + displayName + "? скажи «да» или «нет»." } return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName) } // execHexis runs a resolved capability and records a cross-service trace with // the correlation ID. It reports command success, never operational recovery // (Praxis observes recovery independently). func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityID, displayName string) string { correlationID, err := h.ecosystem.executeCapability(ctx, capID, entityID, nil) if err != nil { log.Printf("ecosystem: hexis execute error (cor=%s): %v", correlationID, err) return "не получилось выполнить команду для " + displayName + "." } h.recordPraxisTrace(ctx, "hexis:"+capName, map[string]any{ "entity_id": entityID, "entity_name": displayName, "capability": capName, "correlation_id": correlationID, }) return "команда выполнена для " + displayName + "." }