@williamzujkowski/live-coding-music-mcp 4.0.0 → 4.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +202 -705
- 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 +384 -76
- package/dist/server/tools/ai.js +124 -91
- package/dist/server/tools/analysis.js +168 -59
- package/dist/server/tools/capture.js +135 -24
- package/dist/server/tools/compose.js +26 -26
- package/dist/server/tools/diagnostics.js +20 -3
- package/dist/server/tools/editor.js +60 -6
- package/dist/server/tools/generate.js +38 -15
- package/dist/server/tools/history.js +30 -8
- package/dist/server/tools/playback.js +2 -1
- package/dist/server/tools/session.js +38 -6
- package/dist/server/tools/storage.js +179 -8
- package/dist/server/tools/transform.js +127 -43
- 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 +27 -16
- package/dist/services/__mocks__/StrudelEngine.js +0 -157
- package/dist/types/index.js +0 -1
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* `browser_window` (show or screenshot the Strudel window).
|
|
5
5
|
*/
|
|
6
6
|
import { InputValidator } from '../../utils/InputValidator.js';
|
|
7
|
+
import { defaultTempoFor, resolveLayers } from '../../services/StyleRegistry.js';
|
|
7
8
|
const SESSION_ID_PROP = {
|
|
8
9
|
session_id: {
|
|
9
10
|
type: 'string',
|
|
@@ -34,7 +35,7 @@ export const tools = [
|
|
|
34
35
|
inputSchema: {
|
|
35
36
|
type: 'object',
|
|
36
37
|
properties: {
|
|
37
|
-
style: { type: 'string', description: 'Genre: techno, house, dnb,
|
|
38
|
+
style: { type: 'string', description: 'Genre: techno, house, dnb, breakbeat, trap, jungle, ambient, experimental, intelligent_dnb, trip_hop, boom_bap. Anything else (jazz included) gets techno drums, and the response says so.' },
|
|
38
39
|
tempo: { type: 'number', description: 'BPM (default: genre-appropriate)' },
|
|
39
40
|
key: { type: 'string', description: 'Musical key (default: C)' },
|
|
40
41
|
auto_play: { type: 'boolean', description: 'Start playback immediately (default: true)' },
|
|
@@ -46,27 +47,6 @@ export const tools = [
|
|
|
46
47
|
},
|
|
47
48
|
];
|
|
48
49
|
export const toolNames = new Set(tools.map(t => t.name));
|
|
49
|
-
const TEMPO_BY_STYLE = {
|
|
50
|
-
techno: 130,
|
|
51
|
-
house: 125,
|
|
52
|
-
dnb: 174,
|
|
53
|
-
'drum and bass': 174,
|
|
54
|
-
ambient: 80,
|
|
55
|
-
trap: 140,
|
|
56
|
-
jungle: 160,
|
|
57
|
-
jazz: 110,
|
|
58
|
-
experimental: 120,
|
|
59
|
-
dubstep: 140,
|
|
60
|
-
trance: 138,
|
|
61
|
-
breakbeat: 130,
|
|
62
|
-
garage: 130,
|
|
63
|
-
electro: 128,
|
|
64
|
-
downtempo: 90,
|
|
65
|
-
idm: 115,
|
|
66
|
-
};
|
|
67
|
-
function defaultTempo(style) {
|
|
68
|
-
return TEMPO_BY_STYLE[style.toLowerCase()] ?? 120;
|
|
69
|
-
}
|
|
70
50
|
async function doShow(ctx, sid) {
|
|
71
51
|
if (!sid && !ctx.isInitialized()) {
|
|
72
52
|
return 'Browser not initialized. Run init first.';
|
|
@@ -98,13 +78,21 @@ export async function execute(name, args, ctx) {
|
|
|
98
78
|
InputValidator.validateBPM(args.tempo);
|
|
99
79
|
}
|
|
100
80
|
// Auto-init only the default session. Named sessions must be
|
|
101
|
-
// created via `
|
|
81
|
+
// created via `session({ action: "create" })` first — ctx.getController(sid)
|
|
102
82
|
// throws if missing, which the dispatcher renders as err('business').
|
|
103
83
|
if (!sid) {
|
|
104
84
|
await ctx.ensureInitialized();
|
|
105
85
|
}
|
|
106
86
|
const controller = ctx.getController(sid);
|
|
107
|
-
|
|
87
|
+
// compose assembles four independently-resolved layers, each
|
|
88
|
+
// falling back on its own. One `style` field cannot describe that
|
|
89
|
+
// — reporting only the drum resolution called
|
|
90
|
+
// compose({style:'jazz'}) "techno" while its bassline, Dorian
|
|
91
|
+
// scale and jazz chords were all genuinely jazz, and let
|
|
92
|
+
// `breakbeat` claim itself supported over a techno bassline
|
|
93
|
+
// (#294, correcting #279).
|
|
94
|
+
const resolution = resolveLayers(args.style);
|
|
95
|
+
const tempo = args.tempo || defaultTempoFor(args.style);
|
|
108
96
|
const key = args.key || 'C';
|
|
109
97
|
const pattern = ctx.generator.generateCompletePattern(args.style, key, tempo);
|
|
110
98
|
await controller.writePattern(pattern);
|
|
@@ -115,9 +103,21 @@ export async function execute(name, args, ctx) {
|
|
|
115
103
|
const response = {
|
|
116
104
|
success: true,
|
|
117
105
|
pattern: pattern.substring(0, 200) + (pattern.length > 200 ? '...' : ''),
|
|
118
|
-
metadata: {
|
|
106
|
+
metadata: {
|
|
107
|
+
style: args.style,
|
|
108
|
+
bpm: tempo,
|
|
109
|
+
key,
|
|
110
|
+
layers: resolution.layers,
|
|
111
|
+
substituted: resolution.substituted,
|
|
112
|
+
},
|
|
119
113
|
status: shouldPlay ? 'playing' : 'ready',
|
|
120
|
-
message:
|
|
114
|
+
message: resolution.substituted.length === 0
|
|
115
|
+
? `Created ${args.style} pattern in ${key}${shouldPlay ? ' - now playing' : ''}`
|
|
116
|
+
: `Created pattern in ${key}${shouldPlay ? ' - now playing' : ''}. ` +
|
|
117
|
+
`No ${resolution.substituted.join(' or ')} defined for style "${args.style}" — ` +
|
|
118
|
+
resolution.substituted
|
|
119
|
+
.map(l => `${l}: ${resolution.layers[l]}`)
|
|
120
|
+
.join(', ') + '.',
|
|
121
121
|
};
|
|
122
122
|
if (args.get_feedback) {
|
|
123
123
|
if (ctx.geminiService.isAvailable()) {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* touch the browser (`level='full'`) or just inspect server state
|
|
11
11
|
* (`perf`, `memory`).
|
|
12
12
|
*/
|
|
13
|
+
import { empty } from './types.js';
|
|
13
14
|
const SESSION_ID_PROP = {
|
|
14
15
|
session_id: {
|
|
15
16
|
type: 'string',
|
|
@@ -20,7 +21,7 @@ export const tools = [
|
|
|
20
21
|
{
|
|
21
22
|
name: 'diagnostics',
|
|
22
23
|
description: 'Inspect server and browser state. ' +
|
|
23
|
-
'level=status returns a quick state snapshot (cache read, <15ms SLA). ' +
|
|
24
|
+
'level=status returns a quick state snapshot (cache read, <15ms SLA), plus which config.json was read and any warnings from it. ' +
|
|
24
25
|
'level=full returns detailed browser diagnostics including caches, errors, and performance. ' +
|
|
25
26
|
'level=perf returns server-side timing metrics + top bottlenecks. ' +
|
|
26
27
|
'level=memory returns process memory usage. ' +
|
|
@@ -52,7 +53,23 @@ function getMemoryUsage(ctx) {
|
|
|
52
53
|
return memory ? JSON.stringify(memory, null, 2) : 'Memory usage not available';
|
|
53
54
|
}
|
|
54
55
|
function getStatus(ctx, sid) {
|
|
55
|
-
|
|
56
|
+
const status = ctx.getController(sid).getStatus();
|
|
57
|
+
const report = ctx.configReport;
|
|
58
|
+
if (!report)
|
|
59
|
+
return status;
|
|
60
|
+
// Config problems reached `logger.warn` and nothing else — a client
|
|
61
|
+
// log file the user may never open. A `strudel_url` typo, or an
|
|
62
|
+
// `audio_analysis` block that parsed to nothing, was invisible from
|
|
63
|
+
// inside the session. The path is reported whether or not a file was
|
|
64
|
+
// found, so a cwd surprise is visible rather than guessed at (#442).
|
|
65
|
+
return {
|
|
66
|
+
...(typeof status === 'object' && status !== null ? status : { status }),
|
|
67
|
+
config: {
|
|
68
|
+
path: report.path,
|
|
69
|
+
found: report.found,
|
|
70
|
+
...(report.warnings.length > 0 ? { warnings: report.warnings } : {}),
|
|
71
|
+
},
|
|
72
|
+
};
|
|
56
73
|
}
|
|
57
74
|
async function getFullDiagnostics(ctx, sid) {
|
|
58
75
|
if (!sid && !ctx.isInitialized()) {
|
|
@@ -68,7 +85,7 @@ function getErrors(ctx, sid) {
|
|
|
68
85
|
const errors = controller.getConsoleErrors();
|
|
69
86
|
const warnings = controller.getConsoleWarnings();
|
|
70
87
|
if (errors.length === 0 && warnings.length === 0) {
|
|
71
|
-
return 'No errors or warnings captured.';
|
|
88
|
+
return empty('No errors or warnings captured.');
|
|
72
89
|
}
|
|
73
90
|
let result = '';
|
|
74
91
|
if (errors.length > 0) {
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* write/append/insert/replace/clear modes, plus `get_pattern` (kept
|
|
6
6
|
* separate — hot read path).
|
|
7
7
|
*/
|
|
8
|
+
import { empty, ok, withStashField } from './types.js';
|
|
8
9
|
import { InputValidator } from '../../utils/InputValidator.js';
|
|
10
|
+
import { ValidationError } from '../../utils/CategorisedError.js';
|
|
9
11
|
const SESSION_ID_PROP = {
|
|
10
12
|
session_id: {
|
|
11
13
|
type: 'string',
|
|
@@ -19,7 +21,8 @@ export const tools = [
|
|
|
19
21
|
'mode=write replaces the editor contents (default; mirrors the old write tool exactly, including optional pattern validation and auto_play). ' +
|
|
20
22
|
'mode=append concatenates `code` after the current pattern with a newline. ' +
|
|
21
23
|
'mode=insert places `code` at the given line `position`. ' +
|
|
22
|
-
'mode=replace
|
|
24
|
+
'mode=replace substitutes `search` with `replace` — the first occurrence only, unless replace_all is true. ' +
|
|
25
|
+
'The response reports how many occurrences matched, were replaced, and remain. ' +
|
|
23
26
|
'mode=clear empties the editor. ' +
|
|
24
27
|
'Example: edit_pattern({ mode: "write", pattern: "s(\\"bd\\")", auto_play: true }). ' +
|
|
25
28
|
'For reading the editor without mutating it use get_pattern; for the on-disk pattern catalog use pattern_store.',
|
|
@@ -33,9 +36,14 @@ export const tools = [
|
|
|
33
36
|
},
|
|
34
37
|
pattern: { type: 'string', description: 'Pattern code (mode=write)' },
|
|
35
38
|
code: { type: 'string', description: 'Code to append/insert (mode=append/insert)' },
|
|
36
|
-
position: {
|
|
39
|
+
position: {
|
|
40
|
+
type: 'number',
|
|
41
|
+
description: 'mode=insert: 0-indexed line to insert BEFORE. 0 puts the code at the top; '
|
|
42
|
+
+ 'a value equal to the line count appends. Out of range is an error, not an append.',
|
|
43
|
+
},
|
|
37
44
|
search: { type: 'string', description: 'Text to replace (mode=replace)' },
|
|
38
45
|
replace: { type: 'string', description: 'Replacement text (mode=replace)' },
|
|
46
|
+
replace_all: { type: 'boolean', description: 'mode=replace: replace every occurrence instead of just the first (default false)' },
|
|
39
47
|
auto_play: { type: 'boolean', description: 'Start playback after write (mode=write only, default: false)' },
|
|
40
48
|
validate: { type: 'boolean', description: 'Validate pattern before write (mode=write only, default: true)' },
|
|
41
49
|
...SESSION_ID_PROP,
|
|
@@ -84,20 +92,66 @@ async function doAppend(args, ctx, sid) {
|
|
|
84
92
|
return await ctx.writePatternSafe(current + '\n' + args.code, sid);
|
|
85
93
|
}
|
|
86
94
|
async function doInsert(args, ctx, sid) {
|
|
87
|
-
InputValidator.validatePositiveInteger(args.position, 'position');
|
|
88
95
|
InputValidator.validateStringLength(args.code, 'code', 10000, true);
|
|
89
96
|
const lines = (await ctx.getCurrentPatternSafe(sid)).split('\n');
|
|
90
|
-
|
|
97
|
+
// 0-indexed, and 0 is a legal position.
|
|
98
|
+
//
|
|
99
|
+
// `validatePositiveInteger` rejected 0 while the `splice` below is
|
|
100
|
+
// 0-indexed, so inserting at the TOP of a pattern was impossible
|
|
101
|
+
// through this tool — and `StrudelController.insertAtLine`, which does
|
|
102
|
+
// the same job, documents the parameter as 0-indexed and permits 0.
|
|
103
|
+
//
|
|
104
|
+
// The other end was unguarded: `position: 9999` on a three-line
|
|
105
|
+
// pattern passed validation and `splice` silently appended, where
|
|
106
|
+
// `insertAtLine` throws. Now it matches that contract exactly
|
|
107
|
+
// (#442), so the two ways of doing this cannot disagree.
|
|
108
|
+
const position = args.position;
|
|
109
|
+
if (!Number.isInteger(position) || position < 0 || position > lines.length) {
|
|
110
|
+
throw new ValidationError(`Invalid position: ${String(position)}. Must be an integer between 0 and ${String(lines.length)} ` +
|
|
111
|
+
`(0 inserts before the first line, ${String(lines.length)} appends).`);
|
|
112
|
+
}
|
|
113
|
+
lines.splice(position, 0, args.code);
|
|
91
114
|
return await ctx.writePatternSafe(lines.join('\n'), sid);
|
|
92
115
|
}
|
|
93
116
|
async function doReplace(args, ctx, sid) {
|
|
94
117
|
InputValidator.validateStringLength(args.search, 'search', 1000, true);
|
|
95
118
|
InputValidator.validateStringLength(args.replace, 'replace', 10000, true);
|
|
119
|
+
const search = args.search ?? '';
|
|
120
|
+
if (search.length === 0) {
|
|
121
|
+
throw new Error('Invalid search: must be a non-empty string.');
|
|
122
|
+
}
|
|
96
123
|
const pattern = await ctx.getCurrentPatternSafe(sid);
|
|
124
|
+
// Literal string operations throughout — never a RegExp built from
|
|
125
|
+
// `search`. Interpolating caller text into a pattern is how #236
|
|
126
|
+
// happened: `(a+)+Z` against a crafted subject blocked the event loop
|
|
127
|
+
// for 25 seconds.
|
|
128
|
+
const matches = pattern.split(search).length - 1;
|
|
97
129
|
// Escape $ in replacement to prevent special sequence injection ($&, $1, $', etc.)
|
|
98
130
|
const safeReplacement = args.replace.replace(/\$/g, '$$$$');
|
|
99
|
-
const
|
|
100
|
-
|
|
131
|
+
const replaceAll = args.replace_all === true;
|
|
132
|
+
const updated = replaceAll
|
|
133
|
+
? pattern.replaceAll(search, safeReplacement)
|
|
134
|
+
: pattern.replace(search, safeReplacement);
|
|
135
|
+
const replaced = replaceAll ? matches : Math.min(matches, 1);
|
|
136
|
+
const remaining = matches - replaced;
|
|
137
|
+
const written = await ctx.writePatternSafe(updated, sid);
|
|
138
|
+
// Counts are reported under BOTH settings. Previously a caller had no
|
|
139
|
+
// way to learn that other occurrences survived — and these callers are
|
|
140
|
+
// LLM agents, which do not reliably re-read the pattern to check (#243).
|
|
141
|
+
// Zero matches is a valid-empty: the search ran and found nothing.
|
|
142
|
+
// Wrapped in a plain ok() it was indistinguishable from a replace
|
|
143
|
+
// that actually changed the pattern (#288).
|
|
144
|
+
const wrap = matches === 0 ? empty : ok;
|
|
145
|
+
return wrap(withStashField({
|
|
146
|
+
success: true,
|
|
147
|
+
message: matches === 0
|
|
148
|
+
? `No occurrences of ${JSON.stringify(search)} found`
|
|
149
|
+
: `Replaced ${String(replaced)} of ${String(matches)} occurrence(s)` +
|
|
150
|
+
(remaining > 0 ? `; ${String(remaining)} remain. Pass replace_all: true to replace them all.` : ''),
|
|
151
|
+
replaced,
|
|
152
|
+
remaining,
|
|
153
|
+
matches,
|
|
154
|
+
}, written));
|
|
101
155
|
}
|
|
102
156
|
async function doClear(ctx, sid) {
|
|
103
157
|
return await ctx.writePatternSafe('', sid);
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* Full-composition generation lives in `compose` (compose.ts), which
|
|
10
10
|
* orchestrates init + generate + play + optional AI feedback.
|
|
11
11
|
*/
|
|
12
|
+
import { withStashNotice } from './types.js';
|
|
12
13
|
import { InputValidator } from '../../utils/InputValidator.js';
|
|
14
|
+
import { BASS_STYLES, DRUM_STYLES, resolveBassStyle, resolveDrumStyle } from '../../services/StyleRegistry.js';
|
|
13
15
|
const SESSION_ID_PROP = {
|
|
14
16
|
session_id: {
|
|
15
17
|
type: 'string',
|
|
@@ -85,10 +87,17 @@ export const tools = [
|
|
|
85
87
|
},
|
|
86
88
|
];
|
|
87
89
|
export const toolNames = new Set(tools.map(t => t.name));
|
|
90
|
+
/**
|
|
91
|
+
* Appends generated material to the current pattern and writes it back.
|
|
92
|
+
*
|
|
93
|
+
* @returns Whatever `writePatternSafe` returned — callers must pass it
|
|
94
|
+
* to `withStashNotice` rather than discard it, because a write with
|
|
95
|
+
* no browser up is a stash, not a write (#285).
|
|
96
|
+
*/
|
|
88
97
|
async function appendOrSet(generated, ctx, sessionId) {
|
|
89
98
|
const current = await ctx.getCurrentPatternSafe(sessionId);
|
|
90
99
|
const combined = current ? current + '\n' + generated : generated;
|
|
91
|
-
await ctx.writePatternSafe(combined, sessionId);
|
|
100
|
+
return await ctx.writePatternSafe(combined, sessionId);
|
|
92
101
|
}
|
|
93
102
|
function doScale(args, ctx) {
|
|
94
103
|
InputValidator.validateRootNote(args.root);
|
|
@@ -101,38 +110,52 @@ async function doChordProgression(args, ctx, sid) {
|
|
|
101
110
|
InputValidator.validateChordStyle(args.style);
|
|
102
111
|
const progression = ctx.theory.generateChordProgression(args.key, args.style);
|
|
103
112
|
const chordPattern = ctx.generator.generateChords(progression);
|
|
104
|
-
await appendOrSet(chordPattern, ctx, sid);
|
|
105
|
-
return `Generated ${args.style} progression in ${args.key}: ${progression}
|
|
113
|
+
const written = await appendOrSet(chordPattern, ctx, sid);
|
|
114
|
+
return withStashNotice(`Generated ${args.style} progression in ${args.key}: ${progression}`, written);
|
|
106
115
|
}
|
|
107
116
|
async function doEuclidean(args, ctx, sid) {
|
|
108
117
|
InputValidator.validateEuclidean(args.hits, args.steps);
|
|
109
118
|
if (args.sound)
|
|
110
119
|
InputValidator.validateStringLength(args.sound, 'sound', 100, false);
|
|
111
120
|
const euclidean = ctx.generator.generateEuclideanPattern(args.hits, args.steps, args.sound || 'bd');
|
|
112
|
-
await appendOrSet(euclidean, ctx, sid);
|
|
113
|
-
return `Generated Euclidean rhythm (${args.hits}/${args.steps})
|
|
121
|
+
const written = await appendOrSet(euclidean, ctx, sid);
|
|
122
|
+
return withStashNotice(`Generated Euclidean rhythm (${args.hits}/${args.steps})`, written);
|
|
114
123
|
}
|
|
115
124
|
async function doPolyrhythm(args, ctx, sid) {
|
|
116
125
|
args.sounds.forEach((s) => InputValidator.validateStringLength(s, 'sound', 100, false));
|
|
117
126
|
args.patterns.forEach((p) => InputValidator.validatePositiveInteger(p, 'pattern'));
|
|
118
127
|
const poly = ctx.generator.generatePolyrhythm(args.sounds, args.patterns);
|
|
119
|
-
await appendOrSet(poly, ctx, sid);
|
|
120
|
-
return 'Generated polyrhythm';
|
|
128
|
+
const written = await appendOrSet(poly, ctx, sid);
|
|
129
|
+
return withStashNotice('Generated polyrhythm', written);
|
|
121
130
|
}
|
|
122
131
|
async function doDrums(args, ctx, sid) {
|
|
123
132
|
InputValidator.validateStringLength(args.style, 'style', 100, false);
|
|
124
133
|
if (args.complexity !== undefined)
|
|
125
134
|
InputValidator.validateNormalizedValue(args.complexity, 'complexity');
|
|
135
|
+
// Say which style actually produced the notes. An unknown genre falls
|
|
136
|
+
// back to techno, and echoing the request back made that invisible (#279).
|
|
137
|
+
const resolution = resolveDrumStyle(args.style);
|
|
126
138
|
const drums = ctx.generator.generateDrumPattern(args.style, args.complexity || 0.5);
|
|
127
|
-
await appendOrSet(drums, ctx, sid);
|
|
128
|
-
return
|
|
139
|
+
const written = await appendOrSet(drums, ctx, sid);
|
|
140
|
+
return withStashNotice(resolution.supported
|
|
141
|
+
? `Generated ${resolution.resolved} drums`
|
|
142
|
+
: `No drum pattern for style "${args.style}" — generated ${resolution.resolved} drums instead. ` +
|
|
143
|
+
`Styles with their own drums: ${DRUM_STYLES.join(', ')}.`, written);
|
|
129
144
|
}
|
|
130
145
|
async function doBassline(args, ctx, sid) {
|
|
131
146
|
InputValidator.validateRootNote(args.key);
|
|
132
147
|
InputValidator.validateStringLength(args.style, 'style', 100, false);
|
|
148
|
+
// role=bass echoed args.style back while the generator had silently
|
|
149
|
+
// fallen back to techno — #279 in the same file, eight lines below
|
|
150
|
+
// the sibling it fixed (#294).
|
|
151
|
+
const resolution = resolveBassStyle(args.style);
|
|
133
152
|
const bass = ctx.generator.generateBassline(args.key, args.style);
|
|
134
|
-
await appendOrSet(bass, ctx, sid);
|
|
135
|
-
return
|
|
153
|
+
const written = await appendOrSet(bass, ctx, sid);
|
|
154
|
+
return withStashNotice(resolution.supported
|
|
155
|
+
? `Generated ${resolution.resolved} bassline in ${args.key}`
|
|
156
|
+
: `No bassline for style "${args.style}" — generated ${resolution.resolved} ` +
|
|
157
|
+
`bassline in ${args.key} instead. ` +
|
|
158
|
+
`Styles with their own bassline: ${BASS_STYLES.join(', ')}.`, written);
|
|
136
159
|
}
|
|
137
160
|
async function doMelody(args, ctx, sid) {
|
|
138
161
|
InputValidator.validateRootNote(args.root);
|
|
@@ -141,16 +164,16 @@ async function doMelody(args, ctx, sid) {
|
|
|
141
164
|
InputValidator.validatePositiveInteger(args.length, 'length');
|
|
142
165
|
const scale = ctx.theory.generateScale(args.root, args.scale);
|
|
143
166
|
const melody = ctx.generator.generateMelody(scale, args.length || 8);
|
|
144
|
-
await appendOrSet(melody, ctx, sid);
|
|
145
|
-
return `Generated melody in ${args.root} ${args.scale}
|
|
167
|
+
const written = await appendOrSet(melody, ctx, sid);
|
|
168
|
+
return withStashNotice(`Generated melody in ${args.root} ${args.scale}`, written);
|
|
146
169
|
}
|
|
147
170
|
async function doFill(args, ctx, sid) {
|
|
148
171
|
InputValidator.validateStringLength(args.style, 'style', 100, false);
|
|
149
172
|
if (args.bars !== undefined)
|
|
150
173
|
InputValidator.validatePositiveInteger(args.bars, 'bars');
|
|
151
174
|
const fill = ctx.generator.generateFill(args.style, args.bars || 1);
|
|
152
|
-
await appendOrSet(fill, ctx, sid);
|
|
153
|
-
return `Generated ${args.bars || 1} bar fill
|
|
175
|
+
const written = await appendOrSet(fill, ctx, sid);
|
|
176
|
+
return withStashNotice(`Generated ${args.bars || 1} bar fill`, written);
|
|
154
177
|
}
|
|
155
178
|
export async function execute(name, args, ctx) {
|
|
156
179
|
const sid = args?.session_id;
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* server pushes onto these arrays on every edit_pattern call, so all
|
|
10
10
|
* mutations share the same underlying storage.
|
|
11
11
|
*/
|
|
12
|
+
import { empty, err } from './types.js';
|
|
12
13
|
const SESSION_ID_PROP = {
|
|
13
14
|
session_id: {
|
|
14
15
|
type: 'string',
|
|
@@ -96,8 +97,11 @@ async function doUndo(ctx, sid) {
|
|
|
96
97
|
const { undoStack, redoStack, maxHistory } = ctx.getHistory(sid);
|
|
97
98
|
if (!sid && !ctx.isInitialized())
|
|
98
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).
|
|
99
103
|
if (undoStack.length === 0)
|
|
100
|
-
return 'Nothing to undo';
|
|
104
|
+
return empty('Nothing to undo');
|
|
101
105
|
const controller = ctx.getController(sid);
|
|
102
106
|
const current = await controller.getCurrentPattern();
|
|
103
107
|
redoStack.push(current);
|
|
@@ -112,7 +116,7 @@ async function doRedo(ctx, sid) {
|
|
|
112
116
|
if (!sid && !ctx.isInitialized())
|
|
113
117
|
return 'Browser not initialized. Run init first.';
|
|
114
118
|
if (redoStack.length === 0)
|
|
115
|
-
return 'Nothing to redo';
|
|
119
|
+
return empty('Nothing to redo');
|
|
116
120
|
const controller = ctx.getController(sid);
|
|
117
121
|
const current = await controller.getCurrentPattern();
|
|
118
122
|
undoStack.push(current);
|
|
@@ -125,7 +129,14 @@ async function doRedo(ctx, sid) {
|
|
|
125
129
|
function doList(args, ctx, sid) {
|
|
126
130
|
const { historyStack } = ctx.getHistory(sid);
|
|
127
131
|
if (historyStack.length === 0) {
|
|
128
|
-
|
|
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
|
+
});
|
|
129
140
|
}
|
|
130
141
|
const limit = args?.limit || 10;
|
|
131
142
|
const recent = historyStack.slice(-limit).reverse();
|
|
@@ -148,7 +159,10 @@ async function doRestore(args, ctx, sid) {
|
|
|
148
159
|
const controller = ctx.getController(sid);
|
|
149
160
|
const entry = historyStack.find(e => e.id === args.id);
|
|
150
161
|
if (!entry) {
|
|
151
|
-
|
|
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.');
|
|
152
166
|
}
|
|
153
167
|
const current = await controller.getCurrentPattern();
|
|
154
168
|
undoStack.push(current);
|
|
@@ -160,14 +174,22 @@ async function doRestore(args, ctx, sid) {
|
|
|
160
174
|
async function doCompare(args, ctx, sid) {
|
|
161
175
|
const { historyStack } = ctx.getHistory(sid);
|
|
162
176
|
const first = historyStack.find(e => e.id === args.id1);
|
|
163
|
-
if (!first)
|
|
164
|
-
|
|
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
|
+
}
|
|
165
185
|
let second;
|
|
166
186
|
let label;
|
|
167
187
|
if (args.id2) {
|
|
168
188
|
const b = historyStack.find(e => e.id === args.id2);
|
|
169
|
-
if (!b)
|
|
170
|
-
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
|
+
}
|
|
171
193
|
second = b.pattern;
|
|
172
194
|
label = `#${args.id2}`;
|
|
173
195
|
}
|
|
@@ -14,7 +14,7 @@ export const tools = [
|
|
|
14
14
|
name: 'playback',
|
|
15
15
|
description: 'Control transport on the current session. ' +
|
|
16
16
|
'action=play starts the editor pattern. ' +
|
|
17
|
-
'action=pause
|
|
17
|
+
'action=pause halts the clock where it is, so play resumes mid-cycle. ' +
|
|
18
18
|
'action=stop ends playback. ' +
|
|
19
19
|
'Example: playback({ action: "play" }). ' +
|
|
20
20
|
'For pattern editing use edit_pattern; for tempo use set_tempo.',
|
|
@@ -47,6 +47,7 @@ export async function execute(name, args, ctx) {
|
|
|
47
47
|
case 'play':
|
|
48
48
|
return await controller.play();
|
|
49
49
|
case 'pause':
|
|
50
|
+
return await controller.pause();
|
|
50
51
|
case 'stop':
|
|
51
52
|
return await controller.stop();
|
|
52
53
|
default:
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* list/switch actions. `init` stays separate — it's the global
|
|
6
6
|
* bootstrap, not a session operation.
|
|
7
7
|
*/
|
|
8
|
+
import { categorizeError, empty, err, ok } from './types.js';
|
|
8
9
|
import { InputValidator } from '../../utils/InputValidator.js';
|
|
9
10
|
export const tools = [
|
|
10
11
|
{
|
|
@@ -41,7 +42,14 @@ async function doCreate(args, ctx) {
|
|
|
41
42
|
};
|
|
42
43
|
}
|
|
43
44
|
catch (error) {
|
|
44
|
-
|
|
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);
|
|
45
53
|
}
|
|
46
54
|
}
|
|
47
55
|
async function doDestroy(args, ctx) {
|
|
@@ -61,16 +69,33 @@ async function doDestroy(args, ctx) {
|
|
|
61
69
|
};
|
|
62
70
|
}
|
|
63
71
|
catch (error) {
|
|
64
|
-
|
|
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);
|
|
65
80
|
}
|
|
66
81
|
}
|
|
67
82
|
function doList(ctx) {
|
|
68
83
|
const sm = ctx.sessionManager;
|
|
69
84
|
const info = sm.getSessionsInfo();
|
|
70
|
-
|
|
85
|
+
const wrap = info.length === 0 ? empty : ok;
|
|
86
|
+
return wrap({
|
|
71
87
|
count: info.length,
|
|
72
88
|
max_sessions: sm.getMaxSessions(),
|
|
73
|
-
|
|
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,
|
|
74
99
|
sessions: info.map(s => ({
|
|
75
100
|
id: s.id,
|
|
76
101
|
created: s.created.toISOString(),
|
|
@@ -78,7 +103,7 @@ function doList(ctx) {
|
|
|
78
103
|
is_playing: s.isPlaying,
|
|
79
104
|
is_default: s.id === sm.getDefaultSessionId(),
|
|
80
105
|
})),
|
|
81
|
-
};
|
|
106
|
+
});
|
|
82
107
|
}
|
|
83
108
|
function doSwitch(args, ctx) {
|
|
84
109
|
const sm = ctx.sessionManager;
|
|
@@ -92,7 +117,14 @@ function doSwitch(args, ctx) {
|
|
|
92
117
|
};
|
|
93
118
|
}
|
|
94
119
|
catch (error) {
|
|
95
|
-
|
|
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);
|
|
96
128
|
}
|
|
97
129
|
}
|
|
98
130
|
export async function execute(name, args, ctx) {
|