package main import ( "context" "errors" "fmt" "log" "strconv" "strings" "time" hexisclient "github.com/kami/hexis/pkg/client" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" "github.com/kami/maven/internal/tool" ) // The three services, spelled the way she says them out loud. A service that is // down or refusing has to be named: they degrade independently, so "не // отвечает" on its own tells him nothing he can act on, and each call site // already knows which one it was talking to — it records the same name in the // trace (Vikunja #521). const ( serviceNexus = "Nexus" servicePraxis = "Praxis" serviceHexis = "Hexis" ) // serviceVars — the one-key map the eco_down and eco_denied lines take. func serviceVars(name string) map[string]string { return map[string]string{"name": name} } // ecosystemGap names the service that failed. A rejected credential gets its // own line, because a wrong token looks exactly like an outage to him and // "try again" is advice that will never work. Every degrade path reads through // here, so all of them name the service and none of them guesses instead. func ecosystemGap(service string, err error) string { if unauthorizedEcosystemError(err) { return phraser.A(phraser.EcoDenied, serviceVars(service)) } return phraser.A(phraser.EcoDown, serviceVars(service)) } // praxisCapability is one arm of the Praxis act dispatch. This is an interface // rather than a map[string]func because each arm carries its own state: the // verb aliases it answers to, the trace name it records, and its own reply // formatting. The dispatch grows an arm per Praxis capability, so a new one is // added to praxisCapabilities below and nothing else changes. type praxisCapability interface { // aliases are the verbs (router fn slots, EN and RU) this capability answers to. aliases() []string // handle runs the capability and returns the user-facing reply. handle(ctx context.Context, h *reactiveHandler, px *praxisClient, dec router.Decision) string } // praxisCapabilities is the registry handlePraxisAct consults, in order. var praxisCapabilities = []praxisCapability{ listAttentionCapability{}, praxisItemAction{ verbs: []string{"acknowledge_item", "принято", "понял", "поняла"}, ask: "какой пункт отметить принятым?", op: "acknowledge", failure: "не получилось отметить принятым.", success: "принято.", call: func(ctx context.Context, px *praxisClient, id string) error { _, err := px.Acknowledge(ctx, id) return err }, }, praxisItemAction{ verbs: []string{"resolve_item", "сделано", "готово", "решено"}, ask: "какой пункт отметить сделанным?", op: "resolve", failure: "не получилось отметить сделанным.", success: "отмечено как сделано.", call: func(ctx context.Context, px *praxisClient, id string) error { _, err := px.Resolve(ctx, id) return err }, }, praxisItemAction{ verbs: []string{"ignore_item", "игнорировать", "неважно"}, ask: "какой пункт игнорировать?", op: "ignore", failure: "не получилось проигнорировать.", success: "проигнорировано.", call: func(ctx context.Context, px *praxisClient, id string) error { _, err := px.Ignore(ctx, id) return err }, }, praxisItemAction{ verbs: []string{"pin_item", "закрепить"}, ask: "какой пункт закрепить?", op: "pin", failure: "не получилось закрепить.", success: "закреплено.", call: func(ctx context.Context, px *praxisClient, id string) error { _, err := px.Pin(ctx, id, true) return err }, }, listChangesCapability{}, entityAttentionCapability{}, } // 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 "" } // Every hop of this action shares one correlation ID, assigned here, so a // digest that calls attention once and surface N times reads as one turn // on the Praxis side instead of N+1 unrelated request ids. if correlationIDFromCtx(ctx) == "" { ctx = withCorrelationID(ctx, newCorrelationID()) } px := h.ecosystem.praxis dec, ok := h.resolveSurfacedPosition(dec) if !ok { // A demonstrative with no digest behind it. "я это сделал" is a sentence // about his day, so the rest of the cascade gets it back rather than // hearing "какой пункт?" for something that was never about a пункт. return "" } for _, capability := range praxisCapabilities { for _, alias := range capability.aliases() { if alias == dec.Slots.Fn { return capability.handle(ctx, h, px, dec) } } } // Not a Praxis verb — let the caller fall through. return "" } // praxisItemAction is the shared shape of the item-lifecycle capabilities: take // an item id from the value slot, call one Praxis endpoint, trace the result. type praxisItemAction struct { verbs []string ask string // reply when no item id was given op string // trace + log name of the operation // failure is the first half of the reply when the Praxis call errors: which // operation did not happen. ecosystemGap supplies the second half, which // names Praxis and splits a refused token from an outage — those two used to // produce the identical sentence and neither said "Praxis" (Vikunja #588). // The verb is kept alongside the service name because the trace is the only // other place it exists, and he is not reading the trace. failure string success string call func(ctx context.Context, px *praxisClient, id string) error } func (a praxisItemAction) aliases() []string { return a.verbs } func (a praxisItemAction) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, dec router.Decision) string { id := dec.Slots.Value if id == "" { return a.ask } started := h.now() if err := a.call(ctx, px, id); err != nil { log.Printf("ecosystem: praxis %s %s: %v", a.op, id, err) h.recordEcosystemTrace(ctx, "praxis", a.op, traceStatusForError(err), started, mergeFields(traceErrorFields(err), map[string]any{"item_id": id})) return a.failure + " " + ecosystemGap(servicePraxis, err) } h.recordPraxisTrace(ctx, a.op, started, map[string]any{"item_id": id}) return a.success } // listAttentionCapability reads the attention digest and surfaces every item it speaks. type listAttentionCapability struct{} func (listAttentionCapability) aliases() []string { return []string{"list_attention", "attention", "внимание", "что требует внимания", "что нового"} } func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, _ router.Decision) string { started := h.now() att, err := px.ListAttention(ctx, 20) if err != nil { log.Printf("ecosystem: praxis attention: %v", err) h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err), started, traceErrorFields(err)) return phraser.A(phraser.AttentionFail, nil) } items := att.Items if len(items) == 0 { if hedge := h.attentionCannotTell(ctx, px, att.Degraded, started); hedge != "" { return hedge } return phraser.A(phraser.AttentionNone, nil) } h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)}) var parts []string var spoken []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 s == "" { // An item Praxis returned without a title is not an item she can // read out. Counting it would put an empty slot in the list. continue } if importance > 0 { s += fmt.Sprintf(" (важность %d", int(importance)) if rule != "" { s += ": " + rule } s += ")" } parts = append(parts, s) // Recorded in the order she says them, and only for items she could // say: an item skipped above has no position in what he heard (#516). if id := surfaceSpoken(ctx, px, item); id != "" { spoken = append(spoken, id) } } h.rememberSurfaced(spoken) if len(parts) == 0 { // Praxis returned items and not one of them could be said. "ничего не // требует внимания" is the honest answer; the list line would render as // its own label and a colon (Vikunja #521). return phraser.A(phraser.AttentionNone, nil) } return phraser.A(phraser.AttentionList, map[string]string{"items": strings.Join(parts, "; ")}) } // listChangesCapability reads the recent-changes feed. type listChangesCapability struct{} func (listChangesCapability) aliases() []string { return []string{"list_changes", "changes", "изменения", "что изменилось"} } func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, _ router.Decision) string { started := h.now() changes, err := px.ListChanges(ctx, 20) if err != nil { log.Printf("ecosystem: praxis changes: %v", err) h.recordEcosystemTrace(ctx, "praxis", "list_changes", traceStatusForError(err), started, traceErrorFields(err)) return phraser.A(phraser.ChangesFail, nil) } if len(changes) == 0 { return phraser.A(phraser.ChangesNone, nil) } h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)}) var parts []string for _, c := range changes { title, _ := c["title"].(string) if title == "" { continue } typ, _ := c["change_type"].(string) if typ == "" { parts = append(parts, title) continue } parts = append(parts, fmt.Sprintf("%s (%s)", title, typ)) } if len(parts) == 0 { return phraser.A(phraser.ChangesNone, nil) } return phraser.A(phraser.ChangesList, map[string]string{"items": strings.Join(parts, "; ")}) } // entityAttentionCapability answers "what's going on with X" by resolving X to // a canonical Nexus entity and asking Praxis for that entity's attention items // (Vikunja #272). Unlike listAttentionCapability it is scoped: the entity_id // travels to Praxis as a query parameter instead of Maven filtering an unscoped // list client-side, which is what makes the ref canonical end to end. // // It also folds in what Maven herself knows about the same entity — facts the // enrichment worker has already resolved to that entity_id — so one question // gets one answer across both stores. type entityAttentionCapability struct{} // aliases are matched against Slots.Fn, which carries a function slot from the // act grammar and never free Russian, so only grammar names belong here. func (entityAttentionCapability) aliases() []string { return []string{"entity_attention", "entity_status"} } func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, dec router.Decision) string { subject := dec.Slots.Value if subject == "" { subject = dec.Slots.Text } if subject == "" { return phraser.A(phraser.EcoAboutWhat, nil) } if h.ecosystem == nil || h.ecosystem.nexus == nil { // Without Nexus there is no canonical ref to scope by. Say so rather // than quietly answering about something else. return phraser.A(phraser.EcoNoNexus, nil) } started := h.now() entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, subject, nil) if err != nil { // The subject is his words, so the log gets the same redaction the // trace gets. A trace that stores a rune count next to a log line // storing the runes is not redacted at all. log.Printf("ecosystem: entity attention resolve %s: %v", redactSubject(subject), err) return h.nexusResolveFailed(ctx, subject, started, err) } if len(ambiguous) > 0 { return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")}) } if entityID == "" { return phraser.A(phraser.EcoUnknownEntity, nil) } if displayName == "" { displayName = subject } queried := h.now() att, err := px.ListAttentionForEntity(ctx, entityID, 20) if err != nil { log.Printf("ecosystem: praxis attention for %s: %v", entityID, err) h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err), queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID})) return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName}) } items, scoped := scopedToEntity(att.Items, entityID) if !scoped { // A Praxis old enough to ignore an unknown query parameter answers the // scoped question with the unscoped list. Reading that back as "по // «X»: ..." is the exact fabrication the entity ref exists to prevent, // so refuse the answer instead of relabelling someone else's items. log.Printf("ecosystem: praxis returned unscoped items for %s, refusing to answer", entityID) h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceFailed, queried, map[string]any{"entity_id": entityID, "class": "unscoped_response"}) return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName}) } h.recordPraxisTrace(ctx, "entity_attention", queried, map[string]any{ "entity_id": entityID, "count": len(items), }) var parts []string var spoken []string for _, item := range items { title, _ := item["title"].(string) if title == "" { continue } parts = append(parts, title) if id := surfaceSpoken(ctx, px, item); id != "" { spoken = append(spoken, id) } } // The scoped digest is a list she read out, so it replaces the positional // memory exactly as the unscoped one does. It used to surface these items // and remember none of them, which left the previous digest live: "отметь // второй как сделанное" then indexed into a list he had not just heard and // transitioned somebody else's item (docs/ecosystem.md — a wrong guess here // transitions the wrong item). h.rememberSurfaced(spoken) if known := h.localFactsForEntity(ctx, entityID); known != "" { parts = append(parts, known) } if len(parts) == 0 { // The scoped list is as exposed to a silent source as the unscoped one, // and a per-entity all-clear is the more convincing of the two (#540). if hedge := h.attentionCannotTell(ctx, px, att.Degraded, queried); hedge != "" { return hedge } return phraser.A(phraser.AttentionNoneEntity, map[string]string{"name": displayName}) } return phraser.A(phraser.AttentionListEntity, map[string]string{"name": displayName, "items": strings.Join(parts, "; ")}) } // surfaceSpoken marks an item she just read out as surfaced. Speaking an item // surfaces it, it does not acknowledge it (ECOSYSTEM-SPEC.md §2.3: surfaced != // acknowledged), so this calls Surface and nothing else. Best effort: a failed // surface call must not block delivering the digest. Returns the item id, or "" // when the item carried none. func surfaceSpoken(ctx context.Context, px *praxisClient, item map[string]any) string { id, _ := item["id"].(string) if id == "" { return "" } if _, err := px.Surface(ctx, id); err != nil { log.Printf("ecosystem: praxis surface %s: %v", id, err) } return id } // scopedToEntity drops items that carry an entity_id other than the one asked // about, and reports whether the response can be trusted as scoped at all. An // item without an entity_id is kept only when at least one sibling carries the // matching id: a whole page with no entity_id is a Praxis that ignored the // scope, not a page of untagged items. func scopedToEntity(items []map[string]any, entityID string) ([]map[string]any, bool) { if len(items) == 0 { return items, true } var kept []map[string]any var sawMatch, sawMismatch bool for _, item := range items { id, _ := item["entity_id"].(string) switch { case id == entityID: sawMatch = true kept = append(kept, item) case id != "": sawMismatch = true default: kept = append(kept, item) } } if sawMatch { return kept, true } if sawMismatch { // Some items were tagged and none matched: the far side answered about // other entities, so nothing here belongs to this one. return nil, true } return nil, false } // localFactsForEntity summarises Maven's own facts already resolved to this // canonical entity. Empty when the store is unavailable or nothing matched — // entity-scoped memory is an enrichment of the answer, never a precondition. func (h *reactiveHandler) localFactsForEntity(ctx context.Context, entityID string) string { if h.dataStore == nil || entityID == "" { return "" } const spoken = 3 // One over the spoken limit, so a truncation can be named rather than // passed off as everything she knows. facts, err := h.dataStore.FactsByEntity(ctx, entityID, spoken+1) if err != nil { log.Printf("ecosystem: facts by entity %s: %v", entityID, err) return "" } more := false if len(facts) > spoken { facts, more = facts[:spoken], true } var parts []string for _, f := range facts { if f.Value != "" { parts = append(parts, f.Value) } } if len(parts) == 0 { return "" } out := phraser.A(phraser.EcoRecall, map[string]string{"items": strings.Join(parts, ", ")}) if more { out += ", и это не всё" } return out } // mergeFields overlays b onto a and returns a. func mergeFields(a, b map[string]any) map[string]any { for k, v := range b { a[k] = v } return a } // recordPraxisTrace — records a completed Praxis call. Thin wrapper over // recordEcosystemTrace so every ecosystem hop lands in one table with one // shape. func (h *reactiveHandler) recordPraxisTrace(ctx context.Context, operation string, started time.Time, details map[string]any) { h.recordEcosystemTrace(ctx, "praxis", operation, traceOK, started, details) } // traceStatus classifies an ecosystem call for the trace record. Kept coarse // on purpose: a trace is read to answer "did this hop work, and how long did // it take", not to re-derive the error. const ( traceOK = "ok" traceFailed = "failed" // the call never got an answer traceRefused = "refused" // the far side answered, and said no traceAmbig = "ambiguous" traceNotFound = "not_found" tracePending = "pending" // deliberately not done yet, awaiting a confirm ) // traceStatusForError distinguishes "I could not reach it" from "it answered // and refused". Both degrade the same way for him and not at all the same way // for whoever reads the trace: one is a network or a dead service, the other // is a token, a version or a rejected argument. func traceStatusForError(err error) string { var ee *ecosystemError if errors.As(err, &ee) && !ee.Unreachable() { return traceRefused } return traceFailed } // nexusResolveFailed records a resolve that failed and returns the named gap. // The subject is his words, so the trace keeps a rune count and not the runes. func (h *reactiveHandler) nexusResolveFailed(ctx context.Context, subject string, started time.Time, err error) string { h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started, mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)})) return ecosystemGap(serviceNexus, err) } // redactSubject reduces a user utterance to something safe to persist in a // trace: its length only. Traces are diagnostics, and his words are not // diagnostics — the correlation ID is what ties a trace to the turn. func redactSubject(s string) string { return fmt.Sprintf("<%d chars>", len([]rune(s))) } // recordEcosystemTrace writes one hop of a cross-service call: which service, // which operation, the outcome, how long it took, and the correlation ID that // stitches the hops together. It is written for every outcome, not only // success — an unrecorded failure is exactly the hop you need when something // went wrong at 3am. // // Traces go to their own store table, never to facts. One act turn produces // three or four of them, at machine rate, while facts arrive at human rate: // sharing the table meant the habit profile's 2000-row window, memeval's // prompt snapshot and the /dash and /history pages all filled with traces and // stopped seeing his actual facts. func (h *reactiveHandler) recordEcosystemTrace(ctx context.Context, service, op, status string, started time.Time, fields map[string]any) { if h.dataStore == nil { return } tr := store.EcosystemTrace{ Ts: h.now(), Service: service, Operation: op, Status: status, DurationMs: h.now().Sub(started).Milliseconds(), CorrelationID: correlationIDFromCtx(ctx), Fields: map[string]any{}, } for k, v := range fields { switch k { case "causation_id": tr.CausationID, _ = v.(string) case "http_status": if n, ok := v.(int); ok { tr.HTTPStatus = n continue } tr.Fields[k] = v default: tr.Fields[k] = v } } if _, err := h.dataStore.WriteEcosystemTrace(ctx, tr); err != nil { log.Printf("ecosystem: record trace %s:%s: %v", service, op, err) } } // unauthorizedEcosystemError reports a credential the far side rejected. It // gets its own reply: a missing or wrong token looks exactly like an outage to // him, and "try again" is advice that will never work. func unauthorizedEcosystemError(err error) bool { var ee *ecosystemError return errors.As(err, &ee) && ee.Unauthorized() } // isEcosystemError reports a failure that belongs to the service rather than to // what was asked of it: a call that never landed, or one the far side refused. // It separates "Hexis is down" from "the restart failed". func isEcosystemError(err error) bool { var ee *ecosystemError return errors.As(err, &ee) } // traceErrorFields describes an ecosystemError for a trace without leaking the // payload: the HTTP status and the failure class, nothing else. func traceErrorFields(err error) map[string]any { fields := map[string]any{"class": "error"} var ee *ecosystemError if !errors.As(err, &ee) { return fields } fields["http_status"] = ee.Status switch { case ee.Unauthorized(): fields["class"] = "unauthorized" case ee.ContractMismatch(): fields["class"] = "contract_mismatch" case ee.Unreachable(): fields["class"] = "unreachable" } return fields } // entityResolution — what asking Nexus about a turn's candidate names came to. // One shape rather than five return values, because the caller needs the // reference that answered as well as the answer: it goes in the trace. type entityResolution struct { subject string // the reference Nexus answered about entityID string // set when exactly one name resolved displayName string // that entity's name as Nexus spells it ambiguous []string // candidate display names to ask between err error // a dependency failure, not a miss } // resolveEntityCandidates asks Nexus about each name the turn offered and // reports what it knows, stopping early where the answer is already decided. // // The rules, in the order they apply: // // - A dependency failure ends it. Nexus being down is not "no such entity", // and asking about the next name would report the outage as a miss. // - Nexus calling one name ambiguous ends it. It has the candidates and it is // telling us to ask. // - Two names resolving to different entities is a clarify too, this time ours: // "перезапусти nginx на muzick-indexer" names both a service and its host, // and picking either would be inventing an intent he did not state. // - Nothing resolving returns the first name as the subject, so the trace says // what was actually looked for. func (h *reactiveHandler) resolveEntityCandidates(ctx context.Context, refs []string) entityResolution { var out entityResolution for _, ref := range refs { entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, ref, nil) if err != nil { return entityResolution{subject: ref, err: err} } if len(ambiguous) > 0 { return entityResolution{subject: ref, ambiguous: ambiguous} } if entityID == "" { continue } if out.entityID == "" { out = entityResolution{subject: ref, entityID: entityID, displayName: displayName} continue } if entityID == out.entityID { continue } // Both are real and they are not the same thing. Hand back the names // Nexus spells, not the words he happened to say. return entityResolution{ subject: out.subject, ambiguous: []string{out.displayName, displayName}, } } if out.entityID == "" && len(refs) > 0 { out.subject = refs[0] } return out } // 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 { // This method is intentionally callable outside runTurn by ecosystem // harnesses. Refuse before correlation ids, Nexus resolution or capability // discovery so the no-op sentinel can never leak into Hexis as a verb. if refusesCommand(dec) { return commandProhibitionReply } if h.ecosystem == nil { return "" } // Every hop of this action shares one correlation ID, assigned here so // resolution and discovery are traceable even when execution never // happens. if correlationIDFromCtx(ctx) == "" { ctx = withCorrelationID(ctx, newCorrelationID()) } // Resolve the utterance text as an entity reference through Nexus. An // ambiguous match must stop and clarify — never guess a mutation target. // The names come from entityReferences, not straight from the Text slot: the // model transliterates Latin names as it routes (Vikunja #476, #524). started := h.now() res := h.resolveEntityCandidates(ctx, entityReferences(dec)) subject, entityID, displayName, ambiguous, err := res.subject, res.entityID, res.displayName, res.ambiguous, res.err 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 h.nexusResolveFailed(ctx, subject, started, err) } if len(ambiguous) > 0 { h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started, map[string]any{"candidates": len(ambiguous)}) return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")}) } if entityID == "" { h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started, map[string]any{"subject": redactSubject(subject)}) return "" } h.recordEcosystemTrace(ctx, "nexus", "resolve", traceOK, started, map[string]any{"entity_id": entityID}) // 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. discovered := h.now() caps, err := h.ecosystem.discoverCapabilities(ctx, entityID) if err != nil { h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID})) return ecosystemGap(serviceHexis, err) } h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered, map[string]any{"entity_id": entityID, "count": len(caps)}) 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) // With no allowlisted fn the verb is a whole phrase ("restart status muzick // indexer"), which no capability name ever contains. Read it the other way // round then: the phrase is the haystack and the capability name is what we // look for in it (Vikunja #476). Only when the fn slot is empty — a matched // fn is a single verb and containment already means what it says. loose := !dec.Slots.HasFn var matches []*hexisclient.Capability for i, c := range caps { name := strings.ToLower(c.Name) hit := strings.Contains(name, verbLower) || (c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower)) if loose && name != "" && strings.Contains(verbLower, name) { hit = true } if hit { 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 phraser.A(phraser.ActWhich, map[string]string{"name": displayName, "items": 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. // The tier decides, and Hexis owns the tier (Vikunja #523). read_only alone // used to decide it here, which flattened three answers into two: a // capability that wipes the thing it names got the same single spoken "да" // as one that restarts a service, and requires_confirmation — which the // Hexis contract calls server-derived and not settable by a caller — was // read by nobody. docs/ecosystem.md §17.3 says confirmation follows risk. tier := tool.RiskOfCapability(matched.Risk, matched.ReadOnly, matched.RequiresConfirmation) policy := tool.PolicyFor(tier) if !policy.VoiceMayRun { // Irreversible. A confirm turn would not help, for the same reason it // does not help a local row: the STT heard it, the model routed it and // a substring matched the capability, and a spoken "да" checks none of // those. She names the gap and he runs it himself. h.recordEcosystemTrace(ctx, "hexis", "confirmation", traceRefused, started, map[string]any{"entity_id": entityID, "capability": matched.Name, "risk": string(tier)}) return phraser.A(phraser.ActNeedsAuthedSurface, nil) } if policy.Confirm { h.mu.Lock() h.pendingHexis = &pendingHexisExec{ capabilityID: matched.ID, capName: matched.Name, entityID: entityID, displayName: displayName, expiry: h.now().Add(confirmTTL), // This action's id, so the execution the confirm authorises is // joined to the resolve and the discovery that proposed it. correlationID: correlationIDFromCtx(ctx), } h.mu.Unlock() h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started, map[string]any{"entity_id": entityID, "capability": matched.Name}) return phraser.A(phraser.ActConfirmEntity, map[string]string{"name": matched.Name, "name_entity": 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 { started := h.now() causationID := correlationIDFromCtx(ctx) correlationID, err := h.ecosystem.executeCapability(ctx, capID, entityID, nil) traced := withCorrelationID(ctx, correlationID) if err != nil { log.Printf("ecosystem: hexis execute error (cor=%s): %v", correlationID, err) h.recordEcosystemTrace(traced, "hexis", "execute", traceStatusForError(err), started, mergeFields(traceErrorFields(err), map[string]any{ "entity_id": entityID, "capability": capName, "causation_id": causationID, })) // Hexis never answering, or answering "no", is a gap in Hexis and is // named as one — a refused token said "не получилось выполнить команду" // here and sent him to debug a capability that was never reached // (Vikunja #587). An execution that genuinely ran and failed is not an // ecosystemError and keeps the command-level line. if isEcosystemError(err) { return ecosystemGap(serviceHexis, err) } return phraser.A(phraser.ActFailEntity, map[string]string{"name": displayName}) } // One record per hop: the second write this used to make said the same // thing under a different key, in a different shape. h.recordEcosystemTrace(traced, "hexis", "execute", traceOK, started, map[string]any{ "entity_id": entityID, "entity_name": displayName, "capability": capName, "causation_id": causationID, }) return phraser.A(phraser.ActDoneEntity, map[string]string{"name": displayName}) } // hexisBeforeClarify gives an entity-shaped act one chance at Hexis before she // asks what to do. // // The stage-3 gate thins an act that never matched an allowlisted fn, so // "перезапусти muzick indexer" was answered with "Что сделать?" and the Hexis // path was never entered — the capability existed and no utterance could reach // it (Vikunja #476). Hexis is exactly where an act with no local fn belongs: // the verb is matched against the capabilities Hexis registers for the entity, // not against the allowlist. // // Narrow on purpose. Only an act, only when the fn slot is still empty, and // only when Hexis is wired — a box with no ecosystem asks the question it // always asked. A "" back means Nexus knew no such entity or Hexis had no // matching capability, and then she asks after all. Authority is unchanged: // resolution stops on ambiguity and a mutating capability still goes through // the spoken confirm in handleHexisAct. func (h *reactiveHandler) hexisBeforeClarify(ctx context.Context, dec router.Decision) string { // A thinned model act reaches this hook before actionAct. Negative authority // must therefore stop here as well, before even a read to Nexus/Hexis. if refusesCommand(dec) { return commandProhibitionReply } if h.ecosystem == nil || h.ecosystem.hexis == nil { return "" } if dec.Intent != router.IntentAct || dec.Slots.HasFn || !router.ActHasEntityTarget(dec) { return "" } return h.handleHexisAct(ctx, dec) } // attentionCannotTell returns the hedge to say instead of an all-clear, or "" // when an empty attention list really does mean nothing needs looking at // (ECOSYSTEM-SPEC §2.6, Vikunja #540). // // "Nothing needs attention" and "I cannot currently tell" are different answers // and only one of them was ever said. The spec's mechanism is a `degraded` array // on the attention response, which the deployed Praxis does not send, so the // source health read is the half that works today. It costs one HTTP call and // only on the empty-list turn, which is the only turn where an all-clear is at // stake. // // A failed sources read is deliberately NOT a hedge. The attention call itself // succeeded, and not being able to ask about health is not evidence of a fault — // hedging on it would turn one flaky endpoint into a permanently uncertain // assistant. func (h *reactiveHandler) attentionCannotTell(ctx context.Context, px *praxisClient, degraded []string, started time.Time) string { if len(degraded) > 0 { h.recordPraxisTrace(ctx, "attention_degraded", started, map[string]any{ "degraded": strings.Join(degraded, ","), "source": "response", }) return phraser.A(phraser.AttentionDegraded, map[string]string{"items": strings.Join(degraded, ", ")}) } bad, total, err := px.UnhealthySources(ctx) if err != nil { log.Printf("ecosystem: praxis sources: %v", err) return "" } if total == 0 { // A Praxis that polls nothing knows nothing, so its silence is not an // all-clear either. This is the state the box is in as of 2026-08-05: // /api/v1/sources answers with an empty array. h.recordPraxisTrace(ctx, "attention_no_sources", started, map[string]any{"sources": 0}) return phraser.A(phraser.AttentionNoSources, nil) } if len(bad) > 0 { h.recordPraxisTrace(ctx, "attention_degraded", started, map[string]any{ "degraded": strings.Join(bad, ","), "sources": total, "source": "health", }) return phraser.A(phraser.AttentionDegraded, map[string]string{"items": strings.Join(bad, ", ")}) } return "" } // rememberSurfaced records the item ids she just read out, replacing whatever the // previous digest left. Called with the ids in speaking order (Vikunja #516). func (h *reactiveHandler) rememberSurfaced(ids []string) { h.mu.Lock() defer h.mu.Unlock() h.surfacedItems = ids } // resolveSurfacedPosition turns a positional item reference into a Praxis item // id, using the list she last read out. // // The router names a position and not an id, because only the daemon has the // list: PraxisGrammars fills the value slot with "2", "last" or "this". An id is // left alone, since "item_ab12" is already one. // // The second return says whether the turn is still Praxis's. A position that // names nothing keeps the turn and clears the slot, so the capability answers its // own "какой пункт?" — he said "второй пункт" and deserves to hear that there is // no second one. A demonstrative that resolves to nothing gives the turn BACK, // because "я это сделал" was probably never about a пункт at all. "это" also // needs the list to hold exactly one item: pointing at one of five is a guess, // and a wrong guess here transitions the wrong item. func (h *reactiveHandler) resolveSurfacedPosition(dec router.Decision) (router.Decision, bool) { ref := dec.Slots.Value if ref == "" || strings.HasPrefix(ref, "item") { return dec, true } h.mu.Lock() ids := h.surfacedItems h.mu.Unlock() idx := -1 switch { case ref == "this": if len(ids) != 1 { log.Printf("ecosystem: praxis \"это\" has no single item (%d surfaced)", len(ids)) return dec, false } idx = 0 case ref == "last": idx = len(ids) - 1 default: n, err := strconv.Atoi(ref) if err != nil || n < 1 { // Neither a position nor an id: leave it for the capability to // reject rather than silently rewriting what he said. return dec, true } idx = n - 1 } if idx < 0 || idx >= len(ids) { log.Printf("ecosystem: praxis position %q has no item (%d surfaced)", ref, len(ids)) dec.Slots.Value = "" return dec, true } dec.Slots.Value = ids[idx] return dec, true }