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/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. */
|
|
@@ -58,6 +64,9 @@ export const MAX_ASK_CONTEXT_LENGTH = 200;
|
|
|
58
64
|
// The most notes one moment carries; the rest are counted in `more`. page/timeline.js mirrors it by
|
|
59
65
|
// hand (test/ask.test.js checks both sides).
|
|
60
66
|
export const MAX_MOMENT_NOTES = 32;
|
|
67
|
+
// The most cycles one range may cover: the cycles of one Timeline page. page/timeline.js mirrors it by hand
|
|
68
|
+
// (test/ask.test.js checks both sides).
|
|
69
|
+
export const MAX_RANGE_CYCLES = 32;
|
|
61
70
|
// The most bytes (UTF-8) one request line may have, the whole line Claude's Monitor reads: the prefix and the
|
|
62
71
|
// JSON with the text, the context, the chip and the moment. Text of 2000 characters in a script that takes 3
|
|
63
72
|
// bytes per character still fits with the rest of the line, but not every text of MAX_ASK_TEXT_LENGTH characters
|
|
@@ -70,6 +79,9 @@ export const MAX_REQUEST_LINE_BYTES = 8192;
|
|
|
70
79
|
export const MOMENT_BEATS = 4;
|
|
71
80
|
// The most characters in a request id (the page makes the id up so answers can be matched).
|
|
72
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;
|
|
73
85
|
// The kinds of thing a right-click can point at. The Jam view is
|
|
74
86
|
// the source of 'note' (a pitch class with its role in the scale) and 'warning' (a chord that leaves it).
|
|
75
87
|
export const ASK_CONTEXT_KINDS = [
|
|
@@ -89,7 +101,15 @@ export const ASK_CONTEXT_KINDS = [
|
|
|
89
101
|
'warning',
|
|
90
102
|
'instrument',
|
|
91
103
|
'error',
|
|
104
|
+
'cell',
|
|
105
|
+
'group',
|
|
106
|
+
'range',
|
|
92
107
|
];
|
|
108
|
+
// The kinds of gesture a request can carry: what the user did to a note of the Timeline that the page could not
|
|
109
|
+
// apply itself. page/ask.js mirrors the list by hand (test/ask.test.js checks both sides).
|
|
110
|
+
export const GESTURE_KINDS = ['pitch', 'rest', 'velocity', 'range'];
|
|
111
|
+
// The most semitones one gesture may carry, up or down: the span of the note range.
|
|
112
|
+
export const MAX_GESTURE_SEMITONES = 127;
|
|
93
113
|
const audioStateSchema = z.enum(['playing', 'waiting-for-click', 'stopped']);
|
|
94
114
|
const midiName = z.string().max(MAX_MIDI_NAME_LENGTH);
|
|
95
115
|
const midiChannel = z.number().int().min(1).max(16);
|
|
@@ -114,6 +134,10 @@ export const askMomentNoteShape = {
|
|
|
114
134
|
state: z.enum(['sounding', 'starts']),
|
|
115
135
|
};
|
|
116
136
|
const askMomentNote = z.object(askMomentNoteShape);
|
|
137
|
+
// A note of a range is a moment note without its state; derived from the moment note, so the two cannot drift apart.
|
|
138
|
+
// test/ask.test.js pins the keys equal to the ones page/ask.js's RANGE_NOTE_FIELDS may copy.
|
|
139
|
+
const askRangeNote = askMomentNote.omit({ state: true });
|
|
140
|
+
export const askRangeNoteShape = askRangeNote.shape;
|
|
117
141
|
// More notes, a longer string or a number out of range is rejected, never trimmed.
|
|
118
142
|
const askMomentSchema = z.object({
|
|
119
143
|
cycle: z.number().int().nonnegative(),
|
|
@@ -123,6 +147,27 @@ const askMomentSchema = z.object({
|
|
|
123
147
|
notes: z.array(askMomentNote).max(MAX_MOMENT_NOTES),
|
|
124
148
|
more: z.number().int().nonnegative(),
|
|
125
149
|
});
|
|
150
|
+
// A closed union: a kind the page does not know, a direction or an amount out of bounds is rejected, never
|
|
151
|
+
// trimmed. z.object drops a key it does not name, so every key of every kind is named here.
|
|
152
|
+
export const askGestureSchema = z.discriminatedUnion('kind', [
|
|
153
|
+
z.object({
|
|
154
|
+
kind: z.literal('pitch'),
|
|
155
|
+
semitones: z
|
|
156
|
+
.number()
|
|
157
|
+
.int()
|
|
158
|
+
.min(-MAX_GESTURE_SEMITONES)
|
|
159
|
+
.max(MAX_GESTURE_SEMITONES)
|
|
160
|
+
.refine((semitones) => semitones !== 0, { message: 'a pitch gesture moves the note' }),
|
|
161
|
+
}),
|
|
162
|
+
z.object({ kind: z.literal('rest') }),
|
|
163
|
+
z.object({ kind: z.literal('velocity'), direction: z.enum(['softer', 'louder']) }),
|
|
164
|
+
z.object({
|
|
165
|
+
kind: z.literal('range'),
|
|
166
|
+
version: z.number().int().positive().nullable(),
|
|
167
|
+
notes: z.array(askRangeNote).max(MAX_MOMENT_NOTES),
|
|
168
|
+
more: z.number().int().nonnegative(),
|
|
169
|
+
}),
|
|
170
|
+
]);
|
|
126
171
|
const askContextSchema = z.discriminatedUnion('kind', [
|
|
127
172
|
z.object({ kind: z.literal('session') }),
|
|
128
173
|
z.object({ kind: z.literal('version'), id: z.number().int().positive(), label: askName.optional() }),
|
|
@@ -151,6 +196,23 @@ const askContextSchema = z.discriminatedUnion('kind', [
|
|
|
151
196
|
}),
|
|
152
197
|
z.object({ kind: z.literal('part'), name: askName, owner: askName.optional() }),
|
|
153
198
|
z.object({ kind: z.literal('lane'), track: askName, cycle: z.number().int().nonnegative().optional() }),
|
|
199
|
+
// Whole cycles of one lane, `to` at least `from` and less than MAX_RANGE_CYCLES cycles after it; a name or a number out of
|
|
200
|
+
// bounds is rejected, never trimmed.
|
|
201
|
+
z
|
|
202
|
+
.object({
|
|
203
|
+
kind: z.literal('range'),
|
|
204
|
+
track: askName,
|
|
205
|
+
from: z.number().int().nonnegative(),
|
|
206
|
+
to: z.number().int().nonnegative(),
|
|
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(),
|
|
209
|
+
})
|
|
210
|
+
.refine((range) => range.to >= range.from && range.to - range.from < MAX_RANGE_CYCLES, {
|
|
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',
|
|
215
|
+
}),
|
|
154
216
|
// `note` is a pitch class name, never an octave, string or fret; `role` and `scale` are both present or
|
|
155
217
|
// both absent (the page enforces that), absent while the scale is not clear.
|
|
156
218
|
z.object({ kind: z.literal('note'), note: askName, role: askNoteRole.optional(), scale: askName.optional() }),
|
|
@@ -164,13 +226,56 @@ const askContextSchema = z.discriminatedUnion('kind', [
|
|
|
164
226
|
seconds: z.number().int().nonnegative(),
|
|
165
227
|
scale: askName,
|
|
166
228
|
}),
|
|
229
|
+
// A cell holds a variation or is a rest, never both and never neither; a rest has no fit; the notes outside the scale
|
|
230
|
+
// come only with a fit of does-not-fit. The page sends nothing else, and the server rejects the rest, never trims it.
|
|
231
|
+
z
|
|
232
|
+
.object({
|
|
233
|
+
kind: z.literal('cell'),
|
|
234
|
+
section: askName,
|
|
235
|
+
track: askName,
|
|
236
|
+
variation: askName.optional(),
|
|
237
|
+
rest: z.literal(true).optional(),
|
|
238
|
+
fit: z.enum(['fits', 'does-not-fit', 'cannot-check']).optional(),
|
|
239
|
+
outside: askName.optional(),
|
|
240
|
+
})
|
|
241
|
+
.refine((cell) => (cell.variation === undefined) !== (cell.rest === undefined), {
|
|
242
|
+
message: 'a cell has a variation or is a rest, not both and not neither',
|
|
243
|
+
})
|
|
244
|
+
.refine((cell) => !(cell.rest !== undefined && cell.fit !== undefined), { message: 'a rest cell has no fit' })
|
|
245
|
+
.refine((cell) => cell.outside === undefined || cell.fit === 'does-not-fit', {
|
|
246
|
+
message: 'outside notes come only with a fit of does-not-fit',
|
|
247
|
+
}),
|
|
248
|
+
z.object({ kind: z.literal('group'), name: askName, parent: askName.optional() }),
|
|
167
249
|
]);
|
|
250
|
+
/** A structure note holds at most this many lines, each at most this many characters. */
|
|
251
|
+
export const MAX_STRUCTURE_NOTE_LINES = 3;
|
|
252
|
+
export const MAX_STRUCTURE_NOTE_LINE = 600;
|
|
168
253
|
/** The longest wait the server accepts in a pending reply: a day. */
|
|
169
254
|
export const MAX_PENDING_SECONDS = 86400;
|
|
170
255
|
const pendingChangeSchema = z.object({
|
|
171
256
|
cycle: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
|
172
257
|
seconds: z.number().finite().nonnegative().max(MAX_PENDING_SECONDS),
|
|
173
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
|
+
]);
|
|
174
279
|
// A plain z.union (not discriminatedUnion): 'runtime-error' has no 'ok' field, so the
|
|
175
280
|
// single-key discriminant used for the two 'evaluated' variants can't cover it.
|
|
176
281
|
const pageMessageSchema = z.union([
|
|
@@ -182,10 +287,12 @@ const pageMessageSchema = z.union([
|
|
|
182
287
|
audio: audioStateSchema,
|
|
183
288
|
pending: pendingChangeSchema.optional(),
|
|
184
289
|
bpm: z.number().finite().positive().max(MAX_REPORTED_BPM).optional(),
|
|
290
|
+
glide: glideReportSchema.optional(),
|
|
185
291
|
}),
|
|
186
292
|
z.object({
|
|
187
293
|
type: z.literal('takeover'),
|
|
188
294
|
id: z.string().min(1).max(64),
|
|
295
|
+
glideRuns: z.literal(true).optional(),
|
|
189
296
|
}),
|
|
190
297
|
z.object({
|
|
191
298
|
type: z.literal('takeover-canceled'),
|
|
@@ -199,12 +306,23 @@ const pageMessageSchema = z.union([
|
|
|
199
306
|
code: z.string(),
|
|
200
307
|
error: z.string(),
|
|
201
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(),
|
|
202
315
|
}),
|
|
203
316
|
z.object({
|
|
204
317
|
type: z.literal('runtime-error'),
|
|
205
318
|
message: z.string(),
|
|
206
319
|
evaluateId: z.string().min(1).max(64).optional(),
|
|
207
320
|
}),
|
|
321
|
+
z.object({
|
|
322
|
+
type: z.literal('structure-note'),
|
|
323
|
+
lines: z.array(z.string().max(MAX_STRUCTURE_NOTE_LINE)).max(MAX_STRUCTURE_NOTE_LINES),
|
|
324
|
+
evaluateId: z.string().min(1).max(64).optional(),
|
|
325
|
+
}),
|
|
208
326
|
z.object({
|
|
209
327
|
type: z.literal('stop-result'),
|
|
210
328
|
id: z.string(),
|
|
@@ -240,6 +358,7 @@ const pageMessageSchema = z.union([
|
|
|
240
358
|
label: z.string().min(1).max(MAX_MIXER_LABEL_LENGTH),
|
|
241
359
|
}),
|
|
242
360
|
z.object({ type: z.literal('history-request') }),
|
|
361
|
+
z.object({ type: z.literal('ask-board-request') }),
|
|
243
362
|
z.object({ type: z.literal('data-folder-request') }),
|
|
244
363
|
z.object({ type: z.literal('take-over') }),
|
|
245
364
|
z.object({ type: z.literal('history-restore'), id: z.number().int().positive() }),
|
|
@@ -263,9 +382,18 @@ const pageMessageSchema = z.union([
|
|
|
263
382
|
context: askContextSchema,
|
|
264
383
|
chip: z.string().min(1).max(MAX_ASK_CONTEXT_LENGTH),
|
|
265
384
|
current: z.number().int().positive().nullable(),
|
|
266
|
-
// z.object drops a key it does not name, so the moment must be named here to reach Claude.
|
|
385
|
+
// z.object drops a key it does not name, so the moment and the gesture must be named here to reach Claude.
|
|
267
386
|
moment: askMomentSchema.optional(),
|
|
268
|
-
|
|
387
|
+
gesture: askGestureSchema.optional(),
|
|
388
|
+
})
|
|
389
|
+
// A gesture about one note of a lane rides only on a lane context that carries a moment; the range gesture and the range
|
|
390
|
+
// context come together and carry no moment (the notes of the range are in the gesture).
|
|
391
|
+
.refine((ask) => {
|
|
392
|
+
const isRange = ask.gesture?.kind === 'range';
|
|
393
|
+
if (isRange || ask.context.kind === 'range')
|
|
394
|
+
return isRange && ask.context.kind === 'range' && ask.moment === undefined;
|
|
395
|
+
return ask.gesture === undefined || (ask.context.kind === 'lane' && ask.moment !== undefined);
|
|
396
|
+
}, { message: 'a gesture needs a lane context and a moment, and a range gesture goes only with a range context' }),
|
|
269
397
|
z.object({
|
|
270
398
|
type: z.literal('midi-state'),
|
|
271
399
|
access: z.enum(['idle', 'pending', 'enabled', 'denied', 'unsupported', 'failed']),
|
package/dist/reference.js
CHANGED
|
@@ -53,6 +53,12 @@ export const REFERENCE_TOPICS = [
|
|
|
53
53
|
title: 'Transitions',
|
|
54
54
|
summary: 'how one change can unfold over several cycles and keep running across later changes with .timeline(id): sweeps, fades, parts that enter later, sections and crossfades',
|
|
55
55
|
},
|
|
56
|
+
{
|
|
57
|
+
id: 'structure',
|
|
58
|
+
file: 'structure.md',
|
|
59
|
+
title: 'Song structure',
|
|
60
|
+
summary: 'the song declaration at the top of a piece: sections and lengths, a variation or a rest per track and section, nested groups, harmony per piece and per section, and the play() helper',
|
|
61
|
+
},
|
|
56
62
|
{
|
|
57
63
|
id: 'midi',
|
|
58
64
|
file: 'midi.md',
|
|
@@ -100,3 +106,19 @@ export function referenceToolDescription() {
|
|
|
100
106
|
`facts about what exists and how to write it, not what to choose. Topics: ${ids}. Call it without a topic for ` +
|
|
101
107
|
'the index. Strudel is JavaScript, not TidalCycles: read tidal-differences before writing Strudel from Tidal habits.');
|
|
102
108
|
}
|
|
109
|
+
const FIRST_JS_BLOCK_RE = /```js\n([\s\S]*?)\n```/;
|
|
110
|
+
/**
|
|
111
|
+
* The template get_code shows when nothing plays yet: the body of the first js block of the structure topic, a
|
|
112
|
+
* complete song structure to start a new piece from. The block is read at call time, so the topic and the template
|
|
113
|
+
* cannot drift apart. A missing block, or a block that declares no song, throws: an empty reply would hide the
|
|
114
|
+
* mistake from Claude.
|
|
115
|
+
*/
|
|
116
|
+
export function structureTemplateText(dir = referenceDir()) {
|
|
117
|
+
const topic = readReferenceTopic('structure', dir);
|
|
118
|
+
const match = FIRST_JS_BLOCK_RE.exec(topic);
|
|
119
|
+
const body = match?.[1];
|
|
120
|
+
if (body === undefined || !/^const song\b/m.test(body)) {
|
|
121
|
+
throw new Error('Moontide: the structure topic has no template block (its first js block must hold const song).');
|
|
122
|
+
}
|
|
123
|
+
return body;
|
|
124
|
+
}
|
|
@@ -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
|
+
}
|