@williamzujkowski/live-coding-music-mcp 4.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 +202 -705
  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 +384 -76
  7. package/dist/server/tools/ai.js +124 -91
  8. package/dist/server/tools/analysis.js +168 -59
  9. package/dist/server/tools/capture.js +135 -24
  10. package/dist/server/tools/compose.js +26 -26
  11. package/dist/server/tools/diagnostics.js +20 -3
  12. package/dist/server/tools/editor.js +60 -6
  13. package/dist/server/tools/generate.js +38 -15
  14. package/dist/server/tools/history.js +30 -8
  15. package/dist/server/tools/playback.js +2 -1
  16. package/dist/server/tools/session.js +38 -6
  17. package/dist/server/tools/storage.js +179 -8
  18. package/dist/server/tools/transform.js +127 -43
  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
@@ -6,6 +6,8 @@
6
6
  * dispatcher. The server keeps the MCP protocol layer thin and delegates
7
7
  * to these modules.
8
8
  */
9
+ import { AiAuthError, AiRateLimitError } from '../../services/ai/AiTransport.js';
10
+ import { BusinessError, TransientError, ValidationError } from '../../utils/CategorisedError.js';
9
11
  /** Type guard for tools and dispatchers. */
10
12
  export function isEnvelope(v) {
11
13
  return (typeof v === 'object' &&
@@ -42,33 +44,226 @@ export function err(category, message, opts) {
42
44
  * dispatcher when a tool throws without wrapping. Tools that know better
43
45
  * should construct `err(category, message)` directly.
44
46
  */
47
+ /**
48
+ * Whether a tool returned a self-declared failure.
49
+ *
50
+ * Several modules report problems as `{ success: false, message }`
51
+ * rather than throwing or building an envelope. Wrapped with `ok()`
52
+ * those reach MCP clients as successes, with the real outcome hidden in
53
+ * a field the envelope contract says nothing about (#274).
54
+ *
55
+ * @param value - A tool's raw return value
56
+ * @returns True when it declares failure
57
+ */
58
+ export function isFailureShaped(value) {
59
+ if (typeof value !== 'object' || value === null)
60
+ return false;
61
+ const record = value;
62
+ // An explicit success:false is unambiguous.
63
+ if ('success' in record && record.success === false)
64
+ return true;
65
+ // A bare `{ error: '...' }` with no success flag is equally a failure,
66
+ // and several modules return exactly that — ai.ts and analysis.ts both
67
+ // do. Requiring a `success` key let those through as ok:true, which is
68
+ // the same bug one shape over (#274).
69
+ //
70
+ // Guarded by `success !== true` so a result that genuinely succeeded
71
+ // while carrying a non-fatal `error` field is left alone.
72
+ return (record.success !== true &&
73
+ typeof record.error === 'string' &&
74
+ record.error.length > 0);
75
+ }
76
+ /**
77
+ * Prefix of the message `writePatternSafe` returns when it did *not*
78
+ * write — no browser session is up, so the pattern is stashed for the
79
+ * next init instead.
80
+ *
81
+ * Exported so `server.ts` builds the message from it and the helpers
82
+ * below test for it. One constant, so the producer and the consumers
83
+ * cannot drift apart.
84
+ */
85
+ export const PATTERN_STASHED_PREFIX = 'Pattern generated (initialize Strudel to use it):';
86
+ /** Whether a `writePatternSafe` result means "stashed, not written". */
87
+ export function wasStashed(writeResult) {
88
+ return typeof writeResult === 'string' && writeResult.startsWith(PATTERN_STASHED_PREFIX);
89
+ }
90
+ /**
91
+ * Appends the "not in the editor yet" caveat to a tool's success
92
+ * message when the write was only a stash.
93
+ *
94
+ * Nineteen call sites across five modules used to `await
95
+ * ctx.writePatternSafe(...)` and throw the result away, then return
96
+ * their own message. Two modules could actually reach the stash path:
97
+ * `generate.ts` (all seven `generate_part` / `music_theory` /
98
+ * `generate_rhythm` tools) and `storage.ts` (`action=load`). Neither
99
+ * gates on `isInitialized`, so both reported the pattern as applied
100
+ * while it sat in `pendingPattern` waiting for an init the caller had
101
+ * no reason to run (#285).
102
+ *
103
+ * `transform.ts`, `editor.ts` and `ai.ts` refuse outright when the
104
+ * default session is down, so they never mis-reported. They thread the
105
+ * result anyway, so the rule holds uniformly and a relaxed gate cannot
106
+ * silently reintroduce the bug.
107
+ *
108
+ * @param message - What the tool would say on a normal write
109
+ * @param writeResult - Exactly what `writePatternSafe` returned
110
+ * @returns The message, with the caveat appended if it applies
111
+ */
112
+ export function withStashNotice(message, writeResult) {
113
+ if (!wasStashed(writeResult))
114
+ return message;
115
+ // Trim a trailing period first: appending an em dash straight after
116
+ // one produced "…boom_bap. — but it is not in the editor yet" (#297).
117
+ return `${message.replace(/\.\s*$/, '')}${STASH_SUFFIX}`;
118
+ }
119
+ /** The caveat, as a suffix for prose results. */
120
+ const STASH_SUFFIX = ' — but it is not in the editor yet: no browser session is ' +
121
+ 'running. Run `init` to open one and load it.';
122
+ /** The same caveat as a standalone sentence, for structured results. */
123
+ export const STASH_WARNING = 'The pattern is not in the editor yet: no browser ' +
124
+ 'session is running. Run `init` to open one and load it.';
125
+ /**
126
+ * Structured-result counterpart to `withStashNotice`: adds a `warning`
127
+ * field when the write was only a stash, and leaves the result
128
+ * untouched otherwise.
129
+ *
130
+ * @param result - What the tool would return on a normal write
131
+ * @param writeResult - Exactly what `writePatternSafe` returned
132
+ */
133
+ export function withStashField(result, writeResult) {
134
+ if (!wasStashed(writeResult))
135
+ return result;
136
+ // Spreading an array produces {"0":"a","1":"b"} — a silent shape
137
+ // change the `T & {...}` signature promised not to make. The type
138
+ // now excludes arrays; this is the runtime half of the same
139
+ // guarantee (#297).
140
+ if (Array.isArray(result)) {
141
+ throw new TypeError('withStashField expects an object result, not an array. ' +
142
+ 'Spreading an array would change its shape.');
143
+ }
144
+ // Append rather than overwrite: a result that already carries a
145
+ // warning has something to say, and the stash notice is additional
146
+ // information, not a replacement for it.
147
+ const existing = result.warning;
148
+ const warning = typeof existing === 'string' && existing.length > 0
149
+ ? `${existing} ${STASH_WARNING}`
150
+ : STASH_WARNING;
151
+ return { ...result, warning };
152
+ }
45
153
  export function categorizeError(error) {
154
+ // A typed error first, so the categorisation does not depend on
155
+ // wording. The rate limiter used to throw a plain Error whose message
156
+ // happened to start "Rate limit exceeded", and everything downstream —
157
+ // three re-throw sites and the string match below — recognised it by
158
+ // that phrase. Rewording it would have quietly turned the single most
159
+ // retryable failure there is into `internal`, which is not retryable
160
+ // (#380).
161
+ if (error instanceof AiRateLimitError)
162
+ return 'transient';
163
+ // Same reasoning as the rate limit: the class exists, it is thrown,
164
+ // and recognising it by type means the categorisation does not depend
165
+ // on the message still saying "not authenticated".
166
+ if (error instanceof AiAuthError)
167
+ return 'permission';
168
+ // A thrower that knows what kind of failure it has beats a phrase
169
+ // match every time. "Steps cannot exceed 256" contains none of the
170
+ // words below, and fell through to `internal` — not retryable, and
171
+ // reading to an agent as "the server is broken" when the fix is to
172
+ // change an argument (#382).
173
+ if (error instanceof ValidationError)
174
+ return 'validation';
175
+ if (error instanceof BusinessError)
176
+ return 'business';
177
+ if (error instanceof TransientError)
178
+ return 'transient';
46
179
  const message = error instanceof Error ? error.message : String(error);
47
180
  const lower = message.toLowerCase();
181
+ // Transient first. It used to be checked last, behind `validation`,
182
+ // which meant a timeout mentioning an invalid state was filed as a
183
+ // caller mistake — and everything not matched fell through to
184
+ // `internal`, whose isRetryable is false.
185
+ //
186
+ // 'timed out' is the important entry: the codebase says that 13 times
187
+ // and 'timeout' twice, and 'timed out'.includes('timeout') is false. So
188
+ // essentially every real timeout was reported to agents as a permanent
189
+ // internal failure not worth retrying. Rate limits had the same
190
+ // problem across 14 throw sites, and a rate limit is the single most
191
+ // retryable error there is.
192
+ if (lower.includes('timed out') ||
193
+ lower.includes('timeout') ||
194
+ lower.includes('rate limit') ||
195
+ lower.includes('econnrefused') ||
196
+ lower.includes('econnreset') ||
197
+ lower.includes('etimedout') ||
198
+ lower.includes('network') ||
199
+ lower.includes('fetch failed') ||
200
+ lower.includes('did not become ready') ||
201
+ // Chromium spells its network errors net::ERR_*, which shares no
202
+ // substring with the POSIX names above — 'ERR_CONNECTION_REFUSED'
203
+ // does not contain 'econnrefused', and 'ERR_TIMED_OUT' does not
204
+ // contain 'timed out' (the underscore). So every navigation failure
205
+ // Playwright surfaced was landing in `internal`: a server bug the
206
+ // caller could do nothing about, when a refused connection is the
207
+ // most retryable error there is (#315).
208
+ lower.includes('net::err_')) {
209
+ return 'transient';
210
+ }
211
+ // Auth before business: a CLI that is installed but not logged in is a
212
+ // credential problem, and saying 'not found' about a login is not the
213
+ // same as saying it about a session.
214
+ //
215
+ // `gemini` used to be in this list, which made EVERY error mentioning
216
+ // the vendor a credentials problem — "Audio analysis failed: gemini
217
+ // returned 500" included. A vendor name is not an error condition.
218
+ // Auth failures from that path throw `AiAuthError`, which is matched
219
+ // by type above, or say "api key"/"not authenticated" in the message.
220
+ // Found because a test of mine passed with its fix removed: the
221
+ // fixture said "gemini rejected the request" (#382).
222
+ if (lower.includes('api key') ||
223
+ lower.includes('unauthorized') ||
224
+ lower.includes('unauthenticated') ||
225
+ lower.includes('not authenticated') ||
226
+ // 'authenticate' bare, as well as 'not authenticated': the
227
+ // no-transport message says "Install and authenticate one of".
228
+ // That is an auth word doing auth work, unlike the vendor name
229
+ // removed above.
230
+ lower.includes('authenticate') ||
231
+ lower.includes('forbidden') ||
232
+ lower.includes('permission denied') ||
233
+ lower.includes('credential')) {
234
+ return 'permission';
235
+ }
48
236
  if (lower.includes('not initialized') ||
49
237
  lower.includes("run 'init' first") ||
50
238
  lower.includes('run init first') ||
51
239
  lower.includes('not found') ||
52
- lower.includes('already exists')) {
240
+ lower.includes('not installed') ||
241
+ lower.includes('no ai transport') ||
242
+ lower.includes('already exists') ||
243
+ lower.includes('already in progress') ||
244
+ lower.includes('not connected') ||
245
+ lower.includes('did not start') ||
246
+ lower.includes('no pattern')) {
53
247
  return 'business';
54
248
  }
55
249
  if (lower.includes('invalid') ||
56
250
  lower.includes('must be') ||
57
251
  lower.includes('required') ||
58
- lower.includes('out of range')) {
252
+ lower.includes('out of range') ||
253
+ lower.includes('too many') ||
254
+ lower.includes('too long') ||
255
+ lower.includes('non-empty') ||
256
+ lower.includes('cannot be empty') ||
257
+ // The MIDI parse message reached `validation` only because it
258
+ // happens to contain 'Invalid'. Reword it without that adjective
259
+ // and it silently became a non-retryable server fault again — the
260
+ // two halves of #280 were coupled by nothing but vocabulary (#297).
261
+ lower.includes('could not be parsed') ||
262
+ lower.includes('parse error') ||
263
+ lower.includes('failed to parse') ||
264
+ lower.includes('malformed') ||
265
+ lower.includes('corrupt')) {
59
266
  return 'validation';
60
267
  }
61
- if (lower.includes('gemini') ||
62
- lower.includes('api key') ||
63
- lower.includes('unauthorized') ||
64
- lower.includes('forbidden')) {
65
- return 'permission';
66
- }
67
- if (lower.includes('timeout') ||
68
- lower.includes('econnrefused') ||
69
- lower.includes('network') ||
70
- lower.includes('fetch failed')) {
71
- return 'transient';
72
- }
73
268
  return 'internal';
74
269
  }