1// What an MCP may ask a tab besides playing code: the tools both MCPs share 2// (worker/src/shared-tools.json), answered from what the tab's panel shows. 3// The dev hub's tab (repl/useWebSocketMCP.jsx) and the hosted MCP's 4// (myTabs.mjs) both hand these commands here; tabCommands.mjs supplies the 5// page's real modules as `deps`, tests their own. 6// 7// list-sounds { category?, filter?, offset, limit } the sounds tab's sounds 8// ask-sound { description, category?, filter? } Jev picks one (soundPick.mjs) 9// get-settings the settings tab's, as allowed 10// set-settings { changes } (worker/src/mcp-settings.mjs) 11// get-console the console tab's lines 12// play-song { id } a song, as its card plays it 13// ask-song { mood, play } the mood picker (picker.mjs) 14// react { kind } the booth's 🔥/😴, now 15// 16// Nothing here evaluates code the MCP sent. A site song is the page's own, 17// looked up by id in this page's own build, and plays in the page as a 18// click on its card does; a listener's song is fetched from the site and 19// plays in the sandbox, as its card does (sandbox.mjs). Jev's calls go 20// through the relay from this page (ask.mjs), with its session, charged to 21// 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'; 27 28export const TAB_TOOLS = new Set(['list-sounds', 'ask-sound', 'get-settings', 'set-settings', 'get-console', 'play-song', 'ask-song', 'react']); 29// 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'; 35 36// The foreign code this module last played (a listener's song), so the 37// hosted MCP's get_logs keeps what its sandbox logs (myTabs.mjs). 38let played = null; 39export const playedForeign = () => played; 40 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} 55 56// 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}