moontide-mcp 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/protocol.js CHANGED
@@ -36,6 +36,12 @@ export const MAX_RIG_DRUM_NAME = 40;
36
36
  */
37
37
  export const MIN_PHRASE_CYCLES = 2;
38
38
  export const MAX_PHRASE_CYCLES = 64;
39
+ /**
40
+ * The shortest and longest tempo ramp, in whole bars (one bar is one cycle), that set_code and edit_code accept with
41
+ * glideBars. page/tempo-glide.js mirrors both by hand (test/protocol.test.js checks both sides).
42
+ */
43
+ export const MIN_GLIDE_BARS = 1;
44
+ export const MAX_GLIDE_BARS = 64;
39
45
  /** The largest tempo the page may report in BPM; a bigger number is not music and the schema refuses it. */
40
46
  export const MAX_REPORTED_BPM = 100000;
41
47
  /** The two scripts the page loads from unpkg.com; a load-failed report names the ones that did not load. */
@@ -73,6 +79,9 @@ export const MAX_REQUEST_LINE_BYTES = 8192;
73
79
  export const MOMENT_BEATS = 4;
74
80
  // The most characters in a request id (the page makes the id up so answers can be matched).
75
81
  export const MAX_ASK_ID_LENGTH = 64;
82
+ // The longest sentence Claude can close a page request with. The page shows it with an ellipsis in a
83
+ // row and whole in the detail.
84
+ export const MAX_ANSWER_TEXT_LENGTH = 200;
76
85
  // The kinds of thing a right-click can point at. The Jam view is
77
86
  // the source of 'note' (a pitch class with its role in the scale) and 'warning' (a chord that leaves it).
