@williamzujkowski/live-coding-music-mcp 4.0.0 → 4.1.1

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 +27 -16
  52. package/dist/services/__mocks__/StrudelEngine.js +0 -157
  53. package/dist/types/index.js +0 -1
@@ -0,0 +1,308 @@
1
+ /**
2
+ * A persistent forked child that evaluates untrusted pattern code, with a
3
+ * heap cap and a wall-clock deadline (#307).
4
+ *
5
+ * ## Why a child process and not a worker thread
6
+ *
7
+ * `worker_threads` + `resourceLimits.maxOldGenerationSizeMb` reads like it
8
+ * was designed for exactly this. It is not. Measured on Node 22 with a
9
+ * 64 MB cap and an `new Array(n).fill(7)` payload, a worker that blows its
10
+ * old-generation cap inside an allocating builtin aborts the **whole
11
+ * process**: the parent dumped core and never ran another line.
12
+ *
13
+ * The same payload against a fork with `--max-old-space-size=64`:
14
+ *
15
+ * small alloc : {"code":0,"msg":{"ok":true,"len":1000}}
16
+ * over the cap : {"code":null,"sig":"SIGABRT"}
17
+ * way over cap : {"code":null,"sig":"SIGABRT"}
18
+ * PARENT ALIVE after all three
19
+ *
20
+ * The child dies, the parent survives, the next call works. That is the
21
+ * containment this needs, so: child process.
22
+ *
23
+ * ## Why the child is persistent
24
+ *
25
+ * A cold fork costs ~30 ms, which is a lot on a sub-10 ms interactive
26
+ * path. Forked once and reused over IPC, the warm round-trip measures a
27
+ * median of 0 ms and a p95 of 1 ms — under the IPC noise floor. The spawn
28
+ * cost is paid at first use and after a kill, which is exactly when you
29
+ * want to pay it.
30
+ *
31
+ * ## What this does NOT bound
32
+ *
33
+ * `--max-old-space-size` bounds V8's old space. It does not bound external
34
+ * or native memory (typed-array backing stores, buffers). A pattern that
35
+ * allocates its way out through those is still not contained by the cap;
36
+ * it is contained by the deadline, which is a weaker guarantee. Stated
37
+ * here rather than implied away.
38
+ */
39
+ import { fork } from 'node:child_process';
40
+ import { existsSync } from 'node:fs';
41
+ import { BusinessError, TransientError, ValidationError } from '../utils/CategorisedError.js';
42
+ /**
43
+ * Raised when the isolated evaluation did not produce an answer: the child
44
+ * ran out of heap, ran past its deadline, died some other way, or could
45
+ * not be started. A rejected *evaluation* (bad syntax, unsafe pattern)
46
+ * is NOT this — that comes back as a normal error from the engine.
47
+ */
48
+ export class IsolatedRunnerError extends Error {
49
+ kind;
50
+ constructor(message, kind) {
51
+ super(message);
52
+ this.kind = kind;
53
+ this.name = 'IsolatedRunnerError';
54
+ }
55
+ }
56
+ /**
57
+ * Keep the FIRST slice of the child's stderr, not the last.
58
+ *
59
+ * This was the tail, and the tail is worthless here: V8 prints
60
+ * "FATAL ERROR: ... JavaScript heap out of memory" and then dumps a
61
+ * ~40-frame native stack, so the last 2000 characters are pure addresses
62
+ * and the one line that identifies the failure has scrolled away. The
63
+ * diagnosis is always at the top.
64
+ */
65
+ const STDERR_CAPTURE_LIMIT = 2000;
66
+ export class IsolatedEngineRunner {
67
+ options;
68
+ child = null;
69
+ nextId = 1;
70
+ stderrHead = '';
71
+ disposed = false;
72
+ /**
73
+ * Whether the current child has ever answered anything. A child that
74
+ * dies without having answered once did not fail at evaluating a
75
+ * pattern — it failed at starting. Those want opposite advice, so they
76
+ * must not share a failure kind.
77
+ */
78
+ answeredSinceSpawn = false;
79
+ /**
80
+ * Calls run one at a time. Not for thread-safety — for attribution: if
81
+ * the child dies, exactly one call is in flight, so there is no question
82
+ * about which payload killed it and no way to fail an innocent caller.
83
+ */
84
+ tail = Promise.resolve();
85
+ constructor(options) {
86
+ this.options = options;
87
+ }
88
+ /** True when a child is currently running. Diagnostics and tests. */
89
+ get isRunning() {
90
+ return this.child !== null && this.child.connected;
91
+ }
92
+ /**
93
+ * Runs one method in the child and resolves with its return value.
94
+ *
95
+ * @param method - Method name the child knows
96
+ * @param args - Arguments, structured-cloneable
97
+ * @returns Whatever the child's method returned
98
+ * @throws {IsolatedRunnerError} When the child died, hung, or would not start
99
+ * @throws {Error} When the child's method itself threw
100
+ */
101
+ async call(method, args) {
102
+ // Chain onto the tail whether or not the previous call succeeded; a
103
+ // failed call must not wedge the queue.
104
+ const run = this.tail.then(() => this.invoke(method, args), () => this.invoke(method, args));
105
+ this.tail = run.catch(() => undefined);
106
+ return run;
107
+ }
108
+ /** Stops the child and refuses further calls. */
109
+ dispose() {
110
+ this.disposed = true;
111
+ this.killChild();
112
+ }
113
+ invoke(method, args) {
114
+ if (this.disposed) {
115
+ return Promise.reject(new IsolatedRunnerError('Isolated engine runner has been disposed.', 'crash'));
116
+ }
117
+ let child;
118
+ try {
119
+ child = this.ensureChild();
120
+ }
121
+ catch (error) {
122
+ const detail = error instanceof Error ? error.message : String(error);
123
+ return Promise.reject(new IsolatedRunnerError(`Could not start the isolated pattern engine: ${detail}. This is a build or ` +
124
+ 'install problem, not something to retry — run `npm run build`.', 'spawn'));
125
+ }
126
+ const id = this.nextId++;
127
+ this.stderrHead = '';
128
+ return new Promise((resolve, reject) => {
129
+ let settled = false;
130
+ const pending = {
131
+ resolve: (value) => {
132
+ if (settled)
133
+ return;
134
+ settled = true;
135
+ cleanup();
136
+ resolve(value);
137
+ },
138
+ reject: (error) => {
139
+ if (settled)
140
+ return;
141
+ settled = true;
142
+ cleanup();
143
+ reject(error);
144
+ },
145
+ };
146
+ const onMessage = (raw) => {
147
+ const message = raw;
148
+ if (!message || message.id !== id)
149
+ return;
150
+ this.answeredSinceSpawn = true;
151
+ if (message.ok) {
152
+ pending.resolve(message.result);
153
+ return;
154
+ }
155
+ // The child's own error, faithfully re-raised. Not an
156
+ // IsolatedRunnerError: the isolation worked, the pattern was bad.
157
+ //
158
+ // Rebuilt with its category when the child sent one. A class
159
+ // cannot cross IPC, so without this the child's ValidationError
160
+ // arrived as a bare Error and was categorised by phrase — which
161
+ // put "Result exceeds the transfer limit. Narrow the query" into
162
+ // `internal`, not retryable and not the caller's fault, when it
163
+ // is exactly the caller's to fix (#382).
164
+ const detail = message.error?.message ?? 'Pattern evaluation failed';
165
+ const rebuilt = message.error?.category === 'validation' ? new ValidationError(detail)
166
+ : message.error?.category === 'business' ? new BusinessError(detail)
167
+ : message.error?.category === 'transient' ? new TransientError(detail)
168
+ : new Error(detail);
169
+ if (message.error?.name !== undefined && message.error.category === undefined) {
170
+ rebuilt.name = message.error.name;
171
+ }
172
+ pending.reject(rebuilt);
173
+ };
174
+ // 'close', not 'exit'. They differ by exactly the thing that
175
+ // matters here: 'exit' fires when the process ends, while the
176
+ // child's stderr may still be unread — so classifying on 'exit'
177
+ // looked at an EMPTY stderr tail and could not see V8's
178
+ // "JavaScript heap out of memory". 'close' fires once the stdio
179
+ // streams are done, which is the first moment the evidence exists.
180
+ const onClose = (code, signal) => {
181
+ this.child = null;
182
+ pending.reject(this.describeDeath(method, code, signal));
183
+ };
184
+ const onError = (error) => {
185
+ pending.reject(new IsolatedRunnerError(`Isolated pattern engine failed: ${error.message}`, 'crash'));
186
+ };
187
+ const timer = setTimeout(() => {
188
+ this.killChild();
189
+ pending.reject(new IsolatedRunnerError(`Pattern evaluation exceeded the ${String(this.options.timeoutMs)}ms deadline and was ` +
190
+ 'stopped. The engine has been restarted; simplify the pattern and try again.', 'timeout'));
191
+ }, this.options.timeoutMs);
192
+ const cleanup = () => {
193
+ clearTimeout(timer);
194
+ child.off('message', onMessage);
195
+ child.off('close', onClose);
196
+ child.off('error', onError);
197
+ };
198
+ child.on('message', onMessage);
199
+ child.on('close', onClose);
200
+ child.on('error', onError);
201
+ try {
202
+ child.send({ id, method, args });
203
+ }
204
+ catch (error) {
205
+ const detail = error instanceof Error ? error.message : String(error);
206
+ pending.reject(new IsolatedRunnerError(`Could not reach the isolated pattern engine: ${detail}`, 'crash'));
207
+ }
208
+ });
209
+ }
210
+ /**
211
+ * Turns an exit code and signal into something a caller can act on.
212
+ *
213
+ * V8 aborts on heap exhaustion, so SIGABRT (and the 134 that a shell
214
+ * reports for it) means "out of heap" in practice. Anything else is an
215
+ * honest "it died and we do not know why", with the stderr tail
216
+ * attached rather than guessed at.
217
+ */
218
+ describeDeath(method, code, signal) {
219
+ // V8 prints "JavaScript heap out of memory" before it aborts, so the
220
+ // stderr signature is the reliable evidence and the signal is only a
221
+ // fallback for when we captured nothing. Treating every SIGABRT as an
222
+ // OOM would file a native assertion as the caller's bad input, which
223
+ // is both wrong and unhelpful — cross-model review (codex) flagged
224
+ // exactly that.
225
+ const sawHeapMessage = /heap out of memory|Allocation failed/i.test(this.stderrHead);
226
+ const abortedSilently = this.stderrHead.trim().length === 0 && (signal === 'SIGABRT' || code === 134);
227
+ const outOfHeap = sawHeapMessage || abortedSilently;
228
+ if (outOfHeap) {
229
+ return new IsolatedRunnerError(`Pattern evaluation exceeded the ${String(this.options.maxOldSpaceMb)}MB memory cap for ` +
230
+ `${method} and was stopped. The engine has been restarted. Reduce what the pattern ` +
231
+ 'allocates — a smaller cycle range, or fewer generated events.', 'oom');
232
+ }
233
+ const how = signal !== null ? `signal ${signal}` : `exit code ${String(code)}`;
234
+ const tail = this.stderrHead.trim();
235
+ // A child that never answered anything did not fail at evaluating
236
+ // this pattern; it failed at starting — a broken build, a bad import,
237
+ // a mismatched Node. Retrying that is a loop with no exit, so it gets
238
+ // its own kind and its own (non-retryable) advice.
239
+ if (!this.answeredSinceSpawn) {
240
+ return new IsolatedRunnerError(`The isolated pattern engine could not start (${how}). This is a build or install ` +
241
+ `problem, not something to retry — run \`npm run build\` and check that ` +
242
+ `${this.options.childPath} exists.${tail ? ` Last output: ${tail}` : ''}`, 'spawn');
243
+ }
244
+ return new IsolatedRunnerError(`The isolated pattern engine died during ${method} (${how}). The engine has been ` +
245
+ `restarted.${tail ? ` Last output: ${tail}` : ''}`, 'crash');
246
+ }
247
+ ensureChild() {
248
+ if (this.child !== null && this.child.connected)
249
+ return this.child;
250
+ // fork() to a path that does not exist succeeds, then the child exits
251
+ // 1 a moment later — which arrives as a death, not as a spawn
252
+ // failure. Checking first turns a confusing "the engine died" into an
253
+ // accurate "the engine was never there".
254
+ if (!existsSync(this.options.childPath)) {
255
+ throw new Error(`child entrypoint not found at ${this.options.childPath}`);
256
+ }
257
+ const reason = this.child === null ? 'first use or after a kill' : 'disconnected child';
258
+ // stdout is IGNORED on purpose. This process speaks MCP over its own
259
+ // stdout; anything the child printed there would land in the middle of
260
+ // a JSON-RPC frame and corrupt the session. stderr is piped rather
261
+ // than inherited for the same reason in reverse — a V8 OOM dump is
262
+ // hundreds of lines, and it is more useful in an error message than on
263
+ // the operator's terminal.
264
+ const child = fork(this.options.childPath, [], {
265
+ execArgv: [
266
+ `--max-old-space-size=${String(this.options.maxOldSpaceMb)}`,
267
+ ...(this.options.extraExecArgv ?? []),
268
+ ],
269
+ stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
270
+ env: { ...process.env, LCM_ISOLATED_ENGINE_CHILD: '1' },
271
+ });
272
+ child.stderr?.setEncoding('utf8');
273
+ child.stderr?.on('data', (chunk) => {
274
+ if (this.stderrHead.length >= STDERR_CAPTURE_LIMIT)
275
+ return;
276
+ this.stderrHead = (this.stderrHead + chunk).slice(0, STDERR_CAPTURE_LIMIT);
277
+ });
278
+ // A dead child is not an event anyone needs to hear about twice; the
279
+ // in-flight call's own listener reports it. This one only clears the
280
+ // handle so the next call forks a fresh one.
281
+ child.on('exit', () => {
282
+ if (this.child === child)
283
+ this.child = null;
284
+ });
285
+ // Without this, an unhandled 'error' on the child would take the
286
+ // parent down — the exact failure this class exists to prevent.
287
+ child.on('error', () => {
288
+ if (this.child === child)
289
+ this.child = null;
290
+ });
291
+ this.child = child;
292
+ this.answeredSinceSpawn = false;
293
+ this.options.onSpawn?.(reason);
294
+ return child;
295
+ }
296
+ killChild() {
297
+ const child = this.child;
298
+ this.child = null;
299
+ if (child === null)
300
+ return;
301
+ try {
302
+ child.kill('SIGKILL');
303
+ }
304
+ catch {
305
+ // Already gone. Nothing to do, and nothing worth reporting.
306
+ }
307
+ }
308
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `LocalPatternEngine` backed by a forked child with a heap cap and a
3
+ * deadline (#307).
4
+ *
5
+ * Before this, `validate_pattern_local`, `analyze_pattern_local`,
6
+ * `query_pattern_events` and `transpile_pattern` each evaluated user code
7
+ * in the MCP server's own process. The AST allowlist in `PatternSandbox`
8
+ * stops the code doing anything *interesting*, but it cannot stop it doing
9
+ * something *large*: `new Array(5e7).fill(7)` is allowlist-clean and kills
10
+ * the server, taking every session's browser state with it.
11
+ *
12
+ * One runner sits under all four tools rather than three isolation paths,
13
+ * which was the point of the fix. It is lazy — a session that never touches
14
+ * a local-engine tool never forks anything.
15
+ */
16
+ import { IsolatedEngineRunner, IsolatedRunnerError } from './IsolatedEngineRunner.js';
17
+ /**
18
+ * V8 old-space cap for the child. Comfortably above what a legitimate
19
+ * pattern needs (the engine already refuses to materialize more than
20
+ * 50,000 events) and far below what would trouble the host.
21
+ */
22
+ export const DEFAULT_ENGINE_HEAP_MB = 256;
23
+ /** Wall-clock deadline for one evaluation. Covers hangs the heap cap cannot. */
24
+ export const DEFAULT_ENGINE_TIMEOUT_MS = 5000;
25
+ export class IsolatedStrudelEngine {
26
+ options;
27
+ runner = null;
28
+ starting = null;
29
+ /**
30
+ * Permanent, not a flag that a later start can clear. Resolving the
31
+ * child entrypoint is asynchronous, so `dispose()` can land while a
32
+ * start is in flight — and without this the start finished afterwards,
33
+ * assigned a fresh runner and forked a child AFTER shutdown. A process
34
+ * spawned by a disposed engine has nobody left to kill it.
35
+ */
36
+ disposed = false;
37
+ constructor(options = {}) {
38
+ this.options = options;
39
+ }
40
+ /** True once a child has been forked. Diagnostics and tests. */
41
+ get isStarted() {
42
+ return this.runner !== null;
43
+ }
44
+ async transpile(code) {
45
+ return this.call('transpile', [code]);
46
+ }
47
+ async validate(code) {
48
+ return this.call('validate', [code]);
49
+ }
50
+ async analyzePattern(code) {
51
+ return this.call('analyzePattern', [code]);
52
+ }
53
+ async queryEvents(code, start, end) {
54
+ return this.call('queryEvents', [code, start, end]);
55
+ }
56
+ /**
57
+ * Stops the child permanently. Safe to call when none was ever started,
58
+ * and safe to call while one is starting.
59
+ */
60
+ dispose() {
61
+ this.disposed = true;
62
+ this.runner?.dispose();
63
+ this.runner = null;
64
+ this.starting = null;
65
+ }
66
+ async call(method, args) {
67
+ const runner = await this.getRunner();
68
+ return runner.call(method, args);
69
+ }
70
+ async getRunner() {
71
+ if (this.disposed) {
72
+ throw new IsolatedRunnerError('Isolated pattern engine has been disposed.', 'crash');
73
+ }
74
+ if (this.runner !== null)
75
+ return this.runner;
76
+ // Single-flight: four concurrent tool calls on a cold engine must
77
+ // resolve the entrypoint once, not four times.
78
+ this.starting ??= this.startRunner();
79
+ try {
80
+ const runner = await this.starting;
81
+ // Re-checked AFTER the await, not just before it. dispose() may
82
+ // have run during the entrypoint resolution, and a runner handed
83
+ // back at that point would fork a child nobody owns.
84
+ if (this.disposed) {
85
+ runner.dispose();
86
+ throw new IsolatedRunnerError('Isolated pattern engine has been disposed.', 'crash');
87
+ }
88
+ return runner;
89
+ }
90
+ finally {
91
+ this.starting = null;
92
+ }
93
+ }
94
+ async startRunner() {
95
+ let childPath = this.options.childPath;
96
+ let extraExecArgv = this.options.extraExecArgv ?? [];
97
+ if (childPath === undefined) {
98
+ // Dynamic, and only on the production path: the module it loads uses
99
+ // `import.meta`, which cannot be parsed by this project's Jest.
100
+ // See engineChildPath.ts for why that is not negotiable.
101
+ try {
102
+ const resolve = this.options.resolveEntrypoint ??
103
+ (async () => {
104
+ const { resolveChildEntrypoint } = await import('./engineChildPath.js');
105
+ return resolveChildEntrypoint();
106
+ });
107
+ const entrypoint = await resolve();
108
+ childPath = entrypoint.childPath;
109
+ if (entrypoint.needsTsx)
110
+ extraExecArgv = ['--import', 'tsx', ...extraExecArgv];
111
+ }
112
+ catch (error) {
113
+ if (error instanceof IsolatedRunnerError)
114
+ throw error;
115
+ const detail = error instanceof Error ? error.message : String(error);
116
+ throw new IsolatedRunnerError(`Could not locate the isolated pattern engine child: ${detail}`, 'spawn');
117
+ }
118
+ }
119
+ const runner = new IsolatedEngineRunner({
120
+ childPath,
121
+ extraExecArgv,
122
+ maxOldSpaceMb: this.options.maxOldSpaceMb ?? DEFAULT_ENGINE_HEAP_MB,
123
+ timeoutMs: this.options.timeoutMs ?? DEFAULT_ENGINE_TIMEOUT_MS,
124
+ onSpawn: this.options.onSpawn,
125
+ });
126
+ // Assigned only if we are still alive. Storing it first would leave
127
+ // dispose() with nothing to find and this method with a live handle.
128
+ if (this.disposed) {
129
+ runner.dispose();
130
+ throw new IsolatedRunnerError('Isolated pattern engine has been disposed.', 'crash');
131
+ }
132
+ this.runner = runner;
133
+ return runner;
134
+ }
135
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The contract the local (non-browser) pattern tools depend on.
3
+ *
4
+ * Two things implement this: `StrudelEngine`, which evaluates in-process,
5
+ * and `IsolatedStrudelEngine`, which evaluates in a forked child with a
6
+ * heap cap and a deadline (#307). The tool layer talks to this interface
7
+ * so it does not care which.
8
+ *
9
+ * Every method is declared as `T | Promise<T>` rather than `Promise<T>`.
10
+ * That is deliberate: the in-process engine is synchronous, the isolated
11
+ * one cannot be, and `await` handles both. Forcing the synchronous
12
+ * implementation to return promises would buy nothing and would break the
13
+ * direct-call tests that exercise it.
14
+ */
15
+ /** The four methods that cross the isolation boundary. */
16
+ export const ISOLATED_METHODS = ['transpile', 'validate', 'analyzePattern', 'queryEvents'];