eleven / ocho Public

server: bot model layer — Claude-backed replies for the Go runtime (#309)

The conversational reply path of agent/runtime.mjs in Go, reached via the
recipe 'model' action. internal/bot/anthropic.go is a minimal net/http
Messages client (no new dependency); brain.go layers the system prompt
(non-overridable safety preamble -> console persona -> fellow-helpers line ->
docs), marks it cacheable, sends adaptive thinking + chip tools, self-heals the
promised-button case with a forced-tool retry, and gates listen-mode 'name'
behind the Haiku classifier — all as in the Node runtime.

Set ANTHROPIC_API_KEY and the bot self-declares kind 'ai' and upgrades from the
command floor to conversational replies; with no key the model rule falls
through, so a recipe reads the same with or without a brain. The three shipped
bots (Announcer, deterministic floor, conversational Eleven/Claude) now reach
Node parity.

The ThreadCtx handed to a Handler carries the thread's services (history,
typing, ModelReply). Adds a Ready hook on Runtime/Engine (fired once the bot is
live) so a supervisor — and the model e2e test — can know the connection is up
before posting; this also removed a latent connect-window race in the tests.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell d8e9cc248ac09e3a44ba89ccfa97b5fc6f749b24 parent bd67555
12 files changed, +826 −88
  • bot_run_test.go +121 −26
  • botcmd.go +4 −1
  • docs/bot-recipes.md +14 −4
  • docs/bots.md +17 −7
  • internal/bot/anthropic.go +115 −0
  • internal/bot/brain.go +341 −0
  • internal/bot/brain_test.go +47 −0
  • internal/bot/commands.go +2 −2
  • internal/bot/commands_test.go +4 −4
  • internal/bot/recipe.go +30 −12
  • internal/bot/recipe_test.go +7 −7
  • internal/bot/runtime.go +124 −25
