@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,16 +1,15 @@
1
1
  /**
2
2
  * history domain — undo/redo stacks and the pattern history browser.
3
3
  *
4
- * Owns (5 tools): undo, redo, list_history, restore_history,
5
- * compare_patterns. Post-consolidation target per the #110 audit:
6
- * single `history` tool with an `action` enum. For now we keep the
7
- * individual verbs.
4
+ * Owns one consolidated tool (`history(action)`) with undo/redo/list/
5
+ * restore/compare actions.
8
6
  *
9
7
  * The history module mutates the three state arrays (undoStack,
10
- * redoStack, historyStack) passed through ctx.history. The outer
11
- * server pushes onto these arrays on write/append/insert/replace/clear,
12
- * so all mutations share the same underlying storage.
8
+ * redoStack, historyStack) reached through ctx.getHistory(). The outer
9
+ * server pushes onto these arrays on every edit_pattern call, so all
10
+ * mutations share the same underlying storage.
13
11
  */
12
+ import { empty, err } from './types.js';
14
13
  const SESSION_ID_PROP = {
15
14
  session_id: {
16
15
  type: 'string',
@@ -45,51 +44,6 @@ export const tools = [
45
44
  required: ['action'],
46
45
  },
47
46
  },
48
- {
49
- name: 'undo',
50
- description: '[DEPRECATED — use history({ action: "undo" }) instead] Undo last action',
51
- inputSchema: { type: 'object', properties: { ...SESSION_ID_PROP } },
52
- },
53
- {
54
- name: 'redo',
55
- description: '[DEPRECATED — use history({ action: "redo" }) instead] Redo action',
56
- inputSchema: { type: 'object', properties: { ...SESSION_ID_PROP } },
57
- },
58
- {
59
- name: 'list_history',
60
- description: '[DEPRECATED — use history({ action: "list" }) instead] List recent pattern history with timestamps and previews',
61
- inputSchema: {
62
- type: 'object',
63
- properties: {
64
- limit: { type: 'number', description: 'Maximum entries to return (default: 10)' },
65
- },
66
- },
67
- },
68
- {
69
- name: 'restore_history',
70
- description: '[DEPRECATED — use history({ action: "restore" }) instead] Restore a previous pattern from history by ID',
71
- inputSchema: {
72
- type: 'object',
73
- properties: {
74
- id: { type: 'number', description: 'History entry ID to restore' },
75
- ...SESSION_ID_PROP,
76
- },
77
- required: ['id'],
78
- },
79
- },
80
- {
81
- name: 'compare_patterns',
82
- description: '[DEPRECATED — use history({ action: "compare" }) instead] Compare two patterns from history showing differences',
83
- inputSchema: {
84
- type: 'object',
85
- properties: {
86
- id1: { type: 'number', description: 'First pattern ID' },
87
- id2: { type: 'number', description: 'Second pattern ID (default: current pattern)' },
88
- ...SESSION_ID_PROP,
89
- },
90
- required: ['id1'],
91
- },
92
- },
93
47
  ];
94
48
  export const toolNames = new Set(tools.map(t => t.name));
