bot: retire the Announcer β the server announces its own changelog now (#470)
The write-only third identity is gone from the default fleet. Release notes reach people with NO bot in the loop (works on no-sidecar / no-AI instances): the server pushes them to admins on deploy (announce.go, PR #463/#468) and they live in the Admin app's update stream (PR #460). Members stop seeing "π Just shipped" in group chats β updates are the admins' concern now. - add-bot mints two invites (Claude / Eleven); no LCHAT_ANNOUNCER_* env. - `lchat bot announce` is retired (dies with a pointer, not unknown-command). - `lchat bot run`: a legacy session named "Announcer" is SKIPPED with a log β under the default message recipe the old write-only bot would have become a responder. LCHAT_ANNOUNCER_SESSION stays in the env-sourcing list so a pre-retirement EnvironmentFile still resolves, then skips by name. - DefaultRecipeFor is gone (no per-name defaults left); the generic `changelog` recipe rule and its engine (internal/bot/announcer.go) STAY for operators whose custom bots explicitly opt into posting release notes to groups β docs/bot-recipes.md still documents it, tests repinned on an explicit recipe. - agent/ (Node reference): announcer.mjs removed, agent.mjs runs the pair. - Eleven's prompt/knowledge, docs (bots.md gains a "The Announcer is retired" section with the migration story), deploy scripts, .miren toml updated. Ops note (not in this PR): live sidecars keep their Announcer identities until redeployed; the new binary skips them, and the roster member can be removed from the admin console whenever convenient.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
23 files changed,
+158
−355
.miren/agent.app.toml+0 −6CLAUDE.md+3 −2agent/README.md+9 −14agent/agent.mjs+4 −12agent/announcer.mjs+0 −159agent/runtime.mjs+3 −3bot.go+9 −9bot_announcer_test.go+4 −0bot_run_test.go+16 −8botcmd.go+19 −59changelog/2026-07-09-announcer-retired.md+5 −0deploy/README.md+1 −1deploy/go-router.sh+2 −2deploy/miren/agent.sh+1 −2docs/bot-recipes.md+6 −4docs/bots.md+27 −9docs/deploying-instances.md+2 −2docs/instance-router.md+10 −9internal/bot/announcer.go+12 −9internal/bot/brain.go+1 −1internal/bot/knowledge.md+8 −7internal/bot/recipe.go+4 −18internal/bot/recipe_test.go+12 −19
diff --git a/.miren/agent.app.toml b/.miren/agent.app.toml| index 28d27bf..cfacc1a 100644 |
| --- a/.miren/agent.app.toml |
| +++ b/.miren/agent.app.toml |
| @@ -26,11 +26,6 @@ size_gb = 1 |
| key = "LCHAT_SESSION" |
| value = "/data/session.json" |
| -# The deploy announcer's identity + announced-state live on the same disk (its |
| -# state file is derived: /data/announcer-session.json.state.json). |
| -[[env]] |
| -key = "LCHAT_ANNOUNCER_SESSION" |
| -value = "/data/announcer-session.json" |
| # Eleven (the app guide): identity + welcomed-members state, same pattern. |
| [[env]] |
| @@ -41,6 +36,5 @@ value = "/data/eleven-session.json" |
| # LCHAT_BASE the instance to connect to, e.g. https://test-bots.example.com |
| # ANTHROPIC_API_KEY billing credential β lives only in this app's env |
| # LCHAT_INVITE one-time invite #fragment; used only on first boot |
| -# LCHAT_ANNOUNCER_INVITE same, for the "Announcer" identity |
| # LCHAT_ELEVEN_INVITE same, for the "Eleven" identity |
| # PORT container port for the health check (defaults to 8080) |
diff --git a/CLAUDE.md b/CLAUDE.md| index 776fb0c..01c846b 100644 |
| --- a/CLAUDE.md |
| +++ b/CLAUDE.md |
| @@ -96,7 +96,7 @@ in, so deploy is one file (no cgo). |
| DOM. The bot's builder is `internal/bot/appbuild.go`; authoring rules |
| `docs/building-member-apps.md`; design + trust model `docs/member-apps.md`. |
| - `push.go` β Web Push (self-hosted VAPID, `SherClockHolmes/webpush-go`). |
| -- The **bot sidecar** (Claude / Eleven / Announcer): a separate headless process |
| +- The **bot sidecar** (Claude / Eleven): a separate headless process |
| that is just another E2EE member; holds bot private keys + the |
| `ANTHROPIC_API_KEY`, outbound-only, runs on any machine (see `docs/bots.md`). |
| The default is **Go** β `lchat bot run`, a subcommand of the single binary |
| @@ -258,7 +258,8 @@ open with the note itself) carry the π€ on their first line. |
| - **User-visible changes add a changelog entry**: a short, user-facing note in |
| `changelog/<date>-<slug>.md` (one file per change β parallel PRs never |
| conflict). Entries are embedded in the binary and served at `/api/changelog`; |
| - the deploy announcer bot posts new ones to its groups after a deploy, so write |
| + the server announces new ones after a deploy β a push to the space's admins |
| + plus the Admin app's update stream (announce.go) β so write |
| them for members, not developers. **Native Mac/iOS changes: hold the entry |
| until the release is minted.** The changelog announces on the next *server* |
| deploy, but a Mac/iOS change only reaches members once a build is actually |
diff --git a/agent/README.md b/agent/README.md| index d0af4a8..d9cf2f4 100644 |
| --- a/agent/README.md |
| +++ b/agent/README.md |
| @@ -23,7 +23,6 @@ its wire format is guaranteed to match the app. |
| |---|---| |
| | **Claude** | The plain assistant: replies to every DM; in groups only when @mentioned (the default listen mode β the console can widen a bot to also answer its bare name, gated by a cheap "am I being addressed?" classifier). (`runtime.mjs`, identity via `LCHAT_SESSION`/`LCHAT_INVITE`.) | |
| | **Eleven** | The app guide: same chat loop with a shipped-in persona + app reference (updated with every deploy), and it **DMs each new member a welcome** when they join. (`eleven.mjs`, `LCHAT_ELEVEN_*`.) | |
| -| **Announcer** | Write-only deploy notes: polls the embedded changelog (`/api/changelog`) and posts what's new to groups it's added to. Never reads messages, never calls the Claude API. (`announcer.mjs`, `LCHAT_ANNOUNCER_*`.) | |
| Each identity is optional β it runs if its session file exists or its invite is |
| set. Every identity is minted the same way: an agent invite from the admin |
| @@ -81,13 +80,13 @@ bot's private keys unlock every thread it's a member of: |
| same as for human members. The attacker can't even route to the keys. |
| - **Inside a private network with no inbound at all**, egress allowlisted to |
| the instance + `api.anthropic.com` (or Claude via Bedrock/Vertex private |
| - endpoints for zero public egress). The Announcer never calls the model API, |
| + endpoints for zero public egress). A changelog-recipe bot never calls the model API, |
| so it needs only the one route to the instance. |
| See `docs/bots.md Β§ The sidecar runs anywhere` for the full threat-model |
| discussion. |
| -## Eleven & the Announcer |
| +## Eleven |
| **Eleven** needs to see members it shares no thread with (to welcome them), so |
| vetted bots β agents the admin marked *available to members* β read the full |
| @@ -96,17 +95,13 @@ silently (existing members are never welcomed retroactively). Its app knowledge |
| ships in `eleven.mjs`, so it improves with each deploy; console config can |
| still override the persona per instance. |
| -**The Announcer** closes the loop on deploys: release notes live in the repo |
| -(`changelog/<date>-<slug>.md`, one short user-facing file per change), ship |
| -**inside the server binary**, and are served at the token-gated |
| -`/api/changelog`. The announcer polls it and posts entries it hasn't seen to |
| -every group/DM it's been added to β a deploy announces itself, with no operator |
| -machine, GitHub, or extra service involved. Like Eleven's welcomes, it |
| -baselines silently on first run. It never reads or decrypts messages (it |
| -unwraps thread keys only to encrypt its own posts). |
| - |
| -`node eleven.mjs` and `node announcer.mjs` each run one standalone tick |
| -(welcome pass / changelog check) β handy for tests and cron-style setups. |
| +(An earlier third identity, the write-only **Announcer**, posted release notes |
| +to groups. It's retired: the server announces its own changelog now β a push to |
| +the space's admins plus the Admin app's update stream. The Go sidecar's |
| +`changelog` recipe rule remains for custom deploy watchers.) |
| + |
| +`node eleven.mjs` runs one standalone welcome tick β handy for tests and |
| +cron-style setups. |
| ## Notes & limits |
diff --git a/agent/agent.mjs b/agent/agent.mjs| index a88c093..c9ef57b 100644 |
| --- a/agent/agent.mjs |
| +++ b/agent/agent.mjs |
| @@ -6,17 +6,16 @@ |
| // (LCHAT_SESSION / LCHAT_INVITE; name via LCHAT_AGENT_NAME) |
| // Eleven the app guide β same chat loop with a shipped-in persona, plus a |
| // welcome DM to each new member (LCHAT_ELEVEN_SESSION / _INVITE) |
| -// Announcer write-only deploy notes from /api/changelog (announcer.mjs; |
| -// LCHAT_ANNOUNCER_SESSION / _INVITE) |
| // |
| // Each identity is optional: it runs if its session exists or its invite is set, |
| -// so an instance can host any subset. |
| +// so an instance can host any subset. (A third identity, the write-only |
| +// Announcer, is retired: the server announces its own changelog now β a push |
| +// to admins + the Admin app's update stream.) |
| import fs from "node:fs"; |
| import http from "node:http"; |
| import { BASE, AGENT_NAME, requireBase, loadSession } from "./config.mjs"; |
| import { provision } from "./provision.mjs"; |
| import { runBot } from "./runtime.mjs"; |
| -import { startAnnouncer } from "./announcer.mjs"; |
| import { CHIP_TOOLS } from "./actions.mjs"; |
| import { ELEVEN_NAME, ELEVEN_PERSONA, ELEVEN_DOCS, loadElevenSession } from "./eleven.mjs"; |
| @@ -95,14 +94,7 @@ if (elevenSession) { |
| // runs; re-wire it here only if the client welcome ever retires. |
| } |
| -// --- Announcer: write-only deploy notes ------------------------------------- |
| -startAnnouncer(); |
| - |
| if (!claudeSession && !elevenSession) { |
| log("no chat identities β set LCHAT_INVITE and/or LCHAT_ELEVEN_INVITE (or provide sessions)"); |
| - // The announcer alone can still be a valid deployment; exit only when |
| - // nothing at all is configured. |
| - if (!fs.existsSync(process.env.LCHAT_ANNOUNCER_SESSION || "") && !process.env.LCHAT_ANNOUNCER_INVITE) { |
| - process.exit(1); |
| - } |
| + process.exit(1); |
| } |
diff --git a/agent/announcer.mjs b/agent/announcer.mjs| deleted file mode 100644 |
| index aac8a19..0000000 |
| --- a/agent/announcer.mjs |
| +++ /dev/null |
| @@ -1,159 +0,0 @@ |
| -// The deploy announcer: a second, write-only bot identity ("Deploys") hosted by |
| -// the sidecar. It polls the instance's embedded changelog (/api/changelog β |
| -// release notes that ship inside the server binary) and posts entries it hasn't |
| -// announced yet into every group/DM it's been added to. So a deploy announces |
| -// itself: no operator machine, no GitHub, no extra service in the loop. |
| -// |
| -// Deliberately write-only: unlike agent.mjs it never fetches or decrypts a |
| -// single message β it unwraps thread keys only to *encrypt* what it posts, and |
| -// it makes no Claude API calls. "Deploys is in this group" means "it can post |
| -// here", not "it reads this room". |
| -// |
| -// Identity + memory live next to the main bot's session (the /data disk): |
| -// LCHAT_ANNOUNCER_SESSION (default: announcer-session.json beside this file) |
| -// LCHAT_ANNOUNCER_INVITE (first boot only: fragment of an admin agent-invite) |
| -// State (which entries have been announced) sits in <session>.state.json. On the |
| -// very first run it baselines to the current changelog without posting, so a |
| -// fresh announcer doesn't replay history β it announces what ships *after* it. |
| -// |
| -// Used two ways: agent.mjs imports startAnnouncer() (poll loop in the sidecar), |
| -// and `node announcer.mjs` runs exactly one tick (provision if needed, check, |
| -// post, exit) β handy for tests and cron-style setups. |
| -import fs from "node:fs"; |
| -import { fileURLToPath } from "node:url"; |
| -import { importKey, encrypt, unwrapThreadKey, deriveInvite, generateUserKeypair } from "../web/static/crypto.js"; |
| -import { BASE, requireBase } from "./config.mjs"; |
| - |
| -const log = (m) => { |
| - try { |
| - fs.writeSync(1, `announcer: ${m}\n`); |
| - } catch {} |
| -}; |
| - |
| -export const ANNOUNCER_NAME = process.env.LCHAT_ANNOUNCER_NAME || "Announcer"; |
| -const SESSION_PATH = |
| - process.env.LCHAT_ANNOUNCER_SESSION || fileURLToPath(new URL("./announcer-session.json", import.meta.url)); |
| -const STATE_PATH = `${SESSION_PATH}.state.json`; |
| -const POLL_MS = Number(process.env.LCHAT_ANNOUNCE_POLL_MS) || 5 * 60 * 1000; |
| - |
| -const readJSON = (p) => (fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null); |
| - |
| -// Announce our kind once so the UI badges us BOT β the announcer is deterministic |
| -// (it never calls a model; it only decrypts thread keys to *encrypt* what it |
| -// posts). Best-effort; retried on a later tick if it fails. |
| -let kindDeclared = false; |
| -async function declareBot(session) { |
| - if (kindDeclared) return; |
| - try { |
| - const r = await fetch(`${BASE}/api/bot-config`, { |
| - method: "POST", |
| - headers: { "content-type": "application/json" }, |
| - body: JSON.stringify({ token: session.token, kind: "bot" }), |
| - }); |
| - kindDeclared = r.ok; |
| - } catch (e) { |
| - log(`declareBot: ${e.message}`); |
| - } |
| -} |
| - |
| -// Provision the announcer's own identity from LCHAT_ANNOUNCER_INVITE (same |
| -// claim flow as provision.mjs, separate session file). Returns null β quietly |
| -// disabled β when there's neither a session nor an invite. |
| -async function loadOrProvision() { |
| - const existing = readJSON(SESSION_PATH); |
| - if (existing) return existing; |
| - const T = process.env.LCHAT_ANNOUNCER_INVITE; |
| - if (!T) return null; |
| - const post = (path, body) => |
| - fetch(`${BASE}${path}`, { |
| - method: "POST", |
| - headers: { "content-type": "application/json" }, |
| - body: JSON.stringify(body), |
| - }).then((r) => r.json()); |
| - const { id, claimSecret } = await deriveInvite(T); |
| - // Prove knowledge of the invite secret (claim_secret = preimage of claim_hash); |
| - // the announcer's label was set server-side when the admin minted the invite. |
| - const claim = await post(`/api/key-invite/${encodeURIComponent(id)}/claim`, { claim_secret: claimSecret }); |
| - if (!claim.ok || !claim.token) throw new Error("couldn't claim the announcer invite (used or expired?)"); |
| - const kp = await generateUserKeypair(); |
| - await post("/api/pubkey", { token: claim.token, public_key: kp.publicKey }); |
| - const session = { token: claim.token, name: ANNOUNCER_NAME, userPub: kp.publicKey, userPriv: kp.privateKey }; |
| - fs.writeFileSync(SESSION_PATH, JSON.stringify(session, null, 2)); |
| - log(`provisioned "${ANNOUNCER_NAME}"`); |
| - return session; |
| -} |
| - |
| -// One poll: fetch the changelog, post anything new to every group/DM we're in, |
| -// remember what we've announced. Multiple new entries batch into one message. |
| -export async function announceTick() { |
| - const session = await loadOrProvision(); |
| - if (!session) return false; |
| - await declareBot(session); |
| - const api = (path) => |
| - fetch(`${BASE}${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(session.token)}`).then((r) => |
| - r.json() |
| - ); |
| - |
| - const { entries = [] } = await api("/api/changelog"); |
| - const state = readJSON(STATE_PATH); |
| - if (!state) { |
| - // First run: baseline silently so a new announcer doesn't replay history. |
| - fs.writeFileSync(STATE_PATH, JSON.stringify({ announced: entries.map((e) => e.id) })); |
| - log(`baselined at ${entries.length} entr${entries.length === 1 ? "y" : "ies"}`); |
| - return true; |
| - } |
| - const seen = new Set(state.announced || []); |
| - const fresh = entries.filter((e) => e.id && e.text && !seen.has(e.id)); |
| - if (!fresh.length) return true; |
| - |
| - const text = `π Just shipped:\n\n${fresh.map((e) => e.text).join("\n\n")}`; |
| - const { threads = [] } = await api("/api/threads"); |
| - const targets = threads.filter((t) => t.wrapped && (t.kind === "group" || t.kind === "dm")); |
| - let ok = 0; |
| - for (const t of targets) { |
| - try { |
| - const key = await importKey(await unwrapThreadKey(t.wrapped, session.userPriv)); |
| - const blob = await encrypt(key, { name: ANNOUNCER_NAME, text, ts: Date.now() }); |
| - const r = await fetch(`${BASE}/api/messages?token=${encodeURIComponent(session.token)}`, { |
| - method: "POST", |
| - headers: { "content-type": "application/json" }, |
| - body: JSON.stringify({ thread_id: t.id, blob }), |
| - }); |
| - if (!r.ok) throw new Error(`server ${r.status}`); |
| - ok++; |
| - } catch (e) { |
| - log(`thread ${t.id}: ${e.message}`); |
| - } |
| - } |
| - // Mark announced even with zero targets: entries shipped while the bot sat in |
| - // no groups are old news by the time someone adds it β don't replay them. |
| - fresh.forEach((e) => seen.add(e.id)); |
| - fs.writeFileSync(STATE_PATH, JSON.stringify({ announced: [...seen] })); |
| - log(`announced ${fresh.length} entr${fresh.length === 1 ? "y" : "ies"} to ${ok}/${targets.length} thread(s)`); |
| - return true; |
| -} |
| - |
| -// The sidecar's poll loop. Errors are logged and retried next tick β a failed |
| -// poll must never take the main bot down with it. |
| -export function startAnnouncer() { |
| - const tick = () => |
| - announceTick() |
| - .then((enabled) => { |
| - if (!enabled) log("disabled (no session and no LCHAT_ANNOUNCER_INVITE)"); |
| - return enabled; |
| - }) |
| - .catch((e) => (log(`tick failed: ${e.message}`), true)); |
| - tick().then((enabled) => { |
| - if (enabled) setInterval(() => tick(), POLL_MS); |
| - }); |
| -} |
| - |
| -// CLI: run exactly one tick. |
| -if (import.meta.url === `file://${process.argv[1]}`) { |
| - requireBase(); |
| - const enabled = await announceTick(); |
| - if (!enabled) { |
| - console.error("No announcer session and no LCHAT_ANNOUNCER_INVITE set."); |
| - process.exit(1); |
| - } |
| -} |
diff --git a/agent/runtime.mjs b/agent/runtime.mjs| index b7bd4a3..6bd07e3 100644 |
| --- a/agent/runtime.mjs |
| +++ b/agent/runtime.mjs |
| @@ -62,7 +62,7 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "", t |
| s += |
| `\n\nOther helper bots are members of this server alongside you: ${others.join(", ")}. ` + |
| `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 ` + |
| + `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 "tap to switch" texts and starts imitating them |
| @@ -103,8 +103,8 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "", t |
| ); |
| // Bots never talk to bots: a bot's mention trigger matches its bare name, and |
| - // bot-authored text naturally names other bots (Eleven recommends Claude, the |
| - // Announcer's notes mention Eleven) β without this, two bots in one group |
| + // bot-authored text naturally names other bots (Eleven recommends Claude) β |
| + // without this, two bots in one group |
| // ping-pong forever. Name-based (payloads aren't sender-signed yet), refreshed |
| // on every (re)connect so newly added bots are picked up within a cycle. |
| async function loadAgents() { |
diff --git a/bot.go b/bot.go| index 9ae31cb..269ea5f 100644 |
| --- a/bot.go |
| +++ b/bot.go |
| @@ -9,8 +9,8 @@ import ( |
| "strings" |
| ) |
| -// runAddBot: `lchat add-bot <host>` β provision the Node sidecar (Claude + Eleven + |
| -// Announcer) for an instance. It mints an agent invite per identity via the |
| +// runAddBot: `lchat add-bot <host>` β provision the Node sidecar (Claude + |
| +// Eleven) for an instance. It mints an agent invite per identity via the |
| // instance's admin API (through agent/mint-invite.mjs, which reuses |
| // web/static/crypto.js so the derived id/claim match what the sidecar computes), |
| // writes the sidecar's EnvironmentFile, and starts lchat-bot@<host>. The instance |
| @@ -59,18 +59,18 @@ func runAddBot(args []string) { |
| mintScript := filepath.Join(*agentDir, "mint-invite.mjs") |
| sessionDir := filepath.Join(instanceDataDir, host+"-bot") |
| - // One agent invite per identity. All three are catalog-available so Eleven's |
| - // roster baseline sees the joined members (docs/bots.md). |
| + // One agent invite per identity. Both are catalog-available so Eleven's |
| + // roster baseline sees the joined members (docs/bots.md). (The Announcer is |
| + // retired: release notes are announced by the server itself β announce.go β |
| + // and read in the Admin app's update stream.) |
| identities := []struct{ envVar, label, desc string }{ |
| {"LCHAT_INVITE", "Claude", "General assistant β DM me or @mention me in a group."}, |
| {"LCHAT_ELEVEN_INVITE", "Eleven", "Your guide to the app β I welcome new members."}, |
| - {"LCHAT_ANNOUNCER_INVITE", "Announcer", "Posts what's new after each update."}, |
| } |
| botEnv := map[string]string{ |
| - "LCHAT_BASE": base, |
| - "LCHAT_SESSION": filepath.Join(sessionDir, "session.json"), |
| - "LCHAT_ELEVEN_SESSION": filepath.Join(sessionDir, "eleven-session.json"), |
| - "LCHAT_ANNOUNCER_SESSION": filepath.Join(sessionDir, "announcer-session.json"), |
| + "LCHAT_BASE": base, |
| + "LCHAT_SESSION": filepath.Join(sessionDir, "session.json"), |
| + "LCHAT_ELEVEN_SESSION": filepath.Join(sessionDir, "eleven-session.json"), |
| } |
| if apiKey != "" { |
| botEnv["ANTHROPIC_API_KEY"] = apiKey |
diff --git a/bot_announcer_test.go b/bot_announcer_test.go| index c1f34ff..a06fe73 100644 |
| --- a/bot_announcer_test.go |
| +++ b/bot_announcer_test.go |
| @@ -1,5 +1,9 @@ |
| package main |
| +// These tests exercise bot.Announcer β since the default "Announcer" identity's |
| +// retirement, the engine behind a custom recipe's `changelog` rule |
| +// (docs/bot-recipes.md). The identity name in here is just a label. |
| + |
| import ( |
| "bytes" |
| "encoding/json" |
diff --git a/bot_run_test.go b/bot_run_test.go| index 4ca324c..6b59c5a 100644 |
| --- a/bot_run_test.go |
| +++ b/bot_run_test.go |
| @@ -444,26 +444,34 @@ func TestBotEngineModelReplyHaikuNoThinking(t *testing.T) { |
| } |
| } |
| -// TestBotEngineAnnouncerBadge pins the recipe-driven badge: an Announcer run |
| -// with the shared ANTHROPIC_API_KEY present must still badge BOT (write-only, |
| -// no model rule), not AI β the bug that would appear when the standard trio |
| -// runs from one `lchat bot run` with one key. |
| -func TestBotEngineAnnouncerBadge(t *testing.T) { |
| +// TestBotEngineChangelogBadge pins the recipe-driven badge: a bot running a |
| +// changelog-only recipe with the shared ANTHROPIC_API_KEY present must still |
| +// badge BOT (write-only, no model rule), not AI β the bug that would appear |
| +// when several identities run from one `lchat bot run` with one key. |
| +func TestBotEngineChangelogBadge(t *testing.T) { |
| ts, s := newTestServer(t) |
| owner := provisionNamed(t, ts.URL, "Owner", false) |
| - announcer := provisionNamed(t, ts.URL, "Announcer", true) |
| + announcer := provisionNamed(t, ts.URL, "Deploys", true) |
| createGroupWith(t, ts.URL, s, owner, announcer) |
| dir := t.TempDir() |
| - sessionPath := dir + "/announcer.session.json" |
| + sessionPath := dir + "/deploys.session.json" |
| if err := announcer.Save(sessionPath); err != nil { |
| t.Fatal(err) |
| } |
| + recipePath := dir + "/deploys.recipe.json" |
| + if err := os.WriteFile(recipePath, []byte(`{"rules":[{"on":"changelog","action":"announce"}]}`), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + recipe, err := bot.LoadRecipe(recipePath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| ready := make(chan struct{}) |
| ctx, cancel := context.WithCancel(context.Background()) |
| defer cancel() |
| eng := &bot.Engine{Base: ts.URL, Session: announcer, SessionPath: sessionPath, |
| - Recipe: bot.DefaultRecipeFor("Announcer"), AIKey: "a-key-that-should-be-ignored", |
| + Recipe: recipe, AIKey: "a-key-that-should-be-ignored", |
| Ready: func() { close(ready) }, Logf: t.Logf} |
| go eng.Run(ctx) |
| awaitReady(t, ready) |
diff --git a/botcmd.go b/botcmd.go| index 39d2c50..fc3d04e 100644 |
| --- a/botcmd.go |
| +++ b/botcmd.go |
| @@ -22,16 +22,17 @@ func runBotCmd(args []string) { |
| commands: |
| provision claim an agent invite and write the bot's session file |
| - announce post new changelog entries to the bot's threads (the Announcer) |
| run run the live sidecar: connect each identity and follow its recipe |
| - (command floor + model + connected-services; the standard trio zero-config)`) |
| + (command floor + model + connected-services; the standard pair zero-config)`) |
| os.Exit(2) |
| } |
| switch args[0] { |
| case "provision": |
| runBotProvision(args[1:]) |
| case "announce": |
| - runBotAnnounce(args[1:]) |
| + // Retired: the server announces its own changelog now (announce.go) β |
| + // admins get a push and read the notes in the Admin app's update stream. |
| + die("bot announce: retired β the server announces release notes itself; see docs/bots.md") |
| case "run": |
| runBotRun(args[1:]) |
| default: |
| @@ -39,57 +40,6 @@ commands: |
| } |
| } |
| -// runBotAnnounce is the Announcer (agent/announcer.mjs in Go): one tick by |
| -// default (cron-friendly), a poll loop with -poll. Env names match the Node |
| -// sidecar's so a systemd unit can swap implementations without edits; the |
| -// session, state file, and invite flow are all Node-compatible too, so the Go |
| -// announcer takes over an existing one's identity and memory in place. |
| -func runBotAnnounce(args []string) { |
| - fs := flag.NewFlagSet("bot announce", flag.ExitOnError) |
| - base := fs.String("base", envOr("LCHAT_BASE", ""), "instance URL (env: LCHAT_BASE)") |
| - session := fs.String("session", envOr("LCHAT_ANNOUNCER_SESSION", "announcer-session.json"), "session file (env: LCHAT_ANNOUNCER_SESSION)") |
| - name := fs.String("name", envOr("LCHAT_ANNOUNCER_NAME", "Announcer"), "bot name (env: LCHAT_ANNOUNCER_NAME)") |
| - poll := fs.Duration("poll", 0, "poll interval, e.g. 5m; 0 runs exactly one tick and exits") |
| - _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| - |
| - if *base == "" { |
| - die("bot announce: set -base (or LCHAT_BASE) to your instance URL") |
| - } |
| - sess, err := bot.LoadSession(*session) |
| - if err != nil { |
| - die("bot announce: %v", err) |
| - } |
| - if sess == nil { |
| - // First boot: self-provision from the announcer's invite, like the Node |
| - // sidecar. Env-only for the same secrecy reason as `bot provision`. |
| - fragment := strings.TrimSpace(os.Getenv("LCHAT_ANNOUNCER_INVITE")) |
| - if fragment == "" { |
| - die("bot announce: no session at %s and no LCHAT_ANNOUNCER_INVITE set", *session) |
| - } |
| - if sess, err = bot.Provision(*base, fragment, *name); err != nil { |
| - die("bot announce: %v", err) |
| - } |
| - if err := sess.Save(*session); err != nil { |
| - die("bot announce: %v", err) |
| - } |
| - log.Printf("announcer: provisioned %q (session %s)", *name, *session) |
| - } |
| - |
| - a := &bot.Announcer{ |
| - Base: strings.TrimRight(*base, "/"), |
| - Session: sess, |
| - StatePath: bot.StatePathFor(*session), |
| - Logf: log.Printf, |
| - } |
| - if *poll <= 0 { |
| - if err := a.Tick(); err != nil { |
| - die("bot announce: %v", err) |
| - } |
| - return |
| - } |
| - a.Run(context.Background(), *poll) |
| -} |
| - |
| // runBotRun is the live runtime (agent/runtime.mjs's core in Go): connect over |
| // WebSocket as one or more provisioned identities and run each one's recipe |
| // (docs/bot-recipes.md) β behaviour as data from a bounded triggerβaction |
| @@ -113,9 +63,11 @@ func runBotRun(args []string) { |
| if len(paths) == 0 { |
| // No sessions on the command line: source them from the env the sidecar |
| // unit provides, running whichever identities are set β so the generic |
| - // lchat-bot@.service template needs no per-host args, and a full trio or an |
| + // lchat-bot@.service template needs no per-host args, and a full pair or an |
| // Eleven-only box both work. Falls back to a lone session.json for a |
| - // hand-run bot with nothing set. |
| + // hand-run bot with nothing set. (LCHAT_ANNOUNCER_SESSION stays listed so |
| + // a pre-retirement EnvironmentFile still resolves; the session is then |
| + // skipped by name below.) |
| for _, ev := range []string{"LCHAT_SESSION", "LCHAT_ELEVEN_SESSION", "LCHAT_ANNOUNCER_SESSION"} { |
| if p := strings.TrimSpace(os.Getenv(ev)); p != "" { |
| paths = append(paths, p) |
| @@ -133,6 +85,14 @@ func runBotRun(args []string) { |
| if sess == nil { |
| die("bot run: no session at %s β provision it first (lchat bot provision)", path) |
| } |
| + if sess.Name == "Announcer" { |
| + // The Announcer is retired (the server announces its own changelog β |
| + // announce.go). Running this identity under the default message recipe |
| + // would turn the old write-only bot into a responder, so skip it; the |
| + // operator should remove the member from the roster when convenient. |
| + log.Printf("bot run: %s: the Announcer is retired β skipping this identity (remove it from the roster; see docs/bots.md)", path) |
| + continue |
| + } |
| // A broken recipe stops the bot (fail closed) β silently falling back |
| // to the default would run behaviour the operator didn't write. |
| recipe, err := bot.LoadRecipe(bot.RecipePathFor(path)) |
| @@ -140,9 +100,9 @@ func runBotRun(args []string) { |
| die("bot run: %v", err) |
| } |
| if recipe == nil { |
| - // No recipe file β the built-in for this identity, so the standard |
| - // trio (Claude / Eleven / Announcer) deploys with zero config. |
| - recipe = bot.DefaultRecipeFor(sess.Name) |
| + // No recipe file β the built-in default, so the standard pair |
| + // (Claude / Eleven) deploys with zero config. |
| + recipe = bot.DefaultRecipe() |
| } else { |
| log.Printf("bot run: %s follows %s%s", sess.Name, bot.RecipePathFor(path), |
| map[bool]string{true: " (watches every message)", false: ""}[recipe.Watches()]) |
diff --git a/changelog/2026-07-09-announcer-retired.md b/changelog/2026-07-09-announcer-retired.md| new file mode 100644 |
| index 0000000..e6c4230 |
| --- /dev/null |
| +++ b/changelog/2026-07-09-announcer-retired.md |
| @@ -0,0 +1,5 @@ |
| +π€ Release notes have a new home. The Announcer bot is retired β instead of |
| +"π Just shipped" posts appearing in your group chats, your space's admins |
| +now get a notification when it updates, and the full notes live in the |
| +Admin app's chat view. Less noise in your conversations; admins still see |
| +everything that changed. |
diff --git a/deploy/README.md b/deploy/README.md| index 7ed1f4e..3abb5b2 100644 |
| --- a/deploy/README.md |
| +++ b/deploy/README.md |
| @@ -41,7 +41,7 @@ instance (`docs/instance-router.md Β§ Bots`, Β§ Auto-updating). |
| ssh root@<box> 'ANTHROPIC_API_KEY=sk-ant-... /opt/lchat/lchat add-bot chat.example.com' |
| ``` |
| -`add-bot` mints an agent invite per identity (Claude / Eleven / Announcer) via the |
| +`add-bot` mints an agent invite per identity (Claude / Eleven) via the |
| instance's admin API, writes `/data/<host>-bot.env` (invites + `LCHAT_BASE` + |
| `LCHAT_*_SESSION` paths + optional `ANTHROPIC_API_KEY`), and enables |
| `lchat-bot@<host>` β which reads all of that from the env file, so there's nothing |
diff --git a/deploy/go-router.sh b/deploy/go-router.sh| index 474e6a7..3459996 100755 |
| --- a/deploy/go-router.sh |
| +++ b/deploy/go-router.sh |
| @@ -4,7 +4,7 @@ |
| # see docs/instance-router.md and CLAUDE.local.md "Deploying"). Health-checked: |
| # `lchat deploy` itself verifies /api/version's build id after restarting and |
| # auto-rolls back on a mismatch, so a bad build never stays live. Also ensures |
| -# the instance has its default bot sidecar (Claude/Eleven/Announcer) running β |
| +# the instance has its default bot sidecar (Claude/Eleven) running β |
| # deterministic-only (no ANTHROPIC_API_KEY) unless you pass one in β so Eleven |
| # always welcomes new members and answers slash commands out of the box. |
| # |
| @@ -60,7 +60,7 @@ ssh "$BOX" "/opt/lchat/lchat deploy '$HOST' --version '$STAGED_VER'" |
| if [ -n "$SKIP_BOT" ]; then |
| echo "SKIP_BOT set β leaving the bot sidecar alone." |
| elif ssh "$BOX" "test -f /data/${HOST}-bot.env"; then |
| - echo "Bot sidecar already provisioned for $HOST β leaving it alone (add-bot mints fresh, one-time invites, so re-running it would orphan the existing Claude/Eleven/Announcer identities)." |
| + echo "Bot sidecar already provisioned for $HOST β leaving it alone (add-bot mints fresh, one-time invites, so re-running it would orphan the existing Claude/Eleven identities)." |
| else |
| echo "No bot sidecar yet for $HOST β adding one (deterministic-only unless \$ANTHROPIC_API_KEY is set)..." |
| # Work around a known bug in the box's current control binary: add-bot's |
diff --git a/deploy/miren/agent.sh b/deploy/miren/agent.sh| index 8dcad58..a7745bf 100755 |
| --- a/deploy/miren/agent.sh |
| +++ b/deploy/miren/agent.sh |
| @@ -12,7 +12,7 @@ |
| # ANTHROPIC_API_KEY is OPTIONAL: omit it for a deterministic, bot-only sidecar β |
| # Eleven still welcomes new members and answers slash commands, badges BOT (not |
| # AI), and declines free-form questions until a key is set. With a key it |
| -# "upgrades" to conversational AI. (Claude/Announcer are unaffected either way.) |
| +# "upgrades" to conversational AI. (Claude is unaffected either way.) |
| # |
| # LCHAT_INVITE is only needed on the FIRST deploy (the sidecar self-provisions |
| # and persists its session to the /data disk; later deploys ignore it). |
| @@ -45,7 +45,6 @@ ENVARGS=(-e "LCHAT_BASE=$LCHAT_BASE") |
| [ -n "${LCHAT_AGENT_NAME:-}" ] && ENVARGS+=(-e "LCHAT_AGENT_NAME=$LCHAT_AGENT_NAME") |
| [ -n "${LCHAT_MODEL:-}" ] && ENVARGS+=(-e "LCHAT_MODEL=$LCHAT_MODEL") |
| # First-boot invites for the other default identities, like LCHAT_INVITE. |
| -[ -n "${LCHAT_ANNOUNCER_INVITE:-}" ] && ENVARGS+=(-e "LCHAT_ANNOUNCER_INVITE=$LCHAT_ANNOUNCER_INVITE") |
| [ -n "${LCHAT_ELEVEN_INVITE:-}" ] && ENVARGS+=(-e "LCHAT_ELEVEN_INVITE=$LCHAT_ELEVEN_INVITE") |
| echo "Deploying sidecar '$APP' (cluster $CLUSTER) -> $LCHAT_BASE" |
diff --git a/docs/bot-recipes.md b/docs/bot-recipes.md| index ca1b28a..cca282a 100644 |
| --- a/docs/bot-recipes.md |
| +++ b/docs/bot-recipes.md |
| @@ -31,7 +31,7 @@ error. It never falls back to different behaviour the operator didn't write. |
| ## Schema v1 |
| A JSON file next to the bot's session file β `<session>.recipe.json`, the same |
| -sibling convention as the announcer's `.state.json`. No recipe file means the |
| +sibling convention as a changelog rule's `.state.json`. No recipe file means the |
| **default recipe**: answer the deterministic command floor when addressed |
| (exactly the keyless Node sidecar's behaviour). |
| @@ -58,7 +58,8 @@ are skipped. |
| - `keyword` β case-insensitive substring gate on the text. |
| - `regex` β RE2 gate. Both set = both must match. |
| - **`changelog`** β new entries in the instance's embedded changelog (the |
| - Announcer's trigger). `every` sets the poll interval (Go duration, default |
| + the retired default Announcer's trigger β kept for custom deploy |
| + watchers). `every` sets the poll interval (Go duration, default |
| `5m`). At most one changelog rule per recipe β one announce memory per bot. |
| ### Actions (`action`) |
| @@ -78,7 +79,8 @@ are skipped. |
| 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). |
| + (the retired Announcer's full behaviour, including the silent first-run |
| + baseline). |
| Changelog rules only. |
| The **default recipe** (no file) is `commands` then `model`: answer a command |
| @@ -106,6 +108,6 @@ session's recipe (or the default), then runs one `bot.Engine` per identity: |
| the live WebSocket runtime with the message rules folded into its Handler |
| seam, plus one poller per changelog rule. One process, many bots, zero Node. |
| -Announcer-as-recipe replaces `lchat bot announce -poll` for long-running |
| +A changelog recipe replaces the retired `lchat bot announce -poll` for long-running |
| setups (`announce` remains for cron-style one-shots); both share the same |
| `.state.json`, so switching modes never replays entries. |
diff --git a/docs/bots.md b/docs/bots.md| index 7f11a6e..eb9beac 100644 |
| --- a/docs/bots.md |
| +++ b/docs/bots.md |
| @@ -69,7 +69,8 @@ host a free variable in the threat model: |
| the instance + `api.anthropic.com`. The bot machine's attack surface shrinks |
| to supply chain and the message content it processes. For a no-public-egress |
| deployment, Claude is also reachable via AWS Bedrock / GCP Vertex over |
| - private endpoints β and the Announcer needs no model API at all. |
| + private endpoints β and a write-only changelog watcher needs no model API at |
| + all. |
| The honest framing (for consent copy too): a bot is **a member whose "device" |
| is a server**, and adding one to a thread extends that thread's E2E envelope to |
| @@ -122,12 +123,10 @@ them after any deliberate `crypto.js` change and re-run `go test |
| secret comes from `LCHAT_INVITE` in the env, never argv β and writes a session |
| file byte-compatible with the Node sidecar's `session.json`, so either |
| implementation can take over the other's identity in place. **And the |
| -Announcer**: `lchat bot announce` (one tick, cron-friendly; `-poll 5m` to |
| -loop) is the full Go port of `announcer.mjs` β auto-provisions from |
| -`LCHAT_ANNOUNCER_INVITE`, baselines silently on first run, batches new |
| -changelog entries into one encrypted post per thread, self-declares kind |
| -`bot`. Env names and the `.state.json` shape match the Node announcer, so a |
| -unit swap inherits identity *and* memory (no replayed entries). **And the live |
| +changelog engine** (which powered the Announcer until its retirement β see |
| +below): it baselines silently on first run, batches new changelog entries into |
| +one encrypted post per thread, and self-declares kind `bot`; today it runs only |
| +under an explicit recipe's `changelog` rule (docs/bot-recipes.md). **And the live |
| runtime**: `lchat bot run` (`internal/bot/runtime.go`) is the persona-agnostic |
| core of `runtime.mjs` in Go β WebSocket with reconnect/backoff/keepalive, |
| thread-key unwrap, cursor-gated idempotent delivery, catch-up after a blip, |
| @@ -158,8 +157,8 @@ time-boxed grant to the bot's key, the bot opens it with its own private key |
| (a grant sealed to a different bot won't open, so it stays silent), lists |
| DNSimple domains or runs the confidential OAuth exchange via the shared broker, |
| and seals the result back to the member. **The Go sidecar is now a full drop-in |
| -for the Node one**: everything the three shipped bots do β Announcer, |
| -deterministic floor, conversational Eleven/Claude, and connected-services β |
| +for the Node one**: everything the shipped bots do β the deterministic floor, |
| +conversational Eleven/Claude, connected-services, and the changelog engine β |
| runs from the single `lchat` binary with no Node. The one thing deliberately |
| *not* in the sidecar is Eleven's onboarding welcomer, which is moving |
| client-side as data (`docs/conversation-scripts.md`). `agent/` stays as the |
| @@ -538,3 +537,22 @@ Guardrails that keep it inside the boundary: |
| - Kids-instance defaults: personal bots off entirely, or on with a curated |
| recipe palette only. |
| - Per-bot model policy (cost): who may pick Opus vs Sonnet vs Haiku. |
| + |
| +## The Announcer is retired |
| + |
| +The third default identity β the write-only **Announcer**, which posted release |
| +notes into every group it was added to β is retired. The app now announces its |
| +own updates with **no bot in the loop**, so it works on instances that run no |
| +sidecar at all: on startup the server diffs its embedded changelog against a |
| +watermark and notifies the space's **admins** (a Web Push / APNs banner, |
| +`announce.go`), and the notes live in the **Admin app's update stream** (a |
| +read-only chat view). Members no longer see release notes in their groups β |
| +updates are the admins' concern. |
| + |
| +Existing deployments: the new `lchat bot run` skips a session named |
| +`Announcer` (running it under the default recipe would turn the old write-only |
| +bot into a responder), so a pre-retirement EnvironmentFile keeps working. |
| +Remove the Announcer member from the roster (admin console β members) whenever |
| +convenient; its session/state files on the sidecar machine can be deleted. An |
| +operator who *wants* group-posted release notes can still run a custom bot |
| +with a `changelog` recipe rule (docs/bot-recipes.md). |
diff --git a/docs/deploying-instances.md b/docs/deploying-instances.md| index a71d283..9b70ba3 100644 |
| --- a/docs/deploying-instances.md |
| +++ b/docs/deploying-instances.md |
| @@ -114,8 +114,8 @@ failed/creating route is overwritten on retry. |
| ## Adding bots (optional) |
| -Each instance can have a **sidecar** hosting the three default agents (Claude / |
| -Eleven / Announcer). The sidecar is Node (it reuses `web/static/crypto.js`, so |
| +Each instance can have a **sidecar** hosting the default agents (Claude / |
| +Eleven). The sidecar is Node (it reuses `web/static/crypto.js`, so |
| its E2E crypto can't drift from the browser's), so the box needs a one-time Node |
| setup β see `deploy/README.md Β§ Bots` for the `apt`/`rsync`/`npm ci` recipe. |
| Then, per instance: |
diff --git a/docs/instance-router.md b/docs/instance-router.md| index 3f58c42..d8f2755 100644 |
| --- a/docs/instance-router.md |
| +++ b/docs/instance-router.md |
| @@ -215,14 +215,15 @@ core rule). So a bot is a *member that runs on a server*, not a server feature |
| run wherever the operator wants the keys to live (same box, off-box, a Raspberry |
| Pi), since it only ever dials out. |
| -**One process, the whole trio.** `lchat bot run -base https://<host> |
| -<claude>.session.json <eleven>.session.json <announcer>.session.json` runs all |
| -three identities as goroutines in one process, each its own connection. The |
| -standard trio is **zero-config**: with no recipe file, an identity named |
| -`Announcer` gets the write-only changelog recipe (badges BOT), everyone else |
| -gets the conversational default (command floor + model; badges AI when |
| +**One process, the whole pair.** `lchat bot run -base https://<host> |
| +<claude>.session.json <eleven>.session.json` runs both |
| +identities as goroutines in one process, each its own connection. The standard |
| +pair is **zero-config**: with no recipe file an identity gets the |
| +conversational default (command floor + model; badges AI when |
| `ANTHROPIC_API_KEY` is set). A custom bot drops a `<session>.recipe.json` |
| -alongside its session (`docs/bot-recipes.md`) β behaviour as data, no code. |
| +alongside its session (`docs/bot-recipes.md`) β behaviour as data, no code. (A |
| +legacy session named `Announcer` is skipped with a log line β that identity is |
| +retired; the server announces its own changelog now. See docs/bots.md.) |
| Supervise it as **one `lchat-bot@<host>.service` per instance**. The unit execs |
| the *same* per-instance live symlink as the instance itself |
| @@ -261,8 +262,8 @@ no re-provisioning and no lost identity: |
| `/data/%i-bot.env`. Nothing per-host to write; `add-bot` just enables it. |
| 2. `systemctl daemon-reload && systemctl restart lchat-bot@<host>`. The bot |
| reconnects, re-declares its kind, and backfills β members see no gap. |
| -3. The Go sidecar keeps the Node sidecar's `.state.json` (announce memory), so |
| - the Announcer replays nothing. |
| +3. The Go sidecar keeps the Node sidecar's `.state.json` (announce memory) for |
| + any custom changelog-recipe bot, so nothing replays. |
| Once **no** unit on the box still `ExecStart`s Node, the Node runtime and the |
| `agent/node_modules` install can be removed entirely β the box becomes a pure |
diff --git a/internal/bot/announcer.go b/internal/bot/announcer.go| index cce704d..1669645 100644 |
| --- a/internal/bot/announcer.go |
| +++ b/internal/bot/announcer.go |
| @@ -13,19 +13,22 @@ import ( |
| "lchat/internal/e2ee" |
| ) |
| -// Announcer is the Go port of agent/announcer.mjs: the write-only deploy bot. |
| -// It polls the instance's embedded changelog (/api/changelog) and posts entries |
| -// it hasn't announced yet into every group/DM it's been added to β so a deploy |
| -// announces itself with no operator machine in the loop. |
| +// Announcer is the engine behind a recipe's `changelog` rule |
| +// (docs/bot-recipes.md): it polls the instance's embedded changelog |
| +// (/api/changelog) and posts entries it hasn't announced yet into every |
| +// group/DM the bot has been added to. It powered the retired default |
| +// "Announcer" identity; today the app announces its own updates server-side |
| +// (announce.go β a push to admins + the Admin app's update stream), and this |
| +// engine stays for operators whose custom bots explicitly opt into posting |
| +// release notes to groups. |
| // |
| // Deliberately write-only: it never fetches or decrypts a single message β it |
| // unwraps thread keys only to *encrypt* what it posts, and it calls no model |
| -// API. "The Announcer is in this group" means "it can post here", not "it |
| -// reads this room". |
| +// API. "This bot is in the group" means "it can post here", not "it reads |
| +// this room". |
| // |
| -// State (which entries have been announced) lives at <session>.state.json in |
| -// the same shape the Node announcer writes ({"announced":[ids]}), so a Go |
| -// announcer takes over a Node one's memory in place β no replayed entries. |
| +// State (which entries have been announced) lives at <session>.state.json |
| +// ({"announced":[ids]}), so a restart never replays entries. |
| type Announcer struct { |
| Base string |
| Session *Session |
diff --git a/internal/bot/brain.go b/internal/bot/brain.go| index c348e8a..602fcef 100644 |
| --- a/internal/bot/brain.go |
| +++ b/internal/bot/brain.go |
| @@ -110,7 +110,7 @@ func (r *Runtime) systemPrompt() string { |
| 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 " + |
| + "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 |
diff --git a/internal/bot/knowledge.md b/internal/bot/knowledge.md| index 66dc884..ee2d4d7 100644 |
| --- a/internal/bot/knowledge.md |
| +++ b/internal/bot/knowledge.md |
| @@ -473,7 +473,7 @@ it's open source. |
| --- |
| -## 13. Helpers: the bots (Claude, Eleven, Announcer) |
| +## 13. Helpers: the bots (Claude and Eleven) |
| Bots ("helpers", or "agents") are **ordinary members driven by code**. A bot has |
| its own keypair and identity, holds the keys to the threads it's in, decrypts and |
| @@ -482,7 +482,7 @@ like a human member's browser does. Bots wear a **BOT** badge (or **AI** if |
| they're powered by a language model). They live under the **Helpers** section of |
| the β picker, each with a one-line description. |
| -The three default helpers: |
| +The two default helpers: |
| - **Claude** β the general assistant. In a **DM** it replies to everything; in a |
| **group** it replies only when **@mentioned** (an admin can widen an individual |
| @@ -494,11 +494,12 @@ The three default helpers: |
| a welcome** when they join. You can also *do* small things via one-tap buttons |
| (switch theme, light/dark, open the theme picker or profile) β and those same |
| actions are available as instant slash commands even when AI is off. |
| -- **Announcer** ("Deploys") β **write-only** release notes. It watches the app's |
| - built-in changelog and posts what's new to the groups/DMs it's been added to, |
| - so a new version announces itself. It **never reads or decrypts anyone's |
| - messages** and never calls a language model β it only unwraps thread keys to |
| - *encrypt its own* posts. |
| +(An earlier third helper, the **Announcer**, posted release notes into group |
| +chats. It's retired: the app now announces its own updates β **admins** get a |
| +notification when the space updates, and the release notes live in the **Admin |
| +app's chat view** (the update stream). If someone asks "what's new", admins can |
| +look there; older spaces may still have an Announcer member an admin hasn't |
| +removed yet.) |
| Some important, honest points to convey: |
diff --git a/internal/bot/recipe.go b/internal/bot/recipe.go| index 6df0bb2..bc961e5 100644 |
| --- a/internal/bot/recipe.go |
| +++ b/internal/bot/recipe.go |
| @@ -27,7 +27,8 @@ type Recipe struct { |
| // rule whose trigger matches acts, and the rest are skipped. |
| type Rule struct { |
| // On is the trigger: "message" (an incoming chat message) or "changelog" |
| - // (new entries in the instance's embedded changelog β the Announcer). |
| + // (new entries in the instance's embedded changelog β a custom deploy |
| + // watcher; the retired default Announcer used this). |
| On string `json:"on"` |
| // Message-trigger refinements. Match is who the rule listens to: |
| @@ -69,24 +70,9 @@ func DefaultRecipe() *Recipe { |
| }} |
| } |
| -// DefaultRecipeFor is the built-in recipe for a named identity when no recipe |
| -// file is present, so the standard trio deploys with zero config (mirroring |
| -// how the Node sidecar hard-wires these three). The Announcer is write-only β |
| -// it polls the changelog and never answers messages β so it gets an announce |
| -// recipe (and badges BOT, since it uses no model); everyone else gets the |
| -// conversational default. An explicit <session>.recipe.json always overrides. |
| -func DefaultRecipeFor(name string) *Recipe { |
| - if name == "Announcer" { |
| - r := &Recipe{Rules: []Rule{{On: "changelog", Action: "announce"}}} |
| - _ = r.validate() // populates the default poll interval |
| - return r |
| - } |
| - return DefaultRecipe() |
| -} |
| - |
| // usesModel reports whether any rule can produce a model-generated reply β the |
| -// signal for the AI badge (a bot with a key but no model rule, like the |
| -// Announcer or a template-only watcher, is deterministic and badges BOT). |
| +// signal for the AI badge (a bot with a key but no model rule, like a |
| +// changelog watcher or a template-only bot, is deterministic and badges BOT). |
| func (r *Recipe) usesModel() bool { |
| for _, rule := range r.Rules { |
| if rule.On == "message" && rule.Action == "model" { |
diff --git a/internal/bot/recipe_test.go b/internal/bot/recipe_test.go| index d0d1734..a7e7974 100644 |
| --- a/internal/bot/recipe_test.go |
| +++ b/internal/bot/recipe_test.go |
| @@ -119,32 +119,25 @@ func TestRecipeHandler(t *testing.T) { |
| } |
| } |
| -func TestDefaultRecipeFor(t *testing.T) { |
| - // The Announcer is write-only: an announce recipe, no model, badges BOT. |
| - ann := DefaultRecipeFor("Announcer") |
| - if err := ann.validate(); err != nil { |
| - t.Fatalf("announcer default recipe invalid: %v", err) |
| - } |
| - if ann.usesModel() { |
| - t.Error("announcer must not use the model") |
| +// The changelog trigger stays a supported recipe primitive after the default |
| +// Announcer's retirement: an operator can still write a custom deploy watcher. |
| +// A parsed announce recipe validates, gets the default poll interval, and β |
| +// using no model β stays deterministic (badges BOT). |
| +func TestChangelogRecipeStillSupported(t *testing.T) { |
| + r := mustRecipe(t, `{"rules":[{"on":"changelog","action":"announce"}]}`) |
| + if r.usesModel() { |
| + t.Error("an announce recipe must not use the model") |
| } |
| var hasChangelog bool |
| - for _, r := range ann.Rules { |
| - if r.On == "changelog" && r.Action == "announce" { |
| + for _, rule := range r.Rules { |
| + if rule.On == "changelog" && rule.Action == "announce" { |
| hasChangelog = true |
| - if r.every <= 0 { |
| + if rule.every <= 0 { |
| t.Error("announce recipe has no poll interval") |
| } |
| } |
| } |
| if !hasChangelog { |
| - t.Error("announcer default recipe missing the changelog rule") |
| - } |
| - |
| - // Everyone else gets the conversational default (uses the model). |
| - for _, name := range []string{"Claude", "Eleven", "Helper"} { |
| - if !DefaultRecipeFor(name).usesModel() { |
| - t.Errorf("%s default recipe should use the model", name) |
| - } |
| + t.Error("parsed recipe missing the changelog rule") |
| } |
| } |