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.
- package/README.md +54 -331
- package/dist/ask.js +59 -12
- package/dist/catalog.js +118 -9
- package/dist/instruments.js +83 -9
- package/dist/mcp.js +146 -20
- package/dist/midi-state.js +43 -1
- package/dist/protocol.js +123 -5
- package/dist/reference.js +12 -0
- package/dist/request-status.js +221 -0
- package/dist/session.js +203 -7
- package/dist/vocabulary.js +420 -0
- package/package.json +3 -2
- package/page/ask-view.js +118 -50
- package/page/ask.js +139 -169
- package/page/bar-model.js +44 -2
- package/page/change-timing.js +35 -0
- package/page/index.html +6 -2
- package/page/instruments-model.js +50 -3
- package/page/instruments-view.js +14 -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 +807 -66
- package/page/midi-clock.js +22 -3
- package/page/midi-in.js +407 -0
- package/page/midi-out.js +557 -48
- package/page/midi-plan.js +42 -6
- package/page/midi-rig.js +286 -13
- package/page/midi-settings.js +507 -0
- 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/played-buffer.js +208 -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/scratch-gesture.js +239 -0
- package/page/scratch-listen.js +260 -0
- package/page/scratch-model.js +1696 -0
- package/page/scratch-view.js +2176 -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 +1243 -43
- 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 +20 -7
- package/reference/midi.md +2 -0
- package/reference/motif.md +83 -0
- package/reference/settings.md +161 -0
- package/reference/transitions.md +13 -1
package/dist/protocol.js
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
/** How many of the newest versions the History view loads first. */
|
|
5
5
|
export const HISTORY_WINDOW_SIZE = 100;
|
|
6
|
+
/**
|
|
7
|
+
* Why the memory guard refused a message: not readable as bytes, longer than it reads, a system exclusive message that
|
|
8
|
+
* does not end, not a Roland data set for the instrument's model, an instrument whose temporary areas are not known,
|
|
9
|
+
* an address outside the temporary scene and tones, a wrong checksum, or an NRPN message that is not one of the part offsets of
|
|
10
|
+
* the NRPN family of the instrument. Worded in src/midi-state.ts.
|
|
11
|
+
*/
|
|
12
|
+
export const MEMORY_GUARD_REASONS = ['not-bytes', 'too-long', 'incomplete', 'not-data-set', 'unknown-areas', 'outside', 'checksum', 'nrpn'];
|
|
6
13
|
// Every string in a midi-state is at most MAX_MIDI_NAME_LENGTH characters (the page cuts longer
|
|
7
14
|
// names with an ellipsis, the server rejects them and never trims); the arrays and the failure
|
|
8
15
|
// name are bounded too.
|
|
@@ -16,6 +23,8 @@ export const MAX_MIDI_EVENTS = 20;
|
|
|
16
23
|
// message. page/midi-rig.js mirrors the limits below (RIG_LIMITS) and the message shape
|
|
17
24
|
// by hand; test/midi-rig.test.js checks both sides.
|
|
18
25
|
export const MAX_RIG_INSTRUMENTS = 16;
|
|
26
|
+
// The most MIDI input ports the setup file may name (its inputs list); mirrored by RIG_LIMITS.inputs in page/midi-rig.js.
|
|
27
|
+
export const MAX_RIG_INPUTS = 4;
|
|
19
28
|
export const MAX_RIG_SOUNDS = 512;
|
|
20
29
|
// The most controls a model may declare: the model schema's own `.max(32)` of `controls` in
|
|
21
30
|
// src/instruments.ts; mirrored by RIG_LIMITS.controls in page/midi-rig.js.
|
|
@@ -30,12 +39,24 @@ export const MAX_RIG_PROBLEMS = 20;
|
|
|
30
39
|
export const MAX_RIG_DRUM_MAPS = 16;
|
|
31
40
|
export const MAX_RIG_DRUM_NOTES = 128;
|
|
32
41
|
export const MAX_RIG_DRUM_NAME = 40;
|
|
42
|
+
// The most setting rows one level (tone, part or scene) of a model may describe, and the most engines a model may
|
|
43
|
+
// declare; mirrored by RIG_LIMITS.settings and RIG_LIMITS.engines in page/midi-rig.js. The page cannot import the
|
|
44
|
+
// vocabulary, so this is a fixed number; test/vocabulary.test.js checks that the whole vocabulary fits in it. It is 512
|
|
45
|
+
// because the MT-32 map holds about 250 tone settings, and a settings event may name every one of them.
|
|
46
|
+
export const MAX_RIG_SETTINGS = 512;
|
|
47
|
+
export const MAX_RIG_ENGINES = 16;
|
|
33
48
|
/**
|
|
34
49
|
* The shortest and longest phrase, in cycles, that set_code accepts with at: 'phrase'. page/change-timing.js
|
|
35
50
|
* mirrors both by hand (test/protocol.test.js checks both sides).
|
|
36
51
|
*/
|
|
37
52
|
export const MIN_PHRASE_CYCLES = 2;
|
|
38
53
|
export const MAX_PHRASE_CYCLES = 64;
|
|
54
|
+
/**
|
|
55
|
+
* The shortest and longest tempo ramp, in whole bars (one bar is one cycle), that set_code and edit_code accept with
|
|
56
|
+
* glideBars. page/tempo-glide.js mirrors both by hand (test/protocol.test.js checks both sides).
|
|
57
|
+
*/
|
|
58
|
+
export const MIN_GLIDE_BARS = 1;
|
|
59
|
+
export const MAX_GLIDE_BARS = 64;
|
|
39
60
|
/** The largest tempo the page may report in BPM; a bigger number is not music and the schema refuses it. */
|
|
40
61
|
export const MAX_REPORTED_BPM = 100000;
|
|
41
62
|
/** The two scripts the page loads from unpkg.com; a load-failed report names the ones that did not load. */
|
|
@@ -73,6 +94,27 @@ export const MAX_REQUEST_LINE_BYTES = 8192;
|
|
|
73
94
|
export const MOMENT_BEATS = 4;
|
|
74
95
|
// The most characters in a request id (the page makes the id up so answers can be matched).
|
|
75
96
|
export const MAX_ASK_ID_LENGTH = 64;
|
|
97
|
+
// The longest sentence Claude can close a page request with. The page shows it with an ellipsis in a
|
|
98
|
+
// row and whole in the detail.
|
|
99
|
+
export const MAX_ANSWER_TEXT_LENGTH = 200;
|
|
100
|
+
// How the notes of a motif sent from the Scratchpad got on its roll, in the order the request lists them; `mixed` is more than one way.
|
|
101
|
+
// page/scratch-model.js mirrors it by hand (test/ask.test.js checks both sides).
|
|
102
|
+
export const MOTIF_SOURCES = ['played', 'drawn', 'tapped', 'mixed'];
|
|
103
|
+
// The ids of the five grids of the Scratchpad, in the page's order. page/scratch-model.js mirrors them by hand
|
|
104
|
+
// (test/ask.test.js checks both sides).
|
|
105
|
+
export const MOTIF_GRIDS = ['quarter', 'eighth', 'sixteenth', 'eighth-triplet', 'sixteenth-triplet'];
|
|
106
|
+
// The fewest and the most cycles one motif has. page/scratch-model.js mirrors them by hand (test/ask.test.js checks both sides).
|
|
107
|
+
export const MIN_MOTIF_CYCLES = 1;
|
|
108
|
+
export const MAX_MOTIF_CYCLES = 8;
|
|
109
|
+
// The most characters the phrase of a motif has. A bound below MAX_REQUEST_LINE_BYTES so the schema is bounded; the line's own
|
|
110
|
+
// bound stays the real limit, and a phrase is refused whole and never cut. page/ask.js mirrors it by hand (test/ask.test.js checks
|
|
111
|
+
// both sides).
|
|
112
|
+
export const MAX_MOTIF_PHRASE_LENGTH = 8000;
|
|
113
|
+
// The lane of a phrase: note("...").velocity("..."), with only the characters of the phrase inside the quotes.
|
|
114
|
+
const MOTIF_PHRASE_LANE = 'note\\("[a-g0-9#.~_\\[\\],/ <>]*"\\)\\.velocity\\("[a-g0-9#.~_\\[\\],/ <>]*"\\)';
|
|
115
|
+
// The shape of a phrase: one lane, or stack(...) of two or more. Nothing but notes and velocities can travel in the phrase field.
|
|
116
|
+
// page/scratch-model.js's PHRASE_SHAPE is the same pattern, written by hand (test/ask.test.js checks both sides).
|
|
117
|
+
export const MOTIF_PHRASE_SHAPE = new RegExp(`^(${MOTIF_PHRASE_LANE}|stack\\(${MOTIF_PHRASE_LANE}(, ${MOTIF_PHRASE_LANE})+\\))$`);
|
|
76
118
|
// The kinds of thing a right-click can point at. The Jam view is
|
|
77
119
|
// the source of 'note' (a pitch class with its role in the scale) and 'warning' (a chord that leaves it).
|
|
78
120
|
export const ASK_CONTEXT_KINDS = [
|
|
@@ -95,6 +137,7 @@ export const ASK_CONTEXT_KINDS = [
|
|
|
95
137
|
'cell',
|
|
96
138
|
'group',
|
|
97
139
|
'range',
|
|
140
|
+
'motif',
|
|
98
141
|
];
|
|
99
142
|
// The kinds of gesture a request can carry: what the user did to a note of the Timeline that the page could not
|
|
100
143
|
// apply itself. page/ask.js mirrors the list by hand (test/ask.test.js checks both sides).
|
|
@@ -104,6 +147,12 @@ export const MAX_GESTURE_SEMITONES = 127;
|
|
|
104
147
|
const audioStateSchema = z.enum(['playing', 'waiting-for-click', 'stopped']);
|
|
105
148
|
const midiName = z.string().max(MAX_MIDI_NAME_LENGTH);
|
|
106
149
|
const midiChannel = z.number().int().min(1).max(16);
|
|
150
|
+
// The names of the settings an event is about: a setting name is a lowercase letter, then letters and digits
|
|
151
|
+
// (page/midi-rig.js reads the same pattern), and a level holds at most MAX_RIG_SETTINGS of them.
|
|
152
|
+
const settingNames = z
|
|
153
|
+
.array(z.string().regex(/^[a-z][A-Za-z0-9]{0,39}$/))
|
|
154
|
+
.min(1)
|
|
155
|
+
.max(MAX_RIG_SETTINGS);
|
|
107
156
|
// A longer string is rejected, never trimmed (the page cuts before it sends).
|
|
108
157
|
const askString = z.string().max(MAX_ASK_CONTEXT_LENGTH);
|
|
109
158
|
const askName = askString.min(1);
|
|
@@ -159,6 +208,18 @@ export const askGestureSchema = z.discriminatedUnion('kind', [
|
|
|
159
208
|
more: z.number().int().nonnegative(),
|
|
160
209
|
}),
|
|
161
210
|
]);
|
|
211
|
+
// The keys of a motif. z.object drops a key it does not name, so test/ask.test.js pins these keys equal to the ones page/scratch-model.js's
|
|
212
|
+
// motifRequest can give. A cycle, a count or a phrase out of bounds is rejected, never trimmed, and a phrase that is not in the phrase form
|
|
213
|
+
// is rejected whole.
|
|
214
|
+
export const askMotifShape = {
|
|
215
|
+
source: z.enum(MOTIF_SOURCES),
|
|
216
|
+
cycle: z.number().int().nonnegative().optional(),
|
|
217
|
+
cycles: z.number().int().min(MIN_MOTIF_CYCLES).max(MAX_MOTIF_CYCLES),
|
|
218
|
+
grid: z.enum(MOTIF_GRIDS),
|
|
219
|
+
notes: z.number().int().min(1),
|
|
220
|
+
phrase: z.string().min(1).max(MAX_MOTIF_PHRASE_LENGTH).regex(MOTIF_PHRASE_SHAPE),
|
|
221
|
+
};
|
|
222
|
+
const askMotifSchema = z.object(askMotifShape);
|
|
162
223
|
const askContextSchema = z.discriminatedUnion('kind', [
|
|
163
224
|
z.object({ kind: z.literal('session') }),
|
|
164
225
|
z.object({ kind: z.literal('version'), id: z.number().int().positive(), label: askName.optional() }),
|
|
@@ -195,11 +256,14 @@ const askContextSchema = z.discriminatedUnion('kind', [
|
|
|
195
256
|
track: askName,
|
|
196
257
|
from: z.number().int().nonnegative(),
|
|
197
258
|
to: z.number().int().nonnegative(),
|
|
198
|
-
|
|
199
|
-
|
|
259
|
+
sections: z.array(askName).min(1).max(MAX_RANGE_CYCLES).optional(),
|
|
260
|
+
moreSections: z.number().int().min(1).max(MAX_RANGE_CYCLES - 1).optional(),
|
|
200
261
|
})
|
|
201
262
|
.refine((range) => range.to >= range.from && range.to - range.from < MAX_RANGE_CYCLES, {
|
|
202
263
|
message: `a range covers 1 to ${MAX_RANGE_CYCLES} whole cycles, to not before from`,
|
|
264
|
+
})
|
|
265
|
+
.refine((range) => range.moreSections === undefined || range.sections !== undefined, {
|
|
266
|
+
message: 'moreSections comes only with sections',
|
|
203
267
|
}),
|
|
204
268
|
// `note` is a pitch class name, never an octave, string or fret; `role` and `scale` are both present or
|
|
205
269
|
// both absent (the page enforces that), absent while the scale is not clear.
|
|
@@ -234,6 +298,7 @@ const askContextSchema = z.discriminatedUnion('kind', [
|
|
|
234
298
|
message: 'outside notes come only with a fit of does-not-fit',
|
|
235
299
|
}),
|
|
236
300
|
z.object({ kind: z.literal('group'), name: askName, parent: askName.optional() }),
|
|
301
|
+
z.object({ kind: z.literal('motif') }),
|
|
237
302
|
]);
|
|
238
303
|
/** A structure note holds at most this many lines, each at most this many characters. */
|
|
239
304
|
export const MAX_STRUCTURE_NOTE_LINES = 3;
|
|
@@ -244,6 +309,26 @@ const pendingChangeSchema = z.object({
|
|
|
244
309
|
cycle: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
|
245
310
|
seconds: z.number().finite().nonnegative().max(MAX_PENDING_SECONDS),
|
|
246
311
|
});
|
|
312
|
+
const glideBpmSchema = z.number().finite().positive().max(MAX_REPORTED_BPM);
|
|
313
|
+
// A discriminated union on `state` so a later state is one more member.
|
|
314
|
+
const glideReportSchema = z.discriminatedUnion('state', [
|
|
315
|
+
z.object({
|
|
316
|
+
state: z.literal('started'),
|
|
317
|
+
fromBpm: glideBpmSchema,
|
|
318
|
+
toBpm: glideBpmSchema,
|
|
319
|
+
bars: z.number().int().min(MIN_GLIDE_BARS).max(MAX_GLIDE_BARS),
|
|
320
|
+
}),
|
|
321
|
+
z.object({
|
|
322
|
+
state: z.literal('at-line'),
|
|
323
|
+
toBpm: glideBpmSchema,
|
|
324
|
+
bars: z.number().int().min(MIN_GLIDE_BARS).max(MAX_GLIDE_BARS),
|
|
325
|
+
}),
|
|
326
|
+
z.object({
|
|
327
|
+
state: z.literal('running'),
|
|
328
|
+
nowBpm: glideBpmSchema,
|
|
329
|
+
toBpm: glideBpmSchema,
|
|
330
|
+
}),
|
|
331
|
+
]);
|
|
247
332
|
// A plain z.union (not discriminatedUnion): 'runtime-error' has no 'ok' field, so the
|
|
248
333
|
// single-key discriminant used for the two 'evaluated' variants can't cover it.
|
|
249
334
|
const pageMessageSchema = z.union([
|
|
@@ -255,10 +340,12 @@ const pageMessageSchema = z.union([
|
|
|
255
340
|
audio: audioStateSchema,
|
|
256
341
|
pending: pendingChangeSchema.optional(),
|
|
257
342
|
bpm: z.number().finite().positive().max(MAX_REPORTED_BPM).optional(),
|
|
343
|
+
glide: glideReportSchema.optional(),
|
|
258
344
|
}),
|
|
259
345
|
z.object({
|
|
260
346
|
type: z.literal('takeover'),
|
|
261
347
|
id: z.string().min(1).max(64),
|
|
348
|
+
glideRuns: z.literal(true).optional(),
|
|
262
349
|
}),
|
|
263
350
|
z.object({
|
|
264
351
|
type: z.literal('takeover-canceled'),
|
|
@@ -272,6 +359,12 @@ const pageMessageSchema = z.union([
|
|
|
272
359
|
code: z.string(),
|
|
273
360
|
error: z.string(),
|
|
274
361
|
line: z.number().int().positive().optional(),
|
|
362
|
+
glideRefused: z.literal(true).optional(),
|
|
363
|
+
}),
|
|
364
|
+
z.object({
|
|
365
|
+
type: z.literal('glide-ended'),
|
|
366
|
+
reason: z.enum(['finished', 'other-tempo', 'no-tempo', 'error']),
|
|
367
|
+
bpm: glideBpmSchema.optional(),
|
|
275
368
|
}),
|
|
276
369
|
z.object({
|
|
277
370
|
type: z.literal('runtime-error'),
|
|
@@ -318,6 +411,7 @@ const pageMessageSchema = z.union([
|
|
|
318
411
|
label: z.string().min(1).max(MAX_MIXER_LABEL_LENGTH),
|
|
319
412
|
}),
|
|
320
413
|
z.object({ type: z.literal('history-request') }),
|
|
414
|
+
z.object({ type: z.literal('ask-board-request') }),
|
|
321
415
|
z.object({ type: z.literal('data-folder-request') }),
|
|
322
416
|
z.object({ type: z.literal('take-over') }),
|
|
323
417
|
z.object({ type: z.literal('history-restore'), id: z.number().int().positive() }),
|
|
@@ -341,9 +435,10 @@ const pageMessageSchema = z.union([
|
|
|
341
435
|
context: askContextSchema,
|
|
342
436
|
chip: z.string().min(1).max(MAX_ASK_CONTEXT_LENGTH),
|
|
343
437
|
current: z.number().int().positive().nullable(),
|
|
344
|
-
// z.object drops a key it does not name, so the moment and the
|
|
438
|
+
// z.object drops a key it does not name, so the moment, the gesture and the motif must be named here to reach Claude.
|
|
345
439
|
moment: askMomentSchema.optional(),
|
|
346
440
|
gesture: askGestureSchema.optional(),
|
|
441
|
+
motif: askMotifSchema.optional(),
|
|
347
442
|
})
|
|
348
443
|
// A gesture about one note of a lane rides only on a lane context that carries a moment; the range gesture and the range
|
|
349
444
|
// context come together and carry no moment (the notes of the range are in the gesture).
|
|
@@ -352,7 +447,11 @@ const pageMessageSchema = z.union([
|
|
|
352
447
|
if (isRange || ask.context.kind === 'range')
|
|
353
448
|
return isRange && ask.context.kind === 'range' && ask.moment === undefined;
|
|
354
449
|
return ask.gesture === undefined || (ask.context.kind === 'lane' && ask.moment !== undefined);
|
|
355
|
-
}, { message: 'a gesture needs a lane context and a moment, and a range gesture goes only with a range context' })
|
|
450
|
+
}, { message: 'a gesture needs a lane context and a moment, and a range gesture goes only with a range context' })
|
|
451
|
+
// A motif goes only with the motif context and the motif context needs one; a motif request carries no moment and no gesture.
|
|
452
|
+
.refine((ask) => (ask.motif !== undefined) === (ask.context.kind === 'motif') && (ask.motif === undefined || (ask.moment === undefined && ask.gesture === undefined)), {
|
|
453
|
+
message: 'a motif goes only with a motif context, which needs it, and carries no moment and no gesture',
|
|
454
|
+
}),
|
|
356
455
|
z.object({
|
|
357
456
|
type: z.literal('midi-state'),
|
|
358
457
|
access: z.enum(['idle', 'pending', 'enabled', 'denied', 'unsupported', 'failed']),
|
|
@@ -380,9 +479,28 @@ const pageMessageSchema = z.union([
|
|
|
380
479
|
z.object({
|
|
381
480
|
kind: z.literal('send-failed'),
|
|
382
481
|
instrument: midiName,
|
|
383
|
-
what: z.enum(['reset', 'setup']),
|
|
482
|
+
what: z.enum(['reset', 'setup', 'settings']),
|
|
384
483
|
failure: z.string().max(MAX_MIDI_FAILURE_LENGTH),
|
|
385
484
|
}),
|
|
485
|
+
z.object({ kind: z.literal('settings-scene'), instrument: midiName, names: settingNames }),
|
|
486
|
+
z
|
|
487
|
+
.object({
|
|
488
|
+
kind: z.literal('settings-left'),
|
|
489
|
+
instrument: midiName,
|
|
490
|
+
level: z.enum(['tone', 'part', 'scene']),
|
|
491
|
+
channel: midiChannel.nullable(),
|
|
492
|
+
names: settingNames,
|
|
493
|
+
})
|
|
494
|
+
// A tone or part setting belongs to a channel; a scene setting reaches every part and has none.
|
|
495
|
+
.refine((event) => (event.level === 'scene') === (event.channel === null), {
|
|
496
|
+
message: 'a scene setting has no channel, a tone or part setting has one',
|
|
497
|
+
}),
|
|
498
|
+
z.object({
|
|
499
|
+
kind: z.literal('sysex-refused'),
|
|
500
|
+
instrument: midiName,
|
|
501
|
+
source: z.enum(['code', 'settings', 'reset']),
|
|
502
|
+
reason: z.enum(MEMORY_GUARD_REASONS),
|
|
503
|
+
}),
|
|
386
504
|
z.object({ kind: z.literal('clock-dropped') }),
|
|
387
505
|
]))
|
|
388
506
|
.max(MAX_MIDI_EVENTS)
|
package/dist/reference.js
CHANGED
|
@@ -65,6 +65,18 @@ export const REFERENCE_TOPICS = [
|
|
|
65
65
|
title: 'MIDI output',
|
|
66
66
|
summary: 'sending a track to a MIDI port with .midi(): port names, channels, notes, control and program changes, the MIDI clock Moontide sends, and what Stop sends',
|
|
67
67
|
},
|
|
68
|
+
{
|
|
69
|
+
id: 'settings',
|
|
70
|
+
file: 'settings.md',
|
|
71
|
+
title: 'Synth settings',
|
|
72
|
+
summary: 'the tone, part and scene settings of a MIDI instrument written in .midi(): what each level reaches, the setting names, what is refused, when they are sent and what undoes them',
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'motif',
|
|
76
|
+
file: 'motif.md',
|
|
77
|
+
title: 'Motif requests',
|
|
78
|
+
summary: 'how to read a motif sent from the Scratchpad: its fields, the moment, the grid, the phrase form, the velocities, the source words, what the words can ask and what the page did',
|
|
79
|
+
},
|
|
68
80
|
];
|
|
69
81
|
export const REFERENCE_TOPIC_IDS = REFERENCE_TOPICS.map((topic) => topic.id);
|
|
70
82
|
/** Module-relative, not cwd-relative (works wherever npm installs the package). */
|
|
@@ -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
|
+
}
|