78
87
  export const ASK_CONTEXT_KINDS = [
@@ -195,11 +204,14 @@ const askContextSchema = z.discriminatedUnion('kind', [
195
204
  track: askName,
196
205
  from: z.number().int().nonnegative(),
197
206
  to: z.number().int().nonnegative(),
198
- section: askName.optional(),
199
- sectionTo: askName.optional(),
207
+ sections: z.array(askName).min(1).max(MAX_RANGE_CYCLES).optional(),
208
+ moreSections: z.number().int().min(1).max(MAX_RANGE_CYCLES - 1).optional(),
200
209
  })
201
210
  .refine((range) => range.to >= range.from && range.to - range.from < MAX_RANGE_CYCLES, {
202
211
  message: `a range covers 1 to ${MAX_RANGE_CYCLES} whole cycles, to not before from`,
212
+ })
213
+ .refine((range) => range.moreSections === undefined || range.sections !== undefined, {
214
+ message: 'moreSections comes only with sections',
203
215
  }),
204
216
  // `note` is a pitch class name, never an octave, string or fret; `role` and `scale` are both present or
205
217
  // both absent (the page enforces that), absent while the scale is not clear.
@@ -244,6 +256,26 @@ const pendingChangeSchema = z.object({
244
256
  cycle: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
245
257
  seconds: z.number().finite().nonnegative().max(MAX_PENDING_SECONDS),
246
258
  });
259
+ const glideBpmSchema = z.number().finite().positive().max(MAX_REPORTED_BPM);
260
+ // A discriminated union on `state` so a later state is one more member.
261
+ const glideReportSchema = z.discriminatedUnion('state', [
262
+ z.object({
263
+ state: z.literal('started'),
264
+ fromBpm: glideBpmSchema,
265
+ toBpm: glideBpmSchema,
266
+ bars: z.number().int().min(MIN_GLIDE_BARS).max(MAX_GLIDE_BARS),
267
+ }),
268
+ z.object({
269
+ state: z.literal('at-line'),
270
+ toBpm: glideBpmSchema,
271
+ bars: z.number().int().min(MIN_GLIDE_BARS).max(MAX_GLIDE_BARS),
272
+ }),
273
+ z.object({
274
+ state: z.literal('running'),
275
+ nowBpm: glideBpmSchema,
276
+ toBpm: glideBpmSchema,
277
+ }),
278
+ ]);
247
279
  // A plain z.union (not discriminatedUnion): 'runtime-error' has no 'ok' field, so the
248
280
  // single-key discriminant used for the two 'evaluated' variants can't cover it.
249
281
  const pageMessageSchema = z.union([
@@ -255,10 +287,12 @@ const pageMessageSchema = z.union([
255
287
  audio: audioStateSchema,
256
288
  pending: pendingChangeSchema.optional(),
257
289
  bpm: z.number().finite().positive().max(MAX_REPORTED_BPM).optional(),
290
+ glide: glideReportSchema.optional(),
258
291
  }),
259
292
  z.object({
260
293
  type: z.literal('takeover'),
261
294
  id: z.string().min(1).max(64),
295
+ glideRuns: z.literal(true).optional(),
262
296
  }),
263
297
  z.object({
264
298
  type: z.literal('takeover-canceled'),
@@ -272,6 +306,12 @@ const pageMessageSchema = z.union([
272
306
  code: z.string(),
273
307
  error: z.string(),
274
308
  line: z.number().int().positive().optional(),
309
+ glideRefused: z.literal(true).optional(),
310
+ }),
311
+ z.object({
312
+ type: z.literal('glide-ended'),
313
+ reason: z.enum(['finished', 'other-tempo', 'no-tempo', 'error']),
314
+ bpm: glideBpmSchema.optional(),
275
315
  }),
276
316
  z.object({
277
317
  type: z.literal('runtime-error'),
@@ -318,6 +358,7 @@ const pageMessageSchema = z.union([
318
358
  label: z.string().min(1).max(MAX_MIXER_LABEL_LENGTH),
319
359
  }),
320
360
  z.object({ type: z.literal('history-request') }),
361
+ z.object({ type: z.literal('ask-board-request') }),
321
362
  z.object({ type: z.literal('data-folder-request') }),
322
363
  z.object({ type: z.literal('take-over') }),
323
364
  z.object({ type: z.literal('history-restore'), id: z.number().int().positive() }),
@@ -0,0 +1,221 @@
1
+ // The request status board: what became of each request a page sent to Claude.
2
+ //
3
+ // The board holds one row per request the page sent, keyed by the id the page made up: the page that
4
+ // sent it, its state and the start of the text and the chip, kept in memory only so a reloaded page can
5
+ // show what it asked. Nothing is ever written to disk and the board holds at most MAX_BOARD_REQUESTS
6
+ // rows (the oldest finished row goes first, an open one only when every row is open). A state only
7
+ // moves forward: a repeat, a step back and any change after a final state are ignored, so the page is
8
+ // never told the same thing twice and never told something that undoes what it was told. A state
9
+ // reaches only the page that sent the request; once that page has gone the row keeps its state and
10
+ // nothing is sent.
11
+ //
12
+ // The page already learns "sent", "waiting" and "received" from the ask-result and ask-received
13
+ // messages. The board adds the states that come later and sends them as ask-status.
14
+ import { MAX_ASK_CONTEXT_LENGTH } from './protocol.js';
15
+ /** How many requests the board keeps; the oldest finished row is dropped first. */
16
+ export const MAX_BOARD_REQUESTS = 50;
17
+ // In the order a request moves through them. page/request-status.js mirrors this list by hand.
18
+ export const REQUEST_STATES = [
19
+ 'sent',
20
+ 'waiting',
21
+ 'received',
22
+ 'started',
23
+ 'line',
24
+ 'applied',
25
+ 'failed',
26
+ 'replaced',
27
+ 'canceled',
28
+ 'answered',
29
+ 'declined',
30
+ ];
31
+ /** A request in one of these is finished: nothing follows it. */
32
+ export const FINAL_REQUEST_STATES = [
33
+ 'applied',
34
+ 'failed',
35
+ 'replaced',
36
+ 'canceled',
37
+ 'answered',
38
+ 'declined',
39
+ ];
40
+ /** Said when a request is closed by its id and the id is not an open request. */
41
+ export const NOT_OPEN_REQUEST_MESSAGE = 'No open page request has this id: it is unknown or already closed. Nothing changed in the page.';
42
+ // Whitespace, C0 and C1 control characters, the line and paragraph separators and the bidirectional
43
+ // override and isolate characters: none of them belongs in a sentence the page shows to the user.
44
+ const ANSWER_NOISE = /[\s\u0000-\u001F\u007F-\u009F\u2028\u2029\u202A-\u202E\u2066-\u2069]+/gu;
45
+ /** The sentence Claude closes a request with, as the page shows it: each run of noise becomes one space. */
46
+ export function cleanAnswerText(text) {
47
+ return text.replace(ANSWER_NOISE, ' ').trim();
48
+ }
49
+ /** Where a state stands in the order: 0 to 4 for sent to line, 5 for every final state. */
50
+ export function requestRank(state) {
51
+ if (FINAL_REQUEST_STATES.includes(state)) {
52
+ return 5;
53
+ }
54
+ return REQUEST_STATES.indexOf(state);
55
+ }
56
+ /** The start of a request text: at most MAX_ASK_CONTEXT_LENGTH code points, an ellipsis when it was cut. */
57
+ function textStart(text) {
58
+ const points = Array.from(text);
59
+ return points.length <= MAX_ASK_CONTEXT_LENGTH ? text : `${points.slice(0, MAX_ASK_CONTEXT_LENGTH).join('')}\u2026`;
60
+ }
61
+ export class RequestStatusBoard {
62
+ send;
63
+ max;
64
+ now;
65
+ // Insertion order is arrival order, so the first key is the oldest request.
66
+ rows = new Map();
67
+ constructor(options) {
68
+ this.send = options.send;
69
+ this.max = options.max ?? MAX_BOARD_REQUESTS;
70
+ this.now = options.now ?? Date.now;
71
+ }
72
+ /** A request was accepted: it is sent to a listener, or queued until a tool reply. */
73
+ opened(id, origin, options) {
74
+ // The channel refuses an id the board holds before it gets here, so a row is never silently reset.
75
+ if (this.rows.has(id)) {
76
+ throw new Error(`The request id ${JSON.stringify(id)} is already on the request board`);
77
+ }
78
+ const at = this.now();
79
+ this.rows.set(id, {
80
+ origin,
81
+ state: options.queued ? 'waiting' : 'sent',
82
+ text: textStart(options.text),
83
+ chip: options.chip,
84
+ openedAt: at,
85
+ newsAt: at,
86
+ last: undefined,
87
+ });
88
+ // The bound caps memory, not live work: the oldest finished row goes first. Only when every row is
89
+ // still open does the oldest open row go.
90
+ while (this.rows.size > this.max) {
91
+ let victim;
92
+ for (const [rowId, row] of this.rows) {
93
+ if (FINAL_REQUEST_STATES.includes(row.state)) {
94
+ victim = rowId;
95
+ break;
96
+ }
97
+ }
98
+ victim ??= this.rows.keys().next().value;
99
+ if (victim === undefined)
100
+ break;
101
+ this.rows.delete(victim);
102
+ }
103
+ }
104
+ /** A request that was sent to a listener went back to the queue. */
105
+ waiting(id) {
106
+ this.advance(id, 'waiting');
107
+ }
108
+ /** Requests were handed to Claude, by a listener or in a tool reply. */
109
+ delivered(ids) {
110
+ for (const id of ids) {
111
+ this.advance(id, 'received');
112
+ }
113
+ }
114
+ /**
115
+ * A Moontide tool call began. Every request already handed to Claude is now being worked on: Claude
116
+ * made a call after the request arrived. A request handed over by this very call is still waiting to
117
+ * be marked, because the mark comes before the reply hands it over.
118
+ */
119
+ noteToolCall() {
120
+ for (const [id, row] of this.rows) {
121
+ if (row.state === 'received') {
122
+ this.advance(id, 'started', { type: 'ask-status', id, state: 'started' });
123
+ }
124
+ }
125
+ }
126
+ /** On the board, open or finished. */
127
+ has(id) {
128
+ return this.rows.has(id);
129
+ }
130
+ /** On the board and not finished. */
131
+ isOpen(id) {
132
+ const row = this.rows.get(id);
133
+ return row !== undefined && !FINAL_REQUEST_STATES.includes(row.state);
134
+ }
135
+ /** The change that answers this request is playing, as the history version it was recorded as. */
136
+ applied(id, version) {
137
+ this.advance(id, 'applied', { type: 'ask-status', id, state: 'applied', version });
138
+ }
139
+ /** A change that answers this request waits for its bar or phrase line. */
140
+ line(id, at) {
141
+ this.advance(id, 'line', { type: 'ask-status', id, state: 'line', at });
142
+ }
143
+ /** The change that answers this request could not be evaluated; the reason is already cleaned. */
144
+ failed(id, reason) {
145
+ this.advance(id, 'failed', { type: 'ask-status', id, state: 'failed', reason });
146
+ }
147
+ /** A newer change took the place of the waiting one that answered this request. */
148
+ replaced(id) {
149
+ this.advance(id, 'replaced', { type: 'ask-status', id, state: 'replaced' });
150
+ }
151
+ /** Stop, or the page leaving, ended the wait of the change that answered this request. */
152
+ canceled(id) {
153
+ this.advance(id, 'canceled', { type: 'ask-status', id, state: 'canceled' });
154
+ }
155
+ /**
156
+ * Claude closed this request in words, without a change to the music. Returns false when the id is not
157
+ * an open request, so the caller can refuse it loudly.
158
+ */
159
+ closed(id, outcome, answer) {
160
+ if (!this.isOpen(id)) {
161
+ return false;
162
+ }
163
+ this.advance(id, outcome, { type: 'ask-status', id, state: outcome, answer });
164
+ return true;
165
+ }
166
+ /**
167
+ * What a page that asks for the board is told: the requests whose page has gone and the ones it sent
168
+ * itself, oldest first. The gone ones now belong to it, so their next states reach it. A request of
169
+ * another page that is still connected is never listed.
170
+ */
171
+ snapshotFor(conn) {
172
+ const at = this.now();
173
+ const entries = [];
174
+ for (const [id, row] of this.rows) {
175
+ if (row.origin !== null && row.origin !== conn) {
176
+ continue;
177
+ }
178
+ row.origin = conn;
179
+ const entry = {
180
+ id,
181
+ text: row.text,
182
+ chip: row.chip,
183
+ state: row.state,
184
+ openedAgoMs: at - row.openedAt,
185
+ newsAgoMs: at - row.newsAt,
186
+ };
187
+ const last = row.last;
188
+ if (last?.state === 'applied')
189
+ entry.version = last.version;
190
+ if (last?.state === 'line')
191
+ entry.at = last.at;
192
+ if (last?.state === 'failed')
193
+ entry.reason = last.reason;
194
+ if (last?.state === 'answered' || last?.state === 'declined')
195
+ entry.answer = last.answer;
196
+ entries.push(entry);
197
+ }
198
+ return entries;
199
+ }
200
+ /** The page that sent these requests has gone: the rows keep their state and nothing more is sent. */
201
+ pageGone(conn) {
202
+ for (const row of this.rows.values()) {
203
+ if (row.origin === conn) {
204
+ row.origin = null;
205
+ }
206
+ }
207
+ }
208
+ // The one place a state changes. It does nothing for an unknown id or a state that is not higher.
209
+ advance(id, state, message) {
210
+ const row = this.rows.get(id);
211
+ if (row === undefined || requestRank(state) <= requestRank(row.state)) {
212
+ return;
213
+ }
214
+ row.state = state;
215
+ row.newsAt = this.now();
216
+ row.last = message;
217
+ if (message !== undefined && row.origin !== null) {
218
+ this.send(row.origin, message, 'the request status');
219
+ }
220
+ }
221
+ }