@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
@@ -1,6 +1,37 @@
1
1
  import { promises as fs } from 'fs';
2
+ import { randomBytes } from 'crypto';
2
3
  import * as path from 'path';
3
4
  import { Logger } from './utils/Logger.js';
5
+ import { ValidationError, BusinessError } from './utils/CategorisedError.js';
6
+ /**
7
+ * Narrows a parsed JSON value to a pattern, or null when it is not one.
8
+ *
9
+ * `JSON.parse(data) as PatternData` is a cast: it asserts a shape rather
10
+ * than checking it, so a file that parses but lacks `tags` or
11
+ * `timestamp` threw further downstream and took the whole listing with
12
+ * it (#426).
13
+ */
14
+ /**
15
+ * Longest pattern name that can actually be saved.
16
+ *
17
+ * 255 (the filesystem's limit on one path component) minus `.json` and
18
+ * minus the `.<16 hex>.tmp` suffix the atomic write needs beside it.
19
+ */
20
+ const MAX_PATTERN_NAME_LENGTH = 255 - '.json'.length - ('.'.length + 16 + '.tmp'.length);
21
+ function asPatternData(value) {
22
+ if (typeof value !== 'object' || value === null)
23
+ return null;
24
+ const candidate = value;
25
+ if (typeof candidate.name !== 'string')
26
+ return null;
27
+ if (typeof candidate.content !== 'string')
28
+ return null;
29
+ if (typeof candidate.timestamp !== 'string')
30
+ return null;
31
+ if (!Array.isArray(candidate.tags))
32
+ return null;
33
+ return candidate;
34
+ }
4
35
  export class PatternStore {
5
36
  basePath;
6
37
  patternCache = new Map();
@@ -55,35 +86,167 @@ export class PatternStore {
55
86
  console.error('Failed to create patterns directory:', error);
56
87
  }
57
88
  }
