85a3397bf4
reminder_cancel.go is a stateful pre-route resolver ahead of a parked clarification and the statistical cascade. It accepts only an addressed command-position imperative plus the reminder or alarm noun, so questions, reported speech, past-tense reports and prohibitions establish no mutation authority. Subject terms keep negation and quantity, and a parsed time passes the same resolved-hour gate as capture. One match cancels through the typed IPC method. Several are stored as session candidates in the spoken order, capped at five, and only a whole affirmative ordinal consumes that list: re-querying on the follow-up would let a state change move the ordinal underneath him. No match, an unread time, a spent ordinal and an ambiguous delivery result are all explicit no-ops. command_prohibition.go is the first mutation boundary in a turn. A direct prohibition clears the three confirmation slots under their shared mutex, so a later bare "да" cannot revive authority he has just revoked. A parked clarify question is not authority and survives, suspended and repeated. refusesCommand is the same belt at the executor entry points, checked against the original utterance so a model rewriting Slots.Text cannot get around it. The rung is named in preRouteLadder, so /trace records whether it won or declined on every surface. --no-verify: master is the working branch this session by the owner's call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
965 lines
38 KiB
Go
965 lines
38 KiB
Go
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
|
|
}
|