@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,8 +1,18 @@
1
1
  import { Logger } from '../utils/Logger.js';
2
+ import { AiAuthError, AiRateLimitError } from './ai/AiTransport.js';
3
+ import { TransientError } from '../utils/CategorisedError.js';
2
4
  import { GoogleAuth } from 'google-auth-library';
5
+ import { cliTransports, hasCliTransport } from './ai/CliTransport.js';
6
+ /**
7
+ * Cheapest possible request that still proves a transport can answer.
8
+ * Being on PATH says nothing about being logged in (#252 review).
9
+ */
10
+ const TRANSPORT_PROBE_PROMPT = 'Reply with exactly: OK';
11
+ import { buildMeasurementPrompt } from './ai/AudioMeasurements.js';
3
12
  import * as os from 'os';
4
13
  import * as path from 'path';
5
14
  import * as fs from 'fs/promises';
15
+ import { ValidationError } from '../utils/CategorisedError.js';
6
16
  /**
7
17
  * GeminiService provides AI-powered music analysis and suggestions
8
18
  * using Google's Gemini API.
@@ -25,6 +35,12 @@ export class GeminiService {
25
35
  cacheTtlMs;
26
36
  timeoutMs;
27
37
  maxPatternLength;
38
+ /** Whether shelling out to a local CLI is permitted. */
39
+ enableCliTransport;
40
+ /** Resolved transport; undefined = not yet resolved, null = none available. */
41
+ transport = undefined;
42
+ /** In-flight transport resolution, so concurrent callers share one probe. */
43
+ transportPromise = null;
28
44
  adcAvailable = null; // null = not checked yet
29
45
  adcCheckPromise = null;
30
46
  cliCredentialsChecked = false;
@@ -47,20 +63,40 @@ export class GeminiService {
47
63
  this.cacheTtlMs = (config?.cacheTtlSeconds || 300) * 1000;
48
64
  this.timeoutMs = (config?.timeoutSeconds || 30) * 1000;
49
65
  this.maxPatternLength = config?.maxPatternLength || 5000;
66
+ this.enableCliTransport =
67
+ config?.enableCliTransport ?? process.env.STRUDEL_DISABLE_CLI_AI !== '1';
50
68
  }
51
69
  /**
52
- * Checks if Gemini service is available (API key or ADC configured)
53
- * Note: This is synchronous and may return false before ADC check completes.
54
- * Use isAvailableAsync() for accurate ADC detection.
70
+ * Whether AI features can currently be served.
71
+ *
72
+ * Synchronous, so it can only report what is already known: an API key,
73
+ * a completed ADC probe, or a transport resolved by an earlier call.
74
+ * It deliberately does NOT scan for CLIs — that is I/O.
75
+ *
76
+ * Gates call this, so it has to be complete without being async. It
77
+ * covers an API key, a finished ADC probe, an already-resolved
78
+ * transport, and an installed CLI. What it CANNOT see synchronously is
79
+ * whether that CLI is logged in — `resolveTransport()` probes for that
80
+ * and falls through to the next candidate, so a gate saying "available"
81
+ * may still be followed by a transport error naming the reason.
55
82
  */
56
83
  isAvailable() {
57
- return !!this.apiKey || this.adcAvailable === true;
84
+ return (!!this.apiKey ||
85
+ this.adcAvailable === true ||
86
+ this.transport != null ||
87
+ // A locally authenticated CLI serves requests perfectly well without
88
+ // any key of ours (#252). Detecting one is a PATH scan, not I/O
89
+ // against a service, so this stays synchronous.
90
+ (this.enableCliTransport && hasCliTransport()));
58
91
  }
59
92
  /**
60
93
  * Async check for Gemini service availability.
61
94
  * Checks in order: API key, env var, Gemini CLI config, ADC.
62
95
  * @returns Promise<boolean> indicating if service can make API calls
63
96
  */
