@hraness/dawg 0.0.0-stage → 0.2.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/CHANGELOG.md +47 -0
- package/DAWG.md +142 -0
- package/LICENSE +21 -0
- package/README.md +164 -2
- package/core/drums.ts +102 -0
- package/core/loop.ts +78 -0
- package/core/score.ts +1065 -0
- package/package.json +42 -4
- package/src/agent/agent.ts +587 -0
- package/src/agent/brief.ts +140 -0
- package/src/agent/gateway.ts +267 -0
- package/src/agent/ops.ts +172 -0
- package/src/agent/planner.ts +221 -0
- package/src/agent/provider.ts +326 -0
- package/src/agent/sse.ts +90 -0
- package/src/agent/tools.ts +976 -0
- package/src/agent/xcb-agent.ts +247 -0
- package/src/agent/xcb.ts +367 -0
- package/src/audio/clock.ts +68 -0
- package/src/audio/engine.ts +467 -0
- package/src/audio/lock.ts +57 -0
- package/src/audio/player.ts +119 -0
- package/src/audio/wav.ts +647 -0
- package/src/auth/cli.ts +112 -0
- package/src/auth/credentials.ts +285 -0
- package/src/auth/login.ts +434 -0
- package/src/auth/runner.ts +184 -0
- package/src/auth/tui.ts +90 -0
- package/src/commands/history.ts +56 -0
- package/src/commands/music.ts +444 -0
- package/src/daemon.ts +31 -0
- package/src/main.ts +1332 -0
- package/src/render.ts +99 -0
- package/src/session/attach.ts +182 -0
- package/src/session/client.ts +498 -0
- package/src/session/daemon.ts +723 -0
- package/src/session/list.ts +180 -0
- package/src/session/lock.ts +92 -0
- package/src/session/meta.ts +253 -0
- package/src/session/naming.ts +464 -0
- package/src/session/port.ts +461 -0
- package/src/session/presence.ts +159 -0
- package/src/session/protocol.ts +618 -0
- package/src/session/rebase.ts +138 -0
- package/src/session/store.ts +486 -0
- package/tui/activity.ts +325 -0
- package/tui/app.ts +1129 -0
- package/tui/drums.ts +21 -0
- package/tui/highway.ts +905 -0
- package/tui/input.ts +63 -0
- package/tui/keys.ts +102 -0
- package/tui/layers.ts +68 -0
- package/tui/prompt.ts +609 -0
- package/tui/render.ts +124 -0
- package/tui/screen.ts +247 -0
- package/tui/text.ts +72 -0
- package/tui/theme.ts +451 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { TrackScore } from "../../core/score.ts";
|
|
2
|
+
import { AVAILABLE_EFFECTS, AVAILABLE_INSTRUMENTS } from "../audio/wav.ts";
|
|
3
|
+
|
|
4
|
+
export const MAX_BRIEF_BYTES = 12 * 1024;
|
|
5
|
+
const MAX_FOCUSED_NOTES = 96;
|
|
6
|
+
const MAX_RECENT = 8;
|
|
7
|
+
const MAX_RECENT_CHARS = 120;
|
|
8
|
+
const NOTE_NAMES = [
|
|
9
|
+
"C",
|
|
10
|
+
"C#",
|
|
11
|
+
"D",
|
|
12
|
+
"D#",
|
|
13
|
+
"E",
|
|
14
|
+
"F",
|
|
15
|
+
"F#",
|
|
16
|
+
"G",
|
|
17
|
+
"G#",
|
|
18
|
+
"A",
|
|
19
|
+
"A#",
|
|
20
|
+
"B",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A compact, deterministic snapshot of the composition for the model. It is
|
|
25
|
+
* derived only from score state and caller-supplied operation summaries, so
|
|
26
|
+
* the same score, revision, and history always yield the same bytes, and no
|
|
27
|
+
* environment value (including credentials) can reach it.
|
|
28
|
+
*/
|
|
29
|
+
export function compositionBrief(options: {
|
|
30
|
+
score: TrackScore;
|
|
31
|
+
revision: number;
|
|
32
|
+
focusedTrackId: string;
|
|
33
|
+
recentOperations?: readonly string[];
|
|
34
|
+
maxBytes?: number;
|
|
35
|
+
}): string {
|
|
36
|
+
const { score } = options;
|
|
37
|
+
const maxBytes = options.maxBytes ?? MAX_BRIEF_BYTES;
|
|
38
|
+
const tpb = score.ticksPerBeat;
|
|
39
|
+
const beats = (ticks: number) => Math.round((ticks / tpb) * 1000) / 1000;
|
|
40
|
+
const tracks = score.tracks.map((track) => {
|
|
41
|
+
const notes = score.notes.filter((note) => note.trackId === track.id);
|
|
42
|
+
const pitches = notes.map((note) => note.pitch);
|
|
43
|
+
return {
|
|
44
|
+
id: track.id,
|
|
45
|
+
name: track.name,
|
|
46
|
+
instrument: track.instrument,
|
|
47
|
+
...(track.muted ? { muted: true } : {}),
|
|
48
|
+
volume: track.volume,
|
|
49
|
+
pan: track.pan,
|
|
50
|
+
notes: notes.length,
|
|
51
|
+
...(notes.length > 0
|
|
52
|
+
? {
|
|
53
|
+
range: `${noteName(Math.min(...pitches))}..${noteName(Math.max(...pitches))}`,
|
|
54
|
+
}
|
|
55
|
+
: {}),
|
|
56
|
+
...(track.volumeAutomation.length > 0
|
|
57
|
+
? { volumeAutomation: track.volumeAutomation.length }
|
|
58
|
+
: {}),
|
|
59
|
+
...(track.panAutomation.length > 0
|
|
60
|
+
? { panAutomation: track.panAutomation.length }
|
|
61
|
+
: {}),
|
|
62
|
+
...(track.solo ? { solo: true } : {}),
|
|
63
|
+
...(track.filter ? { filter: track.filter } : {}),
|
|
64
|
+
...(track.delay ? { delay: track.delay } : {}),
|
|
65
|
+
...((track.filterAutomation?.length ?? 0) > 0
|
|
66
|
+
? { filterAutomation: track.filterAutomation!.length }
|
|
67
|
+
: {}),
|
|
68
|
+
...(track.reverb ? { reverb: track.reverb } : {}),
|
|
69
|
+
...((track.resonanceAutomation?.length ?? 0) > 0
|
|
70
|
+
? { resonanceAutomation: track.resonanceAutomation!.length }
|
|
71
|
+
: {}),
|
|
72
|
+
...((track.delayFeedbackAutomation?.length ?? 0) > 0
|
|
73
|
+
? { delayFeedbackAutomation: track.delayFeedbackAutomation!.length }
|
|
74
|
+
: {}),
|
|
75
|
+
...((track.delayMixAutomation?.length ?? 0) > 0
|
|
76
|
+
? { delayMixAutomation: track.delayMixAutomation!.length }
|
|
77
|
+
: {}),
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
const focusedNotes = score.notes
|
|
81
|
+
.filter((note) => note.trackId === options.focusedTrackId)
|
|
82
|
+
.slice()
|
|
83
|
+
.sort(
|
|
84
|
+
(left, right) =>
|
|
85
|
+
left.startTick - right.startTick ||
|
|
86
|
+
left.pitch - right.pitch ||
|
|
87
|
+
left.id.localeCompare(right.id),
|
|
88
|
+
)
|
|
89
|
+
.map((note) => [
|
|
90
|
+
note.id,
|
|
91
|
+
noteName(note.pitch),
|
|
92
|
+
beats(note.startTick),
|
|
93
|
+
beats(note.durationTicks),
|
|
94
|
+
Math.round(note.velocity * 100) / 100,
|
|
95
|
+
]);
|
|
96
|
+
const recent = (options.recentOperations ?? [])
|
|
97
|
+
.slice(-MAX_RECENT)
|
|
98
|
+
.map((line) => line.replace(/\s+/g, " ").slice(0, MAX_RECENT_CHARS));
|
|
99
|
+
const build = (noteLimit: number, trackLimit: number) => {
|
|
100
|
+
const visible = focusedNotes.slice(0, noteLimit);
|
|
101
|
+
return JSON.stringify({
|
|
102
|
+
revision: options.revision,
|
|
103
|
+
tempoBpm: score.tempoBpm,
|
|
104
|
+
meter: `${score.beatsPerBar}/4`,
|
|
105
|
+
bars: score.bars,
|
|
106
|
+
loopBeats: score.bars * score.beatsPerBar,
|
|
107
|
+
...(score.key ? { key: score.key } : {}),
|
|
108
|
+
focusedTrack: options.focusedTrackId,
|
|
109
|
+
tracks: tracks.slice(0, trackLimit),
|
|
110
|
+
...(tracks.length > trackLimit
|
|
111
|
+
? { omittedTracks: tracks.length - trackLimit }
|
|
112
|
+
: {}),
|
|
113
|
+
focusedNotes: {
|
|
114
|
+
columns: ["id", "pitch", "startBeat", "durationBeats", "velocity"],
|
|
115
|
+
rows: visible,
|
|
116
|
+
...(focusedNotes.length > visible.length
|
|
117
|
+
? { omitted: focusedNotes.length - visible.length }
|
|
118
|
+
: {}),
|
|
119
|
+
},
|
|
120
|
+
recentOperations: recent,
|
|
121
|
+
instruments: AVAILABLE_INSTRUMENTS,
|
|
122
|
+
effects: AVAILABLE_EFFECTS,
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
const encoder = new TextEncoder();
|
|
126
|
+
let noteLimit = Math.min(MAX_FOCUSED_NOTES, focusedNotes.length);
|
|
127
|
+
let trackLimit = tracks.length;
|
|
128
|
+
let brief = build(noteLimit, trackLimit);
|
|
129
|
+
while (encoder.encode(brief).byteLength > maxBytes) {
|
|
130
|
+
if (noteLimit > 0) noteLimit = Math.floor(noteLimit / 2);
|
|
131
|
+
else if (trackLimit > 1) trackLimit = Math.floor(trackLimit / 2);
|
|
132
|
+
else break;
|
|
133
|
+
brief = build(noteLimit, trackLimit);
|
|
134
|
+
}
|
|
135
|
+
return brief;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function noteName(midi: number): string {
|
|
139
|
+
return `${NOTE_NAMES[midi % 12]}${Math.floor(midi / 12) - 1}`;
|
|
140
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { readSseData } from "./sse.ts";
|
|
2
|
+
|
|
3
|
+
/** The only model labels dawg accepts. Provider IDs are local configuration. */
|
|
4
|
+
export const GATEWAY_MODELS = Object.freeze(["opus-5.5", "sol-6.1"] as const);
|
|
5
|
+
export type GatewayModel = (typeof GATEWAY_MODELS)[number];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Default provider IDs, confirmed against the AI Gateway catalog
|
|
9
|
+
* (`GET https://ai-gateway.vercel.sh/v1/models`, 2026-10-06): both are
|
|
10
|
+
* language models tagged `tool-use`.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_MODEL_IDS: Readonly<Record<GatewayModel, string>> =
|
|
13
|
+
Object.freeze({
|
|
14
|
+
"opus-5.5": "anthropic/claude-opus-5.5",
|
|
15
|
+
"sol-6.1": "openai/gpt-6.1-sol",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const MODEL_ID_PATTERN =
|
|
19
|
+
/^[a-z0-9][a-z0-9-]{0,63}\/[a-z0-9][a-z0-9._-]{0,127}$/i;
|
|
20
|
+
const MAX_ERROR_BODY_BYTES = 4 * 1024;
|
|
21
|
+
|
|
22
|
+
export function isGatewayModel(value: unknown): value is GatewayModel {
|
|
23
|
+
return (
|
|
24
|
+
typeof value === "string" &&
|
|
25
|
+
(GATEWAY_MODELS as readonly string[]).includes(value)
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Resolve an allowlisted alias to its provider ID, rejecting anything else. */
|
|
30
|
+
export function resolveModelId(
|
|
31
|
+
alias: string,
|
|
32
|
+
overrides: Partial<Record<GatewayModel, string | undefined>> = {},
|
|
33
|
+
): string {
|
|
34
|
+
if (!isGatewayModel(alias))
|
|
35
|
+
throw new Error(
|
|
36
|
+
`unknown model "${alias.slice(0, 32)}"; use ${GATEWAY_MODELS.join(" or ")}`,
|
|
37
|
+
);
|
|
38
|
+
const id = overrides[alias] ?? DEFAULT_MODEL_IDS[alias];
|
|
39
|
+
if (!MODEL_ID_PATTERN.test(id))
|
|
40
|
+
throw new Error(`model ID for ${alias} must look like provider/model`);
|
|
41
|
+
return id;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function checkedModelId(id: string): string {
|
|
45
|
+
if (!MODEL_ID_PATTERN.test(id))
|
|
46
|
+
throw new Error("model ID must look like provider/model");
|
|
47
|
+
return id;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type ChatToolCall = {
|
|
51
|
+
id: string;
|
|
52
|
+
type: "function";
|
|
53
|
+
function: { name: string; arguments: string };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type ChatMessage =
|
|
57
|
+
| { role: "system" | "user"; content: string }
|
|
58
|
+
| { role: "assistant"; content: string | null; tool_calls?: ChatToolCall[] }
|
|
59
|
+
| { role: "tool"; tool_call_id: string; content: string };
|
|
60
|
+
|
|
61
|
+
export type ChatTool = {
|
|
62
|
+
type: "function";
|
|
63
|
+
function: {
|
|
64
|
+
name: string;
|
|
65
|
+
description: string;
|
|
66
|
+
parameters: Record<string, unknown>;
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type ChatStreamRequest = {
|
|
71
|
+
model: GatewayModel;
|
|
72
|
+
messages: readonly ChatMessage[];
|
|
73
|
+
tools?: readonly ChatTool[];
|
|
74
|
+
temperature?: number;
|
|
75
|
+
/** Exact provider model ID, bypassing the alias (e.g. a small model for naming). */
|
|
76
|
+
modelId?: string;
|
|
77
|
+
maxTokens?: number;
|
|
78
|
+
maxResponseBytes: number;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Normalized stream events; provider chunk shapes never leave this module. */
|
|
82
|
+
export type ChatStreamEvent =
|
|
83
|
+
| { type: "text"; delta: string }
|
|
84
|
+
| {
|
|
85
|
+
type: "tool-delta";
|
|
86
|
+
index: number;
|
|
87
|
+
id?: string;
|
|
88
|
+
name?: string;
|
|
89
|
+
arguments?: string;
|
|
90
|
+
}
|
|
91
|
+
| { type: "finish"; reason: string };
|
|
92
|
+
|
|
93
|
+
export type GatewayClient = {
|
|
94
|
+
/** The provider model ID that a turn will use (for diagnostics only). */
|
|
95
|
+
modelId(model: GatewayModel): string;
|
|
96
|
+
stream(
|
|
97
|
+
request: ChatStreamRequest,
|
|
98
|
+
signal?: AbortSignal,
|
|
99
|
+
): AsyncIterable<ChatStreamEvent>;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export class GatewayError extends Error {
|
|
103
|
+
constructor(
|
|
104
|
+
message: string,
|
|
105
|
+
readonly status?: number,
|
|
106
|
+
) {
|
|
107
|
+
super(message);
|
|
108
|
+
this.name = "GatewayError";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
type GatewayFetcher = (
|
|
113
|
+
input: RequestInfo | URL,
|
|
114
|
+
init?: RequestInit,
|
|
115
|
+
) => Promise<Response>;
|
|
116
|
+
|
|
117
|
+
export function createGatewayClient(
|
|
118
|
+
options: {
|
|
119
|
+
apiKey?: string;
|
|
120
|
+
baseUrl?: string;
|
|
121
|
+
modelIds?: Partial<Record<GatewayModel, string>>;
|
|
122
|
+
fetcher?: GatewayFetcher;
|
|
123
|
+
} = {},
|
|
124
|
+
): GatewayClient {
|
|
125
|
+
const fetcher = options.fetcher ?? fetch;
|
|
126
|
+
const baseUrl = (
|
|
127
|
+
options.baseUrl ??
|
|
128
|
+
process.env.AI_GATEWAY_BASE_URL ??
|
|
129
|
+
"https://ai-gateway.vercel.sh/v1"
|
|
130
|
+
).replace(/\/$/, "");
|
|
131
|
+
const apiKey = options.apiKey ?? process.env.AI_GATEWAY_API_KEY;
|
|
132
|
+
const overrides: Partial<Record<GatewayModel, string | undefined>> = {
|
|
133
|
+
"opus-5.5": process.env.DAWG_OPUS_MODEL || undefined,
|
|
134
|
+
"sol-6.1": process.env.DAWG_SOL_MODEL || undefined,
|
|
135
|
+
...options.modelIds,
|
|
136
|
+
};
|
|
137
|
+
const redact = (text: string): string =>
|
|
138
|
+
apiKey && apiKey.length > 0 ? text.split(apiKey).join("[redacted]") : text;
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
modelId: (model) => resolveModelId(model, overrides),
|
|
142
|
+
async *stream(request, signal) {
|
|
143
|
+
const model =
|
|
144
|
+
request.modelId !== undefined
|
|
145
|
+
? checkedModelId(request.modelId)
|
|
146
|
+
: resolveModelId(request.model, overrides);
|
|
147
|
+
if (!apiKey)
|
|
148
|
+
throw new GatewayError(
|
|
149
|
+
"no AI Gateway key; run `dawg login` or set AI_GATEWAY_API_KEY",
|
|
150
|
+
);
|
|
151
|
+
const body: Record<string, unknown> = {
|
|
152
|
+
model,
|
|
153
|
+
messages: request.messages,
|
|
154
|
+
stream: true,
|
|
155
|
+
};
|
|
156
|
+
if (request.tools && request.tools.length > 0) {
|
|
157
|
+
body.tools = request.tools;
|
|
158
|
+
body.tool_choice = "auto";
|
|
159
|
+
}
|
|
160
|
+
if (request.temperature !== undefined)
|
|
161
|
+
body.temperature = request.temperature;
|
|
162
|
+
if (
|
|
163
|
+
request.maxTokens !== undefined &&
|
|
164
|
+
Number.isInteger(request.maxTokens) &&
|
|
165
|
+
request.maxTokens > 0
|
|
166
|
+
)
|
|
167
|
+
body.max_tokens = Math.min(request.maxTokens, 4096);
|
|
168
|
+
const init: RequestInit = {
|
|
169
|
+
method: "POST",
|
|
170
|
+
headers: {
|
|
171
|
+
authorization: `Bearer ${apiKey}`,
|
|
172
|
+
"content-type": "application/json",
|
|
173
|
+
accept: "text/event-stream",
|
|
174
|
+
},
|
|
175
|
+
body: JSON.stringify(body),
|
|
176
|
+
};
|
|
177
|
+
if (signal !== undefined) init.signal = signal;
|
|
178
|
+
const response = await fetcher(`${baseUrl}/chat/completions`, init);
|
|
179
|
+
if (!response.ok) {
|
|
180
|
+
const detail = redact(await boundedErrorDetail(response));
|
|
181
|
+
throw new GatewayError(
|
|
182
|
+
`AI Gateway request failed (${response.status})${detail ? `: ${detail}` : ""}`,
|
|
183
|
+
response.status,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
if (!response.body)
|
|
187
|
+
throw new GatewayError("AI Gateway returned an empty stream");
|
|
188
|
+
const streamOptions: { maxBytes: number; signal?: AbortSignal } = {
|
|
189
|
+
maxBytes: request.maxResponseBytes,
|
|
190
|
+
};
|
|
191
|
+
if (signal !== undefined) streamOptions.signal = signal;
|
|
192
|
+
for await (const data of readSseData(response.body, streamOptions)) {
|
|
193
|
+
let chunk: unknown;
|
|
194
|
+
try {
|
|
195
|
+
chunk = JSON.parse(data);
|
|
196
|
+
} catch {
|
|
197
|
+
throw new GatewayError("AI Gateway sent a malformed stream chunk");
|
|
198
|
+
}
|
|
199
|
+
yield* normalizeChunk(chunk, redact);
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function* normalizeChunk(
|
|
206
|
+
chunk: unknown,
|
|
207
|
+
redact: (text: string) => string,
|
|
208
|
+
): Generator<ChatStreamEvent> {
|
|
209
|
+
if (!isRecord(chunk)) return;
|
|
210
|
+
if (isRecord(chunk.error)) {
|
|
211
|
+
const message =
|
|
212
|
+
typeof chunk.error.message === "string"
|
|
213
|
+
? chunk.error.message.slice(0, 300)
|
|
214
|
+
: "unknown provider error";
|
|
215
|
+
throw new GatewayError(`AI Gateway stream error: ${redact(message)}`);
|
|
216
|
+
}
|
|
217
|
+
const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
|
|
218
|
+
if (!isRecord(choice)) return;
|
|
219
|
+
const delta = isRecord(choice.delta) ? choice.delta : undefined;
|
|
220
|
+
if (delta && typeof delta.content === "string" && delta.content.length > 0)
|
|
221
|
+
yield { type: "text", delta: delta.content };
|
|
222
|
+
if (delta && Array.isArray(delta.tool_calls)) {
|
|
223
|
+
for (const [position, call] of delta.tool_calls.entries()) {
|
|
224
|
+
if (!isRecord(call)) continue;
|
|
225
|
+
const event: ChatStreamEvent = {
|
|
226
|
+
type: "tool-delta",
|
|
227
|
+
index:
|
|
228
|
+
typeof call.index === "number" && Number.isInteger(call.index)
|
|
229
|
+
? call.index
|
|
230
|
+
: position,
|
|
231
|
+
};
|
|
232
|
+
if (typeof call.id === "string") event.id = call.id;
|
|
233
|
+
const fn = isRecord(call.function) ? call.function : undefined;
|
|
234
|
+
if (fn && typeof fn.name === "string" && fn.name.length > 0)
|
|
235
|
+
event.name = fn.name;
|
|
236
|
+
if (fn && typeof fn.arguments === "string")
|
|
237
|
+
event.arguments = fn.arguments;
|
|
238
|
+
yield event;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (typeof choice.finish_reason === "string")
|
|
242
|
+
yield { type: "finish", reason: choice.finish_reason };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function boundedErrorDetail(response: Response): Promise<string> {
|
|
246
|
+
try {
|
|
247
|
+
const text = (await response.text()).slice(0, MAX_ERROR_BODY_BYTES);
|
|
248
|
+
try {
|
|
249
|
+
const parsed: unknown = JSON.parse(text);
|
|
250
|
+
if (
|
|
251
|
+
isRecord(parsed) &&
|
|
252
|
+
isRecord(parsed.error) &&
|
|
253
|
+
typeof parsed.error.message === "string"
|
|
254
|
+
)
|
|
255
|
+
return parsed.error.message.slice(0, 300);
|
|
256
|
+
} catch {
|
|
257
|
+
// Plain-text error bodies are summarized below.
|
|
258
|
+
}
|
|
259
|
+
return text.replace(/\s+/g, " ").trim().slice(0, 300);
|
|
260
|
+
} catch {
|
|
261
|
+
return "";
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
266
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
267
|
+
}
|
package/src/agent/ops.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { SCORE_LIMITS } from "../../core/score.ts";
|
|
2
|
+
|
|
3
|
+
export type AgentOperation =
|
|
4
|
+
| { type: "add-track"; trackId: string }
|
|
5
|
+
| {
|
|
6
|
+
type: "add-note";
|
|
7
|
+
pitch: number;
|
|
8
|
+
start: number;
|
|
9
|
+
duration: number;
|
|
10
|
+
velocity: number;
|
|
11
|
+
}
|
|
12
|
+
| { type: "remove-note"; noteId: string }
|
|
13
|
+
| {
|
|
14
|
+
type: "update-note";
|
|
15
|
+
noteId: string;
|
|
16
|
+
patch: {
|
|
17
|
+
start?: number;
|
|
18
|
+
duration?: number;
|
|
19
|
+
pitch?: number;
|
|
20
|
+
velocity?: number;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
| { type: "set-tempo"; tempoBpm: number }
|
|
24
|
+
| { type: "set-bars"; bars: number }
|
|
25
|
+
| { type: "extend-bars"; bars: number }
|
|
26
|
+
| {
|
|
27
|
+
type: "track";
|
|
28
|
+
patch: {
|
|
29
|
+
instrument?: string;
|
|
30
|
+
muted?: boolean;
|
|
31
|
+
volume?: number;
|
|
32
|
+
pan?: number;
|
|
33
|
+
name?: string;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
| {
|
|
37
|
+
type: "automation";
|
|
38
|
+
parameter: "volume" | "pan";
|
|
39
|
+
points: readonly { beat: number; value: number }[];
|
|
40
|
+
}
|
|
41
|
+
| { type: "clear-track" }
|
|
42
|
+
| { type: "transport"; action: "play" | "pause" | "toggle" };
|
|
43
|
+
|
|
44
|
+
export function parsePrompt(prompt: string): AgentOperation | undefined {
|
|
45
|
+
const text = prompt.trim().toLowerCase();
|
|
46
|
+
if (/^(play|start)\b/.test(text))
|
|
47
|
+
return { type: "transport", action: "play" };
|
|
48
|
+
if (/^(pause|stop)\b/.test(text))
|
|
49
|
+
return { type: "transport", action: "pause" };
|
|
50
|
+
if (/^toggle\b/.test(text)) return { type: "transport", action: "toggle" };
|
|
51
|
+
const track = text.match(/^(?:add\s+)?track\s+([a-z0-9._-]{1,64})$/);
|
|
52
|
+
if (track) return { type: "add-track", trackId: track[1]! };
|
|
53
|
+
const bars = text.match(/^bars\s+(\d+)$/);
|
|
54
|
+
const extension = text.match(/^extend\s+(\d+)\s+bars?$/);
|
|
55
|
+
if (bars || extension) {
|
|
56
|
+
const count = Number((bars ?? extension)![1]);
|
|
57
|
+
if (Number.isInteger(count) && count >= 1 && count <= SCORE_LIMITS.maxBars)
|
|
58
|
+
return { type: bars ? "set-bars" : "extend-bars", bars: count };
|
|
59
|
+
}
|
|
60
|
+
const tempo = text.match(/^(?:tempo|bpm)\s+(\d+(?:\.\d+)?)\s*$/);
|
|
61
|
+
if (tempo) {
|
|
62
|
+
const tempoBpm = Number(tempo[1]);
|
|
63
|
+
if (Number.isFinite(tempoBpm)) return { type: "set-tempo", tempoBpm };
|
|
64
|
+
}
|
|
65
|
+
if (/^(?:clear|clear\s+track|remove\s+all)$/.test(text))
|
|
66
|
+
return { type: "clear-track" };
|
|
67
|
+
const instrument = text.match(
|
|
68
|
+
/^(?:instrument|sound|voice)\s+([a-z0-9._ -]{1,64})$/,
|
|
69
|
+
);
|
|
70
|
+
if (instrument)
|
|
71
|
+
return { type: "track", patch: { instrument: instrument[1]!.trim() } };
|
|
72
|
+
if (/^(?:mute|silence)\b/.test(text))
|
|
73
|
+
return { type: "track", patch: { muted: true } };
|
|
74
|
+
if (/^(?:unmute|unsilence)\b/.test(text))
|
|
75
|
+
return { type: "track", patch: { muted: false } };
|
|
76
|
+
const volume = text.match(/^(?:volume|vol)\s+(0(?:\.\d+)?|1(?:\.0+)?)$/);
|
|
77
|
+
if (volume) return { type: "track", patch: { volume: Number(volume[1]) } };
|
|
78
|
+
const clearAutomation = text.match(
|
|
79
|
+
/^(?:clear|reset)\s+(?:(volume|pan)\s+)?automation$/,
|
|
80
|
+
);
|
|
81
|
+
if (clearAutomation)
|
|
82
|
+
return {
|
|
83
|
+
type: "automation",
|
|
84
|
+
parameter:
|
|
85
|
+
(clearAutomation[1] as "volume" | "pan" | undefined) ?? "volume",
|
|
86
|
+
points: [],
|
|
87
|
+
};
|
|
88
|
+
const automation = text.match(
|
|
89
|
+
/^(?:automate|automation)\s+(volume|pan)\s+at\s+(\d+(?:\.\d+)?)\s+(-?(?:0(?:\.\d+)?|1(?:\.0+)?))$/,
|
|
90
|
+
);
|
|
91
|
+
if (automation) {
|
|
92
|
+
const parameter = automation[1] as "volume" | "pan";
|
|
93
|
+
const beat = Number(automation[2]);
|
|
94
|
+
const value = Number(automation[3]);
|
|
95
|
+
if (
|
|
96
|
+
Number.isFinite(beat) &&
|
|
97
|
+
Number.isFinite(value) &&
|
|
98
|
+
(parameter === "pan" ? value >= -1 : value >= 0)
|
|
99
|
+
)
|
|
100
|
+
return {
|
|
101
|
+
type: "automation",
|
|
102
|
+
parameter,
|
|
103
|
+
points: [{ beat, value }],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const pan = text.match(/^(?:pan)\s+(-?1(?:\.0+)?|-?0(?:\.\d+)?)$/);
|
|
107
|
+
if (pan) return { type: "track", patch: { pan: Number(pan[1]) } };
|
|
108
|
+
const remove = text.match(
|
|
109
|
+
/^(?:remove|delete)(?:\s+note)?\s+([a-z0-9._-]{1,64})$/,
|
|
110
|
+
);
|
|
111
|
+
if (remove) return { type: "remove-note", noteId: remove[1]! };
|
|
112
|
+
const move = text.match(
|
|
113
|
+
/^(?:move|shift)\s+(?:note\s+)?([a-z0-9._-]{1,64})\s+(?:to|at)\s+(\d+(?:\.\d+)?)$/,
|
|
114
|
+
);
|
|
115
|
+
if (move)
|
|
116
|
+
return {
|
|
117
|
+
type: "update-note",
|
|
118
|
+
noteId: move[1]!,
|
|
119
|
+
patch: { start: Number(move[2]) },
|
|
120
|
+
};
|
|
121
|
+
const length = text.match(
|
|
122
|
+
/^(?:length|duration)\s+(?:note\s+)?([a-z0-9._-]{1,64})\s+(\d+(?:\.\d+)?)$/,
|
|
123
|
+
);
|
|
124
|
+
if (length)
|
|
125
|
+
return {
|
|
126
|
+
type: "update-note",
|
|
127
|
+
noteId: length[1]!,
|
|
128
|
+
patch: { duration: Number(length[2]) },
|
|
129
|
+
};
|
|
130
|
+
const velocity = text.match(
|
|
131
|
+
/^(?:velocity|vel)\s+(?:note\s+)?([a-z0-9._-]{1,64})\s+(0(?:\.\d+)?|1(?:\.0+)?)$/,
|
|
132
|
+
);
|
|
133
|
+
if (velocity)
|
|
134
|
+
return {
|
|
135
|
+
type: "update-note",
|
|
136
|
+
noteId: velocity[1]!,
|
|
137
|
+
patch: { velocity: Number(velocity[2]) },
|
|
138
|
+
};
|
|
139
|
+
const match = text.match(
|
|
140
|
+
/^(?:add|put)\s+(?:note\s+)?([a-g](?:#|b)?-?\d+)\s+(?:at\s+)?(\d+(?:\.\d+)?)\s*(?:for|dur|duration)?\s*(\d+(?:\.\d+)?)?$/,
|
|
141
|
+
);
|
|
142
|
+
if (!match) return undefined;
|
|
143
|
+
const pitch = pitchToMidi(match[1] ?? "c4");
|
|
144
|
+
const start = Number(match[2] ?? 0);
|
|
145
|
+
const duration = Number(match[3] ?? 1);
|
|
146
|
+
if (
|
|
147
|
+
!Number.isFinite(pitch) ||
|
|
148
|
+
!Number.isFinite(start) ||
|
|
149
|
+
!Number.isFinite(duration) ||
|
|
150
|
+
duration <= 0
|
|
151
|
+
)
|
|
152
|
+
return undefined;
|
|
153
|
+
return { type: "add-note", pitch, start, duration, velocity: 0.8 };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function pitchToMidi(value: string): number {
|
|
157
|
+
const match = value.match(/^([a-g])([#b]?)(-?\d+)$/);
|
|
158
|
+
if (!match) return Number.NaN;
|
|
159
|
+
const semitones: Record<string, number> = {
|
|
160
|
+
c: 0,
|
|
161
|
+
d: 2,
|
|
162
|
+
e: 4,
|
|
163
|
+
f: 5,
|
|
164
|
+
g: 7,
|
|
165
|
+
a: 9,
|
|
166
|
+
b: 11,
|
|
167
|
+
};
|
|
168
|
+
const accidental = match[2] === "#" ? 1 : match[2] === "b" ? -1 : 0;
|
|
169
|
+
return (
|
|
170
|
+
(Number(match[3]) + 1) * 12 + (semitones[match[1] ?? "c"] ?? 0) + accidental
|
|
171
|
+
);
|
|
172
|
+
}
|