From 398997f5c15b1bba88cab5382edd9f05181a18c4 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 20 Jul 2026 03:05:41 +0400 Subject: [PATCH] Add typed Praxis lifecycle client + acknowledge/resolve/ignore/pin/surface verbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maven previously only called Praxis list_attention/list_changes and read untyped maps. Adds a typed praxisItem struct plus GetItem/Search/Surface/ Acknowledge/Resolve/Ignore/Pin client methods, and routes new dialogue verbs (RU + EN aliases) through handlePraxisAct to each. Also fixes a lifecycle-invariant bug: reading attention items aloud now calls Surface, not nothing — per ECOSYSTEM-SPEC.md §2.3 surfaced != acknowledged, and previously the digest path didn't record surfacing at all, so 'Maven mentioned it' left no trace distinguishable from 'never came up'. Vikunja #271. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018ghELqYhZNLub2TXGMazqA --- cmd/mavend/ecosystem.go | 101 ++++++++++++++++++++++++++++++++++++++++ cmd/mavend/voice.go | 57 +++++++++++++++++++++++ 2 files changed, 158 insertions(+) diff --git a/cmd/mavend/ecosystem.go b/cmd/mavend/ecosystem.go index 3f51f9f..317142a 100644 --- a/cmd/mavend/ecosystem.go +++ b/cmd/mavend/ecosystem.go @@ -10,6 +10,7 @@ import ( "io" "log" "net/http" + "net/url" "time" hexisclient "github.com/kami/hexis/pkg/client" @@ -162,6 +163,106 @@ func (c *praxisClient) ListChanges(ctx context.Context, limit int) ([]map[string return out, err } +// praxisItem is the typed shape of a Praxis item, decoded from the tools API's +// itemToMap output (pkg/tools/api.go in the praxis repo). Kept as a distinct +// type from the raw attention/changes maps above so lifecycle callers get +// compile-time field checks instead of map[string]any type assertions. +type praxisItem struct { + ID string `json:"id"` + SourceID string `json:"source_id"` + ExternalID string `json:"external_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + Importance int `json:"importance"` + FirstSeenAt string `json:"first_seen_at"` + LastSeenAt string `json:"last_seen_at"` + SurfacedAt string `json:"surfaced_at"` + AckedAt string `json:"acknowledged_at"` + ResolvedAt string `json:"resolved_at"` +} + +// postItemAction posts {"item_id": id} to a Praxis tools lifecycle endpoint +// and decodes the resulting item. Shared by Surface/Acknowledge/Resolve/Ignore. +func (c *praxisClient) postItemAction(ctx context.Context, path, itemID string) (*praxisItem, error) { + body, _ := json.Marshal(map[string]any{"item_id": itemID}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, fmt.Errorf("praxis %s: %s", path, http.StatusText(resp.StatusCode)) + } + var out praxisItem + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + return &out, nil +} + +// Surface marks an item read/spoken without acknowledging it (surfaced != acknowledged, +// ECOSYSTEM-SPEC.md §2.3). Callers that read attention aloud must call this, never +// Acknowledge, so "I mentioned it" stays distinguishable from "you told me you saw it". +func (c *praxisClient) Surface(ctx context.Context, itemID string) (*praxisItem, error) { + return c.postItemAction(ctx, "/api/v1/tools/surface", itemID) +} + +func (c *praxisClient) Acknowledge(ctx context.Context, itemID string) (*praxisItem, error) { + return c.postItemAction(ctx, "/api/v1/tools/acknowledge", itemID) +} + +func (c *praxisClient) Resolve(ctx context.Context, itemID string) (*praxisItem, error) { + return c.postItemAction(ctx, "/api/v1/tools/resolve", itemID) +} + +func (c *praxisClient) Ignore(ctx context.Context, itemID string) (*praxisItem, error) { + return c.postItemAction(ctx, "/api/v1/tools/ignore", itemID) +} + +func (c *praxisClient) Pin(ctx context.Context, itemID string, pinned bool) (*praxisItem, error) { + body, _ := json.Marshal(map[string]any{"item_id": itemID, "pinned": pinned}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/tools/pin", bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, fmt.Errorf("praxis pin: %s", http.StatusText(resp.StatusCode)) + } + var out praxisItem + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + return &out, nil +} + +func (c *praxisClient) GetItem(ctx context.Context, itemID string) (*praxisItem, error) { + var out praxisItem + err := c.getJSON(ctx, "/api/v1/tools/items/"+itemID, &out) + if err != nil { + return nil, err + } + return &out, nil +} + +func (c *praxisClient) Search(ctx context.Context, query string, limit int) ([]praxisItem, error) { + var out []praxisItem + err := c.getJSON(ctx, fmt.Sprintf("/api/v1/tools/search?q=%s&limit=%d", url.QueryEscape(query), limit), &out) + return out, err +} + // ecosystemWiring holds the ecosystem service clients. type ecosystemWiring struct { nexus *nexusClient diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 36d4708..1847b30 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -1169,9 +1169,66 @@ func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decisi 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 {