@hraness/dawg 0.2.0 → 0.3.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 +79 -0
- package/DAWG.md +196 -11
- package/README.md +79 -30
- package/core/diff.ts +249 -0
- package/core/key.ts +43 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +324 -1
- package/core/sdk/eval-child.ts +113 -0
- package/core/sdk/eval.ts +257 -0
- package/core/sdk/print.ts +393 -0
- package/core/sdk/v1.ts +954 -0
- package/core/slug.ts +19 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +280 -14
- package/src/agent/brief.ts +23 -3
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/planner.ts +38 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +398 -1
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +27 -12
- package/src/agent/xcb.ts +233 -21
- package/src/audio/click.ts +125 -0
- package/src/audio/engine.ts +407 -33
- package/src/audio/live.ts +152 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +292 -0
- package/src/audio/samples.ts +683 -0
- package/src/audio/wav.ts +290 -76
- package/src/auth/cli.ts +146 -27
- package/src/auth/credentials.ts +167 -41
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +885 -128
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +25 -2
- package/src/auth/tui.ts +60 -43
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +24 -7
- package/src/commands/sample.ts +302 -0
- package/src/main.ts +1043 -166
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +173 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +292 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +28 -6
- package/src/session/attach.ts +3 -4
- package/src/session/daemon.ts +25 -8
- package/src/session/delta.ts +249 -0
- package/src/session/naming.ts +3 -37
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/menu.ts +1083 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +636 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +681 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +261 -18
- package/tui/drums.ts +44 -0
- package/tui/highway.ts +18 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +143 -0
package/src/main.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
-
import { resolve } from "node:path";
|
|
3
|
+
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join, relative, resolve } from "node:path";
|
|
5
5
|
import { stdin, stdout } from "node:process";
|
|
6
6
|
import {
|
|
7
7
|
ensureSession,
|
|
@@ -30,8 +30,26 @@ import { AutoNamer, providerNameGenerator } from "./session/naming.ts";
|
|
|
30
30
|
import { normalizeSessionName } from "./session/meta.ts";
|
|
31
31
|
import { parsePrompt } from "./agent/ops.ts";
|
|
32
32
|
import { applyMusicCommand, parseMusicCommand } from "./commands/music.ts";
|
|
33
|
+
import { applyEditCommand, parseEditCommand } from "./commands/edit.ts";
|
|
34
|
+
import { helpLines, helpText, usageHint } from "./commands/help.ts";
|
|
33
35
|
import { historyTarget, REDO_KIND, UNDO_KIND } from "./commands/history.ts";
|
|
34
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
SamplePlacementError,
|
|
38
|
+
addSampleVoice,
|
|
39
|
+
freeVoiceName,
|
|
40
|
+
listSampleVoices,
|
|
41
|
+
parseSampleCommand,
|
|
42
|
+
placeSampleFile,
|
|
43
|
+
samplerTarget,
|
|
44
|
+
voiceNameFrom,
|
|
45
|
+
} from "./commands/sample.ts";
|
|
46
|
+
import {
|
|
47
|
+
SampleLibrary,
|
|
48
|
+
hasSamplerTracks,
|
|
49
|
+
type SampleBank,
|
|
50
|
+
type SampleProblem,
|
|
51
|
+
} from "./audio/samples.ts";
|
|
52
|
+
import { drumSnapshotFields, samplerSnapshotFields } from "../tui/drums.ts";
|
|
35
53
|
import { highwayLayers } from "../tui/layers.ts";
|
|
36
54
|
import { drumVoicePitch, isDrumInstrument } from "../core/drums.ts";
|
|
37
55
|
import {
|
|
@@ -40,17 +58,41 @@ import {
|
|
|
40
58
|
type AgentEvent,
|
|
41
59
|
type AgentHost,
|
|
42
60
|
} from "./agent/agent.ts";
|
|
43
|
-
import { type GatewayModel } from "./agent/gateway.ts";
|
|
44
61
|
import {
|
|
62
|
+
isApiSelection,
|
|
45
63
|
providerFingerprint,
|
|
46
64
|
providerLabel,
|
|
47
65
|
runProviderTurn,
|
|
48
66
|
selectProvider,
|
|
49
67
|
type ProviderSelection,
|
|
50
68
|
} from "./agent/provider.ts";
|
|
51
|
-
import {
|
|
52
|
-
|
|
69
|
+
import {
|
|
70
|
+
MODEL_CATALOG,
|
|
71
|
+
priceForModel,
|
|
72
|
+
resolveModelChoice,
|
|
73
|
+
} from "./agent/models.ts";
|
|
74
|
+
import {
|
|
75
|
+
SpendMeter,
|
|
76
|
+
spendLine as formatSpendLine,
|
|
77
|
+
webHostFor,
|
|
78
|
+
} from "./agent/usage.ts";
|
|
79
|
+
import { configDir } from "./auth/credentials.ts";
|
|
80
|
+
import { runAuthCommand, runFirstRunLogin, runTuiLogin } from "./auth/cli.ts";
|
|
81
|
+
import {
|
|
82
|
+
modelPickerItems,
|
|
83
|
+
tuiAuthCommand,
|
|
84
|
+
tuiLoginArgs,
|
|
85
|
+
tuiSetModel,
|
|
86
|
+
} from "./auth/tui.ts";
|
|
53
87
|
import { TransportClock } from "./audio/clock.ts";
|
|
88
|
+
import { AudioEngine } from "./audio/engine.ts";
|
|
89
|
+
import {
|
|
90
|
+
DEFAULT_GRID,
|
|
91
|
+
GRIDS,
|
|
92
|
+
PlaySession,
|
|
93
|
+
type LiveEngine,
|
|
94
|
+
} from "./tui/play-session.ts";
|
|
95
|
+
import { EditMenu, MENU_SECTIONS, type MenuContext } from "./tui/menu.ts";
|
|
54
96
|
import {
|
|
55
97
|
addNote,
|
|
56
98
|
applyScoreOperation,
|
|
@@ -69,12 +111,46 @@ import {
|
|
|
69
111
|
TuiApp,
|
|
70
112
|
type AppView,
|
|
71
113
|
type SyncState,
|
|
114
|
+
type TypesIndicator,
|
|
72
115
|
} from "../tui/app.ts";
|
|
73
|
-
import {
|
|
116
|
+
import { fail, ok, toneOf, warn, type Receipt } from "../tui/activity.ts";
|
|
117
|
+
import { systemRunner } from "./auth/runner.ts";
|
|
118
|
+
import type { MediaServices } from "./media/types.ts";
|
|
74
119
|
import { encodeBuffer } from "../tui/screen.ts";
|
|
75
120
|
import { parseThemeName } from "../tui/theme.ts";
|
|
121
|
+
import { formatDiagnostic } from "../core/sdk/eval.ts";
|
|
122
|
+
import { isProject } from "./project/init.ts";
|
|
123
|
+
import { typecheckProject } from "./project/typecheck.ts";
|
|
124
|
+
import {
|
|
125
|
+
startProjectSync,
|
|
126
|
+
type ProjectSync,
|
|
127
|
+
type SyncHost,
|
|
128
|
+
} from "./project/sync.ts";
|
|
76
129
|
|
|
77
130
|
const ESC = "\u001b[";
|
|
131
|
+
/** `/sessions` rows shown in the overlay. */
|
|
132
|
+
const MAX_LISTED_SESSIONS = 64;
|
|
133
|
+
|
|
134
|
+
const SUBCOMMANDS = [
|
|
135
|
+
"login",
|
|
136
|
+
"logout",
|
|
137
|
+
"auth",
|
|
138
|
+
"sessions",
|
|
139
|
+
"render",
|
|
140
|
+
"init",
|
|
141
|
+
"check",
|
|
142
|
+
];
|
|
143
|
+
const VALUE_FLAGS = ["--session", "--track", "--import", "--export", "--theme"];
|
|
144
|
+
const FLAGS = [
|
|
145
|
+
...VALUE_FLAGS,
|
|
146
|
+
"--new",
|
|
147
|
+
"--demo",
|
|
148
|
+
"--help",
|
|
149
|
+
"-h",
|
|
150
|
+
"--version",
|
|
151
|
+
"-v",
|
|
152
|
+
"--reduce-motion",
|
|
153
|
+
];
|
|
78
154
|
const HELP_TEXT = `dawg · local-first terminal music workstation
|
|
79
155
|
|
|
80
156
|
Usage:
|
|
@@ -82,6 +158,10 @@ Usage:
|
|
|
82
158
|
dawg --import <file> --export <file>
|
|
83
159
|
dawg sessions
|
|
84
160
|
dawg render <out.wav> [--session <name|id>] [--import <file>]
|
|
161
|
+
dawg init [dir] project files: song.ts, tracks/<slug>/track.ts, .dawg/sdk
|
|
162
|
+
dawg check typecheck + evaluate the project; exit 1 on problems
|
|
163
|
+
dawg media doctor|download|stems|analyze|notes|sample|lyrics … (dawg media --help)
|
|
164
|
+
dawg --version
|
|
85
165
|
|
|
86
166
|
Usage flags:
|
|
87
167
|
--reduce-motion static hit/sustain states (also DAWG_REDUCE_MOTION=1)
|
|
@@ -92,24 +172,17 @@ Prompt:
|
|
|
92
172
|
Ctrl-Z undo · Ctrl-Y redo · Ctrl-O transcript · Esc cancel/close · Ctrl-C exit
|
|
93
173
|
Space on an empty prompt toggles playback
|
|
94
174
|
|
|
95
|
-
Commands:
|
|
96
|
-
|
|
97
|
-
automate <lane> at <beat> <value>, clear automation, mute, clear
|
|
98
|
-
lanes: volume pan filter resonance delay-feedback delay-mix
|
|
99
|
-
undo, redo, solo, unsolo, filter <hz> [res], delay <beats> [fb] [mix]
|
|
100
|
-
reverb <mix> [size], reverb off
|
|
101
|
-
instrument kit, hit <voice> at <beat>, pattern <voice> <beats...>|every <step>
|
|
102
|
-
track <name>, bars <count>, extend <count> bars
|
|
103
|
-
/tracks, /status, /export <file>, /import <file>, /model opus-5.5|sol-6.1
|
|
104
|
-
/sessions, /resume [n|name|id], /rename <name>|--auto, /fork [name]
|
|
105
|
-
/log, /view focus|all, /theme default|high-contrast|mono, /motion on|off
|
|
106
|
-
/login [--xcb], /logout, /auth [--check]
|
|
175
|
+
Commands (bare music words; app commands take a slash):
|
|
176
|
+
${helpText()}
|
|
107
177
|
|
|
108
|
-
|
|
109
|
-
dawg login
|
|
110
|
-
dawg
|
|
178
|
+
Sign in (the choice is saved and reused until you log out):
|
|
179
|
+
dawg login find existing setups and pick a provider
|
|
180
|
+
dawg login gateway|openrouter|codex|claude
|
|
181
|
+
dawg model [alias] pick a model, with the estimated cost per prompt
|
|
182
|
+
dawg logout [provider] · dawg auth status [--check]
|
|
111
183
|
Unrecognized requests go to the agent once a provider is configured
|
|
112
|
-
(DAWG_PROVIDER=gateway|
|
|
184
|
+
(DAWG_PROVIDER=gateway|openrouter|codex|claude|auto, DAWG_MODEL=<alias>;
|
|
185
|
+
DAWG_AI=0 disables the agent).`;
|
|
113
186
|
const args = new Set(process.argv.slice(2));
|
|
114
187
|
const requestedSession = optionValue("--session");
|
|
115
188
|
const explicitTrack = optionValue("--track");
|
|
@@ -118,16 +191,46 @@ let requestedTrack = explicitTrack ?? "main";
|
|
|
118
191
|
const initialInstrument = isDrumInstrument(requestedTrack) ? "kit" : "sine";
|
|
119
192
|
const importPath = optionValue("--import");
|
|
120
193
|
const exportPath = optionValue("--export");
|
|
121
|
-
if (["login", "logout", "auth"].includes(process.argv[2] ?? ""))
|
|
194
|
+
if (["login", "logout", "auth", "model"].includes(process.argv[2] ?? ""))
|
|
122
195
|
process.exit(await runAuthCommand(process.argv.slice(2)));
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
process.
|
|
196
|
+
{
|
|
197
|
+
// An unknown DAWG_MODEL is an error, never a silent fallback.
|
|
198
|
+
const fromEnv = process.env.DAWG_MODEL?.trim();
|
|
199
|
+
if (fromEnv && !resolveModelChoice("gateway", fromEnv)) {
|
|
200
|
+
process.stderr.write(
|
|
201
|
+
`dawg: unknown DAWG_MODEL "${fromEnv.slice(0, 40)}"; use one of ${MODEL_CATALOG.map((row) => row.alias).join(", ")} or a vendor/model ID\n`,
|
|
202
|
+
);
|
|
203
|
+
process.exit(2);
|
|
204
|
+
}
|
|
126
205
|
}
|
|
127
206
|
if (process.argv[2] === "sessions") {
|
|
128
|
-
|
|
207
|
+
if (args.has("--help") || args.has("-h"))
|
|
208
|
+
stdout.write(
|
|
209
|
+
"usage: dawg sessions · lists this workspace's sessions, newest first (* marks the current one)\n",
|
|
210
|
+
);
|
|
211
|
+
else await printSessions(process.cwd(), stdout);
|
|
129
212
|
process.exit(0);
|
|
130
213
|
}
|
|
214
|
+
if (process.argv[2] === "init" || process.argv[2] === "check") {
|
|
215
|
+
const command =
|
|
216
|
+
process.argv[2] === "init"
|
|
217
|
+
? (await import("./project/init.ts")).runInitCommand
|
|
218
|
+
: (await import("./project/check.ts")).runCheckCommand;
|
|
219
|
+
process.exit(
|
|
220
|
+
await command(process.argv.slice(2), process.cwd(), stdout, process.stderr),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
if (process.argv[2] === "media") {
|
|
224
|
+
const { runMediaCommand } = await import("./media/cli.ts");
|
|
225
|
+
process.exit(
|
|
226
|
+
await runMediaCommand(
|
|
227
|
+
process.argv.slice(2),
|
|
228
|
+
process.cwd(),
|
|
229
|
+
stdout,
|
|
230
|
+
process.stderr,
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
131
234
|
if (process.argv[2] === "render") {
|
|
132
235
|
const { runRenderCommand } = await import("./render.ts");
|
|
133
236
|
process.exit(
|
|
@@ -139,8 +242,28 @@ if (process.argv[2] === "render") {
|
|
|
139
242
|
),
|
|
140
243
|
);
|
|
141
244
|
}
|
|
245
|
+
if (args.has("--version") || args.has("-v")) {
|
|
246
|
+
stdout.write(`dawg ${await packageVersion()}\n`);
|
|
247
|
+
process.exit(0);
|
|
248
|
+
}
|
|
249
|
+
if (args.has("--help") || args.has("-h")) {
|
|
250
|
+
stdout.write(`${HELP_TEXT}\n`);
|
|
251
|
+
process.exit(0);
|
|
252
|
+
}
|
|
253
|
+
// Argument mistakes are rejected here, before `.dawg/` could be created.
|
|
254
|
+
{
|
|
255
|
+
const problem = argumentProblem(process.argv.slice(2));
|
|
256
|
+
if (problem) {
|
|
257
|
+
process.stderr.write(`${problem} · dawg --help\n`);
|
|
258
|
+
process.exit(2);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
142
261
|
const demo =
|
|
143
262
|
args.has("--demo") || process.env.DAWG_DEMO === "1" || !stdin.isTTY;
|
|
263
|
+
// First run with no provider (or a saved one that stopped working): the
|
|
264
|
+
// sign-in picker, in the shell, before the TUI takes the screen.
|
|
265
|
+
if (!demo && process.env.DAWG_AI !== "0" && stdout.isTTY)
|
|
266
|
+
await runFirstRunLogin();
|
|
144
267
|
|
|
145
268
|
const initial = createScore({
|
|
146
269
|
tracks: [
|
|
@@ -162,6 +285,11 @@ const selectedSession = args.has("--new")
|
|
|
162
285
|
},
|
|
163
286
|
);
|
|
164
287
|
if (selectedSession !== undefined) sessionOptions.sessionId = selectedSession;
|
|
288
|
+
/** True when this launch creates `.dawg/`; the strip says so once. */
|
|
289
|
+
const freshWorkspace = !(await stat(join(process.cwd(), ".dawg")).then(
|
|
290
|
+
() => true,
|
|
291
|
+
() => false,
|
|
292
|
+
));
|
|
165
293
|
const session = await ensureSession(initial.toJSON(), sessionOptions);
|
|
166
294
|
// dawgd when connected, the file-lock path otherwise (see src/session/port.ts).
|
|
167
295
|
let port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
|
|
@@ -181,7 +309,7 @@ if (importPath) {
|
|
|
181
309
|
record,
|
|
182
310
|
{
|
|
183
311
|
kind: "score.import",
|
|
184
|
-
payload: { path: importPath
|
|
312
|
+
payload: { path: importPath },
|
|
185
313
|
},
|
|
186
314
|
score.toJSON(),
|
|
187
315
|
);
|
|
@@ -204,8 +332,9 @@ if (!draftTrack) await ensureFocusedTrack();
|
|
|
204
332
|
|
|
205
333
|
const clock = new TransportClock(score.tempoBpm);
|
|
206
334
|
let audio = port.player;
|
|
207
|
-
|
|
208
|
-
|
|
335
|
+
/** Session and today's spend, from the usage each response reports. */
|
|
336
|
+
const meter = new SpendMeter(configDir());
|
|
337
|
+
void meter.load().catch(() => undefined);
|
|
209
338
|
const prompt = new PromptModel({ width: 72, maxVisualRows: 8 });
|
|
210
339
|
const tui = new TuiApp({
|
|
211
340
|
io: {
|
|
@@ -220,10 +349,21 @@ const tui = new TuiApp({
|
|
|
220
349
|
});
|
|
221
350
|
let syncState: SyncState = port.sync;
|
|
222
351
|
let windowCount = 1;
|
|
352
|
+
/** Project file sync when `dawg.json` is in the working directory. */
|
|
353
|
+
let projectSync: ProjectSync | undefined;
|
|
354
|
+
let typesIndicator: TypesIndicator | undefined;
|
|
223
355
|
let announcedName = record.meta.name;
|
|
224
356
|
let namer = makeNamer();
|
|
225
357
|
/** Re-subscribes the TUI after /fork or /resume replaces `port`. */
|
|
226
358
|
let rebindPort: () => void = () => undefined;
|
|
359
|
+
/** Play mode's controller; kept across entries so settings persist. */
|
|
360
|
+
let play: PlaySession | undefined;
|
|
361
|
+
/** The hand-editing menu (`/menu`, Ctrl-K), drawn as the picker overlay. */
|
|
362
|
+
const menu = new EditMenu();
|
|
363
|
+
/** Daemon windows play no loop; play mode monitors through its own engine. */
|
|
364
|
+
let monitorEngine: AudioEngine | undefined;
|
|
365
|
+
/** The decoded sampler voices, for play mode's live voices. */
|
|
366
|
+
let liveSampleBank: SampleBank | undefined;
|
|
227
367
|
/** The in-flight agent turn: Esc aborts it, Enter steers it. */
|
|
228
368
|
let agentTurn: { controller: AbortController; steering: string[] } | undefined;
|
|
229
369
|
let reportAgentActivity: (text: string) => void = () => undefined;
|
|
@@ -233,6 +373,35 @@ let agentEventSink: (event: AgentEvent) => void = () => undefined;
|
|
|
233
373
|
let provider: Promise<ProviderSelection> | undefined;
|
|
234
374
|
let providerStamp = { fingerprint: "", at: 0, offline: false };
|
|
235
375
|
let providerName = "";
|
|
376
|
+
/** Undo/redo key hints shown so far; only the first few receipts carry one. */
|
|
377
|
+
let undoHintsShown = 0;
|
|
378
|
+
const MAX_UNDO_HINTS = 3;
|
|
379
|
+
let invalidNoticeShown = false;
|
|
380
|
+
/** True while /login has handed the terminal to a shell flow. */
|
|
381
|
+
let screenSuspended = false;
|
|
382
|
+
/** Redraw from outside the input loop (usage arrives asynchronously). */
|
|
383
|
+
let tickUi: () => void = () => undefined;
|
|
384
|
+
/** Suspend the TUI around an interactive shell flow (set by runInteractive). */
|
|
385
|
+
let handoff: <T>(flow: () => Promise<T>) => Promise<T> = (flow) => flow();
|
|
386
|
+
|
|
387
|
+
/** `$0.12 session · $0.48 today · opus-5.5 · gateway`, sized to the width. */
|
|
388
|
+
function spendLine(): string {
|
|
389
|
+
const width = stdout.columns ?? 80;
|
|
390
|
+
if (!providerName || providerName === "offline")
|
|
391
|
+
return width >= 40 ? "no model · dawg login" : "";
|
|
392
|
+
const [model, provider] = providerName.split(" · ");
|
|
393
|
+
return formatSpendLine(
|
|
394
|
+
{
|
|
395
|
+
kind: providerSnapshot?.kind === "xcb" ? "subscription" : "api",
|
|
396
|
+
...(model ? { model } : {}),
|
|
397
|
+
...(provider ? { provider } : {}),
|
|
398
|
+
sessionUsd: meter.sessionUsd,
|
|
399
|
+
todayUsd: meter.todayUsd,
|
|
400
|
+
},
|
|
401
|
+
Math.max(0, width - 8),
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
let providerSnapshot: ProviderSelection | undefined;
|
|
236
405
|
if (demo) {
|
|
237
406
|
if (score.notes.length === 0) score = seedDemo(score, requestedTrack);
|
|
238
407
|
if (exportPath)
|
|
@@ -254,6 +423,32 @@ if (exportPath) {
|
|
|
254
423
|
|
|
255
424
|
await runInteractive();
|
|
256
425
|
|
|
426
|
+
/** Describes an unknown subcommand or option in `argv`, or undefined. */
|
|
427
|
+
function argumentProblem(argv: readonly string[]): string | undefined {
|
|
428
|
+
if (argv[0] && !argv[0].startsWith("-") && !SUBCOMMANDS.includes(argv[0]))
|
|
429
|
+
return `unknown command · ${argv[0]}`;
|
|
430
|
+
if (argv[0] && SUBCOMMANDS.includes(argv[0])) return undefined;
|
|
431
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
432
|
+
const arg = argv[index]!;
|
|
433
|
+
if (VALUE_FLAGS.includes(arg)) {
|
|
434
|
+
index += 1;
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
if (arg.startsWith("-") && !FLAGS.includes(arg))
|
|
438
|
+
return `unknown option · ${arg}`;
|
|
439
|
+
if (!arg.startsWith("-")) return `unknown command · ${arg}`;
|
|
440
|
+
}
|
|
441
|
+
return undefined;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function packageVersion(): Promise<string> {
|
|
445
|
+
const raw = await readFile(
|
|
446
|
+
new URL("../package.json", import.meta.url),
|
|
447
|
+
"utf8",
|
|
448
|
+
);
|
|
449
|
+
return (JSON.parse(raw) as { version?: string }).version ?? "0.0.0";
|
|
450
|
+
}
|
|
451
|
+
|
|
257
452
|
function optionValue(name: string): string | undefined {
|
|
258
453
|
const index = process.argv.indexOf(name);
|
|
259
454
|
const value = index >= 0 ? process.argv[index + 1] : undefined;
|
|
@@ -356,6 +551,10 @@ function snapshot(
|
|
|
356
551
|
value.tracks.find((track) => track.id === requestedTrack)?.instrument,
|
|
357
552
|
notes,
|
|
358
553
|
),
|
|
554
|
+
...samplerSnapshotFields(
|
|
555
|
+
value.tracks.find((track) => track.id === requestedTrack),
|
|
556
|
+
notes,
|
|
557
|
+
),
|
|
359
558
|
layers:
|
|
360
559
|
tui.highwayView === "all"
|
|
361
560
|
? highwayLayers(
|
|
@@ -390,33 +589,65 @@ function appView(value: TrackScore, beat: number): AppView {
|
|
|
390
589
|
return {
|
|
391
590
|
score: snapshot(value, beat),
|
|
392
591
|
beat,
|
|
393
|
-
// `opus-5.5 · gateway`, `
|
|
592
|
+
// `opus-5.5 · gateway`, `sonnet · claude`; hidden when offline.
|
|
394
593
|
model:
|
|
395
594
|
providerName && providerName !== "offline" ? providerName : undefined,
|
|
595
|
+
spend: spendLine(),
|
|
596
|
+
agentOffline: providerName === "offline" || process.env.DAWG_AI === "0",
|
|
396
597
|
sync: syncState,
|
|
397
598
|
sessionName: record.meta.name,
|
|
398
599
|
windows: windowCount,
|
|
600
|
+
types: typesIndicator,
|
|
601
|
+
play: play?.on ? play.header() : undefined,
|
|
399
602
|
};
|
|
400
603
|
}
|
|
401
604
|
|
|
402
|
-
/**
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
605
|
+
/** What the strip compares a receipt against: the revision and score before. */
|
|
606
|
+
type Baseline = { revision: number; score: TrackScore };
|
|
607
|
+
function baseline(): Baseline {
|
|
608
|
+
return { revision: record.revision, score };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Show a command receipt in the activity strip. The tone comes from the
|
|
613
|
+
* receipt itself (strings fall back to a legacy regex); the revision label
|
|
614
|
+
* appears when the revision moved, and the undo hint only when the score
|
|
615
|
+
* changed in this session (not for forks, resumes or transport).
|
|
616
|
+
*/
|
|
617
|
+
function receipt(result: string | Receipt, base?: Baseline): void {
|
|
618
|
+
const message = typeof result === "string" ? result : result.text;
|
|
619
|
+
const tone = toneOf(result);
|
|
620
|
+
const changed = base !== undefined && record.revision !== base.revision;
|
|
621
|
+
const scoreChanged =
|
|
622
|
+
changed && record.sessionId !== undefined && score !== base.score;
|
|
623
|
+
if (tone === "error") {
|
|
624
|
+
tui.activity.pushError(message);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const hint =
|
|
628
|
+
scoreChanged && undoHintsShown < MAX_UNDO_HINTS
|
|
629
|
+
? message.startsWith("undid")
|
|
630
|
+
? "^y redo"
|
|
631
|
+
: "^z undo"
|
|
632
|
+
: undefined;
|
|
633
|
+
if (hint) undoHintsShown += 1;
|
|
634
|
+
tui.activity.pushCard(message, {
|
|
635
|
+
tone,
|
|
636
|
+
baseRevision: changed ? base.revision : undefined,
|
|
637
|
+
resultRevision: changed ? record.revision : undefined,
|
|
638
|
+
hint,
|
|
639
|
+
trackId: requestedTrack,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** One shape for thrown errors: `<what> · <why> · <next step>`. */
|
|
644
|
+
function describeError(command: string, error: unknown): string {
|
|
645
|
+
const detail = error as { code?: unknown; path?: unknown } | undefined;
|
|
646
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
647
|
+
if (detail?.code === "ENOENT" && typeof detail.path === "string")
|
|
648
|
+
return `no such file · ${relative(process.cwd(), detail.path)}`;
|
|
649
|
+
const verb = command.trim().split(/\s+/)[0]?.replace(/^\//, "") || "command";
|
|
650
|
+
return `${verb} failed · ${message}`;
|
|
420
651
|
}
|
|
421
652
|
|
|
422
653
|
function truncateForCard(value: string): string {
|
|
@@ -439,7 +670,7 @@ async function runInteractive(): Promise<void> {
|
|
|
439
670
|
return;
|
|
440
671
|
}
|
|
441
672
|
tui.activity.pushRequest(text);
|
|
442
|
-
const base =
|
|
673
|
+
const base = baseline();
|
|
443
674
|
const baseSession = record.sessionId;
|
|
444
675
|
try {
|
|
445
676
|
const message = await submit(text);
|
|
@@ -450,12 +681,10 @@ async function runInteractive(): Promise<void> {
|
|
|
450
681
|
namer.noteTurn({
|
|
451
682
|
score,
|
|
452
683
|
prompt: text,
|
|
453
|
-
accepted: record.revision !== base,
|
|
684
|
+
accepted: record.revision !== base.revision,
|
|
454
685
|
});
|
|
455
686
|
} catch (error) {
|
|
456
|
-
tui.activity.pushError(
|
|
457
|
-
`error · ${error instanceof Error ? error.message : String(error)}`,
|
|
458
|
-
);
|
|
687
|
+
tui.activity.pushError(describeError(text, error));
|
|
459
688
|
} finally {
|
|
460
689
|
agentReported = false;
|
|
461
690
|
}
|
|
@@ -477,17 +706,22 @@ async function runInteractive(): Promise<void> {
|
|
|
477
706
|
}
|
|
478
707
|
};
|
|
479
708
|
const tick = (force = false) => {
|
|
709
|
+
if (screenSuspended) return;
|
|
710
|
+
play?.tick();
|
|
711
|
+
// Values in the menu follow the score as edits land.
|
|
712
|
+
if (menu.open) refreshMenu();
|
|
480
713
|
tui.render(appView(score, clock.beatAt()), { force });
|
|
481
714
|
};
|
|
482
715
|
reportAgentActivity = () => {
|
|
483
716
|
tui.activity.applyAgentEvent({ type: "start", model: providerName });
|
|
484
717
|
};
|
|
485
718
|
agentEventSink = (event) => {
|
|
719
|
+
if (event.type === "usage") return;
|
|
486
720
|
tui.activity.applyAgentEvent(event);
|
|
487
721
|
if (event.type === "done" || event.type === "error") agentReported = true;
|
|
488
722
|
};
|
|
489
723
|
// ~30 fps cap; the differential writer only emits changed rows.
|
|
490
|
-
|
|
724
|
+
let timer = setInterval(tick, tui.frameIntervalMs);
|
|
491
725
|
const onResize = () => {
|
|
492
726
|
tui.invalidate();
|
|
493
727
|
tick(true);
|
|
@@ -533,6 +767,7 @@ async function runInteractive(): Promise<void> {
|
|
|
533
767
|
else if (payload.action === "pause") await setTransport("pause");
|
|
534
768
|
else if (payload.action === "toggle") await setTransport("toggle");
|
|
535
769
|
}
|
|
770
|
+
projectSync?.scoreChanged(score);
|
|
536
771
|
tui.activity.pushCard("synced from another window", {
|
|
537
772
|
tone: "info",
|
|
538
773
|
baseRevision: previousRevision,
|
|
@@ -581,6 +816,10 @@ async function runInteractive(): Promise<void> {
|
|
|
581
816
|
unsubscribe = port.subscribe(onUpdate);
|
|
582
817
|
refreshPresence();
|
|
583
818
|
};
|
|
819
|
+
if (freshWorkspace)
|
|
820
|
+
tui.activity.pushCard("created .dawg/ · add it to .gitignore", {
|
|
821
|
+
tone: "info",
|
|
822
|
+
});
|
|
584
823
|
if (attachNotice)
|
|
585
824
|
tui.activity.pushCard(attachNotice, {
|
|
586
825
|
tone: "info",
|
|
@@ -589,45 +828,124 @@ async function runInteractive(): Promise<void> {
|
|
|
589
828
|
});
|
|
590
829
|
if (port.status !== "file session")
|
|
591
830
|
tui.activity.pushCard(port.status, { tone: "info" });
|
|
831
|
+
if (await isProject(process.cwd()))
|
|
832
|
+
projectSync = startProjectSync(syncHost());
|
|
592
833
|
void currentProvider().then(() => tick(true));
|
|
834
|
+
tickUi = () => tick(true);
|
|
835
|
+
reportSampleProblems(score);
|
|
836
|
+
// Input arrives through a detachable listener (not `for await`), so /login
|
|
837
|
+
// can hand the terminal to an interactive shell flow and take it back.
|
|
838
|
+
const inbox: string[] = [];
|
|
839
|
+
let wake: (() => void) | undefined;
|
|
840
|
+
const onData = (chunk: Buffer | string) => {
|
|
841
|
+
inbox.push(String(chunk));
|
|
842
|
+
wake?.();
|
|
843
|
+
};
|
|
844
|
+
const onEnd = () => {
|
|
845
|
+
inbox.push("\u0000eof");
|
|
846
|
+
wake?.();
|
|
847
|
+
};
|
|
848
|
+
stdin.on("data", onData);
|
|
849
|
+
stdin.on("end", onEnd);
|
|
850
|
+
async function* chunks(): AsyncGenerator<string> {
|
|
851
|
+
for (;;) {
|
|
852
|
+
while (inbox.length === 0)
|
|
853
|
+
await new Promise<void>((resolve) => (wake = resolve));
|
|
854
|
+
wake = undefined;
|
|
855
|
+
const next = inbox.shift()!;
|
|
856
|
+
if (next === "\u0000eof") return;
|
|
857
|
+
yield next;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
handoff = async <T>(flow: () => Promise<T>): Promise<T> => {
|
|
861
|
+
if (screenSuspended) return flow();
|
|
862
|
+
screenSuspended = true;
|
|
863
|
+
clearInterval(timer);
|
|
864
|
+
stdin.off("data", onData);
|
|
865
|
+
stdin.pause();
|
|
866
|
+
stdin.setRawMode?.(false);
|
|
867
|
+
// Leave the alternate screen; restore the cursor and plain paste.
|
|
868
|
+
stdout.write(`${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
|
|
869
|
+
try {
|
|
870
|
+
return await flow();
|
|
871
|
+
} finally {
|
|
872
|
+
stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J`);
|
|
873
|
+
stdin.setRawMode?.(true);
|
|
874
|
+
stdin.on("data", onData);
|
|
875
|
+
stdin.resume();
|
|
876
|
+
screenSuspended = false;
|
|
877
|
+
timer = setInterval(tick, tui.frameIntervalMs);
|
|
878
|
+
tui.invalidate();
|
|
879
|
+
tick(true);
|
|
880
|
+
}
|
|
881
|
+
};
|
|
593
882
|
tick(true);
|
|
594
883
|
try {
|
|
595
884
|
let exiting = false;
|
|
596
|
-
for await (const
|
|
597
|
-
const text = String(chunk);
|
|
885
|
+
for await (const text of chunks()) {
|
|
598
886
|
// A read that is exactly ESC is the Esc key, not the start of a sequence.
|
|
599
887
|
const values = [
|
|
600
888
|
...inputDecoder.push(text),
|
|
601
889
|
...(text === "\u001b" ? inputDecoder.flush() : []),
|
|
602
890
|
];
|
|
603
891
|
for (const value of values) {
|
|
892
|
+
// The edit menu owns every key while it is up (Esc backs out).
|
|
893
|
+
if (typeof value === "string" && menu.open) {
|
|
894
|
+
if (tui.ui.overlay !== "picker" || tui.ui.picker?.id !== "menu")
|
|
895
|
+
menu.close();
|
|
896
|
+
else {
|
|
897
|
+
const result = menu.key(value, menuContext());
|
|
898
|
+
if (result.type === "close") tui.closePicker();
|
|
899
|
+
else if (result.type === "run") {
|
|
900
|
+
queuedPrompts.unshift(result.command);
|
|
901
|
+
void drainQueue();
|
|
902
|
+
}
|
|
903
|
+
if (result.type !== "pass") {
|
|
904
|
+
refreshMenu();
|
|
905
|
+
tick(true);
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
// Ctrl-K opens the menu on an empty prompt (in play mode too); with
|
|
911
|
+
// text it keeps its kill-to-end-of-line meaning.
|
|
912
|
+
if (
|
|
913
|
+
value === "\u000b" &&
|
|
914
|
+
prompt.value.length === 0 &&
|
|
915
|
+
tui.ui.overlay === undefined
|
|
916
|
+
) {
|
|
917
|
+
openMenu();
|
|
918
|
+
tick(true);
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
if (typeof value === "string" && play?.on && playKey(value)) {
|
|
922
|
+
tick(true);
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
// Ctrl-P enters play mode. A bare `p` would steal the first letter of
|
|
926
|
+
// `pan`, `pattern`, `play` and every prose request starting with p.
|
|
927
|
+
if (value === "\u0010" && tui.ui.overlay === undefined) {
|
|
928
|
+
void enterPlay()
|
|
929
|
+
.then((outcome) => receipt(outcome))
|
|
930
|
+
.finally(() => tick(true));
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
604
933
|
// The prompt is always focused, so ordinary `q` must remain typeable in
|
|
605
934
|
// requests (for example, "quiet hi-hat"). Ctrl-C is the unambiguous
|
|
606
935
|
// shell exit key; Ctrl-Q is reserved for prompt mode switching.
|
|
607
936
|
// Keep the empty-prompt space shortcut for transport, while allowing
|
|
608
937
|
// ordinary spaces once a request is being composed.
|
|
609
938
|
if (value === " " && prompt.value.length === 0) {
|
|
610
|
-
await
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
},
|
|
621
|
-
},
|
|
622
|
-
score.toJSON(),
|
|
623
|
-
);
|
|
624
|
-
} catch (error) {
|
|
625
|
-
if (error instanceof SessionConflictError)
|
|
626
|
-
tui.activity.pushCard("transport changed in another window", {
|
|
627
|
-
tone: "warning",
|
|
628
|
-
});
|
|
629
|
-
else throw error;
|
|
630
|
-
}
|
|
939
|
+
// Never await a daemon round trip here: the key loop must stay
|
|
940
|
+
// live for Esc, quit and redraws while the toggle is in flight.
|
|
941
|
+
void toggleTransport()
|
|
942
|
+
.catch((error: unknown) => {
|
|
943
|
+
tui.activity.pushCard(
|
|
944
|
+
`transport failed · ${error instanceof Error ? error.message : String(error)}`,
|
|
945
|
+
{ tone: "error" },
|
|
946
|
+
);
|
|
947
|
+
})
|
|
948
|
+
.finally(() => tick(true));
|
|
631
949
|
} else {
|
|
632
950
|
const input = tui.input(value);
|
|
633
951
|
const action = input.type === "action" ? input.action : undefined;
|
|
@@ -637,8 +955,17 @@ async function runInteractive(): Promise<void> {
|
|
|
637
955
|
(input.command === "undo" || input.command === "redo") &&
|
|
638
956
|
!agentTurn
|
|
639
957
|
) {
|
|
640
|
-
const base =
|
|
641
|
-
|
|
958
|
+
const base = baseline();
|
|
959
|
+
const command = input.command;
|
|
960
|
+
void stepHistory(command)
|
|
961
|
+
.then((outcome) => receipt(outcome, base))
|
|
962
|
+
.catch((error: unknown) => {
|
|
963
|
+
tui.activity.pushCard(
|
|
964
|
+
`${command} failed · ${error instanceof Error ? error.message : String(error)}`,
|
|
965
|
+
{ tone: "error" },
|
|
966
|
+
);
|
|
967
|
+
})
|
|
968
|
+
.finally(() => tick(true));
|
|
642
969
|
}
|
|
643
970
|
} else if (input.type === "pick") {
|
|
644
971
|
// Picker choices run as the command they stand for.
|
|
@@ -678,11 +1005,16 @@ async function runInteractive(): Promise<void> {
|
|
|
678
1005
|
if (exiting) break;
|
|
679
1006
|
}
|
|
680
1007
|
} finally {
|
|
1008
|
+
stdin.off("data", onData);
|
|
1009
|
+
stdin.off("end", onEnd);
|
|
681
1010
|
clearInterval(timer);
|
|
682
1011
|
clearInterval(presenceTimer);
|
|
1012
|
+
await projectSync?.stop();
|
|
683
1013
|
namer.dispose();
|
|
684
1014
|
stdout.off("resize", onResize);
|
|
685
1015
|
unsubscribe();
|
|
1016
|
+
await play?.exit().catch(() => undefined);
|
|
1017
|
+
await monitorEngine?.dispose().catch(() => undefined);
|
|
686
1018
|
audio.stop();
|
|
687
1019
|
await port.close();
|
|
688
1020
|
stdin.setRawMode?.(false);
|
|
@@ -691,23 +1023,84 @@ async function runInteractive(): Promise<void> {
|
|
|
691
1023
|
}
|
|
692
1024
|
}
|
|
693
1025
|
|
|
694
|
-
async function submit(prompt: string): Promise<string> {
|
|
1026
|
+
async function submit(prompt: string): Promise<string | Receipt> {
|
|
695
1027
|
const command = prompt.trim();
|
|
696
|
-
if (/^\/?help$|^\/?\?$/.test(command.toLowerCase()))
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
1028
|
+
if (/^\/?help$|^\/?\?$/.test(command.toLowerCase())) {
|
|
1029
|
+
tui.openText("help", helpLines(Math.max(40, (stdout.columns ?? 80) - 8)));
|
|
1030
|
+
return ok("help · esc closes");
|
|
1031
|
+
}
|
|
1032
|
+
if (/^\/?tracks$/i.test(command)) {
|
|
1033
|
+
const problems = await sampleProblems(score);
|
|
1034
|
+
const lines = score.tracks.map((track) => {
|
|
1035
|
+
const voices = track.sampler
|
|
1036
|
+
? Object.keys(track.sampler.voices).length
|
|
1037
|
+
: 0;
|
|
1038
|
+
const missing = problems.filter(
|
|
1039
|
+
(problem) => problem.trackId === track.id && problem.level === "error",
|
|
1040
|
+
).length;
|
|
1041
|
+
const samples = track.sampler
|
|
1042
|
+
? ` · ${voices} sample${voices === 1 ? "" : "s"}${missing ? ` · ${missing} missing` : ""}`
|
|
1043
|
+
: "";
|
|
1044
|
+
return `${track.id === requestedTrack ? "*" : " "} ${track.id} · ${track.instrument}${samples}${track.muted ? " · muted" : ""}${track.solo ? " · solo" : ""}`;
|
|
1045
|
+
});
|
|
1046
|
+
tui.openText("tracks · * focused", lines);
|
|
1047
|
+
return ok(
|
|
1048
|
+
`${lines.length} track${lines.length === 1 ? "" : "s"} · esc closes`,
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
const playCommand = command.match(/^\/play(?:\s+(on|off))?$/i);
|
|
1052
|
+
if (playCommand) {
|
|
1053
|
+
const wanted = playCommand[1]?.toLowerCase();
|
|
1054
|
+
if (wanted === "off" || (wanted === undefined && play?.on))
|
|
1055
|
+
return exitPlay();
|
|
1056
|
+
return enterPlay();
|
|
1057
|
+
}
|
|
1058
|
+
const clickCommand = command.match(/^\/click(?:\s+(.+))?$/i);
|
|
1059
|
+
if (clickCommand) {
|
|
1060
|
+
const message = playSession().clickCommand(clickCommand[1] ?? "");
|
|
1061
|
+
return message.startsWith("usage") || message.startsWith("click volume")
|
|
1062
|
+
? fail(message)
|
|
1063
|
+
: ok(message);
|
|
1064
|
+
}
|
|
1065
|
+
const countIn = command.match(/^\/count-?in\s+([0-2])$/i);
|
|
1066
|
+
if (countIn) return ok(playSession().setCountIn(Number(countIn[1])));
|
|
1067
|
+
const menuCommand = command.match(/^\/menu(?:\s+(\S+))?$/i);
|
|
1068
|
+
if (menuCommand) {
|
|
1069
|
+
const section = menuCommand[1]?.toLowerCase();
|
|
1070
|
+
if (section && !(MENU_SECTIONS as readonly string[]).includes(section))
|
|
1071
|
+
return fail(`/menu [${MENU_SECTIONS.join("|")}]`);
|
|
1072
|
+
openMenu(section);
|
|
1073
|
+
return ok("menu · esc closes");
|
|
1074
|
+
}
|
|
1075
|
+
const gridCommand = command.match(/^\/grid\s+(\S+)$/i);
|
|
1076
|
+
if (gridCommand) {
|
|
1077
|
+
const message = playSession().setGrid(gridCommand[1]!);
|
|
1078
|
+
return message
|
|
1079
|
+
? ok(message)
|
|
1080
|
+
: fail("usage: /grid 1/4|1/8|1/8T|1/16|1/16T|1/32");
|
|
1081
|
+
}
|
|
705
1082
|
if (/^\/status$/i.test(command))
|
|
706
|
-
return
|
|
1083
|
+
return ok(
|
|
1084
|
+
`status · ${record.meta.name} · rev ${record.revision} · ${compositionDigest(record.composition)} · ${port.mode === "daemon" ? "shared via dawgd" : "saved locally · no daemon"}`,
|
|
1085
|
+
);
|
|
707
1086
|
if (/^\/?undo$/i.test(command)) return stepHistory("undo");
|
|
708
1087
|
if (/^\/?redo$/i.test(command)) return stepHistory("redo");
|
|
1088
|
+
const trackCommand = command.match(
|
|
1089
|
+
/^\/?(?:add\s+)?track\s+([a-z0-9._-]{1,64})$/i,
|
|
1090
|
+
);
|
|
1091
|
+
if (trackCommand) return focusTrack(trackCommand[1]!.toLowerCase());
|
|
1092
|
+
const sample = parseSampleCommand(command);
|
|
1093
|
+
if (sample) return sampleCommand(sample);
|
|
709
1094
|
const sessionReply = await sessionCommand(command);
|
|
710
1095
|
if (sessionReply !== undefined) return sessionReply;
|
|
1096
|
+
const edit = parseEditCommand(command);
|
|
1097
|
+
if (edit) {
|
|
1098
|
+
await materializeDraft();
|
|
1099
|
+
const result = applyEditCommand(score, requestedTrack, edit);
|
|
1100
|
+
if (result.next && result.kind)
|
|
1101
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1102
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1103
|
+
}
|
|
711
1104
|
const music = parseMusicCommand(command);
|
|
712
1105
|
if (music) {
|
|
713
1106
|
await materializeDraft();
|
|
@@ -716,7 +1109,7 @@ async function submit(prompt: string): Promise<string> {
|
|
|
716
1109
|
);
|
|
717
1110
|
if (result.next && result.kind)
|
|
718
1111
|
await commitScore(result.next, result.kind, result.payload);
|
|
719
|
-
return result.message;
|
|
1112
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
720
1113
|
}
|
|
721
1114
|
const exportCommand = command.match(/^\/?export\s+([^\s]+)$/i);
|
|
722
1115
|
if (exportCommand) {
|
|
@@ -731,33 +1124,56 @@ async function submit(prompt: string): Promise<string> {
|
|
|
731
1124
|
await commitScore(imported, "score.import", { path: importCommand[1] });
|
|
732
1125
|
return `imported · ${importCommand[1]}`;
|
|
733
1126
|
}
|
|
734
|
-
const
|
|
735
|
-
if (
|
|
736
|
-
|
|
737
|
-
if (
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
1127
|
+
const modelMatch = prompt.trim().match(/^\/model(?:\s+(\S+))?\s*$/i);
|
|
1128
|
+
if (modelMatch) {
|
|
1129
|
+
const selection = await currentProvider();
|
|
1130
|
+
if (!modelMatch[1]) {
|
|
1131
|
+
if (selection.kind === "offline")
|
|
1132
|
+
return `model · none · ${selection.reason}`;
|
|
1133
|
+
tui.activity.setSpinner("models");
|
|
1134
|
+
try {
|
|
1135
|
+
const items = await modelPickerItems(selection);
|
|
1136
|
+
if (items.length === 0)
|
|
1137
|
+
return `model · ${providerName} · no other models listed`;
|
|
745
1138
|
tui.openPicker({
|
|
746
|
-
id: "
|
|
747
|
-
title:
|
|
1139
|
+
id: "model",
|
|
1140
|
+
title: `model · ${providerName}`,
|
|
748
1141
|
items,
|
|
1142
|
+
filterable: true,
|
|
1143
|
+
index: Math.max(
|
|
1144
|
+
0,
|
|
1145
|
+
items.findIndex((item) => item.current),
|
|
1146
|
+
),
|
|
749
1147
|
});
|
|
750
|
-
return `
|
|
1148
|
+
return `model · ${providerName} · pick one (or /model <alias>)`;
|
|
1149
|
+
} finally {
|
|
1150
|
+
tui.activity.setSpinner(undefined);
|
|
751
1151
|
}
|
|
752
|
-
} finally {
|
|
753
|
-
tui.activity.setSpinner(undefined);
|
|
754
1152
|
}
|
|
755
|
-
|
|
1153
|
+
const receiptText = await tuiSetModel(modelMatch[1]);
|
|
1154
|
+
provider = undefined;
|
|
1155
|
+
await currentProvider();
|
|
1156
|
+
return receiptText;
|
|
1157
|
+
}
|
|
1158
|
+
if (/^\/login\b/i.test(prompt.trim())) {
|
|
1159
|
+
const parsed = tuiLoginArgs(prompt.trim());
|
|
1160
|
+
if (typeof parsed === "string") return `login · ${parsed}`;
|
|
1161
|
+
await handoff(() =>
|
|
1162
|
+
runTuiLogin(
|
|
1163
|
+
parsed.target === "auto" ? "pick" : parsed.target,
|
|
1164
|
+
parsed.options,
|
|
1165
|
+
),
|
|
1166
|
+
);
|
|
1167
|
+
provider = undefined;
|
|
1168
|
+
await currentProvider();
|
|
1169
|
+
return providerName === "offline"
|
|
1170
|
+
? "login · not signed in · direct commands still work"
|
|
1171
|
+
: `signed in · ${providerName}`;
|
|
756
1172
|
}
|
|
757
|
-
if (/^\/(
|
|
1173
|
+
if (/^\/(logout|auth)\b/i.test(prompt.trim())) {
|
|
758
1174
|
tui.activity.setSpinner(prompt.trim().split(/\s+/)[0]!.slice(1));
|
|
759
1175
|
try {
|
|
760
|
-
const lines = await tuiAuthCommand(prompt.trim()
|
|
1176
|
+
const lines = await tuiAuthCommand(prompt.trim());
|
|
761
1177
|
provider = undefined;
|
|
762
1178
|
await currentProvider();
|
|
763
1179
|
for (const line of lines.slice(0, -1))
|
|
@@ -767,9 +1183,23 @@ async function submit(prompt: string): Promise<string> {
|
|
|
767
1183
|
tui.activity.setSpinner(undefined);
|
|
768
1184
|
}
|
|
769
1185
|
}
|
|
1186
|
+
// `/model` belongs to its own handler above; every other slash word that
|
|
1187
|
+
// reached here is unknown or misused, and never a question for the agent.
|
|
1188
|
+
if (command.startsWith("/") && !/^\/model\b/i.test(command)) {
|
|
1189
|
+
const hint = usageHint(command);
|
|
1190
|
+
return fail(
|
|
1191
|
+
hint
|
|
1192
|
+
? `${truncateForCard(command)} · ${hint}`
|
|
1193
|
+
: `unknown command ${command.split(/\s+/)[0]} · /help`,
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
770
1196
|
const parsed = parsePrompt(prompt);
|
|
771
1197
|
if (!parsed) {
|
|
772
|
-
|
|
1198
|
+
// A known verb with bad arguments gets usage, not a model call.
|
|
1199
|
+
const hint = usageHint(command);
|
|
1200
|
+
if (hint) return fail(`${truncateForCard(command)} · ${hint}`);
|
|
1201
|
+
if (process.env.DAWG_AI === "0")
|
|
1202
|
+
return fail(`unrecognized · ${truncateForCard(command)} · /help`);
|
|
773
1203
|
await materializeDraft();
|
|
774
1204
|
return runAgent(prompt);
|
|
775
1205
|
}
|
|
@@ -791,7 +1221,7 @@ async function submit(prompt: string): Promise<string> {
|
|
|
791
1221
|
);
|
|
792
1222
|
} catch (error) {
|
|
793
1223
|
if (error instanceof SessionConflictError)
|
|
794
|
-
return "transport changed in another window";
|
|
1224
|
+
return warn("transport changed in another window");
|
|
795
1225
|
throw error;
|
|
796
1226
|
}
|
|
797
1227
|
return parsed.action;
|
|
@@ -802,20 +1232,7 @@ async function submit(prompt: string): Promise<string> {
|
|
|
802
1232
|
clock.setTempo?.(next.tempoBpm);
|
|
803
1233
|
return `tempo · ${next.tempoBpm} BPM`;
|
|
804
1234
|
}
|
|
805
|
-
if (parsed.type === "add-track")
|
|
806
|
-
if (score.tracks.some((track) => track.id === parsed.trackId))
|
|
807
|
-
return `track exists · ${parsed.trackId}`;
|
|
808
|
-
const next = applyScoreOperation(score, {
|
|
809
|
-
type: "addTrack",
|
|
810
|
-
track: {
|
|
811
|
-
id: parsed.trackId,
|
|
812
|
-
name: parsed.trackId,
|
|
813
|
-
instrument: isDrumInstrument(parsed.trackId) ? "kit" : "sine",
|
|
814
|
-
},
|
|
815
|
-
});
|
|
816
|
-
await commitScore(next, "track.create", { trackId: parsed.trackId });
|
|
817
|
-
return `track created · ${parsed.trackId}`;
|
|
818
|
-
}
|
|
1235
|
+
if (parsed.type === "add-track") return focusTrack(parsed.trackId);
|
|
819
1236
|
if (parsed.type === "set-bars" || parsed.type === "extend-bars") {
|
|
820
1237
|
const bars =
|
|
821
1238
|
parsed.type === "set-bars"
|
|
@@ -940,7 +1357,7 @@ async function submit(prompt: string): Promise<string> {
|
|
|
940
1357
|
latest,
|
|
941
1358
|
{
|
|
942
1359
|
kind: "score.operation",
|
|
943
|
-
payload: { operation
|
|
1360
|
+
payload: { operation },
|
|
944
1361
|
},
|
|
945
1362
|
next.toJSON(),
|
|
946
1363
|
);
|
|
@@ -951,7 +1368,151 @@ async function submit(prompt: string): Promise<string> {
|
|
|
951
1368
|
if (!(error instanceof SessionConflictError)) throw error;
|
|
952
1369
|
}
|
|
953
1370
|
}
|
|
954
|
-
return "session busy
|
|
1371
|
+
return warn("session busy · retry the note");
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* `/track <name>`: focus `trackId` in this window, creating it when it is
|
|
1376
|
+
* not in the score yet. A track another live window has focused stays
|
|
1377
|
+
* theirs; focus goes through the port so presence is right everywhere.
|
|
1378
|
+
*/
|
|
1379
|
+
async function focusTrack(trackId: string): Promise<Receipt> {
|
|
1380
|
+
if (trackId === requestedTrack && !draftTrack)
|
|
1381
|
+
return warn(`already on ${trackId}`);
|
|
1382
|
+
const clients = await port.presence().catch(() => []);
|
|
1383
|
+
if (
|
|
1384
|
+
clients.some(
|
|
1385
|
+
(client) =>
|
|
1386
|
+
client.clientId !== port.clientId && client.focusedTrackId === trackId,
|
|
1387
|
+
)
|
|
1388
|
+
)
|
|
1389
|
+
return warn(`${trackId} is open in another window`);
|
|
1390
|
+
const exists = score.tracks.some((track) => track.id === trackId);
|
|
1391
|
+
if (!exists) {
|
|
1392
|
+
const next = applyScoreOperation(score, {
|
|
1393
|
+
type: "addTrack",
|
|
1394
|
+
track: {
|
|
1395
|
+
id: trackId,
|
|
1396
|
+
name: trackId,
|
|
1397
|
+
instrument: isDrumInstrument(trackId) ? "kit" : "sine",
|
|
1398
|
+
},
|
|
1399
|
+
});
|
|
1400
|
+
await commitScore(next, "track.create", { trackId });
|
|
1401
|
+
}
|
|
1402
|
+
await port.focus(trackId);
|
|
1403
|
+
requestedTrack = trackId;
|
|
1404
|
+
draftTrack = false;
|
|
1405
|
+
return ok(exists ? `track · ${trackId}` : `track created · ${trackId}`);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
let sampleLibrary: SampleLibrary | undefined;
|
|
1409
|
+
let reportedSampleProblems = "";
|
|
1410
|
+
|
|
1411
|
+
/** Decode every sampler voice (cached) and return what failed to load. */
|
|
1412
|
+
async function sampleProblems(
|
|
1413
|
+
value: TrackScore,
|
|
1414
|
+
): Promise<readonly SampleProblem[]> {
|
|
1415
|
+
if (!hasSamplerTracks(value)) return [];
|
|
1416
|
+
sampleLibrary ??= new SampleLibrary({ projectRoot: process.cwd() });
|
|
1417
|
+
try {
|
|
1418
|
+
const bank = await sampleLibrary.load(value);
|
|
1419
|
+
liveSampleBank = bank;
|
|
1420
|
+
return bank.problems;
|
|
1421
|
+
} catch (error) {
|
|
1422
|
+
return [
|
|
1423
|
+
{
|
|
1424
|
+
trackId: "",
|
|
1425
|
+
voice: "",
|
|
1426
|
+
src: "",
|
|
1427
|
+
level: "error",
|
|
1428
|
+
message: `samples · ${error instanceof Error ? error.message : String(error)} · check the sample files`,
|
|
1429
|
+
},
|
|
1430
|
+
];
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/** Surface sample load problems once per distinct set, as receipts. */
|
|
1435
|
+
function reportSampleProblems(value: TrackScore): void {
|
|
1436
|
+
if (!hasSamplerTracks(value)) return;
|
|
1437
|
+
void sampleProblems(value).then((problems) => {
|
|
1438
|
+
const key = problems.map((problem) => problem.message).join("\n");
|
|
1439
|
+
if (key === reportedSampleProblems) return;
|
|
1440
|
+
reportedSampleProblems = key;
|
|
1441
|
+
for (const problem of problems.slice(0, 3))
|
|
1442
|
+
tui.activity.pushCard(`sample ${problem.voice} · ${problem.message}`, {
|
|
1443
|
+
tone: problem.level === "error" ? "error" : "warning",
|
|
1444
|
+
trackId: problem.trackId || undefined,
|
|
1445
|
+
});
|
|
1446
|
+
if (problems.length > 3)
|
|
1447
|
+
tui.activity.pushCard(
|
|
1448
|
+
`${problems.length - 3} more sample problems · /tracks`,
|
|
1449
|
+
{
|
|
1450
|
+
tone: "warning",
|
|
1451
|
+
},
|
|
1452
|
+
);
|
|
1453
|
+
tickUi();
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
async function sampleCommand(
|
|
1458
|
+
command: NonNullable<ReturnType<typeof parseSampleCommand>>,
|
|
1459
|
+
): Promise<Receipt> {
|
|
1460
|
+
if (command.kind === "list") {
|
|
1461
|
+
const track = score.tracks.find((item) => item.id === requestedTrack);
|
|
1462
|
+
const lines = listSampleVoices(track);
|
|
1463
|
+
if (lines.length === 0)
|
|
1464
|
+
return warn(
|
|
1465
|
+
`sample · ${requestedTrack} has no samples · /sample <path> [as <voice>]`,
|
|
1466
|
+
);
|
|
1467
|
+
tui.openText(`samples · ${requestedTrack}`, lines);
|
|
1468
|
+
return ok(
|
|
1469
|
+
`${lines.length} sample${lines.length === 1 ? "" : "s"} · esc closes`,
|
|
1470
|
+
);
|
|
1471
|
+
}
|
|
1472
|
+
await materializeDraft();
|
|
1473
|
+
const trackId = samplerTarget(score, requestedTrack);
|
|
1474
|
+
const voice =
|
|
1475
|
+
command.voice ?? freeVoiceName(score, trackId, voiceNameFrom(command.path));
|
|
1476
|
+
let placed;
|
|
1477
|
+
try {
|
|
1478
|
+
placed = await placeSampleFile({
|
|
1479
|
+
projectRoot: process.cwd(),
|
|
1480
|
+
cwd: process.cwd(),
|
|
1481
|
+
input: command.path,
|
|
1482
|
+
score,
|
|
1483
|
+
trackId,
|
|
1484
|
+
voice,
|
|
1485
|
+
});
|
|
1486
|
+
} catch (error) {
|
|
1487
|
+
if (error instanceof SamplePlacementError)
|
|
1488
|
+
return fail(`sample · ${error.message} · check the path and retry`);
|
|
1489
|
+
throw error;
|
|
1490
|
+
}
|
|
1491
|
+
const result = addSampleVoice(score, trackId, voice, {
|
|
1492
|
+
src: placed.src,
|
|
1493
|
+
sha256: placed.sha256,
|
|
1494
|
+
});
|
|
1495
|
+
if (!result.ok) return fail(result.message);
|
|
1496
|
+
// Decode before committing so a bad file is a receipt, not a silent voice.
|
|
1497
|
+
const problems = (await sampleProblems(result.next)).filter(
|
|
1498
|
+
(problem) => problem.trackId === trackId && problem.voice === voice,
|
|
1499
|
+
);
|
|
1500
|
+
if (problems.some((problem) => problem.level === "error"))
|
|
1501
|
+
return fail(`sample · ${problems[0]!.message}`);
|
|
1502
|
+
await commitScore(result.next, "sample.add", {
|
|
1503
|
+
trackId,
|
|
1504
|
+
voice,
|
|
1505
|
+
src: placed.src,
|
|
1506
|
+
});
|
|
1507
|
+
if (trackId !== requestedTrack) {
|
|
1508
|
+
await port.focus(trackId);
|
|
1509
|
+
requestedTrack = trackId;
|
|
1510
|
+
draftTrack = false;
|
|
1511
|
+
}
|
|
1512
|
+
await projectSync?.flushScore();
|
|
1513
|
+
return ok(
|
|
1514
|
+
`${result.message}${placed.copied ? ` · copied to ${placed.src}` : ""}`,
|
|
1515
|
+
);
|
|
955
1516
|
}
|
|
956
1517
|
|
|
957
1518
|
async function readLoopFile(path: string): Promise<string> {
|
|
@@ -1002,7 +1563,12 @@ function adoptMeta(meta: typeof record.meta): void {
|
|
|
1002
1563
|
record = { ...record, meta };
|
|
1003
1564
|
if (meta.name !== announcedName) {
|
|
1004
1565
|
announcedName = meta.name;
|
|
1005
|
-
tui.activity.pushCard(
|
|
1566
|
+
tui.activity.pushCard(
|
|
1567
|
+
meta.nameSource === "auto"
|
|
1568
|
+
? `${meta.name} (auto-named) · rename with /rename <name>`
|
|
1569
|
+
: `session · ${meta.name}`,
|
|
1570
|
+
{ tone: "info" },
|
|
1571
|
+
);
|
|
1006
1572
|
}
|
|
1007
1573
|
}
|
|
1008
1574
|
|
|
@@ -1022,7 +1588,9 @@ function makeNamer(): AutoNamer {
|
|
|
1022
1588
|
}
|
|
1023
1589
|
|
|
1024
1590
|
/** `/sessions`, `/resume`, `/rename`, `/fork`; undefined when not one. */
|
|
1025
|
-
async function sessionCommand(
|
|
1591
|
+
async function sessionCommand(
|
|
1592
|
+
command: string,
|
|
1593
|
+
): Promise<string | Receipt | undefined> {
|
|
1026
1594
|
const match = command.match(/^\/(sessions|resume|rename|fork)(?:\s+(.*))?$/i);
|
|
1027
1595
|
if (!match) return undefined;
|
|
1028
1596
|
const verb = match[1]!.toLowerCase();
|
|
@@ -1030,7 +1598,9 @@ async function sessionCommand(command: string): Promise<string | undefined> {
|
|
|
1030
1598
|
const workspace = process.cwd();
|
|
1031
1599
|
if (verb === "rename") {
|
|
1032
1600
|
if (!arg)
|
|
1033
|
-
return
|
|
1601
|
+
return record.meta.nameSource === "auto"
|
|
1602
|
+
? `${record.meta.name} (auto-named) · rename with /rename <name>`
|
|
1603
|
+
: `${record.meta.name} (named by you) · /rename --auto to auto-name`;
|
|
1034
1604
|
if (arg === "--auto") {
|
|
1035
1605
|
const result = await port.updateMeta({
|
|
1036
1606
|
nameSource: "auto",
|
|
@@ -1051,7 +1621,7 @@ async function sessionCommand(command: string): Promise<string | undefined> {
|
|
|
1051
1621
|
const sessions = await listSessions(workspace);
|
|
1052
1622
|
if (verb === "resume" && !arg) {
|
|
1053
1623
|
const readable = sessions.filter((session) => !session.error).slice(0, 64);
|
|
1054
|
-
if (readable.length === 0) return "no sessions to resume";
|
|
1624
|
+
if (readable.length === 0) return warn("no sessions to resume");
|
|
1055
1625
|
const current = readable.findIndex(
|
|
1056
1626
|
(session) => session.sessionId === record.sessionId,
|
|
1057
1627
|
);
|
|
@@ -1064,35 +1634,48 @@ async function sessionCommand(command: string): Promise<string | undefined> {
|
|
|
1064
1634
|
})),
|
|
1065
1635
|
index: Math.max(0, current),
|
|
1066
1636
|
});
|
|
1067
|
-
return "resume ·
|
|
1637
|
+
return "resume · 1-9 shown · /resume <name|id>";
|
|
1068
1638
|
}
|
|
1069
1639
|
if (verb === "sessions") {
|
|
1070
|
-
const lines = pickerLines(
|
|
1071
|
-
|
|
1640
|
+
const lines = pickerLines(
|
|
1641
|
+
sessions,
|
|
1642
|
+
record.sessionId,
|
|
1643
|
+
(session) => formatSessionLine(session, sessions),
|
|
1644
|
+
MAX_LISTED_SESSIONS,
|
|
1645
|
+
);
|
|
1646
|
+
tui.openText("sessions · * current · /resume <n>", lines);
|
|
1647
|
+
return ok(
|
|
1648
|
+
`${lines.length} session${lines.length === 1 ? "" : "s"} · esc closes`,
|
|
1072
1649
|
);
|
|
1073
|
-
for (const line of lines) tui.activity.pushCard(line, { tone: "info" });
|
|
1074
|
-
return `${lines.length} sessions`;
|
|
1075
1650
|
}
|
|
1076
|
-
if (agentTurn) return "agent busy
|
|
1651
|
+
if (agentTurn) return warn("agent busy · finish or Esc first");
|
|
1077
1652
|
if (verb === "fork") {
|
|
1078
1653
|
const forked = await forkSession(workspace, record, arg || undefined);
|
|
1079
1654
|
await switchSession(forked.sessionId);
|
|
1080
1655
|
return `forked · ${forked.meta.name}`;
|
|
1081
1656
|
}
|
|
1082
1657
|
const readable = sessions.filter((session) => !session.error);
|
|
1083
|
-
|
|
1084
|
-
const
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1658
|
+
// Any list index works, not only the digits the picker binds.
|
|
1659
|
+
const index = /^\d+$/.test(arg) ? Number(arg) - 1 : -1;
|
|
1660
|
+
let sessionId = readable[index]?.sessionId;
|
|
1661
|
+
if (sessionId === undefined)
|
|
1662
|
+
try {
|
|
1663
|
+
sessionId = await resolveSessionArg(workspace, arg, sessions);
|
|
1664
|
+
} catch (error) {
|
|
1665
|
+
if (!(error instanceof SessionLookupError)) throw error;
|
|
1666
|
+
return fail(error.message.replace(" · dawg sessions", " · /sessions"));
|
|
1667
|
+
}
|
|
1668
|
+
if (sessionId === record.sessionId)
|
|
1669
|
+
return warn(`already in ${record.meta.name}`);
|
|
1090
1670
|
await switchSession(sessionId);
|
|
1091
1671
|
return `resumed · ${record.meta.name} · ${requestedTrack}${draftTrack ? " (new)" : ""}`;
|
|
1092
1672
|
}
|
|
1093
1673
|
|
|
1094
1674
|
/** Leaves the current session and attaches this window to `sessionId`. */
|
|
1095
1675
|
async function switchSession(sessionId: string): Promise<void> {
|
|
1676
|
+
// Play mode belongs to the old session's engine and track.
|
|
1677
|
+
await play?.exit();
|
|
1678
|
+
play = undefined;
|
|
1096
1679
|
if (clock.playing) await setTransport("pause");
|
|
1097
1680
|
namer.dispose();
|
|
1098
1681
|
await port.close();
|
|
@@ -1123,29 +1706,237 @@ async function switchSession(sessionId: string): Promise<void> {
|
|
|
1123
1706
|
});
|
|
1124
1707
|
}
|
|
1125
1708
|
|
|
1709
|
+
function liveEngine(): LiveEngine | undefined {
|
|
1710
|
+
if (audio instanceof AudioEngine) return audio;
|
|
1711
|
+
monitorEngine ??= new AudioEngine({ projectRoot: process.cwd() });
|
|
1712
|
+
return monitorEngine;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/** The play session for the focused track (re-made when focus moves). */
|
|
1716
|
+
function menuContext(): MenuContext {
|
|
1717
|
+
const session = play;
|
|
1718
|
+
return {
|
|
1719
|
+
score,
|
|
1720
|
+
trackId: requestedTrack,
|
|
1721
|
+
playing: clock.playing,
|
|
1722
|
+
grid: session?.grid ?? DEFAULT_GRID,
|
|
1723
|
+
grids: GRIDS.map((grid) => grid.label),
|
|
1724
|
+
clickOn: session?.clickOn ?? false,
|
|
1725
|
+
countInBars: session?.countInBars ?? 1,
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
function openMenu(section?: string): void {
|
|
1730
|
+
menu.show(menuContext(), section);
|
|
1731
|
+
refreshMenu();
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
function refreshMenu(): void {
|
|
1735
|
+
if (!menu.open) return;
|
|
1736
|
+
if (tui.ui.overlay !== undefined && tui.ui.picker?.id !== "menu") {
|
|
1737
|
+
// Another overlay (help, a picker) replaced the menu.
|
|
1738
|
+
menu.close();
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
const view = menu.view(menuContext());
|
|
1742
|
+
tui.openPicker({
|
|
1743
|
+
id: "menu",
|
|
1744
|
+
title: view.title,
|
|
1745
|
+
items: view.items.length
|
|
1746
|
+
? view.items
|
|
1747
|
+
: [{ label: "no matches", value: "none" }],
|
|
1748
|
+
index: view.index,
|
|
1749
|
+
hint: view.hint,
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
function playSession(): PlaySession {
|
|
1754
|
+
if (play && play.track === requestedTrack) return play;
|
|
1755
|
+
const previous = play;
|
|
1756
|
+
play = new PlaySession(playHost(), {
|
|
1757
|
+
clickOn: previous?.clickOn,
|
|
1758
|
+
clickVolume: previous?.clickVolume,
|
|
1759
|
+
});
|
|
1760
|
+
if (previous) {
|
|
1761
|
+
play.countInBars = previous.countInBars;
|
|
1762
|
+
play.grid = previous.grid;
|
|
1763
|
+
}
|
|
1764
|
+
return play;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
async function enterPlay(): Promise<Receipt> {
|
|
1768
|
+
if (play?.on) return ok(`play · ${play.track} · esc leaves`);
|
|
1769
|
+
await materializeDraft();
|
|
1770
|
+
const session = playSession();
|
|
1771
|
+
await session.enter();
|
|
1772
|
+
if (hasSamplerTracks(score)) void sampleProblems(score);
|
|
1773
|
+
return ok(
|
|
1774
|
+
`play · ${session.track} · ${session.keyboard.range} · z/x octave · r record · m click · esc leaves`,
|
|
1775
|
+
);
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
async function exitPlay(): Promise<Receipt> {
|
|
1779
|
+
if (!play?.on) return ok("play mode is off");
|
|
1780
|
+
await play.exit();
|
|
1781
|
+
return ok("play off");
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
/**
|
|
1785
|
+
* One key in play mode. True when play mode consumed it; false sends it on
|
|
1786
|
+
* to the normal bindings (overlays, a command being typed, Ctrl-C, arrows).
|
|
1787
|
+
*/
|
|
1788
|
+
function playKey(value: string): boolean {
|
|
1789
|
+
const session = play;
|
|
1790
|
+
if (!session?.on) return false;
|
|
1791
|
+
if (tui.ui.overlay !== undefined) return false;
|
|
1792
|
+
// `/play off`, `/click 50%`: once a command is being typed, keys are text.
|
|
1793
|
+
if (prompt.value.length > 0) return false;
|
|
1794
|
+
if (value === "/") return false;
|
|
1795
|
+
if (value === "\u0003") return false;
|
|
1796
|
+
const result = session.press(value);
|
|
1797
|
+
if (result.type === "handled") return true;
|
|
1798
|
+
if (result.type === "command") {
|
|
1799
|
+
if (result.command === "exit")
|
|
1800
|
+
void exitPlay().then((outcome) => receipt(outcome));
|
|
1801
|
+
else if (result.command === "transport") {
|
|
1802
|
+
if (!session.startWithCountIn())
|
|
1803
|
+
void toggleTransport().catch((error: unknown) =>
|
|
1804
|
+
tui.activity.pushError(
|
|
1805
|
+
`transport failed · ${error instanceof Error ? error.message : String(error)}`,
|
|
1806
|
+
),
|
|
1807
|
+
);
|
|
1808
|
+
} else if (result.command === "menu") return false;
|
|
1809
|
+
return true;
|
|
1810
|
+
}
|
|
1811
|
+
// Unmapped: printable keys are swallowed so a stray letter never lands in
|
|
1812
|
+
// the prompt; control keys (Enter, arrows, Ctrl-Z) keep their bindings.
|
|
1813
|
+
return value.length === 1 && value >= " " && value !== "\u007f";
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
function playHost() {
|
|
1817
|
+
return {
|
|
1818
|
+
score: () => score,
|
|
1819
|
+
trackId: () => requestedTrack,
|
|
1820
|
+
now: () => performance.now(),
|
|
1821
|
+
playing: () => clock.playing,
|
|
1822
|
+
beatAt: (ms: number) => clock.beatAt(ms),
|
|
1823
|
+
engine: liveEngine,
|
|
1824
|
+
samples: () => liveSampleBank,
|
|
1825
|
+
async commit(
|
|
1826
|
+
next: TrackScore,
|
|
1827
|
+
kind: string,
|
|
1828
|
+
payload: Record<string, unknown>,
|
|
1829
|
+
): Promise<void> {
|
|
1830
|
+
const operations = (payload.operations ?? []) as ScoreOperation[];
|
|
1831
|
+
let target = next;
|
|
1832
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1833
|
+
try {
|
|
1834
|
+
record = await port.appendOperations(
|
|
1835
|
+
record,
|
|
1836
|
+
{ kind, payload },
|
|
1837
|
+
operations,
|
|
1838
|
+
target.toJSON(),
|
|
1839
|
+
);
|
|
1840
|
+
score = scoreFromJSON(record.composition);
|
|
1841
|
+
if (clock.playing) void audio.play(score);
|
|
1842
|
+
projectSync?.scoreChanged(score);
|
|
1843
|
+
return;
|
|
1844
|
+
} catch (error) {
|
|
1845
|
+
if (!(error instanceof SessionConflictError)) throw error;
|
|
1846
|
+
record = await port.load();
|
|
1847
|
+
score = scoreFromJSON(record.composition);
|
|
1848
|
+
target = operations.reduce(
|
|
1849
|
+
(value, operation) => applyScoreOperation(value, operation),
|
|
1850
|
+
score,
|
|
1851
|
+
);
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
throw new Error("session busy");
|
|
1855
|
+
},
|
|
1856
|
+
async startTransport(beat: number): Promise<void> {
|
|
1857
|
+
if (port.mode === "daemon") {
|
|
1858
|
+
await port.transport("play", { beat });
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
clock.sync(beat, false);
|
|
1862
|
+
await setTransport("play");
|
|
1863
|
+
},
|
|
1864
|
+
async stopTransport(): Promise<void> {
|
|
1865
|
+
await setTransport("pause");
|
|
1866
|
+
},
|
|
1867
|
+
card(text: string, tone: "info" | "success" | "warning" | "error") {
|
|
1868
|
+
if (tone === "error") tui.activity.pushError(text);
|
|
1869
|
+
else
|
|
1870
|
+
tui.activity.pushCard(text, {
|
|
1871
|
+
tone,
|
|
1872
|
+
trackId: requestedTrack,
|
|
1873
|
+
resultRevision: tone === "success" ? record.revision : undefined,
|
|
1874
|
+
});
|
|
1875
|
+
},
|
|
1876
|
+
newNoteId: () => randomUUID().slice(0, 12),
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1126
1880
|
async function commitScore(
|
|
1127
1881
|
next: TrackScore,
|
|
1128
1882
|
kind: string,
|
|
1129
1883
|
payload: Record<string, unknown> = {},
|
|
1130
1884
|
): Promise<void> {
|
|
1131
1885
|
if (next === score) return;
|
|
1132
|
-
record = await port.append(
|
|
1133
|
-
record,
|
|
1134
|
-
{ kind, payload: { ...payload, before: record.composition } },
|
|
1135
|
-
next.toJSON(),
|
|
1136
|
-
);
|
|
1886
|
+
record = await port.append(record, { kind, payload }, next.toJSON());
|
|
1137
1887
|
score = next;
|
|
1138
1888
|
if (clock.playing) void audio.play(score);
|
|
1889
|
+
projectSync?.scoreChanged(score);
|
|
1890
|
+
reportSampleProblems(score);
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
/** The window's side of the project file sync (see src/project/sync.ts). */
|
|
1894
|
+
function syncHost(): SyncHost {
|
|
1895
|
+
return {
|
|
1896
|
+
project: process.cwd(),
|
|
1897
|
+
current: () => score,
|
|
1898
|
+
async commit(plan, summary) {
|
|
1899
|
+
const baseRevision = record.revision;
|
|
1900
|
+
record = await port.appendOperations(
|
|
1901
|
+
record,
|
|
1902
|
+
{
|
|
1903
|
+
kind: "files.apply",
|
|
1904
|
+
payload: { summary },
|
|
1905
|
+
},
|
|
1906
|
+
plan.operations,
|
|
1907
|
+
plan.next.toJSON(),
|
|
1908
|
+
);
|
|
1909
|
+
score = scoreFromJSON(record.composition);
|
|
1910
|
+
clock.setTempo(score.tempoBpm);
|
|
1911
|
+
if (clock.playing) void audio.play(score);
|
|
1912
|
+
reportSampleProblems(score);
|
|
1913
|
+
void baseRevision;
|
|
1914
|
+
return score;
|
|
1915
|
+
},
|
|
1916
|
+
card(text, tone, hint) {
|
|
1917
|
+
if (tone === "error") tui.activity.pushError(text);
|
|
1918
|
+
else
|
|
1919
|
+
tui.activity.pushCard(text, {
|
|
1920
|
+
tone,
|
|
1921
|
+
hint,
|
|
1922
|
+
resultRevision: tone === "success" ? record.revision : undefined,
|
|
1923
|
+
});
|
|
1924
|
+
},
|
|
1925
|
+
types(state) {
|
|
1926
|
+
typesIndicator = state;
|
|
1927
|
+
},
|
|
1928
|
+
};
|
|
1139
1929
|
}
|
|
1140
1930
|
|
|
1141
|
-
async function stepHistory(direction: "undo" | "redo"): Promise<
|
|
1931
|
+
async function stepHistory(direction: "undo" | "redo"): Promise<Receipt> {
|
|
1142
1932
|
const latest = await port.load();
|
|
1143
1933
|
// A fork's undo continues into its parent's history past the fork point.
|
|
1144
1934
|
const target = historyTarget(
|
|
1935
|
+
latest.composition,
|
|
1145
1936
|
await historyEvents(process.cwd(), latest),
|
|
1146
1937
|
direction,
|
|
1147
1938
|
);
|
|
1148
|
-
if (!target) return `nothing to ${direction}
|
|
1939
|
+
if (!target) return warn(`nothing to ${direction}`);
|
|
1149
1940
|
try {
|
|
1150
1941
|
const restored = scoreFromJSON(target.composition);
|
|
1151
1942
|
record = await port.append(
|
|
@@ -1155,18 +1946,46 @@ async function stepHistory(direction: "undo" | "redo"): Promise<string> {
|
|
|
1155
1946
|
payload: {
|
|
1156
1947
|
[direction === "undo" ? "undoneRevision" : "redoneRevision"]:
|
|
1157
1948
|
target.revision,
|
|
1158
|
-
before: latest.composition,
|
|
1159
1949
|
},
|
|
1160
1950
|
},
|
|
1161
1951
|
restored.toJSON(),
|
|
1162
1952
|
);
|
|
1163
1953
|
score = restored;
|
|
1164
1954
|
if (clock.playing) void audio.play(score);
|
|
1165
|
-
return
|
|
1955
|
+
return ok(
|
|
1956
|
+
`${direction === "undo" ? "undid" : "redid"} · rev ${target.revision}`,
|
|
1957
|
+
);
|
|
1166
1958
|
} catch (error) {
|
|
1167
1959
|
if (error instanceof SessionConflictError)
|
|
1168
|
-
return `session changed
|
|
1169
|
-
return
|
|
1960
|
+
return warn(`session changed · retry ${direction}`);
|
|
1961
|
+
return fail(
|
|
1962
|
+
`${direction} failed · ${error instanceof Error ? error.message : String(error)}`,
|
|
1963
|
+
);
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
/** The space-bar toggle: flip the transport, then record it for other windows. */
|
|
1968
|
+
async function toggleTransport(): Promise<void> {
|
|
1969
|
+
await setTransport("toggle");
|
|
1970
|
+
try {
|
|
1971
|
+
record = await port.append(
|
|
1972
|
+
record,
|
|
1973
|
+
{
|
|
1974
|
+
kind: "transport",
|
|
1975
|
+
payload: {
|
|
1976
|
+
action: "toggle",
|
|
1977
|
+
playing: clock.playing,
|
|
1978
|
+
beat: clock.beatAt(),
|
|
1979
|
+
},
|
|
1980
|
+
},
|
|
1981
|
+
score.toJSON(),
|
|
1982
|
+
);
|
|
1983
|
+
} catch (error) {
|
|
1984
|
+
if (error instanceof SessionConflictError)
|
|
1985
|
+
tui.activity.pushCard("transport changed in another window", {
|
|
1986
|
+
tone: "warning",
|
|
1987
|
+
});
|
|
1988
|
+
else throw error;
|
|
1170
1989
|
}
|
|
1171
1990
|
}
|
|
1172
1991
|
|
|
@@ -1198,11 +2017,13 @@ async function setTransport(
|
|
|
1198
2017
|
* own revision through `agentHost`, so cancelling keeps every accepted change
|
|
1199
2018
|
* and never leaves a half-applied call.
|
|
1200
2019
|
*/
|
|
1201
|
-
async function runAgent(text: string): Promise<string> {
|
|
1202
|
-
if (agentTurn) return "agent busy";
|
|
2020
|
+
async function runAgent(text: string): Promise<string | Receipt> {
|
|
2021
|
+
if (agentTurn) return warn("agent busy · Esc cancels");
|
|
1203
2022
|
const selection = await currentProvider();
|
|
1204
2023
|
if (selection.kind === "offline")
|
|
1205
|
-
return
|
|
2024
|
+
return fail(
|
|
2025
|
+
`unrecognized · ${truncateForCard(text)} · ${selection.reason}`,
|
|
2026
|
+
);
|
|
1206
2027
|
const turn = { controller: new AbortController(), steering: [] as string[] };
|
|
1207
2028
|
agentTurn = turn;
|
|
1208
2029
|
reportAgentActivity(`${providerName} · thinking…`);
|
|
@@ -1213,8 +2034,7 @@ async function runAgent(text: string): Promise<string> {
|
|
|
1213
2034
|
const result = await runProviderTurn({
|
|
1214
2035
|
selection,
|
|
1215
2036
|
prompt: text,
|
|
1216
|
-
|
|
1217
|
-
host: agentHost(turn),
|
|
2037
|
+
host: agentHost(turn, selection),
|
|
1218
2038
|
signal: turn.controller.signal,
|
|
1219
2039
|
onEvent: (event) => {
|
|
1220
2040
|
if (admitting && event.type === "step" && event.step <= 1) return;
|
|
@@ -1222,6 +2042,22 @@ async function runAgent(text: string): Promise<string> {
|
|
|
1222
2042
|
admitting = false;
|
|
1223
2043
|
provider = undefined; // Re-read capabilities: now admitted.
|
|
1224
2044
|
}
|
|
2045
|
+
if (event.type === "usage") {
|
|
2046
|
+
// Subscriptions are included; API responses are priced once.
|
|
2047
|
+
if (isApiSelection(selection)) {
|
|
2048
|
+
const { type: _type, ...usage } = event;
|
|
2049
|
+
void priceForModel(selection.kind, selection.modelId, {
|
|
2050
|
+
configDir: configDir(),
|
|
2051
|
+
apiKey: selection.apiKey,
|
|
2052
|
+
})
|
|
2053
|
+
.catch(() => undefined)
|
|
2054
|
+
.then((price) => {
|
|
2055
|
+
meter.add(usage, price);
|
|
2056
|
+
tickUi();
|
|
2057
|
+
});
|
|
2058
|
+
}
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
1225
2061
|
agentEventSink(event);
|
|
1226
2062
|
},
|
|
1227
2063
|
});
|
|
@@ -1257,13 +2093,33 @@ function currentProvider(): Promise<ProviderSelection> {
|
|
|
1257
2093
|
}));
|
|
1258
2094
|
}
|
|
1259
2095
|
return provider.then((selection) => {
|
|
1260
|
-
|
|
2096
|
+
providerSnapshot = selection;
|
|
2097
|
+
providerName = providerLabel(selection);
|
|
2098
|
+
if (
|
|
2099
|
+
selection.kind === "offline" &&
|
|
2100
|
+
selection.invalidSaved &&
|
|
2101
|
+
!invalidNoticeShown
|
|
2102
|
+
) {
|
|
2103
|
+
invalidNoticeShown = true;
|
|
2104
|
+
tui.activity.pushCard(`sign-in stopped working · ${selection.reason}`, {
|
|
2105
|
+
tone: "warning",
|
|
2106
|
+
hint: "/login",
|
|
2107
|
+
});
|
|
2108
|
+
}
|
|
1261
2109
|
providerStamp.offline = selection.kind === "offline";
|
|
1262
2110
|
return selection;
|
|
1263
2111
|
});
|
|
1264
2112
|
}
|
|
1265
2113
|
|
|
1266
|
-
|
|
2114
|
+
/** Media tools write under the workspace's `tracks/<slug>/downloads/`. */
|
|
2115
|
+
function mediaServices(): MediaServices {
|
|
2116
|
+
return { runner: systemRunner, env: process.env };
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
function agentHost(
|
|
2120
|
+
turn: { steering: string[] },
|
|
2121
|
+
selection: ProviderSelection,
|
|
2122
|
+
): AgentHost {
|
|
1267
2123
|
return {
|
|
1268
2124
|
snapshot: () => ({
|
|
1269
2125
|
score,
|
|
@@ -1276,6 +2132,7 @@ function agentHost(turn: { steering: string[] }): AgentHost {
|
|
|
1276
2132
|
: event.kind;
|
|
1277
2133
|
}),
|
|
1278
2134
|
}),
|
|
2135
|
+
media: mediaServices(),
|
|
1279
2136
|
async commit(change) {
|
|
1280
2137
|
// dawgd rebases operation intents onto newer revisions when nothing
|
|
1281
2138
|
// they touch changed; the file port keeps the strict base check.
|
|
@@ -1291,7 +2148,6 @@ function agentHost(turn: { steering: string[] }): AgentHost {
|
|
|
1291
2148
|
callId: change.callId,
|
|
1292
2149
|
summary: change.summary,
|
|
1293
2150
|
operations: change.operations,
|
|
1294
|
-
before: record.composition,
|
|
1295
2151
|
},
|
|
1296
2152
|
},
|
|
1297
2153
|
change.operations,
|
|
@@ -1328,5 +2184,26 @@ function agentHost(turn: { steering: string[] }): AgentHost {
|
|
|
1328
2184
|
}
|
|
1329
2185
|
},
|
|
1330
2186
|
takeSteering: () => turn.steering.splice(0),
|
|
2187
|
+
workspace: { root: process.cwd() },
|
|
2188
|
+
// A written project source is applied before the tool result returns,
|
|
2189
|
+
// so the model reads the outcome and any type errors in the same step.
|
|
2190
|
+
async onWorkspaceWrite(path) {
|
|
2191
|
+
if (!projectSync || !/\.ts$/.test(path)) return;
|
|
2192
|
+
const outcome = await projectSync.checkFiles();
|
|
2193
|
+
const types = await typecheckProject(process.cwd());
|
|
2194
|
+
const errors = types.diagnostics.slice(0, 8).map(formatDiagnostic);
|
|
2195
|
+
return [
|
|
2196
|
+
outcome,
|
|
2197
|
+
types.ok ? "types ✓" : `types ✗ ${types.diagnostics.length}`,
|
|
2198
|
+
...errors,
|
|
2199
|
+
].join("\n");
|
|
2200
|
+
},
|
|
2201
|
+
// web_search uses the turn's own gateway or OpenRouter key; billed
|
|
2202
|
+
// searches go to the spend meter and daily ledger.
|
|
2203
|
+
web: webHostFor(
|
|
2204
|
+
isApiSelection(selection) ? selection : { kind: selection.kind },
|
|
2205
|
+
meter,
|
|
2206
|
+
tickUi,
|
|
2207
|
+
),
|
|
1331
2208
|
};
|
|
1332
2209
|
}
|