| 1 | package bot |
| 2 | |
| 3 | import ( |
| 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 | |
| 20 | const 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. |
| 24 | const classifierModel = "claude-haiku-4-5" |
| 25 | |
| 26 | const 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. |
| 31 | const 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. |
| 39 | func 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). |
| 48 | func 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. |
| 66 | type 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 | |
| 76 | func (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 | |
| 113 | func 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. |
| 125 | func (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. |
| 211 | func (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. |
| 237 | func 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. |
| 246 | func 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. |
| 269 | func 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. |
| 306 | func 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. |
| 343 | func (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). |
| 352 | func (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. |
| 387 | func (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. |
| 451 | var noteToSelfRE = regexp.MustCompile(`\s*\[note to self:[^\]]*\]`) |
| 452 | |
| 453 | func 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. |
| 463 | 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)`) |
| 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. |
| 469 | func (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. |
| 689 | func (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. |
| 724 | func 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 | |
| 764 | var 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). |
| 768 | func 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). |
| 877 | var appCommandHereRE = regexp.MustCompile(`(?i)(^|\s)@here(\s|$)`) |
| 878 | |
| 879 | func 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 | |