moontide-mcp 2.1.0 → 2.4.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.
Files changed (57) hide show
  1. package/README.md +54 -331
  2. package/dist/ask.js +59 -12
  3. package/dist/catalog.js +118 -9
  4. package/dist/instruments.js +83 -9
  5. package/dist/mcp.js +146 -20
  6. package/dist/midi-state.js +43 -1
  7. package/dist/protocol.js +123 -5
  8. package/dist/reference.js +12 -0
  9. package/dist/request-status.js +221 -0
  10. package/dist/session.js +203 -7
  11. package/dist/vocabulary.js +420 -0
  12. package/package.json +3 -2
  13. package/page/ask-view.js +118 -50
  14. package/page/ask.js +139 -169
  15. package/page/bar-model.js +44 -2
  16. package/page/change-timing.js +35 -0
  17. package/page/index.html +6 -2
  18. package/page/instruments-model.js +50 -3
  19. package/page/instruments-view.js +14 -2
  20. package/page/jam-analysis.js +68 -23
  21. package/page/jam-model.js +185 -29
  22. package/page/jam-resolve.js +271 -0
  23. package/page/jam-view.js +165 -10
  24. package/page/lights.js +29 -7
  25. package/page/main.js +807 -66
  26. package/page/midi-clock.js +22 -3
  27. package/page/midi-in.js +407 -0
  28. package/page/midi-out.js +557 -48
  29. package/page/midi-plan.js +42 -6
  30. package/page/midi-rig.js +286 -13
  31. package/page/midi-settings.js +507 -0
  32. package/page/mixer-view.js +7 -1
  33. package/page/note-panel.js +29 -8
  34. package/page/note-tag.js +19 -4
  35. package/page/page-rewrite.js +1 -1
  36. package/page/played-buffer.js +208 -0
  37. package/page/query-check.js +21 -2
  38. package/page/request-status-view.js +419 -0
  39. package/page/request-status.js +686 -0
  40. package/page/scratch-gesture.js +239 -0
  41. package/page/scratch-listen.js +260 -0
  42. package/page/scratch-model.js +1696 -0
  43. package/page/scratch-view.js +2176 -0
  44. package/page/song-grid-model.js +15 -0
  45. package/page/song-grid-view.js +1 -1
  46. package/page/structure.js +2 -1
  47. package/page/style.css +1243 -43
  48. package/page/tempo-glide.js +330 -0
  49. package/page/tempo-guard.js +62 -10
  50. package/page/timeline-edit-view.js +1 -1
  51. package/page/timeline.js +2 -1
  52. package/page/top-bar-view.js +18 -13
  53. package/page/views.js +20 -7
  54. package/reference/midi.md +2 -0
  55. package/reference/motif.md +83 -0
  56. package/reference/settings.md +161 -0
  57. package/reference/transitions.md +13 -1
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. Anchored at a line start with [ \t]* (never \s*, so a run of blank
81
- // lines cannot make the scan quadratic) and ended on a word boundary, so `const songs`, `const song2`, `let song` and a
82
- // commented-out line are not a structure. The m flag only: no global flag, so test() keeps no lastIndex.
83
- const SONG_CONST_RE = /^[ \t]*const[ \t]+song\b/m;
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 (SONG_CONST_RE.test(code))
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
- const tempo = outcome.tempoChanged && outcome.bpm !== null ? [tempoLine(outcome.bpm, outcome.pending?.cycle)] : [];
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, { label, at, phraseCycles });
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, { base, label, at, phraseCycles });
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
- const tempo = outcome.tempoChanged && outcome.bpm !== null ? [tempoLine(outcome.bpm)] : [];
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
  }
@@ -659,12 +779,13 @@ export function createMcpServer(options) {
659
779
  }
660
780
  });
