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.
- package/README.md +94 -6
- package/dist/ask.js +76 -15
- package/dist/mcp.js +198 -26
- package/dist/protocol.js +130 -2
- package/dist/reference.js +22 -0
- package/dist/request-status.js +221 -0
- package/dist/session.js +256 -7
- package/package.json +1 -1
- package/page/ask-view.js +64 -57
- package/page/ask.js +363 -188
- package/page/bar-model.js +43 -2
- package/page/change-timing.js +60 -4
- package/page/harmony.js +13 -0
- package/page/index.html +7 -4
- package/page/jam-analysis.js +68 -23
- package/page/jam-model.js +196 -31
- package/page/jam-resolve.js +271 -0
- package/page/jam-view.js +182 -10
- package/page/lights.js +300 -0
- package/page/main.js +2171 -188
- package/page/midi-clock.js +23 -4
- package/page/midi-out.js +17 -2
- package/page/mixer-groups.js +114 -0
- package/page/mixer-model.js +172 -29
- package/page/mixer-rewrite.js +30 -1
- package/page/mixer-view.js +289 -14
- package/page/note-edit.js +267 -0
- package/page/note-locate.js +346 -0
- package/page/note-panel.js +402 -0
- package/page/note-tag.js +141 -0
- package/page/page-rewrite.js +184 -0
- 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-edit.js +230 -0
- package/page/song-fit.js +250 -0
- package/page/song-grid-model.js +506 -0
- package/page/song-grid-view.js +710 -0
- package/page/song-prefs.js +100 -0
- package/page/structure-check.js +216 -0
- package/page/structure-view.js +38 -0
- package/page/structure.js +761 -0
- package/page/style.css +1218 -65
- package/page/takeover.js +20 -5
- package/page/tempo-glide.js +330 -0
- package/page/tempo-guard.js +62 -10
- package/page/timeline-edit-view.js +1956 -0
- package/page/timeline-gesture.js +279 -0
- package/page/timeline.js +197 -3
- package/page/top-bar-view.js +18 -13
- package/page/tracks.js +37 -8
- package/page/ui-state.js +10 -5
- package/page/views.js +39 -9
- package/reference/functions.md +1 -0
- package/reference/midi.md +2 -0
- package/reference/structure.md +131 -0
- package/reference/transitions.md +13 -1
package/dist/session.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { AskChannel, listenerScriptPath } from './ask.js';
|
|
3
|
+
import { NOT_OPEN_REQUEST_MESSAGE, RequestStatusBoard, cleanAnswerText } from './request-status.js';
|
|
3
4
|
import { MIDI_PERMISSION_WAIT_MESSAGE, MidiChannel } from './midi-state.js';
|
|
4
5
|
import { applyEdits } from './edit-code.js';
|
|
5
6
|
import { CatalogSaveError, MAX_CATALOG_DRAFTS, MAX_CATALOG_KITS, MAX_CATALOG_SOUNDS, MAX_PART_SOUNDS, catalogFileExists, countText, flattenCatalogPart, parseCatalog, problemsText, saveCatalog, summarizeCatalog, } from './catalog.js';
|
|
@@ -71,6 +72,14 @@ export function cleanErrorDetail(detail) {
|
|
|
71
72
|
}
|
|
72
73
|
return codePoints.slice(0, MAX_DETAIL_CODE_POINTS).join('') + '…';
|
|
73
74
|
}
|
|
75
|
+
/** The page's refusal text of a tempo ramp it could not make, trimmed and cut like any error detail but kept as a sentence. */
|
|
76
|
+
function cleanRefusal(text) {
|
|
77
|
+
const trimmed = text.trim();
|
|
78
|
+
if (trimmed === '')
|
|
79
|
+
return UNKNOWN_ERROR_DETAIL;
|
|
80
|
+
const codePoints = Array.from(trimmed);
|
|
81
|
+
return codePoints.length <= MAX_DETAIL_CODE_POINTS ? trimmed : codePoints.slice(0, MAX_DETAIL_CODE_POINTS).join('') + '…';
|
|
82
|
+
}
|
|
74
83
|
/**
|
|
75
84
|
* Builds the tool-response text for a failed evaluation: Strudel's message, the line when
|
|
76
85
|
* known, and a truthful note about what is (or isn't) still playing.
|
|
@@ -145,6 +154,24 @@ export function earlierRuntimeErrorLine(message, count, versionId = null) {
|
|
|
145
154
|
? `Runtime error from an earlier version (#${versionId}, not the code playing now, repeated ${count} times): ${message}.`
|
|
146
155
|
: `Runtime error from an earlier version (#${versionId}, not the code playing now): ${message}.`;
|
|
147
156
|
}
|
|
157
|
+
/** Every structure-note line in a reply starts with this, so no line of the page can start a reply line itself. */
|
|
158
|
+
export const STRUCTURE_NOTE_LEAD = 'Song structure';
|
|
159
|
+
/**
|
|
160
|
+
* Cleans one structure-note line from the page: every run of whitespace, control, format, line and paragraph
|
|
161
|
+
* separator characters becomes one space, then the text is cleaned like an error detail (trailing periods off, cut at
|
|
162
|
+
* MAX_DETAIL_CODE_POINTS code points). Returns null for a line with nothing left, which is dropped.
|
|
163
|
+
*/
|
|
164
|
+
export function cleanStructureLine(text) {
|
|
165
|
+
const collapsed = text.replace(/[\s\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').trim();
|
|
166
|
+
if (collapsed === '') {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
return cleanErrorDetail(collapsed);
|
|
170
|
+
}
|
|
171
|
+
/** One structure-note line as it appears in a reply: the fixed lead, the version when it is known, the cleaned sentence. */
|
|
172
|
+
export function structureNoteLine(line, versionId = null) {
|
|
173
|
+
return versionId === null ? `${STRUCTURE_NOTE_LEAD}: ${line}.` : `${STRUCTURE_NOTE_LEAD} in #${versionId}: ${line}.`;
|
|
174
|
+
}
|
|
148
175
|
/**
|
|
149
176
|
* The note Claude gets, once per distinct report, when a page could not load a script from unpkg.com. The fix is on
|
|
150
177
|
* the user's side, so the note says so: Claude cannot repair a blocked network.
|
|
@@ -171,6 +198,26 @@ export function takeoverCanceledLine(cycle, reason) {
|
|
|
171
198
|
}
|
|
172
199
|
return `${head} the page was closed, reloaded or replaced by another tab before then. get_code returns the code from before it.`;
|
|
173
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* The note Claude gets, once, when a tempo ramp ended without Claude asking for it: a program stated another tempo, a
|
|
203
|
+
* program stated no tempo, a step failed, or the page that ran it was closed, reloaded or replaced by another tab.
|
|
204
|
+
* Stop, a finished ramp and a replaced ramp say nothing. Only the cause (a fixed word) and a number come in: no
|
|
205
|
+
* page text. `bpm` is the tempo that plays after the end; the wording leaves it out when the page gave none.
|
|
206
|
+
*/
|
|
207
|
+
export function glideEndedLine(reason, bpm) {
|
|
208
|
+
const shown = bpm === undefined ? undefined : Number.isInteger(bpm) ? String(bpm) : bpm.toFixed(1);
|
|
209
|
+
const head = 'Note: the tempo ramp ended early:';
|
|
210
|
+
if (reason === 'other-tempo') {
|
|
211
|
+
return `${head} a program stated another tempo${shown === undefined ? '' : `, and ${shown} BPM plays now`}.`;
|
|
212
|
+
}
|
|
213
|
+
if (reason === 'no-tempo') {
|
|
214
|
+
return `${head} a program stated no tempo${shown === undefined ? '' : `, so the tempo stays at ${shown} BPM, where the ramp had got to`}.`;
|
|
215
|
+
}
|
|
216
|
+
if (reason === 'error') {
|
|
217
|
+
return `${head} a step failed in the page${shown === undefined ? '' : `, so the tempo stays at ${shown} BPM`}.`;
|
|
218
|
+
}
|
|
219
|
+
return `${head} the page was closed, reloaded or replaced by another tab, so the tempo of the code plays.`;
|
|
220
|
+
}
|
|
174
221
|
/**
|
|
175
222
|
* The note when a change that waited for its line took over and replaced code that had been edited by hand
|
|
176
223
|
* in the page. Said at the takeover, never at the request: until the line the hand edit is still what plays, and a
|
|
@@ -218,6 +265,9 @@ export class Session {
|
|
|
218
265
|
// already-counted overflow message, so it's just tallied — this only slightly over-counts
|
|
219
266
|
// the rare tail past MAX_RUNTIME_ERRORS + MAX_DROPPED_RUNTIME_ERRORS distinct messages.
|
|
220
267
|
droppedRuntimeErrorOverflowCount = 0;
|
|
268
|
+
// The latest structure note of the active page (see handleStructureNote): one slot, replaced by every note and
|
|
269
|
+
// emptied by the drain. The lines are already cleaned; the version id is looked up at drain time.
|
|
270
|
+
structureNote = null;
|
|
221
271
|
// True once a stop is confirmed by the page; cleared by the next
|
|
222
272
|
// successful evaluate() reply from the active page.
|
|
223
273
|
stopped = false;
|
|
@@ -240,6 +290,11 @@ export class Session {
|
|
|
240
290
|
pendingChange = null;
|
|
241
291
|
// The notes of waiting changes that did not take over, queued once and drained once, like historyNotes.
|
|
242
292
|
takeoverNotes = [];
|
|
293
|
+
// The tempo ramp the page runs, as far as the server knows: set when a reply says a ramp started (or the takeover of a
|
|
294
|
+
// change whose ramp starts at its line), cleared by Stop, a page end and a page loss. Null when none runs.
|
|
295
|
+
glide = null;
|
|
296
|
+
// The notes of a ramp that ended early, queued once and drained once, like takeoverNotes.
|
|
297
|
+
glideNotes = [];
|
|
243
298
|
// The notes of load failure reports a page sent, one per distinct text (at most three), drained once.
|
|
244
299
|
loadFailureNotes = new Set();
|
|
245
300
|
// BPM: the tempo (rounded to one decimal) the last set_code or restore_version reply stated; null before any.
|
|
@@ -259,6 +314,8 @@ export class Session {
|
|
|
259
314
|
supersededPages = new Set();
|
|
260
315
|
/** The page-to-Claude channel (tokens, trust, limits, queue, listener). */
|
|
261
316
|
ask;
|
|
317
|
+
/** What became of each page request: started, waiting for its line, applied and the other states. */
|
|
318
|
+
requests;
|
|
262
319
|
/** The MIDI facts of the active page, worded once per change into notice lines. */
|
|
263
320
|
midi;
|
|
264
321
|
// The setup file's store (null without one), the JSON of the last midi-rig each page was
|
|
@@ -290,12 +347,36 @@ export class Session {
|
|
|
290
347
|
this.rig = options?.rig ?? null;
|
|
291
348
|
this.build = options?.build ?? null;
|
|
292
349
|
this.recordIntentTtlMs = options?.recordIntentTtlMs ?? RECORD_INTENT_TTL_MS;
|
|
350
|
+
const send = (conn, msg, what) => this.sendToPage(conn, msg, what);
|
|
351
|
+
this.requests = new RequestStatusBoard({ send });
|
|
293
352
|
this.ask = new AskChannel({
|
|
294
|
-
send
|
|
353
|
+
send,
|
|
354
|
+
board: this.requests,
|
|
295
355
|
ackTimeoutMs: options?.askAckTimeoutMs,
|
|
296
356
|
});
|
|
297
357
|
this.midi = new MidiChannel();
|
|
298
358
|
}
|
|
359
|
+
/** A Moontide tool call began: the requests already handed to Claude are being worked on. */
|
|
360
|
+
noteToolCall() {
|
|
361
|
+
this.requests.noteToolCall();
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Claude closes an open page request in words. The sentence is cleaned first; one that is empty after
|
|
365
|
+
* cleaning, and an id that is not an open request, are refused with a plain error and change nothing.
|
|
366
|
+
*/
|
|
367
|
+
answerRequest(id, outcome, text) {
|
|
368
|
+
const sentence = cleanAnswerText(text);
|
|
369
|
+
if (sentence === '') {
|
|
370
|
+
throw new Error('The sentence is empty after removing spaces and control characters; give one sentence of words.');
|
|
371
|
+
}
|
|
372
|
+
if (!this.requests.closed(id, outcome, sentence)) {
|
|
373
|
+
throw new Error(NOT_OPEN_REQUEST_MESSAGE);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/** Whether a page request with this id is on the board and not finished. */
|
|
377
|
+
requestIsOpen(id) {
|
|
378
|
+
return this.requests.isOpen(id);
|
|
379
|
+
}
|
|
299
380
|
setPageUrl(url) {
|
|
300
381
|
this.pageUrl = url;
|
|
301
382
|
}
|
|
@@ -341,6 +422,42 @@ export class Session {
|
|
|
341
422
|
return;
|
|
342
423
|
this.pendingChange = null;
|
|
343
424
|
this.takeoverNotes.push(takeoverCanceledLine(waiting.cycle, reason));
|
|
425
|
+
const answering = waiting.intent?.answering;
|
|
426
|
+
if (answering === undefined)
|
|
427
|
+
return;
|
|
428
|
+
if (reason !== 'replaced') {
|
|
429
|
+
this.requests.canceled(answering);
|
|
430
|
+
}
|
|
431
|
+
else if (!this.answeringStillCarried(answering, waiting.id)) {
|
|
432
|
+
this.requests.replaced(answering);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* The ramp ended. Clears the record and queues its one note for the next tool reply, except for a finished ramp and
|
|
437
|
+
* for Stop, which are not told. A no-op when no ramp is recorded, so a second cause (a cancel, then a closing page)
|
|
438
|
+
* never adds a second line.
|
|
439
|
+
*/
|
|
440
|
+
dropGlide(reason, bpm) {
|
|
441
|
+
if (this.glide === null)
|
|
442
|
+
return;
|
|
443
|
+
this.glide = null;
|
|
444
|
+
if (reason === 'finished' || reason === 'stop')
|
|
445
|
+
return;
|
|
446
|
+
this.glideNotes.push(glideEndedLine(reason, bpm));
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Whether a change other than this one still in flight answers the same request. A newer change that
|
|
450
|
+
* names the request carries its answer on, so the request must not end as replaced while that answer is
|
|
451
|
+
* still coming: the newer change settles it (applied, failed or replaced in its turn).
|
|
452
|
+
*/
|
|
453
|
+
answeringStillCarried(answering, exceptEvaluateId) {
|
|
454
|
+
for (const [evaluateId, entry] of this.pending) {
|
|
455
|
+
if (entry.kind === 'evaluate' && evaluateId !== exceptEvaluateId) {
|
|
456
|
+
if (this.recordIntents.get(evaluateId)?.answering === answering)
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return false;
|
|
344
461
|
}
|
|
345
462
|
/**
|
|
346
463
|
* Keeps the change the page accepted for its line. One at most (last wins): a change that is
|
|
@@ -348,16 +465,32 @@ export class Session {
|
|
|
348
465
|
* itself (takeover-canceled 'replaced').
|
|
349
466
|
*/
|
|
350
467
|
keepWaitingChange(change) {
|
|
351
|
-
|
|
352
|
-
|
|
468
|
+
const replaced = this.pendingChange;
|
|
469
|
+
if (replaced !== null && replaced.id !== change.id) {
|
|
470
|
+
console.error(`[moontide] A waiting change (${replaced.id}) was replaced by request ${change.id} without a cancel message`);
|
|
471
|
+
// The same rule as a cancel message: the request ends as replaced unless the new change answers it too.
|
|
472
|
+
const answering = replaced.intent?.answering;
|
|
473
|
+
if (answering !== undefined && answering !== change.intent?.answering) {
|
|
474
|
+
this.requests.replaced(answering);
|
|
475
|
+
}
|
|
353
476
|
}
|
|
354
477
|
this.pendingChange = change;
|
|
478
|
+
if (change.intent?.answering !== undefined) {
|
|
479
|
+
this.requests.line(change.intent.answering, change.at === 'phrase' ? 'phrase' : 'bar');
|
|
480
|
+
}
|
|
355
481
|
}
|
|
356
482
|
/**
|
|
357
483
|
* BPM: rounds the tempo of a reply to one decimal and says whether it differs from the last tempo a reply
|
|
358
484
|
* stated. A reply without a tempo leaves the last told tempo alone.
|
|
359
485
|
*/
|
|
360
|
-
noteTempo(bpm) {
|
|
486
|
+
noteTempo(bpm, glide) {
|
|
487
|
+
// A reply that states a ramp says the ramp, not a tempo change: the ramp's target is the tempo told from here on, so the
|
|
488
|
+
// next reply never announces it as a change when the ramp gets there.
|
|
489
|
+
if (glide !== undefined) {
|
|
490
|
+
const target = Math.round(glide.toBpm * 10) / 10;
|
|
491
|
+
this.lastToldBpm = target;
|
|
492
|
+
return { bpm: bpm === undefined ? null : Math.round(bpm * 10) / 10, tempoChanged: false };
|
|
493
|
+
}
|
|
361
494
|
if (bpm === undefined)
|
|
362
495
|
return { bpm: null, tempoChanged: false };
|
|
363
496
|
const rounded = Math.round(bpm * 10) / 10;
|
|
@@ -383,6 +516,7 @@ export class Session {
|
|
|
383
516
|
// A change that waits does not survive a page taking the role: the page that held its gate is
|
|
384
517
|
// gone or replays its code, so Claude is told once (the line is queued once however many causes meet).
|
|
385
518
|
this.dropWaitingChange('page');
|
|
519
|
+
this.dropGlide('page');
|
|
386
520
|
this.supersededPages.delete(conn);
|
|
387
521
|
if (previous !== null && previous !== conn) {
|
|
388
522
|
this.supersededPages.add(previous);
|
|
@@ -437,6 +571,7 @@ export class Session {
|
|
|
437
571
|
if (this.activePage === conn) {
|
|
438
572
|
this.activePage = null;
|
|
439
573
|
this.dropWaitingChange('page');
|
|
574
|
+
this.dropGlide('page');
|
|
440
575
|
this.midi.clear();
|
|
441
576
|
this.lastMidiFacts = null;
|
|
442
577
|
// A superseded tab says "playing in another tab": tell it that no tab plays any more, or it
|
|
@@ -545,6 +680,10 @@ export class Session {
|
|
|
545
680
|
this.handleRuntimeError(conn, msg);
|
|
546
681
|
return;
|
|
547
682
|
}
|
|
683
|
+
if (msg.type === 'structure-note') {
|
|
684
|
+
this.handleStructureNote(conn, msg);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
548
687
|
if (msg.type === 'stop-result') {
|
|
549
688
|
this.handleStopResult(conn, msg);
|
|
550
689
|
return;
|
|
@@ -557,6 +696,10 @@ export class Session {
|
|
|
557
696
|
this.handleTakeoverCanceled(conn, msg);
|
|
558
697
|
return;
|
|
559
698
|
}
|
|
699
|
+
if (msg.type === 'glide-ended') {
|
|
700
|
+
this.handleGlideEnded(conn, msg);
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
560
703
|
if (msg.type === 'user-stopped') {
|
|
561
704
|
this.handleUserStopped(conn);
|
|
562
705
|
return;
|
|
@@ -598,6 +741,13 @@ export class Session {
|
|
|
598
741
|
this.ask.handleAsk(conn, msg);
|
|
599
742
|
return;
|
|
600
743
|
}
|
|
744
|
+
if (msg.type === 'ask-board-request') {
|
|
745
|
+
const requests = this.requests.snapshotFor(conn);
|
|
746
|
+
// The board told the page where each request stands; the hand-over messages that follow reach it too.
|
|
747
|
+
this.ask.adoptOrigin(conn, requests.map((row) => row.id));
|
|
748
|
+
this.sendToPage(conn, { type: 'ask-board', requests }, 'the request board');
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
601
751
|
if (msg.type === 'history-request') {
|
|
602
752
|
this.handleHistoryRequest(conn);
|
|
603
753
|
return;
|
|
@@ -1140,6 +1290,8 @@ export class Session {
|
|
|
1140
1290
|
return;
|
|
1141
1291
|
}
|
|
1142
1292
|
this.stopped = true;
|
|
1293
|
+
// Stop ends a ramp too; it is the user's own act, so Claude is not told.
|
|
1294
|
+
this.dropGlide('stop');
|
|
1143
1295
|
// A stop the page reports ends a wait too; the page's takeover-canceled for the same id (sent
|
|
1144
1296
|
// just before) already queued the line, and a second cause adds nothing.
|
|
1145
1297
|
this.dropWaitingChange('stop');
|
|
@@ -1161,6 +1313,7 @@ export class Session {
|
|
|
1161
1313
|
cycle: msg.pending.cycle,
|
|
1162
1314
|
at: lateIntent?.at ?? 'bar',
|
|
1163
1315
|
intent: lateIntent,
|
|
1316
|
+
...(msg.glide?.state === 'at-line' ? { glide: msg.glide } : {}),
|
|
1164
1317
|
});
|
|
1165
1318
|
console.error(`[moontide] Late pending confirmation kept for request ${msg.id}`);
|
|
1166
1319
|
}
|
|
@@ -1183,6 +1336,15 @@ export class Session {
|
|
|
1183
1336
|
this.handFailureLine = null;
|
|
1184
1337
|
const lateVersionId = this.recordConfirmed(conn, msg.code, lateOrigin, lateIntent);
|
|
1185
1338
|
this.rememberVersion(msg.id, lateVersionId);
|
|
1339
|
+
// A reply whose audio is stopped records no ramp: Stop ends every ramp, so none runs.
|
|
1340
|
+
// An at-line report on a reply with no pending line is a waiting change that already took over: its ramp started at its line.
|
|
1341
|
+
if ((msg.glide?.state === 'started' || msg.glide?.state === 'at-line') && msg.audio !== 'stopped') {
|
|
1342
|
+
this.glide = { toBpm: msg.glide.toBpm };
|
|
1343
|
+
}
|
|
1344
|
+
// This branch does not pass through confirmSuccess, so it tells the page itself.
|
|
1345
|
+
if (lateIntent?.answering !== undefined) {
|
|
1346
|
+
this.requests.applied(lateIntent.answering, lateVersionId);
|
|
1347
|
+
}
|
|
1186
1348
|
console.error(`[moontide] Late confirmation stored for request ${msg.id}`);
|
|
1187
1349
|
}
|
|
1188
1350
|
else if (msg.ok) {
|
|
@@ -1216,6 +1378,7 @@ export class Session {
|
|
|
1216
1378
|
cycle: msg.pending.cycle,
|
|
1217
1379
|
at: intent?.at ?? 'bar',
|
|
1218
1380
|
intent,
|
|
1381
|
+
...(msg.glide?.state === 'at-line' ? { glide: msg.glide } : {}),
|
|
1219
1382
|
});
|
|
1220
1383
|
entry.resolve({
|
|
1221
1384
|
code: msg.code,
|
|
@@ -1224,17 +1387,25 @@ export class Session {
|
|
|
1224
1387
|
replacedHandEdit: false,
|
|
1225
1388
|
versionId: null,
|
|
1226
1389
|
pending: msg.pending,
|
|
1227
|
-
...this.noteTempo(msg.bpm),
|
|
1390
|
+
...this.noteTempo(msg.bpm, msg.glide),
|
|
1391
|
+
...(msg.glide !== undefined ? { glide: msg.glide } : {}),
|
|
1228
1392
|
});
|
|
1229
1393
|
}
|
|
1230
1394
|
else {
|
|
1231
1395
|
const { replacedHandEdit, versionId } = this.confirmSuccess(conn, msg.id, msg.code, msg.audio, intent);
|
|
1396
|
+
// A reply whose audio is stopped states and records no ramp: Stop ends every ramp, so none runs.
|
|
1397
|
+
const glide = msg.audio === 'stopped' ? undefined : msg.glide;
|
|
1398
|
+
// A ramp this change started is the one the server tells about when it ends early; a running report leaves the record.
|
|
1399
|
+
// An at-line report here is a waiting change that already took over when it was answered: its ramp started at its line.
|
|
1400
|
+
if (glide?.state === 'started' || glide?.state === 'at-line')
|
|
1401
|
+
this.glide = { toBpm: glide.toBpm };
|
|
1232
1402
|
entry.resolve({
|
|
1233
1403
|
code: msg.code,
|
|
1234
1404
|
audio: msg.audio,
|
|
1235
1405
|
replacedHandEdit,
|
|
1236
1406
|
versionId,
|
|
1237
|
-
...this.noteTempo(msg.bpm),
|
|
1407
|
+
...this.noteTempo(msg.bpm, glide),
|
|
1408
|
+
...(glide !== undefined ? { glide } : {}),
|
|
1238
1409
|
});
|
|
1239
1410
|
}
|
|
1240
1411
|
}
|
|
@@ -1249,7 +1420,24 @@ export class Session {
|
|
|
1249
1420
|
}
|
|
1250
1421
|
}
|
|
1251
1422
|
else {
|
|
1423
|
+
const failedIntent = this.recordIntents.get(msg.id);
|
|
1252
1424
|
this.recordIntents.delete(msg.id);
|
|
1425
|
+
const failedFor = failedIntent?.answering;
|
|
1426
|
+
// The same rule as a replaced change: an earlier change for the same request that still waits for
|
|
1427
|
+
// its line, or is still on its way, carries the answer on and settles the request in its turn.
|
|
1428
|
+
// A ramp the page refused was refused whole and nothing changed, so the request stays open for the change Claude sends
|
|
1429
|
+
// next (without glideBars), which can still answer it.
|
|
1430
|
+
if (msg.glideRefused !== true &&
|
|
1431
|
+
failedFor !== undefined &&
|
|
1432
|
+
this.pendingChange?.intent?.answering !== failedFor &&
|
|
1433
|
+
!this.answeringStillCarried(failedFor, msg.id)) {
|
|
1434
|
+
this.requests.failed(failedFor, cleanErrorDetail(msg.error));
|
|
1435
|
+
}
|
|
1436
|
+
if (msg.glideRefused === true) {
|
|
1437
|
+
// The page's own refusal text, whole: it already says that nothing was changed, so no evaluation-failure tail is added.
|
|
1438
|
+
entry.reject(new Error(cleanRefusal(msg.error)));
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1253
1441
|
const tail = this.code === '' ? NOTHING_PLAYING_TAIL : this.stopped ? STILL_STOPPED_TAIL : STILL_PLAYING_TAIL;
|
|
1254
1442
|
entry.reject(new Error(evalFailedMessage(msg.error, msg.line, tail)));
|
|
1255
1443
|
}
|
|
@@ -1274,12 +1462,17 @@ export class Session {
|
|
|
1274
1462
|
this.handFailureLine = null;
|
|
1275
1463
|
const versionId = this.recordConfirmed(conn, code, origin, intent);
|
|
1276
1464
|
this.rememberVersion(id, versionId);
|
|
1465
|
+
if (intent?.answering !== undefined) {
|
|
1466
|
+
this.requests.applied(intent.answering, versionId);
|
|
1467
|
+
}
|
|
1277
1468
|
return { replacedHandEdit, versionId };
|
|
1278
1469
|
}
|
|
1279
1470
|
/**
|
|
1280
1471
|
* The page says the waiting change is audible now. Accepted only from the active page and only for
|
|
1281
1472
|
* the id of the change that waits; anything else is one stderr line and changes nothing. The message carries
|
|
1282
|
-
*
|
|
1473
|
+
* the id and, when the ramp the waiting change started at its line still runs, `glideRuns: true`; the recorded code
|
|
1474
|
+
* is the one stored from the pending reply, and the ramp record is set from `glideRuns` (or cleared without it) for a
|
|
1475
|
+
* change that asked for a ramp.
|
|
1283
1476
|
*/
|
|
1284
1477
|
handleTakeover(conn, msg) {
|
|
1285
1478
|
if (conn !== this.activePage) {
|
|
@@ -1293,6 +1486,11 @@ export class Session {
|
|
|
1293
1486
|
}
|
|
1294
1487
|
this.pendingChange = null;
|
|
1295
1488
|
const { replacedHandEdit } = this.confirmSuccess(conn, waiting.id, waiting.code, 'playing', waiting.intent);
|
|
1489
|
+
// A change that asked for a ramp at its line ended any older ramp there. Its own ramp is the recorded ramp from here on only
|
|
1490
|
+
// when the page says it still runs; otherwise none runs (nothing to ramp between, or it already ended) and the record is
|
|
1491
|
+
// cleared with no line. A change replaced or canceled before its line never gets here.
|
|
1492
|
+
if (waiting.glide !== undefined)
|
|
1493
|
+
this.glide = msg.glideRuns === true ? { toBpm: waiting.glide.toBpm } : null;
|
|
1296
1494
|
// An edit that carried a hand edit replaced nothing of it: the Mixer and Parts refuse while a change
|
|
1297
1495
|
// waits and a hand evaluation cancels the wait, so the code playing at this takeover is still the hand-edited
|
|
1298
1496
|
// text the edit was built on, and the note would be false.
|
|
@@ -1317,6 +1515,17 @@ export class Session {
|
|
|
1317
1515
|
}
|
|
1318
1516
|
this.dropWaitingChange(msg.reason);
|
|
1319
1517
|
}
|
|
1518
|
+
/**
|
|
1519
|
+
* The page says its tempo ramp ended on its own. Only the active page is trusted; the numbers are bounded by the
|
|
1520
|
+
* protocol schema and the wording is the server's.
|
|
1521
|
+
*/
|
|
1522
|
+
handleGlideEnded(conn, msg) {
|
|
1523
|
+
if (conn !== this.activePage) {
|
|
1524
|
+
console.error('[moontide] Ignoring glide-ended from a non-active page');
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
this.dropGlide(msg.reason, msg.bpm === undefined ? undefined : Math.round(msg.bpm * 10) / 10);
|
|
1528
|
+
}
|
|
1320
1529
|
/**
|
|
1321
1530
|
* The editedByHand flag after a confirmed evaluation: Claude's code makes the stored code
|
|
1322
1531
|
* Claude's again (false). A change made from the page (origin 'page') is the user's:
|
|
@@ -1484,6 +1693,7 @@ export class Session {
|
|
|
1484
1693
|
if (conn === this.activePage) {
|
|
1485
1694
|
// A late stop confirmation still reflects reality — the page did stop.
|
|
1486
1695
|
this.stopped = true;
|
|
1696
|
+
this.dropGlide('stop');
|
|
1487
1697
|
this.dropWaitingChange('stop');
|
|
1488
1698
|
console.error(`[moontide] Late stop confirmation stored for request ${msg.id}`);
|
|
1489
1699
|
}
|
|
@@ -1501,6 +1711,7 @@ export class Session {
|
|
|
1501
1711
|
this.pending.delete(msg.id);
|
|
1502
1712
|
if (conn === this.activePage) {
|
|
1503
1713
|
this.stopped = true;
|
|
1714
|
+
this.dropGlide('stop');
|
|
1504
1715
|
this.dropWaitingChange('stop');
|
|
1505
1716
|
entry.resolve(undefined);
|
|
1506
1717
|
}
|
|
@@ -1549,6 +1760,26 @@ export class Session {
|
|
|
1549
1760
|
this.droppedRuntimeErrorOverflowCount++;
|
|
1550
1761
|
}
|
|
1551
1762
|
}
|
|
1763
|
+
/**
|
|
1764
|
+
* What the page read from the song structure of the code that plays. Accepted only from the active page, like a
|
|
1765
|
+
* runtime error. The latest note replaces the earlier one (an empty list clears it); every line is cleaned here and
|
|
1766
|
+
* the drain puts a fixed lead and the version in front of it. A note is a fact about the structure, never a runtime
|
|
1767
|
+
* error, so it never changes the status of a reply.
|
|
1768
|
+
*/
|
|
1769
|
+
handleStructureNote(conn, msg) {
|
|
1770
|
+
if (conn !== this.activePage) {
|
|
1771
|
+
console.error('[moontide] Ignoring a structure note from a non-active page');
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
const lines = [];
|
|
1775
|
+
for (const raw of msg.lines) {
|
|
1776
|
+
const cleaned = cleanStructureLine(raw);
|
|
1777
|
+
if (cleaned !== null) {
|
|
1778
|
+
lines.push(cleaned);
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
this.structureNote = { tag: msg.evaluateId ?? null, lines };
|
|
1782
|
+
}
|
|
1552
1783
|
/**
|
|
1553
1784
|
* The MIDI facts of the page. Used only from the active page, like a runtime error:
|
|
1554
1785
|
* a stale page's facts do not describe what is playing.
|
|
@@ -1811,6 +2042,14 @@ export class Session {
|
|
|
1811
2042
|
? 'Note: 1 more distinct runtime error was not shown.'
|
|
1812
2043
|
: `Note: ${n} more distinct runtime errors were not shown.`);
|
|
1813
2044
|
}
|
|
2045
|
+
if (this.structureNote !== null) {
|
|
2046
|
+
const { tag, lines: noteLines } = this.structureNote;
|
|
2047
|
+
const versionId = tag !== null ? this.versionByEvaluateId.get(tag) ?? null : null;
|
|
2048
|
+
for (const line of noteLines) {
|
|
2049
|
+
lines.push(structureNoteLine(line, versionId));
|
|
2050
|
+
}
|
|
2051
|
+
this.structureNote = null;
|
|
2052
|
+
}
|
|
1814
2053
|
this.runtimeErrors.clear();
|
|
1815
2054
|
this.droppedRuntimeErrors.clear();
|
|
1816
2055
|
this.droppedRuntimeErrorOverflowCount = 0;
|
|
@@ -1827,6 +2066,9 @@ export class Session {
|
|
|
1827
2066
|
// A change that waited for a line and did not take over, told once.
|
|
1828
2067
|
lines.push(...this.takeoverNotes);
|
|
1829
2068
|
this.takeoverNotes.length = 0;
|
|
2069
|
+
// A tempo ramp that ended early, told once.
|
|
2070
|
+
lines.push(...this.glideNotes);
|
|
2071
|
+
this.glideNotes.length = 0;
|
|
1830
2072
|
// The build line comes before the setup file lines, then the MIDI lines, then the ask
|
|
1831
2073
|
// lines (the arming line stays last). Runtime error lines and the history notes come earlier.
|
|
1832
2074
|
lines.push(...this.buildNotes);
|
|
@@ -1896,6 +2138,8 @@ export class Session {
|
|
|
1896
2138
|
force: false,
|
|
1897
2139
|
at: options?.at,
|
|
1898
2140
|
phraseCycles: options?.phraseCycles,
|
|
2141
|
+
glideBars: options?.glideBars,
|
|
2142
|
+
answering: options?.answering,
|
|
1899
2143
|
});
|
|
1900
2144
|
}
|
|
1901
2145
|
/**
|
|
@@ -1954,6 +2198,8 @@ export class Session {
|
|
|
1954
2198
|
force: false,
|
|
1955
2199
|
at: options?.at,
|
|
1956
2200
|
phraseCycles: options?.phraseCycles,
|
|
2201
|
+
glideBars: options?.glideBars,
|
|
2202
|
+
answering: options?.answering,
|
|
1957
2203
|
carriesHandEdit,
|
|
1958
2204
|
}).then((outcome) => ({ outcome, code: result.code, ranges: result.ranges, base, carriesHandEdit }));
|
|
1959
2205
|
}
|
|
@@ -2001,6 +2247,7 @@ export class Session {
|
|
|
2001
2247
|
label: restoredLabel(id),
|
|
2002
2248
|
restoredFrom: id,
|
|
2003
2249
|
origin: options?.origin,
|
|
2250
|
+
answering: options?.answering,
|
|
2004
2251
|
force: true,
|
|
2005
2252
|
});
|
|
2006
2253
|
}
|
|
@@ -2020,6 +2267,8 @@ export class Session {
|
|
|
2020
2267
|
message.at = intent.at;
|
|
2021
2268
|
if (intent.phraseCycles !== undefined)
|
|
2022
2269
|
message.phraseCycles = intent.phraseCycles;
|
|
2270
|
+
if (intent.glideBars !== undefined)
|
|
2271
|
+
message.glideBars = intent.glideBars;
|
|
2023
2272
|
return message;
|
|
2024
2273
|
});
|
|
2025
2274
|
}
|