@williamzujkowski/live-coding-music-mcp 3.0.0 → 4.1.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 (53) hide show
  1. package/README.md +212 -780
  2. package/dist/AudioAnalyzer.js +1294 -91
  3. package/dist/PatternStore.js +276 -23
  4. package/dist/StrudelController.js +388 -56
  5. package/dist/server/resources.js +27 -20
  6. package/dist/server/server.js +391 -83
  7. package/dist/server/tools/ai.js +129 -152
  8. package/dist/server/tools/analysis.js +170 -91
  9. package/dist/server/tools/capture.js +128 -55
  10. package/dist/server/tools/compose.js +28 -42
  11. package/dist/server/tools/diagnostics.js +23 -53
  12. package/dist/server/tools/editor.js +63 -72
  13. package/dist/server/tools/generate.js +45 -167
  14. package/dist/server/tools/history.js +35 -66
  15. package/dist/server/tools/playback.js +7 -22
  16. package/dist/server/tools/session.js +41 -47
  17. package/dist/server/tools/storage.js +173 -50
  18. package/dist/server/tools/transform.js +136 -199
  19. package/dist/server/tools/types.js +209 -14
  20. package/dist/services/AudioCaptureService.js +302 -33
  21. package/dist/services/AudioExportService.js +318 -0
  22. package/dist/services/BrowserOnlyFunctions.js +149 -0
  23. package/dist/services/EventDensityProbe.js +324 -0
  24. package/dist/services/GeminiService.js +221 -36
  25. package/dist/services/IsolatedEngineRunner.js +308 -0
  26. package/dist/services/IsolatedStrudelEngine.js +135 -0
  27. package/dist/services/LocalPatternEngine.js +16 -0
  28. package/dist/services/MIDIExportService.js +765 -71
  29. package/dist/services/MIDIImportService.js +474 -0
  30. package/dist/services/MusicTheory.js +85 -13
  31. package/dist/services/PatternGenerator.js +168 -85
  32. package/dist/services/PatternSandbox.js +359 -0
  33. package/dist/services/SessionManager.js +338 -65
  34. package/dist/services/StrudelEngine.js +218 -12
  35. package/dist/services/StrudelEngineHelpers.js +24 -8
  36. package/dist/services/StyleRegistry.js +204 -0
  37. package/dist/services/Transferable.js +114 -0
  38. package/dist/services/ai/AiTransport.js +56 -0
  39. package/dist/services/ai/AudioMeasurements.js +155 -0
  40. package/dist/services/ai/CliTransport.js +256 -0
  41. package/dist/services/engineChild.js +83 -0
  42. package/dist/services/engineChildPath.js +28 -0
  43. package/dist/utils/CategorisedError.js +59 -0
  44. package/dist/utils/ErrorRecovery.js +130 -110
  45. package/dist/utils/InputValidator.js +55 -7
  46. package/dist/utils/PatternValidator.js +136 -17
  47. package/dist/utils/SafePath.js +112 -0
  48. package/dist/utils/ServerConfig.js +248 -0
  49. package/dist/utils/TableLookup.js +41 -0
  50. package/dist/utils/Tempo.js +143 -0
  51. package/package.json +17 -5
  52. package/dist/services/__mocks__/StrudelEngine.js +0 -157
  53. package/dist/types/index.js +0 -1
@@ -1,18 +1,19 @@
1
1
  /**
2
2
  * capture domain — audio recording and MIDI export.
3
3
  *
4
- * Owns (4 tools): start_audio_capture, stop_audio_capture,
5
- * capture_audio_sample, export_midi.
4
+ * Owns three tools: `audio_capture(action=start|stop|sample)`,
5
+ * `export_audio` (records a window and writes a file), and `export_midi`
6
+ * (kept separate — MIDI is symbolic, audio is waveform).
6
7
  *
7
- * Per the #110 audit, the three audio_capture tools collapse into a
8
- * single `audio_capture(action=start|stop|sample)` post-split.
9
- * `export_midi` stays separate — MIDI is symbolic, audio is waveform.
10
- *
11
- * The AudioCaptureService is lazy-instantiated module-side: we need
12
- * the current Playwright page for `injectRecorder()`, and that only
13
- * exists after `init`. The singleton survives across tool calls.
8
+ * AudioCaptureService instances are lazy-created per session: we need the
9
+ * session's Playwright page for `injectRecorder()`, and that only exists
10
+ * after `init`. The server holds them in a Map keyed by session id (#180),
11
+ * so each instance survives across tool calls and sessions do not share
12
+ * a recorder.
14
13
  */
