From 998264679b43053bf7e741041c0ff5d0f983df7b Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sat, 8 Aug 2026 13:29:14 -0700 Subject: [PATCH] Let the chat server state its own limits and syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 128-character cap lived in four unlinked copies — the chat piece, the keyboard clamp, the MCP tool, and the server that actually enforces it. Now shared/chat-capabilities.mjs holds it once, next to the message syntax and the bare-word commands, and everything else reads from there. The server hands the object to every client in the connected packet, so the chat piece takes its limit from the handshake instead of a hardcoded default, and serves it at /chat/capabilities (plus /api/chat-capabilities on lith) for bots that want the contract before they post. The chat_send MCP tool builds its description from the same object, so an agent learns the cap and the quoting rule that makes a piece name tappable from the tool contract rather than by reading source. --- session-server/chat-manager.mjs | 47 ++++++++- session-server/session.mjs | 17 ++++ shared/chat-capabilities.mjs | 97 +++++++++++++++++++ slab/bin/chat-mcp.mjs | 25 ++++- .../netlify/functions/chat-capabilities.mjs | 53 ++++++++++ .../public/aesthetic.computer/disks/chat.mjs | 13 +++ 6 files changed, 243 insertions(+), 9 deletions(-) create mode 100644 shared/chat-capabilities.mjs create mode 100644 system/netlify/functions/chat-capabilities.mjs diff --git a/session-server/chat-manager.mjs b/session-server/chat-manager.mjs index f81890f54c..dd835344a6 100644 --- a/session-server/chat-manager.mjs +++ b/session-server/chat-manager.mjs @@ -13,6 +13,11 @@ import { ensureIndexes as ensureHeartsIndexes, toggleHeart, countHearts } from " import { MongoClient, ObjectId } from "mongodb"; import { broadcastToTopic } from "../shared/push.mjs"; // Standard push (no Firebase). +import { + MAX_CHARS, + chatCapabilities, + profanityFiltered, +} from "../shared/chat-capabilities.mjs"; const MAX_MESSAGES = 500; @@ -306,6 +311,9 @@ export class ChatManager { handles: this.getOnlineHandles(instance), messages: instance.messages, heartCounts, + // The handshake: what this channel accepts. Clients read their limit + // and syntax from here instead of hardcoding a copy of it. + capabilities: chatCapabilities(instance.config.name), id, }, id, @@ -358,10 +366,16 @@ export class ChatManager { return; } - // Length limit - const len = 128; - if (msg.content.text.length > len) { - ws.send(this.pack("too-long", { message: `Please limit to ${len} characters.` })); + // Length limit. Counted in UTF-16 code units — see shared/chat-capabilities. + if (msg.content.text.length > MAX_CHARS) { + ws.send( + this.pack("too-long", { + message: `Please limit to ${MAX_CHARS} characters.`, + maxChars: MAX_CHARS, + countedAs: "utf16-code-units", + was: msg.content.text.length, + }), + ); return; } @@ -422,7 +436,9 @@ export class ChatManager { redact(message); filteredText = message.text; } else { - filteredText = instance.config.name === "chat-clock" ? message.text : filter(message.text, this.filterDebug); + filteredText = profanityFiltered(instance.config.name) + ? filter(message.text, this.filterDebug) + : message.text; } // Get server time @@ -888,6 +904,27 @@ export class ChatManager { })); } + // The same handshake the `connected` packet carries, for callers with no + // socket. `host` may be a chat host or a bare channel name; without one you + // get every channel, since the profanity policy differs between them. + getCapabilities(host = null) { + if (host) { + const instance = + this.getInstance(host) || + Object.values(this.instances).find((i) => i.config.name === host); + if (!instance) return null; + return { + host: instance.config.allowedHost, + ...chatCapabilities(instance.config.name), + }; + } + + return Object.values(this.instances).map((instance) => ({ + host: instance.config.allowedHost, + ...chatCapabilities(instance.config.name), + })); + } + // Get recent messages for a specific instance (for dashboard) getRecentMessages(host, count = 10) { const instance = this.getInstance(host); diff --git a/session-server/session.mjs b/session-server/session.mjs index 35ee2ba8e1..a9211bc4c2 100644 --- a/session-server/session.mjs +++ b/session-server/session.mjs @@ -845,6 +845,23 @@ fastify.get("/chat/status", async (req) => { return chatManager.getStatus(); }); +// *** Chat Capabilities Endpoint *** +// The limit and syntax a bot needs before it posts, without opening a socket. +// Served from every chat host, so `https://chat-clock.aesthetic.computer/chat/ +// capabilities` answers for the room you're actually in. Defaults to the host +// you asked over; `?channel=` (or `all`) overrides. +fastify.get("/chat/capabilities", async (req, reply) => { + reply.header("Access-Control-Allow-Origin", "*"); + const asked = req.query?.channel || req.query?.instance; + if (asked === "all") return chatManager.getCapabilities(); + const caps = chatManager.getCapabilities(asked || req.headers.host); + if (caps) return caps; + // Reached over a non-chat host (session-server itself) with no ?channel. + if (!asked) return chatManager.getCapabilities(); + reply.status(404); + return { status: "error", message: `Unknown chat channel "${asked}".` }; +}); + const PROFILE_SECRET_CACHE_MS = 60 * 1000; let profileSecretCacheValue = null; let profileSecretCacheAt = 0; diff --git a/shared/chat-capabilities.mjs b/shared/chat-capabilities.mjs new file mode 100644 index 0000000000..88b67e3199 --- /dev/null +++ b/shared/chat-capabilities.mjs @@ -0,0 +1,97 @@ +// chat capabilities, 26.08.08 +// What the AC chat server will accept, stated once. +// +// The 128-char cap used to live in four unlinked places — the `chat` piece, the +// MCP tool, a comment, and the server itself — so they drifted. The server is +// the only authority, and this is the server's copy. Everything else reads it: +// +// session-server/chat-manager.mjs enforces it, and hands it to every client +// in the `connected` packet +// .../disks/chat.mjs takes its limit from that packet +// /chat/capabilities (session-server, per chat host) +// /api/chat-capabilities (lith, for bots that only know the apex) +// slab/bin/chat-mcp.mjs builds the chat_send tool contract from it +// +// If you change the cap or the syntax, change it here and nowhere else. + +export const MAX_CHARS = 128; + +// JS `String.length` — UTF-16 code units, not codepoints, graphemes, or bytes. +// A non-BMP emoji costs 2 and a flag costs 4, so "128 characters" and "128 of +// what a human would call characters" are different numbers. Every surface that +// advertises the cap has to say which one it means or people count graphemes +// and get a `too-long` they don't understand. +export const COUNTED_AS = "utf16-code-units"; + +// Channels that skip session-server/filter.mjs. `chat-clock` is the Danish +// laer-klokken room; the English wordlist mauls it. +const UNFILTERED = new Set(["chat-clock"]); + +export function profanityFiltered(channel) { + return !UNFILTERED.has(channel); +} + +export function tooLong(text) { + return String(text ?? "").length > MAX_CHARS; +} + +// Message syntax — the tokens `lib/chat-highlighting.mjs` finds and makes +// tappable. Kept in that file's scan order so the two stay comparable. +export const TOKENS = [ + { + token: "'…'", + name: "prompt", + example: "try 'starfield'", + does: + "Single-quoted text becomes a tappable jump to `prompt `. This is the ONLY way to link a piece — a bare piece name is plain text. Quoted KidLisp source is syntax-highlighted and runs as KidLisp instead. Contractions (I'll, you'll) are not matched.", + }, + { token: "@handle", name: "handle", example: "@jeffrey", does: "Opens that user's profile." }, + { token: "#code", name: "painting", example: "#Lv2", does: "Opens the painting with that code." }, + { token: "$code", name: "kidlisp", example: "$cow", does: "Opens the stored KidLisp piece." }, + { token: "*code", name: "clock", example: "*bell", does: "Opens that clock piece." }, + { token: "!code", name: "tape", example: "!x7q", does: "Opens that tape recording." }, + { token: "r8dio", name: "r8dio", example: "r8dio", does: "Bare word — starts the radio player. `@r8dio` stays a handle." }, + { token: "https://… | www.…", name: "url", example: "https://prompt.ac", does: "Opens in the browser. A URL that trips the sensitive-word list renders as [click to reveal link]." }, + { token: "youtube link", name: "youtube", example: "https://youtu.be/dQw4w9WgXcQ", does: "Watch/shorts/embed/youtu.be links get an inline player." }, + { token: "name@host.tld", name: "email", example: "me@aesthetic.computer", does: "Opens a mailto:." }, +]; + +// Bare-word commands. There are no slash commands. These are intercepted by the +// chat piece BEFORE the socket send, so they are UI, not protocol — a bot that +// pushes "radio" through `chat:message` just posts the word "radio". +export const COMMANDS = [ + { command: "radio", does: "Toggle the radio player.", local: true }, + { command: "radio off | radio stop | hush | mute radio", does: "Stop the radio.", local: true }, + { command: "r8dio | bj", does: "Play that station.", local: true }, + { command: "fight @handle", does: "Challenge someone to a fight (login required).", local: true }, + { command: "fight accept | fight decline", does: "Answer a pending challenge.", local: true }, +]; + +export function chatCapabilities(channel = "chat-system") { + return { + channel, + maxChars: MAX_CHARS, + countedAs: COUNTED_AS, + profanityFiltered: profanityFiltered(channel), + tokens: TOKENS, + commands: COMMANDS, + }; +} + +// A plain-text rendering of the above, for places that can only carry prose — +// an MCP tool description, a `help` reply. Generated so the numbers can't drift +// from the object. +export function capabilitiesBrief(caps = chatCapabilities()) { + const tokens = caps.tokens.map((t) => ` ${t.token} — ${t.does}`).join("\n"); + const commands = caps.commands.map((c) => ` ${c.command} — ${c.does}`).join("\n"); + return [ + `Limit: ${caps.maxChars} characters, counted as ${caps.countedAs} (JS String.length — a non-BMP emoji costs 2, a flag 4). Over that, the server answers "too-long" and drops the message.`, + `Profanity filter: ${caps.profanityFiltered ? "on" : "off"} for ${caps.channel}.`, + ``, + `Syntax (tokens become tappable links):`, + tokens, + ``, + `Bare-word commands — typed in the chat UI only, no slash commands. Sending these as a message just posts the words:`, + commands, + ].join("\n"); +} diff --git a/slab/bin/chat-mcp.mjs b/slab/bin/chat-mcp.mjs index 590e1c6b3a..17fb009259 100755 --- a/slab/bin/chat-mcp.mjs +++ b/slab/bin/chat-mcp.mjs @@ -21,6 +21,13 @@ // no SDK, only node builtins + the shared http-front. import { httpPort, serveHttp, serveStdio } from "../../toolchain/mcp/http-front.mjs"; import { UA, whoami } from "../../toolchain/mcp/ac-token.mjs"; +// The limit and the message syntax come from the server's own copy, so this +// tool's contract can't drift from what chat-manager.mjs actually enforces. +import { + MAX_CHARS, + capabilitiesBrief, + chatCapabilities, +} from "../../shared/chat-capabilities.mjs"; // `instance` is what /api/chat-messages calls a channel ("system" | "clock") — // NOT the Mongo collection name. Passing the collection, or any unknown param, @@ -31,7 +38,7 @@ const CHANNELS = { clock: { host: "chat-clock.aesthetic.computer", collection: "chat-clock" }, }; const READ_API = "https://aesthetic.computer/api/chat-messages"; -const MAX_TEXT = 128; // chat-manager.mjs rejects anything longer with "too-long" +const MAX_TEXT = MAX_CHARS; // chat-manager.mjs rejects anything longer with "too-long" function channel(name = "system") { const key = String(name).replace(/^chat-/, ""); @@ -172,13 +179,23 @@ const TOOLS = [ }, { name: "chat_send", - description: + description: [ "Post a message to an AC chat channel AS @jeffrey, using his signed-in session. PUBLIC AND PERMANENT — it appears immediately to everyone in the channel and this server has no delete. Confirm the exact wording with him before calling.", + "", + capabilitiesBrief(chatCapabilities("chat-system")), + ].join("\n"), inputSchema: { type: "object", properties: { - text: { type: "string", description: "The message to post, verbatim." }, - channel: { type: "string", description: '"system" (default) or "clock".' }, + text: { + type: "string", + description: `The message to post, verbatim. Max ${MAX_CHARS} characters — see this tool's description for the syntax that makes pieces, handles, and codes tappable.`, + }, + channel: { + type: "string", + description: + '"system" (default) or "clock". `clock` is the Danish laer-klokken room and skips the profanity filter.', + }, }, required: ["text"], }, diff --git a/system/netlify/functions/chat-capabilities.mjs b/system/netlify/functions/chat-capabilities.mjs new file mode 100644 index 0000000000..483127d3a1 --- /dev/null +++ b/system/netlify/functions/chat-capabilities.mjs @@ -0,0 +1,53 @@ +// chat-capabilities, 26.08.08 +// GET: What the AC chat server will accept — the length cap, how it's counted, +// whether the channel filters, the message syntax, and the bare-word +// commands. For bots and LLMs that want the contract before they post. +// +// Query params: +// channel — "system" | "clock" | "sotce" (also accepts the full +// `chat-system` / host form). Omit for every channel. +// +// The authority is the session server, which enforces these on the socket and +// hands the same object to every client in its `connected` packet; both read +// shared/chat-capabilities.mjs. This route exists because a bot that only knows +// `aesthetic.computer/api/...` shouldn't have to learn the chat hostnames — +// `https://chat-clock.aesthetic.computer/chat/capabilities` is the same answer +// straight from the server. + +import { respond } from "../../backend/http.mjs"; +import { chatCapabilities } from "../../../shared/chat-capabilities.mjs"; + +const CHANNELS = { + system: "chat-system.aesthetic.computer", + clock: "chat-clock.aesthetic.computer", + sotce: "chat.sotce.net", +}; + +const forChannel = (key) => ({ + ...chatCapabilities(`chat-${key}`), + host: CHANNELS[key], + socket: `wss://${CHANNELS[key]}`, + handshake: "The `connected` packet carries this same object as `capabilities`.", +}); + +export async function handler(event) { + if (event.httpMethod === "OPTIONS") return respond(204, null); + if (event.httpMethod !== "GET") return respond(405, { message: "Method Not Allowed" }); + + const asked = (event.queryStringParameters?.channel || "") + .trim() + .replace(/^chat[-.]/, "") + .replace(/\..*$/, ""); + + if (!asked) { + return respond(200, { channels: Object.keys(CHANNELS).map(forChannel) }); + } + + if (!CHANNELS[asked]) { + return respond(400, { + message: `Unknown channel "${asked}" — expected one of: ${Object.keys(CHANNELS).join(", ")}`, + }); + } + + return respond(200, forChannel(asked)); +} diff --git a/system/public/aesthetic.computer/disks/chat.mjs b/system/public/aesthetic.computer/disks/chat.mjs index 9789733377..f5342dd6e3 100644 --- a/system/public/aesthetic.computer/disks/chat.mjs +++ b/system/public/aesthetic.computer/disks/chat.mjs @@ -661,6 +661,19 @@ async function boot( // 🤖 Runs on every message... client.receiver = (id, type, content, extra) => { if (type === "connected") { + // The server states its own limit in the handshake — see + // shared/chat-capabilities.mjs. Older servers omit it, so the + // hardcoded default above stays as the fallback. + const advertised = content?.capabilities?.maxChars; + if (advertised && advertised !== chatMaxChars) { + console.log( + "💬 Chat limit from server:", + advertised, + `(${content.capabilities.countedAs})`, + ); + chatMaxChars = advertised; + send({ type: "keyboard:set-max-chars", content: chatMaxChars }); + } messagesNeedLayout = true; return; } -- 2.51.2