From 5934110fa87e8db36b7126c516305f937f93e46e Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 23:43:47 +0400 Subject: [PATCH] Move the Praxis/Hexis act handling out of voice.go voice.go is still the biggest file in cmd/mavend and most of what is left has nothing to do with the audio path. The ecosystem integration is one such lump: it talks to Nexus, Praxis and Hexis over HTTP and only touches the handler for its store and clock. Lifting it into ecosystem_acts.go puts it next to ecosystem.go, where the clients it drives already live. Move-only: handlePraxisAct, recordPraxisTrace (called from nowhere else), handleHexisAct and execHexis verbatim, plus the two imports that became unused in voice.go. --- cmd/mavend/ecosystem_acts.go | 256 +++++++++++++++++++++++++++++++++++ cmd/mavend/voice.go | 245 --------------------------------- 2 files changed, 256 insertions(+), 245 deletions(-) create mode 100644 cmd/mavend/ecosystem_acts.go diff --git a/cmd/mavend/ecosystem_acts.go b/cmd/mavend/ecosystem_acts.go new file mode 100644 index 0000000..e269ed5 --- /dev/null +++ b/cmd/mavend/ecosystem_acts.go @@ -0,0 +1,256 @@ +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 + "." +} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 5a9c955..b48d7e9 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -45,7 +45,6 @@ package main import ( "bufio" "context" - "encoding/json" "errors" "fmt" "log" @@ -55,7 +54,6 @@ import ( "sync" "time" - hexisclient "github.com/kami/hexis/pkg/client" "github.com/kami/maven/internal/audio" "github.com/kami/maven/internal/config" "github.com/kami/maven/internal/delivery" @@ -851,249 +849,6 @@ func loadSeedFile(c *router.Classifier, intent router.Intent) (int, error) { return count, nil } -// 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 + "." -} - // park stores a destructive act awaiting confirmation. Overwrites any prior // pending (last-asked wins — single-user box). func (h *reactiveHandler) park(fn string, args []string, phrase string) {