dsh-theme-endfield 0.0.0-stage → 1.2.2

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/lib/audio.js ADDED
@@ -0,0 +1,426 @@
1
+ 'use strict';
2
+ /**
3
+ * Audio notification engine for the installed (bundle) HOST half.
4
+ *
5
+ * Why the sound is played by the HOST and not in the browser: the moments worth
6
+ * marking are exactly the moments the user is NOT looking at the page. A
7
+ * browser `AudioContext` is suspended until a user gesture and dies with the
8
+ * tab, so a task that finishes while the window is minimized would stay silent.
9
+ * The host owns a real process boundary instead and hands the file to the
10
+ * platform player.
11
+ *
12
+ * Consequences handled here:
13
+ * - `subprocess` is an optional seam. Without it the whole feature degrades to
14
+ * a no-op (and says so once) rather than throwing during boot.
15
+ * - The player is a child process per play. Playback is therefore
16
+ * "fire and forget", latest-wins per slot, with a hard grace timeout.
17
+ * - Volume is applied by rescaling PCM samples into a cache file, never by
18
+ * touching the system volume.
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const os = require('os');
23
+ const path = require('path');
24
+ const { scaleWavVolume, parseWav } = require('./tone.js');
25
+ const { SLOT_IDS } = require('./slots.js');
26
+
27
+ const LOG_TAG = '[theme-endfield/audio]';
28
+ const BUNDLED_SOUNDS = path.join(__dirname, '..', 'sounds');
29
+ const CACHE_DIR = path.join(os.tmpdir(), 'dsh-theme-endfield');
30
+
31
+ /** Preference keys owned by this feature (all strings, per the namespace convention). */
32
+ const PREF = {
33
+ enabled: 'audioEnabled',
34
+ volume: 'audioVolume',
35
+ boot: 'audioBoot',
36
+ start: 'audioTurnStart',
37
+ done: 'audioTurnDone',
38
+ attention: 'audioAttention',
39
+ fail: 'audioTurnFail',
40
+ debounceMs: 'audioDebounceMs',
41
+ soundDir: 'audioSoundDir',
42
+ humanOnly: 'audioHumanOnly',
43
+ diag: 'audioDiag',
44
+ };
45
+
46
+ /** Defaults mirroring FIELD_DEFAULTS in index.js — the master switch ships OFF
47
+ * (sound is opt-in); the per-slot switches ship ON, so turning the master on is
48
+ * what starts the sound. */
49
+ const FALLBACK = {
50
+ [PREF.enabled]: '0',
51
+ [PREF.volume]: '100',
52
+ [PREF.boot]: '1',
53
+ [PREF.start]: '1',
54
+ [PREF.done]: '1',
55
+ [PREF.attention]: '1',
56
+ [PREF.fail]: '1',
57
+ [PREF.debounceMs]: '2500',
58
+ [PREF.soundDir]: '',
59
+ [PREF.humanOnly]: '1',
60
+ [PREF.diag]: '0',
61
+ };
62
+
63
+ /** The user's Desktop, probed lazily: a convenient drop-in place for own sounds. */
64
+ function desktopDir() {
65
+ const home = typeof os.homedir === 'function' ? os.homedir() : '';
66
+ if (home === '') return undefined;
67
+ for (const candidate of [path.join(home, 'Desktop'), path.join(home, '桌面')]) {
68
+ try {
69
+ if (fs.existsSync(candidate)) return candidate;
70
+ } catch (error) { /* ignore */ }
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ /** Short stable hash of a path for cache-file naming: FNV-1a, hex. */
76
+ function pathHash(value) {
77
+ let h = 0x811c9dc5;
78
+ for (let i = 0; i < value.length; i++) {
79
+ h ^= value.charCodeAt(i);
80
+ h = Math.imul(h, 0x01000193) >>> 0;
81
+ }
82
+ return h.toString(16);
83
+ }
84
+
85
+ /**
86
+ * The audio runtime: preference view, sound resolution, volume cache, playback
87
+ * and the diagnostic ring the settings page reads.
88
+ */
89
+ class AudioRuntime {
90
+ /**
91
+ * @param ctx - the host Cordis context the feature is mounted on.
92
+ */
93
+ constructor(ctx) {
94
+ this.ctx = ctx;
95
+ this.values = Object.assign({}, FALLBACK);
96
+ this.scope = undefined;
97
+ /** Last play timestamp per slot, for the per-slot debounce window. */
98
+ this.lastPlayed = Object.create(null);
99
+ /** Per slot: the instant its previous announcement finishes speaking. */
100
+ this.playUntil = Object.create(null);
101
+ /** Sound-file duration in ms, memoised per path (read once per file). */
102
+ this.durationCache = new Map();
103
+ /** Slots whose child process has not settled yet — no overlapping play. */
104
+ this.playing = new Set();
105
+ /** Bounded diagnostic ring: what the plugin saw and what it did about it. */
106
+ this.diag = [];
107
+ this.warnedNoSubprocess = false;
108
+ }
109
+
110
+ /** Record one diagnostic line and echo it to the host console. */
111
+ note(kind, detail) {
112
+ const entry = { at: Date.now(), kind, detail: detail === undefined ? '' : String(detail) };
113
+ this.diag.push(entry);
114
+ if (this.diag.length > 60) this.diag.shift();
115
+ console.log(`${LOG_TAG} ${kind}${entry.detail === '' ? '' : ' — ' + entry.detail}`);
116
+ }
117
+
118
+ /** Whether verbose diagnostics are on (the settings page can switch them on). */
119
+ get diagnosing() {
120
+ return this.values[PREF.diag] !== '0';
121
+ }
122
+
123
+ /** Adopt the durable preference snapshot. */
124
+ setValues(values) {
125
+ this.values = Object.assign({}, FALLBACK, values === undefined ? {} : values);
126
+ }
127
+
128
+ /** One preference as a string, falling back to the shipped default. */
129
+ value(key) {
130
+ const raw = this.values[key];
131
+ return raw === undefined || raw === null ? FALLBACK[key] : String(raw);
132
+ }
133
+
134
+ /** One preference as an integer with bounds. */
135
+ number(key, min, max) {
136
+ const parsed = Number.parseInt(this.value(key), 10);
137
+ if (!Number.isFinite(parsed)) return Number.parseInt(FALLBACK[key], 10);
138
+ return Math.min(max, Math.max(min, parsed));
139
+ }
140
+
141
+ /** Whether one switch preference is on (stored `'1'`, read with `!== '0'`). */
142
+ enabled(key) {
143
+ return this.value(key) !== '0';
144
+ }
145
+
146
+ /** Candidate directories for own sound files, most specific first. */
147
+ soundDirs() {
148
+ const dirs = [];
149
+ const configured = this.value(PREF.soundDir);
150
+ if (configured !== '') dirs.push(configured);
151
+ try {
152
+ const workspace = this.ctx.get('workspace');
153
+ const root = workspace === undefined || typeof workspace.root !== 'function' ? undefined : workspace.root();
154
+ if (typeof root === 'string' && root !== '') dirs.push(root);
155
+ } catch (error) { /* workspace service absent — fine */ }
156
+ const desktop = desktopDir();
157
+ if (desktop !== undefined) dirs.push(desktop);
158
+ dirs.push(BUNDLED_SOUNDS);
159
+ return dirs;
160
+ }
161
+
162
+ /**
163
+ * Resolve one slot to a playable file: configured dirs, then the workspace,
164
+ * then the Desktop drop-in, then the bundled synthesized tone.
165
+ *
166
+ * @param slot - slot id from `lib/slots.js`.
167
+ * @returns `{ file, origin }` or undefined when nothing exists.
168
+ */
169
+ resolve(slot) {
170
+ const name = `${slot}.wav`;
171
+ const dirs = this.soundDirs();
172
+ for (const dir of dirs) {
173
+ const candidate = path.join(dir, name);
174
+ try {
175
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
176
+ return { file: candidate, origin: dir === BUNDLED_SOUNDS ? 'bundled' : candidate };
177
+ }
178
+ } catch (error) { /* unreadable candidate — try the next */ }
179
+ }
180
+ return undefined;
181
+ }
182
+
183
+ /**
184
+ * Whether one slot's OWN switch is on.
185
+ *
186
+ * Callers that fire on an event already test this before calling `play()`; it
187
+ * exists as a method so the preview route can honour the same switch and report
188
+ * it, instead of playing a sound the user has switched off. `play()` itself
189
+ * deliberately does NOT consult it: `force` means "ignore the debounce", and
190
+ * folding a second meaning into it would make the switch and the preview fight
191
+ * each other.
192
+ *
193
+ * @param slot - slot id from `lib/slots.js`.
194
+ * @returns true when the slot may sound.
195
+ */
196
+ slotEnabled(slot) {
197
+ const key = PREF[slotSwitchKey(slot)];
198
+ if (key === undefined) return false;
199
+ return this.enabled(key);
200
+ }
201
+
202
+ /**
203
+ * How long one sound file runs, in milliseconds.
204
+ *
205
+ * Used to derive the per-slot "still speaking" gate. The formula is the WAV
206
+ * one — data bytes / (rate * channels * bytes-per-sample) — and a file that
207
+ * cannot be parsed contributes 0, which degrades to the fixed debounce alone
208
+ * rather than to silence.
209
+ *
210
+ * @param file - sound file path.
211
+ * @returns duration in ms (0 when unknown).
212
+ */
213
+ soundDurationMs(file) {
214
+ const cached = this.durationCache.get(file);
215
+ if (cached !== undefined) return cached;
216
+ let ms = 0;
217
+ try {
218
+ const parsed = parseWav(fs.readFileSync(file));
219
+ if (parsed !== undefined && parsed.sampleRate > 0 && parsed.channels > 0 && parsed.bitsPerSample > 0) {
220
+ ms = Math.round((parsed.dataSize / (parsed.sampleRate * parsed.channels * (parsed.bitsPerSample / 8))) * 1000);
221
+ }
222
+ } catch (error) {
223
+ ms = 0;
224
+ }
225
+ this.durationCache.set(file, ms);
226
+ return ms;
227
+ }
228
+
229
+ /**
230
+ * Apply the volume to a sound file by rescaling its PCM into the cache, so the
231
+ * system volume is never touched.
232
+ *
233
+ * @param file - source wav path.
234
+ * @param volume - 0..100.
235
+ * @returns the file to play (the source itself when scaling is a no-op).
236
+ */
237
+ applyVolume(file, volume) {
238
+ if (volume >= 100) return file;
239
+ let bytes;
240
+ try {
241
+ bytes = fs.readFileSync(file);
242
+ } catch (error) {
243
+ return file;
244
+ }
245
+ let scaled;
246
+ try {
247
+ scaled = scaleWavVolume(bytes, volume);
248
+ } catch (error) {
249
+ // A malformed user file must degrade to "play it as-is", never escape:
250
+ // play() runs inside the approval / user-questions waterfall callbacks,
251
+ // where a throw would skip next() and break the whole chain (see the
252
+ // tools/execute note in index.js).
253
+ return file;
254
+ }
255
+ if (scaled === bytes) return file; // non-rescalable format: original level
256
+ try {
257
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
258
+ /* The cache key covers the SOURCE DIRECTORY too: basename + size alone
259
+ would let a turn-done.wav from the custom dir collide with an unrelated
260
+ same-named, same-length file from the desktop, and the second user
261
+ would hear the first one's sound. */
262
+ const target = path.join(CACHE_DIR, `${path.basename(file, path.extname(file))}-v${volume}-${bytes.length}-${pathHash(file)}.wav`);
263
+ if (!fs.existsSync(target)) fs.writeFileSync(target, scaled);
264
+ return target;
265
+ } catch (error) {
266
+ return file;
267
+ }
268
+ }
269
+
270
+ /** The platform command matrix: try each entry until one runs. */
271
+ playerCommands(file) {
272
+ if (process.platform === 'win32') {
273
+ // PowerShell's Media.SoundPlayer takes a literal path, so quote-escape it
274
+ // and hand the whole script over as -EncodedCommand (no shell quoting
275
+ // games, no console window when run detached).
276
+ const escaped = file.replace(/'/g, "''");
277
+ const script = `$p = New-Object Media.SoundPlayer '${escaped}'; $p.PlaySync()`;
278
+ const encoded = Buffer.from(script, 'utf16le').toString('base64');
279
+ const args = ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded];
280
+ return [
281
+ [path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), ...args],
282
+ ['pwsh.exe', ...args],
283
+ ];
284
+ }
285
+ if (process.platform === 'darwin') return [['/usr/bin/afplay', file]];
286
+ return [['paplay', file], ['aplay', file]];
287
+ }
288
+
289
+ /**
290
+ * Play one slot.
291
+ *
292
+ * @param slot - slot id.
293
+ * @param options - `force` skips the debounce (used by the settings preview),
294
+ * `reason` is recorded in diagnostics.
295
+ * @returns the outcome, for the preview route to report.
296
+ */
297
+ play(slot, options) {
298
+ const force = options !== undefined && options.force === true;
299
+ const reason = options === undefined || options.reason === undefined ? '' : options.reason;
300
+ if (!SLOT_IDS.includes(slot)) return { played: false, why: `unknown slot "${slot}"` };
301
+ if (!this.enabled(PREF.enabled)) return { played: false, why: 'audio disabled' };
302
+ const volume = this.number(PREF.volume, 0, 100);
303
+ if (volume <= 0) return { played: false, why: 'volume is 0' };
304
+ const resolved = this.resolve(slot);
305
+ if (resolved === undefined) return { played: false, why: 'no sound file found' };
306
+ const debounce = this.number(PREF.debounceMs, 0, 60000);
307
+ const now = Date.now();
308
+ if (!force && now - (this.lastPlayed[slot] || 0) < debounce) {
309
+ return { played: false, why: `debounced (${debounce} ms window)`, file: resolved.file };
310
+ }
311
+ /* Second, independent gate: never start a slot while its own previous
312
+ announcement is still speaking.
313
+
314
+ The fixed window above is a floor for rapid repeats, but a real voice line
315
+ is not a chime: the generated Endfield lines run 3-5 seconds (measured), so
316
+ a 2.5 s window would let one line start on top of another. Deriving a
317
+ per-slot window from the file's own duration means a long announcement is
318
+ never clipped and a short chime is not held back — no per-slot setting to
319
+ keep in sync with whatever audio the user drops in. */
320
+ const playingUntil = this.playUntil[slot] || 0;
321
+ if (!force && now < playingUntil) {
322
+ const remaining = playingUntil - now;
323
+ return { played: false, why: `previous ${slot} still speaking (${remaining} ms left)`, file: resolved.file };
324
+ }
325
+ const subprocess = (() => {
326
+ try { return this.ctx.get('subprocess'); } catch (error) { return undefined; }
327
+ })();
328
+ if (subprocess === undefined || typeof subprocess.spawn !== 'function') {
329
+ if (!this.warnedNoSubprocess) {
330
+ this.warnedNoSubprocess = true;
331
+ this.note('unavailable', 'the subprocess service is absent; audio notifications stay silent');
332
+ }
333
+ return { played: false, why: 'subprocess service unavailable' };
334
+ }
335
+ /* `force` (the settings preview) bypasses this too: a preview whose whole job
336
+ is "let me hear it now" must not be refused because the previous
337
+ announcement has not finished, and the child process is fire-and-forget, so
338
+ overlapping one preview with a notification is harmless. */
339
+ if (!force && this.playing.has(slot)) return { played: false, why: 'previous play still running', file: resolved.file };
340
+
341
+ const file = this.applyVolume(resolved.file, volume);
342
+ const attempts = this.playerCommands(file);
343
+ this.lastPlayed[slot] = now;
344
+ /* Record when this announcement finishes so a second trigger cannot start on
345
+ top of it. Measured from the ORIGINAL file (the cache copy has identical
346
+ duration), plus a small margin for player start-up and process teardown. */
347
+ this.playUntil[slot] = now + this.soundDurationMs(resolved.file) + 250;
348
+ this.playing.add(slot);
349
+ const runtime = this;
350
+ let index = 0;
351
+ const tryNext = () => {
352
+ if (index >= attempts.length) {
353
+ runtime.playing.delete(slot);
354
+ /* Nothing sounded, so the not-speaking window recorded in play() must be
355
+ returned too — otherwise real notifications stay gated for the file's
356
+ whole duration after a total player failure. */
357
+ delete runtime.playUntil[slot];
358
+ runtime.note('failed', `${slot}: no player command succeeded`);
359
+ return;
360
+ }
361
+ const argv = attempts[index++];
362
+ let handle;
363
+ try {
364
+ handle = subprocess.spawn({
365
+ argv,
366
+ cwd: process.platform === 'win32' ? (process.env.SystemRoot || 'C:\\Windows') : '/',
367
+ stdio: { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' },
368
+ graceMs: 8000,
369
+ });
370
+ } catch (error) {
371
+ tryNext();
372
+ return;
373
+ }
374
+ Promise.resolve(handle.done).then(
375
+ () => { runtime.playing.delete(slot); },
376
+ (error) => {
377
+ runtime.note('player-failed', `${slot}: ${argv[0]}`);
378
+ tryNext();
379
+ },
380
+ );
381
+ };
382
+ tryNext();
383
+ if (this.diagnosing) {
384
+ this.note('played', `${slot} (${force ? 'preview' : reason || 'event'}) <- ${resolved.origin} volume=${volume}`);
385
+ }
386
+ return { played: true, file: resolved.file, origin: resolved.origin, volume };
387
+ }
388
+
389
+ /** Diagnostic snapshot for the settings page. */
390
+ snapshot() {
391
+ return {
392
+ enabled: this.enabled(PREF.enabled),
393
+ volume: this.number(PREF.volume, 0, 100),
394
+ debounceMs: this.number(PREF.debounceMs, 0, 60000),
395
+ humanOnly: this.enabled(PREF.humanOnly),
396
+ slots: SLOT_IDS.map((slot) => {
397
+ const resolved = this.resolve(slot);
398
+ return {
399
+ id: slot,
400
+ switchOn: this.enabled(PREF[slotSwitchKey(slot)]),
401
+ file: resolved === undefined ? null : resolved.file,
402
+ origin: resolved === undefined ? null : resolved.origin,
403
+ bundled: resolved !== undefined && resolved.origin === 'bundled',
404
+ };
405
+ }),
406
+ soundDirs: this.soundDirs(),
407
+ desktop: desktopDir() === undefined ? null : desktopDir(),
408
+ subprocess: (() => {
409
+ try { return typeof this.ctx.get('subprocess')?.spawn === 'function'; } catch (error) { return false; }
410
+ })(),
411
+ diagnosing: this.diagnosing,
412
+ log: this.diag.slice(-40),
413
+ };
414
+ }
415
+ }
416
+
417
+ /** Preference key holding one slot's own switch. */
418
+ function slotSwitchKey(slot) {
419
+ if (slot === 'boot') return 'boot';
420
+ if (slot === 'turn-start') return 'start';
421
+ if (slot === 'turn-done') return 'done';
422
+ if (slot === 'attention') return 'attention';
423
+ return 'fail';
424
+ }
425
+
426
+ module.exports = { AudioRuntime, PREF, FALLBACK, LOG_TAG, desktopDir, BUNDLED_SOUNDS, CACHE_DIR };
package/lib/slots.js ADDED
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+ /**
3
+ * Sound-slot definitions for the audio-notification feature.
4
+ *
5
+ * One entry per slot, each documented with the moment it marks so the settings
6
+ * page and the docs can describe them without a second source of truth.
7
+ *
8
+ * Wired to events by the host half: `boot` (the loader plates), `turn-start`,
9
+ * `turn-done` and `attention` (approval requests and pending user questions).
10
+ * Generated but NOT wired: `turn-fail` — a turn that fails on its own stays
11
+ * silent by design (see index.js), so the package already carries the whole
12
+ * set and a later version only has to add the trigger.
13
+ */
14
+
15
+ const SLOTS = {
16
+ /**
17
+ * 启动加载动画 —— the boot plate starts. A rising three-note figure that lands
18
+ * on the same G5 the task-end chime begins from, so the boot sound leads INTO
19
+ * that interval instead of competing with it. Shorter and lighter than
20
+ * `turn-start`: it marks a page arriving, not work beginning. Fired once per
21
+ * page load when the boot plate starts — through the host's preview route,
22
+ * which is also what the boot-sound switch in the settings page uses to let
23
+ * you hear it before committing.
24
+ */
25
+ boot: {
26
+ duration: 0.95,
27
+ peakDbfs: -3,
28
+ notes: [
29
+ { note: 'G4', start: 0, duration: 0.42, gain: 0.34, timbre: 'blip', decay: 2.2, curve: 2.4 },
30
+ { note: 'D5', start: 0.11, duration: 0.5, gain: 0.32, timbre: 'blip', decay: 1.9, curve: 2.2 },
31
+ { note: 'G5', start: 0.22, duration: 0.7, gain: 0.32, timbre: 'chime', decay: 1.5, curve: 2.0 },
32
+ { note: 'D6', start: 0.34, duration: 0.55, gain: 0.06, timbre: 'chime', decay: 2.0 },
33
+ ],
34
+ },
35
+
36
+ /**
37
+ * 任务开始 —— the user submitted a prompt from the composer and the agent is
38
+ * picking it up. Short, quiet, informational: it confirms the instruction was
39
+ * received without implying anything finished.
40
+ */
41
+ 'turn-start': {
42
+ duration: 0.5,
43
+ peakDbfs: -4,
44
+ notes: [
45
+ { note: 'F4', start: 0, duration: 0.3, gain: 0.42, timbre: 'blip', decay: 3.4, curve: 2.6 },
46
+ { note: 'A#4', start: 0.075, duration: 0.4, gain: 0.34, timbre: 'blip', decay: 3.0, curve: 2.4 },
47
+ { note: 'C5', start: 0.15, duration: 0.34, gain: 0.22, timbre: 'blip', decay: 3.6, curve: 2.4 },
48
+ ],
49
+ },
50
+
51
+ /**
52
+ * 任务结束 —— the agent delivered a final text result. Rising major third
53
+ * (C5 -> G5) with an almost inaudible C6 shimmer: closing, not celebratory.
54
+ */
55
+ 'turn-done': {
56
+ duration: 1.15,
57
+ peakDbfs: -2,
58
+ notes: [
59
+ { note: 'C5', start: 0, duration: 0.55, gain: 0.34, timbre: 'chime' },
60
+ { note: 'G5', start: 0.16, duration: 0.85, gain: 0.3, timbre: 'chime', decay: 1.0 },
61
+ { note: 'C6', start: 0.34, duration: 0.75, gain: 0.07, timbre: 'chime', decay: 1.4 },
62
+ ],
63
+ },
64
+
65
+ /**
66
+ * 需要你回应 (reserved) —— human attention required. Descending minor second
67
+ * with a detuned partial beats against the fundamental: unresolved on
68
+ * purpose, so it does not read as "done".
69
+ */
70
+ attention: {
71
+ duration: 1.0,
72
+ peakDbfs: -2,
73
+ notes: [
74
+ { note: 'A4', start: 0, duration: 0.5, gain: 0.34, timbre: 'chime', decay: 1.8 },
75
+ { note: 'G#4', start: 0.22, duration: 0.8, gain: 0.32, timbre: 'chime', decay: 1.5 },
76
+ { note: 'A4', start: 0, duration: 0.5, gain: 0.2, timbre: 'chime', detuneCents: 22, decay: 1.8 },
77
+ ],
78
+ },
79
+
80
+ /**
81
+ * 出错 (reserved) —— the turn failed. Low, hollow, two hits: reads as a fault
82
+ * rather than a completion.
83
+ */
84
+ 'turn-fail': {
85
+ duration: 0.9,
86
+ peakDbfs: -2,
87
+ notes: [
88
+ { note: 'F3', start: 0, duration: 0.28, gain: 0.4, timbre: 'hollow', decay: 2.6, attack: 0.003 },
89
+ { note: 'C3', start: 0.2, duration: 0.62, gain: 0.4, timbre: 'hollow', decay: 1.9, attack: 0.003 },
90
+ ],
91
+ },
92
+ };
93
+
94
+ /** Slot ids in playback/preview order. */
95
+ const SLOT_IDS = ['boot', 'turn-start', 'turn-done', 'attention', 'turn-fail'];
96
+
97
+ module.exports = { SLOTS, SLOT_IDS };