package bot // Views (docs/views.md): a bot shows list-shaped data — issues, headlines, // results — as a surface the chat itself carries, opened from an "Open view" // chip on the reply. A view is SCHEMA + DATA, never code: the sidecar posts a // view.create event (an ordinary encrypted thread message) and the client's // views app renders it through the shared app-schema engine. // // This file is the sidecar's half of the two-sided validation, modeled on // card.go's normalizeCard: decoded tool input in, canonical event fields out, // fail closed with an honest error the model can fix when it rides back as a // tool_result. The client fold re-clamps everything independently — payloads // aren't sender-signed, so nothing downstream trusts this pass. import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "strings" "time" "unicode/utf8" ) // Caps — keep in step with the fold's half in web/static/apps/views.js. const ( maxViewItems = 50 maxViewTitle = 80 maxViewGlyph = 4 maxViewItemTitle = 300 maxViewItemSub = 300 maxViewItemNote = 500 maxViewItemURL = 600 maxViewBytes = 32 * 1024 ) // createViewTool is available to the conversational brain AND the research // loop: whenever the model holds list-shaped data worth *looking at*, it can // turn the list into a view instead of flattening it into prose. func createViewTool() anthTool { return anthTool{ Name: "create_view", Description: "Show the member list-shaped data (issues, articles, results) as a VIEW — a tidy, " + "searchable list pinned in this chat, each row optionally linking out. Use it whenever you have " + "more than a couple of items to show: put the items in the view and keep your reply to a short " + "summary — never also paste the list as text. The member opens it from an \"Open view\" chip " + "that is attached to your reply automatically.", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "title": map[string]any{"type": "string", "description": "The view's name, e.g. \"Top GitHub issues\" (≤80 chars)"}, "glyph": map[string]any{"type": "string", "description": "Optional single emoji/glyph for the view"}, "items": map[string]any{ "type": "array", "description": "1–50 rows, in the order to show them", "items": map[string]any{ "type": "object", "properties": map[string]any{ "title": map[string]any{"type": "string", "description": "The row's main line (required, ≤300)"}, "subtitle": map[string]any{"type": "string", "description": "Second line, e.g. \"#412 · opened by amy\" (≤300)"}, "note": map[string]any{"type": "string", "description": "Small trailing detail line (≤500)"}, "url": map[string]any{"type": "string", "description": "https link the row opens when tapped"}, "accent": map[string]any{"type": "boolean", "description": "Tint the subtitle (urgent/due)"}, }, "required": []string{"title"}, }, }, }, "required": []string{"title", "items"}, }, } } // normalizeViewSpec validates create_view's input into the canonical event // fields, or an error naming exactly what to fix. Unlike the client fold // (which silently clamps hostile wire events), this pass REFUSES — the model // is mid-conversation and can correct itself. func normalizeViewSpec(input map[string]any) (map[string]any, error) { title, ok := cardStr(input["title"], maxViewTitle) if !ok { return nil, fmt.Errorf("title is required, ≤%d chars", maxViewTitle) } glyph, ok := cardOptStr(input, "glyph", 16) if !ok || utf8.RuneCountInString(glyph) > maxViewGlyph { return nil, fmt.Errorf("glyph must be a short emoji/glyph (≤%d chars)", maxViewGlyph) } raw, ok := input["items"].([]any) if !ok || len(raw) == 0 { return nil, fmt.Errorf("items must be a non-empty array") } if len(raw) > maxViewItems { return nil, fmt.Errorf("too many items (%d > %d) — trim the list", len(raw), maxViewItems) } items := make([]map[string]any, 0, len(raw)) for i, e := range raw { m, ok := e.(map[string]any) if !ok { return nil, fmt.Errorf("items[%d] must be an object", i) } it, err := normalizeViewItem(m, i) if err != nil { return nil, err } items = append(items, it) } spec := map[string]any{"title": title, "glyph": glyph, "items": items} // The whole view rides inside ONE encrypted message (64KB blob cap), so // bound it well under that, envelope included. Refuse honestly — a // truncated view would lie about the data. if b, err := json.Marshal(spec); err != nil || len(b) > maxViewBytes { return nil, fmt.Errorf("the view is too large (over %d bytes) — fewer or shorter items", maxViewBytes) } return spec, nil } func normalizeViewItem(m map[string]any, i int) (map[string]any, error) { title, ok := cardStr(m["title"], maxViewItemTitle) if !ok { return nil, fmt.Errorf("items[%d].title is required, ≤%d chars", i, maxViewItemTitle) } sub, ok := cardOptStr(m, "subtitle", maxViewItemSub) if !ok { return nil, fmt.Errorf("items[%d].subtitle must be a string ≤%d chars", i, maxViewItemSub) } note, ok := cardOptStr(m, "note", maxViewItemNote) if !ok { return nil, fmt.Errorf("items[%d].note must be a string ≤%d chars", i, maxViewItemNote) } url, ok := cardOptStr(m, "url", maxViewItemURL) if !ok { return nil, fmt.Errorf("items[%d].url must be a string ≤%d chars", i, maxViewItemURL) } if url != "" && !strings.HasPrefix(strings.ToLower(url), "https://") { return nil, fmt.Errorf("items[%d].url must be https", i) } accent, _ := m["accent"].(bool) it := map[string]any{"id": fmt.Sprintf("i%d", i), "title": title} if sub != "" { it["subtitle"] = sub } if note != "" { it["note"] = note } if url != "" { it["url"] = url } if accent { it["accent"] = true } return it, nil } // postView posts the view.create event into the thread and returns the // open_view chip for the reply. The SIDECAR mints the chip — labeled with the // view's real title — so a chip can never claim one view and open another. func (r *Runtime) postView(threadID string, key []byte, spec map[string]any) (map[string]any, error) { var buf [6]byte if _, err := rand.Read(buf[:]); err != nil { return nil, fmt.Errorf("couldn't mint a view id") } id := "vw-" + hex.EncodeToString(buf[:]) payload := map[string]any{"type": "view.create", "id": id, "name": r.Session.Name, "ts": time.Now().UnixMilli()} for k, v := range spec { payload[k] = v } if err := r.sendPayload(threadID, key, payload); err != nil { return nil, err } title, _ := spec["title"].(string) return map[string]any{"type": "open_view", "view": id, "label": "Open “" + title + "”"}, nil } // researchCreateView handles a create_view tool call inside the research loop // (research.go): validate, post, remember the chip for the final answer. The // returned string is the tool_result — validation errors ride back verbatim // so the model can fix its own spec mid-loop. Two views per session: the // answer is one message and chips are not a menu. func (r *Runtime) researchCreateView(threadID string, key []byte, u anthBlock, chips *[]map[string]any) string { if len(*chips) >= 2 { return "You've already created two views — fold anything else into your answer text." } spec, err := normalizeViewSpec(u.Input) if err != nil { return "That view was refused: " + err.Error() + ". Fix the input and try once more." } chip, err := r.postView(threadID, key, spec) if err != nil { r.logf("research create_view: %v", err) return "The view couldn't be posted — put the key points in your answer text instead." } *chips = append(*chips, chip) return "View created — an \"Open view\" chip is attached to your final answer automatically. Keep the answer itself to a short summary; never repeat the list as text." }