moontide-mcp 2.1.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +54 -331
  2. package/dist/ask.js +59 -12
  3. package/dist/catalog.js +118 -9
  4. package/dist/instruments.js +83 -9
  5. package/dist/mcp.js +146 -20
  6. package/dist/midi-state.js +43 -1
  7. package/dist/protocol.js +123 -5
  8. package/dist/reference.js +12 -0
  9. package/dist/request-status.js +221 -0
  10. package/dist/session.js +203 -7
  11. package/dist/vocabulary.js +420 -0
  12. package/package.json +3 -2
  13. package/page/ask-view.js +118 -50
  14. package/page/ask.js +139 -169
  15. package/page/bar-model.js +44 -2
  16. package/page/change-timing.js +35 -0
  17. package/page/index.html +6 -2
  18. package/page/instruments-model.js +50 -3
  19. package/page/instruments-view.js +14 -2
  20. package/page/jam-analysis.js +68 -23
  21. package/page/jam-model.js +185 -29
  22. package/page/jam-resolve.js +271 -0
  23. package/page/jam-view.js +165 -10
  24. package/page/lights.js +29 -7
  25. package/page/main.js +807 -66
  26. package/page/midi-clock.js +22 -3
  27. package/page/midi-in.js +407 -0
  28. package/page/midi-out.js +557 -48
  29. package/page/midi-plan.js +42 -6
  30. package/page/midi-rig.js +286 -13
  31. package/page/midi-settings.js +507 -0
  32. package/page/mixer-view.js +7 -1
  33. package/page/note-panel.js +29 -8
  34. package/page/note-tag.js +19 -4
  35. package/page/page-rewrite.js +1 -1
  36. package/page/played-buffer.js +208 -0
  37. package/page/query-check.js +21 -2
  38. package/page/request-status-view.js +419 -0
  39. package/page/request-status.js +686 -0
  40. package/page/scratch-gesture.js +239 -0
  41. package/page/scratch-listen.js +260 -0
  42. package/page/scratch-model.js +1696 -0
  43. package/page/scratch-view.js +2176 -0
  44. package/page/song-grid-model.js +15 -0
  45. package/page/song-grid-view.js +1 -1
  46. package/page/structure.js +2 -1
  47. package/page/style.css +1243 -43
  48. package/page/tempo-glide.js +330 -0
  49. package/page/tempo-guard.js +62 -10
  50. package/page/timeline-edit-view.js +1 -1
  51. package/page/timeline.js +2 -1
  52. package/page/top-bar-view.js +18 -13
  53. package/page/views.js +20 -7
  54. package/reference/midi.md +2 -0
  55. package/reference/motif.md +83 -0
  56. package/reference/settings.md +161 -0
  57. package/reference/transitions.md +13 -1
package/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
- section: askName.optional(),
199
- sectionTo: askName.optional(),
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 gesture must be named here to reach Claude.
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
+ }