Files
max bittkerandClaude Fable 5 98b28b1ca3 feat(chat): HTTP chat endpoints on gateway; chat CLI bounded budgets, no more silent hangs
The chat CLI opened a fresh observer WebSocket per invocation. Over a TLS
tunnel (split-topology benchmark runs) that handshake is flaky, and a send
against a dead game-client relay burned the SDK's 60s actionTimeout per
80-char chunk - multi-chunk sends stalled 120s+ with zero output.

- gateway: GET /chat/:bot serves recent chat from a per-bot accumulated
  ChatHistory (fed from state snapshots); POST /say/:bot relays through the
  bot's game client with a hard per-chunk deadline (default 10s) and aborts
  remaining chunks on first failure. Same authenticateSDK gate as sdk_connect.
- sdk/chat.ts: HTTP-first with per-attempt timeouts + one retry on network
  failure, a 90s force-exit backstop on every non-watch path, and clear
  non-zero exits. Falls back to the observer WebSocket (now with bounded
  connectTimeout/actionTimeout) for gateways without the endpoints; --watch
  keeps the long-lived observer connection.
- chunkMessage moved to sdk/chunking.ts so the gateway can share it without
  importing the full SDK; re-exported from sdk/index.
- tests: sdk/test/gateway-http-chat.test.ts runs the real gateway subprocess
  with a scripted bot client, covering read/send/chunking/409/timeout paths
  and the CLI end-to-end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 22:29:53 -04:00

59 lines
2.2 KiB
TypeScript

/**
* Chat message chunking, shared by the SDK's say() and the gateway's HTTP say
* endpoint. Lives in its own module so the gateway can import it without
* pulling in the full SDK (pathfinding, collision data, puppeteer glue).
*/
/**
* Nibble-cost mirror of the client's WordPack encoding: the 13 most common
* chars pack to 1 nibble, everything else (digits, punctuation, rarer letters)
* to 2. Chat frames carry a 1-byte length, so packed text is limited to ~240
* bytes (480 nibbles) regardless of the char cap - digit-heavy text hits it
* well before 400 chars.
*/
const WORDPACK_COMMON_CHARS = ' etaoihnsrdlu';
const MAX_PACKED_NIBBLES = 240 * 2;
function packedNibbles(text: string): number {
let nibbles = 0;
for (const ch of text.toLowerCase()) {
nibbles += WORDPACK_COMMON_CHARS.includes(ch) ? 1 : 2;
}
return nibbles;
}
/**
* Split text into chunks on word boundaries such that each chunk fits both the
* char cap and the wire's packed-byte budget (so the client never silently
* truncates a chunk). Words too big for one chunk are hard-split so nothing is
* dropped. Exported for tests.
*/
export function chunkMessage(text: string, maxLen: number): string[] {
const fits = (s: string) => s.length <= maxLen && packedNibbles(s) <= MAX_PACKED_NIBBLES;
const chunks: string[] = [];
let current = '';
for (const word of text.trim().split(/\s+/)) {
// A single oversized word: flush, then hard-split it greedily.
if (!fits(word)) {
if (current) { chunks.push(current); current = ''; }
let rest = word;
while (rest.length > 0) {
let take = Math.min(rest.length, maxLen);
while (take > 1 && !fits(rest.slice(0, take))) take--;
chunks.push(rest.slice(0, take));
rest = rest.slice(take);
}
continue;
}
const candidate = current ? `${current} ${word}` : word;
if (fits(candidate)) {
current = candidate;
} else {
if (current) chunks.push(current);
current = word;
}
}
if (current) chunks.push(current);
return chunks;
}