@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
@@ -4,6 +4,12 @@
4
4
  * Owns one consolidated tool (`pattern_store`) with save/load/list actions.
5
5
  */
6
6
  import { InputValidator } from '../../utils/InputValidator.js';
7
+ import { ok, err, categorizeError, withStashNotice } from './types.js';
8
+ import { readFile } from 'fs/promises';
9
+ import path from 'path';
10
+ import { ValidationError } from '../../utils/CategorisedError.js';
11
+ /** Hard cap on .mid input size — protects @tonejs/midi from pathological allocations. */
12
+ const MAX_MIDI_BYTES = 1_000_000; // 1 MB
7
13
  const SESSION_ID_PROP = {
8
14
  session_id: {
9
15
  type: 'string',
@@ -29,38 +35,201 @@ export const tools = [
29
35
  },
30
36
  name: { type: 'string', description: 'Pattern name (required for save/load)' },
31
37
  tags: { type: 'array', items: { type: 'string' }, description: 'Tags to attach (save only)' },
38
+ overwrite: {
39
+ type: 'boolean',
40
+ description: 'save only: replace a pattern saved under a DIFFERENT name that maps to the '
41
+ + 'same file (names are lowercased and stripped of punctuation, so "My Jam" '
42
+ + 'and "my-jam" collide). Off by default; the replaced pattern is unrecoverable.',
43
+ },
32
44
  tag: { type: 'string', description: 'Filter by tag (list only)' },
33
45
  ...SESSION_ID_PROP,
34
46
  },
35
47
  required: ['action'],
36
48
  },
37
49
  },
50
+ {
51
+ name: 'import_midi',
52
+ description: 'Convert a .mid file into a playable Strudel pattern (Phase 1: literal transcription, #201). ' +
53
+ 'Use source="base64" with data=<base64 string> for inline bytes, or source="path" with data=<basename> ' +
54
+ 'to read from the patterns/midi/ directory (path traversal blocked). ' +
55
+ 'Drum tracks (MIDI channel 10) emit one s() lane per sample so simultaneous kicks/hats do not collide. ' +
56
+ 'Pitched tracks emit note("...").s("piano") with simultaneous notes merged into [a,b,c] chord tokens. ' +
57
+ 'Phase 2+ (structural compression, voice separation, LLM idiomatic pass) tracked in separate issues. ' +
58
+ 'Example: import_midi({ source: "path", data: "drumloop.mid", steps_per_cycle: 16 }). ' +
59
+ 'For the reverse direction (Strudel → MIDI) use the export_midi tool.',
60
+ inputSchema: {
61
+ type: 'object',
62
+ properties: {
63
+ source: {
64
+ type: 'string',
65
+ enum: ['base64', 'path'],
66
+ description: 'How to interpret `data` — raw base64 bytes or a filename under patterns/midi/',
67
+ },
68
+ data: {
69
+ type: 'string',
70
+ description: 'For source=base64: base64-encoded .mid bytes (≤1MB decoded). ' +
71
+ 'For source=path: basename of a .mid file under ./patterns/midi/ (path-traversal blocked).',
72
+ },
73
+ steps_per_cycle: {
74
+ type: 'number',
75
+ description: 'Grid resolution per bar (integer 1-64). Default 16.',
76
+ },
77
+ bars: {
78
+ type: 'number',
79
+ description: 'Cap on bars to emit. Default: full file.',
80
+ },
81
+ drum_map: {
82
+ type: 'object',
83
+ description: 'Optional override / extension to the default GM percussion map. ' +
84
+ 'Keys are MIDI note numbers (as strings, since JSON object keys), values are Strudel sample names. ' +
85
+ 'Example: { "60": "cp", "61": "rim" }. Unmapped drums fall back to rests and are surfaced in the result summary.',
86
+ additionalProperties: { type: 'string' },
87
+ },
88
+ },
89
+ required: ['source', 'data'],
90
+ },
91
+ },
38
92
  ];
39
93
  export const toolNames = new Set(tools.map(t => t.name));
