moontide-mcp 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -2
- package/dist/ask.js +47 -8
- package/dist/mcp.js +143 -17
- package/dist/protocol.js +43 -2
- package/dist/request-status.js +221 -0
- package/dist/session.js +203 -7
- package/package.json +1 -1
- package/page/ask-view.js +43 -50
- package/page/ask.js +82 -165
- package/page/bar-model.js +43 -2
- package/page/change-timing.js +35 -0
- package/page/index.html +4 -2
- package/page/jam-analysis.js +68 -23
- package/page/jam-model.js +185 -29
- package/page/jam-resolve.js +271 -0
- package/page/jam-view.js +165 -10
- package/page/lights.js +29 -7
- package/page/main.js +459 -59
- package/page/midi-clock.js +22 -3
- package/page/midi-out.js +5 -2
- package/page/mixer-view.js +7 -1
- package/page/note-panel.js +29 -8
- package/page/note-tag.js +19 -4
- package/page/page-rewrite.js +1 -1
- package/page/query-check.js +21 -2
- package/page/request-status-view.js +419 -0
- package/page/request-status.js +686 -0
- package/page/song-grid-model.js +15 -0
- package/page/song-grid-view.js +1 -1
- package/page/structure.js +2 -1
- package/page/style.css +389 -31
- package/page/tempo-glide.js +330 -0
- package/page/tempo-guard.js +62 -10
- package/page/timeline-edit-view.js +1 -1
- package/page/timeline.js +2 -1
- package/page/top-bar-view.js +18 -13
- package/page/views.js +15 -2
- package/reference/transitions.md +13 -1
package/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
|
-
|
|
199
|
-
|
|
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
|
+
}
|