jevstrudel.git / worker / src / tab-hub-core.ts

The rules of a signed-in user's tab hub (tab-hub.ts), as plain code so they run in vitest: what a tab may send, how big, how many tabs, and what the hosted MCP (hosted-mcp.ts) may ask a tab to do.

A tab is one open REPL page of one signed-in user, joined to that user's TabHub (TAB_HUB.idFromName(userId)) over a WebSocket. Everything a tab sends is untrusted in shape and size (it is the user's own page, but the page relays what a sandboxed song logged), so every reply is rebuilt here from typed, bounded fields before the MCP reads it.

10import { MAX_THEME_CHARS, settingsView } from './mcp-settings.mjs';

A tab's id: the REPL's session id (useWebSocketMCP.jsx), as the dev hub takes it.

13export const SESSION = /^[a-z0-9-]{1,32}$/;

Tabs one user may have joined at once; the oldest goes when another joins.

15export const MAX_TABS = 8;

The largest frame a tab may send (a reply with its logs); larger closes it.

17export const MAX_FRAME_BYTES = 256 * 1024;

How long a tab has to answer a command. A play waits for the sandbox to start and the song's first samples, so it gets longer.

20export const REPLY_MS = 15_000;
21export const PLAY_REPLY_MS = 30_000;

Asking Jev from the tab (ask_jev_sound's banks, ask_jev_song) is a few relay calls in a row, each with its retries.

24export const JEV_REPLY_MS = 60_000;
25export function replyMs(command: { type: string }): number {
26  if (command.type === 'ask-sound' || command.type === 'ask-song') return JEV_REPLY_MS;
27  if (command.type === 'play' || command.type === 'play-song') return PLAY_REPLY_MS;
28  return REPLY_MS;
29}

Lines of log a reply may carry, and characters in each.

31export const MAX_LOG_LINES = 500;
32export const MAX_LOG_CHARS = 500;

The largest code the hosted MCP plays: a listener song's (content.ts MAX_CODE_BYTES).

34export const MAX_PLAY_CODE_BYTES = 64 * 1024;

Sounds a list_sounds reply carries at most, and a Jev pick's alternatives.

37export const MAX_SOUNDS = 200;
38export const MAX_PICKS = 5;
39export const MAX_THEMES = 100;

The tools both MCPs share (shared-mcp.ts; website/src/jev/tabTools.mjs).

42export type SharedCommand =
43  | { type: 'list-sounds'; category?: string; filter?: string; offset: number; limit: number }
44  | { type: 'ask-sound'; description: string; category?: string; filter?: string }
45  | { type: 'get-settings' }
46  | { type: 'set-settings'; changes: Record<string, string | number | boolean> }
47  | { type: 'get-console' }
48  | { type: 'play-song'; id: string }
49  | { type: 'ask-song'; mood: string; play: boolean }
50  | { type: 'react'; kind: 'fire' | 'sleep' };
52export type TabCommand =
53  | { type: 'play'; code: string; app: string }
54  | { type: 'stop' }
55  | { type: 'get-code' }
56  | { type: 'get-logs' }
57  | { type: 'status' }
58  | SharedCommand;
59
60export type LogLine = { at: number; message: string; kind: string; count: number };
61export type TabStatus = { playing: boolean; cycle: number | null; code: 'none' | 'own' | 'ai' | 'listener' | 'link' };
62export type Sound = { name: string; category: string; type: string; variants: number; use: string };
63export type SoundPick = {
64  picks: (Sound & { p: number | null })[];
65  confidence: number | null;
66  candidates: number;
67  banks: number;
68  calls: number;
69  scope: 'all' | 'finalists';
70};
71export type SongPlayed = { id: string; title: string; where: 'page' | 'sandbox' | null; p: number | null; confidence: number | null };
72export type Reaction = { section: string; kind: 'fire' | 'sleep'; counted: boolean };
73export type TabReply = {
74  ok?: boolean;
75  error?: string;
76  code?: string;
77  logs?: LogLine[];
78  status?: TabStatus;
79  sounds?: { total: number; items: Sound[] };
80  pick?: SoundPick;
81  settings?: Record<string, string | number | boolean>;
82  themes?: string[];
83  song?: SongPlayed;
84  reaction?: Reaction;
85};
86
87const isPlain = (x: unknown): x is Record<string, unknown> => typeof x === 'object' && x !== null && !Array.isArray(x);
88const str = (x: unknown, max: number) => (typeof x === 'string' ? x.slice(0, max) : undefined);
89const num = (x: unknown) => (typeof x === 'number' && Number.isFinite(x) ? x : undefined);
90const CODE_KINDS = new Set<TabStatus['code']>(['none', 'own', 'ai', 'listener', 'link']);
91const prob = (x: unknown) => {
92  const n = num(x);
93  return n !== undefined && n >= 0 && n <= 1 ? n : null;
94};
95const count = (x: unknown, max = 1e6) => Math.max(0, Math.min(max, Math.floor(num(x) ?? 0)));
96
97function sound(s: Record<string, unknown>): Sound {
98  return {
99    name: str(s.name, 128) ?? '',
100    category: str(s.category, 32) ?? '',
101    type: str(s.type, 32) ?? '',
102    variants: count(s.variants),
103    use: str(s.use, 300) ?? '',
104  };
105}

A tab's message: a reply to command id, rebuilt; null when it is not one.

108export function parseReply(raw: string | ArrayBuffer): { id: string; reply: TabReply } | null {
109  if (typeof raw !== 'string' || raw.length > MAX_FRAME_BYTES) return null;
110  let m: unknown;
111  try {
112    m = JSON.parse(raw);
113  } catch {
114    return null;
115  }
116  if (!isPlain(m) || m.type !== 'reply' || typeof m.id !== 'string' || m.id.length > 64) return null;
117  return { id: m.id, reply: rebuildReply(m) };
118}

A reply's fields, typed and bounded, whatever the tab sent: the hosted hub's parseReply, and the dev hub's replies before a shared tool reads them (shared-mcp.ts).

123export function rebuildReply(m: Record<string, unknown>): TabReply {
124  const reply: TabReply = {};
125  if (m.ok === true) reply.ok = true;
126  const error = str(m.error, 2000);
127  if (error !== undefined) reply.error = error;
128  const code = str(m.code, MAX_FRAME_BYTES);
129  if (code !== undefined) reply.code = code;
130  if (Array.isArray(m.logs)) {
131    reply.logs = m.logs
132      .slice(-MAX_LOG_LINES)
133      .filter(isPlain)
134      .map((l) => ({
135        at: num(l.at) ?? 0,
136        message: str(l.message, MAX_LOG_CHARS) ?? '',
137        kind: str(l.kind, 16) ?? '',
138        count: Math.max(1, Math.min(1e6, Math.floor(num(l.count) ?? 1))),
139      }));
140  }
141  if (isPlain(m.status)) {
142    const s = m.status;
143    const kind = CODE_KINDS.has(s.code as TabStatus['code']) ? (s.code as TabStatus['code']) : 'none';
144    reply.status = { playing: s.playing === true, cycle: num(s.cycle) ?? null, code: kind };
145  }
146  if (isPlain(m.sounds) && Array.isArray(m.sounds.items)) {
147    reply.sounds = { total: count(m.sounds.total), items: m.sounds.items.slice(0, MAX_SOUNDS).filter(isPlain).map(sound) };
148  }
149  if (isPlain(m.pick) && Array.isArray(m.pick.picks)) {
150    const p = m.pick;
151    reply.pick = {
152      picks: (p.picks as unknown[]).slice(0, MAX_PICKS).filter(isPlain).map((x) => ({ ...sound(x), p: prob(x.p) })),
153      confidence: prob(p.confidence),
154      candidates: count(p.candidates),
155      banks: count(p.banks, 64),
156      calls: count(p.calls, 64),
157      scope: p.scope === 'finalists' ? 'finalists' : 'all',
158    };
159  }
160  if (isPlain(m.settings)) reply.settings = settingsView(m.settings) as Record<string, string | number | boolean>;
161  if (Array.isArray(m.themes)) {
162    reply.themes = m.themes.slice(0, MAX_THEMES).filter((t): t is string => typeof t === 'string' && t.length <= MAX_THEME_CHARS);
163  }
164  if (isPlain(m.song)) {
165    const s = m.song;
166    reply.song = {
167      id: str(s.id, 96) ?? '',
168      title: str(s.title, 120) ?? '',
169      where: s.where === 'page' || s.where === 'sandbox' ? s.where : null,
170      p: prob(s.p),
171      confidence: prob(s.confidence),
172    };
173  }
174  if (isPlain(m.reaction)) {
175    const r = m.reaction;
176    reply.reaction = { section: str(r.section, 64) ?? '', kind: r.kind === 'sleep' ? 'sleep' : 'fire', counted: r.counted === true };
177  }
178  return reply;
179}

Which tab a command goes to: the one named, or the only one open.

182export function pickTab(open: string[], sessionId: unknown): string {
183  if (sessionId !== undefined) {
184    if (typeof sessionId !== 'string' || !SESSION.test(sessionId)) throw new Error('session_id is 1-32 of a-z, 0-9 and -');
185    if (!open.includes(sessionId)) {
186      throw new Error(`you have no open tab ${sessionId}; open: ${open.join(', ') || 'none'}`);
187    }
188    return sessionId;
189  }
190  if (open.length === 1) return open[0];
191  if (!open.length) {
192    throw new Error('you have no jevstrudel tab open: open the site, signed in on this account, and click the page once');
193  }
194  throw new Error(`you have several tabs open (${open.join(', ')}); pass session_id`);
195}