package bot import ( "fmt" "os" "regexp" "sort" "strings" "time" "lchat/integrations" ) // The model brain: the Claude-backed reply path of agent/runtime.mjs in Go. // It rides the same Runtime as the deterministic floor — recipes reach it via // the "model" action — and holds the line the trust model draws: the // ANTHROPIC_API_KEY and decrypted text meet only here, in the sidecar process, // never in the server (CLAUDE.md, the one rule; docs/bots.md). const defaultModel = "claude-sonnet-5" // classifierModel runs the bare-name "am I being addressed?" gate — a tiny // yes/no call, so always the cheapest model regardless of the bot's own. const classifierModel = "claude-haiku-4-5" const defaultPersona = "Keep replies short, warm, and easy to read." // maxSourceReadRounds bounds modelReply's read_app_source loop — a member owns // at most a handful of apps worth reading in one breath, and each round is a // full model call, so the ceiling is small and deliberate. const maxSourceReadRounds = 3 // adaptiveThinking returns the extended-thinking config for a model, or nil for // models that reject it. Haiku 400s on adaptive thinking ("adaptive thinking is // not supported on this model") where Sonnet/Opus accept it, so a Haiku bot must // send no thinking field at all (nil marshals away via omitempty). Gate by name // because the model id is all we have; the classifier call already runs on Haiku // with no thinking, so this only affects the main reply path. func adaptiveThinking(model string) map[string]any { if strings.Contains(strings.ToLower(model), "haiku") { return nil } return map[string]any{"type": "adaptive"} } // safety is the non-overridable preamble; the console-set persona and docs // layer under it (same text as the Node runtime's SAFETY). func safety(name string) string { app := os.Getenv("LCHAT_APP_NAME") if app == "" { app = "Eleven" } return fmt.Sprintf("You are %s, an AI assistant and a member of %s — a small, private, "+ "end-to-end encrypted group chat app; the server and its operator can't read messages, "+ "and that privacy is the whole point of the app. You are one participant among the people here. "+ "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). "+ "You take part in the conversation — no files or web access of your own, and the ONLY ways you act "+ "beyond words are the tools you're given (one-tap buttons the member must tap; nothing runs from "+ "your say-so). Never promise anything a tool doesn't cover; if asked, say you can't yet. "+ "This chat may include children: always be kind and age-appropriate, and never say anything scary, adult, or unsafe. "+ "Each message is prefixed with the sender's name — reply naturally and don't prefix your own name.", name, app) } // botConfig is the console-managed config (GET /api/bot-config) applied over // env fallbacks. Re-fetched on every (re)connect so admin edits take effect. type botConfig struct { SystemPrompt string `json:"system_prompt"` Model string `json:"model"` Docs string `json:"docs"` Listen string `json:"listen"` // The instance's AI gate. A pointer so a server that predates the field // (or a failed fetch) reads as enabled — the key alone decides then. AIEnabled *bool `json:"ai_enabled"` } func (r *Runtime) loadConfig() { var c botConfig if err := r.get("/api/bot-config", &c); err != nil { r.logf("loadConfig: %v", err) } // Precedence matches the Node runtime: console config, then the code-shipped // builtin for this identity (e.g. Eleven's app guide), then env, then the // generic default. name := r.Session.Name r.persona = firstOf(c.SystemPrompt, builtinPersona(name), os.Getenv("LCHAT_SYSTEM"), defaultPersona) r.model = firstOf(c.Model, os.Getenv("LCHAT_MODEL"), defaultModel) r.docs = firstOf(c.Docs, builtinDocs(name)) r.listen = firstOf(c.Listen, os.Getenv("LCHAT_LISTEN"), "mention") // The admin's AI gate: construct or drop the model client here, so a flip // lands on the next (re)connect with no sidecar restart. With r.ai nil the // deterministic command floor keeps answering, every model path stays // silent, and declareKind (which runs right after this) re-badges honestly. if r.AIKey != "" && (c.AIEnabled == nil || *c.AIEnabled) { if r.ai == nil { r.ai = newAnthropicClient(r.AIKey, r.recordUsage) } } else { r.ai = nil } // The app-builder needs the model brain AND the instance's authoring // guide; a fetch failure just disables the tool for this connection. r.appGuide = "" if r.ai != nil { guide, err := r.getText("/api/member-app-guide") if err != nil { r.logf("member-app guide unavailable (app building off): %v", err) } else { r.appGuide = guide } } } func firstOf(vals ...string) string { for _, v := range vals { if v != "" { return v } } return "" } // systemPrompt layers: safety preamble, persona, fellow-helpers line (so one // bot seeing another's message doesn't treat it as an impostor), the // buttons-need-tool-calls rule, then the reference docs. func (r *Runtime) systemPrompt() string { s := safety(r.Session.Name) + "\n\n" + r.persona var others []string for n := range r.agents { if n != r.Session.Name { others = append(others, n) } } if len(others) > 0 { s += "\n\nOther helper bots are members of this server alongside you: " + strings.Join(others, ", ") + ". " + "They are legitimate, separately-run assistants (Eleven guides people around the app; " + "Claude is a general companion). You may see their messages " + "in shared groups; you never reply to them, and there's no need to question their presence." } // Context is rebuilt from message TEXT only, so the model sees its own past // chip replies as bare texts and starts imitating them without the tool // call — make the rule unconditional (see the forced retry in modelReply). s += "\n\nYour buttons only exist when you call the matching tool: every reply that offers " + "a one-tap action (\"tap to switch\", \"here you go\") MUST include that tool call — text alone " + "attaches nothing, even if earlier replies in the conversation look like plain text. " + "Never tell someone a button is attached without calling the tool in that same reply. " + "\"[note to self: …]\" lines in the history are added automatically where you called tools " + "before — never write them yourself." s += "\n\nYou can also propose operations on services a member has connected via Integrations " + "(domains, code, payments) with the service_op tool — its description is the complete list of " + "what each service can do. Writes always go through the member's confirmation tap; nothing runs " + "from your proposal alone. If they haven't connected the service, the tap will tell them so — " + "proposing is still fine, or point them at Integrations (+ New chat → Integrations) to connect. " + "You cannot fetch, pull up, check, or list anything from a service yourself — NEVER say you will; " + "the only way data moves is the member tapping a chip you attach in that same reply. For a " + "question that needs READING one or more of their connected services — especially " + "cross-referencing two (\"which of my domains point at GitHub Pages?\") — attach a " + "service_research chip instead: one tap consents to a short read-only look across the services " + "you name, you gather what you need, and you answer in this thread. Reads only — anything that " + "changes or buys stays a service_op confirmation chip." s += "\n\nYou can help members act in their own apps — Reminders, To-do — with the app_command " + "tool: it attaches a one-tap button carrying an exact slash command, which runs ON THEIR device " + "when they tap (that's also why it works: \"at 4pm\" is read in THEIR timezone, where their " + "reminders live). \"Remind me to pick up the kids at 4pm\" → app_command \"/remind pick up the " + "kids at 4pm\"; \"add milk to my list\" → app_command \"/todo buy milk\". Pass their words " + "through — don't convert times yourself. The tap is the permission ask: say the button's there, " + "never that you've added anything (you can't; the app confirms when they tap)." if r.appGuide != "" { s += "\n\nYou CAN build small personal mini-apps for members — private tools like trackers, " + "lists, counters, tiny games — via the create_app tool. When someone asks for an app (or " + "describes a problem an app would solve and seems to want one), first make sure you " + "understand the core thing being tracked and its lifecycle — at most one or two short " + "clarifying questions, then build; don't interrogate. Call create_app with a precise spec; " + "the build runs in the background and you post the result when it's done, so your reply " + "alongside the call should only say you've started. Never claim an app was built or " + "updated yourself — the build system posts the real confirmation (with the app's new " + "version number) once the deploy is verified, and a build or update happens ONLY through " + "a create_app call, never through your text. You can also FIX and CHANGE apps a member " + "already owns, whoever built them: any they own are listed in your context with their exact " + "app_ids — call create_app with that app_id (the build loads the app's current source, so " + "put the precise bug or change in the spec), and call read_app_source first when you need " + "to see the code to diagnose a bug or answer how the app works. Apps are personal to the " + "member who asked and appear in their Apps menu (+ New chat). An app can also offer slash " + "commands for quick capture from any chat (e.g. /habit water) and a companion chat view where " + "its activity reads as a conversation — include those in the spec when they fit." } if r.Charters && r.ai != nil { s += "\n\nYou CAN also create personal BOTS for members — autonomous agents that run on a " + "schedule and message them, via the create_bot tool: a morning news digest, a weekly " + "round-up, a daily poem. Apps are surfaces they open; bots act when nobody's looking — " + "when someone asks for something scheduled, unattended, or fed by websites, that's a bot. " + "Before building, make sure you know what it should do, when (ask their timezone if a " + "clock time is involved), and which publications/sites to read — their words are enough; " + "never ask for an RSS or feed URL. The setup runs a real first edition as a test and posts " + "the outcome when done, so your reply alongside the call should only say you've started. " + "To change or stop a bot you made, they just ask you here (create_bot with its bot_id to " + "change it; remove_bot to retire it)." } if r.docs != "" { s += "\n\nAlways keep this reference in mind:\n" + r.docs } return s } // replyTools is what the conversational path may call: the chip whitelist, // plus the app-builder when the instance serves the authoring guide, plus the // bot-builder on the charter host (docs/member-bots.md). // connected-service op proposals (service_op — always offered; the tap tells // the member to connect first if they haven't), plus the app-builder when the // instance serves the authoring guide. Everything except create_app becomes a // CONSENT CHIP — the client whitelists, and nothing runs until a tap. func (r *Runtime) replyTools(tc *ThreadCtx) []anthTool { // create_view is an EXECUTOR like the builders (the sidecar posts the view // itself, no tap needed — a view is inert data, docs/views.md), so it lives // here rather than in the forced-retry chip palette. tools := append(retryTools(), createViewTool()) if r.appGuide != "" { tools = append(tools, createAppTool(), readAppSourceTool()) } if r.Charters { tools = append(tools, createBotTool(), removeBotTool()) } // Memory is per-relationship and DM-only (docs/eleven-memory.md): the // tools exist exactly where the memory lives — this DM's state slot. if tc != nil && tc.Kind == "dm" { tools = append(tools, rememberTool(), forgetTool()) } return tools } // retryTools is the promised-button forced retry's palette: every CONSENT // CHIP the model may attach — the basic chips AND service_op (the live stall // this fixes: "let me pull up the open issues" retried into a palette that // couldn't propose the op) AND service_research (the multi-service sibling of // the same stall). Never the builders: a forced retry must not be able to // trigger a build or mint a bot, but forcing a chip is safe — nothing runs // without the member's tap. func retryTools() []anthTool { return append(chipTools(), serviceOpTool(), serviceResearchTool(), appCommandTool()) } // appCommandTool proposes running one slash command on the member's own // device — the doorway into their personal apps (Reminders, To-do). The tap // is the consent; the client re-validates the command resolves to an app and // renders the command string itself as the label, so the chip can't claim // one thing and run another. func appCommandTool() anthTool { return anthTool{ Name: "app_command", Description: "Attach a one-tap button that runs a slash command on the member's device when THEY tap it — " + "how you file into their personal apps. Commands you may propose:\n" + "/remind — set a reminder; time phrases like \"at 4pm\", \"in 2 hours\", \"tomorrow 9:00\" " + "are parsed on their device in their local timezone, so pass the member's words through unchanged\n" + "/todo — add a to-do to their list\n" + "One command per button, exactly as it should run. Never claim the thing is done — the tap does it " + "and the app confirms.", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{"command": map[string]any{"type": "string", "description": "the exact slash command, e.g. /remind pick up the kids at 4pm"}}, "required": []any{"command"}, }, } } // serviceResearchTool proposes a READ-ONLY research session across one or // more connected services: the chip's tap seals a short-lived read grant per // named service and the sidecar answers the question in a bounded tool loop // (research.go). The registry generates the description, so what the model // can name is exactly what the manifests declare. func serviceResearchTool() anthTool { var b strings.Builder b.WriteString("Propose a short READ-ONLY look across one or more services the member has connected, to answer " + "one question — use this when answering needs live data from a service, or cross-referencing several " + "(\"which DNSimple domains point at GitHub Pages?\"). This attaches a consent chip; when the member taps " + "it you get the named services' read operations as tools and answer in this thread. Nothing runs without " + "the tap, and writes are impossible in this mode. Services and what each can read:\n") for _, m := range integrations.MustRegistry().List() { var reads []string for _, op := range m.Ops { if op.Access == "read" { reads = append(reads, op.Label) } } if len(reads) == 0 { continue } b.WriteString(m.ID + " — " + strings.Join(reads, ", ") + "\n") } return anthTool{ Name: "service_research", Description: b.String(), InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "services": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, "goal": map[string]any{"type": "string", "description": "the member's question, in one sentence"}, }, "required": []any{"services", "goal"}, }, } } // serviceOpTool is the connected-services proposal tool. Its description IS // the embedded registry — the closed list of ops the manifests declare is the // closed list the model can propose, and actionsFromToolUse re-checks every // proposal against the same registry before a chip ever renders. func serviceOpTool() anthTool { var b strings.Builder b.WriteString("Propose one operation on a service the member has connected (Integrations). This attaches " + "a chip; READ ops run when tapped, WRITE ops render their confirmation line and NOTHING runs until " + "the member taps it. Only propose ops listed here, with exactly their args:\n") for _, m := range integrations.MustRegistry().List() { for _, op := range m.Ops { names := make([]string, 0, len(op.Args)) for name := range op.Args { names = append(names, name) } sort.Strings(names) b.WriteString(m.ID + " " + op.ID + " (" + op.Access) if len(names) > 0 { b.WriteString("; args: " + strings.Join(names, ", ")) } b.WriteString(") — " + op.Label + "\n") } } return anthTool{ Name: "service_op", Description: b.String(), InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "service": map[string]any{"type": "string"}, "op": map[string]any{"type": "string"}, "args": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}}, }, "required": []any{"service", "op"}, }, } } // systemBlocks marks the (stable, potentially large) system prompt cacheable — // one trailing breakpoint caches everything; small prompts fall below the // cache floor and are harmlessly uncached. func (r *Runtime) systemBlocks() []anthBlock { return []anthBlock{{Type: "text", Text: r.systemPrompt(), CacheControl: &anthCache{Type: "ephemeral"}}} } // buildContext folds recent thread history into the Claude message list. // Self-identity is the session name, and history stays truthful about past // tool calls via the note-to-self line. Assistant turns are trimmed from both // ends (the API needs a leading user turn; a trailing assistant turn is a // hard 400 under thinking). func (r *Runtime) buildContext(tc *ThreadCtx) ([]anthMessage, error) { hist, err := tc.History(30) if err != nil { return nil, err } var out []anthMessage for _, m := range hist { if m.Text == "" { continue } if m.Mine { content := m.Text if m.ActionCount > 0 { content += fmt.Sprintf("\n[note to self: this reply attached %d button(s) via tool calls]", m.ActionCount) } out = append(out, anthMessage{Role: "assistant", Content: content}) } else { out = append(out, anthMessage{Role: "user", Content: m.Name + ": " + m.Text}) } } for len(out) > 0 && out[0].Role == "assistant" { out = out[1:] } for len(out) > 0 && out[len(out)-1].Role == "assistant" { out = out[:len(out)-1] } return out, nil } // charterHomeNote is the per-thread system addendum for a member bot's home // thread: which charter this chat belongs to, its exact bot id, and how to // change or retire it — the context that turns "change it to The Journal" // from a guess into an edit. Empty for every other thread. The charter is // re-read from the store per reply, so an edit that just landed describes // itself correctly. func (r *Runtime) charterHomeNote(tc *ThreadCtx) string { if !r.Charters { return "" } h, ok := r.charterHomeFor(tc.ID) if !ok { return "" } instKey, err := r.instanceKey() if err != nil { return "" } c, err := r.fetchCharter(h.BotID, instKey) if err != nil || c == nil || c.Thread != tc.ID { return "" } var b strings.Builder fmt.Fprintf(&b, "This chat is the home of %s %s — a scheduled bot you run for the member here, "+ "who owns it. The %s-named messages are the bot's own editions; you reply as yourself.\n\n", c.Title, c.Glyph, c.Title) b.WriteString("Its charter right now — when updating, repeat any field you aren't changing exactly as given:\n") fmt.Fprintf(&b, "- bot_id: %s (use this exact id; never invent another)\n", h.BotID) fmt.Fprintf(&b, "- title: %s · glyph: %s\n", c.Title, c.Glyph) if c.Schedule.At != "" { fmt.Fprintf(&b, "- schedule: at %q", c.Schedule.At) if c.Schedule.TZ != "" { fmt.Fprintf(&b, ", tz %q", c.Schedule.TZ) } b.WriteString("\n") } else { fmt.Fprintf(&b, "- schedule: every %q\n", c.Schedule.Every) } if c.Task.Fetch != nil && len(c.Task.Fetch.Allow) > 0 { // The allowlist holds discovered feed URLs; the member (and create_bot) // speak in sites — so present each entry as its site, deduped. var labels []string seen := map[string]bool{} for _, u := range c.Task.Fetch.Allow { if l := hostLabel(u); !seen[l] { seen[l] = true labels = append(labels, l) } } fmt.Fprintf(&b, "- sources (as sites): %s\n", strings.Join(labels, ", ")) } else { b.WriteString("- sources: none — it writes from its own knowledge\n") } fmt.Fprintf(&b, "- standing instructions: %q\n\n", c.Task.Prompt) b.WriteString("When the member asks to change ANYTHING about this bot — its sources (\"switch to X\"), " + "schedule, name, tone, or instructions — call create_bot with the bot_id above and the COMPLETE " + "updated spec (sources as sites the member would name, e.g. \"rte.ie\" — never feed URLs; the " + "setup re-tests the bot and posts the outcome here). When they ask to stop, remove, or delete it, " + "call remove_bot with that bot_id — this chat and its history stay theirs. Questions about what " + "it does, when it runs, or why an edition looks off, answer directly from the charter above and " + "the conversation.") return b.String() } // noteToSelfRE matches the history annotation buildContext appends to past // assistant turns ("[note to self: this reply attached N button(s) via tool // calls]"). The model reads those turns as its own words, and despite the // system-prompt rule it eventually IMITATES the pattern, writing the bracket // line into a live reply (seen on team.elevenmessenger.com). The note is for // the model, never for members — scrub it from anything about to post. var noteToSelfRE = regexp.MustCompile(`\s*\[note to self:[^\]]*\]`) func scrubSelfNotes(text string) string { return strings.TrimSpace(noteToSelfRE.ReplaceAllString(text, "")) } // promisedButton catches a reply that PROMISES an action instead of attaching // one: the classic "tap the button" with no tool call, and the fetch-promise // family ("Great! Let me pull up the open issues for you." — the live stall // that shipped) — the model cannot fetch anything itself, so a promise with // no tool call is always wrong. Matching generously is cheap insurance: the // forced retry only ATTACHES a consent chip; nothing runs without the tap. var 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)`) // modelReply is the conversational path: build context, ask Claude (adaptive // thinking, chip tools), self-heal the promised-button case, return the reply. // ok=false (no key, API error, empty reply) means "stay silent" — the recipe // falls through to later rules or the degrade hint. func (r *Runtime) modelReply(tc *ThreadCtx) (Reply, bool) { if r.ai == nil { return Reply{}, false } if r.budgetExhausted() { // The space's AI budget is spent (phase 3, docs/ai-provisioning.md): // explain once, honestly, then keep to the command floor. Per the // conduct rule (knowledge.md §20) the notice describes where the // admin acts — never a payment link, never a repeated upsell. if r.noteBudgetExhausted() { 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 } return Reply{}, false } // Best-effort "…is typing", re-beaten for the whole generation: a single // beat's 15s expiry lapses under a long thinking run. stop := make(chan struct{}) go func() { tc.Typing(15) t := time.NewTicker(10 * time.Second) defer t.Stop() for { select { case <-t.C: tc.Typing(15) case <-stop: // An errored/empty run sends no message (the guaranteed clear // signal) — stop the indicator explicitly. tc.Typing(0) return } } }() defer close(stop) context, err := r.buildContext(tc) if err != nil { r.logf("buildContext: %v", err) return Reply{}, false } if len(context) == 0 { context = []anthMessage{{Role: "user", Content: "Say hello."}} } // Per-thread system addenda ride AFTER the cached block, so the big stable // prompt still caches: a member bot's home thread gets its charter; a DM // gets the memory of this member (docs/eleven-memory.md). system := r.systemBlocks() if note := r.charterHomeNote(tc); note != "" { system = append(system, anthBlock{Type: "text", Text: note}) } if tc.Kind == "dm" { system = append(system, anthBlock{Type: "text", Text: r.memoryNote(tc)}) } // The sender's own deployed apps, exact ids included (appbuild.go) — what // lets "fix my habit tracker" target the existing app instead of blindly // building a second one. if note := r.memberAppsNote(tc.SenderUID); note != "" { system = append(system, anthBlock{Type: "text", Text: note}) } // The reply is usually one call, but read_app_source turns it into a short // tool loop: the model may read the code of apps the sender owns (bounded // rounds), then everything below acts on the final, read-free response. messages := context var resp *anthResponse for round := 0; ; round++ { resp, err = r.ai.create(anthRequest{ Model: r.model, // Thinking, text and any tool call share this budget — a tight cap // truncates the tool_use off the end and the chip silently vanishes. MaxTokens: 4096, System: system, Thinking: adaptiveThinking(r.model), Tools: r.replyTools(tc), Messages: messages, }) if err != nil { r.logf("claude: %v", err) return Reply{}, false } reads := false for _, u := range toolUses(resp.Content) { if u.Name == "read_app_source" { reads = true break } } if !reads || round >= maxSourceReadRounds { break } // Continue the turn: the API requires a tool_result for EVERY tool_use // in the assistant turn we echo back, so the reads get real results and // anything else gets a "not now" — chips and builders are only ever // acted on from the final, read-free response below. messages = append(messages, anthMessage{Role: "assistant", Content: resp.Content}) var results []anthBlock for _, u := range toolUses(resp.Content) { out := "not run — only read_app_source runs mid-turn; call this tool again in your final reply, after the reads." if u.Name == "read_app_source" { out = r.runReadAppSource(u.Input, tc.SenderUID) } results = append(results, anthBlock{Type: "tool_result", ToolUseID: u.ID, Content: out}) } messages = append(messages, anthMessage{Role: "user", Content: results}) } text := scrubSelfNotes(resp.text()) // create_app / create_bot are EXECUTORS, not chips: the sidecar itself runs // the build (appbuild.go / botbuild.go) on its own goroutine so other // threads keep getting replies, and posts the outcome when done. The reply // here is just the ack. remove_bot is quick enough to answer inline. for _, b := range resp.Content { if b.Type != "tool_use" { continue } switch b.Name { case "create_app": req, err := parseAppBuildReq(b.Input) if err != nil { return Reply{Text: "I couldn't start that build — " + err.Error() + "."}, true } // One build at a time per requester in a thread: if they re-ask while the // last one is still running (the ~minute reads as a hang), don't kick off // a second — that's how a single request becomes two deployed apps. if !r.beginBuild(tc.ID, tc.SenderUID) { 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 } // runAppBuild posts a live status line and updates it in place (appbuild.go) // — its start, progress, and result — so we don't add a separate "on it" // bubble here (that would double up with it). Keep only the model's own // conversational text, if it wrote any. go r.runAppBuild(tc, req, tc.Sender, tc.SenderUID) return Reply{Text: text}, true case "create_bot": if !r.Charters { continue } req, err := parseBotBuildReq(b.Input) if err != nil { return Reply{Text: "I couldn't start on that bot — " + err.Error() + "."}, true } // Same one-build-at-a-time guard as apps (shared slot: one long // setup per requester per thread, whatever kind). if !r.beginBuild(tc.ID, tc.SenderUID) { 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 } go r.runBotBuild(tc, req, tc.Sender, tc.SenderUID) return Reply{Text: text}, true case "remove_bot": if !r.Charters { continue } botID, _ := b.Input["bot_id"].(string) return r.runBotRemove(tc, strings.TrimSpace(botID), tc.SenderUID), true case "create_view": // Quick enough to run inline: validate, post the view.create event, // and hand the reply the sidecar-minted open_view chip (the chip // carries the view's real title, so it can't claim another). spec, err := normalizeViewSpec(b.Input) if err != nil { return Reply{Text: "I couldn't lay that out as a view — " + err.Error() + "."}, true } chip, err := r.postView(tc.ID, tc.key, spec) if err != nil { r.logf("create_view: %v", err) return Reply{Text: "I couldn't post that view just now — mind asking again?"}, true } if text == "" { text = "Here you go — it's in the view below." } return Reply{Text: text, Actions: []map[string]any{chip}}, true } } actions := actionsFromToolUse(resp.Content) // Memory writes (remember/forget, memory.go) run inline — two small REST // calls — and every successful write announces itself: the sidecar (never // the model) appends an open_memory chip, so the note and the door to the // scratchpad arrive in the same breath. DM-only, like the tools. if tc.Kind == "dm" { for _, b := range resp.Content { if b.Type != "tool_use" || (b.Name != "remember" && b.Name != "forget") { continue } if label, ok := r.runMemoryOp(tc, b); ok { actions = append(actions, map[string]any{"type": "open_memory", "label": label}) } } } // Self-heal the promised-button case: threads full of pre-annotation chip // texts bait the model into promising a button WITHOUT the tool call. The // promise is detectable, so make it deterministic: ask once more with a // forced tool call (forced tool_choice requires thinking off). if len(actions) == 0 && promisedButton.MatchString(text) { r.logf("reply promised a button without a tool call — retrying forced") resp2, err := r.ai.create(anthRequest{ Model: r.model, MaxTokens: 4096, System: system, // Forced tool_choice requires thinking off; omitting the field is // "off" and every model accepts it (an explicit thinking type here // would 400 on Haiku, which is exactly the bug this path must avoid). Tools: retryTools(), ToolChoice: map[string]any{"type": "any"}, Messages: context, }) if err == nil { actions = actionsFromToolUse(resp2.Content) if t2 := scrubSelfNotes(resp2.text()); len(actions) > 0 && t2 != "" { text = t2 // keep the original text if the retry was tool-only } } } if text == "" && len(actions) == 0 { r.logf("empty reply dropped (stop_reason=%s)", resp.StopReason) return Reply{}, false } return Reply{Text: text, Actions: actions}, true } // isAddressed is the bare-name gate for listen mode "name": one tiny yes/no // call over the last few messages. Errs toward silence — @Name is the // guaranteed path, so a false negative is always recoverable by the sender. func (r *Runtime) isAddressed(tc *ThreadCtx) bool { hist, err := tc.History(6) if err != nil { return false } var lines []string for _, m := range hist { if m.Text != "" { lines = append(lines, m.Name+": "+m.Text) } } if len(lines) == 0 { return false } resp, err := r.ai.create(anthRequest{ Model: classifierModel, MaxTokens: 5, System: []anthBlock{{Type: "text", Text: fmt.Sprintf( `"%s" is a chat bot in a group conversation. Given the recent messages, decide whether `+ `the LAST message is addressed to %s and expects %s to reply — as opposed to merely `+ `mentioning %s or talking about %s. Answer with exactly "yes" or "no".`, r.Session.Name, r.Session.Name, r.Session.Name, r.Session.Name, r.Session.Name)}}, Messages: []anthMessage{{Role: "user", Content: strings.Join(lines, "\n")}}, }) if err != nil { r.logf("addressed-classifier: %v", err) return false } return strings.Contains(strings.ToLower(resp.text()), "yes") } // chipTools are the one-tap action chips the model may propose — the Go twin // of agent/actions.mjs CHIP_TOOLS (names drift-checked by test). The CLIENT is // the whitelist that matters; this only decides what the model can propose, // and chips never auto-run. func chipTools() []anthTool { themeList := make([]any, len(themes)) for i, t := range themes { themeList[i] = t } return []anthTool{ { Name: "set_theme", Description: "Attach a one-tap button that switches the member's colour theme when THEY tap it. " + "Themes: litroom (warm amber 'Lit Room'), lagoon (teal), sunny (yellow), plum (purple), " + "linen (soft neutral), classic (blue). Use when someone asks to change the look/colours.", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{"theme": map[string]any{"type": "string", "enum": themeList}}, "required": []any{"theme"}, }, }, { Name: "set_appearance", Description: "Attach a one-tap button that switches light/dark mode when the member taps it. " + "'system' follows their device. Use when someone asks for light mode, dark mode, etc.", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{"appearance": map[string]any{"type": "string", "enum": []any{"light", "dark", "system"}}}, "required": []any{"appearance"}, }, }, { Name: "open_theme_picker", Description: "Attach a one-tap button that opens the theme picker, for when they want to browse the options.", InputSchema: map[string]any{"type": "object", "properties": map[string]any{}}, }, { Name: "open_profile", Description: "Attach a one-tap button that opens the member's own profile editor (screen name + photo).", InputSchema: map[string]any{"type": "object", "properties": map[string]any{}}, }, } } var appearances = []string{"light", "dark", "system"} // actionsFromToolUse maps tool_use blocks to payload actions. Unknown tools // and malformed inputs are dropped (the client re-validates too). func actionsFromToolUse(content []anthBlock) []map[string]any { var out []map[string]any for _, b := range content { if b.Type != "tool_use" { continue } switch b.Name { case "set_theme": if th, _ := b.Input["theme"].(string); themeLabels[th] != "" { out = append(out, map[string]any{"type": "set_theme", "theme": th}) } case "set_appearance": if ap, _ := b.Input["appearance"].(string); contains(appearances, ap) { out = append(out, map[string]any{"type": "set_appearance", "appearance": ap}) } case "open_theme_picker": out = append(out, map[string]any{"type": "open_theme_picker"}) case "open_profile": out = append(out, map[string]any{"type": "open_profile"}) case "service_op": // Fail at proposal, not at tap: a chip that would bounce at // execution is worse than no chip, so the registry is re-consulted // here and anything off-manifest is dropped. service, _ := b.Input["service"].(string) opID, _ := b.Input["op"].(string) m := integrations.MustRegistry().Get(service) if m == nil { continue } op := m.Op(opID) if op == nil { continue } args := map[string]string{} if in, _ := b.Input["args"].(map[string]any); in != nil { for k, v := range in { if s, ok := v.(string); ok { // only strings ride the wire args[k] = s } } } valid := true for k, v := range args { if _, declared := op.Args[k]; !declared || len(v) > 2048 { valid = false break } } if !valid { continue } // A write chip's label is the manifest's rendered confirm line — // the exact sentence the member consents to; reads use the op label. label := op.Label if op.Access == "write" { label = op.RenderConfirm(args) } out = append(out, map[string]any{"type": "service_op", "service": service, "op": opID, "args": args, "access": op.Access, "label": label}) case "service_research": // Same fail-at-proposal rule as service_op: every named service must // be a registry service, deduped, capped — a chip that would bounce // at the tap is worse than no chip. goal, _ := b.Input["goal"].(string) goal = strings.TrimSpace(goal) if goal == "" || len(goal) > maxResearchGoal { continue } raw, _ := b.Input["services"].([]any) var services []string var names []string seen := map[string]bool{} for _, v := range raw { id, _ := v.(string) m := integrations.MustRegistry().Get(id) if m == nil || seen[id] { continue } seen[id] = true services = append(services, id) names = append(names, m.Name) if len(services) == maxResearchServices { break } } if len(services) == 0 { continue } label := "Let me look at " + strings.Join(names, " & ") + " (read-only)" out = append(out, map[string]any{"type": "service_research", "services": services, "goal": goal, "label": label}) case "app_command": // Mirror the client's sanitize (ui.js run_command): a slash command, // one line, bounded, never @here — fail at proposal, not at tap. cmd, _ := b.Input["command"].(string) cmd = strings.TrimSpace(cmd) if !strings.HasPrefix(cmd, "/") || len(cmd) < 2 || len(cmd) > 200 { continue } if strings.ContainsAny(cmd, "\n\r\t") || appCommandHereRE.MatchString(cmd) { continue } out = append(out, map[string]any{"type": "run_command", "command": cmd}) } } return out } // appCommandHereRE spots an @here token inside a proposed command — a group // install/file must never ride a tapped chip (same rule as the client's). var appCommandHereRE = regexp.MustCompile(`(?i)(^|\s)@here(\s|$)`) func contains(list []string, v string) bool { for _, x := range list { if x == v { return true } } return false }