diff --git a/bot_run_test.go b/bot_run_test.go
index 947ed0d..9c00d1e 100644
--- a/bot_run_test.go
+++ b/bot_run_test.go
@@ -5,8 +5,10 @@ import (
"context"
"encoding/json"
"net/http"
+ "net/http/httptest"
"os"
"strings"
+ "sync"
"testing"
"time"
@@ -121,6 +123,18 @@ func waitFor(t *testing.T, what string, cond func() bool) {
t.Fatalf("timed out waiting for %s", what)
}
+// awaitReady blocks until the bot is live (reading frames). A message posted
+// before then is past the connect-time cursor and would only surface on a
+// reconnect — so tests that post-then-assert must wait for this first.
+func awaitReady(t *testing.T, ready <-chan struct{}) {
+ t.Helper()
+ select {
+ case <-ready:
+ case <-time.After(5 * time.Second):
+ t.Fatal("bot never became ready")
+ }
+}
+
// TestBotRunCommandFloor drives the live runtime end-to-end against the real
// router: a provisioned bot connects over WebSocket, and the deterministic
// command floor answers a group @mention and a DM — with the non-mention group
@@ -132,19 +146,13 @@ func TestBotRunCommandFloor(t *testing.T) {
eleven := provisionNamed(t, ts.URL, "Eleven", true)
groupID, groupKey := createGroupWith(t, ts.URL, s, owner, eleven)
+ ready := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- rt := &bot.Runtime{Base: ts.URL, Session: eleven, Handler: bot.CommandFloor("Eleven", false), Logf: t.Logf}
+ rt := &bot.Runtime{Base: ts.URL, Session: eleven, Handler: bot.CommandFloor("Eleven", false),
+ Ready: func() { close(ready) }, Logf: t.Logf}
go rt.Run(ctx)
-
- // declareKind runs during connect setup, so the stored kind doubles as
- // "the bot is connected and listening".
- uid, _ := s.db.deviceTokenUser(eleven.Token)
- waitFor(t, "bot to connect", func() bool {
- var kind string
- s.db.sql.QueryRow("SELECT COALESCE(kind, '') FROM bot_configs WHERE user_id = ?", uid).Scan(&kind)
- return kind == "bot"
- })
+ awaitReady(t, ready)
// A plain group message must not trigger the bot; the @mention after it must.
postAs(t, ts.URL, owner, groupID, groupKey, "just chatting amongst ourselves")
@@ -192,17 +200,13 @@ func TestBotRunIgnoresOtherAgents(t *testing.T) {
// Announcer in Eleven's (connections-only) roster.
threadID, threadKey := createThreadKind(t, ts.URL, s, "group", owner, eleven, announcer)
+ ready := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- rt := &bot.Runtime{Base: ts.URL, Session: eleven, Handler: bot.CommandFloor("Eleven", false), Logf: t.Logf}
+ rt := &bot.Runtime{Base: ts.URL, Session: eleven, Handler: bot.CommandFloor("Eleven", false),
+ Ready: func() { close(ready) }, Logf: t.Logf}
go rt.Run(ctx)
-
- uid, _ := s.db.deviceTokenUser(eleven.Token)
- waitFor(t, "bot to connect", func() bool {
- var kind string
- s.db.sql.QueryRow("SELECT COALESCE(kind, '') FROM bot_configs WHERE user_id = ?", uid).Scan(&kind)
- return kind == "bot"
- })
+ awaitReady(t, ready)
// An agent-authored mention must be skipped; prove it with an ordinary
// mention after it that gets the (single) reply.
@@ -246,22 +250,18 @@ func TestBotEngineRecipe(t *testing.T) {
t.Fatal(err)
}
+ ready := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath, Recipe: recipe, Logf: t.Logf}
+ eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath, Recipe: recipe,
+ Ready: func() { close(ready) }, Logf: t.Logf}
go eng.Run(ctx)
+ awaitReady(t, ready)
// The changelog poller baselines silently on its first tick.
statePath := bot.StatePathFor(sessionPath)
waitFor(t, "announce baseline", func() bool { _, err := os.Stat(statePath); return err == nil })
- uid, _ := s.db.deviceTokenUser(eleven.Token)
- waitFor(t, "bot to connect", func() bool {
- var kind string
- s.db.sql.QueryRow("SELECT COALESCE(kind, '') FROM bot_configs WHERE user_id = ?", uid).Scan(&kind)
- return kind == "bot"
- })
-
// Unaddressed keyword → the watcher's templated nudge.
postAs(t, ts.URL, owner, groupID, groupKey, "morning guys")
waitFor(t, "nudge", func() bool { return len(botReplies(t, s, groupID, groupKey, "Eleven")) > 0 })
@@ -303,3 +303,98 @@ func TestBotEngineRecipe(t *testing.T) {
return false
})
}
+
+// fakeAnthropic stands in for the Messages API: it records the last request and
+// returns a scripted reply (optionally with a tool_use chip). Pointed at via
+// ANTHROPIC_BASE_URL, it lets the model path run end-to-end with no network.
+type fakeAnthropic struct {
+ srv *httptest.Server
+ mu sync.Mutex
+ lastReq map[string]any
+ reply string
+ tool map[string]any // e.g. {"name":"set_theme","input":{"theme":"lagoon"}}
+ calls int
+}
+
+func newFakeAnthropic(t *testing.T, reply string, tool map[string]any) *fakeAnthropic {
+ f := &fakeAnthropic{reply: reply, tool: tool}
+ f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var req map[string]any
+ json.NewDecoder(r.Body).Decode(&req)
+ f.mu.Lock()
+ f.lastReq = req
+ f.calls++
+ f.mu.Unlock()
+ content := []map[string]any{}
+ if f.reply != "" {
+ content = append(content, map[string]any{"type": "text", "text": f.reply})
+ }
+ if f.tool != nil {
+ content = append(content, map[string]any{
+ "type": "tool_use", "name": f.tool["name"], "input": f.tool["input"],
+ })
+ }
+ json.NewEncoder(w).Encode(map[string]any{"content": content, "stop_reason": "end_turn"})
+ }))
+ t.Cleanup(f.srv.Close)
+ return f
+}
+
+// TestBotEngineModelReply drives the model action end-to-end: a mentioned
+// message with no matching command reaches the "model" rule, which asks the
+// (fake) Claude endpoint and posts its reply with the chip it proposed.
+func TestBotEngineModelReply(t *testing.T) {
+ fake := newFakeAnthropic(t, "Sure — switching you to Lagoon.",
+ map[string]any{"name": "set_theme", "input": map[string]any{"theme": "lagoon"}})
+ t.Setenv("ANTHROPIC_BASE_URL", fake.srv.URL)
+
+ ts, s := newTestServer(t)
+ owner := provisionNamed(t, ts.URL, "Owner", false)
+ eleven := provisionNamed(t, ts.URL, "Eleven", true)
+ groupID, groupKey := createGroupWith(t, ts.URL, s, owner, eleven)
+
+ dir := t.TempDir()
+ sessionPath := dir + "/eleven.session.json"
+ if err := eleven.Save(sessionPath); err != nil {
+ t.Fatal(err)
+ }
+ // Default recipe (commands then model) covers this — no recipe file needed.
+ ready := make(chan struct{})
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath,
+ Recipe: bot.DefaultRecipe(), AIKey: "test-key",
+ Ready: func() { close(ready) }, Logf: t.Logf}
+ go eng.Run(ctx)
+
+ awaitReady(t, ready)
+ // A model brain badges "ai".
+ uid, _ := s.db.deviceTokenUser(eleven.Token)
+ waitFor(t, "bot badge ai", func() bool {
+ var kind string
+ s.db.sql.QueryRow("SELECT COALESCE(kind, '') FROM bot_configs WHERE user_id = ?", uid).Scan(&kind)
+ return kind == "ai"
+ })
+
+ postAs(t, ts.URL, owner, groupID, groupKey, "@Eleven make it lagoon please")
+ waitFor(t, "model reply", func() bool { return len(botReplies(t, s, groupID, groupKey, "Eleven")) > 0 })
+ replies := botReplies(t, s, groupID, groupKey, "Eleven")
+ if replies[0].Text != "Sure — switching you to Lagoon." {
+ t.Fatalf("bad model reply text: %+v", replies[0])
+ }
+ if len(replies[0].Actions) != 1 || replies[0].Actions[0]["theme"] != "lagoon" {
+ t.Fatalf("model reply missing chip: %+v", replies[0])
+ }
+
+ // The request carried the cacheable system prompt and the triggering line.
+ fake.mu.Lock()
+ defer fake.mu.Unlock()
+ sys, _ := fake.lastReq["system"].([]any)
+ if len(sys) == 0 {
+ t.Fatal("no system blocks sent")
+ }
+ block := sys[0].(map[string]any)
+ if _, ok := block["cache_control"]; !ok {
+ t.Error("system block not marked cacheable")
+ }
+}
diff --git a/botcmd.go b/botcmd.go
index d26701a..a1bb380 100644
--- a/botcmd.go
+++ b/botcmd.go
@@ -137,7 +137,10 @@ func runBotRun(args []string) {
Session: sess,
SessionPath: path,
Recipe: recipe,
- Logf: log.Printf,
+ // With a key the bots upgrade to conversational AI (and badge AI);
+ // without one they run the deterministic floor and badge BOT.
+ AIKey: strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")),
+ Logf: log.Printf,
}
go func() {
if err := eng.Run(context.Background()); err != nil {
diff --git a/docs/bot-recipes.md b/docs/bot-recipes.md
index b74d98a..ca1b28a 100644
--- a/docs/bot-recipes.md
+++ b/docs/bot-recipes.md
@@ -70,13 +70,23 @@ are skipped.
- **`reply`** — post `text` in the thread, with `{name}` replaced by the
sender's name. Deterministic, no model call — per `docs/bots.md`, a fixed
template is often the *best* form for a nudge. Message rules only.
+- **`model`** — phrase the reply with Claude (the runtime's model layer,
+ `internal/bot/brain.go`): builds context from thread history, calls the
+ Messages API with the layered system prompt + chip tools, posts the result.
+ Needs `ANTHROPIC_API_KEY` (the bot then badges `ai`); with no key it falls
+ through to later rules, so a recipe reads the same with or without a brain.
+ This is the one action that sends text to a provider — hence its place in a
+ watcher matters (below). Message rules only.
- **`announce`** — post fresh changelog entries to every thread the bot is in
(the full Announcer behaviour, including the silent first-run baseline).
Changelog rules only.
-Planned, not in v1: a `model` action (phrase the response with the LLM — the
-runtime's model layer), a `schedule` trigger, and a private-DM nudge variant.
-Each lands as a schema addition with its own review, per the boundary above.
+The **default recipe** (no file) is `commands` then `model`: answer a command
+when addressed, else let the brain reply — exactly the Node sidecar with and
+without a key.
+
+Planned, not in v1: a `schedule` trigger and a private-DM nudge variant. Each
+lands as a schema addition with its own review, per the boundary above.
## Watchers are loud
@@ -87,7 +97,7 @@ message"* at startup), and the consent story rides on it: adding a watcher to
a group is the consent moment, and the admin console should show the flag
wherever the bot appears. Detection stays in the sidecar — with a
deterministic `reply` action, a watched message **never leaves the box at
-all**; when the `model` action exists, only *matches* ever reach the provider.
+all**; with a `model` action, only *matches* ever reach the provider.
## Execution
diff --git a/docs/bots.md b/docs/bots.md
index 8c96c3b..3d9451b 100644
--- a/docs/bots.md
+++ b/docs/bots.md
@@ -127,15 +127,25 @@ drift-pinned to `web/static/bot-commands.js` by a test), so a keyless Go bot
answers exactly like a keyless Node one. One process hosts many identities
(one session file per bot on the command line), and one deliberate divergence:
the Go runtime answers the *first* message of a newly-seen thread (Node
-baselines the cursor past it and stays silent). Remaining for full Node
-parity: the model layer (Claude API + Eleven's persona; welcome stays
-client-side, see `docs/conversation-scripts.md`). **And recipes**: the runtime
-is now recipe-shaped (`internal/bot/recipe.go`, schema + rationale in
+baselines the cursor past it and stays silent). **And recipes**: the runtime
+is recipe-shaped (`internal/bot/recipe.go`, schema + rationale in
`docs/bot-recipes.md`) — behaviour as an ordered trigger→action rule list from
-a bounded palette (message × trigger/any/keyword/regex → commands/reply;
+a bounded palette (message × trigger/any/keyword/regex → commands/reply/model;
changelog → announce), validated fail-closed, one `<session>.recipe.json` per
-identity, watcher rules loudly flagged. The Announcer is expressible as a
-recipe, and the planned `model` action is where the Claude layer lands.
+identity, watcher rules loudly flagged. **And the model layer**: `brain.go` is
+the Claude-backed reply path (`internal/bot/anthropic.go`, a minimal net/http
+Messages client — no new dependency) reached via the recipe `model` action.
+Set `ANTHROPIC_API_KEY` and the bot self-declares kind `ai` and upgrades from
+the command floor to conversational replies — same layered system prompt
+(non-overridable safety preamble → console persona → fellow-helpers line →
+docs), cache-marked system block, adaptive thinking, the chip tools
+(drift-pinned to `actions.mjs`), the promised-button forced-retry self-heal,
+and the listen-mode "name" classifier gate — all as in `runtime.mjs`. **The Go
+port now reaches Node parity for the three shipped bots** (Announcer, the
+deterministic floor, and conversational Eleven/Claude); what stays in Node is
+only the connected-services capability ops (`handleConnect`/`handleOAuthExchange`)
+and Eleven's onboarding welcomer, which is deliberately moving client-side as
+data (`docs/conversation-scripts.md`), not into the sidecar.
## What exists today (`agent/`)
diff --git a/internal/bot/anthropic.go b/internal/bot/anthropic.go
new file mode 100644
index 0000000..22d129b
--- /dev/null
+++ b/internal/bot/anthropic.go
@@ -0,0 +1,115 @@
+package bot
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "time"
+)
+
+// A minimal Anthropic Messages API client — just the slice the bot brain
+// needs (system blocks with cache markers, adaptive thinking, tools), on
+// net/http so the static binary takes no new dependency. Base URL and key
+// come from the same env the SDKs read (ANTHROPIC_API_KEY,
+// ANTHROPIC_BASE_URL), so tests point it at an httptest server.
+
+const anthropicVersion = "2023-06-01"
+
+type anthropicClient struct {
+ key string
+ base string
+ http *http.Client
+}
+
+func newAnthropicClient(key string) *anthropicClient {
+ base := os.Getenv("ANTHROPIC_BASE_URL")
+ if base == "" {
+ base = "https://api.anthropic.com"
+ }
+ // Long timeout: a thinking run over a large cached system prompt can
+ // legitimately take a couple of minutes.
+ return &anthropicClient{key: key, base: base, http: &http.Client{Timeout: 5 * time.Minute}}
+}
+
+type anthBlock struct {
+ Type string `json:"type"`
+ Text string `json:"text,omitempty"`
+ // CacheControl marks the prefix up to this block cacheable (the big system
+ // prompt rides every call — see systemBlocks in brain.go).
+ CacheControl *anthCache `json:"cache_control,omitempty"`
+ // tool_use response fields
+ Name string `json:"name,omitempty"`
+ Input map[string]any `json:"input,omitempty"`
+}
+
+type anthCache struct {
+ Type string `json:"type"` // "ephemeral"
+}
+
+type anthMessage struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+}
+
+type anthTool struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ InputSchema map[string]any `json:"input_schema"`
+}
+
+type anthRequest struct {
+ Model string `json:"model"`
+ MaxTokens int `json:"max_tokens"`
+ System []anthBlock `json:"system,omitempty"`
+ Thinking map[string]any `json:"thinking,omitempty"`
+ Tools []anthTool `json:"tools,omitempty"`
+ ToolChoice map[string]any `json:"tool_choice,omitempty"`
+ Messages []anthMessage `json:"messages"`
+}
+
+type anthResponse struct {
+ Content []anthBlock `json:"content"`
+ StopReason string `json:"stop_reason"`
+}
+
+func (c *anthropicClient) create(req anthRequest) (*anthResponse, error) {
+ body, err := json.Marshal(req)
+ if err != nil {
+ return nil, err
+ }
+ hr, err := http.NewRequest("POST", c.base+"/v1/messages", bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ hr.Header.Set("Content-Type", "application/json")
+ hr.Header.Set("x-api-key", c.key)
+ hr.Header.Set("anthropic-version", anthropicVersion)
+ resp, err := c.http.Do(hr)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ msg, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
+ return nil, fmt.Errorf("anthropic: %d: %s", resp.StatusCode, msg)
+ }
+ var out anthResponse
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+// text joins the response's text blocks.
+func (r *anthResponse) text() string {
+ var b bytes.Buffer
+ for _, blk := range r.Content {
+ if blk.Type == "text" {
+ b.WriteString(blk.Text)
+ }
+ }
+ return b.String()
+}
diff --git a/internal/bot/brain.go b/internal/bot/brain.go
new file mode 100644
index 0000000..cc93868
--- /dev/null
+++ b/internal/bot/brain.go
@@ -0,0 +1,341 @@
+package bot
+
+import (
+ "fmt"
+ "os"
+ "regexp"
+ "strings"
+ "time"
+)
+
+// 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."
+
+// 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 can only take part in the conversation — no reminders, notifications, files, or web access — "+
+ "so never promise to do those things; 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"`
+}
+
+func (r *Runtime) loadConfig() {
+ var c botConfig
+ if err := r.get("/api/bot-config", &c); err != nil {
+ r.logf("loadConfig: %v", err)
+ }
+ r.persona = firstOf(c.SystemPrompt, os.Getenv("LCHAT_SYSTEM"), defaultPersona)
+ r.model = firstOf(c.Model, os.Getenv("LCHAT_MODEL"), defaultModel)
+ r.docs = c.Docs
+ r.listen = firstOf(c.Listen, os.Getenv("LCHAT_LISTEN"), "mention")
+}
+
+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; " +
+ "Announcer posts release notes; 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."
+ if r.docs != "" {
+ s += "\n\nAlways keep this reference in mind:\n" + r.docs
+ }
+ return s
+}
+
+// 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
+}
+
+var promisedButton = regexp.MustCompile(`(?i)\b(tap|button|chip)\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
+ }
+ // 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."}}
+ }
+ 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: r.systemBlocks(),
+ Thinking: map[string]any{"type": "adaptive"},
+ Tools: chipTools(),
+ Messages: context,
+ })
+ if err != nil {
+ r.logf("claude: %v", err)
+ return Reply{}, false
+ }
+ text := strings.TrimSpace(resp.text())
+ actions := actionsFromToolUse(resp.Content)
+ // 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: r.systemBlocks(),
+ Thinking: map[string]any{"type": "disabled"},
+ Tools: chipTools(),
+ ToolChoice: map[string]any{"type": "any"},
+ Messages: context,
+ })
+ if err == nil {
+ actions = actionsFromToolUse(resp2.Content)
+ if t2 := strings.TrimSpace(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"})
+ }
+ }
+ return out
+}
+
+func contains(list []string, v string) bool {
+ for _, x := range list {
+ if x == v {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/bot/brain_test.go b/internal/bot/brain_test.go
new file mode 100644
index 0000000..b472517
--- /dev/null
+++ b/internal/bot/brain_test.go
@@ -0,0 +1,47 @@
+package bot
+
+import (
+ "os"
+ "regexp"
+ "testing"
+)
+
+// TestChipToolsMatchJS pins the Go chip-tool names to agent/actions.mjs
+// CHIP_TOOLS — same drift-proofing as the command floor.
+func TestChipToolsMatchJS(t *testing.T) {
+ raw, err := os.ReadFile("../../agent/actions.mjs")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var js []string
+ for _, m := range regexp.MustCompile(`name: "([a-z_]+)"`).FindAllStringSubmatch(string(raw), -1) {
+ js = append(js, m[1])
+ }
+ tools := chipTools()
+ if len(js) != len(tools) {
+ t.Fatalf("actions.mjs has %d tools, Go has %d", len(js), len(tools))
+ }
+ for i, tool := range tools {
+ if js[i] != tool.Name {
+ t.Errorf("tool %d: js %q, go %q", i, js[i], tool.Name)
+ }
+ }
+}
+
+func TestActionsFromToolUse(t *testing.T) {
+ content := []anthBlock{
+ {Type: "text", Text: "Here you go."},
+ {Type: "tool_use", Name: "set_theme", Input: map[string]any{"theme": "lagoon"}},
+ {Type: "tool_use", Name: "set_theme", Input: map[string]any{"theme": "neon"}}, // unknown theme → dropped
+ {Type: "tool_use", Name: "set_appearance", Input: map[string]any{"appearance": "dark"}},
+ {Type: "tool_use", Name: "delete_everything", Input: map[string]any{}}, // unknown tool → dropped
+ {Type: "tool_use", Name: "open_profile"},
+ }
+ got := actionsFromToolUse(content)
+ if len(got) != 3 {
+ t.Fatalf("got %d actions: %+v", len(got), got)
+ }
+ if got[0]["theme"] != "lagoon" || got[1]["appearance"] != "dark" || got[2]["type"] != "open_profile" {
+ t.Fatalf("bad actions: %+v", got)
+ }
+}
diff --git a/internal/bot/commands.go b/internal/bot/commands.go
index c087cf8..85f3fbf 100644
--- a/internal/bot/commands.go
+++ b/internal/bot/commands.go
@@ -113,9 +113,9 @@ func mentionStripper(name string) func(string) string {
// answered in place and anything else gets the no-AI hint — the exact
// behaviour of the Node sidecar running without an ANTHROPIC_API_KEY.
// (Recipes fold the same floor in via the "commands" action.)
-func CommandFloor(name string, aiEnabled bool) func(Incoming) (Reply, bool) {
+func CommandFloor(name string, aiEnabled bool) func(*ThreadCtx, Incoming) (Reply, bool) {
strip := mentionStripper(name)
- return func(in Incoming) (Reply, bool) {
+ return func(_ *ThreadCtx, in Incoming) (Reply, bool) {
if !in.Triggered {
return Reply{}, false
}
diff --git a/internal/bot/commands_test.go b/internal/bot/commands_test.go
index 540e9fd..a72bddc 100644
--- a/internal/bot/commands_test.go
+++ b/internal/bot/commands_test.go
@@ -83,20 +83,20 @@ func TestRunCommand(t *testing.T) {
func TestCommandFloor(t *testing.T) {
h := CommandFloor("Eleven", false)
// Group command with the mention prefix.
- rep, ok := h(Incoming{Kind: "group", Text: "@Eleven /dark", Triggered: true})
+ rep, ok := h(nil, Incoming{Kind: "group", Text: "@Eleven /dark", Triggered: true})
if !ok || rep.Actions[0]["appearance"] != "dark" {
t.Fatalf("mention + command: ok=%v rep=%+v", ok, rep)
}
// Bare command in a DM.
- if rep, _ := h(Incoming{Kind: "dm", Text: "/light", Triggered: true}); rep.Actions[0]["appearance"] != "light" {
+ if rep, _ := h(nil, Incoming{Kind: "dm", Text: "/light", Triggered: true}); rep.Actions[0]["appearance"] != "light" {
t.Fatalf("dm command: %+v", rep)
}
// Not a command → the deterministic hint.
- if rep, _ := h(Incoming{Kind: "dm", Text: "hi there", Triggered: true}); rep.Text != degradeText {
+ if rep, _ := h(nil, Incoming{Kind: "dm", Text: "hi there", Triggered: true}); rep.Text != degradeText {
t.Fatalf("degrade: %+v", rep)
}
// Unaddressed messages never reach the floor's mouth.
- if _, ok := h(Incoming{Kind: "group", Text: "/help"}); ok {
+ if _, ok := h(nil, Incoming{Kind: "group", Text: "/help"}); ok {
t.Fatal("unaddressed message must stay silent")
}
}
diff --git a/internal/bot/recipe.go b/internal/bot/recipe.go
index c607b39..84df5e5 100644
--- a/internal/bot/recipe.go
+++ b/internal/bot/recipe.go
@@ -58,10 +58,15 @@ type Rule struct {
// the session file it configures.
func RecipePathFor(sessionPath string) string { return sessionPath + ".recipe.json" }
-// DefaultRecipe is a bot with no recipe file: answer the command floor when
-// addressed — exactly the keyless Node sidecar's behaviour.
+// DefaultRecipe is a bot with no recipe file: when addressed, answer the
+// command floor, and let the model take anything that isn't a command —
+// exactly the Node sidecar's behaviour with and without an ANTHROPIC_API_KEY
+// (the model rule falls through harmlessly when there's no key).
func DefaultRecipe() *Recipe {
- return &Recipe{Rules: []Rule{{On: "message", Action: "commands"}}}
+ return &Recipe{Rules: []Rule{
+ {On: "message", Action: "commands"},
+ {On: "message", Action: "model"},
+ }}
}
// LoadRecipe reads and validates a recipe file. A missing file returns
@@ -104,9 +109,9 @@ func (r *Recipe) validate() error {
return fmt.Errorf("rule %d: unknown match %q", i, rule.Match)
}
switch rule.Action {
- case "commands":
+ case "commands", "model":
if rule.Text != "" {
- return fmt.Errorf("rule %d: commands takes no text", i)
+ return fmt.Errorf("rule %d: %s takes no text", i, rule.Action)
}
case "reply":
if rule.Text == "" {
@@ -164,11 +169,12 @@ func (r *Recipe) Watches() bool {
// handler folds the message rules into the Runtime's Handler seam. First
// matching rule acts. A "commands" rule that was addressed but got a
-// non-command lets later rules try, then falls back to the no-AI hint — the
-// same UX as the plain command floor.
-func (r *Recipe) handler(botName string, aiEnabled bool) func(Incoming) (Reply, bool) {
+// non-command lets later rules try; if nothing else acts, an addressed
+// message falls back to the no-AI hint (only without a model — with one, a
+// failed model call stays silent, like the Node runtime).
+func (r *Recipe) handler(botName string, aiEnabled bool) func(*ThreadCtx, Incoming) (Reply, bool) {
strip := mentionStripper(botName)
- return func(in Incoming) (Reply, bool) {
+ return func(tc *ThreadCtx, in Incoming) (Reply, bool) {
sawCommands := false
for _, rule := range r.Rules {
if rule.On != "message" {
@@ -192,9 +198,14 @@ func (r *Recipe) handler(botName string, aiEnabled bool) func(Incoming) (Reply,
sawCommands = true // not a command — later rules may still act
case "reply":
return Reply{Text: strings.ReplaceAll(rule.Text, "{name}", in.Sender)}, true
+ case "model":
+ if rep, ok := tc.ModelReply(); ok {
+ return rep, true
+ }
+ // No brain, or the call failed — later rules may still act.
}
}
- if sawCommands && in.Triggered {
+ if sawCommands && in.Triggered && !aiEnabled {
return Reply{Text: degradeText}, true
}
return Reply{}, false
@@ -209,7 +220,12 @@ type Engine struct {
Session *Session
SessionPath string // for the announce state file (StatePathFor)
Recipe *Recipe
- Logf func(format string, args ...any)
+ // AIKey is the ANTHROPIC_API_KEY (empty = deterministic-only: model rules
+ // fall through, the bot badges BOT, and nothing ever leaves the box).
+ AIKey string
+ // Ready, if set, is forwarded to the Runtime (called once the bot is live).
+ Ready func()
+ Logf func(format string, args ...any)
}
func (e *Engine) Run(ctx context.Context) error {
@@ -223,7 +239,9 @@ func (e *Engine) Run(ctx context.Context) error {
rt := &Runtime{
Base: e.Base,
Session: e.Session,
- Handler: e.Recipe.handler(e.Session.Name, false),
+ Handler: e.Recipe.handler(e.Session.Name, e.AIKey != ""),
+ AIKey: e.AIKey,
+ Ready: e.Ready,
Logf: e.Logf,
}
return rt.Run(ctx)
diff --git a/internal/bot/recipe_test.go b/internal/bot/recipe_test.go
index abb4adc..e424ac2 100644
--- a/internal/bot/recipe_test.go
+++ b/internal/bot/recipe_test.go
@@ -72,34 +72,34 @@ func TestRecipeHandler(t *testing.T) {
h := r.handler("Eleven", false)
// Watcher fires on an unaddressed group message, template fills the sender.
- rep, ok := h(Incoming{Kind: "group", Sender: "Paul", Text: "morning guys", Triggered: false})
+ rep, ok := h(nil, Incoming{Kind: "group", Sender: "Paul", Text: "morning guys", Triggered: false})
if !ok || rep.Text != `Hey Paul — how about "folks"?` {
t.Fatalf("nudge: ok=%v %+v", ok, rep)
}
// Unaddressed, no keyword → silent (commands rule is mention-gated).
- if _, ok := h(Incoming{Kind: "group", Sender: "Paul", Text: "morning all", Triggered: false}); ok {
+ if _, ok := h(nil, Incoming{Kind: "group", Sender: "Paul", Text: "morning all", Triggered: false}); ok {
t.Fatal("unaddressed non-match must stay silent")
}
// Addressed command answers via the floor (mention stripped first).
- rep, ok = h(Incoming{Kind: "group", Sender: "Paul", Text: "@Eleven /dark", Triggered: true})
+ rep, ok = h(nil, Incoming{Kind: "group", Sender: "Paul", Text: "@Eleven /dark", Triggered: true})
if !ok || rep.Actions[0]["appearance"] != "dark" {
t.Fatalf("command via recipe: ok=%v %+v", ok, rep)
}
// Addressed non-command falls through the commands rule to the hint.
- rep, ok = h(Incoming{Kind: "dm", Sender: "Paul", Text: "hello", Triggered: true})
+ rep, ok = h(nil, Incoming{Kind: "dm", Sender: "Paul", Text: "hello", Triggered: true})
if !ok || rep.Text != degradeText {
t.Fatalf("degrade via recipe: ok=%v %+v", ok, rep)
}
// Rule order: an addressed message that ALSO matches the watcher gets the
// first rule's action, not the floor.
- rep, _ = h(Incoming{Kind: "group", Sender: "Paul", Text: "@Eleven you guys ok?", Triggered: true})
+ rep, _ = h(nil, Incoming{Kind: "group", Sender: "Paul", Text: "@Eleven you guys ok?", Triggered: true})
if !strings.Contains(rep.Text, "folks") {
t.Fatalf("first matching rule must win: %+v", rep)
}
// Keyword gate is case-insensitive substring.
r2 := mustRecipe(t, `{"rules":[{"on":"message","match":"any","keyword":"standup","action":"reply","text":"Logged."}]}`)
- if _, ok := r2.handler("Bot", false)(Incoming{Text: "STANDUP done", Sender: "P"}); !ok {
+ if _, ok := r2.handler("Bot", false)(nil, Incoming{Text: "STANDUP done", Sender: "P"}); !ok {
t.Fatal("keyword must match case-insensitively")
}
if r2.Watches() != true {
@@ -114,7 +114,7 @@ func TestRecipeHandler(t *testing.T) {
if d.Watches() {
t.Fatal("default recipe must not watch")
}
- if _, ok := d.handler("Bot", false)(Incoming{Kind: "group", Text: "/help", Triggered: false}); ok {
+ if _, ok := d.handler("Bot", false)(nil, Incoming{Kind: "group", Text: "/help", Triggered: false}); ok {
t.Fatal("default recipe must ignore unaddressed messages")
}
}
diff --git a/internal/bot/runtime.go b/internal/bot/runtime.go
index ed9c2a1..8691f0e 100644
--- a/internal/bot/runtime.go
+++ b/internal/bot/runtime.go
@@ -30,24 +30,104 @@ import (
type Runtime struct {
Base string
Session *Session
- // Handler decides the response to a message that triggered the bot.
- // Return ok=false to stay silent.
- Handler func(Incoming) (Reply, bool)
- // Kind is what we self-declare to the server so the UI badges us honestly:
- // "bot" (deterministic, the default) or "ai" (model brain present).
- Kind string
- Logf func(format string, args ...any)
+ // Handler decides the response to a message. The ThreadCtx gives it the
+ // thread's services (history, typing, the model brain); return ok=false to
+ // stay silent.
+ Handler func(*ThreadCtx, Incoming) (Reply, bool)
+ // AIKey is the ANTHROPIC_API_KEY. Set, the runtime self-declares kind "ai"
+ // and the model brain (brain.go) is live; empty, the bot is deterministic
+ // and badges "bot".
+ AIKey string
+ // Ready, if set, is called once each time the runtime finishes connecting —
+ // right before it starts reading frames, so a supervisor (or a test) knows
+ // the bot is live and will receive broadcasts.
+ Ready func()
+ Logf func(format string, args ...any)
client *http.Client
+ ai *anthropicClient
keys map[string]threadEntry
cursors map[string]int64 // threadId -> last message id we've processed
agents map[string]bool // roster labels of ALL agents — never reply to one
mention *regexp.Regexp
+ bare *regexp.Regexp // bare-name mention, for listen mode "name"
+
+ // Console-managed config (loadConfig, re-fetched each connect).
+ persona string
+ model string
+ docs string
+ listen string
wmu sync.Mutex // coder/websocket allows one concurrent writer
conn *websocket.Conn
}
+// ThreadCtx hands a Handler the thread's services: decrypted history, the
+// typing indicator, and the model brain — everything a reply path needs
+// beyond the triggering message itself.
+type ThreadCtx struct {
+ rt *Runtime
+ ID string
+ Kind string // "dm" or "group"
+ key []byte
+}
+
+// HistoryMsg is one decrypted message from the thread's recent history.
+type HistoryMsg struct {
+ ID int64
+ Name string
+ Text string
+ Mine bool // authored by this bot (by payload name)
+ ActionCount int // chips the message carried (history truthfulness — brain.go)
+}
+
+// History returns the thread's latest messages, oldest first, decrypted.
+// Undecryptable or non-chat payloads are skipped.
+func (tc *ThreadCtx) History(limit int) ([]HistoryMsg, error) {
+ var ml struct {
+ Messages []struct {
+ ID int64 `json:"id"`
+ Blob string `json:"blob"`
+ } `json:"messages"`
+ }
+ path := fmt.Sprintf("/api/messages?thread=%s&limit=%d", url.QueryEscape(tc.ID), limit)
+ if err := tc.rt.get(path, &ml); err != nil {
+ return nil, err
+ }
+ var out []HistoryMsg
+ for _, m := range ml.Messages {
+ var p struct {
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Text string `json:"text"`
+ Actions []map[string]any `json:"actions"`
+ }
+ if e2ee.DecryptJSON(tc.key, m.Blob, &p) != nil || p.Type != "" {
+ continue
+ }
+ out = append(out, HistoryMsg{
+ ID: m.ID, Name: p.Name, Text: p.Text,
+ Mine: p.Name == tc.rt.Session.Name, ActionCount: len(p.Actions),
+ })
+ }
+ return out, nil
+}
+
+// Typing sends a best-effort "…is typing" beat (expiry seconds; 0 clears it
+// now). Same relay-only ephemeral chat.typing shape the live chat engine uses.
+func (tc *ThreadCtx) Typing(expiry int) {
+ blob, err := e2ee.EncryptJSON(tc.key, map[string]any{"type": "chat.typing", "name": tc.rt.Session.Name, "expiry": expiry})
+ if err != nil {
+ return
+ }
+ tc.rt.send(map[string]any{"type": "msg", "thread_id": tc.ID, "ephemeral": true, "blob": blob})
+}
+
+// ModelReply asks the model brain for a conversational reply (recipes reach it
+// via the "model" action). ok=false when there's no API key, the call fails,
+// or the reply is empty — the caller stays silent or falls through.
+func (tc *ThreadCtx) ModelReply() (Reply, bool) { return tc.rt.modelReply(tc) }
+
// Incoming is one decrypted chat message. The Runtime hands the Handler every
// person-authored message it can read (that's what lets watcher recipes exist);
// Triggered says whether the bot was actually addressed — a DM, or a group
@@ -80,8 +160,9 @@ func (r *Runtime) Run(ctx context.Context) error {
r.cursors = map[string]int64{}
r.agents = map[string]bool{}
r.mention = regexp.MustCompile(`(?i)(^|[^a-z0-9])@` + regexp.QuoteMeta(r.Session.Name) + `\b`)
- if r.Kind == "" {
- r.Kind = "bot"
+ r.bare = regexp.MustCompile(`(?i)(^|[^a-z0-9])` + regexp.QuoteMeta(r.Session.Name) + `\b`)
+ if r.AIKey != "" {
+ r.ai = newAnthropicClient(r.AIKey)
}
backoff := time.Second
for {
@@ -117,11 +198,12 @@ func (r *Runtime) connectOnce(ctx context.Context) error {
r.conn = conn
defer func() { r.conn = nil }()
- // Same setup as the Node runtime's ws.on("open"): learn the roster's bots,
- // unwrap thread keys, declare what we are, then backfill anything missed.
- // Order matters only in that declareKind is a visible "connected and caught
- // up to here" signal; the cursor gate makes catchUp vs live frames safe in
- // any interleaving.
+ // Same setup as the Node runtime's ws.on("open"): pull console config,
+ // learn the roster's bots, unwrap thread keys, declare what we are, then
+ // backfill anything missed. Order matters only in that declareKind is a
+ // visible "connected and caught up to here" signal; the cursor gate makes
+ // catchUp vs live frames safe in any interleaving.
+ r.loadConfig()
r.loadAgents()
r.loadThreads()
r.declareKind()
@@ -144,6 +226,9 @@ func (r *Runtime) connectOnce(ctx context.Context) error {
}
}()
+ if r.Ready != nil {
+ r.Ready()
+ }
for {
_, data, err := conn.Read(ctx)
if err != nil {
@@ -227,12 +312,13 @@ func (r *Runtime) processMsg(threadID string, id int64, blob string) {
if r.Handler == nil {
return
}
- rep, ok := r.Handler(Incoming{
+ tc := &ThreadCtx{rt: r, ID: threadID, Kind: entry.kind, key: entry.key}
+ rep, ok := r.Handler(tc, Incoming{
ThreadID: threadID,
Kind: entry.kind,
Sender: p.Name,
Text: p.Text,
- Triggered: r.triggered(entry.kind, p.Text),
+ Triggered: r.triggered(tc, p.Text),
})
if !ok {
return
@@ -240,14 +326,22 @@ func (r *Runtime) processMsg(threadID string, id int64, blob string) {
r.postReply(threadID, entry.key, rep)
}
-// triggered: DMs always address the bot; groups only on an explicit @Name. The
-// Node runtime's listen-mode "name" (bare-name + classifier) needs the model
-// brain, so it arrives with the model layer.
-func (r *Runtime) triggered(kind, text string) bool {
- if kind == "dm" {
+// triggered decides whether the bot was addressed. DMs always; groups on an
+// explicit @Name. Listen mode "name" (console config) also counts a bare-name
+// mention — but a bare name is ambiguous (people talk *about* a bot as often
+// as *to* it), so it must additionally pass the model classifier, which means
+// the mode does nothing without an API key.
+func (r *Runtime) triggered(tc *ThreadCtx, text string) bool {
+ if tc.Kind == "dm" {
+ return true
+ }
+ if r.mention.MatchString(text) {
return true
}
- return r.mention.MatchString(text)
+ if r.listen == "name" && r.ai != nil && r.bare.MatchString(text) {
+ return r.isAddressed(tc)
+ }
+ return false
}
// postReply encrypts and sends one reply payload — the same wire shape the Node
@@ -382,10 +476,15 @@ func (r *Runtime) catchUp() {
}
}
-// declareKind self-reports what we are so the UI badges us honestly. Re-declared
-// on every connect, best-effort.
+// declareKind self-reports what we are so the UI badges us honestly: "ai"
+// when the model brain is available, else "bot" (deterministic). Re-declared
+// on every connect, so toggling the key flips the badge with no admin action.
func (r *Runtime) declareKind() {
- body, _ := json.Marshal(map[string]string{"token": r.Session.Token, "kind": r.Kind})
+ kind := "bot"
+ if r.ai != nil {
+ kind = "ai"
+ }
+ body, _ := json.Marshal(map[string]string{"token": r.Session.Token, "kind": kind})
resp, err := r.client.Post(r.Base+"/api/bot-config", "application/json", strings.NewReader(string(body)))
if err != nil {
r.logf("declareKind: %v", err)