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.
Files changed (57) hide show
  1. package/README.md +94 -6
  2. package/dist/ask.js +76 -15
  3. package/dist/mcp.js +198 -26
  4. package/dist/protocol.js +130 -2
  5. package/dist/reference.js +22 -0
  6. package/dist/request-status.js +221 -0
  7. package/dist/session.js +256 -7
  8. package/package.json +1 -1
  9. package/page/ask-view.js +64 -57
  10. package/page/ask.js +363 -188
  11. package/page/bar-model.js +43 -2
  12. package/page/change-timing.js +60 -4
  13. package/page/harmony.js +13 -0
  14. package/page/index.html +7 -4
  15. package/page/jam-analysis.js +68 -23
  16. package/page/jam-model.js +196 -31
  17. package/page/jam-resolve.js +271 -0
  18. package/page/jam-view.js +182 -10
  19. package/page/lights.js +300 -0
  20. package/page/main.js +2171 -188
  21. package/page/midi-clock.js +23 -4
  22. package/page/midi-out.js +17 -2
  23. package/page/mixer-groups.js +114 -0
  24. package/page/mixer-model.js +172 -29
  25. package/page/mixer-rewrite.js +30 -1
  26. package/page/mixer-view.js +289 -14
  27. package/page/note-edit.js +267 -0
  28. package/page/note-locate.js +346 -0
  29. package/page/note-panel.js +402 -0
  30. package/page/note-tag.js +141 -0
  31. package/page/page-rewrite.js +184 -0
  32. package/page/query-check.js +21 -2
  33. package/page/request-status-view.js +419 -0
  34. package/page/request-status.js +686 -0
  35. package/page/song-edit.js +230 -0
  36. package/page/song-fit.js +250 -0
  37. package/page/song-grid-model.js +506 -0
  38. package/page/song-grid-view.js +710 -0
  39. package/page/song-prefs.js +100 -0
  40. package/page/structure-check.js +216 -0
  41. package/page/structure-view.js +38 -0
  42. package/page/structure.js +761 -0
  43. package/page/style.css +1218 -65
  44. package/page/takeover.js +20 -5
  45. package/page/tempo-glide.js +330 -0
  46. package/page/tempo-guard.js +62 -10
  47. package/page/timeline-edit-view.js +1956 -0
  48. package/page/timeline-gesture.js +279 -0
  49. package/page/timeline.js +197 -3
  50. package/page/top-bar-view.js +18 -13
  51. package/page/tracks.js +37 -8
  52. package/page/ui-state.js +10 -5
  53. package/page/views.js +39 -9
  54. package/reference/functions.md +1 -0
  55. package/reference/midi.md +2 -0
  56. package/reference/structure.md +131 -0
  57. 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
- 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
+ : [];
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
- throw new Error(NOT_STARTED_MESSAGE);
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. Returns ' +
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
- const outcome = await session.evaluate(code, { label, at, phraseCycles });
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
- // The t/f flag note comes right before the getTime() note. The getTime() warn note, when
405
- // present, is always the last line.
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, { 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
+ });
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
- 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
+ : [];
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
  });