jevstrudel.git / website / src / jev / tabTools.mjs

What an MCP may ask a tab besides playing code: the tools both MCPs share (worker/src/shared-tools.json), answered from what the tab's panel shows. The dev hub's tab (repl/useWebSocketMCP.jsx) and the hosted MCP's (myTabs.mjs) both hand these commands here; tabCommands.mjs supplies the page's real modules as deps, tests their own.

list-sounds { category?, filter?, offset, limit } the sounds tab's sounds ask-sound { description, category?, filter? } Jev picks one (soundPick.mjs) get-settings the settings tab's, as allowed set-settings { changes } (worker/src/mcp-settings.mjs) get-console the console tab's lines play-song { id } a song, as its card plays it ask-song { mood, play } the mood picker (picker.mjs) react { kind } the booth's 🔥/😴, now

Nothing here evaluates code the MCP sent. A site song is the page's own, looked up by id in this page's own build, and plays in the page as a click on its card does; a listener's song is fetched from the site and plays in the sandbox, as its card does (sandbox.mjs). Jev's calls go through the relay from this page (ask.mjs), with its session, charged to the signed-in listener's budget as the mood picker's are.

22import { whyNotChanges, settingsView } from '../../../worker/src/mcp-settings.mjs';
23import { playing } from './boothStore.mjs';
24import { htmlText } from './reference.mjs';
25import { CATEGORIES, soundEntry, soundList } from './sounds.mjs';
26import { DESCRIPTION_MAX, pickSound } from './soundPick.mjs';
28export const TAB_TOOLS = new Set(['list-sounds', 'ask-sound', 'get-settings', 'set-settings', 'get-console', 'play-song', 'ask-song', 'react']);

as the hub bounds them (worker/src/tab-hub-core.ts)

30export const MAX_SOUNDS = 200;
31const MOOD_MAX = 280;
32const SITE_SONG = /^[a-z0-9-]{1,64}\/[a-z0-9-]{1,64}$/;
33const LISTENER_SONG = /^listener:([A-Za-z0-9_-]{22})$/;
34const CLICK = 'this tab has not been clicked yet, so its audio is off; click the page once';

The foreign code this module last played (a listener's song), so the hosted MCP's get_logs keeps what its sandbox logs (myTabs.mjs).

38let played = null;
39export const playedForeign = () => played;
41const category = (c) => {
42  if (c === undefined || c === null || c === '' || c === 'all') return undefined;
43  if (!CATEGORIES.includes(c)) throw new Error(`category is one of ${CATEGORIES.join(', ')}`);
44  return c;
45};
46const filterOf = (f) => (typeof f === 'string' ? f.trim().slice(0, 64) : '');
47
48async function playSiteSong(editor, song, deps) {
49  if (!deps.audioRunning()) return { error: CLICK };
50  await deps.playSong(editor, song);
51  const err = editor.repl?.state?.evalError;
52  if (err) return { error: err.message ?? String(err) };
53  return { song: { id: song.id, title: song.title, where: 'page' } };
54}

One command, answered; undefined when it is not one of these.

57export async function runTabTool(editor, command, deps) {
58  if (!TAB_TOOLS.has(command?.type)) return undefined;
59  if (!editor) return { error: 'the editor is not ready' };
60  switch (command.type) {
61    case 'list-sounds': {
62      const all = soundList(deps.sounds(), { category: category(command.category), filter: filterOf(command.filter) });
63      const offset = Math.max(0, Math.floor(Number(command.offset) || 0));
64      const limit = Math.min(MAX_SOUNDS, Math.max(1, Math.floor(Number(command.limit) || 100)));
65      const items = all.slice(offset, offset + limit).map(([name, sound]) => soundEntry(name, sound?.data));
66      return { sounds: { total: all.length, items } };
67    }
68    case 'ask-sound': {
69      const description = typeof command.description === 'string' ? command.description.trim().slice(0, DESCRIPTION_MAX) : '';
70      if (!description) return { error: 'description is what sound you want' };
71      const sounds = soundList(deps.sounds(), { category: category(command.category), filter: filterOf(command.filter) }).map(([name, sound]) =>
72        soundEntry(name, sound?.data),
73      );
74      return { pick: await pickSound(sounds, description, { ask: deps.askJev }) };
75    }
76    case 'get-settings':
77      return { settings: settingsView(deps.settings.get()), themes: deps.themes() };
78    case 'set-settings': {
79      const themes = deps.themes();
80      const why = whyNotChanges(command.changes, themes);
81      if (why) return { error: why };
82      for (const [key, value] of Object.entries(command.changes)) deps.settings.setKey(key, value);
83      return { settings: settingsView(deps.settings.get()), themes };
84    }
85    case 'get-console':
86      return {
87        logs: deps.consoleLog().map((l) => ({ at: 0, message: htmlText(l.message), kind: String(l.type ?? ''), count: l.count ?? 1 })),
88      };
89    case 'play-song': {
90      const id = command.id;
91      const listener = typeof id === 'string' ? LISTENER_SONG.exec(id) : null;
92      if (listener) {
93        const song = await deps.loadListener(listener[1]);
94        await deps.showListener(editor, song);
95        played = deps.foreign();
96        const result = await deps.playForeign(song.foreignCode);
97        if (result?.error) return { error: result.error };
98        return { song: { id: song.id, title: song.title, where: 'sandbox' } };
99      }
100      if (typeof id !== 'string' || !SITE_SONG.test(id)) return { error: 'id is "<theme>/<song>" or "listener:<id>", from list_songs' };
101      const song = deps.readSongs().find((s) => s.id === id && s.code);
102      if (!song) return { error: `this tab has no song ${id}` };
103      return playSiteSong(editor, song, deps);
104    }
105    case 'ask-song': {
106      const mood = typeof command.mood === 'string' ? command.mood.trim().slice(0, MOOD_MAX) : '';
107      if (!mood) return { error: 'mood is how the listener feels' };
108      if (command.play && !deps.audioRunning()) return { error: CLICK };
109      const { song, p, confidence } = await deps.pickByMood(deps.readSongs(), mood);
110      const picked = { id: song.id, title: song.title, p: p ?? null, confidence: confidence ?? null };
111      if (!command.play) return { song: picked };
112      const result = await playSiteSong(editor, song, deps);
113      return result.error ? result : { song: { ...picked, where: 'page' } };
114    }
115    case 'react': {
116      if (command.kind !== 'fire' && command.kind !== 'sleep') return { error: 'kind is fire or sleep' };
117      const booth = deps.booth();
118      const now = playing(booth, deps.cycle(editor));
119      if (!now) return { error: 'no section of a song with Jev is playing in this tab' };
120      const { section, counted } = deps.press(booth, now.segment, command.kind, editor);
121      return { reaction: { section: section ?? String(now.segment + 1), kind: command.kind, counted } };
122    }
123  }
124}