@williamzujkowski/live-coding-music-mcp 4.0.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +17 -5
- package/dist/services/__mocks__/StrudelEngine.js +0 -157
- package/dist/types/index.js +0 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copies a value into something Node's IPC can carry (#307).
|
|
3
|
+
*
|
|
4
|
+
* Its own module because it has to be testable: `engineChild.ts` imports
|
|
5
|
+
* `@strudel/*`, which is ESM and will not load under this project's
|
|
6
|
+
* CommonJS Jest, so nothing in that file can be reached by a unit test.
|
|
7
|
+
* This has no dependencies at all.
|
|
8
|
+
*/
|
|
9
|
+
import { ValidationError } from '../utils/CategorisedError.js';
|
|
10
|
+
/** Bounds on what may cross the IPC boundary. Both are far above any real result. */
|
|
11
|
+
const MAX_TRANSFER_DEPTH = 32;
|
|
12
|
+
const MAX_TRANSFER_NODES = 2_000_000;
|
|
13
|
+
/**
|
|
14
|
+
* A result that cannot cross the IPC boundary: too many values, or
|
|
15
|
+
* nested too deep.
|
|
16
|
+
*
|
|
17
|
+
* Extends `ValidationError` because that is what it is — the caller
|
|
18
|
+
* asked for more than can be returned, and the fix is a narrower query.
|
|
19
|
+
* It reaches the parent as a reconstructed error, so the category also
|
|
20
|
+
* travels as an explicit field; see `engineChild`.
|
|
21
|
+
*/
|
|
22
|
+
export class TransferError extends ValidationError {
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Copies a result into something Node's IPC can carry.
|
|
26
|
+
*
|
|
27
|
+
* The obvious implementation is `JSON.parse(JSON.stringify(value))`, and
|
|
28
|
+
* it was, until cross-model review (codex) pointed out what that hands to
|
|
29
|
+
* a pattern author: **`JSON.stringify` calls `toJSON` on any value that
|
|
30
|
+
* defines one**. A hap value is pattern-controlled, so
|
|
31
|
+
* `pure({toJSON: () => { while (true); }})` runs an infinite loop here —
|
|
32
|
+
* outside the vm's timeout, which has already returned. The parent's
|
|
33
|
+
* deadline still fires and still kills the child, so the server survives;
|
|
34
|
+
* what does not survive is the child if the PARENT is killed first, since
|
|
35
|
+
* a blocked event loop can never process `disconnect`. That is an orphan
|
|
36
|
+
* process with no owner.
|
|
37
|
+
*
|
|
38
|
+
* So: walk it by hand. Never invoke `toJSON`, never invoke a getter on a
|
|
39
|
+
* prototype, drop what cannot cross, and bound both depth and node count
|
|
40
|
+
* so a pathological structure fails loudly instead of silently eating the
|
|
41
|
+
* heap the cap is there to protect.
|
|
42
|
+
*/
|
|
43
|
+
export function toTransferable(value, depth = 0, budget = { nodes: 0 }) {
|
|
44
|
+
if (++budget.nodes > MAX_TRANSFER_NODES) {
|
|
45
|
+
throw new TransferError(`Result exceeds the ${String(MAX_TRANSFER_NODES)}-value transfer limit. ` +
|
|
46
|
+
'Narrow the query or simplify the pattern.');
|
|
47
|
+
}
|
|
48
|
+
if (depth > MAX_TRANSFER_DEPTH) {
|
|
49
|
+
throw new TransferError(`Result nests deeper than ${String(MAX_TRANSFER_DEPTH)} levels and cannot be returned.`);
|
|
50
|
+
}
|
|
51
|
+
if (value === null || value === undefined)
|
|
52
|
+
return null;
|
|
53
|
+
switch (typeof value) {
|
|
54
|
+
case 'string':
|
|
55
|
+
case 'boolean':
|
|
56
|
+
return value;
|
|
57
|
+
case 'number':
|
|
58
|
+
// NaN and Infinity are not JSON; null is what JSON.stringify
|
|
59
|
+
// produced for them too, so this keeps the wire shape unchanged.
|
|
60
|
+
return Number.isFinite(value) ? value : null;
|
|
61
|
+
case 'bigint':
|
|
62
|
+
return value.toString();
|
|
63
|
+
// Functions and symbols have no representation on the other side, and
|
|
64
|
+
// a pattern that produced one was not going to be read anyway.
|
|
65
|
+
case 'function':
|
|
66
|
+
case 'symbol':
|
|
67
|
+
return null;
|
|
68
|
+
default:
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
if (Array.isArray(value)) {
|
|
72
|
+
// Index loop, not `.map`. `map` is looked up on the value, and an
|
|
73
|
+
// array's own `map` property is writable: `const a = []; a.map = ...`
|
|
74
|
+
// is an ordinary assignment, so calling it would run pattern-supplied
|
|
75
|
+
// code in the child — the same class of hole as `toJSON`, one method
|
|
76
|
+
// over. Descriptors for the same reason as below.
|
|
77
|
+
const out = [];
|
|
78
|
+
for (let i = 0; i < value.length; i++) {
|
|
79
|
+
// Charged before the descriptor is even looked at. A hole and a
|
|
80
|
+
// getter both used to `continue` without recursing, and the node
|
|
81
|
+
// budget is only charged inside `toTransferable` — so a sparse
|
|
82
|
+
// array cost nothing against it. `const a = []; a.length = 5e7`
|
|
83
|
+
// is cheap to build and walked all 50 million holes, pushing a
|
|
84
|
+
// null for each, past a 2-million-node bound whose whole purpose
|
|
85
|
+
// is to refuse exactly that (#408).
|
|
86
|
+
if (++budget.nodes > MAX_TRANSFER_NODES) {
|
|
87
|
+
throw new TransferError(`Result exceeds the ${String(MAX_TRANSFER_NODES)}-value transfer limit. ` +
|
|
88
|
+
'Narrow the query or simplify the pattern.');
|
|
89
|
+
}
|
|
90
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, i);
|
|
91
|
+
if (descriptor === undefined) {
|
|
92
|
+
out.push(null);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (descriptor.get !== undefined) {
|
|
96
|
+
out.push(null);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
out.push(toTransferable(descriptor.value, depth + 1, budget));
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
// Own enumerable properties only. Inherited accessors are pattern-
|
|
104
|
+
// controlled code too, and nothing legitimate arrives on a prototype.
|
|
105
|
+
const out = {};
|
|
106
|
+
for (const key of Object.keys(value)) {
|
|
107
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
108
|
+
// A getter is a function call. Skip it rather than run it.
|
|
109
|
+
if (descriptor === undefined || descriptor.get !== undefined)
|
|
110
|
+
continue;
|
|
111
|
+
out[key] = toTransferable(descriptor.value, depth + 1, budget);
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AiTransport — the one thing that varies between AI providers.
|
|
3
|
+
*
|
|
4
|
+
* `GeminiService` grew ~950 lines around a single provider, but almost
|
|
5
|
+
* none of it is provider-specific: prompt building, JSON parsing, caching,
|
|
6
|
+
* rate limiting and the response contracts are all provider-agnostic. The
|
|
7
|
+
* genuinely variable part is one operation — get a prompt to a model and
|
|
8
|
+
* get text back.
|
|
9
|
+
*
|
|
10
|
+
* So that is the whole seam:
|
|
11
|
+
*
|
|
12
|
+
* type AiTransport = (prompt: string) => Promise<string>
|
|
13
|
+
*
|
|
14
|
+
* Two implementations satisfy it: an HTTP call to the Gemini API, and a
|
|
15
|
+
* subprocess call to a locally authenticated CLI (`agy`, `claude`,
|
|
16
|
+
* `codex`). Everything above the seam stays exactly where it is, which is
|
|
17
|
+
* also why the ~80 existing tests keep working.
|
|
18
|
+
*
|
|
19
|
+
* ## Why there is no audio attachment parameter
|
|
20
|
+
*
|
|
21
|
+
* There was going to be one. It turned out none of the installed CLIs can
|
|
22
|
+
* actually hear audio — verified by asking each directly:
|
|
23
|
+
*
|
|
24
|
+
* agy -> "I CANNOT PERCEIVE AUDIO DIRECTLY"
|
|
25
|
+
* codex -> "CANNOT DECODE AUDIO"
|
|
26
|
+
* claude -> "CANNOT DECODE AUDIO"
|
|
27
|
+
*
|
|
28
|
+
* `agy` is the dangerous one: as an agentic CLI it will sometimes write
|
|
29
|
+
* and run DSP code, producing exactly correct numbers, and sometimes just
|
|
30
|
+
* describe plausible-sounding music it never analysed. A differential
|
|
31
|
+
* probe caught it reporting a clipped file as clean and a clean file as
|
|
32
|
+
* clipped. Right often enough to trust, wrong unpredictably, is the worst
|
|
33
|
+
* property an automated feedback loop can have.
|
|
34
|
+
*
|
|
35
|
+
* The answer is to not ask a model to hear. We already measure the audio
|
|
36
|
+
* locally (`AudioAnalyzer` for FFT/tempo/key, `AudioExportService` for
|
|
37
|
+
* peak/RMS) and send the *numbers* as text. That is deterministic, and it
|
|
38
|
+
* works with every provider rather than one — so the transport only ever
|
|
39
|
+
* needs to carry text.
|
|
40
|
+
*
|
|
41
|
+
* @module services/ai/AiTransport
|
|
42
|
+
*/
|
|
43
|
+
/** Raised when a transport cannot authenticate. */
|
|
44
|
+
export class AiAuthError extends Error {
|
|
45
|
+
constructor(message) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = 'AiAuthError';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Raised when a transport is rate limited. */
|
|
51
|
+
export class AiRateLimitError extends Error {
|
|
52
|
+
constructor(message) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = 'AiRateLimitError';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AudioMeasurements — describing audio to a model that cannot hear it.
|
|
3
|
+
*
|
|
4
|
+
* The obvious design was to hand a WAV to a multimodal model. That does
|
|
5
|
+
* not work here: none of the installed CLIs can decode audio. Asked
|
|
6
|
+
* directly, they say so —
|
|
7
|
+
*
|
|
8
|
+
* agy -> "I CANNOT PERCEIVE AUDIO DIRECTLY"
|
|
9
|
+
* codex -> "CANNOT DECODE AUDIO"
|
|
10
|
+
* claude -> "CANNOT DECODE AUDIO"
|
|
11
|
+
*
|
|
12
|
+
* `agy` is the dangerous one. Being agentic, it sometimes writes and runs
|
|
13
|
+
* DSP code and returns exactly correct figures, and sometimes just
|
|
14
|
+
* describes plausible music it never analysed. A differential probe
|
|
15
|
+
* caught it calling a clipped file clean and a clean file clipped. Right
|
|
16
|
+
* often enough to trust, wrong unpredictably, is the worst possible
|
|
17
|
+
* property for an automated feedback loop.
|
|
18
|
+
*
|
|
19
|
+
* So we do not ask a model to listen. We measure the audio ourselves —
|
|
20
|
+
* `AudioExportService` already computes peak and RMS while decoding, and
|
|
21
|
+
* `AudioAnalyzer` computes FFT bands, spectral centroid, tempo and key —
|
|
22
|
+
* and send the numbers as text.
|
|
23
|
+
*
|
|
24
|
+
* That inverts the constraint into an advantage. Measurement is
|
|
25
|
+
* deterministic and reproducible, the model does the part it is actually
|
|
26
|
+
* good at (interpretation and musical judgement), and it works with every
|
|
27
|
+
* provider rather than one.
|
|
28
|
+
*
|
|
29
|
+
* @module services/ai/AudioMeasurements
|
|
30
|
+
*/
|
|
31
|
+
/** Converts a linear amplitude to dBFS, the unit mixing engineers use. */
|
|
32
|
+
/**
|
|
33
|
+
* Amplitude at or below which a capture is treated as silent.
|
|
34
|
+
*
|
|
35
|
+
* Not exactly zero: dithering, a DC offset, or a single stray sample
|
|
36
|
+
* can leave a capture technically non-zero while containing no audio.
|
|
37
|
+
*/
|
|
38
|
+
export const SILENCE_THRESHOLD = 1e-4;
|
|
39
|
+
export function toDbfs(amplitude) {
|
|
40
|
+
if (amplitude <= 0)
|
|
41
|
+
return -Infinity;
|
|
42
|
+
return 20 * Math.log10(amplitude);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Renders measurements as text a model can reason about.
|
|
46
|
+
*
|
|
47
|
+
* Deliberately states units and interpretation rather than dumping raw
|
|
48
|
+
* numbers: "peak 1.12 (CLIPPING)" gets a useful answer where "peak: 1.12"
|
|
49
|
+
* invites the model to guess what scale that is on.
|
|
50
|
+
*
|
|
51
|
+
* @param m - Locally computed measurements
|
|
52
|
+
* @returns Human- and model-readable measurement block
|
|
53
|
+
*/
|
|
54
|
+
export function describeMeasurements(m) {
|
|
55
|
+
const lines = [];
|
|
56
|
+
lines.push(`- Duration: ${String(Math.round(m.durationMs))} ms`);
|
|
57
|
+
if (m.sampleRate !== undefined) {
|
|
58
|
+
lines.push(`- Format: ${String(m.sampleRate)} Hz, ${String(m.channels ?? 2)} channel(s)`);
|
|
59
|
+
}
|
|
60
|
+
// Silence first, and loudly.
|
|
61
|
+
//
|
|
62
|
+
// A silent capture was described as "Infinity dB of headroom", which
|
|
63
|
+
// is arithmetically true and useless: toDbfs(0) is -Infinity, and the
|
|
64
|
+
// verdict ladder had a branch for clipping and one for low headroom
|
|
65
|
+
// but none for nothing-at-all. buildMeasurementPrompt then wraps this
|
|
66
|
+
// in "these measurements are accurate — treat them as ground truth"
|
|
67
|
+
// and asks the model for mood, style and energy. Silence is THE
|
|
68
|
+
// documented failure mode of this project's headless capture, and the
|
|
69
|
+
// model was given no signal it had happened (#343).
|
|
70
|
+
const isSilent = m.peak !== undefined && m.peak <= SILENCE_THRESHOLD
|
|
71
|
+
&& (m.rms === undefined || m.rms <= SILENCE_THRESHOLD);
|
|
72
|
+
if (isSilent) {
|
|
73
|
+
lines.push('- SILENT: nothing was recorded. Peak and RMS are both at or below ' +
|
|
74
|
+
`${String(SILENCE_THRESHOLD)}. Do not infer mood, style or energy from this — ` +
|
|
75
|
+
'there is no audio to describe. The capture failed, or the pattern was not playing.');
|
|
76
|
+
}
|
|
77
|
+
if (m.peak !== undefined) {
|
|
78
|
+
const db = toDbfs(m.peak);
|
|
79
|
+
const verdict = m.peak > 1
|
|
80
|
+
? 'CLIPPING — above full scale, so the mix is distorting'
|
|
81
|
+
: m.peak > 0.95
|
|
82
|
+
? 'very close to full scale, little headroom'
|
|
83
|
+
: m.peak <= SILENCE_THRESHOLD
|
|
84
|
+
? 'silence — no signal at all'
|
|
85
|
+
: `${(-db).toFixed(1)} dB of headroom`;
|
|
86
|
+
lines.push(`- Peak: ${m.peak.toFixed(4)} (${db.toFixed(2)} dBFS) — ${verdict}`);
|
|
87
|
+
}
|
|
88
|
+
if (m.rms !== undefined) {
|
|
89
|
+
lines.push(`- RMS: ${m.rms.toFixed(4)} (${toDbfs(m.rms).toFixed(2)} dBFS) — perceived loudness`);
|
|
90
|
+
}
|
|
91
|
+
if (m.peak !== undefined && m.rms !== undefined && m.rms > 0) {
|
|
92
|
+
const crest = toDbfs(m.peak / m.rms);
|
|
93
|
+
lines.push(`- Crest factor: ${crest.toFixed(1)} dB — ` +
|
|
94
|
+
(crest < 6 ? 'heavily compressed / dense' : crest > 15 ? 'very dynamic / sparse' : 'moderate dynamics'));
|
|
95
|
+
}
|
|
96
|
+
if (m.tempo !== undefined) {
|
|
97
|
+
lines.push(`- Detected tempo: ${String(Math.round(m.tempo.bpm))} BPM ` +
|
|
98
|
+
`(confidence ${m.tempo.confidence.toFixed(2)})`);
|
|
99
|
+
}
|
|
100
|
+
if (m.key !== undefined) {
|
|
101
|
+
lines.push(`- Detected key: ${m.key.key} ${m.key.scale} ` +
|
|
102
|
+
`(confidence ${m.key.confidence.toFixed(2)})`);
|
|
103
|
+
}
|
|
104
|
+
if (m.spectrum !== undefined) {
|
|
105
|
+
const s = m.spectrum;
|
|
106
|
+
lines.push(`- Spectrum (0-255): bass ${String(Math.round(s.bass))}, ` +
|
|
107
|
+
`low-mid ${String(Math.round(s.lowMid))}, mid ${String(Math.round(s.mid))}, ` +
|
|
108
|
+
`high-mid ${String(Math.round(s.highMid))}, treble ${String(Math.round(s.treble))}`);
|
|
109
|
+
lines.push(`- Spectral centroid: ${String(Math.round(s.centroid))} Hz (${s.brightness})`);
|
|
110
|
+
}
|
|
111
|
+
if (m.rhythm !== undefined) {
|
|
112
|
+
lines.push(`- Rhythm: complexity ${m.rhythm.complexity.toFixed(2)}, ` +
|
|
113
|
+
`density ${m.rhythm.density.toFixed(2)}, syncopation ${m.rhythm.syncopation.toFixed(2)}`);
|
|
114
|
+
}
|
|
115
|
+
return lines.join('\n');
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Builds the audio-feedback prompt from measurements plus the pattern.
|
|
119
|
+
*
|
|
120
|
+
* The pattern source matters as much as the numbers: it lets the model
|
|
121
|
+
* connect a measurement to the line that caused it, which is the
|
|
122
|
+
* difference between "the mix is clipping" and "these three layers sum
|
|
123
|
+
* above 1.0, lower them".
|
|
124
|
+
*
|
|
125
|
+
* @param m - Locally computed measurements
|
|
126
|
+
* @param pattern - The Strudel source that produced the audio
|
|
127
|
+
* @param context - Optional intent (style/bpm/key the user was aiming for)
|
|
128
|
+
* @returns Prompt text for any model
|
|
129
|
+
*/
|
|
130
|
+
export function buildMeasurementPrompt(m, pattern, context) {
|
|
131
|
+
const parts = [
|
|
132
|
+
'You are analysing a short recording of a live-coded music performance made with Strudel.',
|
|
133
|
+
'',
|
|
134
|
+
'These measurements were computed directly from the audio signal. They are',
|
|
135
|
+
'accurate — treat them as ground truth rather than re-estimating them.',
|
|
136
|
+
'',
|
|
137
|
+
describeMeasurements(m),
|
|
138
|
+
];
|
|
139
|
+
if (pattern !== undefined && pattern.trim().length > 0) {
|
|
140
|
+
parts.push('', 'The Strudel pattern that produced it:', '```javascript', pattern.trim(), '```');
|
|
141
|
+
}
|
|
142
|
+
if (context !== undefined) {
|
|
143
|
+
const intent = [];
|
|
144
|
+
if (context.style !== undefined)
|
|
145
|
+
intent.push(`intended style: ${context.style}`);
|
|
146
|
+
if (context.bpm !== undefined)
|
|
147
|
+
intent.push(`intended BPM: ${String(context.bpm)}`);
|
|
148
|
+
if (context.key !== undefined)
|
|
149
|
+
intent.push(`intended key: ${context.key}`);
|
|
150
|
+
if (intent.length > 0)
|
|
151
|
+
parts.push('', `Intent — ${intent.join(', ')}.`);
|
|
152
|
+
}
|
|
153
|
+
parts.push('', 'Respond with ONLY this JSON object and no other text:', '{', ' "mood": "<one word, e.g. energetic, hypnotic, melancholic>",', ' "style": "<musical genre you infer>",', ' "energy": "<low|medium|high>",', ' "suggestions": ["<specific, actionable change>", "<another>", "<another>"],', ' "confidence": <0.0-1.0>', '}', '', 'Ground every suggestion in a measurement or a line of the pattern. If the', 'audio is clipping, say so first and name the layers responsible. Do not', 'claim to have heard timbral detail the measurements do not support.');
|
|
154
|
+
return parts.join('\n');
|
|
155
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CliTransport — reaches a model through a locally authenticated CLI.
|
|
3
|
+
*
|
|
4
|
+
* Fixes the case where a machine has working AI credentials that
|
|
5
|
+
* `GeminiService` cannot see (#252). Its auth ladder only understands
|
|
6
|
+
* `GEMINI_API_KEY`, `~/.gemini/settings.json`, and gcloud ADC — but the
|
|
7
|
+
* Antigravity CLI (`agy`) keeps credentials elsewhere entirely, so
|
|
8
|
+
* `ai_assist` reported "Gemini API not configured" while Gemini worked
|
|
9
|
+
* fine two directories away.
|
|
10
|
+
*
|
|
11
|
+
* Each CLI is invoked in print/non-interactive mode. The flags are not
|
|
12
|
+
* guessable and were established by probing the installed binaries:
|
|
13
|
+
*
|
|
14
|
+
* agy --print='PROMPT' (must be the `=` form; a bare
|
|
15
|
+
* positional gets eaten by --add-dir)
|
|
16
|
+
* claude -p 'PROMPT' (fastest, ~3s)
|
|
17
|
+
* codex exec --sandbox read-only --skip-git-repo-check 'PROMPT'
|
|
18
|
+
* (reads stdin when given no prompt
|
|
19
|
+
* argument, so stdin must be closed
|
|
20
|
+
* or it hangs forever)
|
|
21
|
+
*
|
|
22
|
+
* @module services/ai/CliTransport
|
|
23
|
+
* @nist si-10 "Information input validation"
|
|
24
|
+
*/
|
|
25
|
+
import { spawn } from 'node:child_process';
|
|
26
|
+
import { accessSync, constants, statSync } from 'node:fs';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { AiAuthError } from './AiTransport.js';
|
|
29
|
+
/** Default budget for a CLI call. They are slower than an HTTP request. */
|
|
30
|
+
export const CLI_TIMEOUT_MS = 120_000;
|
|
31
|
+
/** Cap on captured output, so a runaway CLI cannot exhaust memory. */
|
|
32
|
+
export const CLI_MAX_BUFFER = 8 * 1024 * 1024;
|
|
33
|
+
/** Time allowed for buffered output to drain after the child exits. */
|
|
34
|
+
export const DRAIN_GRACE_MS = 50;
|
|
35
|
+
/**
|
|
36
|
+
* Environment handed to the CLI.
|
|
37
|
+
*
|
|
38
|
+
* These are agentic CLIs with filesystem and tool access, and the prompt
|
|
39
|
+
* carries pattern text that may have come from a stored file or an
|
|
40
|
+
* imported MIDI. Passing the full parent environment would hand any
|
|
41
|
+
* provider credential in it to a different vendor's agent, so the child
|
|
42
|
+
* gets only what it needs to find its own config.
|
|
43
|
+
*/
|
|
44
|
+
function childEnv() {
|
|
45
|
+
const keep = ['PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'LC_ALL', 'TERM', 'TMPDIR', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME'];
|
|
46
|
+
const env = {};
|
|
47
|
+
for (const key of keep) {
|
|
48
|
+
const value = process.env[key];
|
|
49
|
+
if (value !== undefined)
|
|
50
|
+
env[key] = value;
|
|
51
|
+
}
|
|
52
|
+
return env;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The CLIs this server knows how to drive, in preference order.
|
|
56
|
+
*
|
|
57
|
+
* Order is by measured latency and output cleanliness: claude ~3s with
|
|
58
|
+
* the tidiest output, codex ~5s, agy ~2.4s bare but slower with files.
|
|
59
|
+
* `gemini` is deliberately absent — its free tier was retired and it
|
|
60
|
+
* fails with `IneligibleTierError` before reaching a model, so listing it
|
|
61
|
+
* would only produce confusing failures.
|
|
62
|
+
*/
|
|
63
|
+
export const KNOWN_CLIS = [
|
|
64
|
+
{
|
|
65
|
+
bin: 'claude',
|
|
66
|
+
label: 'Claude CLI',
|
|
67
|
+
// The prompt must precede any variadic flag: --disallowedTools and
|
|
68
|
+
// friends will swallow a trailing positional and tokenize it into
|
|
69
|
+
// bogus deny rules.
|
|
70
|
+
args: prompt => ['-p', prompt],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
bin: 'agy',
|
|
74
|
+
label: 'Antigravity CLI',
|
|
75
|
+
// `--print=` rather than `--print `: the space form lets a following
|
|
76
|
+
// flag be taken as the prompt.
|
|
77
|
+
args: prompt => [`--print=${prompt}`],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
bin: 'codex',
|
|
81
|
+
label: 'Codex CLI',
|
|
82
|
+
args: prompt => ['exec', '--sandbox', 'read-only', '--skip-git-repo-check', prompt],
|
|
83
|
+
needsClosedStdin: true,
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
/** Resolves a binary on PATH by scanning it — no shell, no subprocess. */
|
|
87
|
+
function isOnPath(bin) {
|
|
88
|
+
for (const dir of (process.env.PATH ?? '').split(':')) {
|
|
89
|
+
if (dir === '')
|
|
90
|
+
continue;
|
|
91
|
+
try {
|
|
92
|
+
const candidate = join(dir, bin);
|
|
93
|
+
accessSync(candidate, constants.X_OK);
|
|
94
|
+
// Directories are mode 755, so X_OK alone passes for them. Without
|
|
95
|
+
// this check a directory named `claude` on PATH makes the gate
|
|
96
|
+
// promise AI is configured, and spawn then fails.
|
|
97
|
+
if (statSync(candidate).isFile())
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// not here; keep looking
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Runs a CLI with stdin closed and its output captured.
|
|
108
|
+
*
|
|
109
|
+
* `spawn` rather than `execFile` because execFile ignores the `stdio`
|
|
110
|
+
* option: codex reads stdin when it gets no prompt argument, so without
|
|
111
|
+
* an explicitly closed stdin it blocks until the timeout kills it —
|
|
112
|
+
* measured, it hung for the full 90s budget.
|
|
113
|
+
*/
|
|
114
|
+
function runCli(bin, args, timeoutMs) {
|
|
115
|
+
return new Promise(resolve => {
|
|
116
|
+
// 'ignore' on stdin is the whole point; never a shell, so prompt text
|
|
117
|
+
// cannot be reinterpreted as shell syntax.
|
|
118
|
+
// detached: the CLI is agentic and spawns its own tool subprocesses.
|
|
119
|
+
// Killing only the direct child orphans those, and an orphan holding
|
|
120
|
+
// the inherited stdout fd keeps 'close' from ever firing — measured,
|
|
121
|
+
// a child SIGKILLed at 500ms did not emit 'close' until 30,002ms.
|
|
122
|
+
// Its own process group lets us kill the whole tree.
|
|
123
|
+
const child = spawn(bin, args, {
|
|
124
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
125
|
+
shell: false,
|
|
126
|
+
detached: true,
|
|
127
|
+
env: childEnv(),
|
|
128
|
+
});
|
|
129
|
+
let stdout = '';
|
|
130
|
+
let stderr = '';
|
|
131
|
+
let timedOut = false;
|
|
132
|
+
let bytes = 0;
|
|
133
|
+
let settled = false;
|
|
134
|
+
// Decode as a stream: a multi-byte character split across a chunk
|
|
135
|
+
// boundary becomes U+FFFD if each chunk is toString()'d alone, and
|
|
136
|
+
// model output is full of curly quotes and accents.
|
|
137
|
+
child.stdout.setEncoding('utf8');
|
|
138
|
+
child.stderr.setEncoding('utf8');
|
|
139
|
+
const killTree = (signal) => {
|
|
140
|
+
try {
|
|
141
|
+
if (child.pid !== undefined)
|
|
142
|
+
process.kill(-child.pid, signal);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// group already gone
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
child.kill(signal);
|
|
149
|
+
}
|
|
150
|
+
catch { /* already dead */ }
|
|
151
|
+
};
|
|
152
|
+
const timer = setTimeout(() => {
|
|
153
|
+
timedOut = true;
|
|
154
|
+
killTree('SIGKILL');
|
|
155
|
+
}, timeoutMs);
|
|
156
|
+
let overflowed = false;
|
|
157
|
+
const capture = (chunk, onto) => {
|
|
158
|
+
bytes += chunk.length;
|
|
159
|
+
if (bytes > CLI_MAX_BUFFER) {
|
|
160
|
+
// Say why, rather than surfacing a bare `exited null` with empty
|
|
161
|
+
// stderr that reads like an external kill.
|
|
162
|
+
overflowed = true;
|
|
163
|
+
killTree('SIGKILL');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (onto === 'out')
|
|
167
|
+
stdout += chunk;
|
|
168
|
+
else
|
|
169
|
+
stderr += chunk;
|
|
170
|
+
};
|
|
171
|
+
const settle = (code, errMessage) => {
|
|
172
|
+
if (settled)
|
|
173
|
+
return;
|
|
174
|
+
settled = true;
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
// Release the pipes.
|
|
177
|
+
//
|
|
178
|
+
// Settling on 'exit' rather than 'close' is deliberate (see below),
|
|
179
|
+
// but it means the child's stdout and stderr can still be open when
|
|
180
|
+
// this resolves — and an open pipe is an open handle. Under Jest
|
|
181
|
+
// that surfaces as "a worker process has failed to exit gracefully"
|
|
182
|
+
// on any parallel run large enough to reuse a worker: measured, 18
|
|
183
|
+
// suites plus this one warns where 18 plus any other suite does not
|
|
184
|
+
// (#362). Nothing needs the streams after this point; everything
|
|
185
|
+
// they carried is already in `stdout`/`stderr`.
|
|
186
|
+
child.stdout.destroy();
|
|
187
|
+
child.stderr.destroy();
|
|
188
|
+
resolve({
|
|
189
|
+
stdout,
|
|
190
|
+
stderr: overflowed
|
|
191
|
+
? `output exceeded ${String(CLI_MAX_BUFFER)} bytes and was terminated`
|
|
192
|
+
: errMessage ?? stderr,
|
|
193
|
+
code,
|
|
194
|
+
timedOut,
|
|
195
|
+
});
|
|
196
|
+
};
|
|
197
|
+
child.stdout.on('data', (c) => { capture(c, 'out'); });
|
|
198
|
+
child.stderr.on('data', (c) => { capture(c, 'err'); });
|
|
199
|
+
child.on('error', (err) => { settle(null, err.message); });
|
|
200
|
+
// Settle on 'exit', not 'close'. 'close' waits for every inherited
|
|
201
|
+
// stdio pipe to close, which an orphaned grandchild can hold open
|
|
202
|
+
// indefinitely — that is the hang above. A short grace lets buffered
|
|
203
|
+
// output drain first, so nothing is truncated in the normal case.
|
|
204
|
+
child.on('exit', (code) => {
|
|
205
|
+
setTimeout(() => { settle(code); }, DRAIN_GRACE_MS);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Builds a transport entry for one CLI.
|
|
211
|
+
*
|
|
212
|
+
* The prompt is passed as an argument-vector element, never interpolated
|
|
213
|
+
* into a shell string, so prompt content cannot become shell syntax.
|
|
214
|
+
*
|
|
215
|
+
* @param spec - How to drive the CLI
|
|
216
|
+
* @param timeoutMs - Budget for a single invocation
|
|
217
|
+
* @returns A transport entry usable by the AI service
|
|
218
|
+
* @nist si-10 "Information input validation"
|
|
219
|
+
*/
|
|
220
|
+
export function createCliTransport(spec, timeoutMs = CLI_TIMEOUT_MS) {
|
|
221
|
+
return {
|
|
222
|
+
id: `cli:${spec.bin}`,
|
|
223
|
+
label: spec.label,
|
|
224
|
+
isAvailable: () => Promise.resolve(isOnPath(spec.bin)),
|
|
225
|
+
send: async (prompt) => {
|
|
226
|
+
const { stdout, stderr, code, timedOut } = await runCli(spec.bin, spec.args(prompt), timeoutMs);
|
|
227
|
+
if (timedOut) {
|
|
228
|
+
throw new Error(`${spec.label} timed out after ${String(timeoutMs)}ms.`);
|
|
229
|
+
}
|
|
230
|
+
const detail = stderr.trim().slice(0, 400);
|
|
231
|
+
if (code !== 0) {
|
|
232
|
+
if (/ENOENT|not found/i.test(detail)) {
|
|
233
|
+
throw new AiAuthError(`${spec.label} (${spec.bin}) is not installed.`);
|
|
234
|
+
}
|
|
235
|
+
if (/auth|login|credential|unauthor|tier|eligib/i.test(detail)) {
|
|
236
|
+
throw new AiAuthError(`${spec.label} is not authenticated: ${detail}`);
|
|
237
|
+
}
|
|
238
|
+
throw new Error(`${spec.label} exited ${String(code)}: ${detail}`);
|
|
239
|
+
}
|
|
240
|
+
return stdout.trim();
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Whether any known CLI is installed — synchronously.
|
|
246
|
+
*
|
|
247
|
+
* A PATH scan, not a subprocess, so availability gates stay synchronous
|
|
248
|
+
* and callers (and their ~80 existing mocks) do not have to become async.
|
|
249
|
+
*/
|
|
250
|
+
export function hasCliTransport() {
|
|
251
|
+
return KNOWN_CLIS.some(spec => isOnPath(spec.bin));
|
|
252
|
+
}
|
|
253
|
+
/** Transport entries for every CLI this server knows how to drive. */
|
|
254
|
+
export function cliTransports(timeoutMs = CLI_TIMEOUT_MS) {
|
|
255
|
+
return KNOWN_CLIS.map(spec => createCliTransport(spec, timeoutMs));
|
|
256
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child entrypoint for the isolated local pattern engine (#307).
|
|
3
|
+
*
|
|
4
|
+
* Forked by `IsolatedEngineRunner` with `--max-old-space-size`. Owns one
|
|
5
|
+
* `StrudelEngine`, answers RPC over IPC, and is expected to die from time
|
|
6
|
+
* to time — that is the design, not a fault. Nothing here should try to
|
|
7
|
+
* survive a bad pattern; the parent handles death and respawns.
|
|
8
|
+
*
|
|
9
|
+
* This file is deliberately the only place that imports `StrudelEngine`
|
|
10
|
+
* into the isolated process, so the parent never loads `@strudel/*` at all.
|
|
11
|
+
*/
|
|
12
|
+
import { StrudelEngine } from './StrudelEngine.js';
|
|
13
|
+
import { ISOLATED_METHODS } from './LocalPatternEngine.js';
|
|
14
|
+
import { toTransferable } from './Transferable.js';
|
|
15
|
+
import { BusinessError, TransientError, ValidationError } from '../utils/CategorisedError.js';
|
|
16
|
+
const engine = new StrudelEngine();
|
|
17
|
+
/**
|
|
18
|
+
* Object-literal method shorthand, and `Object.hasOwn` on the way in.
|
|
19
|
+
* A plain `HANDLERS[method]` would happily resolve `constructor` or
|
|
20
|
+
* `toString` off the prototype chain and call it with attacker-chosen
|
|
21
|
+
* arguments — the same class of bug this repo has now found nine times.
|
|
22
|
+
*/
|
|
23
|
+
const HANDLERS = {
|
|
24
|
+
transpile(code) {
|
|
25
|
+
return engine.transpile(code);
|
|
26
|
+
},
|
|
27
|
+
validate(code) {
|
|
28
|
+
return engine.validate(code);
|
|
29
|
+
},
|
|
30
|
+
analyzePattern(code) {
|
|
31
|
+
return engine.analyzePattern(code);
|
|
32
|
+
},
|
|
33
|
+
queryEvents(code, start, end) {
|
|
34
|
+
return engine.queryEvents(code, start, end);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
/** The category an error carries, when it is one of ours. */
|
|
38
|
+
function categoryOf(error) {
|
|
39
|
+
if (error instanceof ValidationError)
|
|
40
|
+
return 'validation';
|
|
41
|
+
if (error instanceof BusinessError)
|
|
42
|
+
return 'business';
|
|
43
|
+
if (error instanceof TransientError)
|
|
44
|
+
return 'transient';
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
process.on('message', (raw) => {
|
|
48
|
+
const request = raw;
|
|
49
|
+
if (!request || typeof request.id !== 'number')
|
|
50
|
+
return;
|
|
51
|
+
const send = (payload) => {
|
|
52
|
+
process.send?.({ id: request.id, ...payload });
|
|
53
|
+
};
|
|
54
|
+
if (!ISOLATED_METHODS.includes(request.method) || !Object.hasOwn(HANDLERS, request.method)) {
|
|
55
|
+
send({ ok: false, error: { name: 'TypeError', message: `Unknown engine method: ${String(request.method)}` } });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const handler = HANDLERS[request.method];
|
|
60
|
+
const result = handler(...(request.args ?? []));
|
|
61
|
+
send({ ok: true, result: toTransferable(result) });
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
send({
|
|
65
|
+
ok: false,
|
|
66
|
+
error: {
|
|
67
|
+
name: error instanceof Error ? error.name : 'Error',
|
|
68
|
+
message: error instanceof Error ? error.message : String(error),
|
|
69
|
+
// The class cannot cross IPC — the parent rebuilds a plain Error
|
|
70
|
+
// from this payload — so the verdict travels as a field. Without
|
|
71
|
+
// it, "Result exceeds the 2000000-value transfer limit. Narrow
|
|
72
|
+
// the query" arrived uncategorised and was reported as an
|
|
73
|
+
// internal failure not worth retrying (#382).
|
|
74
|
+
category: categoryOf(error),
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
// If the parent goes away, so do we. A forked engine outliving its server
|
|
80
|
+
// is a leaked process, not a feature.
|
|
81
|
+
process.on('disconnect', () => {
|
|
82
|
+
process.exit(0);
|
|
83
|
+
});
|