@williamzujkowski/live-coding-music-mcp 2.0.0 → 4.0.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.
@@ -1,6 +1,6 @@
1
1
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
- import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
3
+ import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
4
4
  import { StrudelController } from '../StrudelController.js';
5
5
  import { PatternStore } from '../PatternStore.js';
6
6
  import { MusicTheory } from '../services/MusicTheory.js';
@@ -12,7 +12,6 @@ import { SessionManager } from '../services/SessionManager.js';
12
12
  import { readFileSync, existsSync } from 'fs';
13
13
  import { Logger } from '../utils/Logger.js';
14
14
  import { PerformanceMonitor } from '../utils/PerformanceMonitor.js';
15
- import { InputValidator } from '../utils/InputValidator.js';
16
15
  import { StrudelEngine } from '../services/StrudelEngine.js';
17
16
  import { diagnosticsModule } from './tools/diagnostics.js';
18
17
  import { playbackModule } from './tools/playback.js';
@@ -25,13 +24,14 @@ import { generateModule } from './tools/generate.js';
25
24
  import { sessionModule } from './tools/session.js';
26
25
  import { captureModule } from './tools/capture.js';
27
26
  import { aiModule } from './tools/ai.js';
27
+ import { composeModule } from './tools/compose.js';
28
+ import { categorizeError, err, isEnvelope, ok } from './tools/types.js';
29
+ import { readResource, resources as mcpResources } from './resources.js';
30
+ import { join } from 'node:path';
28
31
  const configPath = './config.json';
29
32
  const config = existsSync(configPath)
30
33
  ? JSON.parse(readFileSync(configPath, 'utf-8'))
31
34
  : { headless: false };
