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/page/ask-view.js CHANGED
@@ -1,52 +1,54 @@
1
1
  // The Ask Claude box's DOM: the small box a right-click opens near the pointer, its
2
- // chips, its field, its message line, the top-bar request status and the top-bar page-requests
2
+ // chips, its field, its message line and the top-bar page-requests
3
3
  // status (whether page requests reach Claude live) with its (i) button and the
4
4
  // short popover the (i) opens, and a refused page token on the top-bar error line as soon as the
5
5
  // server says so. Every text reaches the page through
6
6
  // textContent, title, placeholder or setAttribute, never as markup: the chips carry names and code
7
7
  // written by a person or by Claude. No DOM access at import time: main.js calls createAskView() with
8
- // its document and a send function. The pure rules (copy, limits, placement, status timing) live in
9
- // page/ask.js.
8
+ // its document and a send function. The pure rules (copy, limits, placement) live in page/ask.js. What became of
9
+ // each request is the Requests list's business (page/request-status-view.js): this module hands every request and
10
+ // every answer to the request tracker, and shows only the line under the Parts button.
10
11
 
11
12
  import {
12
13
  ASK_COPY,
13
14
  BOX_GAP_REM,
14
15
  MAX_ASK_TEXT_LENGTH,
16
+ MAX_MOTIF_PHRASE_LENGTH,
17
+ MAX_REQUEST_LINE_BYTES,
15
18
  PAGE_REQUESTS_COPY,
16
19
  pageRequestsText,
17
20
  askMessage,
18
21
  askTextProblem,
19
- createAskStatus,
20
22
  cutText,
21
23
  describeContext,
22
24
  fitGestureToLine,
23
25
  fitMomentToLine,
26
+ motifChip,
24
27
  newAskId,
25
28
  partsAskState,
26
29
  placeBox,
27
30
  refusalText,
28
31
  requestFitsLine,
32
+ requestLineBytes,
29
33
  } from './ask.js';
30
34
 
31
35
  /**
32
36
  * Creates the box controller. `document` is the page's document (named so on purpose: every element
33
37
  * lookup below reads `document.getElementById`, the shape test/page-hidden.test.js scans); `send`
34
38
  * sends one JSON-able message to the server; `reportError` and `clearError` write and clear the
35
- * top-bar error line; `setTimer`, `clearTimer` and `now` are the page's clock; `fit` is the top bar's fit step, run
36
- * after the status changes because a new text changes the bar's width (a ResizeObserver does not fire for content
37
- * growing inside a full bar).
39
+ * top-bar error line; `setTimer`, `clearTimer` and `now` are the page's clock. `requests` is the per-request tracker
40
+ * of page/request-status.js: every request sent is handed to it, and so is every server answer to one. The tracker
41
+ * tells the page (main.js) when a request was never confirmed; the page then calls `notConfirmed(id)` here. `onMotifResult` is
42
+ * called with what became of the Scratchpad's motif request (`{ status: 'received' }` once Claude has it, `{ status: 'waiting' }` once the
43
+ * server holds it for Claude's next call); the motif request has its own pending id, so nothing it meets ever reaches the Ask box or its draft.
38
44
  */
