jevstrudel.git / website / src / jev / tabTools.mjs
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}