14
+ import { categorizeError, err } from './types.js';
15
15
  import { InputValidator } from '../../utils/InputValidator.js';
16
+ import { declaredBpm } from '../../utils/Tempo.js';
16
17
  // AudioCaptureService lifecycle lives on the server; we fetch a shared
17
18
  // instance via ctx.getAudioCaptureService() so tests can mock the class
18
19
  // at module boundary without the extractor caching a stale instance.
@@ -27,7 +28,7 @@ function blobToBase64(arrayBuffer) {
27
28
  const SESSION_ID_PROP = {
28
29
  session_id: {
29
30
  type: 'string',
30
- description: 'Optional session ID (#108). Routes page reference to the named session. Note: AudioCaptureService is currently server-wide — concurrent captures across sessions will conflict.',
31
+ description: 'Optional session ID (#108). Routes the capture to the named session, which has its own recorder — concurrent captures across sessions are safe (#180).',
31
32
  },
32
33
  };
33
34
  export const tools = [
@@ -40,13 +41,18 @@ export const tools = [
40
41
  'Example: audio_capture({ action: "sample", duration: 3000 }). ' +
41
42
  'Audio must be playing for capture to record meaningful data. ' +
42
43
  'For MIDI export use export_midi; for runtime diagnostics use diagnostics. ' +
43
- 'Note: the AudioCaptureService is currently a server-wide singleton — concurrent captures across sessions will conflict.',
44
+ 'Each session has its own recorder, so captures in different sessions do not interfere (#180).',
44
45
  inputSchema: {
45
46
  type: 'object',
46
47
  properties: {
47
48
  action: { type: 'string', enum: ['start', 'stop', 'sample'], description: 'Capture action' },
48
49
  format: { type: 'string', enum: ['webm', 'opus'], description: 'action=start: audio format (default webm)' },
49
- maxDuration: { type: 'number', description: 'action=start: maximum capture duration ms' },
50
+ maxDuration: {
51
+ type: 'number',
52
+ description: 'action=start: stop the recorder automatically after this many ms. '
53
+ + 'Defaults to 600000 (ten minutes); the recording so far is kept and '
54
+ + 'returned by a later stop.',
55
+ },
50
56
  duration: { type: 'number', description: 'action=sample: duration ms (100-60000, default 5000)' },
51
57
  ...SESSION_ID_PROP,
52
58
  },
@@ -54,29 +60,21 @@ export const tools = [
54
60
  },
55
61
  },
56
62
  {
57
- name: 'start_audio_capture',
58
- description: '[DEPRECATED — use audio_capture({ action: "start" }) instead] Start capturing audio. Audio must be playing.',
59
- inputSchema: {
60
- type: 'object',
61
- properties: {
62
- format: { type: 'string', enum: ['webm', 'opus'], description: 'Audio format (default: webm)' },
63
- maxDuration: { type: 'number', description: 'Maximum capture duration in milliseconds' },
64
- ...SESSION_ID_PROP,
65
- },
66
- },
67
- },
68
- {
69
- name: 'stop_audio_capture',
70
- description: '[DEPRECATED — use audio_capture({ action: "stop" }) instead] Stop audio capture and return base64-encoded audio.',
71
- inputSchema: { type: 'object', properties: { ...SESSION_ID_PROP } },
72
- },
73
- {
74
- name: 'capture_audio_sample',
75
- description: '[DEPRECATED — use audio_capture({ action: "sample" }) instead] Capture a fixed-duration audio sample.',
63
+ name: 'export_audio',
64
+ description: 'Record a window of live Strudel audio and write it to a file. ' +
65
+ 'Returns a path plus what was actually recorded, not a wall of base64 — ' +
66
+ 'prefer this over audio_capture when you want the audio to exist somewhere. ' +
67
+ 'format=wav (default) decodes to 16-bit PCM a DAW will open; format=webm writes the raw Opus recording. ' +
68
+ 'Reports silent captures instead of writing a silent file and claiming success. ' +
69
+ 'Audio must already be playing: call playback({ action: "play" }) first. ' +
70
+ 'Example: export_audio({ duration: 4000, filename: "take-01" }).',
76
71
  inputSchema: {
77
72
  type: 'object',
78
73
  properties: {
79
- duration: { type: 'number', description: 'Duration in milliseconds (default: 5000)' },
74
+ duration: { type: 'number', description: 'Window to record in ms (100-30000, default 5000)' },
75
+ format: { type: 'string', enum: ['wav', 'webm'], description: 'Output format (default wav)' },
76
+ filename: { type: 'string', description: 'Output filename; reduced to a basename and confined to the export directory' },
77
+ output: { type: 'string', enum: ['file', 'base64'], description: 'file (default) writes to disk and returns a path; base64 returns bytes inline' },
80
78
  ...SESSION_ID_PROP,
81
79
  },
82
80
  },
@@ -89,7 +87,7 @@ export const tools = [
89
87
  properties: {
90
88
  filename: { type: 'string', description: 'Output filename (optional, default: pattern.mid)' },
91
89
  duration: { type: 'number', description: 'Export duration in bars (default: 4)' },
92
- bpm: { type: 'number', description: 'Tempo in BPM (default: 120)' },
90
+ bpm: { type: 'number', description: "Tempo in BPM (default: the pattern's own tempo, or 120 if it declares none)" },
93
91
  format: { type: 'string', enum: ['file', 'base64'], description: 'Output format: file or base64 (default: base64)' },
94
92
  ...SESSION_ID_PROP,
95
93
  },
@@ -113,66 +111,98 @@ export async function execute(name, args, ctx) {
113
111
  throw new Error(`Invalid action: ${a}. Must be one of: start, stop, sample`);
114
112
  }
115
113
  }
116
- case 'start_audio_capture':
117
- return await startAudioCapture(args?.format, args?.maxDuration, ctx, sid);
118
- case 'stop_audio_capture':
119
- return await stopAudioCapture(ctx, sid);
120
- case 'capture_audio_sample':
121
- return await captureAudioSample(args?.duration, ctx, sid);
114
+ case 'export_audio':
115
+ return await exportAudio(args, ctx, sid);
122
116
  case 'export_midi':
123
117
  return await exportMidi(args?.filename, args?.duration, args?.bpm, args?.format, ctx, sid);
124
118
  default:
125
119
  throw new Error(`capture module does not handle tool: ${name}`);
126
120
  }
127
121
  }
128
- async function startAudioCapture(format, _maxDuration, ctx, sid) {
122
+ async function startAudioCapture(format, maxDuration, ctx, sid) {
129
123
  try {
130
124
  const service = await ctx.getAudioCaptureService(sid);
131
125
  if (service.isCapturing()) {
132
- return { success: false, message: 'Audio capture already in progress. Stop it first.' };
126
+ return err('business', 'Audio capture already in progress. Stop it first.');
133
127
  }
134
- await service.startCapture(ctx.getController(sid).page, { format });
128
+ if (maxDuration !== undefined) {
129
+ InputValidator.validatePositiveInteger(maxDuration, 'maxDuration');
130
+ }
131
+ await service.startCapture(ctx.getController(sid).page, { format, maxDuration });
135
132
  return {
136
133
  success: true,
137
- message: 'Audio capture started. Use stop_audio_capture to get the recorded audio.',
138
- format: format || 'webm',
134
+ message: 'Audio capture started. Use audio_capture({ action: "stop" }) to get the recorded audio.',
135
+ // The mime the recorder actually produces, which is what `stop`
136
+ // reports. Echoing the requested `format` here meant start said
137
+ // "opus" and the matching stop said "audio/webm;codecs=opus" for
138
+ // one recording (#437). MediaRecorder gives a webm container with
139
+ // an Opus stream either way.
140
+ format: service.getMimeType(),
139
141
  };
140
142
  }
141
143
  catch (error) {
142
144
  const message = error instanceof Error ? error.message : String(error);
143
- return { success: false, message: `Failed to start audio capture: ${message}` };
145
+ return err(categorizeError(error), `Failed to start audio capture: ${message}`);
144
146
  }
145
147
  }
146
148
  async function stopAudioCapture(ctx, sid) {
147
149
  try {
148
150
  const service = await ctx.getAudioCaptureService(sid);
149
151
  if (!service.isCapturing()) {
150
- return { success: false, message: 'No audio capture in progress. Start capture first.' };
152
+ // Distinguish "nothing is recording" from "something else is".
153
+ //
154
+ // `export_audio` drives the page-side recorder directly and never
155
+ // touches this service's mirror, so for the whole of an export
156
+ // this branch answered "No audio capture in progress" — the
157
+ // opposite of what was happening (#437 item 9). The export is
158
+ // left to finish on its own; stopping its recorder from here
159
+ // would leave its own stopCapture with nothing to collect.
160
+ const page = ctx.getController(sid).page;
161
+ // Optional-chained, as `server.ts` does for `isInjectedInto`.
162
+ if (page && (await service.isPageCapturing?.(page)) === true) {
163
+ return err('business', 'An audio export is recording on this page. It stops on its own when '
164
+ + 'its duration elapses; audio_capture cannot end it early.');
165
+ }
166
+ return err('business', 'No audio capture in progress. Start capture first.');
151
167
  }
152
168
  const result = await service.stopCapture(ctx.getController(sid).page);
153
169
  const buf = await result.blob.arrayBuffer();
154
170
  return {
155
171
  success: true,
156
- message: `Captured ${result.duration}ms of audio`,
172
+ // A capture that hit `maxDuration` is shorter than the caller
173
+ // asked for. Saying so is the whole point of the page computing
174
+ // it — this handler used to drop it (#464).
175
+ message: `Captured ${result.duration}ms of audio`
176
+ + (result.warning ? ` — ${result.warning}` : ''),
157
177
  audio: blobToBase64(buf),
158
178
  duration: result.duration,
159
179
  format: result.format,
180
+ ...(result.warning ? { warning: result.warning } : {}),
160
181
  };
161
182
  }
162
183
  catch (error) {
163
184
  const message = error instanceof Error ? error.message : String(error);
164
- return { success: false, message: `Failed to stop audio capture: ${message}` };
185
+ return err(categorizeError(error), `Failed to stop audio capture: ${message}`);
165
186
  }
166
187
  }
167
188
  async function captureAudioSample(duration, ctx, sid) {
168
- const durationMs = duration || 5000;
189
+ // `?? 5000`, not `|| 5000`. Zero is a number the caller passed, and
190
+ // `||` swallowed it into the default, so `duration: 0` recorded five
191
+ // seconds instead of being refused by the bound two lines down. The
192
+ // same shape as every other falsy-vs-absent bug in this repo (#437
193
+ // item 7).
194
+ const durationMs = duration ?? 5000;
169
195
  if (durationMs < 100 || durationMs > 60000) {
170
- return { success: false, message: 'Duration must be between 100ms and 60000ms (1 minute)' };
196
+ // Validation, not business: the caller passed a number outside the
197
+ // allowed range, and no amount of setup changes that. My codemod
198
+ // guessed `business` from the absence of a nearby catch, which is
199
+ // the same guessing-by-shape this work exists to remove.
200
+ return err('validation', 'Duration must be between 100ms and 60000ms (1 minute)');
171
201
  }
172
202
  try {
173
203
  const service = await ctx.getAudioCaptureService(sid);
174
204
  if (service.isCapturing()) {
175
- return { success: false, message: 'Audio capture already in progress. Stop it first.' };
205
+ return err('business', 'Audio capture already in progress. Stop it first.');
176
206
  }
177
207
  const result = await service.captureForDuration(ctx.getController(sid).page, durationMs);
178
208
  const buf = await result.blob.arrayBuffer();
@@ -186,32 +216,71 @@ async function captureAudioSample(duration, ctx, sid) {
186
216
  }
187
217
  catch (error) {
188
218
  const message = error instanceof Error ? error.message : String(error);
189
- return { success: false, message: `Failed to capture audio sample: ${message}` };
219
+ return err(categorizeError(error), `Failed to capture audio sample: ${message}`);
220
+ }
221
+ }
222
+ async function exportAudio(args, ctx, sid) {
223
+ try {
224
+ const service = ctx.audioExportService;
225
+ // Ensures the recorder is injected for this session; export needs the
226
+ // same GainNode interception audio_capture uses.
227
+ await ctx.getAudioCaptureService(sid);
228
+ const result = await service.exportAudio(ctx.getController(sid).page, {
229
+ duration: args?.duration,
230
+ format: args?.format,
231
+ filename: args?.filename,
232
+ output: args?.output,
233
+ });
234
+ if (!result.success) {
235
+ return { success: false, message: result.error ?? 'Audio export failed.' };
236
+ }
237
+ return {
238
+ ...result,
239
+ message: result.path
240
+ ? `Exported ${String(result.bytes)} bytes of ${result.format} to ${result.path}`
241
+ : `Captured ${String(result.bytes)} bytes of ${result.format}`,
242
+ };
243
+ }
244
+ catch (error) {
245
+ const message = error instanceof Error ? error.message : String(error);
246
+ return err(categorizeError(error), `Audio export failed: ${message}`);
190
247
  }
191
248
  }
192
249
  async function exportMidi(filename, bars, bpm, format, ctx, sid) {
193
250
  if (bpm !== undefined)
194
251
  InputValidator.validateBPM(bpm);
195
252
  if (bars !== undefined && (bars < 1 || bars > 128)) {
196
- return { success: false, message: 'Bars must be between 1 and 128' };
253
+ return err('validation', 'Bars must be between 1 and 128');
197
254
  }
198
255
  const pattern = await ctx.getCurrentPatternSafe(sid);
199
256
  if (!pattern || pattern.trim().length === 0) {
200
- return { success: false, message: 'No pattern to export. Write a pattern first.' };
257
+ return err('business', 'No pattern to export. Write a pattern first.');
201
258
  }
202
- const exportOptions = { bpm: bpm || 120, bars: bars || 4 };
259
+ // The pattern's own tempo, unless the caller asked for a different
260
+ // one. This used to be a flat `bpm || 120`, so exporting a 174 BPM
261
+ // pattern wrote a 120 BPM file and a round trip silently rewrote the
262
+ // tempo — the same guarantee #336 protects for the bar (#399).
263
+ const exportOptions = { bpm: bpm ?? declaredBpm(pattern) ?? 120, bars: bars ?? 4 };
203
264
  const outputFormat = format || 'base64';
204
265
  if (outputFormat === 'file') {
205
266
  const result = ctx.midiExportService.exportToFile(pattern, filename, exportOptions);
206
267
  return {
207
268
  success: result.success,
269
+ // The warning has to reach the caller or computing it in the
270
+ // service is pointless: this handler builds its own response and
271
+ // used to drop it, so an export that skipped most of a pattern
272
+ // still reported "Exported 2 notes" and nothing else (#335).
208
273
  message: result.success
209
274
  ? `Exported ${result.noteCount} notes to ${result.output}`
275
+ + (result.warning ? ` — ${result.warning}` : '')
210
276
  : result.error || 'Export failed',
211
277
  output: result.output,
212
278
  noteCount: result.noteCount,
213
279
  bars: result.bars,
214
280
  bpm: result.bpm,
281
+ ...(result.warning ? { warning: result.warning } : {}),
282
+ ...(result.unrepresented ? { unrepresented: result.unrepresented } : {}),
283
+ ...(result.partiallyExported ? { partiallyExported: result.partiallyExported } : {}),
215
284
  error: result.error,
216
285
  };
217
286
  }
@@ -220,11 +289,15 @@ async function exportMidi(filename, bars, bpm, format, ctx, sid) {
220
289
  success: result.success,
221
290
  message: result.success
222
291
  ? `Exported ${result.noteCount} notes as base64 MIDI data`
292
+ + (result.warning ? ` — ${result.warning}` : '')
223
293
  : result.error || 'Export failed',
224
294
  output: result.output,
225
295
  noteCount: result.noteCount,
226
296
  bars: result.bars,
227
297
  bpm: result.bpm,
298
+ ...(result.warning ? { warning: result.warning } : {}),
299
+ ...(result.unrepresented ? { unrepresented: result.unrepresented } : {}),
300
+ ...(result.partiallyExported ? { partiallyExported: result.partiallyExported } : {}),
228
301
  error: result.error,
229
302
  };
230
303
  }
@@ -1,16 +1,10 @@
1
1
  /**
2
2
  * compose domain — high-level UX tools that aren't a thin verb on a
3
- * subsystem: `compose` (one-shot generate+write+play) and `show_browser`
4
- * (bring the Strudel window forward).
5
- *
6
- * Post-consolidation (per #110 audit):
7
- * - `compose` absorbs `generate_pattern` after the alias period
8
- * - `show_browser` merges with `screenshot` into `browser_window`
9
- * These targets live in different epics (#120). Keeping the two tools
10
- * together here matches today's "UX Tools" grouping; the file is small
11
- * enough to split later without disruption.
3
+ * subsystem: `compose` (one-shot generate+write+play) and
4
+ * `browser_window` (show or screenshot the Strudel window).
12
5
  */
13
6
  import { InputValidator } from '../../utils/InputValidator.js';
7
+ import { defaultTempoFor, resolveLayers } from '../../services/StyleRegistry.js';
14
8
  const SESSION_ID_PROP = {
15
9
  session_id: {
16
10
  type: 'string',
@@ -35,18 +29,13 @@ export const tools = [
35
29
  required: ['action'],
36
30
  },
37
31
  },
38
- {
39
- name: 'show_browser',
40
- description: '[DEPRECATED — use browser_window({ action: "show" }) instead] Bring browser window to foreground',
41
- inputSchema: { type: 'object', properties: { ...SESSION_ID_PROP } },
42
- },
43
32
  {
44
33
  name: 'compose',
45
34
  description: 'Generate, write, and play a complete pattern in one step. Auto-initializes default browser if needed.',
46
35
  inputSchema: {
47
36
  type: 'object',
48
37
  properties: {
49
- style: { type: 'string', description: 'Genre: techno, house, dnb, ambient, trap, jungle, jazz, experimental' },
38
+ style: { type: 'string', description: 'Genre: techno, house, dnb, breakbeat, trap, jungle, ambient, experimental, intelligent_dnb, trip_hop, boom_bap. Anything else (jazz included) gets techno drums, and the response says so.' },
50
39
  tempo: { type: 'number', description: 'BPM (default: genre-appropriate)' },
51
40
  key: { type: 'string', description: 'Musical key (default: C)' },
52
41
  auto_play: { type: 'boolean', description: 'Start playback immediately (default: true)' },
@@ -58,27 +47,6 @@ export const tools = [
58
47
  },
59
48
  ];
60
49
  export const toolNames = new Set(tools.map(t => t.name));
61
- const TEMPO_BY_STYLE = {
62
- techno: 130,
63
- house: 125,
64
- dnb: 174,
65
- 'drum and bass': 174,
66
- ambient: 80,
67
- trap: 140,
68
- jungle: 160,
69
- jazz: 110,
70
- experimental: 120,
71
- dubstep: 140,
72
- trance: 138,
73
- breakbeat: 130,
74
- garage: 130,
75
- electro: 128,
76
- downtempo: 90,
77
- idm: 115,
78
- };
79
- function defaultTempo(style) {
80
- return TEMPO_BY_STYLE[style.toLowerCase()] ?? 120;
81
- }
82
50
  async function doShow(ctx, sid) {
83
51
  if (!sid && !ctx.isInitialized()) {
84
52
  return 'Browser not initialized. Run init first.';
@@ -101,8 +69,6 @@ export async function execute(name, args, ctx) {
101
69
  }
102
70
  return a === 'show' ? await doShow(ctx, sid) : await doScreenshot(args, ctx, sid);
103
71
  }
104
- case 'show_browser':
105
- return await doShow(ctx, sid);
106
72
  case 'compose': {
107
73
  InputValidator.validateStringLength(args.style, 'style', 100, false);
108
74
  if (args.key) {
@@ -112,13 +78,21 @@ export async function execute(name, args, ctx) {
112
78
  InputValidator.validateBPM(args.tempo);
113
79
  }
114
80
  // Auto-init only the default session. Named sessions must be
115
- // created via `create_session` first — ctx.getController(sid)
81
+ // created via `session({ action: "create" })` first — ctx.getController(sid)
116
82
  // throws if missing, which the dispatcher renders as err('business').
117
83
  if (!sid) {
118
84
  await ctx.ensureInitialized();
119
85
  }
120
86
  const controller = ctx.getController(sid);
121
- const tempo = args.tempo || defaultTempo(args.style);
87
+ // compose assembles four independently-resolved layers, each
88
+ // falling back on its own. One `style` field cannot describe that
89
+ // — reporting only the drum resolution called
90
+ // compose({style:'jazz'}) "techno" while its bassline, Dorian
91
+ // scale and jazz chords were all genuinely jazz, and let
92
+ // `breakbeat` claim itself supported over a techno bassline
93
+ // (#294, correcting #279).
94
+ const resolution = resolveLayers(args.style);
95
+ const tempo = args.tempo || defaultTempoFor(args.style);
122
96
  const key = args.key || 'C';
123
97
  const pattern = ctx.generator.generateCompletePattern(args.style, key, tempo);
124
98
  await controller.writePattern(pattern);
@@ -129,9 +103,21 @@ export async function execute(name, args, ctx) {
129
103
  const response = {
130
104
  success: true,
131
105
  pattern: pattern.substring(0, 200) + (pattern.length > 200 ? '...' : ''),
132
- metadata: { style: args.style, bpm: tempo, key },
106
+ metadata: {
107
+ style: args.style,
108
+ bpm: tempo,
109
+ key,
110
+ layers: resolution.layers,
111
+ substituted: resolution.substituted,
112
+ },
133
113
  status: shouldPlay ? 'playing' : 'ready',
134
- message: `Created ${args.style} pattern in ${key}${shouldPlay ? ' - now playing' : ''}`,
114
+ message: resolution.substituted.length === 0
115
+ ? `Created ${args.style} pattern in ${key}${shouldPlay ? ' - now playing' : ''}`
116
+ : `Created pattern in ${key}${shouldPlay ? ' - now playing' : ''}. ` +
117
+ `No ${resolution.substituted.join(' or ')} defined for style "${args.style}" — ` +
118
+ resolution.substituted
119
+ .map(l => `${l}: ${resolution.layers[l]}`)
120
+ .join(', ') + '.',
135
121
  };
136
122
  if (args.get_feedback) {
137
123
  if (ctx.geminiService.isAvailable()) {
@@ -2,18 +2,15 @@
2
2
  * diagnostics domain — observability tools (browser status, errors,
3
3
  * server metrics).
4
4
  *
5
- * Owns one consolidated tool (`diagnostics(level)`) plus four deprecated
6
- * aliases (`status`, `show_errors`, `performance_report`, `memory_usage`)
7
- * per #120 / #144.
5
+ * Owns one consolidated tool (`diagnostics(level)`) with status/full/
6
+ * perf/memory/errors levels.
8
7
  *
9
8
  * The `<15ms` SLA on `level='status'` is preserved — that branch only
10
9
  * reads from the controller's cache, not the browser. Other levels can
11
10
  * touch the browser (`level='full'`) or just inspect server state
12
11
  * (`perf`, `memory`).
13
- *
14
- * `screenshot` stays separate; it folds into `browser_window` per
15
- * Phase 3 (#156).
16
12
  */
13
+ import { empty } from './types.js';
17
14
  const SESSION_ID_PROP = {
18
15
  session_id: {
19
16
  type: 'string',
@@ -24,14 +21,14 @@ export const tools = [
24
21
  {
25
22
  name: 'diagnostics',
26
23
  description: 'Inspect server and browser state. ' +
27
- 'level=status returns a quick state snapshot (cache read, <15ms SLA). ' +
24
+ 'level=status returns a quick state snapshot (cache read, <15ms SLA), plus which config.json was read and any warnings from it. ' +
28
25
  'level=full returns detailed browser diagnostics including caches, errors, and performance. ' +
29
26
  'level=perf returns server-side timing metrics + top bottlenecks. ' +
30
27
  'level=memory returns process memory usage. ' +
31
28
  'level=errors returns captured console errors and warnings from Strudel. ' +
32
29
  'Default level=full preserves the pre-consolidation behaviour. ' +
33
30
  'Example: diagnostics({ level: "status" }) — millisecond-cheap. ' +
34
- 'For screenshots use browser_window (Phase 3 / #156); for tool listings use the strudel://docs/tools resource.',
31
+ 'For screenshots use browser_window; for tool listings use the strudel://docs/tools resource.',
35
32
  inputSchema: {
36
33
  type: 'object',
37
34
  properties: {
@@ -44,37 +41,6 @@ export const tools = [
44
41
  },
45
42
  },
46
43
  },
47
- {
48
- name: 'status',
49
- description: '[DEPRECATED — use diagnostics({ level: "status" }) instead] Get current browser and playback status (quick state check)',
50
- inputSchema: { type: 'object', properties: { ...SESSION_ID_PROP } },
51
- },
52
- {
53
- name: 'show_errors',
54
- description: '[DEPRECATED — use diagnostics({ level: "errors" }) instead] Display captured console errors and warnings from Strudel',
55
- inputSchema: { type: 'object', properties: { ...SESSION_ID_PROP } },
56
- },
57
- {
58
- name: 'performance_report',
59
- description: '[DEPRECATED — use diagnostics({ level: "perf" }) instead] Get performance metrics and bottlenecks',
60
- inputSchema: { type: 'object', properties: {} },
61
- },
62
- {
63
- name: 'memory_usage',
64
- description: '[DEPRECATED — use diagnostics({ level: "memory" }) instead] Get current memory usage statistics',
65
- inputSchema: { type: 'object', properties: {} },
66
- },
67
- {
68
- name: 'screenshot',
69
- description: '[DEPRECATED — use browser_window({ action: "screenshot" }) instead] Take a screenshot of the current Strudel editor state',
70
- inputSchema: {
71
- type: 'object',
72
- properties: {
73
- filename: { type: 'string', description: 'Optional filename for screenshot' },
74
- ...SESSION_ID_PROP,
75
- },
76
- },
77
- },
78
44
  ];
79
45
  export const toolNames = new Set(tools.map(t => t.name));
80
46
  function getPerformanceReport(ctx) {
@@ -87,7 +53,23 @@ function getMemoryUsage(ctx) {
87
53
  return memory ? JSON.stringify(memory, null, 2) : 'Memory usage not available';
88
54
  }
89
55
  function getStatus(ctx, sid) {
90
- return ctx.getController(sid).getStatus();
56
+ const status = ctx.getController(sid).getStatus();
57
+ const report = ctx.configReport;
58
+ if (!report)
59
+ return status;
60
+ // Config problems reached `logger.warn` and nothing else — a client
61
+ // log file the user may never open. A `strudel_url` typo, or an
62
+ // `audio_analysis` block that parsed to nothing, was invisible from
63
+ // inside the session. The path is reported whether or not a file was
64
+ // found, so a cwd surprise is visible rather than guessed at (#442).
65
+ return {
66
+ ...(typeof status === 'object' && status !== null ? status : { status }),
67
+ config: {
68
+ path: report.path,
69
+ found: report.found,
70
+ ...(report.warnings.length > 0 ? { warnings: report.warnings } : {}),
71
+ },
72
+ };
91
73
  }
92
74
  async function getFullDiagnostics(ctx, sid) {
93
75
  if (!sid && !ctx.isInitialized()) {
@@ -103,7 +85,7 @@ function getErrors(ctx, sid) {
103
85
  const errors = controller.getConsoleErrors();
104
86
  const warnings = controller.getConsoleWarnings();
105
87
  if (errors.length === 0 && warnings.length === 0) {
106
- return 'No errors or warnings captured.';
88
+ return empty('No errors or warnings captured.');
107
89
  }
108
90
  let result = '';
109
91
  if (errors.length > 0) {
@@ -129,18 +111,6 @@ export async function execute(name, args, ctx) {
129
111
  throw new Error(`Invalid level: ${level}. Must be one of: status, full, perf, memory, errors`);
130
112
  }
131
113
  }
132
- // Deprecated aliases — forward to consolidated handler. Kept for ≥1
133
- // release per #120 migration policy.
134
- case 'status': return getStatus(ctx, sid);
135
- case 'show_errors': return getErrors(ctx, sid);
136
- case 'performance_report': return getPerformanceReport(ctx);
137
- case 'memory_usage': return getMemoryUsage(ctx);
138
- case 'screenshot': {
139
- if (!sid && !ctx.isInitialized()) {
140
- return 'Browser not initialized. Run init first.';
141
- }
142
- return await ctx.getController(sid).takeScreenshot(args?.filename);
143
- }
144
114
  default:
145
115
  throw new Error(`diagnostics module does not handle tool: ${name}`);
146
116
  }