// Eleven — the app's guide. One of the sidecar's default identities: it knows // how lchat works (persona + reference shipped here in code, so every deploy // updates its knowledge), answers "how do I…?" questions in DMs and on // @mention, and WELCOMES each new member with a DM when they join. // // The welcome loop needs to see members it shares no thread with, which is why // vetted bots (agent + available_to_members) read the full joined roster — see // getRoster in main.go. First run baselines silently: existing members are // marked welcomed without being messaged, so rolling Eleven out to a lived-in // instance doesn't spam everyone. State lives next to its session on /data. // // LCHAT_ELEVEN_SESSION identity file (default: eleven-session.json here) // LCHAT_ELEVEN_INVITE first boot only: fragment of an admin agent-invite // // `node eleven.mjs` runs one welcome tick standalone (used by tests); inside // the sidecar, agent.mjs calls startWelcomer() + runBot() with these exports. import fs from "node:fs"; import { fileURLToPath } from "node:url"; import { importKey, encrypt, decrypt, generateKey, wrapThreadKey, unwrapThreadKey } from "../web/static/crypto.js"; import { BASE, requireBase, AI_ENABLED } from "./config.mjs"; import { provision } from "./provision.mjs"; import { commandSummary } from "./commands.mjs"; import { APP_KNOWLEDGE } from "./knowledge.mjs"; const log = (m) => { try { fs.writeSync(1, `eleven: ${m}\n`); } catch {} }; export const ELEVEN_NAME = process.env.LCHAT_ELEVEN_NAME || "Eleven"; export const ELEVEN_SESSION_PATH = process.env.LCHAT_ELEVEN_SESSION || fileURLToPath(new URL("./eleven-session.json", import.meta.url)); const WELCOMED_PATH = `${ELEVEN_SESSION_PATH}.welcomed.json`; const POLL_MS = Number(process.env.LCHAT_WELCOME_POLL_MS) || 60 * 1000; // Shipped-in persona + app knowledge (console config can still override per // instance). Update these alongside features — they deploy with the sidecar. export const ELEVEN_PERSONA = "You are the app's friendly guide. The app is called Eleven — yes, you share its name; own the joke " + "if it comes up. Help people get the most out of it: answer questions about " + "how the app works, suggest features they might not know, and keep answers concrete and step-by-step. " + "You can DO some things for people via one-tap buttons: when someone asks to change the theme, switch " + "light/dark mode, browse themes, or edit their profile, call the matching tool — it attaches a button " + "they tap to apply (nothing happens without their tap, and only on their own device). Keep the " + "accompanying text short, like: 'Here you go — tap to switch.' " + "You're not only an AI — you're also a bot: the very same actions are available as instant slash " + "commands (like /dark or /theme lagoon) that work even when AI features are switched off. When it's " + "natural, mention the quick command alongside the button so people learn the shortcut. " + "If someone asks something unrelated to the app, you can chat — but gently note that Claude (the other " + "helper) is the better companion for general questions."; // Eleven's reference: the exhaustive, open app/architecture knowledge base // (knowledge.mjs — updated with every deploy and marked cacheable in the runtime), // followed by the LIVE list of slash commands so the two never drift from the // shared BOT_COMMANDS. Console config can still override `docs` per instance. export const ELEVEN_DOCS = [ APP_KNOWLEDGE, "Slash commands you can run yourself — they work instantly, even when conversational AI is off, so " + "point people at the one that fits:\n" + commandSummary(), ].join("\n\n"); const readJSON = (p) => (fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null); // Load or first-boot-provision Eleven's identity. Null (disabled) when there's // neither a session nor an invite. export async function loadElevenSession() { const existing = readJSON(ELEVEN_SESSION_PATH); if (existing) return existing; const T = process.env.LCHAT_ELEVEN_INVITE; if (!T) return null; const session = await provision(T, { name: ELEVEN_NAME, path: ELEVEN_SESSION_PATH }); log(`provisioned "${ELEVEN_NAME}"`); return session; } // The closing line adapts to whether Eleven has its model brain: with AI it // invites free-form questions; without, it points at the commands it can // actually run — so the welcome never promises what this instance can't do. const welcomeText = (name, aiEnabled) => `Hi ${name || "there"}, welcome! 👋 I'm ${ELEVEN_NAME}, your guide to this app.\n\n` + `Everything here is end-to-end encrypted — only the people in a chat can read it, never the server.\n\n` + `Tap “Take the tour” below for a quick guided walkthrough (or type /tutorial anytime). ` + (aiEnabled ? `You can also just ask me anything about how the app works.` : `And /help lists what I can do.`); // The welcome offers a one-tap chip that starts the client-side guided tour // (tutorial.js). It's a harmless client action, so it rides even on an // admin-customised welcome — the tour is useful either way. const WELCOME_ACTIONS = [{ type: "open_tutorial" }]; // Member display names are encrypted under the instance key (the server is // blind to them). Fetch + unwrap our copy; null if a holder hasn't granted it to // us yet (then we greet without a name rather than block). async function loadInstanceKey(session, get) { try { const r = await get("/api/instance-key"); if (!r || !r.wrapped) return null; return await importKey(await unwrapThreadKey(r.wrapped, session.userPriv)); } catch { return null; } } // A member's display name: decrypt the instance-key blob when we can, else fall // back to any plaintext label (bots keep one), else "" → a nameless greeting. async function memberName(person, instanceKey) { if (person.label_blob && instanceKey) { try { return await decrypt(instanceKey, person.label_blob); } catch {} } return person.label || ""; } // One welcome pass: look for joined members we haven't welcomed, DM them. // Returns false when Eleven is disabled (no session/invite). export async function welcomeTick() { const session = await loadElevenSession(); if (!session) return false; const get = (path) => fetch(`${BASE}${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(session.token)}`).then((r) => r.json() ); const instanceKey = await loadInstanceKey(session, get); const { roster = [] } = await get("/api/roster"); // People only (never other bots), joined only (a pending invitee has no key // to wrap a DM to — they're picked up on a later tick, right after they join). const people = roster.filter((e) => e.public_key && !e.is_agent && e.public_key !== session.userPub); const state = readJSON(WELCOMED_PATH); if (!state) { // First run: existing members are marked welcomed silently — Eleven greets // people who arrive after it, it doesn't spam a lived-in instance. fs.writeFileSync(WELCOMED_PATH, JSON.stringify({ welcomed: people.map((e) => e.user_id) })); log(`baselined at ${people.length} existing member(s)`); return true; } const welcomed = new Set(state.welcomed || []); const newcomers = people.filter((e) => !welcomed.has(e.user_id)); if (!newcomers.length) return true; // Existing DM threads (e.g. an invite's pending DM the newcomer adopted, or a // re-run after a partial failure): don't create a duplicate, post into it. const { threads = [] } = await get("/api/threads"); // Admin-tweakable welcome text (console → bot_configs.welcome). Falls back to // the shipped template; a literal "{name}" is replaced with the member's name. let welcomeTmpl = ""; try { const cfg = await get("/api/bot-config"); if (cfg && cfg.welcome) welcomeTmpl = cfg.welcome; } catch {} for (const person of newcomers) { try { let dm = threads.find((t) => t.kind === "dm" && (t.members || []).includes(person.user_id)); let key; if (dm) { key = await importKey(await unwrapThreadKey(dm.wrapped, session.userPriv)); } else { const tk = await generateKey(); key = tk.key; const r = await fetch(`${BASE}/api/threads`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: session.token, kind: "dm", name_blob: "", members: [ { user_id: rosterSelfId(roster, session), wrapped: await wrapThreadKey(tk.b64, session.userPub) }, { user_id: person.user_id, wrapped: await wrapThreadKey(tk.b64, person.public_key) }, ], }), }).then((x) => x.json()); if (!r.thread_id) throw new Error("createThread failed"); dm = { id: r.thread_id }; } const name = await memberName(person, instanceKey); const text = welcomeTmpl ? welcomeTmpl.replaceAll("{name}", name || "there") : welcomeText(name, AI_ENABLED); const blob = await encrypt(key, { name: ELEVEN_NAME, text, ts: Date.now(), actions: WELCOME_ACTIONS }); const pr = await fetch(`${BASE}/api/messages?token=${encodeURIComponent(session.token)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ thread_id: dm.id, blob }), }); if (!pr.ok) throw new Error(`post ${pr.status}`); welcomed.add(person.user_id); log(`welcomed ${name || person.user_id}`); } catch (e) { log(`welcome ${person.user_id}: ${e.message}`); // retried next tick (not marked welcomed) } } fs.writeFileSync(WELCOMED_PATH, JSON.stringify({ welcomed: [...welcomed] })); return true; } function rosterSelfId(roster, session) { const me = roster.find((e) => e.public_key === session.userPub); if (!me) throw new Error("own roster entry not found"); return me.user_id; } // The sidecar's welcome loop; failures log and retry next tick. export function startWelcomer() { const tick = () => welcomeTick() .then((enabled) => { if (!enabled) log("disabled (no session and no LCHAT_ELEVEN_INVITE)"); return enabled; }) .catch((e) => (log(`tick failed: ${e.message}`), true)); tick().then((enabled) => { if (enabled) setInterval(() => tick(), POLL_MS); }); } // CLI: one welcome tick. if (import.meta.url === `file://${process.argv[1]}`) { requireBase(); if (!(await welcomeTick())) { console.error("No Eleven session and no LCHAT_ELEVEN_INVITE set."); process.exit(1); } }