40
94
  async function doSave(args, ctx, sid) {
41
95
  InputValidator.validateStringLength(args.name, 'name', 255, false);
42
96
  const toSave = await ctx.getCurrentPatternSafe(sid);
43
- if (!toSave) {
44
- return 'No pattern to save';
97
+ // Whitespace counts as nothing. `!toSave` covered '' only, so a
98
+ // pattern of " \n\t " was written to disk and reported as a real
99
+ // save (#293).
100
+ if (!toSave || toSave.trim().length === 0) {
101
+ // Nothing was written to disk, so this is a failure — but as a bare
102
+ // string it was none of the three things the dispatcher recognises
103
+ // (envelope, failure-shaped object, legacy 'Error: ' prefix), so it
104
+ // reached clients as { ok: true, data: 'No pattern to save' }. An
105
+ // agent branching on `ok` recorded a successful save and moved on,
106
+ // and the pattern was gone (#287).
107
+ return err('business', 'No pattern to save. Write or generate a pattern first.');
45
108
  }
46
- await ctx.store.save(args.name, toSave, args.tags || []);
109
+ // A collision throws ValidationError, which `categorizeError` maps to
110
+ // the validation envelope at the dispatcher — so the caller gets a
111
+ // refusal naming the pattern in the way, not a success (#428).
112
+ await ctx.store.save(args.name, toSave, args.tags || [], {
113
+ overwrite: args.overwrite === true,
114
+ });
47
115
  return `Pattern saved as "${args.name}"`;
48
116
  }
49
117
  async function doLoad(args, ctx, sid) {
50
118
  InputValidator.validateStringLength(args.name, 'name', 255, false);
51
119
  const saved = await ctx.store.load(args.name);
52
120
  if (saved) {
53
- await ctx.writePatternSafe(saved.content, sid);
54
- return `Loaded pattern "${args.name}"`;
121
+ const written = await ctx.writePatternSafe(saved.content, sid);
122
+ return withStashNotice(`Loaded pattern "${args.name}"`, written);
55
123
  }
56
- return `Pattern "${args.name}" not found`;
124
+ // Bare prose, so the dispatcher's legacy normalisation — which only
125
+ // recognises 'Error: ' and 'Browser not initialized' prefixes — let
126
+ // this through as { ok: true, data: 'Pattern "x" not found' }. Same
127
+ // false success as #287, twenty lines below the code that fixed it
128
+ // (#293).
129
+ return err('business', `Pattern "${args.name}" not found. ` +
130
+ 'Use pattern_store({ action: "list" }) to see what is saved.');
57
131
  }
58
132
  async function doList(args, ctx) {
59
133
  if (args?.tag) {
60
134
  InputValidator.validateStringLength(args.tag, 'tag', 100, false);
61
135
  }
62
- const patterns = await ctx.store.list(args?.tag);
63
- return patterns.map(p => `• ${p.name} [${p.tags.join(', ')}] - ${p.timestamp}`).join('\n') || 'No patterns found';
136
+ const { patterns, skipped } = await ctx.store.listDetailed(args?.tag);
137
+ // A file the store could not read is not the same as a pattern that
138
+ // is not there, and the caller has no other way to learn the
139
+ // difference — the store logs it, and no MCP client reads the log
140
+ // (#426, same reasoning as the export warning in #335).
141
+ const note = skipped > 0
142
+ ? `\n\n(${String(skipped)} file(s) in the pattern directory could not be read and were skipped.)`
143
+ : '';
144
+ if (patterns.length === 0) {
145
+ return skipped > 0
146
+ ? `No readable patterns found.${note}`
147
+ : 'No patterns found';
148
+ }
149
+ return patterns.map(p => `• ${p.name} [${p.tags.join(', ')}] - ${p.timestamp}`).join('\n') + note;
150
+ }
151
+ /**
152
+ * Resolve `args.data` to a Buffer based on `args.source`. Enforces the
153
+ * 1 MB cap and, for path sources, blocks path traversal by clamping to
154
+ * `./patterns/midi/` via `path.basename` (mirrors PatternStore).
155
+ */
156
+ async function loadMidiBuffer(args) {
157
+ const source = args?.source;
158
+ const data = args?.data;
159
+ if (typeof data !== 'string' || data.length === 0) {
160
+ throw new Error('Invalid data: must be a non-empty string');
161
+ }
162
+ if (source === 'base64') {
163
+ // Decoded length ≤ raw length × 3/4; reject before allocating.
164
+ if (data.length > MAX_MIDI_BYTES * 2) {
165
+ throw new ValidationError(`MIDI base64 input too large (>${MAX_MIDI_BYTES} bytes decoded).`);
166
+ }
167
+ const buf = Buffer.from(data, 'base64');
168
+ if (buf.length === 0) {
169
+ throw new Error('Invalid data: base64 decoded to zero bytes');
170
+ }
171
+ if (buf.length > MAX_MIDI_BYTES) {
172
+ throw new ValidationError(`MIDI input too large (${buf.length} > ${MAX_MIDI_BYTES} bytes).`);
173
+ }
174
+ return buf;
175
+ }
176
+ if (source === 'path') {
177
+ InputValidator.validateStringLength(data, 'data', 255, false);
178
+ const safeName = path.basename(data);
179
+ if (safeName !== data) {
180
+ throw new Error('Invalid filename: path traversal detected');
181
+ }
182
+ const fullPath = path.resolve('./patterns/midi', safeName);
183
+ const expectedRoot = path.resolve('./patterns/midi');
184
+ if (!fullPath.startsWith(expectedRoot + path.sep)) {
185
+ throw new Error('Invalid filename: must resolve under patterns/midi/');
186
+ }
187
+ const buf = await readFile(fullPath);
188
+ if (buf.length > MAX_MIDI_BYTES) {
189
+ throw new ValidationError(`MIDI input too large (${buf.length} > ${MAX_MIDI_BYTES} bytes).`);
190
+ }
191
+ return buf;
192
+ }
193
+ throw new Error(`Invalid source: ${source}. Must be "base64" or "path".`);
194
+ }
195
+ async function doImportMidi(args, ctx) {
196
+ let buffer;
197
+ try {
198
+ buffer = await loadMidiBuffer(args);
199
+ }
200
+ catch (e) {
201
+ return err('validation', e?.message ?? 'Failed to load MIDI input');
202
+ }
203
+ // Normalize drum_map keys: JSON object keys are strings, the service wants number keys.
204
+ let drumMapNormalized;
205
+ if (args?.drum_map && typeof args.drum_map === 'object') {
206
+ drumMapNormalized = {};
207
+ for (const [k, v] of Object.entries(args.drum_map)) {
208
+ const n = Number(k);
209
+ if (!Number.isInteger(n) || n < 0 || n > 127 || typeof v !== 'string' || v.length === 0) {
210
+ return err('validation', `Invalid drum_map entry: ${k} -> ${String(v)}`);
211
+ }
212
+ drumMapNormalized[n] = v;
213
+ }
214
+ }
215
+ try {
216
+ const result = ctx.midiImportService.convertBuffer(buffer, {
217
+ steps_per_cycle: args?.steps_per_cycle,
218
+ bars: args?.bars,
219
+ drum_map: drumMapNormalized,
220
+ });
221
+ return ok(result);
222
+ }
223
+ catch (e) {
224
+ // Use the shared categoriser rather than a second, narrower one.
225
+ // This local matcher knew only parse/invalid/must-be, so every cap
226
+ // added in #235 landed in `internal` — including "MIDI file spans too
227
+ // many bars (N > 512). Pass bars=<n> to import a prefix.", which is
228
+ // the most actionable message in the file and was being delivered as
229
+ // a server bug the caller could do nothing about (#280).
230
+ const msg = e instanceof Error ? e.message : String(e);
231
+ return err(categorizeError(e), msg);
232
+ }
64
233
  }
65
234
  export async function execute(name, args, ctx) {
66
235
  const sid = args?.session_id;
@@ -78,6 +247,8 @@ export async function execute(name, args, ctx) {
78
247
  // Unreachable due to enum check above, but TypeScript wants it.
79
248
  return undefined;
80
249
  }
250
+ case 'import_midi':
251
+ return await doImportMidi(args, ctx);
81
252
  default:
82
253
  throw new Error(`storage module does not handle tool: ${name}`);
83
254
  }
@@ -8,7 +8,10 @@
8
8
  * - `shape` (dimension enum: mood/energy/refine)
9
9
  * - `set_tempo` — high-traffic standalone verb
10
10
  */
11
+ import { empty, err, withStashField, withStashNotice } from './types.js';
11
12
  import { InputValidator } from '../../utils/InputValidator.js';
13
+ import { BEATS_PER_CYCLE } from '../../utils/Tempo.js';
14
+ import { lookup } from '../../utils/TableLookup.js';
12
15
  const ENERGY_LEVELS = {
13
16
  0: { tempoAdjust: -20, roomAmount: 0.5, densityAdjust: '.slow(4)', description: 'minimal/ambient' },
14
17
  1: { tempoAdjust: -15, roomAmount: 0.4, densityAdjust: '.slow(3)', description: 'very sparse' },
@@ -38,7 +41,7 @@ function transposePattern(pattern, semitones) {
38
41
  'f#': 6, 'g': 7, 'g#': 8, 'a': 9, 'a#': 10, 'b': 11,
39
42
  };
40
43
  const currentNote = note.toLowerCase();
41
- const noteValue = noteMap[currentNote] || 0;
44
+ const noteValue = lookup(noteMap, currentNote, 0);
42
45
  const newNoteValue = (noteValue + semitones + 12) % 12;
43
46
  const noteNames = ['c', 'c#', 'd', 'd#', 'e', 'f', 'f#', 'g', 'g#', 'a', 'a#', 'b'];
44
47
  const newOctave = parseInt(octave) + Math.floor((noteValue + semitones) / 12);
@@ -89,7 +92,7 @@ export const tools = [
89
92
  name: 'effect',
90
93
  description: 'Add or remove a Strudel effect on the current session pattern. ' +
91
94
  'action=add appends `.<effect>(<params>)`. ' +
92
- 'action=remove strips the last `.<effect>(...)` call from the pattern. ' +
95
+ 'action=remove strips EVERY `.<effect>(...)` call from the pattern, not just the last. ' +
93
96
  'Example: effect({ action: "add", effect: "lpf", params: "1000" }). ' +
94
97
  'For higher-level effect bundles (mood/energy/refine) use shape; for raw transforms use transform.',
95
98
  inputSchema: {
@@ -105,7 +108,7 @@ export const tools = [
105
108
  },
106
109
  {
107
110
  name: 'set_tempo',
108
- description: 'Set BPM',
111
+ description: 'Set BPM. Writes setcpm(bpm/4), assuming one bar of 4/4 per cycle.',
109
112
  inputSchema: {
110
113
  type: 'object',
111
114
  properties: { bpm: { type: 'number', description: 'Tempo in BPM' }, ...SESSION_ID_PROP },
@@ -147,73 +150,139 @@ async function opTranspose(args, ctx, sid) {
147
150
  throw new Error('Semitones must be an integer');
148
151
  }
149
152
  const p = await ctx.getCurrentPatternSafe(sid);
150
- await ctx.writePatternSafe(transposePattern(p, args.semitones), sid);
151
- return `Transposed ${args.semitones} semitones`;
153
+ const written = await ctx.writePatternSafe(transposePattern(p, args.semitones), sid);
154
+ return withStashNotice(`Transposed ${args.semitones} semitones`, written);
152
155
  }
153
156
  async function opReverse(ctx, sid) {
154
157
  const p = await ctx.getCurrentPatternSafe(sid);
155
- await ctx.writePatternSafe(p + '.rev', sid);
156
- return 'Pattern reversed';
158
+ const written = await ctx.writePatternSafe(p + '.rev', sid);
159
+ return withStashNotice('Pattern reversed', written);
157
160
  }
158
161
  async function opStretch(args, ctx, sid) {
159
162
  InputValidator.validateGain(args.factor);
160
163
  const p = await ctx.getCurrentPatternSafe(sid);
161
- await ctx.writePatternSafe(p + `.slow(${args.factor})`, sid);
162
- return `Stretched by factor of ${args.factor}`;
164
+ const written = await ctx.writePatternSafe(p + `.slow(${args.factor})`, sid);
165
+ return withStashNotice(`Stretched by factor of ${args.factor}`, written);
163
166
  }
164
167
  async function opQuantize(args, ctx, sid) {
165
168
  InputValidator.validateStringLength(args.grid, 'grid', 50, false);
166
169
  const p = await ctx.getCurrentPatternSafe(sid);
167
- await ctx.writePatternSafe(p + `.struct("${args.grid}")`, sid);
168
- return `Quantized to ${args.grid} grid`;
170
+ const written = await ctx.writePatternSafe(p + `.struct("${args.grid}")`, sid);
171
+ return withStashNotice(`Quantized to ${args.grid} grid`, written);
169
172
  }
170
173
  async function opHumanize(args, ctx, sid) {
171
174
  if (args.amount !== undefined)
172
175
  InputValidator.validateNormalizedValue(args.amount, 'amount');
173
176
  const p = await ctx.getCurrentPatternSafe(sid);
174
177
  const amt = args.amount || 0.01;
175
- await ctx.writePatternSafe(p + `.nudge(rand.range(-${amt}, ${amt}))`, sid);
176
- return 'Added human timing';
178
+ const written = await ctx.writePatternSafe(p + `.nudge(rand.range(-${amt}, ${amt}))`, sid);
179
+ return withStashNotice('Added human timing', written);
177
180
  }
178
181
  async function opVary(args, ctx, sid) {
179
182
  const p = await ctx.getCurrentPatternSafe(sid);
180
183
  const varied = ctx.generator.generateVariation(p, args.type || 'subtle');
181
- await ctx.writePatternSafe(varied, sid);
182
- return `Added ${args.type || 'subtle'} variation`;
184
+ const written = await ctx.writePatternSafe(varied, sid);
185
+ return withStashNotice(`Added ${args.type || 'subtle'} variation`, written);
183
186
  }
184
187
  async function opSwing(args, ctx, sid) {
185
188
  InputValidator.validateNormalizedValue(args.amount, 'amount');
186
189
  const p = await ctx.getCurrentPatternSafe(sid);
187
- await ctx.writePatternSafe(p + `.swing(${args.amount})`, sid);
188
- return `Added swing: ${args.amount}`;
190
+ const written = await ctx.writePatternSafe(p + `.swing(${args.amount})`, sid);
191
+ return withStashNotice(`Added swing: ${args.amount}`, written);
189
192
  }
190
193
  async function opScale(args, ctx, sid) {
191
194
  InputValidator.validateStringLength(args.scale, 'scale', 50, false);
195
+ // Concatenated straight into the pattern source below, so it must be a
196
+ // scale name and not arbitrary code — the same reason `opEffectAdd`
197
+ // validates its effect name (#236). A length check is not a content
198
+ // check: `scale: 'minor") .gain(9) //'` closed the string literal and
199
+ // appended a call, which is how it got past `PatternValidator`'s
200
+ // dangerous-gain rule — that runs on the pattern before this is
201
+ // appended to it (#440).
202
+ //
203
+ // `validateScaleName` already existed and `generate.ts` already used
204
+ // it; this path never did.
205
+ InputValidator.validateScaleName(args.scale);
192
206
  InputValidator.validateRootNote(args.root);
193
207
  const p = await ctx.getCurrentPatternSafe(sid);
194
- await ctx.writePatternSafe(p + `.scale("${args.root}:${args.scale}")`, sid);
195
- return `Applied ${args.root} ${args.scale} scale`;
208
+ const written = await ctx.writePatternSafe(p + `.scale("${args.root}:${args.scale}")`, sid);
209
+ return withStashNotice(`Applied ${args.root} ${args.scale} scale`, written);
196
210
  }
197
211
  async function opEffectAdd(args, ctx, sid) {
198
212
  InputValidator.validateStringLength(args.effect, 'effect', 100, false);
213
+ // Concatenated straight into the pattern source, so it must be a plain
214
+ // method name and not arbitrary code (#236).
215
+ InputValidator.validateIdentifier(args.effect, 'effect');
199
216
  if (args.params)
200
217
  InputValidator.validateStringLength(args.params, 'params', 1000, true);
201
218
  const p = await ctx.getCurrentPatternSafe(sid);
202
219
  const withEffect = args.params
203
220
  ? p + `.${args.effect}(${args.params})`
204
221
  : p + `.${args.effect}()`;
205
- await ctx.writePatternSafe(withEffect, sid);
206
- return `Added ${args.effect} effect`;
222
+ const written = await ctx.writePatternSafe(withEffect, sid);
223
+ return withStashNotice(`Added ${args.effect} effect`, written);
224
+ }
225
+ /**
226
+ * Removes every `.effect(...)` call from a pattern.
227
+ *
228
+ * Deliberately not a RegExp. The old implementation interpolated the
229
+ * caller's effect name into `new RegExp(...)`, which handed the caller
230
+ * regex *syntax* — `(a+)+Z` against a crafted pattern blocked the event
231
+ * loop for 25 seconds, and a synchronous String.replace has no timeout to
232
+ * break out of (#236).
233
+ *
234
+ * Scanning also fixes a correctness bug the regex had: `[^)]*` stops at
235
+ * the FIRST `)`, so `.lpf(add(1,2))` was left as a stray `)`. This walks
236
+ * balanced parentheses instead.
237
+ *
238
+ * @param pattern - Pattern source to strip
239
+ * @param effect - Effect name (validated as an identifier by the caller)
240
+ * @returns The pattern with every matching call removed
241
+ */
242
+ function stripEffectCalls(pattern, effect) {
243
+ const needle = `.${effect}(`;
244
+ let out = '';
245
+ let cursor = 0;
246
+ for (;;) {
247
+ const start = pattern.indexOf(needle, cursor);
248
+ if (start === -1)
249
+ break;
250
+ // Walk to the matching close paren, respecting nesting.
251
+ let depth = 0;
252
+ let end = -1;
253
+ for (let i = start + needle.length - 1; i < pattern.length; i++) {
254
+ const ch = pattern[i];
255
+ if (ch === '(')
256
+ depth++;
257
+ else if (ch === ')') {
258
+ depth--;
259
+ if (depth === 0) {
260
+ end = i;
261
+ break;
262
+ }
263
+ }
264
+ }
265
+ // Unbalanced: leave the rest untouched rather than mangling it.
266
+ if (end === -1)
267
+ break;
268
+ out += pattern.slice(cursor, start);
269
+ cursor = end + 1;
270
+ }
271
+ return out + pattern.slice(cursor);
207
272
  }
208
273
  async function opEffectRemove(args, ctx, sid) {
209
274
  InputValidator.validateStringLength(args.effect, 'effect', 100, false);
275
+ // Must be a plain identifier: it is matched literally below, and was
276
+ // previously interpolated into a RegExp (#236).
277
+ InputValidator.validateIdentifier(args.effect, 'effect');
210
278
  const p = await ctx.getCurrentPatternSafe(sid);
211
- const regex = new RegExp(`\\.${args.effect}\\([^)]*\\)`, 'g');
212
- const stripped = p.replace(regex, '');
279
+ const stripped = stripEffectCalls(p, args.effect);
280
+ // The strip ran and matched nothing. That is a valid-empty result,
281
+ // not a failure and not a plain success (#288).
213
282
  if (stripped === p)
214
- return `No ${args.effect} effect found to remove`;
215
- await ctx.writePatternSafe(stripped, sid);
216
- return `Removed ${args.effect} effect`;
283
+ return empty(`No ${args.effect} effect found to remove`);
284
+ const written = await ctx.writePatternSafe(stripped, sid);
285
+ return withStashNotice(`Removed ${args.effect} effect`, written);
217
286
  }
218
287
  export async function execute(name, args, ctx) {
219
288
  const sid = args?.session_id;
@@ -248,8 +317,12 @@ export async function execute(name, args, ctx) {
248
317
  case 'set_tempo': {
249
318
  InputValidator.validateBPM(args.bpm);
250
319
  const p = await ctx.getCurrentPatternSafe(sid);
251
- await ctx.writePatternSafe(`setcpm(${args.bpm})\n${p}`, sid);
252
- return `Set tempo to ${args.bpm} BPM`;
320
+ // `setcpm` is CYCLES per minute, and the Strudel convention this
321
+ // project follows is one bar of four beats per cycle — so the
322
+ // divisor is what makes `set_tempo({ bpm: 130 })` produce 130 BPM
323
+ // rather than 520 (#395).
324
+ const written = await ctx.writePatternSafe(`setcpm(${String(args.bpm)}/${String(BEATS_PER_CYCLE)})\n${p}`, sid);
325
+ return withStashNotice(`Set tempo to ${args.bpm} BPM`, written);
253
326
  }
254
327
  case 'shape': {
255
328
  const dim = args?.dimension;
@@ -267,12 +340,24 @@ export async function execute(name, args, ctx) {
267
340
  }
268
341
  async function shiftMood(args, ctx, sid) {
269
342
  const mood = args.target_mood?.toLowerCase()?.trim();
270
- const profile = MOOD_PROFILES[mood];
343
+ // hasOwn, not a bare index: MOOD_PROFILES is a plain object literal,
344
+ // so MOOD_PROFILES['constructor'] returned Object — truthy, so the
345
+ // `if (!profile)` guard below never fired. The result was
346
+ // s("bd*4").slow(NaN).lpf(NaN).room(NaN).gain(NaN) written into the
347
+ // editor and reported as success:true (#308).
348
+ const profile = typeof mood === 'string' && Object.hasOwn(MOOD_PROFILES, mood)
349
+ ? MOOD_PROFILES[mood]
350
+ : undefined;
271
351
  if (!profile) {
272
- return {
273
- success: false,
274
- error: `Unknown mood: ${args.target_mood}. Valid moods: ${Object.keys(MOOD_PROFILES).join(', ')}.`,
275
- };
352
+ // `err('validation', ...)`, not a failure-shaped object.
353
+ //
354
+ // The dispatcher categorises those by running the MESSAGE through
355
+ // the string matcher, and "Valid moods: ..." contains none of the
356
+ // phrases it looks for — so a caller's typo landed in `internal`,
357
+ // which is the non-retryable fall-through for a fault that is not
358
+ // the caller's (#453). This is the audit #382 describes, at four
359
+ // sites it did not reach.
360
+ return err('validation', `Unknown mood: ${args.target_mood}. Valid moods: ${Object.keys(MOOD_PROFILES).join(', ')}.`);
276
361
  }
277
362
  const pattern = await ctx.getCurrentPatternSafe(sid);
278
363
  if (!pattern || pattern.trim().length === 0) {
@@ -316,11 +401,11 @@ async function shiftMood(args, ctx, sid) {
316
401
  result += `.gain(${g.toFixed(2)})`;
317
402
  applied.push(`gain ${profile.gainMod > 0 ? '+' : ''}${Math.round(profile.gainMod * 100 * intensity)}%`);
318
403
  }
319
- await ctx.writePatternSafe(result, sid);
404
+ const written = await ctx.writePatternSafe(result, sid);
320
405
  if (args.auto_play !== false && (sid || ctx.isInitialized())) {
321
406
  await ctx.getController(sid).play();
322
407
  }
323
- return { success: true, target_mood: mood, intensity, applied_effects: applied };
408
+ return withStashField({ success: true, target_mood: mood, intensity, applied_effects: applied }, written);
324
409
  }
325
410
  async function setEnergyLevel(level, ctx, sid) {
326
411
  if (level < 0 || level > 10 || !Number.isInteger(level)) {
@@ -335,10 +420,10 @@ async function setEnergyLevel(level, ctx, sid) {
335
420
  if (config.densityAdjust)
336
421
  result += config.densityAdjust;
337
422
  result += `.room(${config.roomAmount})`;
338
- await ctx.writePatternSafe(result, sid);
423
+ const written = await ctx.writePatternSafe(result, sid);
339
424
  if (sid || ctx.isInitialized())
340
425
  await ctx.getController(sid).play();
341
- return { success: true, level, description: config.description };
426
+ return withStashField({ success: true, level, description: config.description }, written);
342
427
  }
343
428
  async function refinePattern(direction, ctx, sid) {
344
429
  const pattern = await ctx.getCurrentPatternSafe(sid);
@@ -373,14 +458,13 @@ async function refinePattern(direction, ctx, sid) {
373
458
  modification = '.room(0.1)';
374
459
  break;
375
460
  default:
376
- return {
377
- success: false,
378
- error: `Unknown refinement direction: ${direction}. Supported: faster, slower, louder, quieter, brighter, darker, "more reverb", drier.`,
379
- };
461
+ // See the mood branch above: the caller's argument, categorised
462
+ // `validation` rather than `internal` (#453).
463
+ return err('validation', `Unknown refinement direction: ${direction}. Supported: faster, slower, louder, quieter, brighter, darker, "more reverb", drier.`);
380
464
  }
381
- await ctx.writePatternSafe(pattern + modification, sid);
465
+ const written = await ctx.writePatternSafe(pattern + modification, sid);
382
466
  if (sid || ctx.isInitialized())
383
467
  await ctx.getController(sid).play();
384
- return { success: true, direction: dir, applied: modification };
468
+ return withStashField({ success: true, direction: dir, applied: modification }, written);
385
469
  }
386
470
  export const transformModule = { tools, toolNames, execute };