Add typed Praxis lifecycle client + acknowledge/resolve/ignore/pin/surface verbs

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ghELqYhZNLub2TXGMazqA
This commit is contained in:
kami
2026-07-20 03:05:41 +04:00
parent 29f23e3715
commit 398997f5c1
2 changed files with 158 additions and 0 deletions
+101
View File
@@ -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
+57
View File
@@ -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 {