32
- /** Energy level configuration for set_energy tool (#81) */
33
- // MOOD_PROFILES + ENERGY_LEVELS moved to src/server/tools/transform.ts
34
- // alongside the shift_mood / set_energy / refine handlers that use them.
35
35
  export class StrudelMCPServer {
36
36
  server;
37
37
  controller;
@@ -39,29 +39,39 @@ export class StrudelMCPServer {
39
39
  theory;
40
40
  generator;
41
41
  geminiService;
42
- audioCaptureService = null;
42
+ /**
43
+ * Per-session AudioCaptureService instances (#180). Keyed by session id
44
+ * (or 'default' for the legacy/single-session path). Each session's
45
+ * recorder injects into its own page, so concurrent captures across
46
+ * sessions no longer conflict.
47
+ */
48
+ audioCaptureServices = new Map();
43
49
  midiExportService;
44
50
  sessionManager;
45
51
  logger;
46
52
  perfMonitor;
47
53
  strudelEngine;
48
54
  sessionHistory = [];
49
- undoStack = [];
50
- redoStack = [];
51
- /** Pattern history with metadata for browsing (#41) */
52
- historyStack = [];
55
+ /**
56
+ * Per-session undo/redo/history bundles (#179). Keyed by session id;
57
+ * 'default' for the legacy/single-session path. Bundles are lazily
58
+ * created on first access via getHistory().
59
+ */
60
+ historyBundles = new Map();
61
+ /** Server-wide history-entry counter; IDs unique across all sessions. */
53
62
  historyIdCounter = 0;
54
- /** Maximum history entries to prevent memory leaks */
63
+ /** Maximum history entries per session to prevent memory leaks */
55
64
  MAX_HISTORY = 100;
56
65
  isInitialized = false;
57
66
  generatedPatterns = new Map();
58
67
  constructor() {
59
68
  this.server = new Server({
60
69
  name: 'live-coding-music-mcp',
61
- version: '2.0.1',
70
+ version: '4.0.0',
62
71
  }, {
63
72
  capabilities: {
64
73
  tools: {},
74
+ resources: {},
65
75
  },
66
76
  });
67
77
  this.controller = new StrudelController(config.headless);
@@ -77,70 +87,24 @@ export class StrudelMCPServer {
77
87
  this.setupHandlers();
78
88
  }
79
89
  getTools() {
80
- // Same tools as before - keeping the same structure
81
90
  return [
82
- // Core Control Tools (10)
83
91
  {
84
92
  name: 'init',
85
93
  description: 'Initialize Strudel in browser',
86
- inputSchema: { type: 'object', properties: {} }
94
+ inputSchema: { type: 'object', properties: {} },
87
95
  },
88
- // write, append, insert, replace, clear, get_pattern —
89
- // extracted to src/server/tools/editor.ts (#104)
90
96
  ...editorModule.tools,
91
- // play, pause, stop — extracted to src/server/tools/playback.ts (#104)
92
97
  ...playbackModule.tools,
93
- // transform + effect + shape + set_tempo — extracted to src/server/tools/transform.ts (#104)
94
98
  ...transformModule.tools,
95
- // generate_pattern, generate_drums, generate_bassline, generate_melody,
96
- // generate_scale, generate_chord_progression, generate_euclidean,
97
- // generate_polyrhythm, generate_fill — extracted to src/server/tools/generate.ts (#104)
98
99
  ...generateModule.tools,
99
- // Audio Analysis + runtime validation — extracted to src/server/tools/analysis.ts (#104)
100
100
  ...analysisModule.tools,
101
- // add_effect, remove_effect, set_tempo, add_swing, apply_scale,
102
- // shift_mood — all handled by transformModule (see above).
103
- // save, load, list — extracted to src/server/tools/storage.ts (#104)
104
101
  ...storageModule.tools,
105
- // undo, redo, list_history, restore_history, compare_patterns
106
- // — extracted to src/server/tools/history.ts (#104)
107
102
  ...historyModule.tools,
108
- // generate_scale, generate_chord_progression, generate_euclidean,
109
- // generate_polyrhythm, generate_fill — handled by generateModule (above).
110
- // Performance, diagnostics, screenshots — extracted to src/server/tools/diagnostics.ts (#104)
111
103
  ...diagnosticsModule.tools,
112
- // UX Tools - Browser Control (#37)
113
- {
114
- name: 'show_browser',
115
- description: 'Bring browser window to foreground for visual feedback',
116
- inputSchema: { type: 'object', properties: {} }
117
- },
118
- // UX Tools - High-level Compose (#42, #73)
119
- {
120
- name: 'compose',
121
- description: 'Generate, write, and play a complete pattern in one step. Auto-initializes browser if needed.',
122
- inputSchema: {
123
- type: 'object',
124
- properties: {
125
- style: { type: 'string', description: 'Genre: techno, house, dnb, ambient, trap, jungle, jazz, experimental' },
126
- tempo: { type: 'number', description: 'BPM (default: genre-appropriate)' },
127
- key: { type: 'string', description: 'Musical key (default: C)' },
128
- auto_play: { type: 'boolean', description: 'Start playback immediately (default: true)' },
129
- get_feedback: { type: 'boolean', description: 'Get AI feedback on the generated pattern (default: false)' }
130
- },
131
- required: ['style']
132
- }
133
- },
134
- // get_pattern_feedback, suggest_pattern_from_audio, jam_with —
135
- // extracted to src/server/tools/ai.ts (#104)
104
+ ...composeModule.tools,
136
105
  ...aiModule.tools,
137
- // start_audio_capture, stop_audio_capture, capture_audio_sample,
138
- // export_midi — extracted to src/server/tools/capture.ts (#104)
139
106
  ...captureModule.tools,
140
- // create_session, destroy_session, list_sessions, switch_session —
141
- // extracted to src/server/tools/session.ts (#104)
142
107
  ...sessionModule.tools,
143
- // refine, set_energy — handled by transformModule (see above).
144
108
  ];
145
109
  }
146
110
  setupHandlers() {
@@ -149,60 +113,115 @@ export class StrudelMCPServer {
149
113
  }));
150
114
  this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
151
115
  const { name, arguments: args } = request.params;
116
+ const envelope = await this.dispatchToolCall(name, args);
117
+ return {
118
+ content: [{ type: 'text', text: JSON.stringify(envelope, null, 2) }],
119
+ };
120
+ });
121
+ // MCP resources — read-only catalogs (#131). Resource handlers stay
122
+ // here in server.ts because they're protocol surface, not tool calls.
123
+ this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({
124
+ resources: mcpResources,
125
+ }));
126
+ this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
127
+ const uri = request.params.uri;
152
128
  try {
153
- this.logger.info(`Executing tool: ${name}`, args);
154
- // Measure performance
155
- const result = await this.perfMonitor.measureAsync(name, () => this.executeTool(name, args));
156
- return {
157
- content: [{
158
- type: 'text',
159
- text: typeof result === 'string' ? result : JSON.stringify(result, null, 2)
160
- }],
161
- };
129
+ const content = await readResource(uri, {
130
+ store: this.store,
131
+ examplesDir: join(process.cwd(), 'patterns', 'examples'),
132
+ });
133
+ return { contents: [content] };
162
134
  }
