eleven / ocho Public

bot: talk to a member bot in its own chat — Eleven answers, edits, retires (#810)

* bot: member bots back off on repeated failures instead of apologising hourly

A charter run that errors, comes back empty, or reaches NONE of its
sources (every fetch failed — the all-403 case, where the model writes
an apology that posted as a normal edition every occurrence) now counts
as a failed edition: the first failure of an outage posts the model's
explanation once with a keep-trying note, further consecutive failures
post nothing, and interval schedules stretch the next attempt (2x, 4x,
8x... the interval, capped at a day). A successful run clears the state
and editions resume by themselves.

The same all-sources-down signal hardens the builder's dry-run gate: a
bot can no longer be born against sources that are all down, with its
"first edition" an apology.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* bot: talk to a member bot in its own chat — Eleven answers, edits, retires

A member bot's dedicated thread was one-way: a plain "Hello?" got no
reply (groups only trigger on @mention), and even @Eleven had no idea
the thread belonged to a charter — it would offer colour themes when
asked to "change to The Journal".

Now the host keeps a thread->charter map (rebuilt on every charter
read), and the OWNER talking in a bot's home thread always triggers the
brain — gated on the owner's uid, so the bot's own persona-named
editions never draw a reply. The brain gets the charter as a per-thread
system block (after the cache breakpoint): the real bot_id, persona,
schedule, sources, and standing instructions, plus how to use
create_bot/remove_bot with that id. So "change what it reads", "make it
daily", and "remove this bot" all work right where the member lives
with the bot, and update confirmations speak from where the member is
standing (no "open your new chat" chip inside that very chat).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell b4ae3942667e35e0aee378ac6097fbc92803c8ab parent 48f5741
8 files changed, +408 −21
  • bot_charterthread_test.go +181 −0
  • changelog/2026-07-12-talk-to-your-bot.md +5 −0
  • docs/member-bots.md +17 −6
  • internal/bot/botbuild.go +26 −7
  • internal/bot/brain.go +73 −2
  • internal/bot/charter.go +4 −0
  • internal/bot/charterhome_test.go +45 −0
  • internal/bot/runtime.go +57 −6
diff --git a/bot_charterthread_test.go b/bot_charterthread_test.go
new file mode 100644
index 0000000..225ca0c
--- /dev/null
+++ b/bot_charterthread_test.go
@@ -0,0 +1,181 @@
+package main
+
+// End-to-end test of the conversational bot-home thread (internal/bot:
+// runtime.go triggered + brain.go charterHomeNote): in a member bot's own
+// chat the owner talks to the host WITHOUT an @mention, the brain receives
+// the charter (bot id included) as system context, and remove_bot retires
+// the bot from right there — while the bot's own persona-named editions
+// never trigger a reply.
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "lchat/internal/bot"
+ "lchat/internal/e2ee"
+)
+
+// capturingAnthropic is scriptedAnthropic plus request capture, so a test can
+// assert what context actually reached the model.
+func capturingAnthropic(t *testing.T, responses ...[]map[string]any) (*httptest.Server, func() [][]byte) {
+ t.Helper()
+ var mu sync.Mutex
+ var bodies [][]byte
+ calls := 0
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var buf bytes.Buffer
+ buf.ReadFrom(r.Body)
+ mu.Lock()
+ bodies = append(bodies, buf.Bytes())
+ i := calls
+ calls++
+ mu.Unlock()
+ if i >= len(responses) {
+ i = len(responses) - 1
+ }
+ json.NewEncoder(w).Encode(map[string]any{"content": responses[i], "stop_reason": "end_turn"})
+ }))
+ t.Cleanup(srv.Close)
+ return srv, func() [][]byte {
+ mu.Lock()
+ defer mu.Unlock()
+ return append([][]byte(nil), bodies...)
+ }
+}
+
+func TestCharterThreadConversation(t *testing.T) {
+ fakeAI, requests := capturingAnthropic(t,
+ // Turn 1 — the un-mentioned "Hello?": a plain conversational reply.
+ []map[string]any{{"type": "text", "text": "Hi! I look after Home News here — want me to change what it reads or when it runs?"}},
+ // Turn 2 — "remove this bot": the model uses the bot_id from its context.
+ []map[string]any{{"type": "tool_use", "name": "remove_bot", "input": map[string]any{"bot_id": "bot-home01"}}},
+ )
+ t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL)
+
+ ts, s := newTestServer(t)
+ owner := provisionNamed(t, ts.URL, "Owner", false)
+ eleven := provisionNamed(t, ts.URL, "Eleven", true)
+ ownerUID, _ := s.db.deviceTokenUser(owner.Token)
+ elevenUID, _ := s.db.deviceTokenUser(eleven.Token)
+ instKey := grantInstanceKey(t, ts.URL, owner, eleven, s)
+ threadID, threadKey := createThreadKind(t, ts.URL, s, "group", owner, eleven)
+
+ // A stored charter whose home is that thread. Its daily slot is far away
+ // and this test never sweeps the schedule queue, so no task run interferes.
+ farSlot := time.Now().UTC().Add(12 * time.Hour).Format("15:04")
+ charter := map[string]any{
+ "v": 1, "name": "home-news", "title": "Home News", "glyph": "🏠",
+ "schedule": map[string]any{"at": farSlot},
+ "task": map[string]any{"prompt": "Write the household news."},
+ "thread": threadID, "owner": ownerUID,
+ }
+ blob, err := e2ee.EncryptJSON(instKey, charter)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, _ := json.Marshal(map[string]string{"token": eleven.Token, "blob": blob, "owner_id": ownerUID})
+ req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/member-bots/bot-home01", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ if resp, err := http.DefaultClient.Do(req); err != nil || resp.StatusCode != http.StatusOK {
+ t.Fatalf("seed charter: %v %d", err, resp.StatusCode)
+ }
+
+ dir := t.TempDir()
+ sessionPath := dir + "/eleven.session.json"
+ if err := eleven.Save(sessionPath); err != nil {
+ 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: bot.DefaultRecipe(), AIKey: "test-key", Charters: true,
+ Ready: func() { close(ready) }, Logf: t.Logf}
+ go eng.Run(ctx)
+ awaitReady(t, ready)
+
+ // The bot's own editions post under the persona name with no uid — they
+ // must never trigger the host. Posted first: if this DID draw a reply, the
+ // count assertions below would catch the extra message.
+ edition, err := e2ee.EncryptJSON(threadKey, map[string]any{
+ "name": "Home News", "text": "Tonight's household news: all beds made.", "ts": time.Now().UnixMilli(),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ eb, _ := json.Marshal(map[string]string{"thread_id": threadID, "blob": edition})
+ if resp, err := http.Post(ts.URL+"/api/messages?token="+owner.Token, "application/json", bytes.NewReader(eb)); err != nil || resp.StatusCode != http.StatusOK {
+ t.Fatalf("post edition: %v %d", err, resp.StatusCode)
+ }
+
+ // The owner says hello — NO @mention. The host must reply anyway.
+ postAsMember(t, ts.URL, threadID, threadKey, owner, ownerUID, "Hello?")
+ waitFor(t, "a reply to the un-mentioned hello", func() bool {
+ return len(botReplies(t, s, threadID, threadKey, "Eleven")) == 1
+ })
+ if got := botReplies(t, s, threadID, threadKey, "Eleven")[0].Text; !strings.Contains(got, "look after Home News") {
+ t.Fatalf("unexpected reply: %q", got)
+ }
+
+ // The brain got the charter as system context: a second system block
+ // carrying the real bot id, the persona, and the edit/retire tools.
+ reqs := requests()
+ if len(reqs) != 1 {
+ t.Fatalf("want 1 model call so far, got %d", len(reqs))
+ }
+ var call struct {
+ System []struct {
+ Text string `json:"text"`
+ } `json:"system"`
+ }
+ if err := json.Unmarshal(reqs[0], &call); err != nil {
+ t.Fatal(err)
+ }
+ if len(call.System) != 2 {
+ t.Fatalf("want the cached prompt + the charter note, got %d system blocks", len(call.System))
+ }
+ note := call.System[1].Text
+ for _, want := range []string{"bot-home01", "Home News", "create_bot", "remove_bot", "Write the household news."} {
+ if !strings.Contains(note, want) {
+ t.Fatalf("charter note missing %q:\n%s", want, note)
+ }
+ }
+
+ // "Remove this bot" — the model answers with remove_bot + the id from its
+ // context; the charter is deleted, its pending alarm dropped, and the
+ // thread (the member's history) stays.
+ if n := s.db.countScheduledMessages(selfThreadOf(t, s, elevenUID)); n != 1 {
+ t.Fatalf("want the seeded charter's 1 pending alarm before removal, got %d", n)
+ }
+ postAsMember(t, ts.URL, threadID, threadKey, owner, ownerUID, "Please remove this bot.")
+ waitFor(t, "the retirement confirmation", func() bool {
+ replies := botReplies(t, s, threadID, threadKey, "Eleven")
+ return len(replies) == 2 && strings.Contains(replies[1].Text, "retired")
+ })
+ waitFor(t, "charter deleted", func() bool {
+ bots, _ := s.db.listMemberBots()
+ return len(bots) == 0
+ })
+ waitFor(t, "alarm dropped", func() bool {
+ return s.db.countScheduledMessages(selfThreadOf(t, s, elevenUID)) == 0
+ })
+}
+
+// selfThreadOf finds a bot identity's self-thread (where charter alarms live).
+func selfThreadOf(t *testing.T, s *Server, uid string) string {
+ t.Helper()
+ var selfID string
+ if err := s.db.sql.QueryRow(
+ `SELECT t.id FROM threads t JOIN thread_members m ON m.thread_id = t.id
+ WHERE t.kind = 'self' AND m.user_id = ?`, uid).Scan(&selfID); err != nil {
+ t.Fatalf("self thread: %v", err)
+ }
+ return selfID
+}
diff --git a/changelog/2026-07-12-talk-to-your-bot.md b/changelog/2026-07-12-talk-to-your-bot.md
new file mode 100644
index 0000000..2703e79
--- /dev/null
+++ b/changelog/2026-07-12-talk-to-your-bot.md
@@ -0,0 +1,5 @@
+🤖 You can talk to your bots now — right in their own chats, no @mention
+needed. Say "change it to read The Journal", "make it daily at 8", or "remove
+this bot" in a bot's chat and Eleven sorts it there and then: it knows which
+bot the chat belongs to, what it reads, and when it runs. Removing a bot keeps
+the chat and everything it posted.
diff --git a/docs/member-bots.md b/docs/member-bots.md
index 6fcdd90..b54144d 100644
--- a/docs/member-bots.md
+++ b/docs/member-bots.md
@@ -107,6 +107,15 @@ keypair. The plaintext trust note is the same as any AI bot today: the
sidecar process holds thread plaintext while it works; the *server* still
never holds a usable key.
+**That thread is conversational.** The owner talking there is always talking
+to the host — no `@` needed (`triggered`, `runtime.go`, gated on the owner's
+uid so the bot's own persona-named editions never draw a reply) — and the
+brain answers with the charter as per-thread system context: the real
+`bot_id`, persona, schedule, sources, and standing instructions
+(`charterHomeNote`, `brain.go`), plus the `create_bot`/`remove_bot` tools. So
+"change it to The Journal", "make it daily at 8", and "remove this bot" all
+work right in the bot's own chat, not just in the DM where it was born.
+
## How one is born: the builder, and the dry run as the gate
`internal/bot/botbuild.go`, mirroring `appbuild.go`: the brain's `create_bot`
@@ -127,11 +136,12 @@ the setup on its own goroutine, reporting progress on one action line.
first alarm armed, and the dry run's output delivered as the bot's **first
edition** — proof, not promise.
-Changing a bot is conversational: ask the builder in the same DM
-(`create_bot` with the `bot_id` from its earlier reply — ownership checked
-against the requester in the sidecar, where plaintext lives). `remove_bot`
-retires it: the charter is deleted, alarms cancelled, and the bot's thread —
-the member's history — stays.
+Changing a bot is conversational: ask in the bot's own chat (the host has the
+charter and its id as context there), or ask the builder in the DM where it
+was born (`create_bot` with the `bot_id` from its earlier reply). Ownership is
+checked against the requester in the sidecar, where plaintext lives, either
+way. `remove_bot` retires it: the charter is deleted, alarms cancelled, and
+the bot's thread — the member's history — stays.
## What the server gained (deliberately little)
@@ -187,4 +197,5 @@ later — the bot nudges your open tab to grant it.
- **Governance dials**: write rate limits; an admin oversight panel. (The
interval floor is handled: 15 minutes in production, lowered only via
`LCHAT_BOT_MIN_EVERY` on tests/demo canaries.)
-- A client surface for listing/managing your bots (today: ask the builder).
+- A client surface for listing/managing your bots (today: ask in the bot's
+ own chat, or ask the builder).
diff --git a/internal/bot/botbuild.go b/internal/bot/botbuild.go
index 255e2a3..c31ffba 100644
--- a/internal/bot/botbuild.go
+++ b/internal/bot/botbuild.go
@@ -50,8 +50,9 @@ func createBotTool() anthTool {
"properties": map[string]any{
"bot_id": map[string]any{
"type": "string",
- "description": "ONLY to UPDATE a bot you already created in this conversation — the exact opaque id " +
- "from your earlier reply (looks like \"bot-1a2b3c4d\"). NEVER invent one. Omit to create.",
+ "description": "ONLY to UPDATE an existing bot — the exact opaque id from your earlier reply, or " +
+ "from your context when you're chatting in the bot's own chat (looks like \"bot-1a2b3c4d\"). " +
+ "NEVER invent one. Omit to create.",
},
"name": map[string]any{
"type": "string",
@@ -100,8 +101,9 @@ func removeBotTool() anthTool {
return anthTool{
Name: "remove_bot",
Description: "Retire one of this member's bots — it stops running and its charter is deleted " +
- "(its chat and history stay). Use the exact bot id from an earlier reply. Only do this when " +
- "the member clearly asked to stop/remove the bot.",
+ "(its chat and history stay). Use the exact bot id from an earlier reply or from your " +
+ "context in the bot's own chat. Only do this when the member clearly asked to stop/remove " +
+ "the bot.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{"bot_id": map[string]any{"type": "string"}},
@@ -418,9 +420,22 @@ func (r *Runtime) runBotBuild(tc *ThreadCtx, req botBuildReq, requester, request
// next one lands, in plain words — no URLs (they'd drag a link preview into
// the chat), no error transcripts (the log has those), and the opaque id
// rides the muted breadcrumb line, where the brain can still read it back
- // for later updates without it cluttering the real message.
- sentences := fmt.Sprintf("✨ %s %s %s — its first edition is waiting in your new **%s** chat, and the next arrives %s.",
- req.Title, req.Glyph, verb, req.Title, charter.Schedule.Summary())
+ // for later updates without it cluttering the real message. Told from
+ // where the member is standing: an ask made IN the bot's own chat isn't
+ // pointed at "your new chat" — the fresh edition just posted right here.
+ inHome := tc.ID == charter.Thread
+ var sentences string
+ switch {
+ case inHome:
+ sentences = fmt.Sprintf("✨ %s %s %s — that fresh edition above is the proof, and the next arrives %s.",
+ req.Title, req.Glyph, verb, charter.Schedule.Summary())
+ case existing != nil:
+ sentences = fmt.Sprintf("✨ %s %s %s — a fresh edition just landed in its **%s** chat, and the next arrives %s.",
+ req.Title, req.Glyph, verb, req.Title, charter.Schedule.Summary())
+ default:
+ sentences = fmt.Sprintf("✨ %s %s %s — its first edition is waiting in your new **%s** chat, and the next arrives %s.",
+ req.Title, req.Glyph, verb, req.Title, charter.Schedule.Summary())
+ }
if len(req.Sources) > 0 {
labels := make([]string, len(req.Sources))
for i, s := range req.Sources {
@@ -435,6 +450,10 @@ func (r *Runtime) runBotBuild(tc *ThreadCtx, req botBuildReq, requester, request
}
sentences += " Want to change it — or stop it? Just ask me here."
r.logf("bot build %q: stored as %s (%d sources)", req.Name, id, len(allow))
+ if inHome {
+ settle(fmt.Sprintf("✅ %s %s %s · %s", req.Title, req.Glyph, verb, id), sentences)
+ return
+ }
settle(fmt.Sprintf("✅ %s %s %s · %s", req.Title, req.Glyph, verb, id),
sentences,
map[string]any{"type": "open_thread", "thread": charter.Thread, "label": "Open " + req.Title + " " + req.Glyph})
diff --git a/internal/bot/brain.go b/internal/bot/brain.go
index 8767bd6..e991bfd 100644
--- a/internal/bot/brain.go
+++ b/internal/bot/brain.go
@@ -359,6 +359,70 @@ func (r *Runtime) buildContext(tc *ThreadCtx) ([]anthMessage, error) {
return out, nil
}
+// charterHomeNote is the per-thread system addendum for a member bot's home
+// thread: which charter this chat belongs to, its exact bot id, and how to
+// change or retire it — the context that turns "change it to The Journal"
+// from a guess into an edit. Empty for every other thread. The charter is
+// re-read from the store per reply, so an edit that just landed describes
+// itself correctly.
+func (r *Runtime) charterHomeNote(tc *ThreadCtx) string {
+ if !r.Charters {
+ return ""
+ }
+ h, ok := r.charterHomeFor(tc.ID)
+ if !ok {
+ return ""
+ }
+ instKey, err := r.instanceKey()
+ if err != nil {
+ return ""
+ }
+ c, err := r.fetchCharter(h.BotID, instKey)
+ if err != nil || c == nil || c.Thread != tc.ID {
+ return ""
+ }
+ var b strings.Builder
+ fmt.Fprintf(&b, "This chat is the home of %s %s — a scheduled bot you run for the member here, "+
+ "who owns it. The %s-named messages are the bot's own editions; you reply as yourself.\n\n",
+ c.Title, c.Glyph, c.Title)
+ b.WriteString("Its charter right now — when updating, repeat any field you aren't changing exactly as given:\n")
+ fmt.Fprintf(&b, "- bot_id: %s (use this exact id; never invent another)\n", h.BotID)
+ fmt.Fprintf(&b, "- title: %s · glyph: %s\n", c.Title, c.Glyph)
+ if c.Schedule.At != "" {
+ fmt.Fprintf(&b, "- schedule: at %q", c.Schedule.At)
+ if c.Schedule.TZ != "" {
+ fmt.Fprintf(&b, ", tz %q", c.Schedule.TZ)
+ }
+ b.WriteString("\n")
+ } else {
+ fmt.Fprintf(&b, "- schedule: every %q\n", c.Schedule.Every)
+ }
+ if c.Task.Fetch != nil && len(c.Task.Fetch.Allow) > 0 {
+ // The allowlist holds discovered feed URLs; the member (and create_bot)
+ // speak in sites — so present each entry as its site, deduped.
+ var labels []string
+ seen := map[string]bool{}
+ for _, u := range c.Task.Fetch.Allow {
+ if l := hostLabel(u); !seen[l] {
+ seen[l] = true
+ labels = append(labels, l)
+ }
+ }
+ fmt.Fprintf(&b, "- sources (as sites): %s\n", strings.Join(labels, ", "))
+ } else {
+ b.WriteString("- sources: none — it writes from its own knowledge\n")
+ }
+ fmt.Fprintf(&b, "- standing instructions: %q\n\n", c.Task.Prompt)
+ b.WriteString("When the member asks to change ANYTHING about this bot — its sources (\"switch to X\"), " +
+ "schedule, name, tone, or instructions — call create_bot with the bot_id above and the COMPLETE " +
+ "updated spec (sources as sites the member would name, e.g. \"rte.ie\" — never feed URLs; the " +
+ "setup re-tests the bot and posts the outcome here). When they ask to stop, remove, or delete it, " +
+ "call remove_bot with that bot_id — this chat and its history stay theirs. Questions about what " +
+ "it does, when it runs, or why an edition looks off, answer directly from the charter above and " +
+ "the conversation.")
+ return b.String()
+}
+
// promisedButton catches a reply that PROMISES an action instead of attaching
// one: the classic "tap the button" with no tool call, and the fetch-promise
// family ("Great! Let me pull up the open issues for you." — the live stall
@@ -404,12 +468,19 @@ func (r *Runtime) modelReply(tc *ThreadCtx) (Reply, bool) {
if len(context) == 0 {
context = []anthMessage{{Role: "user", Content: "Say hello."}}
}
+ // In a member bot's home thread the brain also gets the charter — appended
+ // AFTER the cached block, so the big stable prompt still caches and the
+ // per-thread addendum rides uncached.
+ system := r.systemBlocks()
+ if note := r.charterHomeNote(tc); note != "" {
+ system = append(system, anthBlock{Type: "text", Text: note})
+ }
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(),
+ System: system,
Thinking: adaptiveThinking(r.model),
Tools: r.replyTools(),
Messages: context,
@@ -478,7 +549,7 @@ func (r *Runtime) modelReply(tc *ThreadCtx) (Reply, bool) {
resp2, err := r.ai.create(anthRequest{
Model: r.model,
MaxTokens: 4096,
- System: r.systemBlocks(),
+ System: system,
// Forced tool_choice requires thinking off; omitting the field is
// "off" and every model accepts it (an explicit thinking type here
// would 400 on Haiku, which is exactly the bug this path must avoid).
diff --git a/internal/bot/charter.go b/internal/bot/charter.go
index c4c7b41..d0cd5f5 100644
--- a/internal/bot/charter.go
+++ b/internal/bot/charter.go
@@ -195,6 +195,10 @@ func (r *Runtime) listCharters(instKey []byte) ([]charterEntry, error) {
}
out = append(out, charterEntry{ID: b.ID, Charter: &c})
}
+ // Every read refreshes the thread→charter map that makes each bot's home
+ // thread conversational (runtime.go) — connect, reconcile, ticks, builds
+ // and removals all pass through here, so the map never goes stale long.
+ r.setCharterHomes(out)
return out, nil
}
diff --git a/internal/bot/charterhome_test.go b/internal/bot/charterhome_test.go
new file mode 100644
index 0000000..a36b700
--- /dev/null
+++ b/internal/bot/charterhome_test.go
@@ -0,0 +1,45 @@
+package bot
+
+import (
+ "regexp"
+ "testing"
+)
+
+// TestTriggeredCharterHome pins the home-thread gate: in a member bot's own
+// thread the OWNER triggers without a mention; the bot's persona-named
+// editions (no uid) and anyone else never do.
+func TestTriggeredCharterHome(t *testing.T) {
+ r := &Runtime{
+ Session: &Session{Name: "Eleven"},
+ ai: &anthropicClient{},
+ Charters: true,
+ mention: regexp.MustCompile(`(?i)(^|[^a-z0-9])@Eleven\b`),
+ charterHomes: map[string]charterHome{"th-home": {BotID: "bot-1", Owner: "u-owner"}},
+ }
+ cases := []struct {
+ name string
+ thread string
+ text string
+ uid string
+ want bool
+ }{
+ {"owner, no mention, home thread", "th-home", "Hello?", "u-owner", true},
+ {"owner, other thread", "th-else", "Hello?", "u-owner", false},
+ {"persona edition (no uid), home thread", "th-home", "Tonight's news…", "", false},
+ {"someone else's uid, home thread", "th-home", "Hello?", "u-other", false},
+ {"mention still works anywhere", "th-else", "@Eleven hi", "u-owner", true},
+ }
+ for _, c := range cases {
+ tc := &ThreadCtx{ID: c.thread, Kind: "group"}
+ if got := r.triggered(tc, c.text, c.uid); got != c.want {
+ t.Errorf("%s: triggered = %v, want %v", c.name, got, c.want)
+ }
+ }
+
+ // Without a model there is no conversational brain to answer — the home
+ // thread must not trigger the deterministic floor's degrade hint.
+ r.ai = nil
+ if r.triggered(&ThreadCtx{ID: "th-home", Kind: "group"}, "Hello?", "u-owner") {
+ t.Error("home thread must not trigger without an AI brain")
+ }
+}
diff --git a/internal/bot/runtime.go b/internal/bot/runtime.go
index 32900ee..0e60ad8 100644
--- a/internal/bot/runtime.go
+++ b/internal/bot/runtime.go
@@ -103,6 +103,23 @@ type Runtime struct {
// or a removal clears it from the builder goroutine.
failMu sync.Mutex
charterFails map[string]int
+
+ // charterHomes maps each member bot's dedicated thread to (bot id, owner)
+ // — what makes the bot's own chat conversational: the owner talking there
+ // is always talking to the host, no @mention needed, and the brain gets
+ // the charter as context (charterHomeNote, brain.go). Rebuilt whole on
+ // every listCharters, so builds, edits, and removals all refresh it. Own
+ // lock: the frame goroutine reads per message; reconcile + builder
+ // goroutines write.
+ homeMu sync.Mutex
+ charterHomes map[string]charterHome
+}
+
+// charterHome is one entry of charterHomes: which charter a thread belongs to
+// and whose bot it is.
+type charterHome struct {
+ BotID string
+ Owner string
}
// ThreadCtx hands a Handler the thread's services: decrypted history, the
@@ -234,6 +251,7 @@ func (r *Runtime) Run(ctx context.Context) error {
r.muted = map[string]time.Time{}
r.charterRuns = map[string]time.Time{}
r.charterFails = map[string]int{}
+ r.charterHomes = map[string]charterHome{}
r.mention = regexp.MustCompile(`(?i)(^|[^a-z0-9])@` + regexp.QuoteMeta(r.Session.Name) + `\b`)
r.bare = regexp.MustCompile(`(?i)(^|[^a-z0-9])` + regexp.QuoteMeta(r.Session.Name) + `\b`)
if r.AIKey != "" {
@@ -438,7 +456,7 @@ func (r *Runtime) processMsg(threadID string, id int64, blob string) {
Kind: entry.kind,
Sender: p.Name,
Text: p.Text,
- Triggered: r.triggered(tc, p.Text),
+ Triggered: r.triggered(tc, p.Text, p.UID),
})
if !ok {
return
@@ -506,23 +524,56 @@ func (r *Runtime) handleTyped(threadID string, entry threadEntry, typ, blob stri
}
// 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 {
+// explicit @Name — except a member bot's home thread, where the OWNER talking
+// is always talking to the host (the thread holds no one else, and "Hello?"
+// going unanswered there reads as the bot ignoring them). The uid gate
+// matters: the bot's own editions post under the persona name, not ours, so
+// without it the host would answer its own bot every occurrence. 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, uid string) bool {
if tc.Kind == "dm" {
return true
}
if r.mention.MatchString(text) {
return true
}
+ if r.ai != nil && uid != "" {
+ if h, ok := r.charterHomeFor(tc.ID); ok && h.Owner == uid {
+ return true
+ }
+ }
if r.listen == "name" && r.ai != nil && r.bare.MatchString(text) {
return r.isAddressed(tc)
}
return false
}
+// charterHomeFor looks a thread up in the charter-home map (any goroutine).
+func (r *Runtime) charterHomeFor(threadID string) (charterHome, bool) {
+ r.homeMu.Lock()
+ defer r.homeMu.Unlock()
+ h, ok := r.charterHomes[threadID]
+ return h, ok
+}
+
+// setCharterHomes replaces the charter-home map with the store's current
+// truth — called from listCharters, so every path that reads charters
+// (connect + slow reconcile, ticks, builds, removals) keeps it fresh.
+func (r *Runtime) setCharterHomes(entries []charterEntry) {
+ homes := make(map[string]charterHome, len(entries))
+ for _, e := range entries {
+ if e.Charter.Thread != "" {
+ homes[e.Charter.Thread] = charterHome{BotID: e.ID, Owner: e.Charter.Owner}
+ }
+ }
+ r.homeMu.Lock()
+ r.charterHomes = homes
+ r.homeMu.Unlock()
+}
+
// postReply encrypts and sends one chat reply payload — the same wire shape the
// Node sidecar posts, so clients can't tell the implementations apart.
func (r *Runtime) postReply(threadID string, key []byte, rep Reply) {