eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package bot
2
3// Views (docs/views.md): a bot shows list-shaped data — issues, headlines,
4// results — as a surface the chat itself carries, opened from an "Open view"
5// chip on the reply. A view is SCHEMA + DATA, never code: the sidecar posts a
6// view.create event (an ordinary encrypted thread message) and the client's
7// views app renders it through the shared app-schema engine.
8//
9// This file is the sidecar's half of the two-sided validation, modeled on
10// card.go's normalizeCard: decoded tool input in, canonical event fields out,
11// fail closed with an honest error the model can fix when it rides back as a
12// tool_result. The client fold re-clamps everything independently — payloads
13// aren't sender-signed, so nothing downstream trusts this pass.
14
15import (
16 "crypto/rand"
17 "encoding/hex"
18 "encoding/json"
19 "fmt"
20 "strings"
21 "time"
22 "unicode/utf8"
23)
24
25// Caps — keep in step with the fold's half in web/static/apps/views.js.
26const (
27 maxViewItems = 50
28 maxViewTitle = 80
29 maxViewGlyph = 4
30 maxViewItemTitle = 300
31 maxViewItemSub = 300
32 maxViewItemNote = 500
33 maxViewItemURL = 600
34 maxViewBytes = 32 * 1024
35)
36
37// createViewTool is available to the conversational brain AND the research
38// loop: whenever the model holds list-shaped data worth *looking at*, it can
39// turn the list into a view instead of flattening it into prose.
40func createViewTool() anthTool {
41 return anthTool{
42 Name: "create_view",
43 Description: "Show the member list-shaped data (issues, articles, results) as a VIEW — a tidy, " +
44 "searchable list pinned in this chat, each row optionally linking out. Use it whenever you have " +
45 "more than a couple of items to show: put the items in the view and keep your reply to a short " +
46 "summary — never also paste the list as text. The member opens it from an \"Open view\" chip " +
47 "that is attached to your reply automatically.",
48 InputSchema: map[string]any{
49 "type": "object",
50 "properties": map[string]any{
51 "title": map[string]any{"type": "string", "description": "The view's name, e.g. \"Top GitHub issues\" (≤80 chars)"},
52 "glyph": map[string]any{"type": "string", "description": "Optional single emoji/glyph for the view"},
53 "items": map[string]any{
54 "type": "array",
55 "description": "1–50 rows, in the order to show them",
56 "items": map[string]any{
57 "type": "object",
58 "properties": map[string]any{
59 "title": map[string]any{"type": "string", "description": "The row's main line (required, ≤300)"},
60 "subtitle": map[string]any{"type": "string", "description": "Second line, e.g. \"#412 · opened by amy\" (≤300)"},
61 "note": map[string]any{"type": "string", "description": "Small trailing detail line (≤500)"},
62 "url": map[string]any{"type": "string", "description": "https link the row opens when tapped"},
63 "accent": map[string]any{"type": "boolean", "description": "Tint the subtitle (urgent/due)"},
64 },
65 "required": []string{"title"},
66 },
67 },
68 },
69 "required": []string{"title", "items"},
70 },
71 }
72}
73
74// normalizeViewSpec validates create_view's input into the canonical event
75// fields, or an error naming exactly what to fix. Unlike the client fold
76// (which silently clamps hostile wire events), this pass REFUSES — the model
77// is mid-conversation and can correct itself.
78func normalizeViewSpec(input map[string]any) (map[string]any, error) {
79 title, ok := cardStr(input["title"], maxViewTitle)
80 if !ok {
81 return nil, fmt.Errorf("title is required, ≤%d chars", maxViewTitle)
82 }
83 glyph, ok := cardOptStr(input, "glyph", 16)
84 if !ok || utf8.RuneCountInString(glyph) > maxViewGlyph {
85 return nil, fmt.Errorf("glyph must be a short emoji/glyph (≤%d chars)", maxViewGlyph)
86 }
87 raw, ok := input["items"].([]any)
88 if !ok || len(raw) == 0 {
89 return nil, fmt.Errorf("items must be a non-empty array")
90 }
91 if len(raw) > maxViewItems {
92 return nil, fmt.Errorf("too many items (%d > %d) — trim the list", len(raw), maxViewItems)
93 }
94 items := make([]map[string]any, 0, len(raw))
95 for i, e := range raw {
96 m, ok := e.(map[string]any)
97 if !ok {
98 return nil, fmt.Errorf("items[%d] must be an object", i)
99 }
100 it, err := normalizeViewItem(m, i)
101 if err != nil {
102 return nil, err
103 }
104 items = append(items, it)
105 }
106 spec := map[string]any{"title": title, "glyph": glyph, "items": items}
107 // The whole view rides inside ONE encrypted message (64KB blob cap), so
108 // bound it well under that, envelope included. Refuse honestly — a
109 // truncated view would lie about the data.
110 if b, err := json.Marshal(spec); err != nil || len(b) > maxViewBytes {
111 return nil, fmt.Errorf("the view is too large (over %d bytes) — fewer or shorter items", maxViewBytes)
112 }
113 return spec, nil
114}
115
116func normalizeViewItem(m map[string]any, i int) (map[string]any, error) {
117 title, ok := cardStr(m["title"], maxViewItemTitle)
118 if !ok {
119 return nil, fmt.Errorf("items[%d].title is required, ≤%d chars", i, maxViewItemTitle)
120 }
121 sub, ok := cardOptStr(m, "subtitle", maxViewItemSub)
122 if !ok {
123 return nil, fmt.Errorf("items[%d].subtitle must be a string ≤%d chars", i, maxViewItemSub)
124 }
125 note, ok := cardOptStr(m, "note", maxViewItemNote)
126 if !ok {
127 return nil, fmt.Errorf("items[%d].note must be a string ≤%d chars", i, maxViewItemNote)
128 }
129 url, ok := cardOptStr(m, "url", maxViewItemURL)
130 if !ok {
131 return nil, fmt.Errorf("items[%d].url must be a string ≤%d chars", i, maxViewItemURL)
132 }
133 if url != "" && !strings.HasPrefix(strings.ToLower(url), "https://") {
134 return nil, fmt.Errorf("items[%d].url must be https", i)
135 }
136 accent, _ := m["accent"].(bool)
137 it := map[string]any{"id": fmt.Sprintf("i%d", i), "title": title}
138 if sub != "" {
139 it["subtitle"] = sub
140 }
141 if note != "" {
142 it["note"] = note
143 }
144 if url != "" {
145 it["url"] = url
146 }
147 if accent {
148 it["accent"] = true
149 }
150 return it, nil
151}
152
153// postView posts the view.create event into the thread and returns the
154// open_view chip for the reply. The SIDECAR mints the chip — labeled with the
155// view's real title — so a chip can never claim one view and open another.
156func (r *Runtime) postView(threadID string, key []byte, spec map[string]any) (map[string]any, error) {
157 var buf [6]byte
158 if _, err := rand.Read(buf[:]); err != nil {
159 return nil, fmt.Errorf("couldn't mint a view id")
160 }
161 id := "vw-" + hex.EncodeToString(buf[:])
162 payload := map[string]any{"type": "view.create", "id": id, "name": r.Session.Name, "ts": time.Now().UnixMilli()}
163 for k, v := range spec {
164 payload[k] = v
165 }
166 if err := r.sendPayload(threadID, key, payload); err != nil {
167 return nil, err
168 }
169 title, _ := spec["title"].(string)
170 return map[string]any{"type": "open_view", "view": id, "label": "Open “" + title + "”"}, nil
171}
172
173// researchCreateView handles a create_view tool call inside the research loop
174// (research.go): validate, post, remember the chip for the final answer. The
175// returned string is the tool_result — validation errors ride back verbatim
176// so the model can fix its own spec mid-loop. Two views per session: the
177// answer is one message and chips are not a menu.
178func (r *Runtime) researchCreateView(threadID string, key []byte, u anthBlock, chips *[]map[string]any) string {
179 if len(*chips) >= 2 {
180 return "You've already created two views — fold anything else into your answer text."
181 }
182 spec, err := normalizeViewSpec(u.Input)
183 if err != nil {
184 return "That view was refused: " + err.Error() + ". Fix the input and try once more."
185 }
186 chip, err := r.postView(threadID, key, spec)
187 if err != nil {
188 r.logf("research create_view: %v", err)
189 return "The view couldn't be posted — put the key points in your answer text instead."
190 }
191 *chips = append(*chips, chip)
192 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."
193}
194