163
135
  catch (error) {
164
136
  const message = error instanceof Error ? error.message : String(error);
165
- this.logger.error(`Tool execution failed: ${name}`, { error: message });
166
- return {
167
- content: [{
168
- type: 'text',
169
- text: `Error: ${message}`
170
- }],
171
- };
137
+ this.logger.error(`Resource read failed: ${uri}`, { error: message });
138
+ throw error;
172
139
  }
173
140
  });
174
141
  }
175
- requiresInitialization(toolName) {
176
- const toolsRequiringInit = [
177
- 'write', 'append', 'insert', 'replace', 'play', 'pause', 'stop',
178
- 'clear', 'get_pattern', 'analyze', 'analyze_spectrum', 'analyze_rhythm',
179
- 'transpose', 'reverse', 'stretch', 'humanize', 'generate_variation',
180
- 'add_effect', 'add_swing', 'set_tempo', 'save', 'undo', 'redo',
181
- 'validate_pattern_runtime'
182
- ];
183
- const toolsRequiringWrite = [
184
- 'generate_pattern', 'generate_drums', 'generate_bassline', 'generate_melody',
185
- 'generate_chord_progression', 'generate_euclidean', 'generate_polyrhythm',
186
- 'generate_fill'
187
- ];
188
- return toolsRequiringInit.includes(toolName) || toolsRequiringWrite.includes(toolName);
142
+ /**
143
+ * Wraps every tool call into the shared envelope (#130).
144
+ *
145
+ * Tools may return:
146
+ * - a raw value → auto-wrapped via `ok(value)`
147
+ * - a pre-built Envelope (via the `ok()`/`err()`/`empty()` helpers in
148
+ * `tools/types.ts`) → passed through unchanged
149
+ * - an "error-shaped" string (starts with "Error: " or "Browser not
150
+ * initialized") → converted to `err(...)` so legacy modules still
151
+ * surface as failures while their internal migration to envelope
152
+ * helpers proceeds incrementally
153
+ *
154
+ * Thrown errors are caught and converted to `err(...)` with a category
155
+ * inferred from the message (see `categorizeError`).
156
+ */
157
+ async dispatchToolCall(name, args) {
158
+ try {
159
+ this.logger.info(`Executing tool: ${name}`, args);
160
+ const result = await this.perfMonitor.measureAsync(name, () => this.executeTool(name, args));
161
+ if (isEnvelope(result)) {
162
+ return result;
163
+ }
164
+ // Convert legacy "Error: ..." / "Browser not initialized" string returns
165
+ // into the envelope. Module-level migration is incremental — this branch
166
+ // shrinks as modules adopt the helpers natively.
167
+ if (typeof result === 'string') {
168
+ if (result.startsWith('Error: ')) {
169
+ return err('internal', result.slice('Error: '.length));
170
+ }
171
+ if (result.startsWith('Browser not initialized')) {
172
+ return err('business', result);
173
+ }
174
+ return ok(result);
175
+ }
176
+ return ok(result);
177
+ }
178
+ catch (error) {
179
+ const message = error instanceof Error ? error.message : String(error);
180
+ this.logger.error(`Tool execution failed: ${name}`, { error: message });
181
+ return err(categorizeError(error), message);
182
+ }
189
183
  }
190
- async getCurrentPatternSafe() {
184
+ // requiresInitialization() removed (#141 / #108): each module's
185
+ // execute() does its own session-aware init check. The hardcoded
186
+ // tool-name lists had drifted and were silently wrong for explicit
187
+ // session_id.
188
+ async getCurrentPatternSafe(sessionId) {
189
+ if (sessionId) {
190
+ // Explicit session — strict lookup, no pre-init stash. Named sessions
191
+ // must be created via `session({ action: "create" })` before they're useful.
192
+ const sessionController = this.sessionManager.getSession(sessionId);
193
+ if (!sessionController) {
194
+ throw new Error(`Session '${sessionId}' not found. Create it first with session({ action: "create" }).`);
195
+ }
196
+ try {
197
+ return await sessionController.getCurrentPattern();
198
+ }
199
+ catch {
200
+ return '';
201
+ }
202
+ }
191
203
  if (!this.isInitialized) {
192
- // Return the last generated pattern if available
204
+ // Default session: return the last pre-init generated pattern if any
193
205
  const lastPattern = Array.from(this.generatedPatterns.values()).pop();
194
206
  return lastPattern || '';
195
207
  }
196
208
  try {
197
209
  return await this.controller.getCurrentPattern();
198
210
  }
199
- catch (e) {
211
+ catch {
200
212
  return '';
201
213
  }
202
214
  }
203
- async writePatternSafe(pattern) {
215
+ async writePatternSafe(pattern, sessionId) {
216
+ if (sessionId) {
217
+ const sessionController = this.sessionManager.getSession(sessionId);
218
+ if (!sessionController) {
219
+ throw new Error(`Session '${sessionId}' not found. Create it first with session({ action: "create" }).`);
220
+ }
221
+ return await sessionController.writePattern(pattern);
222
+ }
204
223
  if (!this.isInitialized) {
205
- // Store the pattern for later use
224
+ // Default session: stash the pattern for the next init/auto-init
206
225
  const id = `pattern_${Date.now()}`;
207
226
  this.generatedPatterns.set(id, pattern);
208
227
  return `Pattern generated (initialize Strudel to use it): ${pattern.substring(0, 50)}...`;
@@ -221,7 +240,7 @@ export class StrudelMCPServer {
221
240
  if (sessionId) {
222
241
  const controller = this.sessionManager.getSession(sessionId);
223
242
  if (!controller) {
224
- throw new Error(`Session '${sessionId}' not found. Create it first with create_session.`);
243
+ throw new Error(`Session '${sessionId}' not found. Create it first with session({ action: "create" }).`);
225
244
  }
226
245
  return controller;
227
246
  }
@@ -246,41 +265,40 @@ export class StrudelMCPServer {
246
265
  return this.sessionManager.getDefaultSession() !== undefined || this.isInitialized;
247
266
  }
248
267
  async executeTool(name, args) {
249
- // Check if tool needs initialization
250
- if (this.requiresInitialization(name) && !this.isInitialized && name !== 'init') {
251
- // For generation tools that don't require browser, handle them specially
252
- const generationTools = [
253
- 'generate_pattern', 'generate_drums', 'generate_bassline', 'generate_melody',
254
- 'generate_chord_progression', 'generate_euclidean', 'generate_polyrhythm', 'generate_fill'
255
- ];
256
- if (!generationTools.includes(name)) {
257
- return `Browser not initialized. Run 'init' first to use ${name}.`;
258
- }
259
- }
260
- // Save current state for undo and history (#41) (only if initialized)
261
- if (['write', 'append', 'insert', 'replace', 'clear'].includes(name) && this.isInitialized) {
262
- try {
263
- const current = await this.controller.getCurrentPattern();
264
- this.undoStack.push(current);
265
- // Add to history stack with metadata (#41)
266
- this.historyIdCounter++;
267
- this.historyStack.push({
268
- id: this.historyIdCounter,
269
- pattern: current,
270
- timestamp: new Date(),
271
- action: name
272
- });
273
- // Enforce bounds to prevent memory leaks
274
- if (this.undoStack.length > this.MAX_HISTORY) {
275
- this.undoStack.shift();
268
+ // Pre-flight init check removed (#141): every module's execute() does
269
+ // its own session-aware init check via ctx.isInitialized() /
270
+ // ctx.getController(sid). The old hardcoded tool-name list drifted as
271
+ // tools moved between modules and #108 made it silently wrong for
272
+ // explicit session_id (it returned "Browser not initialized" instead
273
+ // of "Session 'X' not found").
274
+ // Save current state for undo and history (#41) before any edit
275
+ // (#179): route to the session-specific bundle. Default session is
276
+ // 'default'; named sessions get their own isolated stacks.
277
+ if (name === 'edit_pattern') {
278
+ const sid = args?.session_id;
279
+ const canRead = sid !== undefined || this.isInitialized;
280
+ if (canRead) {
281
+ try {
282
+ const controller = this.getControllerForSession(sid);
283
+ const current = await controller.getCurrentPattern();
284
+ const bundle = this.getHistoryBundle(sid ?? 'default');
285
+ bundle.undoStack.push(current);
286
+ this.historyIdCounter++;
287
+ bundle.historyStack.push({
288
+ id: this.historyIdCounter,
289
+ pattern: current,
290
+ timestamp: new Date(),
291
+ action: args?.mode ?? 'write',
292
+ });
293
+ if (bundle.undoStack.length > this.MAX_HISTORY)
294
+ bundle.undoStack.shift();
295
+ if (bundle.historyStack.length > this.MAX_HISTORY)
296
+ bundle.historyStack.shift();
297
+ bundle.redoStack.length = 0;
276
298
  }
277
- if (this.historyStack.length > this.MAX_HISTORY) {
278
- this.historyStack.shift();
299
+ catch {
300
+ // Controller might not be initialized yet, or session is gone
279
301
  }
280
- this.redoStack = [];
281
- }
282
- catch (e) {
283
- // Controller might not be initialized yet
284
302
  }
285
303
  }
286
304
  // Delegate to extracted per-domain tool modules before the big switch.
@@ -296,17 +314,25 @@ export class StrudelMCPServer {
296
314
  geminiService: this.geminiService,
297
315
  strudelEngine: this.strudelEngine,
298
316
  midiExportService: this.midiExportService,
299
- getAudioCaptureService: () => this.getAudioCaptureService(),
300
- history: {
301
- undoStack: this.undoStack,
302
- redoStack: this.redoStack,
303
- historyStack: this.historyStack,
304
- maxHistory: this.MAX_HISTORY,
317
+ getAudioCaptureService: (sessionId) => this.getAudioCaptureService(sessionId),
318
+ dropAudioCaptureService: (sessionId) => { this.audioCaptureServices.delete(sessionId); },
319
+ getHistory: (sessionId) => {
320
+ const bundle = this.getHistoryBundle(sessionId ?? 'default');
321
+ return {
322
+ undoStack: bundle.undoStack,
323
+ redoStack: bundle.redoStack,
324
+ historyStack: bundle.historyStack,
325
+ maxHistory: this.MAX_HISTORY,
326
+ };
305
327
  },
328
+ historyEntryId: () => ++this.historyIdCounter,
329
+ dropHistory: (sessionId) => { this.historyBundles.delete(sessionId); },
306
330
  logger: this.logger,
307
331
  isInitialized: () => this.isInitialized,
308
- getCurrentPatternSafe: () => this.getCurrentPatternSafe(),
309
- writePatternSafe: (p) => this.writePatternSafe(p),
332
+ ensureInitialized: () => this.ensureInitialized(),
333
+ getController: (sessionId) => this.getControllerForSession(sessionId),
334
+ getCurrentPatternSafe: (sessionId) => this.getCurrentPatternSafe(sessionId),
335
+ writePatternSafe: (p, sessionId) => this.writePatternSafe(p, sessionId),
310
336
  };
311
337
  if (diagnosticsModule.toolNames.has(name)) {
312
338
  return await diagnosticsModule.execute(name, args, ctx);
@@ -341,230 +367,82 @@ export class StrudelMCPServer {
341
367
  if (aiModule.toolNames.has(name)) {
342
368
  return await aiModule.execute(name, args, ctx);
343
369
  }
344
- switch (name) {
345
- // Core Control
346
- case 'init':
347
- const initResult = await this.controller.initialize();
348
- this.isInitialized = true;
349
- // Write any pending patterns
350
- if (this.generatedPatterns.size > 0) {
351
- const lastPattern = Array.from(this.generatedPatterns.values()).pop();
352
- if (lastPattern) {
353
- await this.controller.writePattern(lastPattern);
354
- return `${initResult}. Loaded generated pattern.`;
355
- }
356
- }
357
- return initResult;
358
- // write, append, insert, replace, clear, get_pattern
359
- // — handled by editorModule above.
360
- // play, pause, stop — handled by playbackModule above.
361
- // Pattern + music-theory generation — all handled by
362
- // generateModule above. See src/server/tools/generate.ts.
363
- // Pattern manipulation + effects + tempo — all handled by
364
- // transformModule above. See src/server/tools/transform.ts.
365
- // analyze, analyze_spectrum, analyze_rhythm, detect_tempo, detect_key,
366
- // validate_pattern_runtime — handled by analysisModule above.
367
- // Local Pattern Tools (#83) - No browser required
368
- case 'validate_pattern_local':
369
- InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
370
- const localValidation = this.strudelEngine.validate(args.pattern);
371
- return {
372
- valid: localValidation.valid,
373
- errors: localValidation.errors,
374
- warnings: localValidation.warnings,
375
- suggestions: localValidation.suggestions,
376
- errorLocation: localValidation.errorLocation,
377
- message: localValidation.valid
378
- ? '✅ Pattern is valid'
379
- : `❌ Pattern has ${localValidation.errors.length} error(s)`
380
- };
381
- case 'analyze_pattern_local':
382
- InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
383
- const patternMetadata = this.strudelEngine.analyzePattern(args.pattern);
384
- return {
385
- ...patternMetadata,
386
- message: `Pattern analysis: ${patternMetadata.eventsPerCycle} events/cycle, ` +
387
- `complexity ${(patternMetadata.complexity * 100).toFixed(0)}%` +
388
- (patternMetadata.bpm ? `, ${patternMetadata.bpm} BPM` : '')
389
- };
390
- case 'query_pattern_events':
391
- InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
392
- const startCycle = args.start ?? 0;
393
- const endCycle = args.end ?? 1;
394
- if (startCycle >= endCycle) {
395
- return { error: 'Start must be less than end' };
396
- }
397
- if (endCycle - startCycle > 16) {
398
- return { error: 'Maximum range is 16 cycles to prevent excessive output' };
399
- }
400
- try {
401
- const events = this.strudelEngine.queryEvents(args.pattern, startCycle, endCycle);
402
- return {
403
- count: events.length,
404
- range: { start: startCycle, end: endCycle },
405
- events: events.map((e) => ({
406
- value: e.value,
407
- start: e.start,
408
- end: e.end,
409
- duration: e.end - e.start
410
- }))
411
- };
412
- }
413
- catch (error) {
414
- const message = error instanceof Error ? error.message : String(error);
415
- return {
416
- error: message,
417
- suggestion: 'Check pattern syntax with validate_pattern_local first'
418
- };
419
- }
420
- case 'transpile_pattern':
421
- InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
422
- const transpileResult = this.strudelEngine.transpile(args.pattern);
423
- if (transpileResult.success) {
424
- return {
425
- success: true,
426
- transpiledCode: transpileResult.transpiledCode,
427
- message: 'Pattern transpiled successfully'
428
- };
429
- }
430
- else {
431
- return {
432
- success: false,
433
- error: transpileResult.error,
434
- errorLocation: transpileResult.errorLocation,
435
- message: 'Transpilation failed'
436
- };
437
- }
438
- // shift_mood — handled by transformModule above.
439
- // Session Management
440
- // save, load, list — handled by storageModule above.
441
- // undo, redo, list_history, restore_history, compare_patterns
442
- // — handled by historyModule above.
443
- // performance_report, memory_usage, screenshot, status, diagnostics,
444
- // show_errors — all handled by diagnosticsModule before this switch.
445
- // show_browser stays here until session.ts extraction lands.
446
- // UX Tools - Browser Control (#37)
447
- case 'show_browser':
448
- if (!this.isInitialized) {
449
- return 'Browser not initialized. Run init first.';
450
- }
451
- return await this.controller.showBrowser();
452
- // UX Tools - High-level Compose (#42, #73)
453
- case 'compose':
454
- InputValidator.validateStringLength(args.style, 'style', 100, false);
455
- if (args.key) {
456
- InputValidator.validateRootNote(args.key);
457
- }
458
- if (args.tempo !== undefined) {
459
- InputValidator.validateBPM(args.tempo);
460
- }
461
- // Auto-initialize if needed
462
- if (!this.isInitialized) {
463
- await this.controller.initialize();
464
- this.isInitialized = true;
465
- }
466
- // Generate pattern
467
- const composedPattern = this.generator.generateCompletePattern(args.style, args.key || 'C', args.tempo || this.getDefaultTempo(args.style));
468
- // Write pattern
469
- await this.controller.writePattern(composedPattern);
470
- // Auto-play by default (unless explicitly set to false)
471
- const shouldPlay = args.auto_play !== false;
472
- if (shouldPlay) {
473
- await this.controller.play();
474
- }
475
- // Build response
476
- const composeResponse = {
477
- success: true,
478
- pattern: composedPattern.substring(0, 200) + (composedPattern.length > 200 ? '...' : ''),
479
- metadata: {
480
- style: args.style,
481
- bpm: args.tempo || this.getDefaultTempo(args.style),
482
- key: args.key || 'C'
483
- },
484
- status: shouldPlay ? 'playing' : 'ready',
485
- message: `Created ${args.style} pattern in ${args.key || 'C'}${shouldPlay ? ' - now playing' : ''}`
486
- };
487
- // Get AI feedback if requested (#73)
488
- if (args.get_feedback) {
489
- if (this.geminiService.isAvailable()) {
490
- try {
491
- const feedback = await this.geminiService.getCreativeFeedback(composedPattern);
492
- composeResponse.feedback = feedback;
493
- composeResponse.message += ` (AI feedback: ${feedback.complexity} complexity, estimated ${feedback.estimatedStyle})`;
494
- }
495
- catch (error) {
496
- const message = error instanceof Error ? error.message : String(error);
497
- this.logger.warn('Failed to get AI feedback for compose', { error: message });
498
- // Don't fail the whole operation, just note the feedback failure
499
- composeResponse.message += ' (AI feedback unavailable)';
500
- }
501
- }
502
- else {
503
- composeResponse.message += ' (AI feedback requires GEMINI_API_KEY)';
504
- }
370
+ if (composeModule.toolNames.has(name)) {
371
+ return await composeModule.execute(name, args, ctx);
372
+ }
373
+ if (name === 'init') {
374
+ const initResult = await this.controller.initialize();
375
+ this.isInitialized = true;
376
+ // Replay any pattern generated before init landed.
377
+ if (this.generatedPatterns.size > 0) {
378
+ const lastPattern = Array.from(this.generatedPatterns.values()).pop();
379
+ if (lastPattern) {
380
+ await this.controller.writePattern(lastPattern);
381
+ return `${initResult}. Loaded generated pattern.`;
505
382
  }
506
- return composeResponse;
507
- // get_pattern_feedback, suggest_pattern_from_audio —
508
- // handled by aiModule above.
509
- // start_audio_capture, stop_audio_capture, capture_audio_sample,
510
- // export_midi — handled by captureModule above.
511
- // create_session, destroy_session, list_sessions, switch_session —
512
- // handled by sessionModule above.
513
- // jam_with — handled by aiModule above.
514
- // refine, set_energy — handled by transformModule above.
515
- default:
516
- throw new Error(`Unknown tool: ${name}`);
383
+ }
384
+ return initResult;
517
385
  }
386
+ throw new Error(`Unknown tool: ${name}`);
518
387
  }
519
- // refinePattern, setEnergyLevel, transposePattern moved to
520
- // src/server/tools/transform.ts alongside the tools that used them.
521
388
  /**
522
- * Gets the default tempo for a given music style
523
- * @param style - Music style/genre
524
- * @returns Default BPM for the style
389
+ * Lazily get or create the per-session history bundle (#179). Bundle is
390
+ * a mutable triple — callers push/shift directly on the arrays.
525
391
  */
526
- getDefaultTempo(style) {
527
- const tempoMap = {
528
- 'techno': 130,
529
- 'house': 125,
530
- 'dnb': 174,
531
- 'drum and bass': 174,
532
- 'ambient': 80,
533
- 'trap': 140,
534
- 'jungle': 160,
535
- 'jazz': 110,
536
- 'experimental': 120,
537
- 'dubstep': 140,
538
- 'trance': 138,
539
- 'breakbeat': 130,
540
- 'garage': 130,
541
- 'electro': 128,
542
- 'downtempo': 90,
543
- 'idm': 115
544
- };
545
- return tempoMap[style.toLowerCase()] || 120;
392
+ getHistoryBundle(sessionId) {
393
+ let bundle = this.historyBundles.get(sessionId);
394
+ if (!bundle) {
395
+ bundle = { undoStack: [], redoStack: [], historyStack: [] };
396
+ this.historyBundles.set(sessionId, bundle);
397
+ }
398
+ return bundle;
399
+ }
400
+ /** Idempotent browser bring-up. Used by tools that promise auto-init (e.g. `compose`). */
401
+ async ensureInitialized() {
402
+ if (this.isInitialized)
403
+ return;
404
+ await this.controller.initialize();
405
+ this.isInitialized = true;
546
406
  }
547
- // formatTimeAgo, generateDiff, summarizeDiff moved to
548
- // src/server/tools/history.ts along with the tools that used them.
549
- // getPatternFeedback, suggestPatternFromAudio, captureAudioSample,
550
- // jamWith + six pattern-analysis helpers moved to src/server/tools/ai.ts.
551
407
  /** Getter for page access in audio capture. */
552
408
  get _page() {
553
409
  return this.controller.page;
554
410
  }
555
- // Audio capture + MIDI export logic moved to src/server/tools/capture.ts.
556
- // Server still owns the AudioCaptureService lifecycle so tests can mock
557
- // the class and the module fetches the (possibly mocked) instance via
558
- // ctx.getAudioCaptureService() instead of caching its own.
559
- async getAudioCaptureService() {
560
- if (!this.isInitialized || !this._page) {
561
- throw new Error('Browser not initialized. Run init first.');
411
+ // Audio capture + MIDI export logic lives in src/server/tools/capture.ts.
412
+ // Server still owns AudioCaptureService lifecycles so tests can mock
413
+ // the class. Post-#180: per-session — each session's recorder injects
414
+ // into its own page; concurrent captures across sessions no longer
415
+ // share a singleton stream.
416
+ async getAudioCaptureService(sessionId) {
417
+ // Resolve the right page: explicit session via SessionManager, or
418
+ // legacy/default via this.controller.
419
+ let page;
420
+ let key;
421
+ if (sessionId) {
422
+ const sessionController = this.sessionManager.getSession(sessionId);
423
+ if (!sessionController) {
424
+ throw new Error(`Session '${sessionId}' not found. Create it first with session({ action: "create" }).`);
425
+ }
426
+ if (!sessionController.page) {
427
+ throw new Error(`Session '${sessionId}' has no active page yet.`);
428
+ }
429
+ page = sessionController.page;
430
+ key = sessionId;
431
+ }
432
+ else {
433
+ if (!this.isInitialized || !this._page) {
434
+ throw new Error('Browser not initialized. Run init first.');
435
+ }
436
+ page = this._page;
437
+ key = 'default';
562
438
  }
563
- if (!this.audioCaptureService) {
564
- this.audioCaptureService = new AudioCaptureService();
565
- await this.audioCaptureService.injectRecorder(this._page);
439
+ let service = this.audioCaptureServices.get(key);
440
+ if (!service) {
441
+ service = new AudioCaptureService();
442
+ await service.injectRecorder(page);
443
+ this.audioCaptureServices.set(key, service);
566
444
  }
567
- return this.audioCaptureService;
445
+ return service;
568
446
  }
569
447
  async run() {
570
448
  const transport = new StdioServerTransport();