661
781
  server.registerTool('get_midi_instruments', {
662
- description: "When the user has described their instruments, call this before writing MIDI code. Lists the MIDI instruments the user described in instruments.json, as facts: for each one its id, model, exact port name, whether it is connected right now, its channels and their roles, how many sounds it has, its bank select, its reset, whether it follows MIDI clock and its controls. Pass instrument (an id from that list) to get that instrument's full sound list, with the program number sent for each sound and its bank when it has one. A sound is chosen in the code with the sound option of .midi(). An instrument with a sound catalog shows how many sounds the catalog holds and its categories; search_midi_sounds finds them. For an instrument with a rhythm channel it says how a kit goes out and which drum map applies; with instrument set it lists each drum map as note numbers and drum names, a kit's own map only when it differs from the model's, and says when a kit has no known map. Drum notes are written as note numbers in the code. It also says which instruments the user switched off in the page: their notes are silent, and .midi() code newly aimed at them is refused until the user switches them on.",
782
+ description: "When the user has described their instruments, call this before writing MIDI code. Lists the MIDI instruments the user described in instruments.json, as facts: for each one its id, model, exact port name, whether it is connected right now, its channels and their roles, how many sounds it has, its bank select, its reset, whether it follows MIDI clock and its controls. Pass instrument (an id from that list) to get that instrument's full sound list, with the program number sent for each sound and its bank when it has one. A sound is chosen in the code with the sound option of .midi(). An instrument with a sound catalog shows how many sounds the catalog holds and its categories; search_midi_sounds finds them. For an instrument whose model describes settings it lists the tone, part and scene setting names with their ranges, the tone ones per engine, and the settings topic of get_strudel_reference has their rules; with instrument set it gives the engine of each sound the catalog places in one. For an instrument with a rhythm channel it says how a kit goes out and which drum map applies; with instrument set it lists each drum map as note numbers and drum names, a kit's own map only when it differs from the model's, and says when a kit has no known map. Drum notes are written as note numbers in the code. It also says which instruments the user switched off in the page: their notes are silent, and .midi() code newly aimed at them is refused until the user switches them on.",
663
783
  annotations: { readOnlyHint: true },
664
784
  inputSchema: {
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();
@@ -677,7 +798,7 @@ export function createMcpServer(options) {
677
798
  }
678
799
  });
679
800
  server.registerTool('search_midi_sounds', {
680
- description: "Searches the sound catalog of one instrument: a list of the model's sounds kept in the user's data folder, built from a patch-name file or a sound list the user owns. It finds sounds by words in the name or the category, in any order, with case and spaces ignored. It returns at most 25 matches: the exact name first, then names that start with the words, then names that contain them, then matches in the category only. Each match gives the name as the sound option of .midi() takes it, the program as sent (0-127), the bank when there is one and the category. It says how many more matched. A drum kit is marked (drum kit) and gives its drum map, or says the map is unknown.",
801
+ description: "Searches the sound catalog of one instrument: a list of the model's sounds kept in the user's data folder, built from a patch-name file or a sound list the user owns. It finds sounds by words in the name or the category, in any order, with case and spaces ignored. It returns at most 25 matches: the exact name first, then names that start with the words, then names that contain them, then matches in the category only. Each match gives the name as the sound option of .midi() takes it, the program as sent (0-127), the bank when there is one, the category and, for an instrument whose model declares engines, the engine of the sound or that it is in no engine. It says how many more matched. A drum kit is marked (drum kit) and gives its drum map, or says the map is unknown.",
681
802
  annotations: { readOnlyHint: true },
682
803
  inputSchema: {
683
804
  instrument: z.string().min(1).max(32).describe('An instrument id from instruments.json whose model has a sound catalog'),
@@ -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();
@@ -697,13 +819,14 @@ export function createMcpServer(options) {
697
819
  }
698
820
  });
699
821
  server.registerTool('save_midi_model', {
700
- description: "Saves an instrument model that you drafted from a page of the instrument's manual the user pasted, into the user's Moontide data folder (models/<id>.json), never into Moontide itself. First call it with confirmed: false: it checks the model and returns a summary (channels, sounds with their program numbers as sent and as numbered in a manual from 1, controls, reset bytes, notes) and saves nothing. Show that summary to the user, and call it again with the same model and confirmed: true only after they say yes. It refuses an id of a model Moontide ships, and an existing model unless overwrite is true. Fields: id (lowercase letters, digits, dashes or underscores, at most 32), name, channels { melodic, rhythm } (lists of channels 1-16), bankSelect (none, msb or msb-lsb), rhythmBank (optional: true when the instrument chooses drum kits by bank select and program change; every rhythm sound then needs a bank), drumMaps (optional, at most 8: [{ id, name, notes: [{ note 0-127, name }] }] typed from the instrument's manual), drumMap (optional: the id of the map of kits without their own; general-midi is built in), a rhythm sound's noteMap (optional: a map id, or null when no map is known for that kit), defaultVolume (0-127), clock (optional: true when the instrument follows MIDI clock, so Moontide sends it the clock, Start and Stop), reset (optional: sysex with at most 4 messages, each { kind: roland-dt1, model, address, data } as hex bytes or { kind: raw, bytes } from F0 to F7 and at most 32 bytes, and waitMs), controls ([{ id, name, cc, min, max, default }]), sounds { melodic, rhythm } ([{ name, program 0-127 as sent, bank { msb, lsb } when the instrument needs one }]) and notes (facts about the instrument, at most 10).",
822
+ description: "Saves an instrument model that you drafted from a page of the instrument's manual the user pasted, into the user's Moontide data folder (models/<id>.json), never into Moontide itself. First call it with confirmed: false: it checks the model and returns a summary (channels, sounds with their program numbers as sent and as numbered in a manual from 1, controls, reset bytes, notes) and saves nothing. Show that summary to the user, and call it again with the same model and confirmed: true only after they say yes. It refuses an id of a model Moontide ships, and an existing model unless overwrite is true. Fields: id (lowercase letters, digits, dashes or underscores, at most 32), name, channels { melodic, rhythm } (lists of channels 1-16), bankSelect (none, msb or msb-lsb), rhythmBank (optional: true when the instrument chooses drum kits by bank select and program change; every rhythm sound then needs a bank), drumMaps (optional, at most 8: [{ id, name, notes: [{ note 0-127, name }] }] typed from the instrument's manual), drumMap (optional: the id of the map of kits without their own; general-midi is built in), a rhythm sound's noteMap (optional: a map id, or null when no map is known for that kit), defaultVolume (0-127), clock (optional: true when the instrument follows MIDI clock, so Moontide sends it the clock, Start and Stop), monophonic (optional: true when the instrument plays one note at a time), reset (optional: sysex with at most 4 messages, each { kind: roland-dt1, model, address, data } as hex bytes or { kind: raw, bytes } from F0 to F7 and at most 32 bytes, and waitMs), controls ([{ id, name, cc, min, max, default }]), sounds { melodic, rhythm } ([{ name, program 0-127 as sent, bank { msb, lsb } when the instrument needs one }]) and notes (facts about the instrument, at most 10).",
701
823
  inputSchema: {
702
824
  model: z.record(z.string(), z.unknown()).describe('The drafted model, as a JSON object'),
703
825
  confirmed: z.boolean().describe('false to check and summarize without saving; true only after the user said yes to the summary'),
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
  });
@@ -104,6 +104,21 @@ export function midiFailedLine(failure) {
104
104
  const reason = failure === null ? '' : ` (${quoteMidiName(failure)})`;
105
105
  return `MIDI: Web MIDI could not start${reason}, so tracks using .midi() are silent. Other tracks keep playing.`;
106
106
  }
107
+ /** Setting names joined by ', ': the schema holds each to a letter and then letters and digits, and cleaning keeps it so. */
108
+ function settingNameList(names) {
109
+ return names.map(cleanMidiName).join(', ');
110
+ }
111
+ /** The words of each reason the memory guard gives (src/protocol.ts lists them). */
112
+ const GUARD_REASON_WORDS = {
113
+ 'not-bytes': 'it is not a list of numbers, so it cannot be checked',
114
+ 'too-long': 'it is too long to be checked',
115
+ incomplete: 'it is not a complete system exclusive message',
116
+ 'not-data-set': 'it is not a Roland data set (command 12) for the model of the instrument',
117
+ 'unknown-areas': 'Moontide does not know which addresses of this instrument are temporary',
118
+ outside: 'it would write outside the temporary scene and tones',
119
+ checksum: 'its checksum does not match',
120
+ nrpn: 'it is not one of the NRPN part offsets Moontide knows for this instrument',
121
+ };
107
122
  /**
108
123
  * The line of one event the page sent: a reset of an instrument, a sound change that may
109
124
  * cut a note, a sound kept after a failed evaluation, a send that failed, and clock or transport the
@@ -129,9 +144,36 @@ export function midiEventLine(event) {
129
144
  }
130
145
  case 'send-failed': {
131
146
  const id = quoteMidiName(event.instrument);
132
- const what = event.what === 'reset' ? 'the reset' : 'the sound setup';
147
+ const what = event.what === 'reset' ? 'the reset' : event.what === 'settings' ? 'the settings' : 'the sound setup';
133
148
  return `MIDI: ${what} could not be sent to instrument ${id} (${quoteMidiName(event.failure)}).`;
134
149
  }
150
+ case 'settings-scene': {
151
+ const id = quoteMidiName(event.instrument);
152
+ return `MIDI: the scene settings ${settingNameList(event.names)} of ${id} reach every part of the instrument, not only the channel of their .midi() call.`;
153
+ }
154
+ case 'settings-left': {
155
+ const id = quoteMidiName(event.instrument);
156
+ const names = settingNameList(event.names);
157
+ const nothing = 'are no longer in the code that plays. Nothing is sent for them, so the instrument keeps the last value';
158
+ if (event.level === 'scene')
159
+ return `MIDI: the scene settings ${names} of ${id} ${nothing} until the instrument itself changes it.`;
160
+ if (event.level === 'part')
161
+ return `MIDI: the part settings ${names} of ${id} channel ${event.channel} ${nothing} until the instrument itself changes it.`;
162
+ return `MIDI: the tone settings ${names} of ${id} channel ${event.channel} ${nothing} until it loads the tone again.`;
163
+ }
164
+ case 'sysex-refused': {
165
+ const id = quoteMidiName(event.instrument);
166
+ const unread = event.reason === 'not-bytes' || event.reason === 'too-long';
167
+ const what = event.source === 'reset'
168
+ ? `the reset of ${id} was`
169
+ : event.source === 'settings'
170
+ ? `the settings for ${id} were`
171
+ : `a ${unread ? 'MIDI' : 'system exclusive'} message from the code to ${id} was`;
172
+ const only = event.reason === 'nrpn'
173
+ ? 'only Roland data sets into its temporary scene and tones, and the NRPN part offsets it knows.'
174
+ : 'only Roland data sets into its temporary scene and tones.';
175
+ return `MIDI: ${what} not sent: ${GUARD_REASON_WORDS[event.reason]}. Moontide sends nothing to an instrument that could write to its memory, ${only}`;
176
+ }
135
177
  case 'clock-dropped':
136
178
  return MIDI_CLOCK_DROPPED_LINE;
137
179
  }