58
- async save(name, content, tags = []) {
89
+ /**
90
+ * Writes a pattern, refusing to silently destroy a different one.
91
+ *
92
+ * `sanitizeFilename` is many-to-one and always has been: `Track 1`,
93
+ * `TRACK 1` and `Track#1` all land on `track_1.json`, `drums/kick` and
94
+ * `synth/kick` both land on `kick.json`, and any two all-non-ASCII
95
+ * names collapse together. That mapping stays — names with spaces and
96
+ * capitals are natural for music, and rejecting them would break every
97
+ * pattern already saved.
98
+ *
99
+ * What changes is that a collision is no longer silent. Measured
100
+ * before this guard:
101
+ *
102
+ * save("My-Jam", "VERSION-ONE"); save("my-jam", "VERSION-TWO")
103
+ * -> one file, VERSION-ONE gone, both saves reported success
104
+ *
105
+ * Re-saving under the SAME original name is an ordinary update and is
106
+ * not a collision — refusing that would break every edit of an
107
+ * existing pattern.
108
+ *
109
+ * @param name - Pattern name as the caller spells it
110
+ * @param content - Pattern source
111
+ * @param tags - Tags to attach
112
+ * @param options.overwrite - Replace a different pattern on the same
113
+ * filename. Deliberately explicit: the loss is not recoverable.
114
+ * @throws {ValidationError} When the filename is taken by a pattern
115
+ * saved under a different name and `overwrite` is not set
116
+ */
117
+ async save(name, content, tags = [], options = {}) {
59
118
  await this.ensureDirectory();
60
119
  const filename = this.sanitizeFilename(name) + '.json';
61
120
  const filepath = path.join(this.basePath, filename);
121
+ // One file, one cache entry. See the note on setCacheWithLRU below.
122
+ const cacheKey = this.sanitizeFilename(name);
123
+ if (options.overwrite !== true) {
124
+ const existing = await this.readIfPresent(filepath);
125
+ if (existing !== null && existing.name !== name) {
126
+ throw new ValidationError(`Cannot save "${name}": the name "${existing.name}" already uses the file ` +
127
+ `${filename}. Names are lowercased and stripped of punctuation, so these ` +
128
+ `collide. Choose a name that differs by more than case or punctuation, or ` +
129
+ `pass overwrite: true to replace "${existing.name}" — which cannot be undone.`);
130
+ }
131
+ }
62
132
  const data = {
63
133
  name,
64
134
  content,
65
- tags,
135
+ // Copied, not aliased. The caller's array was stored by reference
136
+ // and cached, so mutating it after the save changed what `load`
137
+ // returned while the file on disk kept the original — the cache
138
+ // and the disk disagreeing, which is the thing #428 exists to
139
+ // prevent:
140
+ //
141
+ // const tags = ['x'];
142
+ // await save('p', 'code', tags);
143
+ // tags[0] = 'y';
144
+ // (await load('p')).tags // ['y'], file holds ['x'] (#473)
145
+ tags: [...tags],
66
146
  timestamp: new Date().toISOString(),
67
147
  };
68
- // Update cache with LRU eviction
69
- this.setCacheWithLRU(name, data);
148
+ // Write first, cache second.
149
+ //
150
+ // The cache used to be written before the file, so a rejected write
151
+ // left a pattern cached that was never persisted and `load` served
152
+ // it as though it had been. A 255-character name reaches here — it
153
+ // passes InputValidator's 255 limit and `sanitizeFilename` measures
154
+ // the stem without `.json` — and then fails ENAMETOOLONG on the
155
+ // 260-byte component (#428).
156
+ // Write to a temp file, then rename.
157
+ //
158
+ // `rename` is atomic within a filesystem, so a reader sees either
159
+ // the old pattern or the new one and never a half-written file. The
160
+ // direct write left truncated JSON behind on a crash or a full disk
161
+ // — which is exactly the input that used to take the entire listing
162
+ // down (#426). That fix made the listing survive such a file; this
163
+ // one stops producing them.
164
+ //
165
+ // The temp name is random, and the write is exclusive.
166
+ //
167
+ // A predictable name (pid plus a counter) is guessable, so anyone
168
+ // able to write into the pattern directory could pre-create it as a
169
+ // symlink and redirect the write elsewhere — CodeQL flags exactly
170
+ // this as js/insecure-temporary-file, and it was right. `randomBytes`
171
+ // removes the guess and the `wx` flag refuses to open a path that
172
+ // already exists, symlink included, rather than following it.
173
+ //
174
+ // It sits in the same directory so the rename stays within one
175
+ // filesystem, which is what makes the rename atomic.
176
+ const tempPath = `${filepath}.${randomBytes(8).toString('hex')}.tmp`;
177
+ try {
178
+ // `mode: 0o600` is the part CodeQL's js/insecure-temporary-file is
179
+ // actually about — "accessible by other users". A temp file
180
+ // holding the user's pattern has no business being world-readable
181
+ // even for the instant before the rename.
182
+ await fs.writeFile(tempPath, JSON.stringify(data, null, 2), { flag: 'wx', mode: 0o600 });
183
+ await fs.rename(tempPath, filepath);
184
+ }
185
+ catch (error) {
186
+ // Leaving a stray .tmp would make it the next listing's problem.
187
+ await fs.unlink(tempPath).catch(() => undefined);
188
+ throw error;
189
+ }
190
+ // Keyed by the FILE's name, not the caller's.
191
+ //
192
+ // The cache was keyed by the raw name while the file is keyed by the
193
+ // sanitized one, so two names that sanitize alike had one file and
194
+ // two cache entries. Measured:
195
+ //
196
+ // save("My-Jam", "VERSION-ONE"); save("my-jam", "VERSION-TWO")
197
+ // files on disk: ["my-jam.json"] disk content: VERSION-TWO
198
+ // load("My-Jam") -> VERSION-ONE load("my-jam") -> VERSION-TWO
199
+ //
200
+ // Two answers for one file, and the stale one never expires — this
201
+ // cache has no TTL.
202
+ this.setCacheWithLRU(cacheKey, data);
70
203
  this.listCache = null; // Invalidate list cache
71
- // Write file asynchronously
72
- await fs.writeFile(filepath, JSON.stringify(data, null, 2));
204
+ }
205
+ /**
206
+ * The pattern in a file, or null when it is absent or unreadable.
207
+ *
208
+ * An unreadable file is treated as absent on purpose: refusing a save
209
+ * because something in the directory cannot be parsed would make one
210
+ * corrupt file block writes, which is the mistake #426 fixed for
211
+ * reads.
212
+ */
213
+ async readIfPresent(filepath) {
214
+ try {
215
+ return asPatternData(JSON.parse(await fs.readFile(filepath, 'utf-8')));
216
+ }
217
+ catch {
218
+ return null;
219
+ }
73
220
  }
74
221
  async load(name) {
222
+ // Keyed by the file, not the caller's spelling of it (#428).
223
+ const cacheKey = this.sanitizeFilename(name);
75
224
  // Check cache first with LRU update
76
- const cached = this.getCacheWithLRU(name);
225
+ const cached = this.getCacheWithLRU(cacheKey);
77
226
  if (cached) {
78
227
  return cached;
79
228
  }
80
- const filename = this.sanitizeFilename(name) + '.json';
229
+ const filename = cacheKey + '.json';
81
230
  const filepath = path.join(this.basePath, filename);
82
231
  try {
83
232
  const data = await fs.readFile(filepath, 'utf-8');
84
- const pattern = JSON.parse(data);
233
+ // Validated, like every other reader.
234
+ //
235
+ // `load` parsed the JSON and returned it, so a file holding
236
+ // `{"nonsense": 1}` or `[1,2,3]` came back as a PatternData that
237
+ // is nothing of the kind — and callers read `.code` off it and
238
+ // wrote `undefined` into the editor. `listDetailed` and
239
+ // `readIfPresent` both run `asPatternData`; this one never got
240
+ // the #426 hardening, so the two disagreed about the same file:
241
+ // the listing skipped it as malformed while `load` handed it
242
+ // straight back (#471).
243
+ const pattern = asPatternData(JSON.parse(data));
244
+ if (pattern === null) {
245
+ this.logger.warn(`Pattern file is not a pattern: ${filename}`);
246
+ return null;
247
+ }
85
248
  // Update cache with LRU eviction
86
- this.setCacheWithLRU(name, pattern);
249
+ this.setCacheWithLRU(cacheKey, pattern);
87
250
  return pattern;
88
251
  }
89
252
  catch (error) {
@@ -91,23 +254,82 @@ export class PatternStore {
91
254
  return null;
92
255
  }
93
256
  }
257
+ /**
258
+ * Patterns, plus how many files could not be read.
259
+ *
260
+ * A partial listing presented as complete is the same lie as reporting
261
+ * zero, quieter — the store logs what it skipped and no MCP client
262
+ * reads the log (#426; #335 is the precedent for making a warning
263
+ * reach the caller).
264
+ */
265
+ async listDetailed(tag) {
266
+ return this.readAll(tag);
267
+ }
94
268
  async list(tag) {
269
+ return (await this.readAll(tag)).patterns;
270
+ }
271
+ /**
272
+ * The one read. Returns the count WITH the data.
273
+ *
274
+ * This used to leave the count on the instance as `skippedInLastRead`
275
+ * and have `listDetailed` pick it up after awaiting `list`. The
276
+ * docstring above it disclaimed exactly that design — "a
277
+ * `getLastSkipped()` would belong to whichever listing finished most
278
+ * recently, and two concurrent calls would cross their answers" —
279
+ * while the code did it anyway: the field is written inside `list`
280
+ * and read after an await, so two concurrent listings genuinely
281
+ * could, and the second one's count would be reported for the first
282
+ * one's patterns (#473).
283
+ *
284
+ * Returning both from one call is what makes the docstring true.
285
+ */
286
+ async readAll(tag) {
95
287
  // Use cached list if available and not expired
96
288
  const now = Date.now();
97
289
  if (!tag && this.listCache && (now - this.listCache.timestamp) < this.LIST_CACHE_TTL) {
98
- return this.listCache.patterns;
290
+ // The cached listing carries its own skipped count, so a cache hit
291
+ // does not inherit the previous uncached read's.
292
+ return { patterns: this.listCache.patterns, skipped: this.listCache.skipped };
99
293
  }
100
294
  try {
295
+ await this.ensureDirectory();
101
296
  const files = await fs.readdir(this.basePath);
102
- // Parallel file reading for better performance
103
- const readPromises = files
297
+ // Parallel file reading for better performance.
298
+ //
299
+ // `allSettled`, not `all`. Fail-fast meant one truncated or
300
+ // unparseable `.json` rejected the whole batch, the catch below
301
+ // returned `[]`, and `doList` reported that as the success "No
302
+ // patterns found" — measured, five saved patterns plus one
303
+ // truncated file listed as zero (#426). A hundred patterns and one
304
+ // bad file told the user they had none.
305
+ const results = await Promise.allSettled(files
104
306
  .filter(file => file.endsWith('.json'))
105
307
  .map(async (file) => {
106
308
  const filepath = path.join(this.basePath, file);
107
309
  const data = await fs.readFile(filepath, 'utf-8');
108
- return JSON.parse(data);
109
- });
110
- const allPatterns = await Promise.all(readPromises);
310
+ return { file, parsed: JSON.parse(data) };
311
+ }));
312
+ const allPatterns = [];
313
+ const skipped = [];
314
+ for (const result of results) {
315
+ if (result.status !== 'fulfilled') {
316
+ skipped.push('unreadable');
317
+ continue;
318
+ }
319
+ // A cast is not a check. A file missing `timestamp` threw in the
320
+ // sort below and one missing `tags` threw in the tag filter —
321
+ // each taking the entire listing down the same way a truncated
322
+ // file did.
323
+ const pattern = asPatternData(result.value.parsed);
324
+ if (pattern === null) {
325
+ skipped.push(result.value.file);
326
+ continue;
327
+ }
328
+ allPatterns.push(pattern);
329
+ }
330
+ if (skipped.length > 0) {
331
+ this.logger.warn(`Skipped ${String(skipped.length)} unreadable or malformed pattern file(s)`, { skipped });
332
+ }
111
333
  // Filter and sort
112
334
  const filteredPatterns = tag
113
335
  ? allPatterns.filter(p => p.tags.includes(tag))
@@ -115,13 +337,29 @@ export class PatternStore {
115
337
  const sorted = filteredPatterns.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
116
338
  // Update cache only for non-filtered lists
117
339
  if (!tag) {
118
- this.listCache = { patterns: sorted, timestamp: now };
340
+ this.listCache = { patterns: sorted, timestamp: now, skipped: skipped.length };
119
341
  }
120
- return sorted;
342
+ return { patterns: sorted, skipped: skipped.length };
121
343
  }
122
344
  catch (error) {
345
+ // A directory that cannot be READ is not a directory with no
346
+ // patterns in it.
347
+ //
348
+ // Individual bad files are already handled above by `allSettled`
349
+ // and counted in `skipped`, so this catch only fires when the
350
+ // whole directory is unavailable — the readdir or the
351
+ // ensureDirectory failed. Returning `[]` reported that as a clean
352
+ // empty listing:
353
+ //
354
+ // chmod 000 <store>; listDetailed()
355
+ // -> {"patterns":[],"skipped":0}
356
+ //
357
+ // which tells the caller they have no saved patterns when the
358
+ // truth is that nothing could be looked at. That is #426's lie
359
+ // exactly, reached down a different path (#473).
123
360
  this.logger.warn(`Failed to list patterns${tag ? ` with tag: ${tag}` : ''}`, error);
124
- return [];
361
+ const reason = error instanceof Error ? error.message : String(error);
362
+ throw new BusinessError(`Cannot read the pattern directory ${this.basePath}: ${reason}`);
125
363
  }
126
364
  }
127
365
  // Clear cache method
@@ -154,14 +392,29 @@ export class PatternStore {
154
392
  const cleaned = baseName
155
393
  .replace(/[^a-z0-9_-]/gi, '_')
156
394
  .toLowerCase();
157
- // Validate length
158
- if (cleaned.length === 0 || cleaned.length > 255) {
159
- throw new Error('Pattern name must be between 1 and 255 characters');
395
+ // Validate length against the ceiling that actually exists.
396
+ //
397
+ // The stem is not the whole filename. `.json` costs 5 bytes, and
398
+ // the atomic write needs `<file>.<16 hex>.tmp` to fit in the same
399
+ // directory entry, which costs 21 more. A 255-byte component is the
400
+ // limit on ext4 and on every filesystem this runs on, so the real
401
+ // ceiling for the stem is 229.
402
+ //
403
+ // This check said 255 and let anything under it through to fail as
404
+ // a raw ENAMETOOLONG from `fs` — an uncategorised Error, so the
405
+ // envelope called it `internal` and not retryable, when it is
406
+ // exactly the caller's input being too long (#471, and the same
407
+ // class of miscategorisation as #382). Saving a 230-character name
408
+ // has never worked; it now says so, in a message with the number
409
+ // in it.
410
+ if (cleaned.length === 0 || cleaned.length > MAX_PATTERN_NAME_LENGTH) {
411
+ throw new ValidationError(`Pattern name must be between 1 and ${MAX_PATTERN_NAME_LENGTH} characters `
412
+ + `after sanitizing; got ${cleaned.length}.`);
160
413
  }
161
414
  // Prevent reserved filenames on Windows
162
415
  const reserved = ['con', 'prn', 'aux', 'nul', 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9', 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9'];
163
416
  if (reserved.includes(cleaned.toLowerCase())) {
164
- throw new Error('Pattern name uses a reserved filename');
417
+ throw new ValidationError('Pattern name uses a reserved filename');
165
418
  }
166
419
  return cleaned;
167
420
  }