39
- export function createAskView({ document, send, reportError, clearError, setTimer, clearTimer, now, fit }) {
45
+ export function createAskView({ document, send, reportError, clearError, setTimer, clearTimer, now, requests, onMotifResult = () => {} }) {
40
46
  const askBox = document.getElementById('ask-box');
41
47
  const askContextChip = document.getElementById('ask-context-chip');
42
48
  const askCurrentChip = document.getElementById('ask-current-chip');
43
49
  const askField = document.getElementById('ask-field');
44
50
  const askMessageEl = document.getElementById('ask-message');
45
51
  const askHint = document.getElementById('ask-hint');
46
- const askStatus = document.getElementById('ask-status');
47
- // The status holds its long sentence and a short form (shown at fit step 3, aria-hidden).
48
- const askLong = askStatus === null ? null : askStatus.querySelector('.ask-long');
49
- const askShort = askStatus === null ? null : askStatus.querySelector('.ask-short');
50
52
  const partsAsk = document.getElementById('parts-ask');
51
53
  const partsAskStatus = document.getElementById('parts-ask-status');
52
54
  const pageRequests = document.getElementById('page-requests');
@@ -60,9 +62,6 @@ export function createAskView({ document, send, reportError, clearError, setTime
60
62
  !askField ||
61
63
  !askMessageEl ||
62
64
  !askHint ||
63
- !askStatus ||
64
- !askLong ||
65
- !askShort ||
66
65
  !partsAsk ||
67
66
  !partsAskStatus ||
68
67
  !pageRequests ||
@@ -72,8 +71,8 @@ export function createAskView({ document, send, reportError, clearError, setTime
72
71
  ) {
73
72
  throw new Error('Moontide: the Ask Claude markup is missing from the page.');
74
73
  }
75
- if (typeof fit !== 'function') {
76
- throw new Error('Moontide: createAskView needs the top bar\'s fit function.');
74
+ if (requests === undefined || typeof requests.sent !== 'function' || typeof requests.serverMessage !== 'function') {
75
+ throw new Error('Moontide: createAskView needs the request tracker.');
77
76
  }
78
77
 
79
78
  // The field's description lists the message line only while it shows, so it is never empty.
@@ -139,36 +138,12 @@ export function createAskView({ document, send, reportError, clearError, setTime
139
138
  // The side the box opened on (above the pointer or below), kept while its height changes; null
140
139
  // means choose again.
141
140
  let placedAbove = null;
142
- // The Parts view's button: whether the code has no sections (so the button shows), the id of its
143
- // request while it waits for Claude to take it, and the top-bar status text now on screen.
141
+ // The Parts view's button: whether the code has no sections (so the button shows) and the id of its
142
+ // request while it waits for Claude to take it.
144
143
  let partsAvailable = false;
145
144
  let partsPendingId = null;
146
- let statusText = null;
147
-
148
- const status = createAskStatus({
149
- render: (text, short) => {
150
- statusText = text;
151
- askLong.textContent = text ?? '';
152
- askShort.textContent = short ?? '';
153
- askStatus.hidden = text === null;
154
- fit();
155
- renderPartsAsk();
156
- },
157
- onTimeout: (id) => {
158
- reportError(ASK_COPY.didNotConfirm);
159
- if (id === partsPendingId) {
160
- // The canned request needs no draft: the button just comes back.
161
- partsPendingId = null;
162
- renderPartsAsk();
163
- return;
164
- }
165
- notSent = true;
166
- renderMessage();
167
- },
168
- setTimer,
169
- clearTimer,
170
- now,
171
- });
145
+ // The Scratchpad's motif request, the same way: the id of the one on its way, so its refusal never arms the Ask box.
146
+ let motifPendingId = null;
172
147
 
173
148
  /** The fields of the request the box would send for `text` (trimmed), the id measured with the next one's shape. */
174
149
  function requestFields(text) {
@@ -202,6 +177,14 @@ export function createAskView({ document, send, reportError, clearError, setTime
202
177
  refit();
203
178
  }
204
179
 
180
+ /** The state phrase of the Parts request's row in the Requests list, or null when it has no row. */
181
+ function partsRowPhrase() {
182
+ if (partsPendingId === null) return null;
183
+ const model = requests.model();
184
+ const row = [...model.rows, ...model.recent].find((candidate) => candidate.id === partsPendingId);
185
+ return row === undefined ? null : row.phrase;
186
+ }
187
+
205
188
  /** The Parts button and the line under it, from partsAskState. */
206
189
  function renderPartsAsk() {
207
190
  partsAsk.hidden = !partsAvailable;
@@ -210,7 +193,7 @@ export function createAskView({ document, send, reportError, clearError, setTime
210
193
  partsAskStatus.hidden = true;
211
194
  return;
212
195
  }
213
- const state = partsAskState({ connected, pending: partsPendingId !== null, statusText });
196
+ const state = partsAskState({ connected, pending: partsPendingId !== null, statusText: partsRowPhrase() });
214
197
  partsAsk.disabled = state.disabled;
215
198
  partsAskStatus.textContent = state.line ?? '';
216
199
  partsAskStatus.hidden = state.line === null;
@@ -344,7 +327,7 @@ export function createAskView({ document, send, reportError, clearError, setTime
344
327
  send(frame);
345
328
  // While the token is refused the line stays: the server's 'token' refusal writes it again, so it never blinks off.
346
329
  if (!authRefused) clearError();
347
- status.sent(id);
330
+ requests.sent(id, { text, chip: describedContext.chip });
348
331
  return id;
349
332
  }
350
333
 
@@ -455,6 +438,46 @@ export function createAskView({ document, send, reportError, clearError, setTime
455
438
  renderPartsAsk();
456
439
  });
457
440
 
441
+ /**
442
+ * Sends the Scratchpad's motif as a page request: `words` are the user's own (trimmed) and `motif` is the motif field of
443
+ * page/scratch-model.js's motifRequest. The request has its own pending id, goes to the tracker like any other and never
444
+ * touches the Ask box. Returns `{ id }` once it is sent, or `{ refused: word, ...numbers }` and sends nothing: 'disconnected',
445
+ * 'pending' (the last motif request has not reached the server: the server has not answered it yet), 'empty-words', 'words-too-long' (`length`, `max`) or 'too-big'
446
+ * (`bytes` and `max`, measured on the exact frame, id included; a phrase over MAX_MOTIF_PHRASE_LENGTH characters is refused the same way). The
447
+ * phrase is never cut: a motif that does not fit is refused whole.
448
+ */
449
+ function sendMotif({ words, motif }) {
450
+ if (!connected) return { refused: 'disconnected' };
451
+ if (motifPendingId !== null) return { refused: 'pending' };
452
+ const text = words.trim();
453
+ if (text === '') return { refused: 'empty-words' };
454
+ if (text.length > MAX_ASK_TEXT_LENGTH) return { refused: 'words-too-long', length: text.length, max: MAX_ASK_TEXT_LENGTH };
455
+ const id = newAskId(counter + 1, now());
456
+ // A phrase longer than the server takes is refused like any motif too big for the line, before describeContext (which throws on it)
457
+ // checks the motif. The bytes are those of the line it would make; the rest of the request always puts that line over its bound.
458
+ if (motif !== null && typeof motif === 'object' && typeof motif.phrase === 'string' && motif.phrase.length > MAX_MOTIF_PHRASE_LENGTH) {
459
+ const bytes = requestLineBytes({ id, text, context: { kind: 'motif' }, chip: motifChip(motif), current: currentVersion, motif });
460
+ return { refused: 'too-big', bytes, max: MAX_REQUEST_LINE_BYTES };
461
+ }
462
+ const described = describeContext({ kind: 'motif', motif });
463
+ const frame = { type: 'ask', id, text, context: described.context, chip: described.chip, current: currentVersion, motif: described.motif };
464
+ const bytes = requestLineBytes(frame);
465
+ if (bytes > MAX_REQUEST_LINE_BYTES) return { refused: 'too-big', bytes, max: MAX_REQUEST_LINE_BYTES };
466
+ counter += 1;
467
+ send(frame);
468
+ // While the token is refused the line stays: the server's 'token' refusal writes it again, so it never blinks off.
469
+ if (!authRefused) clearError();
470
+ requests.sent(frame.id, { text, chip: described.chip });
471
+ motifPendingId = frame.id;
472
+ return { id: frame.id };
473
+ }
474
+
475
+ /** The server has the motif request: it is no longer on its way, and the page is told. */
476
+ function arrivedMotif() {
477
+ motifPendingId = null;
478
+ onMotifResult({ status: 'received' });
479
+ }
480
+
458
481
  /** The server's answers: token result, one result per request, and hand-overs of waiting requests. */
459
482
  function onServerMessage(msg) {
460
483
  if (msg.type === 'ask-auth') {
@@ -478,13 +501,28 @@ export function createAskView({ document, send, reportError, clearError, setTime
478
501
  return;
479
502
  }
480
503
  if (msg.type === 'ask-received') {
481
- status.handedOver(msg.ids);
504
+ requests.serverMessage(msg);
482
505
  if (partsPendingId !== null && msg.ids.includes(partsPendingId)) partsPendingId = null;
506
+ if (motifPendingId !== null && msg.ids.includes(motifPendingId)) arrivedMotif();
483
507
  renderPartsAsk();
484
508
  return;
485
509
  }
486
510
  if (msg.type !== 'ask-result') return;
487
- status.answered(msg);
511
+ requests.serverMessage(msg);
512
+ if (msg.id === motifPendingId) {
513
+ // The Scratchpad's request is answered in its own strip: nothing of it reaches the Ask box, its draft or its not-sent message.
514
+ if (msg.status === 'received') {
515
+ arrivedMotif();
516
+ } else if (msg.status === 'waiting') {
517
+ // The server holds it until Claude's next Moontide call: it is no longer on its way, so the next motif may go, and the strip says it waits.
518
+ motifPendingId = null;
519
+ onMotifResult({ status: 'waiting' });
520
+ } else if (msg.status === 'refused') {
521
+ motifPendingId = null;
522
+ onMotifResult({ status: 'refused', text: refusalText(msg.reason, msg.detail) });
523
+ }
524
+ return;
525
+ }
488
526
  const fromParts = msg.id === partsPendingId;
489
527
  if (msg.status === 'refused') {
490
528
  reportError(refusalText(msg.reason, msg.detail));
@@ -505,11 +543,33 @@ export function createAskView({ document, send, reportError, clearError, setTime
505
543
  renderPartsAsk();
506
544
  }
507
545
 
546
+ /**
547
+ * The tracker gave up on request `id`: the server never confirmed it, or the connection closed first. The error line
548
+ * says so, and the draft comes back (or, for the Parts button's canned request, the button is released).
549
+ */
550
+ function notConfirmed(id) {
551
+ if (id === motifPendingId) {
552
+ // The Scratchpad's strip says it; the Ask box and its draft are not involved.
553
+ motifPendingId = null;
554
+ onMotifResult({ status: 'not-confirmed' });
555
+ return;
556
+ }
557
+ reportError(ASK_COPY.didNotConfirm);
558
+ if (id === partsPendingId) {
559
+ // The canned request needs no draft: the button just comes back.
560
+ partsPendingId = null;
561
+ renderPartsAsk();
562
+ return;
563
+ }
564
+ notSent = true;
565
+ renderMessage();
566
+ }
567
+
508
568
  function setConnected(state) {
509
569
  connected = state === 'connected';
510
570
  if (state === 'disconnected') {
511
- status.dropAll();
512
571
  partsPendingId = null;
572
+ motifPendingId = null;
513
573
  // The state is unknown once the socket is gone (the page does not reconnect by itself).
514
574
  pageRequests.hidden = true;
515
575
  closeHelp();
@@ -541,7 +601,15 @@ export function createAskView({ document, send, reportError, clearError, setTime
541
601
  contains: (node) => askBox.contains(node),
542
602
  setConnected,
543
603
  setCurrentVersion,
604
+ sendMotif,
605
+ /** True while a motif request is on its way to the server. */
606
+ motifPending: () => motifPendingId !== null,
607
+ /** True while the page is connected to the server. */
608
+ isConnected: () => connected,
544
609
  setPartsAvailable,
545
610
  onServerMessage,
611
+ notConfirmed,
612
+ /** Draws the line under the Parts button again from its request's row; the page calls it after every tracker change. */
613
+ refreshPartsLine: () => renderPartsAsk(),
546
614
  };
547
615
  }