@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.
- package/README.md +212 -780
- package/dist/AudioAnalyzer.js +1294 -91
- package/dist/PatternStore.js +276 -23
- package/dist/StrudelController.js +388 -56
- package/dist/server/resources.js +27 -20
- package/dist/server/server.js +391 -83
- package/dist/server/tools/ai.js +129 -152
- package/dist/server/tools/analysis.js +170 -91
- package/dist/server/tools/capture.js +128 -55
- package/dist/server/tools/compose.js +28 -42
- package/dist/server/tools/diagnostics.js +23 -53
- package/dist/server/tools/editor.js +63 -72
- package/dist/server/tools/generate.js +45 -167
- package/dist/server/tools/history.js +35 -66
- package/dist/server/tools/playback.js +7 -22
- package/dist/server/tools/session.js +41 -47
- package/dist/server/tools/storage.js +173 -50
- package/dist/server/tools/transform.js +136 -199
- package/dist/server/tools/types.js +209 -14
- package/dist/services/AudioCaptureService.js +302 -33
- package/dist/services/AudioExportService.js +318 -0
- package/dist/services/BrowserOnlyFunctions.js +149 -0
- package/dist/services/EventDensityProbe.js +324 -0
- package/dist/services/GeminiService.js +221 -36
- package/dist/services/IsolatedEngineRunner.js +308 -0
- package/dist/services/IsolatedStrudelEngine.js +135 -0
- package/dist/services/LocalPatternEngine.js +16 -0
- package/dist/services/MIDIExportService.js +765 -71
- package/dist/services/MIDIImportService.js +474 -0
- package/dist/services/MusicTheory.js +85 -13
- package/dist/services/PatternGenerator.js +168 -85
- package/dist/services/PatternSandbox.js +359 -0
- package/dist/services/SessionManager.js +338 -65
- package/dist/services/StrudelEngine.js +218 -12
- package/dist/services/StrudelEngineHelpers.js +24 -8
- package/dist/services/StyleRegistry.js +204 -0
- package/dist/services/Transferable.js +114 -0
- package/dist/services/ai/AiTransport.js +56 -0
- package/dist/services/ai/AudioMeasurements.js +155 -0
- package/dist/services/ai/CliTransport.js +256 -0
- package/dist/services/engineChild.js +83 -0
- package/dist/services/engineChildPath.js +28 -0
- package/dist/utils/CategorisedError.js +59 -0
- package/dist/utils/ErrorRecovery.js +130 -110
- package/dist/utils/InputValidator.js +55 -7
- package/dist/utils/PatternValidator.js +136 -17
- package/dist/utils/SafePath.js +112 -0
- package/dist/utils/ServerConfig.js +248 -0
- package/dist/utils/TableLookup.js +41 -0
- package/dist/utils/Tempo.js +143 -0
- package/package.json +17 -5
- package/dist/services/__mocks__/StrudelEngine.js +0 -157
- 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
|
-
*
|
|
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)
|
|
11
|
-
* server pushes onto these arrays on
|
|
12
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)`)
|
|
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
|
|
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
|
-
|
|
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)`)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
+
const wrap = info.length === 0 ? empty : ok;
|
|
86
|
+
return wrap({
|
|
104
87
|
count: info.length,
|
|
105
88
|
max_sessions: sm.getMaxSessions(),
|
|
106
|
-
|
|
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
|
-
|
|
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`)
|
|
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: '
|
|
48
|
-
description: '
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
106
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
}
|