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
3import (
4 "fmt"
5 "os"
6 "regexp"
7 "sort"
8 "strings"
9 "time"
10
11 "lchat/integrations"
12)
13
14// The model brain: the Claude-backed reply path of agent/runtime.mjs in Go.
15// It rides the same Runtime as the deterministic floor — recipes reach it via
16// the "model" action — and holds the line the trust model draws: the
17// ANTHROPIC_API_KEY and decrypted text meet only here, in the sidecar process,
18// never in the server (CLAUDE.md, the one rule; docs/bots.md).
19
20const defaultModel = "claude-sonnet-5"
21
22// classifierModel runs the bare-name "am I being addressed?" gate — a tiny
23// yes/no call, so always the cheapest model regardless of the bot's own.
24const classifierModel = "claude-haiku-4-5"
25
26const defaultPersona = "Keep replies short, warm, and easy to read."
27
28// maxSourceReadRounds bounds modelReply's read_app_source loop — a member owns
29// at most a handful of apps worth reading in one breath, and each round is a
30// full model call, so the ceiling is small and deliberate.
31const maxSourceReadRounds = 3
32
33// adaptiveThinking returns the extended-thinking config for a model, or nil for
34// models that reject it. Haiku 400s on adaptive thinking ("adaptive thinking is
35// not supported on this model") where Sonnet/Opus accept it, so a Haiku bot must
36// send no thinking field at all (nil marshals away via omitempty). Gate by name
37// because the model id is all we have; the classifier call already runs on Haiku
38// with no thinking, so this only affects the main reply path.
39func adaptiveThinking(model string) map[string]any {
40 if strings.Contains(strings.ToLower(model), "haiku") {
41 return nil
42 }
43 return map[string]any{"type": "adaptive"}
44}
45
46// safety is the non-overridable preamble; the console-set persona and docs
47// layer under it (same text as the Node runtime's SAFETY).
48func safety(name string) string {
49 app := os.Getenv("LCHAT_APP_NAME")
50 if app == "" {
51 app = "Eleven"
52 }
53 return fmt.Sprintf("You are %s, an AI assistant and a member of %s — a small, private, "+
54 "end-to-end encrypted group chat app; the server and its operator can't read messages, "+
55 "and that privacy is the whole point of the app. You are one participant among the people here. "+
56 "You may be in a one-to-one DM or in a group; in a group you're brought in only when someone addresses you directly (usually an @mention). "+
57 "You take part in the conversation — no files or web access of your own, and the ONLY ways you act "+
58 "beyond words are the tools you're given (one-tap buttons the member must tap; nothing runs from "+
59 "your say-so). Never promise anything a tool doesn't cover; if asked, say you can't yet. "+
60 "This chat may include children: always be kind and age-appropriate, and never say anything scary, adult, or unsafe. "+
61 "Each message is prefixed with the sender's name — reply naturally and don't prefix your own name.", name, app)
62}
63
64// botConfig is the console-managed config (GET /api/bot-config) applied over
65// env fallbacks. Re-fetched on every (re)connect so admin edits take effect.
66type botConfig struct {
67 SystemPrompt string `json:"system_prompt"`
68 Model string `json:"model"`
69 Docs string `json:"docs"`
70 Listen string `json:"listen"`
71 // The instance's AI gate. A pointer so a server that predates the field
72 // (or a failed fetch) reads as enabled — the key alone decides then.
73 AIEnabled *bool `json:"ai_enabled"`
74}
75
76func (r *Runtime) loadConfig() {
77 var c botConfig
78 if err := r.get("/api/bot-config", &c); err != nil {
79 r.logf("loadConfig: %v", err)
80 }
81 // Precedence matches the Node runtime: console config, then the code-shipped
82 // builtin for this identity (e.g. Eleven's app guide), then env, then the
83 // generic default.
84 name := r.Session.Name
85 r.persona = firstOf(c.SystemPrompt, builtinPersona(name), os.Getenv("LCHAT_SYSTEM"), defaultPersona)
86 r.model = firstOf(c.Model, os.Getenv("LCHAT_MODEL"), defaultModel)
87 r.docs = firstOf(c.Docs, builtinDocs(name))
88 r.listen = firstOf(c.Listen, os.Getenv("LCHAT_LISTEN"), "mention")
89 // The admin's AI gate: construct or drop the model client here, so a flip
90 // lands on the next (re)connect with no sidecar restart. With r.ai nil the
91 // deterministic command floor keeps answering, every model path stays
92 // silent, and declareKind (which runs right after this) re-badges honestly.
93 if r.AIKey != "" && (c.AIEnabled == nil || *c.AIEnabled) {
94 if r.ai == nil {
95 r.ai = newAnthropicClient(r.AIKey, r.recordUsage)
96 }
97 } else {
98 r.ai = nil
99 }
100 // The app-builder needs the model brain AND the instance's authoring
101 // guide; a fetch failure just disables the tool for this connection.
102 r.appGuide = ""
103 if r.ai != nil {
104 guide, err := r.getText("/api/member-app-guide")
105 if err != nil {
106 r.logf("member-app guide unavailable (app building off): %v", err)
107 } else {
108 r.appGuide = guide
109 }
110 }
111}
112
113func firstOf(vals ...string) string {
114 for _, v := range vals {
115 if v != "" {
116 return v
117 }
118 }
119 return ""
120}
121
122// systemPrompt layers: safety preamble, persona, fellow-helpers line (so one
123// bot seeing another's message doesn't treat it as an impostor), the
124// buttons-need-tool-calls rule, then the reference docs.
125func (r *Runtime) systemPrompt() string {
126 s := safety(r.Session.Name) + "\n\n" + r.persona
127 var others []string
128 for n := range r.agents {
129 if n != r.Session.Name {
130 others = append(others, n)
131 }
132 }
133 if len(others) > 0 {
134 s += "\n\nOther helper bots are members of this server alongside you: " + strings.Join(others, ", ") + ". " +
135 "They are legitimate, separately-run assistants (Eleven guides people around the app; " +
136 "Claude is a general companion). You may see their messages " +
137 "in shared groups; you never reply to them, and there's no need to question their presence."
138 }
139 // Context is rebuilt from message TEXT only, so the model sees its own past
140 // chip replies as bare texts and starts imitating them without the tool
141 // call — make the rule unconditional (see the forced retry in modelReply).
142 s += "\n\nYour buttons only exist when you call the matching tool: every reply that offers " +
143 "a one-tap action (\"tap to switch\", \"here you go\") MUST include that tool call — text alone " +
144 "attaches nothing, even if earlier replies in the conversation look like plain text. " +
145 "Never tell someone a button is attached without calling the tool in that same reply. " +
146 "\"[note to self: …]\" lines in the history are added automatically where you called tools " +
147 "before — never write them yourself."
148 s += "\n\nYou can also propose operations on services a member has connected via Integrations " +
149 "(domains, code, payments) with the service_op tool — its description is the complete list of " +
150 "what each service can do. Writes always go through the member's confirmation tap; nothing runs " +
151 "from your proposal alone. If they haven't connected the service, the tap will tell them so — " +
152 "proposing is still fine, or point them at Integrations (+ New chat → Integrations) to connect. " +
153 "You cannot fetch, pull up, check, or list anything from a service yourself — NEVER say you will; " +
154 "the only way data moves is the member tapping a chip you attach in that same reply. For a " +
155 "question that needs READING one or more of their connected services — especially " +
156 "cross-referencing two (\"which of my domains point at GitHub Pages?\") — attach a " +
157 "service_research chip instead: one tap consents to a short read-only look across the services " +
158 "you name, you gather what you need, and you answer in this thread. Reads only — anything that " +
159 "changes or buys stays a service_op confirmation chip."
160 s += "\n\nYou can help members act in their own apps — Reminders, To-do — with the app_command " +
161 "tool: it attaches a one-tap button carrying an exact slash command, which runs ON THEIR device " +
162 "when they tap (that's also why it works: \"at 4pm\" is read in THEIR timezone, where their " +
163 "reminders live). \"Remind me to pick up the kids at 4pm\" → app_command \"/remind pick up the " +
164 "kids at 4pm\"; \"add milk to my list\" → app_command \"/todo buy milk\". Pass their words " +
165 "through — don't convert times yourself. The tap is the permission ask: say the button's there, " +
166 "never that you've added anything (you can't; the app confirms when they tap)."
167 if r.appGuide != "" {
168 s += "\n\nYou CAN build small personal mini-apps for members — private tools like trackers, " +
169 "lists, counters, tiny games — via the create_app tool. When someone asks for an app (or " +
170 "describes a problem an app would solve and seems to want one), first make sure you " +
171 "understand the core thing being tracked and its lifecycle — at most one or two short " +
172 "clarifying questions, then build; don't interrogate. Call create_app with a precise spec; " +
173 "the build runs in the background and you post the result when it's done, so your reply " +
174 "alongside the call should only say you've started. Never claim an app was built or " +
175 "updated yourself — the build system posts the real confirmation (with the app's new " +
176 "version number) once the deploy is verified, and a build or update happens ONLY through " +
177 "a create_app call, never through your text. You can also FIX and CHANGE apps a member " +
178 "already owns, whoever built them: any they own are listed in your context with their exact " +
179 "app_ids — call create_app with that app_id (the build loads the app's current source, so " +
180 "put the precise bug or change in the spec), and call read_app_source first when you need " +
181 "to see the code to diagnose a bug or answer how the app works. Apps are personal to the " +
182 "member who asked and appear in their Apps menu (+ New chat). An app can also offer slash " +
183 "commands for quick capture from any chat (e.g. /habit water) and a companion chat view where " +
184 "its activity reads as a conversation — include those in the spec when they fit."
185 }
186 if r.Charters && r.ai != nil {
187 s += "\n\nYou CAN also create personal BOTS for members — autonomous agents that run on a " +
188 "schedule and message them, via the create_bot tool: a morning news digest, a weekly " +
189 "round-up, a daily poem. Apps are surfaces they open; bots act when nobody's looking — " +
190 "when someone asks for something scheduled, unattended, or fed by websites, that's a bot. " +
191 "Before building, make sure you know what it should do, when (ask their timezone if a " +
192 "clock time is involved), and which publications/sites to read — their words are enough; " +
193 "never ask for an RSS or feed URL. The setup runs a real first edition as a test and posts " +
194 "the outcome when done, so your reply alongside the call should only say you've started. " +
195 "To change or stop a bot you made, they just ask you here (create_bot with its bot_id to " +
196 "change it; remove_bot to retire it)."
197 }
198 if r.docs != "" {
199 s += "\n\nAlways keep this reference in mind:\n" + r.docs
200 }
201 return s
202}
203
204// replyTools is what the conversational path may call: the chip whitelist,
205// plus the app-builder when the instance serves the authoring guide, plus the
206// bot-builder on the charter host (docs/member-bots.md).
207// connected-service op proposals (service_op — always offered; the tap tells
208// the member to connect first if they haven't), plus the app-builder when the
209// instance serves the authoring guide. Everything except create_app becomes a
210// CONSENT CHIP — the client whitelists, and nothing runs until a tap.
211func (r *Runtime) replyTools(tc *ThreadCtx) []anthTool {
212 // create_view is an EXECUTOR like the builders (the sidecar posts the view
213 // itself, no tap needed — a view is inert data, docs/views.md), so it lives
214 // here rather than in the forced-retry chip palette.
215 tools := append(retryTools(), createViewTool())
216 if r.appGuide != "" {
217 tools = append(tools, createAppTool(), readAppSourceTool())
218 }
219 if r.Charters {
220 tools = append(tools, createBotTool(), removeBotTool())
221 }
222 // Memory is per-relationship and DM-only (docs/eleven-memory.md): the
223 // tools exist exactly where the memory lives — this DM's state slot.
224 if tc != nil && tc.Kind == "dm" {
225 tools = append(tools, rememberTool(), forgetTool())
226 }
227 return tools
228}
229
230// retryTools is the promised-button forced retry's palette: every CONSENT
231// CHIP the model may attach — the basic chips AND service_op (the live stall
232// this fixes: "let me pull up the open issues" retried into a palette that
233// couldn't propose the op) AND service_research (the multi-service sibling of
234// the same stall). Never the builders: a forced retry must not be able to
235// trigger a build or mint a bot, but forcing a chip is safe — nothing runs
236// without the member's tap.
237func retryTools() []anthTool {
238 return append(chipTools(), serviceOpTool(), serviceResearchTool(), appCommandTool())
239}
240
241// appCommandTool proposes running one slash command on the member's own
242// device — the doorway into their personal apps (Reminders, To-do). The tap
243// is the consent; the client re-validates the command resolves to an app and
244// renders the command string itself as the label, so the chip can't claim
245// one thing and run another.
246func appCommandTool() anthTool {
247 return anthTool{
248 Name: "app_command",
249 Description: "Attach a one-tap button that runs a slash command on the member's device when THEY tap it — " +
250 "how you file into their personal apps. Commands you may propose:\n" +
251 "/remind <what> <when> — set a reminder; time phrases like \"at 4pm\", \"in 2 hours\", \"tomorrow 9:00\" " +
252 "are parsed on their device in their local timezone, so pass the member's words through unchanged\n" +
253 "/todo <task> — add a to-do to their list\n" +
254 "One command per button, exactly as it should run. Never claim the thing is done — the tap does it " +
255 "and the app confirms.",
256 InputSchema: map[string]any{
257 "type": "object",
258 "properties": map[string]any{"command": map[string]any{"type": "string", "description": "the exact slash command, e.g. /remind pick up the kids at 4pm"}},
259 "required": []any{"command"},
260 },
261 }
262}
263
264// serviceResearchTool proposes a READ-ONLY research session across one or
265// more connected services: the chip's tap seals a short-lived read grant per
266// named service and the sidecar answers the question in a bounded tool loop
267// (research.go). The registry generates the description, so what the model
268// can name is exactly what the manifests declare.
269func serviceResearchTool() anthTool {
270 var b strings.Builder
271 b.WriteString("Propose a short READ-ONLY look across one or more services the member has connected, to answer " +
272 "one question — use this when answering needs live data from a service, or cross-referencing several " +
273 "(\"which DNSimple domains point at GitHub Pages?\"). This attaches a consent chip; when the member taps " +
274 "it you get the named services' read operations as tools and answer in this thread. Nothing runs without " +
275 "the tap, and writes are impossible in this mode. Services and what each can read:\n")
276 for _, m := range integrations.MustRegistry().List() {
277 var reads []string
278 for _, op := range m.Ops {
279 if op.Access == "read" {
280 reads = append(reads, op.Label)
281 }
282 }
283 if len(reads) == 0 {
284 continue
285 }
286 b.WriteString(m.ID + " — " + strings.Join(reads, ", ") + "\n")
287 }
288 return anthTool{
289 Name: "service_research",
290 Description: b.String(),
291 InputSchema: map[string]any{
292 "type": "object",
293 "properties": map[string]any{
294 "services": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
295 "goal": map[string]any{"type": "string", "description": "the member's question, in one sentence"},
296 },
297 "required": []any{"services", "goal"},
298 },
299 }
300}
301
302// serviceOpTool is the connected-services proposal tool. Its description IS
303// the embedded registry — the closed list of ops the manifests declare is the
304// closed list the model can propose, and actionsFromToolUse re-checks every
305// proposal against the same registry before a chip ever renders.
306func serviceOpTool() anthTool {
307 var b strings.Builder
308 b.WriteString("Propose one operation on a service the member has connected (Integrations). This attaches " +
309 "a chip; READ ops run when tapped, WRITE ops render their confirmation line and NOTHING runs until " +
310 "the member taps it. Only propose ops listed here, with exactly their args:\n")
311 for _, m := range integrations.MustRegistry().List() {
312 for _, op := range m.Ops {
313 names := make([]string, 0, len(op.Args))
314 for name := range op.Args {
315 names = append(names, name)
316 }
317 sort.Strings(names)
318 b.WriteString(m.ID + " " + op.ID + " (" + op.Access)
319 if len(names) > 0 {
320 b.WriteString("; args: " + strings.Join(names, ", "))
321 }
322 b.WriteString(") — " + op.Label + "\n")
323 }
324 }
325 return anthTool{
326 Name: "service_op",
327 Description: b.String(),
328 InputSchema: map[string]any{
329 "type": "object",
330 "properties": map[string]any{
331 "service": map[string]any{"type": "string"},
332 "op": map[string]any{"type": "string"},
333 "args": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}},
334 },
335 "required": []any{"service", "op"},
336 },
337 }
338}
339
340// systemBlocks marks the (stable, potentially large) system prompt cacheable —
341// one trailing breakpoint caches everything; small prompts fall below the
342// cache floor and are harmlessly uncached.
343func (r *Runtime) systemBlocks() []anthBlock {
344 return []anthBlock{{Type: "text", Text: r.systemPrompt(), CacheControl: &anthCache{Type: "ephemeral"}}}
345}
346
347// buildContext folds recent thread history into the Claude message list.
348// Self-identity is the session name, and history stays truthful about past
349// tool calls via the note-to-self line. Assistant turns are trimmed from both
350// ends (the API needs a leading user turn; a trailing assistant turn is a
351// hard 400 under thinking).
352func (r *Runtime) buildContext(tc *ThreadCtx) ([]anthMessage, error) {
353 hist, err := tc.History(30)
354 if err != nil {
355 return nil, err
356 }
357 var out []anthMessage
358 for _, m := range hist {
359 if m.Text == "" {
360 continue
361 }
362 if m.Mine {
363 content := m.Text
364 if m.ActionCount > 0 {
365 content += fmt.Sprintf("\n[note to self: this reply attached %d button(s) via tool calls]", m.ActionCount)
366 }
367 out = append(out, anthMessage{Role: "assistant", Content: content})
368 } else {
369 out = append(out, anthMessage{Role: "user", Content: m.Name + ": " + m.Text})
370 }
371 }
372 for len(out) > 0 && out[0].Role == "assistant" {
373 out = out[1:]
374 }
375 for len(out) > 0 && out[len(out)-1].Role == "assistant" {
376 out = out[:len(out)-1]
377 }
378 return out, nil
379}
380
381// charterHomeNote is the per-thread system addendum for a member bot's home
382// thread: which charter this chat belongs to, its exact bot id, and how to
383// change or retire it — the context that turns "change it to The Journal"
384// from a guess into an edit. Empty for every other thread. The charter is
385// re-read from the store per reply, so an edit that just landed describes
386// itself correctly.
387func (r *Runtime) charterHomeNote(tc *ThreadCtx) string {
388 if !r.Charters {
389 return ""
390 }
391 h, ok := r.charterHomeFor(tc.ID)
392 if !ok {
393 return ""
394 }
395 instKey, err := r.instanceKey()
396 if err != nil {
397 return ""
398 }
399 c, err := r.fetchCharter(h.BotID, instKey)
400 if err != nil || c == nil || c.Thread != tc.ID {
401 return ""
402 }
403 var b strings.Builder
404 fmt.Fprintf(&b, "This chat is the home of %s %s — a scheduled bot you run for the member here, "+
405 "who owns it. The %s-named messages are the bot's own editions; you reply as yourself.\n\n",
406 c.Title, c.Glyph, c.Title)
407 b.WriteString("Its charter right now — when updating, repeat any field you aren't changing exactly as given:\n")
408 fmt.Fprintf(&b, "- bot_id: %s (use this exact id; never invent another)\n", h.BotID)
409 fmt.Fprintf(&b, "- title: %s · glyph: %s\n", c.Title, c.Glyph)
410 if c.Schedule.At != "" {
411 fmt.Fprintf(&b, "- schedule: at %q", c.Schedule.At)
412 if c.Schedule.TZ != "" {
413 fmt.Fprintf(&b, ", tz %q", c.Schedule.TZ)
414 }
415 b.WriteString("\n")
416 } else {
417 fmt.Fprintf(&b, "- schedule: every %q\n", c.Schedule.Every)
418 }
419 if c.Task.Fetch != nil && len(c.Task.Fetch.Allow) > 0 {
420 // The allowlist holds discovered feed URLs; the member (and create_bot)
421 // speak in sites — so present each entry as its site, deduped.
422 var labels []string
423 seen := map[string]bool{}
424 for _, u := range c.Task.Fetch.Allow {
425 if l := hostLabel(u); !seen[l] {
426 seen[l] = true
427 labels = append(labels, l)
428 }
429 }
430 fmt.Fprintf(&b, "- sources (as sites): %s\n", strings.Join(labels, ", "))
431 } else {
432 b.WriteString("- sources: none — it writes from its own knowledge\n")
433 }
434 fmt.Fprintf(&b, "- standing instructions: %q\n\n", c.Task.Prompt)
435 b.WriteString("When the member asks to change ANYTHING about this bot — its sources (\"switch to X\"), " +
436 "schedule, name, tone, or instructions — call create_bot with the bot_id above and the COMPLETE " +
437 "updated spec (sources as sites the member would name, e.g. \"rte.ie\" — never feed URLs; the " +
438 "setup re-tests the bot and posts the outcome here). When they ask to stop, remove, or delete it, " +
439 "call remove_bot with that bot_id — this chat and its history stay theirs. Questions about what " +
440 "it does, when it runs, or why an edition looks off, answer directly from the charter above and " +
441 "the conversation.")
442 return b.String()
443}
444
445// noteToSelfRE matches the history annotation buildContext appends to past
446// assistant turns ("[note to self: this reply attached N button(s) via tool
447// calls]"). The model reads those turns as its own words, and despite the
448// system-prompt rule it eventually IMITATES the pattern, writing the bracket
449// line into a live reply (seen on team.elevenmessenger.com). The note is for
450// the model, never for members — scrub it from anything about to post.
451var noteToSelfRE = regexp.MustCompile(`\s*\[note to self:[^\]]*\]`)
452
453func scrubSelfNotes(text string) string {
454 return strings.TrimSpace(noteToSelfRE.ReplaceAllString(text, ""))
455}
456
457// promisedButton catches a reply that PROMISES an action instead of attaching
458// one: the classic "tap the button" with no tool call, and the fetch-promise
459// family ("Great! Let me pull up the open issues for you." — the live stall
460// that shipped) — the model cannot fetch anything itself, so a promise with
461// no tool call is always wrong. Matching generously is cheap insurance: the
462// forced retry only ATTACHES a consent chip; nothing runs without the tap.
463var promisedButton = regexp.MustCompile(`(?i)(\b(tap|button|chip)\b|\b(let me|i'?ll|i will|one moment while i|one sec while i)\s+(just\s+)?(pull|fetch|grab|check|look|get|list|query|remind|set a reminder|add)\b)`)
464
465// modelReply is the conversational path: build context, ask Claude (adaptive
466// thinking, chip tools), self-heal the promised-button case, return the reply.
467// ok=false (no key, API error, empty reply) means "stay silent" — the recipe
468// falls through to later rules or the degrade hint.
469func (r *Runtime) modelReply(tc *ThreadCtx) (Reply, bool) {
470 if r.ai == nil {
471 return Reply{}, false
472 }
473 if r.budgetExhausted() {
474 // The space's AI budget is spent (phase 3, docs/ai-provisioning.md):
475 // explain once, honestly, then keep to the command floor. Per the
476 // conduct rule (knowledge.md §20) the notice describes where the
477 // admin acts — never a payment link, never a repeated upsell.
478 if r.noteBudgetExhausted() {
479 return Reply{Text: "I've used this space's included AI for now, so I'm sticking to plain commands — try /help for what still works. An admin can bring the AI back from the Admin app."}, true
480 }
481 return Reply{}, false
482 }
483 // Best-effort "…is typing", re-beaten for the whole generation: a single
484 // beat's 15s expiry lapses under a long thinking run.
485 stop := make(chan struct{})
486 go func() {
487 tc.Typing(15)
488 t := time.NewTicker(10 * time.Second)
489 defer t.Stop()
490 for {
491 select {
492 case <-t.C:
493 tc.Typing(15)
494 case <-stop:
495 // An errored/empty run sends no message (the guaranteed clear
496 // signal) — stop the indicator explicitly.
497 tc.Typing(0)
498 return
499 }
500 }
501 }()
502 defer close(stop)
503
504 context, err := r.buildContext(tc)
505 if err != nil {
506 r.logf("buildContext: %v", err)
507 return Reply{}, false
508 }
509 if len(context) == 0 {
510 context = []anthMessage{{Role: "user", Content: "Say hello."}}
511 }
512 // Per-thread system addenda ride AFTER the cached block, so the big stable
513 // prompt still caches: a member bot's home thread gets its charter; a DM
514 // gets the memory of this member (docs/eleven-memory.md).
515 system := r.systemBlocks()
516 if note := r.charterHomeNote(tc); note != "" {
517 system = append(system, anthBlock{Type: "text", Text: note})
518 }
519 if tc.Kind == "dm" {
520 system = append(system, anthBlock{Type: "text", Text: r.memoryNote(tc)})
521 }
522 // The sender's own deployed apps, exact ids included (appbuild.go) — what
523 // lets "fix my habit tracker" target the existing app instead of blindly
524 // building a second one.
525 if note := r.memberAppsNote(tc.SenderUID); note != "" {
526 system = append(system, anthBlock{Type: "text", Text: note})
527 }
528 // The reply is usually one call, but read_app_source turns it into a short
529 // tool loop: the model may read the code of apps the sender owns (bounded
530 // rounds), then everything below acts on the final, read-free response.
531 messages := context
532 var resp *anthResponse
533 for round := 0; ; round++ {
534 resp, err = r.ai.create(anthRequest{
535 Model: r.model,
536 // Thinking, text and any tool call share this budget — a tight cap
537 // truncates the tool_use off the end and the chip silently vanishes.
538 MaxTokens: 4096,
539 System: system,
540 Thinking: adaptiveThinking(r.model),
541 Tools: r.replyTools(tc),
542 Messages: messages,
543 })
544 if err != nil {
545 r.logf("claude: %v", err)
546 return Reply{}, false
547 }
548 reads := false
549 for _, u := range toolUses(resp.Content) {
550 if u.Name == "read_app_source" {
551 reads = true
552 break
553 }
554 }
555 if !reads || round >= maxSourceReadRounds {
556 break
557 }
558 // Continue the turn: the API requires a tool_result for EVERY tool_use
559 // in the assistant turn we echo back, so the reads get real results and
560 // anything else gets a "not now" — chips and builders are only ever
561 // acted on from the final, read-free response below.
562 messages = append(messages, anthMessage{Role: "assistant", Content: resp.Content})
563 var results []anthBlock
564 for _, u := range toolUses(resp.Content) {
565 out := "not run — only read_app_source runs mid-turn; call this tool again in your final reply, after the reads."
566 if u.Name == "read_app_source" {
567 out = r.runReadAppSource(u.Input, tc.SenderUID)
568 }
569 results = append(results, anthBlock{Type: "tool_result", ToolUseID: u.ID, Content: out})
570 }
571 messages = append(messages, anthMessage{Role: "user", Content: results})
572 }
573 text := scrubSelfNotes(resp.text())
574 // create_app / create_bot are EXECUTORS, not chips: the sidecar itself runs
575 // the build (appbuild.go / botbuild.go) on its own goroutine so other
576 // threads keep getting replies, and posts the outcome when done. The reply
577 // here is just the ack. remove_bot is quick enough to answer inline.
578 for _, b := range resp.Content {
579 if b.Type != "tool_use" {
580 continue
581 }
582 switch b.Name {
583 case "create_app":
584 req, err := parseAppBuildReq(b.Input)
585 if err != nil {
586 return Reply{Text: "I couldn't start that build — " + err.Error() + "."}, true
587 }
588 // One build at a time per requester in a thread: if they re-ask while the
589 // last one is still running (the ~minute reads as a hang), don't kick off
590 // a second — that's how a single request becomes two deployed apps.
591 if !r.beginBuild(tc.ID, tc.SenderUID) {
592 return Reply{Text: "I'm still building your last app — hang tight, the status line will update the moment it's ready (usually about a minute). No need to ask again 🙂"}, true
593 }
594 // runAppBuild posts a live status line and updates it in place (appbuild.go)
595 // — its start, progress, and result — so we don't add a separate "on it"
596 // bubble here (that would double up with it). Keep only the model's own
597 // conversational text, if it wrote any.
598 go r.runAppBuild(tc, req, tc.Sender, tc.SenderUID)
599 return Reply{Text: text}, true
600 case "create_bot":
601 if !r.Charters {
602 continue
603 }
604 req, err := parseBotBuildReq(b.Input)
605 if err != nil {
606 return Reply{Text: "I couldn't start on that bot — " + err.Error() + "."}, true
607 }
608 // Same one-build-at-a-time guard as apps (shared slot: one long
609 // setup per requester per thread, whatever kind).
610 if !r.beginBuild(tc.ID, tc.SenderUID) {
611 return Reply{Text: "I'm still working on your last request — the status line will update the moment it's done. No need to ask again 🙂"}, true
612 }
613 go r.runBotBuild(tc, req, tc.Sender, tc.SenderUID)
614 return Reply{Text: text}, true
615 case "remove_bot":
616 if !r.Charters {
617 continue
618 }
619 botID, _ := b.Input["bot_id"].(string)
620 return r.runBotRemove(tc, strings.TrimSpace(botID), tc.SenderUID), true
621 case "create_view":
622 // Quick enough to run inline: validate, post the view.create event,
623 // and hand the reply the sidecar-minted open_view chip (the chip
624 // carries the view's real title, so it can't claim another).
625 spec, err := normalizeViewSpec(b.Input)
626 if err != nil {
627 return Reply{Text: "I couldn't lay that out as a view — " + err.Error() + "."}, true
628 }
629 chip, err := r.postView(tc.ID, tc.key, spec)
630 if err != nil {
631 r.logf("create_view: %v", err)
632 return Reply{Text: "I couldn't post that view just now — mind asking again?"}, true
633 }
634 if text == "" {
635 text = "Here you go — it's in the view below."
636 }
637 return Reply{Text: text, Actions: []map[string]any{chip}}, true
638 }
639 }
640 actions := actionsFromToolUse(resp.Content)
641 // Memory writes (remember/forget, memory.go) run inline — two small REST
642 // calls — and every successful write announces itself: the sidecar (never
643 // the model) appends an open_memory chip, so the note and the door to the
644 // scratchpad arrive in the same breath. DM-only, like the tools.
645 if tc.Kind == "dm" {
646 for _, b := range resp.Content {
647 if b.Type != "tool_use" || (b.Name != "remember" && b.Name != "forget") {
648 continue
649 }
650 if label, ok := r.runMemoryOp(tc, b); ok {
651 actions = append(actions, map[string]any{"type": "open_memory", "label": label})
652 }
653 }
654 }
655 // Self-heal the promised-button case: threads full of pre-annotation chip
656 // texts bait the model into promising a button WITHOUT the tool call. The
657 // promise is detectable, so make it deterministic: ask once more with a
658 // forced tool call (forced tool_choice requires thinking off).
659 if len(actions) == 0 && promisedButton.MatchString(text) {
660 r.logf("reply promised a button without a tool call — retrying forced")
661 resp2, err := r.ai.create(anthRequest{
662 Model: r.model,
663 MaxTokens: 4096,
664 System: system,
665 // Forced tool_choice requires thinking off; omitting the field is
666 // "off" and every model accepts it (an explicit thinking type here
667 // would 400 on Haiku, which is exactly the bug this path must avoid).
668 Tools: retryTools(),
669 ToolChoice: map[string]any{"type": "any"},
670 Messages: context,
671 })
672 if err == nil {
673 actions = actionsFromToolUse(resp2.Content)
674 if t2 := scrubSelfNotes(resp2.text()); len(actions) > 0 && t2 != "" {
675 text = t2 // keep the original text if the retry was tool-only
676 }
677 }
678 }
679 if text == "" && len(actions) == 0 {
680 r.logf("empty reply dropped (stop_reason=%s)", resp.StopReason)
681 return Reply{}, false
682 }
683 return Reply{Text: text, Actions: actions}, true
684}
685
686// isAddressed is the bare-name gate for listen mode "name": one tiny yes/no
687// call over the last few messages. Errs toward silence — @Name is the
688// guaranteed path, so a false negative is always recoverable by the sender.
689func (r *Runtime) isAddressed(tc *ThreadCtx) bool {
690 hist, err := tc.History(6)
691 if err != nil {
692 return false
693 }
694 var lines []string
695 for _, m := range hist {
696 if m.Text != "" {
697 lines = append(lines, m.Name+": "+m.Text)
698 }
699 }
700 if len(lines) == 0 {
701 return false
702 }
703 resp, err := r.ai.create(anthRequest{
704 Model: classifierModel,
705 MaxTokens: 5,
706 System: []anthBlock{{Type: "text", Text: fmt.Sprintf(
707 `"%s" is a chat bot in a group conversation. Given the recent messages, decide whether `+
708 `the LAST message is addressed to %s and expects %s to reply — as opposed to merely `+
709 `mentioning %s or talking about %s. Answer with exactly "yes" or "no".`,
710 r.Session.Name, r.Session.Name, r.Session.Name, r.Session.Name, r.Session.Name)}},
711 Messages: []anthMessage{{Role: "user", Content: strings.Join(lines, "\n")}},
712 })
713 if err != nil {
714 r.logf("addressed-classifier: %v", err)
715 return false
716 }
717 return strings.Contains(strings.ToLower(resp.text()), "yes")
718}
719
720// chipTools are the one-tap action chips the model may propose — the Go twin
721// of agent/actions.mjs CHIP_TOOLS (names drift-checked by test). The CLIENT is
722// the whitelist that matters; this only decides what the model can propose,
723// and chips never auto-run.
724func chipTools() []anthTool {
725 themeList := make([]any, len(themes))
726 for i, t := range themes {
727 themeList[i] = t
728 }
729 return []anthTool{
730 {
731 Name: "set_theme",
732 Description: "Attach a one-tap button that switches the member's colour theme when THEY tap it. " +
733 "Themes: litroom (warm amber 'Lit Room'), lagoon (teal), sunny (yellow), plum (purple), " +
734 "linen (soft neutral), classic (blue). Use when someone asks to change the look/colours.",
735 InputSchema: map[string]any{
736 "type": "object",
737 "properties": map[string]any{"theme": map[string]any{"type": "string", "enum": themeList}},
738 "required": []any{"theme"},
739 },
740 },
741 {
742 Name: "set_appearance",
743 Description: "Attach a one-tap button that switches light/dark mode when the member taps it. " +
744 "'system' follows their device. Use when someone asks for light mode, dark mode, etc.",
745 InputSchema: map[string]any{
746 "type": "object",
747 "properties": map[string]any{"appearance": map[string]any{"type": "string", "enum": []any{"light", "dark", "system"}}},
748 "required": []any{"appearance"},
749 },
750 },
751 {
752 Name: "open_theme_picker",
753 Description: "Attach a one-tap button that opens the theme picker, for when they want to browse the options.",
754 InputSchema: map[string]any{"type": "object", "properties": map[string]any{}},
755 },
756 {
757 Name: "open_profile",
758 Description: "Attach a one-tap button that opens the member's own profile editor (screen name + photo).",
759 InputSchema: map[string]any{"type": "object", "properties": map[string]any{}},
760 },
761 }
762}
763
764var appearances = []string{"light", "dark", "system"}
765
766// actionsFromToolUse maps tool_use blocks to payload actions. Unknown tools
767// and malformed inputs are dropped (the client re-validates too).
768func actionsFromToolUse(content []anthBlock) []map[string]any {
769 var out []map[string]any
770 for _, b := range content {
771 if b.Type != "tool_use" {
772 continue
773 }
774 switch b.Name {
775 case "set_theme":
776 if th, _ := b.Input["theme"].(string); themeLabels[th] != "" {
777 out = append(out, map[string]any{"type": "set_theme", "theme": th})
778 }
779 case "set_appearance":
780 if ap, _ := b.Input["appearance"].(string); contains(appearances, ap) {
781 out = append(out, map[string]any{"type": "set_appearance", "appearance": ap})
782 }
783 case "open_theme_picker":
784 out = append(out, map[string]any{"type": "open_theme_picker"})
785 case "open_profile":
786 out = append(out, map[string]any{"type": "open_profile"})
787 case "service_op":
788 // Fail at proposal, not at tap: a chip that would bounce at
789 // execution is worse than no chip, so the registry is re-consulted
790 // here and anything off-manifest is dropped.
791 service, _ := b.Input["service"].(string)
792 opID, _ := b.Input["op"].(string)
793 m := integrations.MustRegistry().Get(service)
794 if m == nil {
795 continue
796 }
797 op := m.Op(opID)
798 if op == nil {
799 continue
800 }
801 args := map[string]string{}
802 if in, _ := b.Input["args"].(map[string]any); in != nil {
803 for k, v := range in {
804 if s, ok := v.(string); ok { // only strings ride the wire
805 args[k] = s
806 }
807 }
808 }
809 valid := true
810 for k, v := range args {
811 if _, declared := op.Args[k]; !declared || len(v) > 2048 {
812 valid = false
813 break
814 }
815 }
816 if !valid {
817 continue
818 }
819 // A write chip's label is the manifest's rendered confirm line —
820 // the exact sentence the member consents to; reads use the op label.
821 label := op.Label
822 if op.Access == "write" {
823 label = op.RenderConfirm(args)
824 }
825 out = append(out, map[string]any{"type": "service_op", "service": service, "op": opID,
826 "args": args, "access": op.Access, "label": label})
827 case "service_research":
828 // Same fail-at-proposal rule as service_op: every named service must
829 // be a registry service, deduped, capped — a chip that would bounce
830 // at the tap is worse than no chip.
831 goal, _ := b.Input["goal"].(string)
832 goal = strings.TrimSpace(goal)
833 if goal == "" || len(goal) > maxResearchGoal {
834 continue
835 }
836 raw, _ := b.Input["services"].([]any)
837 var services []string
838 var names []string
839 seen := map[string]bool{}
840 for _, v := range raw {
841 id, _ := v.(string)
842 m := integrations.MustRegistry().Get(id)
843 if m == nil || seen[id] {
844 continue
845 }
846 seen[id] = true
847 services = append(services, id)
848 names = append(names, m.Name)
849 if len(services) == maxResearchServices {
850 break
851 }
852 }
853 if len(services) == 0 {
854 continue
855 }
856 label := "Let me look at " + strings.Join(names, " & ") + " (read-only)"
857 out = append(out, map[string]any{"type": "service_research", "services": services, "goal": goal, "label": label})
858 case "app_command":
859 // Mirror the client's sanitize (ui.js run_command): a slash command,
860 // one line, bounded, never @here — fail at proposal, not at tap.
861 cmd, _ := b.Input["command"].(string)
862 cmd = strings.TrimSpace(cmd)
863 if !strings.HasPrefix(cmd, "/") || len(cmd) < 2 || len(cmd) > 200 {
864 continue
865 }
866 if strings.ContainsAny(cmd, "\n\r\t") || appCommandHereRE.MatchString(cmd) {
867 continue
868 }
869 out = append(out, map[string]any{"type": "run_command", "command": cmd})
870 }
871 }
872 return out
873}
874
875// appCommandHereRE spots an @here token inside a proposed command — a group
876// install/file must never ride a tapped chip (same rule as the client's).
877var appCommandHereRE = regexp.MustCompile(`(?i)(^|\s)@here(\s|$)`)
878
879func contains(list []string, v string) bool {
880 for _, x := range list {
881 if x == v {
882 return true
883 }
884 }
885 return false
886}
887