moontide-mcp 2.0.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -6
- package/dist/ask.js +76 -15
- package/dist/mcp.js +198 -26
- package/dist/protocol.js +130 -2
- package/dist/reference.js +22 -0
- package/dist/request-status.js +221 -0
- package/dist/session.js +256 -7
- package/package.json +1 -1
- package/page/ask-view.js +64 -57
- package/page/ask.js +363 -188
- package/page/bar-model.js +43 -2
- package/page/change-timing.js +60 -4
- package/page/harmony.js +13 -0
- package/page/index.html +7 -4
- package/page/jam-analysis.js +68 -23
- package/page/jam-model.js +196 -31
- package/page/jam-resolve.js +271 -0
- package/page/jam-view.js +182 -10
- package/page/lights.js +300 -0
- package/page/main.js +2171 -188
- package/page/midi-clock.js +23 -4
- package/page/midi-out.js +17 -2
- package/page/mixer-groups.js +114 -0
- package/page/mixer-model.js +172 -29
- package/page/mixer-rewrite.js +30 -1
- package/page/mixer-view.js +289 -14
- package/page/note-edit.js +267 -0
- package/page/note-locate.js +346 -0
- package/page/note-panel.js +402 -0
- package/page/note-tag.js +141 -0
- package/page/page-rewrite.js +184 -0
- package/page/query-check.js +21 -2
- package/page/request-status-view.js +419 -0
- package/page/request-status.js +686 -0
- package/page/song-edit.js +230 -0
- package/page/song-fit.js +250 -0
- package/page/song-grid-model.js +506 -0
- package/page/song-grid-view.js +710 -0
- package/page/song-prefs.js +100 -0
- package/page/structure-check.js +216 -0
- package/page/structure-view.js +38 -0
- package/page/structure.js +761 -0
- package/page/style.css +1218 -65
- package/page/takeover.js +20 -5
- package/page/tempo-glide.js +330 -0
- package/page/tempo-guard.js +62 -10
- package/page/timeline-edit-view.js +1956 -0
- package/page/timeline-gesture.js +279 -0
- package/page/timeline.js +197 -3
- package/page/top-bar-view.js +18 -13
- package/page/tracks.js +37 -8
- package/page/ui-state.js +10 -5
- package/page/views.js +39 -9
- package/reference/functions.md +1 -0
- package/reference/midi.md +2 -0
- package/reference/structure.md +131 -0
- package/reference/transitions.md +13 -1
package/dist/mcp.js
CHANGED
|
@@ -7,10 +7,10 @@ import { z } from 'zod';
|
|
|
7
7
|
import { EDIT_NOTHING_PLAYING_MESSAGE, NOT_STARTED_MESSAGE } from './session.js';
|
|
8
8
|
import { PAGE_WAIT_MS, START_INSTRUCTION, START_PROMPT, START_PROMPT_DESCRIPTION, START_PROMPT_TITLE, START_TOOL, START_TOOL_DESCRIPTION, notStartedForRequestsText, notStartedForRequestsToolText, pageLine, startMoontide, } from './start.js';
|
|
9
9
|
import { MAX_EDITS, changedExcerpt } from './edit-code.js';
|
|
10
|
-
import { MAX_PHRASE_CYCLES, MIN_PHRASE_CYCLES } from './protocol.js';
|
|
10
|
+
import { MAX_ANSWER_TEXT_LENGTH, MAX_ASK_ID_LENGTH, MAX_GLIDE_BARS, MAX_PHRASE_CYCLES, MIN_GLIDE_BARS, MIN_PHRASE_CYCLES, } from './protocol.js';
|
|
11
11
|
import { MAX_LABEL_LENGTH, MAX_FAVORITE_NAME_LENGTH } from './history.js';
|
|
12
12
|
import { PAGE_REQUESTS_ALREADY_OFF, PAGE_REQUESTS_ALREADY_ON, PAGE_REQUESTS_OFF_DESCRIPTION, PAGE_REQUESTS_OFF_PROMPT, PAGE_REQUESTS_OFF_TEXT, PAGE_REQUESTS_OFF_TITLE, PAGE_REQUESTS_ON_DESCRIPTION, PAGE_REQUESTS_ON_PROMPT, PAGE_REQUESTS_ON_TITLE, PAGE_REQUESTS_INSTRUCTION, PAGE_REQUESTS_STATE_DESCRIPTION, PAGE_REQUESTS_TOOL, PAGE_REQUESTS_TOOL_DESCRIPTION, REQUEST_LINE_FRAMING, listenerScriptPath, noPageForRequestsText, noPageForRequestsToolText, pageRequestsOnText, } from './ask.js';
|
|
13
|
-
import { REFERENCE_TOPICS, REFERENCE_TOPIC_IDS, REFERENCE_URI_PREFIX, readReferenceTopic, referenceIndexText, referenceToolDescription, } from './reference.js';
|
|
13
|
+
import { REFERENCE_TOPICS, REFERENCE_TOPIC_IDS, REFERENCE_URI_PREFIX, readReferenceTopic, referenceIndexText, referenceToolDescription, structureTemplateText, } from './reference.js';
|
|
14
14
|
export const NOTHING_PLAYING_MESSAGE = 'Nothing is playing yet.';
|
|
15
15
|
export const PLAYING_WITH_ERRORS_MESSAGE = 'Playing, but with runtime errors (see below).';
|
|
16
16
|
export const STOPPED_MESSAGE = 'ok, stopped. The code stays in the page; call set_code to start playing again.';
|
|
@@ -22,6 +22,12 @@ export const HAND_EDIT_REPLACED_NOTE = 'Note: the code you replaced had been edi
|
|
|
22
22
|
export const HAND_EDIT_KEPT_NOTE = 'Note: the code you edited had been edited by hand in the page since your last change; your edit was applied on top of it.';
|
|
23
23
|
// The page's text is authoritative, as for set_code; when it differs from the composed program the reply says so.
|
|
24
24
|
export const EDIT_OTHER_TEXT_NOTE = 'Note: the page confirmed a text that differs from the edited code, so the lines above may not match what plays; get_code returns what plays.';
|
|
25
|
+
// The line above the song structure template get_code shows when nothing plays yet.
|
|
26
|
+
export const STRUCTURE_TEMPLATE_INTRO = 'A song structure to start a new piece from (the structure topic of get_strudel_reference explains it):';
|
|
27
|
+
/** The intro line and the template in a js fence, read from the structure topic at call time (a missing block throws). */
|
|
28
|
+
function structureTemplateBlock() {
|
|
29
|
+
return `${STRUCTURE_TEMPLATE_INTRO}\n\`\`\`js\n${structureTemplateText()}\n\`\`\``;
|
|
30
|
+
}
|
|
25
31
|
export const GETTIME_ANCHOR_NOTE = 'Note: this code reads getTime(); an anchor taken from it restarts every time a later change re-sends this code, ' +
|
|
26
32
|
'while a transition anchored with .timeline(id) keeps running across changes (see the transitions topic).';
|
|
27
33
|
// A warning only, as a text match: the code is never parsed, evaluated or changed on the server;
|
|
@@ -67,6 +73,44 @@ export function whenFlagLines(code) {
|
|
|
67
73
|
}
|
|
68
74
|
return [];
|
|
69
75
|
}
|
|
76
|
+
// The reply line a set_code of a new piece carries when the piece declares no song structure. One plain line, fixed
|
|
77
|
+
// text: it holds no code and no page text. The music plays as written; nothing is refused, blocked or rewritten.
|
|
78
|
+
export const NO_STRUCTURE_NOTE = 'Note: this new piece declares no song structure; every new piece declares one once at the top (see the structure topic of get_strudel_reference). It plays as written.';
|
|
79
|
+
// A text match only, like GETTIME_CALL_RE: the code is never parsed, evaluated or changed on the server, and a match
|
|
80
|
+
// inside a comment or a string counts too, by design. A song declared with let or var gets its own line from the page,
|
|
81
|
+
// which reads only const, so the server says nothing more for it and the two lines never disagree. Anchored at a line
|
|
82
|
+
// start with [ \t]* (never \s*, so a run of blank lines cannot make the scan quadratic) and ended on a word boundary, so
|
|
83
|
+
// `const songs`, `const song2` and a commented-out line are not a song. The m flag only: no global flag, so test() keeps
|
|
84
|
+
// no lastIndex.
|
|
85
|
+
const SONG_DECLARATION_RE = /^[ \t]*(?:const|let|var)[ \t]+song\b/m;
|
|
86
|
+
/** The shortest line, in characters after trimming, that counts when telling a new piece from a changed one. */
|
|
87
|
+
const NEW_PIECE_MIN_LINE = 8;
|
|
88
|
+
/**
|
|
89
|
+
* True when `code` is a new piece compared with `previous`: the previous code is empty, holds no line of
|
|
90
|
+
* NEW_PIECE_MIN_LINE or more characters, or `code` keeps fewer than half of those lines (kept * 2 < total, so keeping
|
|
91
|
+
* exactly half is not new). Lines are compared trimmed, through a Set, so the cost is linear in the two texts.
|
|
92
|
+
*/
|
|
93
|
+
function isNewPiece(previous, code) {
|
|
94
|
+
const longLines = previous
|
|
95
|
+
.split('\n')
|
|
96
|
+
.map((line) => line.trim())
|
|
97
|
+
.filter((line) => line.length >= NEW_PIECE_MIN_LINE);
|
|
98
|
+
if (longLines.length === 0)
|
|
99
|
+
return true;
|
|
100
|
+
const kept = new Set(code.split('\n').map((line) => line.trim()));
|
|
101
|
+
let keptCount = 0;
|
|
102
|
+
for (const line of longLines) {
|
|
103
|
+
if (kept.has(line))
|
|
104
|
+
keptCount += 1;
|
|
105
|
+
}
|
|
106
|
+
return keptCount * 2 < longLines.length;
|
|
107
|
+
}
|
|
108
|
+
/** The note when `code` replaces `previousCode` as a new piece and declares no song structure, empty otherwise. */
|
|
109
|
+
export function newPieceWithoutStructureLines(previousCode, code) {
|
|
110
|
+
if (SONG_DECLARATION_RE.test(code))
|
|
111
|
+
return [];
|
|
112
|
+
return isNewPiece(previousCode, code) ? [NO_STRUCTURE_NOTE] : [];
|
|
113
|
+
}
|
|
70
114
|
export function waitingForClickMessage(pageUrl) {
|
|
71
115
|
return `Waiting for the user's click: the code is loaded and will start playing after one click on the page at ${pageUrl}.`;
|
|
72
116
|
}
|
|
@@ -112,6 +156,24 @@ export function tempoLine(bpm, pendingCycle) {
|
|
|
112
156
|
const shown = Number.isInteger(bpm) ? String(bpm) : bpm.toFixed(1);
|
|
113
157
|
return pendingCycle === undefined ? `Tempo: ${shown} BPM.` : `Tempo from cycle ${pendingCycle}: ${shown} BPM.`;
|
|
114
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* The one-line tempo ramp of a reply, said in place of the tempo line. Three states: a ramp that started with this change,
|
|
161
|
+
* a ramp that starts at the line of a waiting change (`pendingCycle` is the line), and a ramp that goes on because this
|
|
162
|
+
* change restated its target. The numbers come from the page, bounded by the protocol schema; only numbers are
|
|
163
|
+
* formatted here, so no page text enters the reply.
|
|
164
|
+
*/
|
|
165
|
+
export function glideLine(glide, pendingCycle) {
|
|
166
|
+
const shown = (bpm) => (Number.isInteger(bpm) ? String(bpm) : bpm.toFixed(1));
|
|
167
|
+
if (glide.state === 'running') {
|
|
168
|
+
return `Tempo ramp goes on: to ${shown(glide.toBpm)} BPM, now ${shown(glide.nowBpm)} BPM.`;
|
|
169
|
+
}
|
|
170
|
+
const length = glide.bars === 1 ? 'over 1 bar' : `over ${glide.bars} bars`;
|
|
171
|
+
if (glide.state === 'at-line') {
|
|
172
|
+
const line = pendingCycle === undefined ? 'at its line' : `from cycle ${pendingCycle}`;
|
|
173
|
+
return `Tempo ramp ${line}: from the tempo playing then to ${shown(glide.toBpm)} BPM ${length}.`;
|
|
174
|
+
}
|
|
175
|
+
return `Tempo ramp: from ${shown(glide.fromBpm)} to ${shown(glide.toBpm)} BPM ${length}.`;
|
|
176
|
+
}
|
|
115
177
|
/**
|
|
116
178
|
* The first note of get_code while a change waits to take over: the code the reply shows is the code
|
|
117
179
|
* that plays until the line.
|
|
@@ -147,16 +209,47 @@ function changeStatus(outcome, at, hadRuntimeErrors, pageUrl) {
|
|
|
147
209
|
* the at-once note when a requested line was skipped because nothing played.
|
|
148
210
|
*/
|
|
149
211
|
function changeNoteLines(outcome, at) {
|
|
150
|
-
|
|
212
|
+
// A change that started a ramp says the ramp in place of the tempo line: the tempo that plays has not changed yet.
|
|
213
|
+
const tempo = outcome.glide !== undefined
|
|
214
|
+
? [glideLine(outcome.glide, outcome.pending?.cycle)]
|
|
215
|
+
: outcome.tempoChanged && outcome.bpm !== null
|
|
216
|
+
? [tempoLine(outcome.bpm, outcome.pending?.cycle)]
|
|
217
|
+
: [];
|
|
151
218
|
const atOnce = (at === 'bar' || at === 'phrase') && outcome.pending === undefined && outcome.audio !== 'stopped'
|
|
152
219
|
? [AT_ONCE_NOTE]
|
|
153
220
|
: [];
|
|
154
221
|
return [...tempo, ...atOnce];
|
|
155
222
|
}
|
|
156
223
|
const LABEL_FIELD_DESCRIPTION = 'A few words saying what this change is, for example "added a bass"; shown by list_history';
|
|
224
|
+
// One description for the three tools that change the music, so Claude meets the same fact wherever it reads it.
|
|
225
|
+
export const ANSWERING_FIELD_DESCRIPTION = 'The id of the page request this change answers, from its request line; the page shows that request as applied only when the change names it here';
|
|
226
|
+
// Said in a reply when `answering` names no open page request. No user text, no id: the note is the same every time.
|
|
227
|
+
// The music change itself is never refused or held back for it.
|
|
228
|
+
export const UNKNOWN_ANSWERING_NOTE = 'Note: answering names no open page request (it is unknown or already closed), so it changed no request status; the music change is not affected by it.';
|
|
229
|
+
/** The note when `answering` was given and names no open request, empty otherwise. */
|
|
230
|
+
export function unknownAnsweringLines(answering, open) {
|
|
231
|
+
return answering !== undefined && !open ? [UNKNOWN_ANSWERING_NOTE] : [];
|
|
232
|
+
}
|
|
233
|
+
export const ANSWER_REQUEST_TOOL = 'answer_request';
|
|
234
|
+
export const ANSWER_REQUEST_DESCRIPTION = 'Closes a request typed in the Moontide page without changing the music. Use outcome answered when the request was answered in words, declined when it will not or cannot be done. ' +
|
|
235
|
+
`Give one sentence of at most ${MAX_ANSWER_TEXT_LENGTH} characters; the page shows it under the request. ` +
|
|
236
|
+
'A change that answers a request names it with answering on set_code, edit_code or restore_version instead. ' +
|
|
237
|
+
'An id that is not an open request is refused and nothing changes.';
|
|
238
|
+
export const ANSWER_REQUEST_ID_DESCRIPTION = 'The id of the page request to close, from its request line';
|
|
239
|
+
export const ANSWER_REQUEST_OUTCOME_DESCRIPTION = 'answered when the request was answered in words, declined when it will not or cannot be done';
|
|
240
|
+
export const ANSWER_REQUEST_TEXT_DESCRIPTION = `One sentence for the user, at most ${MAX_ANSWER_TEXT_LENGTH} characters; the page shows it under the request`;
|
|
241
|
+
/** The reply when a request was closed, one fixed line per outcome. */
|
|
242
|
+
export function answerRequestReply(outcome) {
|
|
243
|
+
return outcome === 'answered' ? 'The page request was closed as answered.' : 'The page request was closed as declined.';
|
|
244
|
+
}
|
|
157
245
|
const AT_FIELD_DESCRIPTION = "When the change takes over: 'now' (the default) within a fraction of a second, 'bar' on the next bar line, 'phrase' on the next phrase line (4 cycles, counted from Play). " +
|
|
158
246
|
'The code is checked at once; the reply says the cycle and about how many seconds away it is.';
|
|
159
247
|
const PHRASE_CYCLES_FIELD_DESCRIPTION = "How many cycles one phrase lasts, for at: 'phrase' (2 to 64, default 4)";
|
|
248
|
+
// Facts only: the bounds are the only digits. It is one description for both tools that change the music's code.
|
|
249
|
+
export const GLIDE_BARS_FIELD_DESCRIPTION = 'Whole bars, 1 to 64, to move the tempo smoothly from the tempo that plays to the one this code sets with setcps or setcpm; with at, it starts at the line. Refused when nothing plays, no tempo is set or it already plays. Stop, or code with another tempo or none, cancels it';
|
|
250
|
+
// Said when set_code or edit_code asks for a ramp while nothing plays. The page holds the same text for the case the server
|
|
251
|
+
// cannot know about (a page that has not played yet); a test checks both. Nothing was started, opened or sent.
|
|
252
|
+
export const GLIDE_NOTHING_PLAYS_MESSAGE = 'Nothing plays, so there is no tempo to ramp from. Nothing was changed. Send the change again without glideBars.';
|
|
160
253
|
/**
|
|
161
254
|
* The tool description of edit_code. The when-to-use rule comes first; Claude Code keeps at most 2048
|
|
162
255
|
* characters of a tool description.
|
|
@@ -243,15 +336,12 @@ export const SERVER_INSTRUCTIONS = [
|
|
|
243
336
|
// underscore and never lets it solo. The solo-label claim of scripts/check-reference.mjs reads
|
|
244
337
|
// this string from the source, so it stays one single-quoted line.
|
|
245
338
|
'A label longer than one character that starts with a capital S plays alone, unless it also ends with an underscore, which silences it instead: while a label that plays alone exists, every other label is silent.',
|
|
339
|
+
// The standing rule for song structure, kept to one short sentence: the full form is the structure topic and the
|
|
340
|
+
// template get_code shows when nothing plays yet, so only the duty and the place to look are said here. No example
|
|
341
|
+
// and no section names: any example would suggest a style.
|
|
342
|
+
'Every new piece declares its song structure once at the top; the structure topic of get_strudel_reference has the form.',
|
|
246
343
|
// Read before editing.
|
|
247
344
|
"Before changing the music, call get_code: it includes the user's hand edits.",
|
|
248
|
-
// Act on a vague request. How to explain a change and when to ask before a rewrite
|
|
249
|
-
// are in the set_code description. No example line: any example would suggest a style.
|
|
250
|
-
'When a request is vague, act on your own reading of it instead of asking first.',
|
|
251
|
-
// Guidance, not structure. The reply that reports a failed sound says which sound
|
|
252
|
-
// and that it plays nothing. Moontide never blocks, rewrites or refuses code because a sound failed;
|
|
253
|
-
// naming a replacement would steer the palette.
|
|
254
|
-
'If you replace a sound that failed to load, tell the user which one and what replaced it, never silently.',
|
|
255
345
|
// A session frame applies to every change, surprises included, until lifted, and a
|
|
256
346
|
// gradual frame means each change, surprises and small edits included, arrives through a transition,
|
|
257
347
|
// not only as a smaller step. No variety rule; nothing says how a surprise should sound.
|
|
@@ -305,19 +395,22 @@ export function createMcpServer(options) {
|
|
|
305
395
|
const server = new McpServer({ name: 'moontide', version: readPackageVersion() }, { instructions: SERVER_INSTRUCTIONS });
|
|
306
396
|
server.registerTool('get_code', {
|
|
307
397
|
description: "Reads the Strudel code currently playing in the Moontide page, i.e. the last code the page confirmed, " +
|
|
308
|
-
"including edits the user evaluated by hand; returns 'Nothing is playing yet.' before anything played. " +
|
|
398
|
+
"including edits the user evaluated by hand; returns 'Nothing is playing yet.' before anything played, with a template of a song structure to start from. " +
|
|
309
399
|
'Before Moontide is started it says so and starts nothing; after the start its reply also names the page address, the port and the tab title. ' +
|
|
310
400
|
'Call it before every change.',
|
|
311
401
|
annotations: { readOnlyHint: true },
|
|
312
402
|
}, async () => {
|
|
403
|
+
// Marks the page requests Claude already holds as started; first, so a request this reply hands over is not.
|
|
404
|
+
session.noteToolCall();
|
|
313
405
|
// Before the start there is no page and no code: say how to start and start nothing. No drain, so queued
|
|
314
406
|
// notices wait for the next reply.
|
|
315
407
|
const server = starter.current();
|
|
316
408
|
if (server === null) {
|
|
317
|
-
|
|
409
|
+
// The first get_code of a fresh session comes before the start, and nothing plays yet either way.
|
|
410
|
+
throw new Error(`${NOT_STARTED_MESSAGE}\n\n${structureTemplateBlock()}`);
|
|
318
411
|
}
|
|
319
412
|
const code = session.getCode();
|
|
320
|
-
const text = code === '' ? NOTHING_PLAYING_MESSAGE : code;
|
|
413
|
+
const text = code === '' ? `${NOTHING_PLAYING_MESSAGE}\n\n${structureTemplateBlock()}` : code;
|
|
321
414
|
const { lines } = session.drainNotices();
|
|
322
415
|
// STOPPED_NOTE first when both apply, then HAND_EDIT_NOTE, then the version line, then the page line, then drained lines.
|
|
323
416
|
const notices = [];
|
|
@@ -342,6 +435,7 @@ export function createMcpServer(options) {
|
|
|
342
435
|
topic: z.enum(REFERENCE_TOPIC_IDS).optional().describe('One topic to return; omit it to get the index of topics'),
|
|
343
436
|
},
|
|
344
437
|
}, async ({ topic }) => {
|
|
438
|
+
session.noteToolCall();
|
|
345
439
|
try {
|
|
346
440
|
const text = topic ? readReferenceTopic(topic) : referenceIndexText();
|
|
347
441
|
return { content: [{ type: 'text', text }] };
|
|
@@ -365,7 +459,9 @@ export function createMcpServer(options) {
|
|
|
365
459
|
'Double-quoted and backtick strings are parsed as mini-notation wherever they appear, helpers included, while single-quoted strings stay plain JavaScript (see the tidal-differences topic). ' +
|
|
366
460
|
`When Moontide is not started yet, set_code starts it first: its page opens in a new browser tab and the call waits up to ${PAGE_WAIT_MS / 1000} seconds for that page to connect. ` +
|
|
367
461
|
'Before changing existing music, call get_code and build on what it returns, including hand edits. Keep what the user said matters; if a small request ' +
|
|
368
|
-
'needs a full rewrite and the user said what matters, ask first, otherwise rewrite and say so.
|
|
462
|
+
'needs a full rewrite and the user said what matters, ask first, otherwise rewrite and say so. ' +
|
|
463
|
+
// Moved here from the server instructions (CLIENT_INSTRUCTIONS_LIMIT): Claude meets it where it writes the code.
|
|
464
|
+
'When a request is vague, act on your own reading of it instead of asking first. Returns ' +
|
|
369
465
|
'the code as confirmed by the page. Pass a short label saying what changed; every version that plays is ' +
|
|
370
466
|
'kept in the history. Then tell the user in one line what you chose: the musical terms, plus a short ' +
|
|
371
467
|
'image in plain words.',
|
|
@@ -380,17 +476,34 @@ export function createMcpServer(options) {
|
|
|
380
476
|
.max(MAX_PHRASE_CYCLES)
|
|
381
477
|
.optional()
|
|
382
478
|
.describe(PHRASE_CYCLES_FIELD_DESCRIPTION),
|
|
479
|
+
glideBars: z.number().int().min(MIN_GLIDE_BARS).max(MAX_GLIDE_BARS).optional().describe(GLIDE_BARS_FIELD_DESCRIPTION),
|
|
480
|
+
answering: z.string().min(1).max(MAX_ASK_ID_LENGTH).optional().describe(ANSWERING_FIELD_DESCRIPTION),
|
|
383
481
|
},
|
|
384
|
-
}, async ({ code, label, at, phraseCycles }) => {
|
|
482
|
+
}, async ({ code, label, at, phraseCycles, glideBars, answering }) => {
|
|
483
|
+
session.noteToolCall();
|
|
484
|
+
// An id that is not open never reaches the change: it is told once and the music change goes ahead.
|
|
485
|
+
const answeringOpen = answering === undefined ? true : session.requestIsOpen(answering);
|
|
385
486
|
try {
|
|
386
487
|
// A phrase length means nothing for another timing; refused before anything reaches the page.
|
|
387
488
|
if (phraseCycles !== undefined && at !== 'phrase') {
|
|
388
489
|
throw new Error("phraseCycles applies only with at: 'phrase'.");
|
|
389
490
|
}
|
|
491
|
+
// A ramp needs music to leave: refused before Moontide starts, a tab opens or anything reaches the page.
|
|
492
|
+
if (glideBars !== undefined && (!session.hasActivePage() || session.isStopped())) {
|
|
493
|
+
throw new Error(GLIDE_NOTHING_PLAYS_MESSAGE);
|
|
494
|
+
}
|
|
390
495
|
// The first change of the music starts Moontide and waits for the page of the tab it opens.
|
|
391
496
|
await starter.ensureStarted();
|
|
392
497
|
await starter.waitForPage();
|
|
393
|
-
|
|
498
|
+
// What played before this call, read before the page changes it: the new-piece note compares the two.
|
|
499
|
+
const previousCode = session.getCode();
|
|
500
|
+
const outcome = await session.evaluate(code, {
|
|
501
|
+
label,
|
|
502
|
+
at,
|
|
503
|
+
phraseCycles,
|
|
504
|
+
glideBars,
|
|
505
|
+
answering: answeringOpen ? answering : undefined,
|
|
506
|
+
});
|
|
394
507
|
const { lines, hadRuntimeErrors } = session.drainNotices();
|
|
395
508
|
// Claude must never be told "ok, playing" when something went wrong —
|
|
396
509
|
// including when the user pressed Stop while this code was still starting.
|
|
@@ -401,13 +514,16 @@ export function createMcpServer(options) {
|
|
|
401
514
|
// BPM: the tempo line first when it changed; the at-once note when a requested line was skipped
|
|
402
515
|
// because nothing played (both from changeNoteLines, shared with edit_code).
|
|
403
516
|
const notices = outcome.replacedHandEdit ? [HAND_EDIT_REPLACED_NOTE] : [];
|
|
404
|
-
//
|
|
405
|
-
//
|
|
517
|
+
// Order of the reply lines: the change notes, the hand-edit note, the drained notices (structure notes included),
|
|
518
|
+
// the t/f flag note, the new-piece note, then the getTime() warn note, which is always the last line. The new-piece
|
|
519
|
+
// note is for set_code only: edit_code changes part of a piece and restore_version brings an older one back.
|
|
406
520
|
const text = withNoticeLines(`${status}\n\n${outcome.code}`, [
|
|
407
521
|
...changeNoteLines(outcome, at),
|
|
408
522
|
...notices,
|
|
409
523
|
...lines,
|
|
410
524
|
...whenFlagLines(outcome.code),
|
|
525
|
+
...newPieceWithoutStructureLines(previousCode, outcome.code),
|
|
526
|
+
...unknownAnsweringLines(answering, answeringOpen),
|
|
411
527
|
...getTimeAnchorLines(outcome.code),
|
|
412
528
|
]);
|
|
413
529
|
return { content: [{ type: 'text', text }] };
|
|
@@ -418,7 +534,7 @@ export function createMcpServer(options) {
|
|
|
418
534
|
// A failed set_code doesn't clear a pending hand edit, so keep surfacing it.
|
|
419
535
|
const notices = session.wasEditedByHand() ? [HAND_EDIT_NOTE] : [];
|
|
420
536
|
const message = err instanceof Error ? err.message : String(err);
|
|
421
|
-
throw new Error(withNoticeLines(message, [...notices, ...lines]));
|
|
537
|
+
throw new Error(withNoticeLines(message, [...notices, ...lines, ...unknownAnsweringLines(answering, answeringOpen)]));
|
|
422
538
|
}
|
|
423
539
|
});
|
|
424
540
|
// Changes part of the code that will play by exact replacements, through the same Session path as
|
|
@@ -456,20 +572,35 @@ export function createMcpServer(options) {
|
|
|
456
572
|
.max(MAX_PHRASE_CYCLES)
|
|
457
573
|
.optional()
|
|
458
574
|
.describe(PHRASE_CYCLES_FIELD_DESCRIPTION),
|
|
575
|
+
glideBars: z.number().int().min(MIN_GLIDE_BARS).max(MAX_GLIDE_BARS).optional().describe(GLIDE_BARS_FIELD_DESCRIPTION),
|
|
576
|
+
answering: z.string().min(1).max(MAX_ASK_ID_LENGTH).optional().describe(ANSWERING_FIELD_DESCRIPTION),
|
|
459
577
|
},
|
|
460
|
-
}, async ({ edits, base, label, at, phraseCycles }) => {
|
|
578
|
+
}, async ({ edits, base, label, at, phraseCycles, glideBars, answering }) => {
|
|
579
|
+
session.noteToolCall();
|
|
580
|
+
const answeringOpen = answering === undefined ? true : session.requestIsOpen(answering);
|
|
461
581
|
try {
|
|
462
582
|
// The same rule and text as set_code, before anything reaches the page.
|
|
463
583
|
if (phraseCycles !== undefined && at !== 'phrase') {
|
|
464
584
|
throw new Error("phraseCycles applies only with at: 'phrase'.");
|
|
465
585
|
}
|
|
586
|
+
// The same rule and text as set_code.
|
|
587
|
+
if (glideBars !== undefined && (!session.hasActivePage() || session.isStopped())) {
|
|
588
|
+
throw new Error(GLIDE_NOTHING_PLAYS_MESSAGE);
|
|
589
|
+
}
|
|
466
590
|
// Nothing plays before a start, so there is nothing to edit: refuse before a tab opens for a request that cannot work.
|
|
467
591
|
if (starter.current() === null) {
|
|
468
592
|
throw new Error(EDIT_NOTHING_PLAYING_MESSAGE);
|
|
469
593
|
}
|
|
470
594
|
await starter.ensureStarted();
|
|
471
595
|
await starter.waitForPage();
|
|
472
|
-
const edit = await session.editCode(edits, {
|
|
596
|
+
const edit = await session.editCode(edits, {
|
|
597
|
+
base,
|
|
598
|
+
label,
|
|
599
|
+
at,
|
|
600
|
+
phraseCycles,
|
|
601
|
+
glideBars,
|
|
602
|
+
answering: answeringOpen ? answering : undefined,
|
|
603
|
+
});
|
|
473
604
|
const { outcome } = edit;
|
|
474
605
|
const { lines, hadRuntimeErrors } = session.drainNotices();
|
|
475
606
|
const status = changeStatus(outcome, at, hadRuntimeErrors, session.getPageUrl());
|
|
@@ -489,6 +620,7 @@ export function createMcpServer(options) {
|
|
|
489
620
|
...otherText,
|
|
490
621
|
...lines,
|
|
491
622
|
...whenFlagLines(outcome.code),
|
|
623
|
+
...unknownAnsweringLines(answering, answeringOpen),
|
|
492
624
|
...getTimeAnchorLines(outcome.code),
|
|
493
625
|
]);
|
|
494
626
|
return { content: [{ type: 'text', text }] };
|
|
@@ -497,7 +629,7 @@ export function createMcpServer(options) {
|
|
|
497
629
|
const { lines } = session.drainNotices();
|
|
498
630
|
const notices = session.wasEditedByHand() ? [HAND_EDIT_NOTE] : [];
|
|
499
631
|
const message = err instanceof Error ? err.message : String(err);
|
|
500
|
-
throw new Error(withNoticeLines(message, [...notices, ...lines]));
|
|
632
|
+
throw new Error(withNoticeLines(message, [...notices, ...lines, ...unknownAnsweringLines(answering, answeringOpen)]));
|
|
501
633
|
}
|
|
502
634
|
});
|
|
503
635
|
server.registerTool('list_history', {
|
|
@@ -512,6 +644,7 @@ export function createMcpServer(options) {
|
|
|
512
644
|
favorites: z.boolean().optional().describe('true to list only favorites'),
|
|
513
645
|
},
|
|
514
646
|
}, async ({ limit, filter, favorites }) => {
|
|
647
|
+
session.noteToolCall();
|
|
515
648
|
try {
|
|
516
649
|
const text = session.listHistory({ limit, filter, favoritesOnly: favorites });
|
|
517
650
|
const { lines } = session.drainNotices();
|
|
@@ -534,6 +667,7 @@ export function createMcpServer(options) {
|
|
|
534
667
|
.describe("Name to keep it under, in the user's words"),
|
|
535
668
|
},
|
|
536
669
|
}, async ({ id, name }) => {
|
|
670
|
+
session.noteToolCall();
|
|
537
671
|
try {
|
|
538
672
|
const text = session.favoriteVersion(id, name);
|
|
539
673
|
const { lines } = session.drainNotices();
|
|
@@ -549,14 +683,17 @@ export function createMcpServer(options) {
|
|
|
549
683
|
description: 'Plays a past version again, by its id from list_history, in one call: it takes over within a fraction of a second without stopping the music, with the same checks as set_code. The restore is added to the history as a new version ("restored from #id"); nothing is rewritten. Returns the code as confirmed by the page. A restore always takes over at once and replaces a change that waits for a bar or phrase line. The old version replaces the music at once; when the user asked for gradual changes this session, bring it in with set_code and a transition instead. Like set_code, it starts Moontide first when it is not started yet, but only for an id that exists in the history.',
|
|
550
684
|
inputSchema: {
|
|
551
685
|
id: z.number().int().positive().describe('Version id from list_history, for example 41 for #41'),
|
|
686
|
+
answering: z.string().min(1).max(MAX_ASK_ID_LENGTH).optional().describe(ANSWERING_FIELD_DESCRIPTION),
|
|
552
687
|
},
|
|
553
|
-
}, async ({ id }) => {
|
|
688
|
+
}, async ({ id, answering }) => {
|
|
689
|
+
session.noteToolCall();
|
|
690
|
+
const answeringOpen = answering === undefined ? true : session.requestIsOpen(answering);
|
|
554
691
|
try {
|
|
555
692
|
// The history needs no page: an id that cannot be restored fails before Moontide starts or a tab opens.
|
|
556
693
|
session.assertVersionExists(id);
|
|
557
694
|
await starter.ensureStarted();
|
|
558
695
|
await starter.waitForPage();
|
|
559
|
-
const outcome = await session.restoreVersion(id);
|
|
696
|
+
const outcome = await session.restoreVersion(id, { answering: answeringOpen ? answering : undefined });
|
|
560
697
|
const { lines, hadRuntimeErrors } = session.drainNotices();
|
|
561
698
|
// versionId is null only on a history-write failure — the write-failure
|
|
562
699
|
// note (queued by the same recordConfirmed path set_code uses) explains why below.
|
|
@@ -566,7 +703,12 @@ export function createMcpServer(options) {
|
|
|
566
703
|
const status = outcome.audio === 'playing' && !hadRuntimeErrors
|
|
567
704
|
? `${head} — now playing.`
|
|
568
705
|
: `${head}. ${playStatus(outcome.audio, hadRuntimeErrors, session.getPageUrl())}`;
|
|
569
|
-
|
|
706
|
+
// A restored version that restates the target of a running ramp lets it go on, and says so in place of the tempo line.
|
|
707
|
+
const tempo = outcome.glide !== undefined
|
|
708
|
+
? [glideLine(outcome.glide)]
|
|
709
|
+
: outcome.tempoChanged && outcome.bpm !== null
|
|
710
|
+
? [tempoLine(outcome.bpm)]
|
|
711
|
+
: [];
|
|
570
712
|
const notices = outcome.replacedHandEdit ? [HAND_EDIT_REPLACED_NOTE] : [];
|
|
571
713
|
// The same t/f flag note and getTime() warn note as set_code, the getTime() note last.
|
|
572
714
|
const text = withNoticeLines(`${status}\n\n${outcome.code}`, [
|
|
@@ -574,6 +716,7 @@ export function createMcpServer(options) {
|
|
|
574
716
|
...notices,
|
|
575
717
|
...lines,
|
|
576
718
|
...whenFlagLines(outcome.code),
|
|
719
|
+
...unknownAnsweringLines(answering, answeringOpen),
|
|
577
720
|
...getTimeAnchorLines(outcome.code),
|
|
578
721
|
]);
|
|
579
722
|
return { content: [{ type: 'text', text }] };
|
|
@@ -585,13 +728,36 @@ export function createMcpServer(options) {
|
|
|
585
728
|
const { lines } = session.drainNotices();
|
|
586
729
|
const notices = session.wasEditedByHand() ? [HAND_EDIT_NOTE] : [];
|
|
587
730
|
const message = err instanceof Error ? err.message : String(err);
|
|
588
|
-
throw new Error(withNoticeLines(message, [...notices, ...lines]));
|
|
731
|
+
throw new Error(withNoticeLines(message, [...notices, ...lines, ...unknownAnsweringLines(answering, answeringOpen)]));
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
// Needs no started Moontide: a request id is only open while a server runs. It changes no music.
|
|
735
|
+
server.registerTool(ANSWER_REQUEST_TOOL, {
|
|
736
|
+
description: ANSWER_REQUEST_DESCRIPTION,
|
|
737
|
+
annotations: { destructiveHint: false },
|
|
738
|
+
inputSchema: {
|
|
739
|
+
id: z.string().min(1).max(MAX_ASK_ID_LENGTH).describe(ANSWER_REQUEST_ID_DESCRIPTION),
|
|
740
|
+
outcome: z.enum(['answered', 'declined']).describe(ANSWER_REQUEST_OUTCOME_DESCRIPTION),
|
|
741
|
+
text: z.string().max(MAX_ANSWER_TEXT_LENGTH).describe(ANSWER_REQUEST_TEXT_DESCRIPTION),
|
|
742
|
+
},
|
|
743
|
+
}, async ({ id, outcome, text }) => {
|
|
744
|
+
session.noteToolCall();
|
|
745
|
+
try {
|
|
746
|
+
session.answerRequest(id, outcome, text);
|
|
747
|
+
const { lines } = session.drainNotices();
|
|
748
|
+
return { content: [{ type: 'text', text: withNoticeLines(answerRequestReply(outcome), lines) }] };
|
|
749
|
+
}
|
|
750
|
+
catch (err) {
|
|
751
|
+
const { lines } = session.drainNotices();
|
|
752
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
753
|
+
throw new Error(withNoticeLines(message, lines));
|
|
589
754
|
}
|
|
590
755
|
});
|
|
591
756
|
server.registerTool('stop', {
|
|
592
757
|
description: 'Stops all sound in the Moontide page right away, even when the current code is broken. The code stays in the editor; get_code still returns it, and the next set_code starts playing again. Before Moontide is started it says so and starts nothing.',
|
|
593
758
|
annotations: { destructiveHint: false, idempotentHint: true },
|
|
594
759
|
}, async () => {
|
|
760
|
+
session.noteToolCall();
|
|
595
761
|
if (starter.current() === null) {
|
|
596
762
|
throw new Error(NOT_STARTED_MESSAGE);
|
|
597
763
|
}
|
|
@@ -619,6 +785,7 @@ export function createMcpServer(options) {
|
|
|
619
785
|
instrument: z.string().min(1).max(32).optional().describe('An instrument id from instruments.json, to list its sounds'),
|
|
620
786
|
},
|
|
621
787
|
}, async ({ instrument }) => {
|
|
788
|
+
session.noteToolCall();
|
|
622
789
|
try {
|
|
623
790
|
const text = session.instrumentsReply(instrument);
|
|
624
791
|
const { lines } = session.drainNotices();
|
|
@@ -639,6 +806,7 @@ export function createMcpServer(options) {
|
|
|
639
806
|
category: z.string().max(60).optional().describe('Only sounds of this category; case and spaces do not matter'),
|
|
640
807
|
},
|
|
641
808
|
}, async ({ instrument, query, category }) => {
|
|
809
|
+
session.noteToolCall();
|
|
642
810
|
try {
|
|
643
811
|
const text = session.searchMidiSounds({ instrument, query, category });
|
|
644
812
|
const { lines } = session.drainNotices();
|
|
@@ -658,6 +826,7 @@ export function createMcpServer(options) {
|
|
|
658
826
|
overwrite: z.boolean().optional().describe('true to replace an existing user model with the same id'),
|
|
659
827
|
},
|
|
660
828
|
}, async ({ model, confirmed, overwrite }) => {
|
|
829
|
+
session.noteToolCall();
|
|
661
830
|
try {
|
|
662
831
|
const text = session.saveMidiModel({ model, confirmed, overwrite });
|
|
663
832
|
const { lines } = session.drainNotices();
|
|
@@ -706,6 +875,7 @@ export function createMcpServer(options) {
|
|
|
706
875
|
.describe('The sounds of this part, grouped by bank and category; at most 500 sounds in the call'),
|
|
707
876
|
},
|
|
708
877
|
}, async ({ model, confirmed, append, more, source, programNumbering, overwrite, banks }) => {
|
|
878
|
+
session.noteToolCall();
|
|
709
879
|
try {
|
|
710
880
|
const text = session.saveMidiCatalog({ model, confirmed, append, more, source, programNumbering, overwrite, banks });
|
|
711
881
|
const { lines } = session.drainNotices();
|
|
@@ -754,6 +924,7 @@ export function createMcpServer(options) {
|
|
|
754
924
|
state: z.enum(['on', 'off']).describe(PAGE_REQUESTS_STATE_DESCRIPTION),
|
|
755
925
|
},
|
|
756
926
|
}, async ({ state }) => {
|
|
927
|
+
session.noteToolCall();
|
|
757
928
|
const result = switchPageRequests({ session, state });
|
|
758
929
|
if ('noPage' in result) {
|
|
759
930
|
throw new Error(starter.current() === null ? notStartedForRequestsToolText() : noPageForRequestsToolText(session.getPageUrl()));
|
|
@@ -780,6 +951,7 @@ export function createMcpServer(options) {
|
|
|
780
951
|
description: START_TOOL_DESCRIPTION,
|
|
781
952
|
annotations: { destructiveHint: false, idempotentHint: true },
|
|
782
953
|
}, async () => {
|
|
954
|
+
session.noteToolCall();
|
|
783
955
|
const text = await startMoontide(starter);
|
|
784
956
|
return { content: [{ type: 'text', text }] };
|
|
785
957
|
});
|