97
+ async canServe() {
98
+ return (await this.resolveTransport()) !== null;
99
+ }
64
100
  async isAvailableAsync() {
65
101
  // 1. Explicit API key or env var already set
66
102
  if (this.apiKey)
@@ -225,10 +261,10 @@ export class GeminiService {
225
261
  * Get authentication error message
226
262
  */
227
263
  getAuthErrorMessage() {
228
- return 'Gemini API key not configured. Options:\n' +
264
+ return ('No AI transport available. Options:\n' +
229
265
  '1. Set GEMINI_API_KEY environment variable\n' +
230
- '2. Install Gemini CLI and run "gemini auth login"\n' +
231
- '3. Run "gcloud auth application-default login" for ADC';
266
+ '2. Run "gcloud auth application-default login" for ADC\n' +
267
+ '3. Install and authenticate one of: claude, agy, codex');
232
268
  }
233
269
  /**
234
270
  * Ensure authentication is available (API key, Gemini CLI, or ADC)
@@ -236,18 +272,52 @@ export class GeminiService {
236
272
  * @throws {Error} When no authentication method is available
237
273
  */
238
274
  async ensureAuthentication() {
239
- // 1. Already have API key (explicit or from env var)
240
- if (this.apiKey)
275
+ // A resolved transport IS authentication. An authenticated CLI needs
276
+ // no API key of ours — it holds its own credentials, which is the
277
+ // entire point of #252. Checking the key ladder first would reject a
278
+ // machine where AI works perfectly well.
279
+ if ((await this.resolveTransport()) !== null)
241
280
  return;
242
- // 2. Check Gemini CLI config file
243
- const cliKey = await this.loadGeminiCliCredentials();
244
- if (cliKey)
245
- return;
246
- // 3. Fall back to ADC
247
- const adcAvailable = await this.checkADC();
248
- if (!adcAvailable) {
249
- throw new Error(this.getAuthErrorMessage());
281
+ // Typed, not phrased. The message lists GEMINI_API_KEY and gcloud
282
+ // commands, and used to be categorised `permission` only because
283
+ // `gemini` was in the matcher's auth list — which also made every
284
+ // unrelated Gemini failure a credentials problem (#382).
285
+ throw new AiAuthError(this.getAuthErrorMessage());
286
+ }
287
+ /**
288
+ * Analyses audio from locally computed measurements.
289
+ *
290
+ * Preferred over {@link analyzeAudio}. No installed CLI can decode
291
+ * audio — asked directly, they say so — and `agy` will sometimes
292
+ * confabulate detailed analysis of audio it never examined, which is
293
+ * worse than refusing. Measuring locally and sending numbers is
294
+ * deterministic and works with every provider rather than one.
295
+ *
296
+ * @param measurements - Peak, RMS, tempo, key and spectrum measured locally
297
+ * @param pattern - The Strudel source that produced the audio
298
+ * @param context - What the user was aiming for
299
+ * @returns Structured feedback in the same shape as analyzeAudio
300
+ *
301
+ * @example
302
+ * await service.analyzeAudioMeasurements(
303
+ * { durationMs: 5000, peak: 1.12, rms: 0.36 },
304
+ * 'stack(s("bd*4"), s("~ cp"))',
305
+ * );
306
+ */
307
+ async analyzeAudioMeasurements(measurements, pattern, context) {
308
+ await this.ensureAuthentication();
309
+ this.checkRateLimit();
310
+ const cacheKey = `measure-${await this.getTransportId() ?? 'none'}-${JSON.stringify(measurements)}-${(pattern ?? '').slice(0, 200)}`;
311
+ const cached = this.getFromCache(this.audioCache, cacheKey);
312
+ if (cached) {
313
+ this.logger.debug('Returning cached measurement analysis');
314
+ return cached;
250
315
  }
316
+ const prompt = buildMeasurementPrompt(measurements, pattern, context);
317
+ const response = await this.callGeminiAPIWithTimeout(prompt);
318
+ const feedback = this.parseAudioResponse(response);
319
+ this.audioCache.set(cacheKey, { result: feedback, timestamp: Date.now() });
320
+ return feedback;
251
321
  }
252
322
  /**
253
323
  * Analyzes audio data and provides qualitative feedback
@@ -276,15 +346,31 @@ export class GeminiService {
276
346
  const prompt = this.buildAudioPrompt(context);
277
347
  // Use timeout for audio analysis (may take longer due to media processing)
278
348
  const audioTimeoutMs = this.timeoutMs * 2; // Double timeout for audio
349
+ let audioTimer;
279
350
  const timeoutPromise = new Promise((_, reject) => {
280
- setTimeout(() => {
351
+ audioTimer = setTimeout(() => {
281
352
  reject(new Error(`Audio analysis timed out after ${audioTimeoutMs / 1000} seconds`));
282
353
  }, audioTimeoutMs);
283
354
  });
284
- const response = await Promise.race([
285
- this.callGeminiAPI(prompt, audioBase64, 'audio/webm'),
286
- timeoutPromise
287
- ]);
355
+ let response;
356
+ try {
357
+ response = await Promise.race([
358
+ // Derive from the blob rather than hardcoding: the feedback path
359
+ // now sends WAV (decoded PCM), and telling Gemini it is WebM makes
360
+ // it decode the wrong container.
361
+ this.callGeminiAPI(prompt, audioBase64, audioData.type || 'audio/webm'),
362
+ timeoutPromise
363
+ ]);
364
+ }
365
+ finally {
366
+ // The race settling does not disarm the loser. Without this the
367
+ // handle and its closure live for the full 60s even when the API
368
+ // answered at once — eight of them outlived one test file, and in
369
+ // a long-lived server they accumulate (#404). The sibling call
370
+ // site at `callWithTimeout` already did this; this one did not.
371
+ if (audioTimer !== undefined)
372
+ clearTimeout(audioTimer);
373
+ }
288
374
  const feedback = this.parseAudioResponse(response);
289
375
  this.audioCache.set(cacheKey, { result: feedback, timestamp: Date.now() });
290
376
  return feedback;
@@ -295,7 +381,7 @@ export class GeminiService {
295
381
  if (error.message?.includes('timed out')) {
296
382
  throw new Error('Audio analysis timed out. The audio sample may be too long. Try a shorter recording.');
297
383
  }
298
- if (error.message?.includes('rate limit') || error.message?.includes('Rate limit')) {
384
+ if (error instanceof AiRateLimitError) {
299
385
  throw error; // Re-throw rate limit errors as-is
300
386
  }
301
387
  throw new Error(`Audio analysis failed: ${error.message}`);
@@ -324,7 +410,7 @@ export class GeminiService {
324
410
  if (error.message?.includes('timed out')) {
325
411
  throw new Error('Variation suggestion timed out. The pattern may be too complex. Try a simpler pattern.');
326
412
  }
327
- if (error.message?.includes('rate limit') || error.message?.includes('Rate limit')) {
413
+ if (error instanceof AiRateLimitError) {
328
414
  throw error; // Re-throw rate limit errors as-is
329
415
  }
330
416
  throw new Error(`Variation suggestion failed: ${error.message}`);
@@ -361,7 +447,7 @@ export class GeminiService {
361
447
  if (error.message?.includes('timed out')) {
362
448
  throw new Error('Creative feedback timed out. The pattern may be too complex. Try a simpler pattern.');
363
449
  }
364
- if (error.message?.includes('rate limit') || error.message?.includes('Rate limit')) {
450
+ if (error instanceof AiRateLimitError) {
365
451
  throw error; // Re-throw rate limit errors as-is
366
452
  }
367
453
  throw new Error(`Creative feedback failed: ${error.message}`);
@@ -370,6 +456,16 @@ export class GeminiService {
370
456
  /**
371
457
  * Clears all cached analysis results
372
458
  */
459
+ /**
460
+ * Forget the resolved transport so the next call re-probes.
461
+ *
462
+ * A long-lived server that started with no credentials could otherwise
463
+ * never recover: `resolveTransport()` caches `null` and logging into a
464
+ * CLI afterwards would require a restart.
465
+ */
466
+ resetTransport() {
467
+ this.transport = undefined;
468
+ }
373
469
  clearCache() {
374
470
  this.audioCache.clear();
375
471
  this.patternCache.clear();
@@ -392,7 +488,12 @@ export class GeminiService {
392
488
  const waitTimeMs = this.rateLimit.lastResetTime + this.rateLimitWindowMs - now;
393
489
  const waitTimeSec = Math.ceil(waitTimeMs / 1000);
394
490
  this.rateLimit.nextAvailableTime = now + waitTimeMs;
395
- throw new Error(`Rate limit exceeded (${this.maxRequestsPerMinute} requests/minute). ` +
491
+ // A typed error, not a phrase. Three call sites and
492
+ // `categorizeError` all used to recognise this by matching "rate
493
+ // limit" in the message, so rewording it would have silently
494
+ // recategorised a rate limit as an internal failure that is not
495
+ // worth retrying — which is the opposite of the truth (#380).
496
+ throw new AiRateLimitError(`Rate limit exceeded (${this.maxRequestsPerMinute} requests/minute). ` +
396
497
  `Wait ${waitTimeSec} seconds before retrying.`);
397
498
  }
398
499
  this.rateLimit.requestCount++;
@@ -445,7 +546,7 @@ export class GeminiService {
445
546
  }
446
547
  // Check for excessive length
447
548
  if (trimmed.length > this.maxPatternLength) {
448
- throw new Error(`${methodName}: Pattern exceeds maximum length of ${this.maxPatternLength} characters ` +
549
+ throw new ValidationError(`${methodName}: Pattern exceeds maximum length of ${this.maxPatternLength} characters ` +
449
550
  `(current: ${trimmed.length}). Truncate or simplify the pattern.`);
450
551
  }
451
552
  return trimmed;
@@ -456,16 +557,100 @@ export class GeminiService {
456
557
  * @returns API response text
457
558
  * @throws {Error} When request times out or API fails
458
559
  */
560
+ /**
561
+ * Resolves the transport that will actually serve requests.
562
+ *
563
+ * The Gemini HTTP API when credentials exist, otherwise the first
564
+ * locally authenticated CLI. Cached after the first resolution so a
565
+ * PATH scan and auth probe do not run per request.
566
+ *
567
+ * @returns The active transport, or null when nothing can serve
568
+ */
569
+ async resolveTransport() {
570
+ if (this.transport !== undefined)
571
+ return this.transport;
572
+ // Single-flight, matching checkADC() above. Without it two concurrent
573
+ // ai_assist calls each ran the full PATH scan and each SPAWNED A
574
+ // PROBE SUBPROCESS per candidate CLI — real quota, burned twice —
575
+ // before the last writer won the assignment anyway (#265).
576
+ this.transportPromise ??= this.doResolveTransport()
577
+ .finally(() => { this.transportPromise = null; });
578
+ return this.transportPromise;
579
+ }
580
+ async doResolveTransport() {
581
+ if (this.transport !== undefined)
582
+ return this.transport;
583
+ if (await this.isAvailableAsync()) {
584
+ this.transport = {
585
+ id: `gemini-api:${this.model}`,
586
+ label: 'Gemini API',
587
+ isAvailable: () => Promise.resolve(true),
588
+ send: (prompt) => this.callGeminiAPI(prompt),
589
+ };
590
+ return this.transport;
591
+ }
592
+ // No API credentials. A locally authenticated CLI still works, which
593
+ // is the whole of #252: `agy` holds valid Gemini auth that this
594
+ // service's credential ladder cannot see.
595
+ //
596
+ // Being on PATH is not the same as being usable. An installed but
597
+ // unauthenticated `claude` would otherwise win — it is first in the
598
+ // preference order — and every request would fail without `agy` ever
599
+ // being tried, on exactly the machine this feature exists to serve.
600
+ // So probe each candidate and fall through when it cannot answer.
601
+ for (const candidate of this.enableCliTransport ? cliTransports() : []) {
602
+ if (!(await candidate.isAvailable()))
603
+ continue;
604
+ try {
605
+ await candidate.send(TRANSPORT_PROBE_PROMPT);
606
+ }
607
+ catch (error) {
608
+ this.logger.debug(`Transport ${candidate.label} unusable: ${error instanceof Error ? error.message : String(error)}`);
609
+ continue;
610
+ }
611
+ this.logger.debug(`Using AI transport: ${candidate.label}`);
612
+ this.transport = candidate;
613
+ return this.transport;
614
+ }
615
+ this.transport = null;
616
+ return null;
617
+ }
618
+ /** Identifier of the transport in use, for diagnostics. */
619
+ async getTransportId() {
620
+ return (await this.resolveTransport())?.id ?? null;
621
+ }
459
622
  async callGeminiAPIWithTimeout(prompt) {
623
+ const transport = await this.resolveTransport();
624
+ if (transport === null) {
625
+ // Typed, not phrased. The message lists GEMINI_API_KEY and gcloud
626
+ // commands, and used to be categorised `permission` only because
627
+ // `gemini` was in the matcher's auth list — which also made every
628
+ // unrelated Gemini failure a credentials problem (#382).
629
+ throw new AiAuthError(this.getAuthErrorMessage());
630
+ }
631
+ // A CLI transport enforces its own timeout and can kill the process
632
+ // tree; racing it against a shorter one here would leave the loser
633
+ // running. The caller would see "timed out" while an agentic CLI kept
634
+ // working for another 90 seconds, burning quota, and repeated
635
+ // timeouts would stack live subprocesses.
636
+ if (transport.id.startsWith('cli:')) {
637
+ return transport.send(prompt);
638
+ }
639
+ let timer;
460
640
  const timeoutPromise = new Promise((_, reject) => {
461
- setTimeout(() => {
641
+ timer = setTimeout(() => {
462
642
  reject(new Error(`Request timed out after ${this.timeoutMs / 1000} seconds`));
463
643
  }, this.timeoutMs);
464
644
  });
465
- return Promise.race([
466
- this.callGeminiAPI(prompt),
467
- timeoutPromise
468
- ]);
645
+ try {
646
+ return await Promise.race([transport.send(prompt), timeoutPromise]);
647
+ }
648
+ finally {
649
+ // Otherwise the handle and its closure are held until the deadline
650
+ // even when the request succeeded immediately.
651
+ if (timer !== undefined)
652
+ clearTimeout(timer);
653
+ }
469
654
  }
470
655
  async callGeminiAPI(prompt, mediaData, mimeType) {
471
656
  // Dynamic import for ES module compatibility
@@ -600,7 +785,7 @@ Consider:
600
785
  try {
601
786
  const jsonMatch = response.match(/\{[\s\S]*\}/);
602
787
  if (!jsonMatch)
603
- throw new Error('No JSON found in response');
788
+ throw new TransientError('No JSON found in response');
604
789
  const parsed = JSON.parse(jsonMatch[0]);
605
790
  return {
606
791
  mood: parsed.mood || 'unknown',
@@ -625,7 +810,7 @@ Consider:
625
810
  try {
626
811
  const jsonMatch = response.match(/\[[\s\S]*\]/);
627
812
  if (!jsonMatch)
628
- throw new Error('No JSON array found in response');
813
+ throw new TransientError('No JSON array found in response');
629
814
  const parsed = JSON.parse(jsonMatch[0]);
630
815
  return parsed.map((item) => ({
631
816
  description: item.description || '',
@@ -642,7 +827,7 @@ Consider:
642
827
  try {
643
828
  const jsonMatch = response.match(/\{[\s\S]*\}/);
644
829
  if (!jsonMatch)
645
- throw new Error('No JSON found in response');
830
+ throw new TransientError('No JSON found in response');
646
831
  const parsed = JSON.parse(jsonMatch[0]);
647
832
  return {
648
833
  complexity: parsed.complexity || 'moderate',
@@ -705,7 +890,7 @@ Consider:
705
890
  if (error.message?.includes('timed out')) {
706
891
  throw new Error('Pattern modification timed out. The pattern may be too complex. Try a simpler pattern.');
707
892
  }
708
- if (error.message?.includes('rate limit') || error.message?.includes('Rate limit')) {
893
+ if (error instanceof AiRateLimitError) {
709
894
  throw error; // Re-throw rate limit errors as-is
710
895
  }
711
896
  throw new Error(`Pattern modification failed: ${error.message}`);