95
49
  function formatTimeAgo(date) {
@@ -143,8 +97,11 @@ async function doUndo(ctx, sid) {
143
97
  const { undoStack, redoStack, maxHistory } = ctx.getHistory(sid);
144
98
  if (!sid && !ctx.isInitialized())
145
99
  return 'Browser not initialized. Run init first.';
100
+ // A valid-empty, not a failure: the call worked, there was simply
101
+ // nothing on the stack. `empty()` lets an agent tell that apart from
102
+ // a rich result without parsing the prose (#288).
146
103
  if (undoStack.length === 0)
147
- return 'Nothing to undo';
104
+ return empty('Nothing to undo');
148
105
  const controller = ctx.getController(sid);
149
106
  const current = await controller.getCurrentPattern();
150
107
  redoStack.push(current);
@@ -159,7 +116,7 @@ async function doRedo(ctx, sid) {
159
116
  if (!sid && !ctx.isInitialized())
160
117
  return 'Browser not initialized. Run init first.';
161
118
  if (redoStack.length === 0)
162
- return 'Nothing to redo';
119
+ return empty('Nothing to redo');
163
120
  const controller = ctx.getController(sid);
164
121
  const current = await controller.getCurrentPattern();
165
122
  undoStack.push(current);
@@ -172,7 +129,14 @@ async function doRedo(ctx, sid) {
172
129
  function doList(args, ctx, sid) {
173
130
  const { historyStack } = ctx.getHistory(sid);
174
131
  if (historyStack.length === 0) {
175
- return 'No pattern history yet. Make some edits to build history.';
132
+ // Same record shape as the populated branch below, so a caller can
133
+ // read `entries` unconditionally instead of branching on a string.
134
+ return empty({
135
+ count: 0,
136
+ showing: 0,
137
+ entries: [],
138
+ message: 'No pattern history yet. Make some edits to build history.',
139
+ });
176
140
  }
177
141
  const limit = args?.limit || 10;
178
142
  const recent = historyStack.slice(-limit).reverse();
@@ -195,7 +159,10 @@ async function doRestore(args, ctx, sid) {
195
159
  const controller = ctx.getController(sid);
196
160
  const entry = historyStack.find(e => e.id === args.id);
197
161
  if (!entry) {
198
- return `History entry #${args.id} not found. Use history({ action: "list" }) to see available entries.`;
162
+ // Same bare-string false success as storage's load (#293): nothing
163
+ // was restored, but `ok: true` said otherwise.
164
+ return err('business', `History entry #${args.id} not found. ` +
165
+ 'Use history({ action: "list" }) to see available entries.');
199
166
  }
200
167
  const current = await controller.getCurrentPattern();
201
168
  undoStack.push(current);
@@ -207,14 +174,22 @@ async function doRestore(args, ctx, sid) {
207
174
  async function doCompare(args, ctx, sid) {
208
175
  const { historyStack } = ctx.getHistory(sid);
209
176
  const first = historyStack.find(e => e.id === args.id1);
210
- if (!first)
211
- return `History entry #${args.id1} not found.`;
177
+ if (!first) {
178
+ // Bare strings reach the client as `{ok: true, data: "...not
179
+ // found."}` — the #287/#293 false success, still here twelve lines
180
+ // below the `doRestore` that fixed it for the identical condition
181
+ // (#453).
182
+ return err('business', `History entry #${args.id1} not found. ` +
183
+ 'Use history({ action: "list" }) to see available entries.');
184
+ }
212
185
  let second;
213
186
  let label;
214
187
  if (args.id2) {
215
188
  const b = historyStack.find(e => e.id === args.id2);
216
- if (!b)
217
- return `History entry #${args.id2} not found.`;
189
+ if (!b) {
190
+ return err('business', `History entry #${args.id2} not found. ` +
191
+ 'Use history({ action: "list" }) to see available entries.');
192
+ }
218
193
  second = b.pattern;
219
194
  label = `#${args.id2}`;
220
195
  }
@@ -244,12 +219,6 @@ export async function execute(name, args, ctx) {
244
219
  throw new Error(`Invalid action: ${action}. Must be one of: undo, redo, list, restore, compare`);
245
220
  }
246
221
  }
247
- // Deprecated aliases — forward to consolidated handler.
248
- case 'undo': return await doUndo(ctx, sid);
249
- case 'redo': return await doRedo(ctx, sid);
250
- case 'list_history': return doList(args, ctx, sid);
251
- case 'restore_history': return await doRestore(args, ctx, sid);
252
- case 'compare_patterns': return await doCompare(args, ctx, sid);
253
222
  default:
254
223
  throw new Error(`history module does not handle tool: ${name}`);
255
224
  }
@@ -1,11 +1,7 @@
1
1
  /**
2
2
  * playback domain — transport controls.
3
3
  *
4
- * Owns one consolidated tool (`playback(action)`) plus three deprecated
5
- * aliases (play, pause, stop) per #120 / #152.
6
- *
7
- * Aliases forward to `playback` and will be removed in a future release
8
- * per the deprecation policy.
4
+ * Owns one consolidated tool (`playback(action)`) with play/pause/stop.
9
5
  */
10
6
  const SESSION_ID_PROP = {
11
7
  session_id: {
@@ -18,7 +14,7 @@ export const tools = [
18
14
  name: 'playback',
19
15
  description: 'Control transport on the current session. ' +
20
16
  'action=play starts the editor pattern. ' +
21
- 'action=pause stops without resetting clock. ' +
17
+ 'action=pause halts the clock where it is, so play resumes mid-cycle. ' +
22
18
  'action=stop ends playback. ' +
23
19
  'Example: playback({ action: "play" }). ' +
24
20
  'For pattern editing use edit_pattern; for tempo use set_tempo.',
@@ -35,21 +31,6 @@ export const tools = [
35
31
  required: ['action'],
36
32
  },
37
33
  },
38
- {
39
- name: 'play',
40
- description: '[DEPRECATED — use playback({ action: "play" }) instead] Start playing pattern',
41
- inputSchema: { type: 'object', properties: { ...SESSION_ID_PROP } },
42
- },
43
- {
44
- name: 'pause',
45
- description: '[DEPRECATED — use playback({ action: "pause" }) instead] Pause playback',
46
- inputSchema: { type: 'object', properties: { ...SESSION_ID_PROP } },
47
- },
48
- {
49
- name: 'stop',
50
- description: '[DEPRECATED — use playback({ action: "stop" }) instead] Stop playback',
51
- inputSchema: { type: 'object', properties: { ...SESSION_ID_PROP } },
52
- },
53
34
  ];
54
35
  export const toolNames = new Set(tools.map(t => t.name));
55
36
  export async function execute(name, args, ctx) {
@@ -58,11 +39,15 @@ export async function execute(name, args, ctx) {
58
39
  return 'Browser not initialized. Run init first.';
59
40
  }
60
41
  const controller = ctx.getController(sid);
61
- const action = name === 'playback' ? args?.action : name;
42
+ if (name !== 'playback') {
43
+ throw new Error(`playback module does not handle tool: ${name}`);
44
+ }
45
+ const action = args?.action;
62
46
  switch (action) {
63
47
  case 'play':
64
48
  return await controller.play();
65
49
  case 'pause':
50
+ return await controller.pause();
66
51
  case 'stop':
67
52
  return await controller.stop();
68
53
  default:
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * session domain — multi-session lifecycle management.
3
3
  *
4
- * Owns one consolidated tool (`session(action)`) plus four deprecated
5
- * aliases (create_session, destroy_session, list_sessions, switch_session)
6
- * per #120 / #158. `init` stays separate — it's the global bootstrap,
7
- * not a session operation.
4
+ * Owns one consolidated tool (`session(action)`) with create/destroy/
5
+ * list/switch actions. `init` stays separate — it's the global
6
+ * bootstrap, not a session operation.
8
7
  */
8
+ import { categorizeError, empty, err, ok } from './types.js';
9
9
  import { InputValidator } from '../../utils/InputValidator.js';
10
10
  export const tools = [
11
11
  {
@@ -26,38 +26,6 @@ export const tools = [
26
26
  required: ['action'],
27
27
  },
28
28
  },
29
- {
30
- name: 'create_session',
31
- description: '[DEPRECATED — use session({ action: "create" }) instead] Create a new isolated Strudel browser session.',
32
- inputSchema: {
33
- type: 'object',
34
- properties: { session_id: { type: 'string', description: 'Unique identifier for the session' } },
35
- required: ['session_id'],
36
- },
37
- },
38
- {
39
- name: 'destroy_session',
40
- description: '[DEPRECATED — use session({ action: "destroy" }) instead] Close and destroy a Strudel session.',
41
- inputSchema: {
42
- type: 'object',
43
- properties: { session_id: { type: 'string', description: 'Session identifier to destroy' } },
44
- required: ['session_id'],
45
- },
46
- },
47
- {
48
- name: 'list_sessions',
49
- description: '[DEPRECATED — use session({ action: "list" }) instead] List all active Strudel sessions.',
50
- inputSchema: { type: 'object', properties: {} },
51
- },
52
- {
53
- name: 'switch_session',
54
- description: '[DEPRECATED — use session({ action: "switch" }) instead] Change the default session.',
55
- inputSchema: {
56
- type: 'object',
57
- properties: { session_id: { type: 'string', description: 'Session identifier to set as default' } },
58
- required: ['session_id'],
59
- },
60
- },
61
29
  ];
62
30
  export const toolNames = new Set(tools.map(t => t.name));
63
31
  async function doCreate(args, ctx) {
@@ -74,7 +42,14 @@ async function doCreate(args, ctx) {
74
42
  };
75
43
  }
76
44
  catch (error) {
77
- return { success: false, error: error instanceof Error ? error.message : String(error) };
45
+ // `err(...)` rather than `{ success: false }`: the dispatcher turns a
46
+ // failure-shaped object back into a PLAIN Error before categorising
47
+ // it, so a typed error thrown below this point loses its category on
48
+ // the way out and lands in `internal` — not retryable, and wrong
49
+ // about whose problem it is. Cross-model review caught that the
50
+ // typed errors were being destroyed at this seam (#382).
51
+ const message = error instanceof Error ? error.message : String(error);
52
+ return err(categorizeError(error), message);
78
53
  }
79
54
  }
80
55
  async function doDestroy(args, ctx) {
@@ -94,16 +69,33 @@ async function doDestroy(args, ctx) {
94
69
  };
95
70
  }
96
71
  catch (error) {
97
- return { success: false, error: error instanceof Error ? error.message : String(error) };
72
+ // `err(...)` rather than `{ success: false }`: the dispatcher turns a
73
+ // failure-shaped object back into a PLAIN Error before categorising
74
+ // it, so a typed error thrown below this point loses its category on
75
+ // the way out and lands in `internal` — not retryable, and wrong
76
+ // about whose problem it is. Cross-model review caught that the
77
+ // typed errors were being destroyed at this seam (#382).
78
+ const message = error instanceof Error ? error.message : String(error);
79
+ return err(categorizeError(error), message);
98
80
  }
99
81
  }
100
82
  function doList(ctx) {
101
83
  const sm = ctx.sessionManager;
102
84
  const info = sm.getSessionsInfo();
103
- return {
85
+ const wrap = info.length === 0 ? empty : ok;
86
+ return wrap({
104
87
  count: info.length,
105
88
  max_sessions: sm.getMaxSessions(),
106
- default_session: sm.getDefaultSessionId(),
89
+ // Only when it exists. After a destroy, SessionManager resets this
90
+ // to the literal 'default', and reporting that named a session no
91
+ // caller could switch to or find in the list below (#421).
92
+ //
93
+ // Checked against `info` rather than by looking the session up:
94
+ // `getSession` stamps `lastActivity`, and listing sessions is not
95
+ // using one.
96
+ default_session: info.some(s => s.id === sm.getDefaultSessionId())
97
+ ? sm.getDefaultSessionId()
98
+ : null,
107
99
  sessions: info.map(s => ({
108
100
  id: s.id,
109
101
  created: s.created.toISOString(),
@@ -111,7 +103,7 @@ function doList(ctx) {
111
103
  is_playing: s.isPlaying,
112
104
  is_default: s.id === sm.getDefaultSessionId(),
113
105
  })),
114
- };
106
+ });
115
107
  }
116
108
  function doSwitch(args, ctx) {
117
109
  const sm = ctx.sessionManager;
@@ -125,7 +117,14 @@ function doSwitch(args, ctx) {
125
117
  };
126
118
  }
127
119
  catch (error) {
128
- return { success: false, error: error instanceof Error ? error.message : String(error) };
120
+ // `err(...)` rather than `{ success: false }`: the dispatcher turns a
121
+ // failure-shaped object back into a PLAIN Error before categorising
122
+ // it, so a typed error thrown below this point loses its category on
123
+ // the way out and lands in `internal` — not retryable, and wrong
124
+ // about whose problem it is. Cross-model review caught that the
125
+ // typed errors were being destroyed at this seam (#382).
126
+ const message = error instanceof Error ? error.message : String(error);
127
+ return err(categorizeError(error), message);
129
128
  }
130
129
  }
131
130
  export async function execute(name, args, ctx) {
@@ -141,11 +140,6 @@ export async function execute(name, args, ctx) {
141
140
  throw new Error(`Invalid action: ${a}. Must be one of: create, destroy, list, switch`);
142
141
  }
143
142
  }
144
- // Deprecated aliases.
145
- case 'create_session': return await doCreate(args, ctx);
146
- case 'destroy_session': return await doDestroy(args, ctx);
147
- case 'list_sessions': return doList(ctx);
148
- case 'switch_session': return doSwitch(args, ctx);
149
143
  default:
150
144
  throw new Error(`session module does not handle tool: ${name}`);
151
145
  }
@@ -1,17 +1,15 @@
1
1
  /**
2
2
  * storage domain — pattern persistence.
3
3
  *
4
- * Owns one consolidated tool (`pattern_store`) plus three deprecated
5
- * aliases (`save`, `load`, `list`) per the #120 / #143 consolidation.
6
- *
7
- * The consolidation also resolves the `list` / `list_sessions` naming
8
- * collision from #110 — `pattern_store(action='list')` is unambiguous
9
- * versus `session(action='list')` after the #158 session consolidation.
10
- *
11
- * Aliases forward to `pattern_store` and will be removed in a future
12
- * release per the deprecation policy in #120.
4
+ * Owns one consolidated tool (`pattern_store`) with save/load/list actions.
13
5
  */
14
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
15
13
  const SESSION_ID_PROP = {
16
14
  session_id: {
17
15
  type: 'string',
@@ -37,6 +35,12 @@ export const tools = [
37
35
  },
38
36
  name: { type: 'string', description: 'Pattern name (required for save/load)' },
39
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
+ },
40
44
  tag: { type: 'string', description: 'Filter by tag (list only)' },
41
45
  ...SESSION_ID_PROP,
42
46
  },
@@ -44,38 +48,45 @@ export const tools = [
44
48
  },
45
49
  },
46
50
  {
47
- name: 'save',
48
- description: '[DEPRECATED — use pattern_store({ action: "save" }) instead] Save pattern with metadata',
49
- inputSchema: {
50
- type: 'object',
51
- properties: {
52
- name: { type: 'string', description: 'Pattern name' },
53
- tags: { type: 'array', items: { type: 'string' } },
54
- ...SESSION_ID_PROP,
55
- },
56
- required: ['name'],
57
- },
58
- },
59
- {
60
- name: 'load',
61
- description: '[DEPRECATED — use pattern_store({ action: "load" }) instead] Load saved pattern',
62
- inputSchema: {
63
- type: 'object',
64
- properties: {
65
- name: { type: 'string', description: 'Pattern name' },
66
- ...SESSION_ID_PROP,
67
- },
68
- required: ['name'],
69
- },
70
- },
71
- {
72
- name: 'list',
73
- description: '[DEPRECATED — use pattern_store({ action: "list" }) instead] List saved patterns',
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.',
74
60
  inputSchema: {
75
61
  type: 'object',
76
62
  properties: {
77
- tag: { type: 'string', description: 'Filter by tag' },
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
+ },
78
88
  },
89
+ required: ['source', 'data'],
79
90
  },
80
91
  },
81
92
  ];
@@ -83,27 +94,142 @@ export const toolNames = new Set(tools.map(t => t.name));
83
94
  async function doSave(args, ctx, sid) {
84
95
  InputValidator.validateStringLength(args.name, 'name', 255, false);
85
96
  const toSave = await ctx.getCurrentPatternSafe(sid);
86
- if (!toSave) {
87
- 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.');
88
108
  }
89
- 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
+ });
90
115
  return `Pattern saved as "${args.name}"`;
91
116
  }
92
117
  async function doLoad(args, ctx, sid) {
93
118
  InputValidator.validateStringLength(args.name, 'name', 255, false);
94
119
  const saved = await ctx.store.load(args.name);
95
120
  if (saved) {
96
- await ctx.writePatternSafe(saved.content, sid);
97
- return `Loaded pattern "${args.name}"`;
121
+ const written = await ctx.writePatternSafe(saved.content, sid);
122
+ return withStashNotice(`Loaded pattern "${args.name}"`, written);
98
123
  }
99
- 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.');
100
131
  }
101
132
  async function doList(args, ctx) {
102
133
  if (args?.tag) {
103
134
  InputValidator.validateStringLength(args.tag, 'tag', 100, false);
104
135
  }
105
- const patterns = await ctx.store.list(args?.tag);
106
- 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
+ }
107
233
  }
108
234
  export async function execute(name, args, ctx) {
109
235
  const sid = args?.session_id;
@@ -121,11 +247,8 @@ export async function execute(name, args, ctx) {
121
247
  // Unreachable due to enum check above, but TypeScript wants it.
122
248
  return undefined;
123
249
  }
124
- // Aliases — forward to consolidated handler. Kept for ≥1 release per
125
- // #120 migration rules; removed afterwards.
126
- case 'save': return await doSave(args, ctx, sid);
127
- case 'load': return await doLoad(args, ctx, sid);
128
- case 'list': return await doList(args, ctx);
250
+ case 'import_midi':
251
+ return await doImportMidi(args, ctx);
129
252
  default:
130
253
  throw new Error(`storage module does not handle tool: ${name}`);
131
254
  }