moontide-mcp 2.1.0 → 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 +54 -2
- package/dist/ask.js +47 -8
- package/dist/mcp.js +143 -17
- package/dist/protocol.js +43 -2
- package/dist/request-status.js +221 -0
- package/dist/session.js +203 -7
- package/package.json +1 -1
- package/page/ask-view.js +43 -50
- package/page/ask.js +82 -165
- package/page/bar-model.js +43 -2
- package/page/change-timing.js +35 -0
- package/page/index.html +4 -2
- package/page/jam-analysis.js +68 -23
- package/page/jam-model.js +185 -29
- package/page/jam-resolve.js +271 -0
- package/page/jam-view.js +165 -10
- package/page/lights.js +29 -7
- package/page/main.js +459 -59
- package/page/midi-clock.js +22 -3
- package/page/midi-out.js +5 -2
- package/page/mixer-view.js +7 -1
- package/page/note-panel.js +29 -8
- package/page/note-tag.js +19 -4
- package/page/page-rewrite.js +1 -1
- 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-grid-model.js +15 -0
- package/page/song-grid-view.js +1 -1
- package/page/structure.js +2 -1
- package/page/style.css +389 -31
- package/page/tempo-glide.js +330 -0
- package/page/tempo-guard.js +62 -10
- package/page/timeline-edit-view.js +1 -1
- package/page/timeline.js +2 -1
- package/page/top-bar-view.js +18 -13
- package/page/views.js +15 -2
- package/reference/transitions.md +13 -1
package/README.md
CHANGED
|
@@ -41,8 +41,11 @@ change.
|
|
|
41
41
|
- Around the code, the page shows the music in several views: the named tracks on a timeline
|
|
42
42
|
where you can edit notes, the Song grid with the sections of a piece, the history as a list
|
|
43
43
|
or a tree, a mixer with groups and lights whose moves rewrite the code, and a jam view with
|
|
44
|
-
the scale on a piano or a guitar.
|
|
45
|
-
|
|
44
|
+
the scale on a piano or a guitar. When the notes fit several scales, the jam view proposes
|
|
45
|
+
one and says why.
|
|
46
|
+
- Claude can move the tempo up or down over a number of bars, instead of jumping.
|
|
47
|
+
- A right-click on anything in the page sends Claude a request with that context. The Requests
|
|
48
|
+
list in the top bar shows what became of each request.
|
|
46
49
|
- The same code can play real MIDI instruments from the page, through Web MIDI.
|
|
47
50
|
|
|
48
51
|
Moontide does not start from scratch. The music engine is
|
|
@@ -70,6 +73,20 @@ Strudel contributors. Moontide is the bridge between Claude and a Strudel page.
|
|
|
70
73
|
bar) says whether page requests are live (they reach Claude right away) or wait for Claude
|
|
71
74
|
(they reach it with its next action). Its (i) explains how to switch.
|
|
72
75
|
|
|
76
|
+
The **Requests** button in the top bar counts the requests still in flight and opens a list:
|
|
77
|
+
one row per request, with what happened to it and how long ago. A request reads Sent, then
|
|
78
|
+
Waiting for Claude's next action or Received, then Claude has started on it, which means
|
|
79
|
+
Claude made a call after the request arrived. A change made for it reads Waiting for its
|
|
80
|
+
line while it waits for the next bar or phrase, then Applied as v12 (the version in the
|
|
81
|
+
History view); a change that failed reads Failed with the error, and one that a newer change
|
|
82
|
+
replaced or Stop canceled says so. A request Claude answers in words reads Answered or
|
|
83
|
+
Declined, with Claude's sentence. A request shows as applied only when Claude names it with
|
|
84
|
+
its change, never from timing alone. After 30 seconds without news a row reads No change yet
|
|
85
|
+
and keeps counting: that is not a failure. If the page loses its connection, open rows read
|
|
86
|
+
Connection lost. Click a row to see the whole request and what its
|
|
87
|
+
state means. Finished rows move to a short Recent list after 30 seconds. Nothing is kept
|
|
88
|
+
after the Moontide server stops.
|
|
89
|
+
|
|
73
90
|
## Install
|
|
74
91
|
|
|
75
92
|
The short version, for someone who already has Node 22 or later and Claude Code:
|
|
@@ -274,6 +291,15 @@ The code can say which harmony it means with one top-level line, for example
|
|
|
274
291
|
`const harmony = 'C:minor'`, placed above the patterns. It plays nothing and is never a
|
|
275
292
|
track. The Jam view then shows two lines, "Declared:" and "Heard:", side by side.
|
|
276
293
|
|
|
294
|
+
When the notes fit several scales, the Jam view proposes one and says why in a sentence, for
|
|
295
|
+
example "Proposed: C major, C is the note heard most." It takes the declared scale of the playing
|
|
296
|
+
section when that scale is among the ones that fit, and otherwise the scale the notes support
|
|
297
|
+
most. Warnings stay on. "Scales that fit, click to switch:" lists them, and one click or Enter
|
|
298
|
+
switches the whole view. Your pick holds while the same scales fit, and is forgotten on reload.
|
|
299
|
+
When the scales differ in their notes and the code declares none of them, warnings are off: the
|
|
300
|
+
view says why and lists the scales without a switch. The piano is coloured like the guitar, with
|
|
301
|
+
the same legend, and every role also has a shape or text cue, so it reads without colour.
|
|
302
|
+
|
|
277
303
|
## Song structure
|
|
278
304
|
|
|
279
305
|
Every new piece Claude writes starts with one `const song` at the top. It names the sections
|
|
@@ -309,6 +335,30 @@ Left or Right, to select a range of whole cycles, and use "Ask Claude" to send i
|
|
|
309
335
|
|
|
310
336
|
A right-click on a section, a track, a cell or a group asks Claude about that thing.
|
|
311
337
|
|
|
338
|
+
## Tempo ramp
|
|
339
|
+
|
|
340
|
+
Ask Claude to speed up or slow down over a number of bars, for example "bring it up to 140 over
|
|
341
|
+
eight bars". Claude sends the new code with the tempo to reach and `glideBars`, a whole number
|
|
342
|
+
of bars from 1 to 64, on `set_code` or `edit_code`. The tempo then moves from the tempo that
|
|
343
|
+
plays to the one in the code in equal small steps, and ends exactly on it. It starts at once, or
|
|
344
|
+
at the line when the change waits for the next bar or phrase.
|
|
345
|
+
|
|
346
|
+
While it runs, the tempo in the top bar shows the live tempo, an arrow and the tempo to reach.
|
|
347
|
+
Hover it to see how many bars are left.
|
|
348
|
+
|
|
349
|
+
Your Mixer moves, Song grid cells, Timeline edits and hand edits keep the ramp going. Code with
|
|
350
|
+
another tempo cancels it, and that tempo plays at once. Code with no tempo cancels it, and the
|
|
351
|
+
tempo stays where it was. A new `glideBars` replaces it. Stop cancels it too, and the next Play
|
|
352
|
+
starts at the code's tempo. A reload, or another tab taking over, ends it. There is no cancel
|
|
353
|
+
button of its own.
|
|
354
|
+
|
|
355
|
+
A ramp is refused, and nothing changes, when nothing plays, when the code sets no tempo, or when
|
|
356
|
+
it sets the tempo that already plays. A ramp is never a version: it writes nothing into the code
|
|
357
|
+
or the history.
|
|
358
|
+
|
|
359
|
+
The MIDI clock follows every step of a ramp. How hardware that follows the clock, such as an
|
|
360
|
+
arpeggiator or a step sequencer, reacts to a ramp has not been verified.
|
|
361
|
+
|
|
312
362
|
## History
|
|
313
363
|
|
|
314
364
|
Every version that plays is kept: Claude's own changes and your Ctrl+Enter edits, each with
|
|
@@ -368,6 +418,8 @@ Known limits:
|
|
|
368
418
|
- Windows and Linux are untested by hand. Edge, Safari and Firefox are untested.
|
|
369
419
|
- Internet access is needed.
|
|
370
420
|
- MIDI needs Chrome or Edge.
|
|
421
|
+
- How hardware that follows Moontide's MIDI clock, such as an arpeggiator or a step sequencer,
|
|
422
|
+
reacts to a tempo ramp has not been verified.
|
|
371
423
|
- Run one Moontide at a time with the same history file.
|
|
372
424
|
- The first start through npx downloads the package and can be slow.
|
|
373
425
|
|
package/dist/ask.js
CHANGED
|
@@ -58,11 +58,24 @@ export const REQUEST_LINE_MOMENT_FACT = 'About the moment field of a page reques
|
|
|
58
58
|
'turn the note into a rest. Kind velocity has direction, softer or louder, for the note\'s velocity. ' +
|
|
59
59
|
'About a request whose context has kind range: the user selected whole cycles of one lane. track is the lane, from and to ' +
|
|
60
60
|
`are the first and last cycle, both included (a range covers at most ${MAX_RANGE_CYCLES} cycles), and in a piece with a song ` +
|
|
61
|
-
'
|
|
61
|
+
'sections lists the sections those cycles cross in playing order, a section played twice in a row once and a section that ' +
|
|
62
|
+
'comes back after another one again, and moreSections, when present, counts the sections left out so that the list fits. ' +
|
|
63
|
+
'Such a request has no moment; ' +
|
|
62
64
|
'its gesture has kind range. The gesture\'s notes lists the notes of that lane that start in those cycles, in order of ' +
|
|
63
65
|
`start and then pitch (the lower first), at most ${MAX_MOMENT_NOTES}, each shaped like a moment note without state; more ` +
|
|
64
66
|
'counts the notes left out, and version is the version that was playing (null when it was not known). A note that began ' +
|
|
65
67
|
'before from and still sounds is not listed. A range with no notes in it is a valid request.';
|
|
68
|
+
/**
|
|
69
|
+
* What the page shows for a request, as facts. Like the moment fact it goes where Claude reads page requests
|
|
70
|
+
* and nothing limits the length: the arming line, the page-requests-on text and the line in front of request
|
|
71
|
+
* lines that a tool reply hands over without an arming line. It says what the page shows and when, never what
|
|
72
|
+
* to do about it.
|
|
73
|
+
*/
|
|
74
|
+
export const REQUEST_LINE_STATUS_FACT = 'About the id field of a page request: the page shows the user what became of each request it sent. ' +
|
|
75
|
+
'It shows a request as applied, or as waiting for its line when the change takes over at the next bar or phrase, ' +
|
|
76
|
+
'only when the set_code, edit_code or restore_version call that answers it passes that id as answering. ' +
|
|
77
|
+
'It shows a request as answered or declined, with one sentence, only after answer_request closes it with that id. ' +
|
|
78
|
+
'A request with neither shows as no change yet. An id that is not an open request changes nothing.';
|
|
66
79
|
/**
|
|
67
80
|
* A log function that writes at most one line per window for each kind, to stderr. The next line
|
|
68
81
|
* that does get written says how many were left out. Every log that a page or a local process can
|
|
@@ -148,7 +161,7 @@ export function listenerCommandOrReason(options) {
|
|
|
148
161
|
*/
|
|
149
162
|
export function armingLine(options) {
|
|
150
163
|
const head = 'Page requests: no listener is connected. A request typed in the Moontide page waits on the server and arrives in the reply of your next other Moontide tool. ' +
|
|
151
|
-
`${REQUEST_LINE_FRAMING} ${REQUEST_LINE_MOMENT_FACT}`;
|
|
164
|
+
`${REQUEST_LINE_FRAMING} ${REQUEST_LINE_MOMENT_FACT} ${REQUEST_LINE_STATUS_FACT}`;
|
|
152
165
|
const result = listenerCommandOrReason(options);
|
|
153
166
|
if ('reason' in result) {
|
|
154
167
|
return `${head} ${result.reason}`;
|
|
@@ -199,6 +212,7 @@ export function pageRequestsOnText(options) {
|
|
|
199
212
|
`Note: ${MONITOR_LIMIT_FACT}; when a Moontide tool reply gives this command again, run it again the same way.`,
|
|
200
213
|
REQUEST_LINE_FRAMING,
|
|
201
214
|
REQUEST_LINE_MOMENT_FACT,
|
|
215
|
+
REQUEST_LINE_STATUS_FACT,
|
|
202
216
|
].join('\n');
|
|
203
217
|
}
|
|
204
218
|
export const PAGE_REQUESTS_ALREADY_ON = 'Moontide page requests are already live: a Moontide monitor is connected and brings each request as soon as it is sent. ' +
|
|
@@ -290,6 +304,8 @@ export class AskChannel {
|
|
|
290
304
|
/** Given only to Claude, in the arming line of a tool reply; changes at every server start. */
|
|
291
305
|
listenerToken = newToken();
|
|
292
306
|
send;
|
|
307
|
+
// Told when a request is accepted, handed to Claude, sent back to the queue or loses its page.
|
|
308
|
+
board;
|
|
293
309
|
ackTimeoutMs;
|
|
294
310
|
now;
|
|
295
311
|
trusted = new Set();
|
|
@@ -307,6 +323,7 @@ export class AskChannel {
|
|
|
307
323
|
logRefusal;
|
|
308
324
|
constructor(options) {
|
|
309
325
|
this.send = options.send;
|
|
326
|
+
this.board = options.board;
|
|
310
327
|
this.ackTimeoutMs = options.ackTimeoutMs ?? LISTENER_ACK_TIMEOUT_MS;
|
|
311
328
|
this.now = options.now ?? Date.now;
|
|
312
329
|
this.logRefusal = makeThrottledLog(ASK_RATE_WINDOW_MS, this.now);
|
|
@@ -409,8 +426,11 @@ export class AskChannel {
|
|
|
409
426
|
this.refuse(conn, msg.id, 'line-too-long');
|
|
410
427
|
return;
|
|
411
428
|
}
|
|
412
|
-
// The id is chosen by the page. A repeat would overwrite the in-flight slot and orphan its timer
|
|
413
|
-
|
|
429
|
+
// The id is chosen by the page. A repeat would overwrite the in-flight slot and orphan its timer, or
|
|
430
|
+
// take over the board row of a request Claude already holds, which may be another page's.
|
|
431
|
+
if (this.inFlight.has(msg.id) ||
|
|
432
|
+
this.queue.some((waiting) => waiting.id === msg.id) ||
|
|
433
|
+
this.board?.has(msg.id) === true) {
|
|
414
434
|
this.logRefusal('duplicate', '[moontide] Refused a page request: its id is already in use');
|
|
415
435
|
this.refuse(conn, msg.id, 'invalid', 'duplicate request id');
|
|
416
436
|
return;
|
|
@@ -418,6 +438,7 @@ export class AskChannel {
|
|
|
418
438
|
this.recent.push(now);
|
|
419
439
|
const entry = { id: msg.id, line, origin: conn, answered: 'none' };
|
|
420
440
|
if (this.listener !== null) {
|
|
441
|
+
this.board?.opened(msg.id, conn, { queued: false, text: msg.text, chip: msg.chip });
|
|
421
442
|
this.dispatch(entry);
|
|
422
443
|
return;
|
|
423
444
|
}
|
|
@@ -427,6 +448,7 @@ export class AskChannel {
|
|
|
427
448
|
return;
|
|
428
449
|
}
|
|
429
450
|
entry.answered = 'waiting';
|
|
451
|
+
this.board?.opened(msg.id, conn, { queued: true, text: msg.text, chip: msg.chip });
|
|
430
452
|
this.queue.push(entry);
|
|
431
453
|
this.send(conn, { type: 'ask-result', id: msg.id, status: 'waiting', waiting: this.queue.length }, 'the ask answer');
|
|
432
454
|
}
|
|
@@ -479,6 +501,7 @@ export class AskChannel {
|
|
|
479
501
|
this.pushPageRequestsState();
|
|
480
502
|
const requeued = this.requeueInFlight();
|
|
481
503
|
for (const entry of requeued) {
|
|
504
|
+
this.board?.waiting(entry.id);
|
|
482
505
|
if (entry.answered === 'none') {
|
|
483
506
|
entry.answered = 'waiting';
|
|
484
507
|
if (entry.origin !== null) {
|
|
@@ -512,6 +535,7 @@ export class AskChannel {
|
|
|
512
535
|
}
|
|
513
536
|
this.inFlight.delete(ack);
|
|
514
537
|
const { entry } = slot;
|
|
538
|
+
this.board?.delivered([entry.id]);
|
|
515
539
|
if (entry.origin === null) {
|
|
516
540
|
return;
|
|
517
541
|
}
|
|
@@ -525,6 +549,7 @@ export class AskChannel {
|
|
|
525
549
|
/** A page connection closed: its trust goes, and nobody is answered for its requests any more. */
|
|
526
550
|
detachPage(conn) {
|
|
527
551
|
this.trusted.delete(conn);
|
|
552
|
+
this.board?.pageGone(conn);
|
|
528
553
|
for (const entry of this.queue) {
|
|
529
554
|
if (entry.origin === conn) {
|
|
530
555
|
entry.origin = null;
|
|
@@ -541,17 +566,31 @@ export class AskChannel {
|
|
|
541
566
|
}
|
|
542
567
|
this.untrustedRefusals.delete(conn);
|
|
543
568
|
}
|
|
569
|
+
/**
|
|
570
|
+
* A reloaded page took over these requests from the board: the ones still waiting or in flight whose
|
|
571
|
+
* page has gone now answer to it, so it is told when they are handed over or go back to waiting.
|
|
572
|
+
*/
|
|
573
|
+
adoptOrigin(conn, ids) {
|
|
574
|
+
const adopted = new Set(ids);
|
|
575
|
+
const entries = [...this.queue, ...[...this.inFlight.values()].map((slot) => slot.entry)];
|
|
576
|
+
for (const entry of entries) {
|
|
577
|
+
if (entry.origin === null && adopted.has(entry.id)) {
|
|
578
|
+
entry.origin = conn;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
544
582
|
/**
|
|
545
583
|
* Called by the tool replies: takes the WHOLE waiting queue at once and returns its request lines
|
|
546
584
|
* (oldest first), telling each page its requests were handed over. Then, while no listener is
|
|
547
585
|
* connected, a page has authenticated and page-requests-off has not paused it, the arming line. When
|
|
548
|
-
* there is request text but no arming line, the moment fact
|
|
586
|
+
* there is request text but no arming line, the moment fact and the status fact go in front of the request lines.
|
|
549
587
|
*/
|
|
550
588
|
drainLines(options) {
|
|
551
589
|
const lines = [];
|
|
552
590
|
const entries = this.queue;
|
|
553
591
|
this.queue = [];
|
|
554
592
|
const handedOver = new Map();
|
|
593
|
+
this.board?.delivered(entries.map((entry) => entry.id));
|
|
555
594
|
for (const entry of entries) {
|
|
556
595
|
lines.push(entry.line);
|
|
557
596
|
if (entry.origin !== null) {
|
|
@@ -564,13 +603,13 @@ export class AskChannel {
|
|
|
564
603
|
this.send(origin, { type: 'ask-received', ids }, 'the ask hand-over');
|
|
565
604
|
}
|
|
566
605
|
if (!this.hasListener() && this.hasTrustedPage() && !this.armingPaused) {
|
|
567
|
-
// The arming line carries
|
|
606
|
+
// The arming line carries both facts itself.
|
|
568
607
|
lines.push(armingLine({ scriptPath: options.scriptPath, port: options.port, token: this.listenerToken }));
|
|
569
608
|
}
|
|
570
609
|
else if (lines.length > 0) {
|
|
571
610
|
// No arming line (page requests are paused, or a listener is connected): the reply still says what the
|
|
572
|
-
// moment of the request lines it carries holds.
|
|
573
|
-
lines.unshift(REQUEST_LINE_MOMENT_FACT);
|
|
611
|
+
// moment of the request lines it carries holds and what the page shows for a request.
|
|
612
|
+
lines.unshift(REQUEST_LINE_MOMENT_FACT, REQUEST_LINE_STATUS_FACT);
|
|
574
613
|
}
|
|
575
614
|
return lines;
|
|
576
615
|
}
|
package/dist/mcp.js
CHANGED
|
@@ -7,7 +7,7 @@ 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
13
|
import { REFERENCE_TOPICS, REFERENCE_TOPIC_IDS, REFERENCE_URI_PREFIX, readReferenceTopic, referenceIndexText, referenceToolDescription, structureTemplateText, } from './reference.js';
|
|
@@ -77,10 +77,12 @@ export function whenFlagLines(code) {
|
|
|
77
77
|
// text: it holds no code and no page text. The music plays as written; nothing is refused, blocked or rewritten.
|
|
78
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
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.
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
const
|
|
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;
|
|
84
86
|
/** The shortest line, in characters after trimming, that counts when telling a new piece from a changed one. */
|
|
85
87
|
const NEW_PIECE_MIN_LINE = 8;
|
|
86
88
|
/**
|
|
@@ -105,7 +107,7 @@ function isNewPiece(previous, code) {
|
|
|
105
107
|
}
|
|
106
108
|
/** The note when `code` replaces `previousCode` as a new piece and declares no song structure, empty otherwise. */
|
|
107
109
|
export function newPieceWithoutStructureLines(previousCode, code) {
|
|
108
|
-
if (
|
|
110
|
+
if (SONG_DECLARATION_RE.test(code))
|
|
109
111
|
return [];
|
|
110
112
|
return isNewPiece(previousCode, code) ? [NO_STRUCTURE_NOTE] : [];
|
|
111
113
|
}
|
|
@@ -154,6 +156,24 @@ export function tempoLine(bpm, pendingCycle) {
|
|
|
154
156
|
const shown = Number.isInteger(bpm) ? String(bpm) : bpm.toFixed(1);
|
|
155
157
|
return pendingCycle === undefined ? `Tempo: ${shown} BPM.` : `Tempo from cycle ${pendingCycle}: ${shown} BPM.`;
|
|
156
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
|
+
}
|
|
157
177
|
/**
|
|
158
178
|
* The first note of get_code while a change waits to take over: the code the reply shows is the code
|
|
159
179
|
* that plays until the line.
|
|
@@ -189,16 +209,47 @@ function changeStatus(outcome, at, hadRuntimeErrors, pageUrl) {
|
|
|
189
209
|
* the at-once note when a requested line was skipped because nothing played.
|
|
190
210
|
*/
|
|
191
211
|
function changeNoteLines(outcome, at) {
|
|
192
|
-
|
|
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
|
+
: [];
|
|
193
218
|
const atOnce = (at === 'bar' || at === 'phrase') && outcome.pending === undefined && outcome.audio !== 'stopped'
|
|
194
219
|
? [AT_ONCE_NOTE]
|
|
195
220
|
: [];
|
|
196
221
|
return [...tempo, ...atOnce];
|
|
197
222
|
}
|
|
198
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
|
+
}
|
|
199
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). " +
|
|
200
246
|
'The code is checked at once; the reply says the cycle and about how many seconds away it is.';
|
|
201
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.';
|
|
202
253
|
/**
|
|
203
254
|
* The tool description of edit_code. The when-to-use rule comes first; Claude Code keeps at most 2048
|
|
204
255
|
* characters of a tool description.
|
|
@@ -349,6 +400,8 @@ export function createMcpServer(options) {
|
|
|
349
400
|
'Call it before every change.',
|
|
350
401
|
annotations: { readOnlyHint: true },
|
|
351
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();
|
|
352
405
|
// Before the start there is no page and no code: say how to start and start nothing. No drain, so queued
|
|
353
406
|
// notices wait for the next reply.
|
|
354
407
|
const server = starter.current();
|
|
@@ -382,6 +435,7 @@ export function createMcpServer(options) {
|
|
|
382
435
|
topic: z.enum(REFERENCE_TOPIC_IDS).optional().describe('One topic to return; omit it to get the index of topics'),
|
|
383
436
|
},
|
|
384
437
|
}, async ({ topic }) => {
|
|
438
|
+
session.noteToolCall();
|
|
385
439
|
try {
|
|
386
440
|
const text = topic ? readReferenceTopic(topic) : referenceIndexText();
|
|
387
441
|
return { content: [{ type: 'text', text }] };
|
|
@@ -422,19 +476,34 @@ export function createMcpServer(options) {
|
|
|
422
476
|
.max(MAX_PHRASE_CYCLES)
|
|
423
477
|
.optional()
|
|
424
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),
|
|
425
481
|
},
|
|
426
|
-
}, 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);
|
|
427
486
|
try {
|
|
428
487
|
// A phrase length means nothing for another timing; refused before anything reaches the page.
|
|
429
488
|
if (phraseCycles !== undefined && at !== 'phrase') {
|
|
430
489
|
throw new Error("phraseCycles applies only with at: 'phrase'.");
|
|
431
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
|
+
}
|
|
432
495
|
// The first change of the music starts Moontide and waits for the page of the tab it opens.
|
|
433
496
|
await starter.ensureStarted();
|
|
434
497
|
await starter.waitForPage();
|
|
435
498
|
// What played before this call, read before the page changes it: the new-piece note compares the two.
|
|
436
499
|
const previousCode = session.getCode();
|
|
437
|
-
const outcome = await session.evaluate(code, {
|
|
500
|
+
const outcome = await session.evaluate(code, {
|
|
501
|
+
label,
|
|
502
|
+
at,
|
|
503
|
+
phraseCycles,
|
|
504
|
+
glideBars,
|
|
505
|
+
answering: answeringOpen ? answering : undefined,
|
|
506
|
+
});
|
|
438
507
|
const { lines, hadRuntimeErrors } = session.drainNotices();
|
|
439
508
|
// Claude must never be told "ok, playing" when something went wrong —
|
|
440
509
|
// including when the user pressed Stop while this code was still starting.
|
|
@@ -454,6 +523,7 @@ export function createMcpServer(options) {
|
|
|
454
523
|
...lines,
|
|
455
524
|
...whenFlagLines(outcome.code),
|
|
456
525
|
...newPieceWithoutStructureLines(previousCode, outcome.code),
|
|
526
|
+
...unknownAnsweringLines(answering, answeringOpen),
|
|
457
527
|
...getTimeAnchorLines(outcome.code),
|
|
458
528
|
]);
|
|
459
529
|
return { content: [{ type: 'text', text }] };
|
|
@@ -464,7 +534,7 @@ export function createMcpServer(options) {
|
|
|
464
534
|
// A failed set_code doesn't clear a pending hand edit, so keep surfacing it.
|
|
465
535
|
const notices = session.wasEditedByHand() ? [HAND_EDIT_NOTE] : [];
|
|
466
536
|
const message = err instanceof Error ? err.message : String(err);
|
|
467
|
-
throw new Error(withNoticeLines(message, [...notices, ...lines]));
|
|
537
|
+
throw new Error(withNoticeLines(message, [...notices, ...lines, ...unknownAnsweringLines(answering, answeringOpen)]));
|
|
468
538
|
}
|
|
469
539
|
});
|
|
470
540
|
// Changes part of the code that will play by exact replacements, through the same Session path as
|
|
@@ -502,20 +572,35 @@ export function createMcpServer(options) {
|
|
|
502
572
|
.max(MAX_PHRASE_CYCLES)
|
|
503
573
|
.optional()
|
|
504
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),
|
|
505
577
|
},
|
|
506
|
-
}, 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);
|
|
507
581
|
try {
|
|
508
582
|
// The same rule and text as set_code, before anything reaches the page.
|
|
509
583
|
if (phraseCycles !== undefined && at !== 'phrase') {
|
|
510
584
|
throw new Error("phraseCycles applies only with at: 'phrase'.");
|
|
511
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
|
+
}
|
|
512
590
|
// Nothing plays before a start, so there is nothing to edit: refuse before a tab opens for a request that cannot work.
|
|
513
591
|
if (starter.current() === null) {
|
|
514
592
|
throw new Error(EDIT_NOTHING_PLAYING_MESSAGE);
|
|
515
593
|
}
|
|
516
594
|
await starter.ensureStarted();
|
|
517
595
|
await starter.waitForPage();
|
|
518
|
-
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
|
+
});
|
|
519
604
|
const { outcome } = edit;
|
|
520
605
|
const { lines, hadRuntimeErrors } = session.drainNotices();
|
|
521
606
|
const status = changeStatus(outcome, at, hadRuntimeErrors, session.getPageUrl());
|
|
@@ -535,6 +620,7 @@ export function createMcpServer(options) {
|
|
|
535
620
|
...otherText,
|
|
536
621
|
...lines,
|
|
537
622
|
...whenFlagLines(outcome.code),
|
|
623
|
+
...unknownAnsweringLines(answering, answeringOpen),
|
|
538
624
|
...getTimeAnchorLines(outcome.code),
|
|
539
625
|
]);
|
|
540
626
|
return { content: [{ type: 'text', text }] };
|
|
@@ -543,7 +629,7 @@ export function createMcpServer(options) {
|
|
|
543
629
|
const { lines } = session.drainNotices();
|
|
544
630
|
const notices = session.wasEditedByHand() ? [HAND_EDIT_NOTE] : [];
|
|
545
631
|
const message = err instanceof Error ? err.message : String(err);
|
|
546
|
-
throw new Error(withNoticeLines(message, [...notices, ...lines]));
|
|
632
|
+
throw new Error(withNoticeLines(message, [...notices, ...lines, ...unknownAnsweringLines(answering, answeringOpen)]));
|
|
547
633
|
}
|
|
548
634
|
});
|
|
549
635
|
server.registerTool('list_history', {
|
|
@@ -558,6 +644,7 @@ export function createMcpServer(options) {
|
|
|
558
644
|
favorites: z.boolean().optional().describe('true to list only favorites'),
|
|
559
645
|
},
|
|
560
646
|
}, async ({ limit, filter, favorites }) => {
|
|
647
|
+
session.noteToolCall();
|
|
561
648
|
try {
|
|
562
649
|
const text = session.listHistory({ limit, filter, favoritesOnly: favorites });
|
|
563
650
|
const { lines } = session.drainNotices();
|
|
@@ -580,6 +667,7 @@ export function createMcpServer(options) {
|
|
|
580
667
|
.describe("Name to keep it under, in the user's words"),
|
|
581
668
|
},
|
|
582
669
|
}, async ({ id, name }) => {
|
|
670
|
+
session.noteToolCall();
|
|
583
671
|
try {
|
|
584
672
|
const text = session.favoriteVersion(id, name);
|
|
585
673
|
const { lines } = session.drainNotices();
|
|
@@ -595,14 +683,17 @@ export function createMcpServer(options) {
|
|
|
595
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.',
|
|
596
684
|
inputSchema: {
|
|
597
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),
|
|
598
687
|
},
|
|
599
|
-
}, async ({ id }) => {
|
|
688
|
+
}, async ({ id, answering }) => {
|
|
689
|
+
session.noteToolCall();
|
|
690
|
+
const answeringOpen = answering === undefined ? true : session.requestIsOpen(answering);
|
|
600
691
|
try {
|
|
601
692
|
// The history needs no page: an id that cannot be restored fails before Moontide starts or a tab opens.
|
|
602
693
|
session.assertVersionExists(id);
|
|
603
694
|
await starter.ensureStarted();
|
|
604
695
|
await starter.waitForPage();
|
|
605
|
-
const outcome = await session.restoreVersion(id);
|
|
696
|
+
const outcome = await session.restoreVersion(id, { answering: answeringOpen ? answering : undefined });
|
|
606
697
|
const { lines, hadRuntimeErrors } = session.drainNotices();
|
|
607
698
|
// versionId is null only on a history-write failure — the write-failure
|
|
608
699
|
// note (queued by the same recordConfirmed path set_code uses) explains why below.
|
|
@@ -612,7 +703,12 @@ export function createMcpServer(options) {
|
|
|
612
703
|
const status = outcome.audio === 'playing' && !hadRuntimeErrors
|
|
613
704
|
? `${head} — now playing.`
|
|
614
705
|
: `${head}. ${playStatus(outcome.audio, hadRuntimeErrors, session.getPageUrl())}`;
|
|
615
|
-
|
|
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
|
+
: [];
|
|
616
712
|
const notices = outcome.replacedHandEdit ? [HAND_EDIT_REPLACED_NOTE] : [];
|
|
617
713
|
// The same t/f flag note and getTime() warn note as set_code, the getTime() note last.
|
|
618
714
|
const text = withNoticeLines(`${status}\n\n${outcome.code}`, [
|
|
@@ -620,6 +716,7 @@ export function createMcpServer(options) {
|
|
|
620
716
|
...notices,
|
|
621
717
|
...lines,
|
|
622
718
|
...whenFlagLines(outcome.code),
|
|
719
|
+
...unknownAnsweringLines(answering, answeringOpen),
|
|
623
720
|
...getTimeAnchorLines(outcome.code),
|
|
624
721
|
]);
|
|
625
722
|
return { content: [{ type: 'text', text }] };
|
|
@@ -631,13 +728,36 @@ export function createMcpServer(options) {
|
|
|
631
728
|
const { lines } = session.drainNotices();
|
|
632
729
|
const notices = session.wasEditedByHand() ? [HAND_EDIT_NOTE] : [];
|
|
633
730
|
const message = err instanceof Error ? err.message : String(err);
|
|
634
|
-
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));
|
|
635
754
|
}
|
|
636
755
|
});
|
|
637
756
|
server.registerTool('stop', {
|
|
638
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.',
|
|
639
758
|
annotations: { destructiveHint: false, idempotentHint: true },
|
|
640
759
|
}, async () => {
|
|
760
|
+
session.noteToolCall();
|
|
641
761
|
if (starter.current() === null) {
|
|
642
762
|
throw new Error(NOT_STARTED_MESSAGE);
|
|
643
763
|
}
|
|
@@ -665,6 +785,7 @@ export function createMcpServer(options) {
|
|
|
665
785
|
instrument: z.string().min(1).max(32).optional().describe('An instrument id from instruments.json, to list its sounds'),
|
|
666
786
|
},
|
|
667
787
|
}, async ({ instrument }) => {
|
|
788
|
+
session.noteToolCall();
|
|
668
789
|
try {
|
|
669
790
|
const text = session.instrumentsReply(instrument);
|
|
670
791
|
const { lines } = session.drainNotices();
|
|
@@ -685,6 +806,7 @@ export function createMcpServer(options) {
|
|
|
685
806
|
category: z.string().max(60).optional().describe('Only sounds of this category; case and spaces do not matter'),
|
|
686
807
|
},
|
|
687
808
|
}, async ({ instrument, query, category }) => {
|
|
809
|
+
session.noteToolCall();
|
|
688
810
|
try {
|
|
689
811
|
const text = session.searchMidiSounds({ instrument, query, category });
|
|
690
812
|
const { lines } = session.drainNotices();
|
|
@@ -704,6 +826,7 @@ export function createMcpServer(options) {
|
|
|
704
826
|
overwrite: z.boolean().optional().describe('true to replace an existing user model with the same id'),
|
|
705
827
|
},
|
|
706
828
|
}, async ({ model, confirmed, overwrite }) => {
|
|
829
|
+
session.noteToolCall();
|
|
707
830
|
try {
|
|
708
831
|
const text = session.saveMidiModel({ model, confirmed, overwrite });
|
|
709
832
|
const { lines } = session.drainNotices();
|
|
@@ -752,6 +875,7 @@ export function createMcpServer(options) {
|
|
|
752
875
|
.describe('The sounds of this part, grouped by bank and category; at most 500 sounds in the call'),
|
|
753
876
|
},
|
|
754
877
|
}, async ({ model, confirmed, append, more, source, programNumbering, overwrite, banks }) => {
|
|
878
|
+
session.noteToolCall();
|
|
755
879
|
try {
|
|
756
880
|
const text = session.saveMidiCatalog({ model, confirmed, append, more, source, programNumbering, overwrite, banks });
|
|
757
881
|
const { lines } = session.drainNotices();
|
|
@@ -800,6 +924,7 @@ export function createMcpServer(options) {
|
|
|
800
924
|
state: z.enum(['on', 'off']).describe(PAGE_REQUESTS_STATE_DESCRIPTION),
|
|
801
925
|
},
|
|
802
926
|
}, async ({ state }) => {
|
|
927
|
+
session.noteToolCall();
|
|
803
928
|
const result = switchPageRequests({ session, state });
|
|
804
929
|
if ('noPage' in result) {
|
|
805
930
|
throw new Error(starter.current() === null ? notStartedForRequestsToolText() : noPageForRequestsToolText(session.getPageUrl()));
|
|
@@ -826,6 +951,7 @@ export function createMcpServer(options) {
|
|
|
826
951
|
description: START_TOOL_DESCRIPTION,
|
|
827
952
|
annotations: { destructiveHint: false, idempotentHint: true },
|
|
828
953
|
}, async () => {
|
|
954
|
+
session.noteToolCall();
|
|
829
955
|
const text = await startMoontide(starter);
|
|
830
956
|
return { content: [{ type: 'text', text }] };
|
|
831
957
|
});
|