@hraness/dawg 0.0.0-stage → 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 +126 -0
- package/DAWG.md +327 -0
- package/LICENSE +21 -0
- package/README.md +213 -2
- package/core/diff.ts +249 -0
- package/core/drums.ts +102 -0
- package/core/key.ts +43 -0
- package/core/loop.ts +78 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +1388 -0
- 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 +45 -4
- package/src/agent/agent.ts +853 -0
- package/src/agent/brief.ts +160 -0
- package/src/agent/gateway.ts +441 -0
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +157 -0
- package/src/agent/planner.ts +259 -0
- package/src/agent/provider.ts +454 -0
- package/src/agent/sse.ts +114 -0
- package/src/agent/tools.ts +1373 -0
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +262 -0
- package/src/agent/xcb.ts +579 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/clock.ts +68 -0
- package/src/audio/engine.ts +841 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/lock.ts +57 -0
- package/src/audio/player.ts +134 -0
- 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 +861 -0
- package/src/auth/cli.ts +231 -0
- package/src/auth/credentials.ts +411 -0
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +1191 -0
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +207 -0
- package/src/auth/tui.ts +107 -0
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +69 -0
- package/src/commands/music.ts +461 -0
- package/src/commands/sample.ts +302 -0
- package/src/daemon.ts +31 -0
- package/src/main.ts +2209 -0
- 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 +121 -0
- package/src/session/attach.ts +181 -0
- package/src/session/client.ts +498 -0
- package/src/session/daemon.ts +740 -0
- package/src/session/delta.ts +249 -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 +430 -0
- package/src/session/port.ts +481 -0
- package/src/session/presence.ts +159 -0
- package/src/session/protocol.ts +618 -0
- package/src/session/rebase.ts +168 -0
- package/src/session/store.ts +581 -0
- 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 +364 -0
- package/tui/app.ts +1372 -0
- package/tui/drums.ts +65 -0
- package/tui/highway.ts +921 -0
- package/tui/input.ts +63 -0
- package/tui/keys.ts +102 -0
- package/tui/layers.ts +80 -0
- package/tui/play-strip.ts +143 -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
package/src/main.ts
ADDED
|
@@ -0,0 +1,2209 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join, relative, resolve } from "node:path";
|
|
5
|
+
import { stdin, stdout } from "node:process";
|
|
6
|
+
import {
|
|
7
|
+
ensureSession,
|
|
8
|
+
SessionConflictError,
|
|
9
|
+
type SessionRecord,
|
|
10
|
+
} from "./session/store.ts";
|
|
11
|
+
import { openSessionPort } from "./session/port.ts";
|
|
12
|
+
import { compositionDigest, monotonicEpochMs } from "./session/protocol.ts";
|
|
13
|
+
import {
|
|
14
|
+
formatSessionLine,
|
|
15
|
+
listSessions,
|
|
16
|
+
printSessions,
|
|
17
|
+
} from "./session/list.ts";
|
|
18
|
+
import {
|
|
19
|
+
ALL_TRACKS_OPEN_HINT,
|
|
20
|
+
attachTrack,
|
|
21
|
+
forkSession,
|
|
22
|
+
historyEvents,
|
|
23
|
+
namingTarget,
|
|
24
|
+
pickerLines,
|
|
25
|
+
resolveSessionArg,
|
|
26
|
+
SessionLookupError,
|
|
27
|
+
withTrack,
|
|
28
|
+
} from "./session/attach.ts";
|
|
29
|
+
import { AutoNamer, providerNameGenerator } from "./session/naming.ts";
|
|
30
|
+
import { normalizeSessionName } from "./session/meta.ts";
|
|
31
|
+
import { parsePrompt } from "./agent/ops.ts";
|
|
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";
|
|
35
|
+
import { historyTarget, REDO_KIND, UNDO_KIND } from "./commands/history.ts";
|
|
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";
|
|
53
|
+
import { highwayLayers } from "../tui/layers.ts";
|
|
54
|
+
import { drumVoicePitch, isDrumInstrument } from "../core/drums.ts";
|
|
55
|
+
import {
|
|
56
|
+
describeAgentEvent,
|
|
57
|
+
StaleRevisionError,
|
|
58
|
+
type AgentEvent,
|
|
59
|
+
type AgentHost,
|
|
60
|
+
} from "./agent/agent.ts";
|
|
61
|
+
import {
|
|
62
|
+
isApiSelection,
|
|
63
|
+
providerFingerprint,
|
|
64
|
+
providerLabel,
|
|
65
|
+
runProviderTurn,
|
|
66
|
+
selectProvider,
|
|
67
|
+
type ProviderSelection,
|
|
68
|
+
} from "./agent/provider.ts";
|
|
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";
|
|
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";
|
|
96
|
+
import {
|
|
97
|
+
addNote,
|
|
98
|
+
applyScoreOperation,
|
|
99
|
+
createScore,
|
|
100
|
+
scoreFromJSON,
|
|
101
|
+
SCORE_LIMITS,
|
|
102
|
+
type TrackScore,
|
|
103
|
+
type ScoreOperation,
|
|
104
|
+
} from "../core/score.ts";
|
|
105
|
+
import { decodeLoop, encodeLoop } from "../core/loop.ts";
|
|
106
|
+
import type { TrackScoreSnapshot } from "../tui/render.ts";
|
|
107
|
+
import { PromptModel } from "../tui/prompt.ts";
|
|
108
|
+
import { TerminalInputDecoder } from "../tui/input.ts";
|
|
109
|
+
import {
|
|
110
|
+
composeFrame,
|
|
111
|
+
TuiApp,
|
|
112
|
+
type AppView,
|
|
113
|
+
type SyncState,
|
|
114
|
+
type TypesIndicator,
|
|
115
|
+
} from "../tui/app.ts";
|
|
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";
|
|
119
|
+
import { encodeBuffer } from "../tui/screen.ts";
|
|
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";
|
|
129
|
+
|
|
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
|
+
];
|
|
154
|
+
const HELP_TEXT = `dawg · local-first terminal music workstation
|
|
155
|
+
|
|
156
|
+
Usage:
|
|
157
|
+
dawg [--new] [--session <name|id>] [--track <name>]
|
|
158
|
+
dawg --import <file> --export <file>
|
|
159
|
+
dawg sessions
|
|
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
|
|
165
|
+
|
|
166
|
+
Usage flags:
|
|
167
|
+
--reduce-motion static hit/sustain states (also DAWG_REDUCE_MOTION=1)
|
|
168
|
+
--theme <name> default | high-contrast | mono (NO_COLOR forces mono)
|
|
169
|
+
|
|
170
|
+
Prompt:
|
|
171
|
+
Enter submit · Shift-Enter newline · Alt-Enter queue · Ctrl-Q toggle queue
|
|
172
|
+
Ctrl-Z undo · Ctrl-Y redo · Ctrl-O transcript · Esc cancel/close · Ctrl-C exit
|
|
173
|
+
Space on an empty prompt toggles playback
|
|
174
|
+
|
|
175
|
+
Commands (bare music words; app commands take a slash):
|
|
176
|
+
${helpText()}
|
|
177
|
+
|
|
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]
|
|
183
|
+
Unrecognized requests go to the agent once a provider is configured
|
|
184
|
+
(DAWG_PROVIDER=gateway|openrouter|codex|claude|auto, DAWG_MODEL=<alias>;
|
|
185
|
+
DAWG_AI=0 disables the agent).`;
|
|
186
|
+
const args = new Set(process.argv.slice(2));
|
|
187
|
+
const requestedSession = optionValue("--session");
|
|
188
|
+
const explicitTrack = optionValue("--track");
|
|
189
|
+
/** The focused track; claimed at startup unless `--track` is given. */
|
|
190
|
+
let requestedTrack = explicitTrack ?? "main";
|
|
191
|
+
const initialInstrument = isDrumInstrument(requestedTrack) ? "kit" : "sine";
|
|
192
|
+
const importPath = optionValue("--import");
|
|
193
|
+
const exportPath = optionValue("--export");
|
|
194
|
+
if (["login", "logout", "auth", "model"].includes(process.argv[2] ?? ""))
|
|
195
|
+
process.exit(await runAuthCommand(process.argv.slice(2)));
|
|
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
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (process.argv[2] === "sessions") {
|
|
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);
|
|
212
|
+
process.exit(0);
|
|
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
|
+
}
|
|
234
|
+
if (process.argv[2] === "render") {
|
|
235
|
+
const { runRenderCommand } = await import("./render.ts");
|
|
236
|
+
process.exit(
|
|
237
|
+
await runRenderCommand(
|
|
238
|
+
process.argv.slice(2),
|
|
239
|
+
process.cwd(),
|
|
240
|
+
stdout,
|
|
241
|
+
process.stderr,
|
|
242
|
+
),
|
|
243
|
+
);
|
|
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
|
+
}
|
|
261
|
+
const demo =
|
|
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();
|
|
267
|
+
|
|
268
|
+
const initial = createScore({
|
|
269
|
+
tracks: [
|
|
270
|
+
{ id: requestedTrack, name: requestedTrack, instrument: initialInstrument },
|
|
271
|
+
],
|
|
272
|
+
});
|
|
273
|
+
const sessionOptions: { sessionId?: string; setCurrent: boolean } = {
|
|
274
|
+
setCurrent: !requestedSession || args.has("--new"),
|
|
275
|
+
};
|
|
276
|
+
const selectedSession = args.has("--new")
|
|
277
|
+
? randomUUID()
|
|
278
|
+
: requestedSession === undefined
|
|
279
|
+
? undefined
|
|
280
|
+
: await resolveSessionArg(process.cwd(), requestedSession).catch(
|
|
281
|
+
(error: unknown) => {
|
|
282
|
+
if (!(error instanceof SessionLookupError)) throw error;
|
|
283
|
+
process.stderr.write(`${error.message}\n`);
|
|
284
|
+
process.exit(1);
|
|
285
|
+
},
|
|
286
|
+
);
|
|
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
|
+
));
|
|
293
|
+
const session = await ensureSession(initial.toJSON(), sessionOptions);
|
|
294
|
+
// dawgd when connected, the file-lock path otherwise (see src/session/port.ts).
|
|
295
|
+
let port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
|
|
296
|
+
paths: session.paths,
|
|
297
|
+
sessionId: session.record.sessionId,
|
|
298
|
+
label: explicitTrack ?? "window",
|
|
299
|
+
focusedTrackId: explicitTrack ?? null,
|
|
300
|
+
daemon: !demo,
|
|
301
|
+
});
|
|
302
|
+
let record: SessionRecord<ReturnType<TrackScore["toJSON"]>> =
|
|
303
|
+
port.mode === "daemon" ? await port.load() : session.record;
|
|
304
|
+
let score = scoreFromJSON(record.composition);
|
|
305
|
+
if (importPath) {
|
|
306
|
+
const imported = decodeLoop(await readLoopFile(importPath));
|
|
307
|
+
score = imported;
|
|
308
|
+
record = await port.append(
|
|
309
|
+
record,
|
|
310
|
+
{
|
|
311
|
+
kind: "score.import",
|
|
312
|
+
payload: { path: importPath },
|
|
313
|
+
},
|
|
314
|
+
score.toJSON(),
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
/** True while the focused track is a reserved draft not yet in the score. */
|
|
318
|
+
let draftTrack = false;
|
|
319
|
+
let attachNotice = "";
|
|
320
|
+
if (!demo) {
|
|
321
|
+
const attached = await attachTrack(port, score, explicitTrack);
|
|
322
|
+
requestedTrack = attached.trackId;
|
|
323
|
+
draftTrack = attached.draft;
|
|
324
|
+
attachNotice = attached.draft
|
|
325
|
+
? ALL_TRACKS_OPEN_HINT
|
|
326
|
+
: attached.reason === "claimed" && score.tracks.length > 1
|
|
327
|
+
? `opened · ${requestedTrack}`
|
|
328
|
+
: "";
|
|
329
|
+
} else if (explicitTrack === undefined)
|
|
330
|
+
requestedTrack = score.tracks[0]?.id ?? requestedTrack;
|
|
331
|
+
if (!draftTrack) await ensureFocusedTrack();
|
|
332
|
+
|
|
333
|
+
const clock = new TransportClock(score.tempoBpm);
|
|
334
|
+
let audio = port.player;
|
|
335
|
+
/** Session and today's spend, from the usage each response reports. */
|
|
336
|
+
const meter = new SpendMeter(configDir());
|
|
337
|
+
void meter.load().catch(() => undefined);
|
|
338
|
+
const prompt = new PromptModel({ width: 72, maxVisualRows: 8 });
|
|
339
|
+
const tui = new TuiApp({
|
|
340
|
+
io: {
|
|
341
|
+
write: (data) => stdout.write(data),
|
|
342
|
+
columns: () => stdout.columns ?? 80,
|
|
343
|
+
rows: () => stdout.rows ?? 24,
|
|
344
|
+
},
|
|
345
|
+
prompt,
|
|
346
|
+
theme: parseThemeName(optionValue("--theme") ?? process.env.DAWG_THEME),
|
|
347
|
+
reducedMotion:
|
|
348
|
+
args.has("--reduce-motion") || process.env.DAWG_REDUCE_MOTION === "1",
|
|
349
|
+
});
|
|
350
|
+
let syncState: SyncState = port.sync;
|
|
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;
|
|
355
|
+
let announcedName = record.meta.name;
|
|
356
|
+
let namer = makeNamer();
|
|
357
|
+
/** Re-subscribes the TUI after /fork or /resume replaces `port`. */
|
|
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;
|
|
367
|
+
/** The in-flight agent turn: Esc aborts it, Enter steers it. */
|
|
368
|
+
let agentTurn: { controller: AbortController; steering: string[] } | undefined;
|
|
369
|
+
let reportAgentActivity: (text: string) => void = () => undefined;
|
|
370
|
+
// Declared before `await runInteractive()` runs, or assigning it is a TDZ error.
|
|
371
|
+
let agentEventSink: (event: AgentEvent) => void = () => undefined;
|
|
372
|
+
/** Resolved lazily (and again after /login); `undefined` until first needed. */
|
|
373
|
+
let provider: Promise<ProviderSelection> | undefined;
|
|
374
|
+
let providerStamp = { fingerprint: "", at: 0, offline: false };
|
|
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;
|
|
405
|
+
if (demo) {
|
|
406
|
+
if (score.notes.length === 0) score = seedDemo(score, requestedTrack);
|
|
407
|
+
if (exportPath)
|
|
408
|
+
await writeFile(resolve(exportPath), encodeLoop(score), "utf8");
|
|
409
|
+
renderOnce(
|
|
410
|
+
score,
|
|
411
|
+
clock.beatAt(),
|
|
412
|
+
"demo · press space to play",
|
|
413
|
+
"add C4 at 0 for 1",
|
|
414
|
+
0,
|
|
415
|
+
);
|
|
416
|
+
process.exit(0);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (exportPath) {
|
|
420
|
+
await writeFile(resolve(exportPath), encodeLoop(score), "utf8");
|
|
421
|
+
if (!stdin.isTTY) process.exit(0);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
await runInteractive();
|
|
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
|
+
|
|
452
|
+
function optionValue(name: string): string | undefined {
|
|
453
|
+
const index = process.argv.indexOf(name);
|
|
454
|
+
const value = index >= 0 ? process.argv[index + 1] : undefined;
|
|
455
|
+
return value && !value.startsWith("--") ? value : undefined;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function seedDemo(value: TrackScore, trackId: string): TrackScore {
|
|
459
|
+
const instrument = value.tracks.find(
|
|
460
|
+
(track) => track.id === trackId,
|
|
461
|
+
)?.instrument;
|
|
462
|
+
if (isDrumInstrument(instrument)) return seedDemoDrums(value, trackId);
|
|
463
|
+
return addNote(
|
|
464
|
+
addNote(
|
|
465
|
+
addNote(value, {
|
|
466
|
+
id: "demo-1",
|
|
467
|
+
trackId,
|
|
468
|
+
startTick: 0,
|
|
469
|
+
durationTicks: 240,
|
|
470
|
+
pitch: 60,
|
|
471
|
+
velocity: 0.9,
|
|
472
|
+
}),
|
|
473
|
+
{
|
|
474
|
+
id: "demo-2",
|
|
475
|
+
trackId,
|
|
476
|
+
startTick: 480,
|
|
477
|
+
durationTicks: 480,
|
|
478
|
+
pitch: 64,
|
|
479
|
+
velocity: 0.75,
|
|
480
|
+
},
|
|
481
|
+
),
|
|
482
|
+
{
|
|
483
|
+
id: "demo-3",
|
|
484
|
+
trackId,
|
|
485
|
+
startTick: 960,
|
|
486
|
+
durationTicks: 240,
|
|
487
|
+
pitch: 67,
|
|
488
|
+
velocity: 0.85,
|
|
489
|
+
},
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** A one-bar kick/snare/hat groove for a drum track in demo mode. */
|
|
494
|
+
function seedDemoDrums(value: TrackScore, trackId: string): TrackScore {
|
|
495
|
+
const hits: Array<["kick" | "snare" | "hat", number, number]> = [
|
|
496
|
+
["kick", 0, 0.95],
|
|
497
|
+
["hat", 0.5, 0.6],
|
|
498
|
+
["snare", 1, 0.85],
|
|
499
|
+
["hat", 1.5, 0.6],
|
|
500
|
+
["kick", 2, 0.9],
|
|
501
|
+
["hat", 2.5, 0.6],
|
|
502
|
+
["snare", 3, 0.85],
|
|
503
|
+
["hat", 3.5, 0.6],
|
|
504
|
+
];
|
|
505
|
+
return hits.reduce(
|
|
506
|
+
(next, [voice, beat, velocity], index) =>
|
|
507
|
+
addNote(next, {
|
|
508
|
+
id: `demo-${index + 1}`,
|
|
509
|
+
trackId,
|
|
510
|
+
startTick: Math.round(beat * next.ticksPerBeat),
|
|
511
|
+
durationTicks: Math.round(next.ticksPerBeat / 4),
|
|
512
|
+
pitch: drumVoicePitch(voice),
|
|
513
|
+
velocity,
|
|
514
|
+
}),
|
|
515
|
+
value,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function snapshot(
|
|
520
|
+
value: TrackScore,
|
|
521
|
+
beat: number,
|
|
522
|
+
activity?: string,
|
|
523
|
+
): TrackScoreSnapshot {
|
|
524
|
+
const notes = value.notes
|
|
525
|
+
.filter((note) => note.trackId === requestedTrack)
|
|
526
|
+
.map((note) => ({
|
|
527
|
+
id: note.id,
|
|
528
|
+
startBeat: note.startTick / value.ticksPerBeat,
|
|
529
|
+
durationBeats: note.durationTicks / value.ticksPerBeat,
|
|
530
|
+
pitch: note.pitch,
|
|
531
|
+
velocity: note.velocity,
|
|
532
|
+
muted: value.tracks.find((track) => track.id === requestedTrack)?.muted,
|
|
533
|
+
}));
|
|
534
|
+
return {
|
|
535
|
+
notes,
|
|
536
|
+
trackName:
|
|
537
|
+
value.tracks.find((track) => track.id === requestedTrack)?.name ??
|
|
538
|
+
requestedTrack,
|
|
539
|
+
trackId: requestedTrack,
|
|
540
|
+
sessionId: record.sessionId,
|
|
541
|
+
revision: record.revision,
|
|
542
|
+
bpm: value.tempoBpm,
|
|
543
|
+
key: value.key ?? undefined,
|
|
544
|
+
loopBeats: value.bars * value.beatsPerBar,
|
|
545
|
+
beatsPerBar: value.beatsPerBar,
|
|
546
|
+
laneCount: 24,
|
|
547
|
+
currentBeat: beat,
|
|
548
|
+
playing: clock.playing,
|
|
549
|
+
activity,
|
|
550
|
+
...drumSnapshotFields(
|
|
551
|
+
value.tracks.find((track) => track.id === requestedTrack)?.instrument,
|
|
552
|
+
notes,
|
|
553
|
+
),
|
|
554
|
+
...samplerSnapshotFields(
|
|
555
|
+
value.tracks.find((track) => track.id === requestedTrack),
|
|
556
|
+
notes,
|
|
557
|
+
),
|
|
558
|
+
layers:
|
|
559
|
+
tui.highwayView === "all"
|
|
560
|
+
? highwayLayers(
|
|
561
|
+
value.tracks,
|
|
562
|
+
value.notes,
|
|
563
|
+
value.ticksPerBeat,
|
|
564
|
+
requestedTrack,
|
|
565
|
+
)
|
|
566
|
+
: undefined,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function renderOnce(
|
|
571
|
+
value: TrackScore,
|
|
572
|
+
transportBeat: number,
|
|
573
|
+
activity?: string,
|
|
574
|
+
draft = "",
|
|
575
|
+
nowMs = Date.now(),
|
|
576
|
+
): void {
|
|
577
|
+
if (activity) tui.activity.pushCard(activity, { tone: "info" });
|
|
578
|
+
if (draft) tui.input({ type: "paste", text: draft });
|
|
579
|
+
const frame = composeFrame(
|
|
580
|
+
appView(value, transportBeat),
|
|
581
|
+
tui.ui,
|
|
582
|
+
{ width: Math.max(24, stdout.columns ?? 80), height: 20 },
|
|
583
|
+
nowMs,
|
|
584
|
+
);
|
|
585
|
+
stdout.write(`${encodeBuffer(frame.buffer, tui.capabilities)}\n`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function appView(value: TrackScore, beat: number): AppView {
|
|
589
|
+
return {
|
|
590
|
+
score: snapshot(value, beat),
|
|
591
|
+
beat,
|
|
592
|
+
// `opus-5.5 · gateway`, `sonnet · claude`; hidden when offline.
|
|
593
|
+
model:
|
|
594
|
+
providerName && providerName !== "offline" ? providerName : undefined,
|
|
595
|
+
spend: spendLine(),
|
|
596
|
+
agentOffline: providerName === "offline" || process.env.DAWG_AI === "0",
|
|
597
|
+
sync: syncState,
|
|
598
|
+
sessionName: record.meta.name,
|
|
599
|
+
windows: windowCount,
|
|
600
|
+
types: typesIndicator,
|
|
601
|
+
play: play?.on ? play.header() : undefined,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
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}`;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function truncateForCard(value: string): string {
|
|
654
|
+
const line = value.replace(/\s+/g, " ").trim();
|
|
655
|
+
return line.length > 32 ? `${line.slice(0, 31)}…` : line;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function runInteractive(): Promise<void> {
|
|
659
|
+
stdin.setRawMode?.(true);
|
|
660
|
+
stdin.resume();
|
|
661
|
+
// Alternate screen, hidden cursor, bracketed paste.
|
|
662
|
+
stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J`);
|
|
663
|
+
const inputDecoder = new TerminalInputDecoder();
|
|
664
|
+
const queuedPrompts: string[] = [];
|
|
665
|
+
let processingQueue = false;
|
|
666
|
+
const runPrompt = async (text: string): Promise<void> => {
|
|
667
|
+
const ui = tui.command(text);
|
|
668
|
+
if (ui !== undefined) {
|
|
669
|
+
tui.activity.pushCard(ui, { tone: "info" });
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
tui.activity.pushRequest(text);
|
|
673
|
+
const base = baseline();
|
|
674
|
+
const baseSession = record.sessionId;
|
|
675
|
+
try {
|
|
676
|
+
const message = await submit(text);
|
|
677
|
+
// Agent turns report through agentEventSink; skip a duplicate receipt.
|
|
678
|
+
if (!agentReported) receipt(message, base);
|
|
679
|
+
// Naming runs later on a timer; it never delays the next prompt.
|
|
680
|
+
if (record.sessionId === baseSession)
|
|
681
|
+
namer.noteTurn({
|
|
682
|
+
score,
|
|
683
|
+
prompt: text,
|
|
684
|
+
accepted: record.revision !== base.revision,
|
|
685
|
+
});
|
|
686
|
+
} catch (error) {
|
|
687
|
+
tui.activity.pushError(describeError(text, error));
|
|
688
|
+
} finally {
|
|
689
|
+
agentReported = false;
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
let agentReported = false;
|
|
693
|
+
const drainQueue = async (): Promise<void> => {
|
|
694
|
+
if (processingQueue) return;
|
|
695
|
+
processingQueue = true;
|
|
696
|
+
try {
|
|
697
|
+
while (queuedPrompts.length > 0) {
|
|
698
|
+
const nextPrompt = queuedPrompts.shift()!;
|
|
699
|
+
tui.activity.setQueueDepth(queuedPrompts.length);
|
|
700
|
+
await runPrompt(nextPrompt);
|
|
701
|
+
tick(true);
|
|
702
|
+
}
|
|
703
|
+
} finally {
|
|
704
|
+
processingQueue = false;
|
|
705
|
+
tui.activity.setQueueDepth(queuedPrompts.length);
|
|
706
|
+
}
|
|
707
|
+
};
|
|
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();
|
|
713
|
+
tui.render(appView(score, clock.beatAt()), { force });
|
|
714
|
+
};
|
|
715
|
+
reportAgentActivity = () => {
|
|
716
|
+
tui.activity.applyAgentEvent({ type: "start", model: providerName });
|
|
717
|
+
};
|
|
718
|
+
agentEventSink = (event) => {
|
|
719
|
+
if (event.type === "usage") return;
|
|
720
|
+
tui.activity.applyAgentEvent(event);
|
|
721
|
+
if (event.type === "done" || event.type === "error") agentReported = true;
|
|
722
|
+
};
|
|
723
|
+
// ~30 fps cap; the differential writer only emits changed rows.
|
|
724
|
+
let timer = setInterval(tick, tui.frameIntervalMs);
|
|
725
|
+
const onResize = () => {
|
|
726
|
+
tui.invalidate();
|
|
727
|
+
tick(true);
|
|
728
|
+
};
|
|
729
|
+
stdout.on("resize", onResize);
|
|
730
|
+
let applying: Promise<void> = Promise.resolve();
|
|
731
|
+
const applyLatest = (latest: typeof record): Promise<void> =>
|
|
732
|
+
(applying = applying.then(() => applyRecord(latest)));
|
|
733
|
+
const applyRecord = async (latest: typeof record): Promise<void> => {
|
|
734
|
+
try {
|
|
735
|
+
if (latest.revision > record.revision) {
|
|
736
|
+
const previousRevision = record.revision;
|
|
737
|
+
record = latest;
|
|
738
|
+
score = scoreFromJSON(record.composition);
|
|
739
|
+
clock.setTempo(score.tempoBpm);
|
|
740
|
+
if (clock.playing) void audio.play(score);
|
|
741
|
+
// Connected windows follow dawgd's transport frames instead.
|
|
742
|
+
const replay =
|
|
743
|
+
port.mode === "file" ? latest.events.slice(previousRevision) : [];
|
|
744
|
+
for (const event of replay) {
|
|
745
|
+
if (
|
|
746
|
+
event.kind !== "transport" ||
|
|
747
|
+
typeof event.payload !== "object" ||
|
|
748
|
+
event.payload === null
|
|
749
|
+
)
|
|
750
|
+
continue;
|
|
751
|
+
const payload = event.payload as {
|
|
752
|
+
action?: string;
|
|
753
|
+
playing?: boolean;
|
|
754
|
+
beat?: number;
|
|
755
|
+
};
|
|
756
|
+
if (
|
|
757
|
+
typeof payload.playing === "boolean" &&
|
|
758
|
+
typeof payload.beat === "number" &&
|
|
759
|
+
Number.isFinite(payload.beat)
|
|
760
|
+
) {
|
|
761
|
+
clock.sync(payload.beat, payload.playing, Date.parse(event.at));
|
|
762
|
+
if (payload.playing) void audio.play(score, clock.beatAt());
|
|
763
|
+
else audio.stop();
|
|
764
|
+
} else if (typeof payload.playing === "boolean")
|
|
765
|
+
await setTransport(payload.playing ? "play" : "pause");
|
|
766
|
+
else if (payload.action === "play") await setTransport("play");
|
|
767
|
+
else if (payload.action === "pause") await setTransport("pause");
|
|
768
|
+
else if (payload.action === "toggle") await setTransport("toggle");
|
|
769
|
+
}
|
|
770
|
+
projectSync?.scoreChanged(score);
|
|
771
|
+
tui.activity.pushCard("synced from another window", {
|
|
772
|
+
tone: "info",
|
|
773
|
+
baseRevision: previousRevision,
|
|
774
|
+
resultRevision: record.revision,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
} catch {
|
|
778
|
+
// An invalid composition is skipped; the next update retries.
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
const onUpdate: Parameters<typeof port.subscribe>[0] = (update) => {
|
|
782
|
+
if (update.type === "record") {
|
|
783
|
+
void applyLatest(update.record);
|
|
784
|
+
adoptMeta(update.record.meta);
|
|
785
|
+
} else if (update.type === "meta") adoptMeta(update.meta);
|
|
786
|
+
else if (update.type === "presence") windowCount = update.clients.length;
|
|
787
|
+
else if (update.type === "sync") syncState = update.sync;
|
|
788
|
+
else if (update.type === "transport") {
|
|
789
|
+
// Every window renders the same hit line from dawgd's timestamp.
|
|
790
|
+
const { playing, beat, bpm, atMs } = update.transport;
|
|
791
|
+
clock.setTempo(bpm);
|
|
792
|
+
clock.sync(beat, playing, atMs, monotonicEpochMs());
|
|
793
|
+
} else if (update.type === "status") {
|
|
794
|
+
syncState = port.sync;
|
|
795
|
+
tui.activity.pushCard(update.message, {
|
|
796
|
+
tone: syncState === "offline" ? "warning" : "info",
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
let unsubscribe = port.subscribe(onUpdate);
|
|
801
|
+
const refreshPresence = () =>
|
|
802
|
+
void port
|
|
803
|
+
.presence()
|
|
804
|
+
.then((clients) => {
|
|
805
|
+
windowCount = Math.max(1, clients.length);
|
|
806
|
+
})
|
|
807
|
+
.catch(() => undefined);
|
|
808
|
+
// File-lock windows have no presence push; poll the heartbeat files.
|
|
809
|
+
const presenceTimer = setInterval(() => {
|
|
810
|
+
if (port.mode === "file") refreshPresence();
|
|
811
|
+
}, 2_000);
|
|
812
|
+
refreshPresence();
|
|
813
|
+
rebindPort = () => {
|
|
814
|
+
unsubscribe();
|
|
815
|
+
syncState = port.sync;
|
|
816
|
+
unsubscribe = port.subscribe(onUpdate);
|
|
817
|
+
refreshPresence();
|
|
818
|
+
};
|
|
819
|
+
if (freshWorkspace)
|
|
820
|
+
tui.activity.pushCard("created .dawg/ · add it to .gitignore", {
|
|
821
|
+
tone: "info",
|
|
822
|
+
});
|
|
823
|
+
if (attachNotice)
|
|
824
|
+
tui.activity.pushCard(attachNotice, {
|
|
825
|
+
tone: "info",
|
|
826
|
+
trackId: requestedTrack,
|
|
827
|
+
hint: draftTrack ? "first edit creates it" : undefined,
|
|
828
|
+
});
|
|
829
|
+
if (port.status !== "file session")
|
|
830
|
+
tui.activity.pushCard(port.status, { tone: "info" });
|
|
831
|
+
if (await isProject(process.cwd()))
|
|
832
|
+
projectSync = startProjectSync(syncHost());
|
|
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
|
+
};
|
|
882
|
+
tick(true);
|
|
883
|
+
try {
|
|
884
|
+
let exiting = false;
|
|
885
|
+
for await (const text of chunks()) {
|
|
886
|
+
// A read that is exactly ESC is the Esc key, not the start of a sequence.
|
|
887
|
+
const values = [
|
|
888
|
+
...inputDecoder.push(text),
|
|
889
|
+
...(text === "\u001b" ? inputDecoder.flush() : []),
|
|
890
|
+
];
|
|
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
|
+
}
|
|
933
|
+
// The prompt is always focused, so ordinary `q` must remain typeable in
|
|
934
|
+
// requests (for example, "quiet hi-hat"). Ctrl-C is the unambiguous
|
|
935
|
+
// shell exit key; Ctrl-Q is reserved for prompt mode switching.
|
|
936
|
+
// Keep the empty-prompt space shortcut for transport, while allowing
|
|
937
|
+
// ordinary spaces once a request is being composed.
|
|
938
|
+
if (value === " " && prompt.value.length === 0) {
|
|
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));
|
|
949
|
+
} else {
|
|
950
|
+
const input = tui.input(value);
|
|
951
|
+
const action = input.type === "action" ? input.action : undefined;
|
|
952
|
+
if (input.type === "ui") {
|
|
953
|
+
if (input.command === "quit") exiting = true;
|
|
954
|
+
else if (
|
|
955
|
+
(input.command === "undo" || input.command === "redo") &&
|
|
956
|
+
!agentTurn
|
|
957
|
+
) {
|
|
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));
|
|
969
|
+
}
|
|
970
|
+
} else if (input.type === "pick") {
|
|
971
|
+
// Picker choices run as the command they stand for.
|
|
972
|
+
queuedPrompts.unshift(
|
|
973
|
+
input.picker === "resume"
|
|
974
|
+
? `/resume ${input.value}`
|
|
975
|
+
: input.value,
|
|
976
|
+
);
|
|
977
|
+
void drainQueue();
|
|
978
|
+
} else if (action?.kind === "exit") exiting = true;
|
|
979
|
+
else if (action?.kind === "cancel" && agentTurn) {
|
|
980
|
+
agentTurn.controller.abort();
|
|
981
|
+
tui.activity.setSpinner("cancelling");
|
|
982
|
+
} else if (action?.kind === "submit" && action.value && agentTurn) {
|
|
983
|
+
// Enter during a turn steers it; Alt-Enter still queues a follow-up.
|
|
984
|
+
agentTurn.steering.push(action.value);
|
|
985
|
+
tui.activity.pushCard(
|
|
986
|
+
`steering · ${truncateForCard(action.value)}`,
|
|
987
|
+
{ tone: "agent", hint: "next step" },
|
|
988
|
+
);
|
|
989
|
+
} else if (action?.kind === "submit" && action.value) {
|
|
990
|
+
queuedPrompts.unshift(action.value);
|
|
991
|
+
// Do not await: the input loop must stay live so Esc can cancel.
|
|
992
|
+
void drainQueue();
|
|
993
|
+
} else if (action?.kind === "queue" && action.value) {
|
|
994
|
+
queuedPrompts.push(action.value);
|
|
995
|
+
tui.activity.setQueueDepth(queuedPrompts.length);
|
|
996
|
+
tui.activity.pushCard(`queued · ${truncateForCard(action.value)}`, {
|
|
997
|
+
tone: "info",
|
|
998
|
+
});
|
|
999
|
+
void drainQueue();
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
if (exiting) break;
|
|
1003
|
+
tick(true);
|
|
1004
|
+
}
|
|
1005
|
+
if (exiting) break;
|
|
1006
|
+
}
|
|
1007
|
+
} finally {
|
|
1008
|
+
stdin.off("data", onData);
|
|
1009
|
+
stdin.off("end", onEnd);
|
|
1010
|
+
clearInterval(timer);
|
|
1011
|
+
clearInterval(presenceTimer);
|
|
1012
|
+
await projectSync?.stop();
|
|
1013
|
+
namer.dispose();
|
|
1014
|
+
stdout.off("resize", onResize);
|
|
1015
|
+
unsubscribe();
|
|
1016
|
+
await play?.exit().catch(() => undefined);
|
|
1017
|
+
await monitorEngine?.dispose().catch(() => undefined);
|
|
1018
|
+
audio.stop();
|
|
1019
|
+
await port.close();
|
|
1020
|
+
stdin.setRawMode?.(false);
|
|
1021
|
+
stdin.pause();
|
|
1022
|
+
stdout.write(`${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
async function submit(prompt: string): Promise<string | Receipt> {
|
|
1027
|
+
const command = prompt.trim();
|
|
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
|
+
}
|
|
1082
|
+
if (/^\/status$/i.test(command))
|
|
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
|
+
);
|
|
1086
|
+
if (/^\/?undo$/i.test(command)) return stepHistory("undo");
|
|
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);
|
|
1094
|
+
const sessionReply = await sessionCommand(command);
|
|
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
|
+
}
|
|
1104
|
+
const music = parseMusicCommand(command);
|
|
1105
|
+
if (music) {
|
|
1106
|
+
await materializeDraft();
|
|
1107
|
+
const result = applyMusicCommand(score, requestedTrack, music, () =>
|
|
1108
|
+
randomUUID().slice(0, 12),
|
|
1109
|
+
);
|
|
1110
|
+
if (result.next && result.kind)
|
|
1111
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1112
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1113
|
+
}
|
|
1114
|
+
const exportCommand = command.match(/^\/?export\s+([^\s]+)$/i);
|
|
1115
|
+
if (exportCommand) {
|
|
1116
|
+
const path = resolve(exportCommand[1]!);
|
|
1117
|
+
await writeFile(path, encodeLoop(score), "utf8");
|
|
1118
|
+
return `exported · ${exportCommand[1]}`;
|
|
1119
|
+
}
|
|
1120
|
+
const importCommand = command.match(/^\/?import\s+([^\s]+)$/i);
|
|
1121
|
+
if (importCommand) {
|
|
1122
|
+
await materializeDraft();
|
|
1123
|
+
const imported = decodeLoop(await readLoopFile(importCommand[1]!));
|
|
1124
|
+
await commitScore(imported, "score.import", { path: importCommand[1] });
|
|
1125
|
+
return `imported · ${importCommand[1]}`;
|
|
1126
|
+
}
|
|
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`;
|
|
1138
|
+
tui.openPicker({
|
|
1139
|
+
id: "model",
|
|
1140
|
+
title: `model · ${providerName}`,
|
|
1141
|
+
items,
|
|
1142
|
+
filterable: true,
|
|
1143
|
+
index: Math.max(
|
|
1144
|
+
0,
|
|
1145
|
+
items.findIndex((item) => item.current),
|
|
1146
|
+
),
|
|
1147
|
+
});
|
|
1148
|
+
return `model · ${providerName} · pick one (or /model <alias>)`;
|
|
1149
|
+
} finally {
|
|
1150
|
+
tui.activity.setSpinner(undefined);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
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}`;
|
|
1172
|
+
}
|
|
1173
|
+
if (/^\/(logout|auth)\b/i.test(prompt.trim())) {
|
|
1174
|
+
tui.activity.setSpinner(prompt.trim().split(/\s+/)[0]!.slice(1));
|
|
1175
|
+
try {
|
|
1176
|
+
const lines = await tuiAuthCommand(prompt.trim());
|
|
1177
|
+
provider = undefined;
|
|
1178
|
+
await currentProvider();
|
|
1179
|
+
for (const line of lines.slice(0, -1))
|
|
1180
|
+
tui.activity.pushCard(line, { tone: authTone(line) });
|
|
1181
|
+
return lines.at(-1) ?? "auth · done";
|
|
1182
|
+
} finally {
|
|
1183
|
+
tui.activity.setSpinner(undefined);
|
|
1184
|
+
}
|
|
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
|
+
}
|
|
1196
|
+
const parsed = parsePrompt(prompt);
|
|
1197
|
+
if (!parsed) {
|
|
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`);
|
|
1203
|
+
await materializeDraft();
|
|
1204
|
+
return runAgent(prompt);
|
|
1205
|
+
}
|
|
1206
|
+
if (parsed.type !== "transport") await materializeDraft();
|
|
1207
|
+
if (parsed.type === "transport") {
|
|
1208
|
+
await setTransport(parsed.action);
|
|
1209
|
+
try {
|
|
1210
|
+
record = await port.append(
|
|
1211
|
+
record,
|
|
1212
|
+
{
|
|
1213
|
+
kind: "transport",
|
|
1214
|
+
payload: {
|
|
1215
|
+
action: parsed.action,
|
|
1216
|
+
playing: clock.playing,
|
|
1217
|
+
beat: clock.beatAt(),
|
|
1218
|
+
},
|
|
1219
|
+
},
|
|
1220
|
+
score.toJSON(),
|
|
1221
|
+
);
|
|
1222
|
+
} catch (error) {
|
|
1223
|
+
if (error instanceof SessionConflictError)
|
|
1224
|
+
return warn("transport changed in another window");
|
|
1225
|
+
throw error;
|
|
1226
|
+
}
|
|
1227
|
+
return parsed.action;
|
|
1228
|
+
}
|
|
1229
|
+
if (parsed.type === "set-tempo") {
|
|
1230
|
+
const next = score.withTempo(parsed.tempoBpm);
|
|
1231
|
+
await commitScore(next, "score.tempo", { tempoBpm: parsed.tempoBpm });
|
|
1232
|
+
clock.setTempo?.(next.tempoBpm);
|
|
1233
|
+
return `tempo · ${next.tempoBpm} BPM`;
|
|
1234
|
+
}
|
|
1235
|
+
if (parsed.type === "add-track") return focusTrack(parsed.trackId);
|
|
1236
|
+
if (parsed.type === "set-bars" || parsed.type === "extend-bars") {
|
|
1237
|
+
const bars =
|
|
1238
|
+
parsed.type === "set-bars"
|
|
1239
|
+
? parsed.bars
|
|
1240
|
+
: Math.min(SCORE_LIMITS.maxBars, score.bars + parsed.bars);
|
|
1241
|
+
const next = applyScoreOperation(score, { type: "setBars", bars });
|
|
1242
|
+
await commitScore(next, "score.bars", { bars });
|
|
1243
|
+
return `bars · ${bars}`;
|
|
1244
|
+
}
|
|
1245
|
+
if (parsed.type === "track") {
|
|
1246
|
+
const next = applyScoreOperation(score, {
|
|
1247
|
+
type: "updateTrack",
|
|
1248
|
+
trackId: requestedTrack,
|
|
1249
|
+
patch: parsed.patch,
|
|
1250
|
+
});
|
|
1251
|
+
await commitScore(next, "score.track", {
|
|
1252
|
+
trackId: requestedTrack,
|
|
1253
|
+
patch: parsed.patch,
|
|
1254
|
+
});
|
|
1255
|
+
return `track · ${requestedTrack}`;
|
|
1256
|
+
}
|
|
1257
|
+
if (parsed.type === "automation") {
|
|
1258
|
+
const points = parsed.points.map((point) => ({
|
|
1259
|
+
tick: Math.max(0, Math.round(point.beat * score.ticksPerBeat)),
|
|
1260
|
+
value: point.value,
|
|
1261
|
+
}));
|
|
1262
|
+
const track = score.tracks.find(
|
|
1263
|
+
(candidate) => candidate.id === requestedTrack,
|
|
1264
|
+
);
|
|
1265
|
+
const current =
|
|
1266
|
+
parsed.parameter === "pan"
|
|
1267
|
+
? (track?.panAutomation ?? [])
|
|
1268
|
+
: (track?.volumeAutomation ?? []);
|
|
1269
|
+
const merged =
|
|
1270
|
+
points.length === 0
|
|
1271
|
+
? []
|
|
1272
|
+
: Array.from(
|
|
1273
|
+
new Map(
|
|
1274
|
+
[...current, ...points].map((point) => [point.tick, point]),
|
|
1275
|
+
).values(),
|
|
1276
|
+
).sort((left, right) => left.tick - right.tick);
|
|
1277
|
+
const next = applyScoreOperation(score, {
|
|
1278
|
+
type: "setAutomation",
|
|
1279
|
+
trackId: requestedTrack,
|
|
1280
|
+
parameter: parsed.parameter,
|
|
1281
|
+
points: merged,
|
|
1282
|
+
});
|
|
1283
|
+
await commitScore(next, "score.automation", {
|
|
1284
|
+
trackId: requestedTrack,
|
|
1285
|
+
parameter: parsed.parameter,
|
|
1286
|
+
points: merged,
|
|
1287
|
+
});
|
|
1288
|
+
return `automation · ${parsed.parameter} ${merged.length} point${merged.length === 1 ? "" : "s"}`;
|
|
1289
|
+
}
|
|
1290
|
+
if (parsed.type === "clear-track") {
|
|
1291
|
+
const next = applyScoreOperation(score, {
|
|
1292
|
+
type: "clearTrack",
|
|
1293
|
+
trackId: requestedTrack,
|
|
1294
|
+
});
|
|
1295
|
+
await commitScore(next, "score.clear", { trackId: requestedTrack });
|
|
1296
|
+
return `cleared · ${requestedTrack}`;
|
|
1297
|
+
}
|
|
1298
|
+
if (parsed.type === "remove-note") {
|
|
1299
|
+
const next = applyScoreOperation(score, {
|
|
1300
|
+
type: "removeNote",
|
|
1301
|
+
noteId: parsed.noteId,
|
|
1302
|
+
});
|
|
1303
|
+
await commitScore(next, "score.operation", {
|
|
1304
|
+
operation: { type: "removeNote", noteId: parsed.noteId },
|
|
1305
|
+
});
|
|
1306
|
+
return `removed · ${parsed.noteId}`;
|
|
1307
|
+
}
|
|
1308
|
+
if (parsed.type === "update-note") {
|
|
1309
|
+
const patch = {
|
|
1310
|
+
...(typeof parsed.patch.start === "number"
|
|
1311
|
+
? { startTick: Math.round(parsed.patch.start * score.ticksPerBeat) }
|
|
1312
|
+
: {}),
|
|
1313
|
+
...(typeof parsed.patch.duration === "number"
|
|
1314
|
+
? {
|
|
1315
|
+
durationTicks: Math.max(
|
|
1316
|
+
1,
|
|
1317
|
+
Math.round(parsed.patch.duration * score.ticksPerBeat),
|
|
1318
|
+
),
|
|
1319
|
+
}
|
|
1320
|
+
: {}),
|
|
1321
|
+
...(typeof parsed.patch.pitch === "number"
|
|
1322
|
+
? { pitch: parsed.patch.pitch }
|
|
1323
|
+
: {}),
|
|
1324
|
+
...(typeof parsed.patch.velocity === "number"
|
|
1325
|
+
? { velocity: parsed.patch.velocity }
|
|
1326
|
+
: {}),
|
|
1327
|
+
};
|
|
1328
|
+
const next = applyScoreOperation(score, {
|
|
1329
|
+
type: "updateNote",
|
|
1330
|
+
noteId: parsed.noteId,
|
|
1331
|
+
patch,
|
|
1332
|
+
});
|
|
1333
|
+
await commitScore(next, "score.note", {
|
|
1334
|
+
operation: { type: "updateNote", noteId: parsed.noteId, patch },
|
|
1335
|
+
});
|
|
1336
|
+
return `updated · ${parsed.noteId}`;
|
|
1337
|
+
}
|
|
1338
|
+
if (parsed.type !== "add-note") return "queued";
|
|
1339
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1340
|
+
const latest = attempt === 0 ? record : await port.load();
|
|
1341
|
+
const latestScore = scoreFromJSON(latest.composition);
|
|
1342
|
+
const note = {
|
|
1343
|
+
id: `${requestedTrack}-${latest.revision + 1}-${randomUUID().slice(0, 6)}`,
|
|
1344
|
+
trackId: requestedTrack,
|
|
1345
|
+
startTick: Math.round(parsed.start * latestScore.ticksPerBeat),
|
|
1346
|
+
durationTicks: Math.max(
|
|
1347
|
+
1,
|
|
1348
|
+
Math.round(parsed.duration * latestScore.ticksPerBeat),
|
|
1349
|
+
),
|
|
1350
|
+
pitch: parsed.pitch,
|
|
1351
|
+
velocity: parsed.velocity,
|
|
1352
|
+
};
|
|
1353
|
+
const operation: ScoreOperation = { type: "addNote", note };
|
|
1354
|
+
const next = applyScoreOperation(latestScore, operation);
|
|
1355
|
+
try {
|
|
1356
|
+
record = await port.append(
|
|
1357
|
+
latest,
|
|
1358
|
+
{
|
|
1359
|
+
kind: "score.operation",
|
|
1360
|
+
payload: { operation },
|
|
1361
|
+
},
|
|
1362
|
+
next.toJSON(),
|
|
1363
|
+
);
|
|
1364
|
+
score = next;
|
|
1365
|
+
if (clock.playing) void audio.play(score);
|
|
1366
|
+
return `added ${note.id}`;
|
|
1367
|
+
} catch (error) {
|
|
1368
|
+
if (!(error instanceof SessionConflictError)) throw error;
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
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
|
+
);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
async function readLoopFile(path: string): Promise<string> {
|
|
1519
|
+
const contents = await readFile(resolve(path));
|
|
1520
|
+
if (contents.byteLength > 512 * 1024)
|
|
1521
|
+
throw new Error("loop import exceeds 512 KiB");
|
|
1522
|
+
return contents.toString("utf8");
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/**
|
|
1526
|
+
* Adds the focused track to the score when it is missing (an explicit
|
|
1527
|
+
* `--track`, or a draft on its first edit), retrying past concurrent writes.
|
|
1528
|
+
*/
|
|
1529
|
+
async function ensureFocusedTrack(): Promise<void> {
|
|
1530
|
+
if (score.tracks.some((track) => track.id === requestedTrack)) return;
|
|
1531
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1532
|
+
const latest = attempt === 0 ? record : await port.load();
|
|
1533
|
+
const latestScore = scoreFromJSON(latest.composition);
|
|
1534
|
+
if (latestScore.tracks.some((track) => track.id === requestedTrack)) {
|
|
1535
|
+
record = latest;
|
|
1536
|
+
score = latestScore;
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
const next = withTrack(latestScore, requestedTrack);
|
|
1540
|
+
try {
|
|
1541
|
+
record = await port.append(
|
|
1542
|
+
latest,
|
|
1543
|
+
{ kind: "track.attach", payload: { trackId: requestedTrack } },
|
|
1544
|
+
next.toJSON(),
|
|
1545
|
+
);
|
|
1546
|
+
score = next;
|
|
1547
|
+
return;
|
|
1548
|
+
} catch (error) {
|
|
1549
|
+
if (!(error instanceof SessionConflictError)) throw error;
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/** A draft track becomes real on the window's first edit. */
|
|
1555
|
+
async function materializeDraft(): Promise<void> {
|
|
1556
|
+
if (!draftTrack) return;
|
|
1557
|
+
await ensureFocusedTrack();
|
|
1558
|
+
draftTrack = false;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
function adoptMeta(meta: typeof record.meta): void {
|
|
1562
|
+
if (meta.version < record.meta.version) return;
|
|
1563
|
+
record = { ...record, meta };
|
|
1564
|
+
if (meta.name !== announcedName) {
|
|
1565
|
+
announcedName = meta.name;
|
|
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
|
+
);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
function makeNamer(): AutoNamer {
|
|
1576
|
+
return new AutoNamer({
|
|
1577
|
+
target: namingTarget(
|
|
1578
|
+
process.cwd(),
|
|
1579
|
+
() => ({ port, record }),
|
|
1580
|
+
(meta) => adoptMeta(meta),
|
|
1581
|
+
),
|
|
1582
|
+
// DAWG_AI=0 keeps naming local; an offline provider falls back too.
|
|
1583
|
+
generator:
|
|
1584
|
+
process.env.DAWG_AI === "0"
|
|
1585
|
+
? undefined
|
|
1586
|
+
: providerNameGenerator(currentProvider),
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
/** `/sessions`, `/resume`, `/rename`, `/fork`; undefined when not one. */
|
|
1591
|
+
async function sessionCommand(
|
|
1592
|
+
command: string,
|
|
1593
|
+
): Promise<string | Receipt | undefined> {
|
|
1594
|
+
const match = command.match(/^\/(sessions|resume|rename|fork)(?:\s+(.*))?$/i);
|
|
1595
|
+
if (!match) return undefined;
|
|
1596
|
+
const verb = match[1]!.toLowerCase();
|
|
1597
|
+
const arg = (match[2] ?? "").trim();
|
|
1598
|
+
const workspace = process.cwd();
|
|
1599
|
+
if (verb === "rename") {
|
|
1600
|
+
if (!arg)
|
|
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`;
|
|
1604
|
+
if (arg === "--auto") {
|
|
1605
|
+
const result = await port.updateMeta({
|
|
1606
|
+
nameSource: "auto",
|
|
1607
|
+
namedFingerprint: null,
|
|
1608
|
+
namedStructure: null,
|
|
1609
|
+
});
|
|
1610
|
+
adoptMeta(result.meta);
|
|
1611
|
+
namer.request(score);
|
|
1612
|
+
return "auto-naming on";
|
|
1613
|
+
}
|
|
1614
|
+
const name = normalizeSessionName(arg);
|
|
1615
|
+
// Unconditional: the latest user rename wins over any in-flight auto-name.
|
|
1616
|
+
const result = await port.updateMeta({ name, nameSource: "user" });
|
|
1617
|
+
announcedName = result.meta.name;
|
|
1618
|
+
adoptMeta(result.meta);
|
|
1619
|
+
return `renamed · ${result.meta.name}`;
|
|
1620
|
+
}
|
|
1621
|
+
const sessions = await listSessions(workspace);
|
|
1622
|
+
if (verb === "resume" && !arg) {
|
|
1623
|
+
const readable = sessions.filter((session) => !session.error).slice(0, 64);
|
|
1624
|
+
if (readable.length === 0) return warn("no sessions to resume");
|
|
1625
|
+
const current = readable.findIndex(
|
|
1626
|
+
(session) => session.sessionId === record.sessionId,
|
|
1627
|
+
);
|
|
1628
|
+
tui.openPicker({
|
|
1629
|
+
id: "resume",
|
|
1630
|
+
title: "resume session · ↑/↓ Enter · Esc",
|
|
1631
|
+
items: readable.map((session) => ({
|
|
1632
|
+
label: `${session.sessionId === record.sessionId ? "* " : " "}${formatSessionLine(session, sessions)}`,
|
|
1633
|
+
value: session.sessionId,
|
|
1634
|
+
})),
|
|
1635
|
+
index: Math.max(0, current),
|
|
1636
|
+
});
|
|
1637
|
+
return "resume · 1-9 shown · /resume <name|id>";
|
|
1638
|
+
}
|
|
1639
|
+
if (verb === "sessions") {
|
|
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`,
|
|
1649
|
+
);
|
|
1650
|
+
}
|
|
1651
|
+
if (agentTurn) return warn("agent busy · finish or Esc first");
|
|
1652
|
+
if (verb === "fork") {
|
|
1653
|
+
const forked = await forkSession(workspace, record, arg || undefined);
|
|
1654
|
+
await switchSession(forked.sessionId);
|
|
1655
|
+
return `forked · ${forked.meta.name}`;
|
|
1656
|
+
}
|
|
1657
|
+
const readable = sessions.filter((session) => !session.error);
|
|
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}`);
|
|
1670
|
+
await switchSession(sessionId);
|
|
1671
|
+
return `resumed · ${record.meta.name} · ${requestedTrack}${draftTrack ? " (new)" : ""}`;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
/** Leaves the current session and attaches this window to `sessionId`. */
|
|
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;
|
|
1679
|
+
if (clock.playing) await setTransport("pause");
|
|
1680
|
+
namer.dispose();
|
|
1681
|
+
await port.close();
|
|
1682
|
+
const next = await ensureSession(initial.toJSON(), {
|
|
1683
|
+
sessionId,
|
|
1684
|
+
setCurrent: true,
|
|
1685
|
+
});
|
|
1686
|
+
port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
|
|
1687
|
+
paths: next.paths,
|
|
1688
|
+
sessionId,
|
|
1689
|
+
label: "window",
|
|
1690
|
+
focusedTrackId: null,
|
|
1691
|
+
});
|
|
1692
|
+
audio = port.player;
|
|
1693
|
+
record = port.mode === "daemon" ? await port.load() : next.record;
|
|
1694
|
+
score = scoreFromJSON(record.composition);
|
|
1695
|
+
clock.setTempo(score.tempoBpm);
|
|
1696
|
+
const attached = await attachTrack(port, score, undefined);
|
|
1697
|
+
requestedTrack = attached.trackId;
|
|
1698
|
+
draftTrack = attached.draft;
|
|
1699
|
+
announcedName = record.meta.name;
|
|
1700
|
+
namer = makeNamer();
|
|
1701
|
+
rebindPort();
|
|
1702
|
+
if (attached.draft)
|
|
1703
|
+
tui.activity.pushCard(ALL_TRACKS_OPEN_HINT, {
|
|
1704
|
+
tone: "info",
|
|
1705
|
+
trackId: requestedTrack,
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
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
|
+
|
|
1880
|
+
async function commitScore(
|
|
1881
|
+
next: TrackScore,
|
|
1882
|
+
kind: string,
|
|
1883
|
+
payload: Record<string, unknown> = {},
|
|
1884
|
+
): Promise<void> {
|
|
1885
|
+
if (next === score) return;
|
|
1886
|
+
record = await port.append(record, { kind, payload }, next.toJSON());
|
|
1887
|
+
score = next;
|
|
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
|
+
};
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
async function stepHistory(direction: "undo" | "redo"): Promise<Receipt> {
|
|
1932
|
+
const latest = await port.load();
|
|
1933
|
+
// A fork's undo continues into its parent's history past the fork point.
|
|
1934
|
+
const target = historyTarget(
|
|
1935
|
+
latest.composition,
|
|
1936
|
+
await historyEvents(process.cwd(), latest),
|
|
1937
|
+
direction,
|
|
1938
|
+
);
|
|
1939
|
+
if (!target) return warn(`nothing to ${direction}`);
|
|
1940
|
+
try {
|
|
1941
|
+
const restored = scoreFromJSON(target.composition);
|
|
1942
|
+
record = await port.append(
|
|
1943
|
+
latest,
|
|
1944
|
+
{
|
|
1945
|
+
kind: direction === "undo" ? UNDO_KIND : REDO_KIND,
|
|
1946
|
+
payload: {
|
|
1947
|
+
[direction === "undo" ? "undoneRevision" : "redoneRevision"]:
|
|
1948
|
+
target.revision,
|
|
1949
|
+
},
|
|
1950
|
+
},
|
|
1951
|
+
restored.toJSON(),
|
|
1952
|
+
);
|
|
1953
|
+
score = restored;
|
|
1954
|
+
if (clock.playing) void audio.play(score);
|
|
1955
|
+
return ok(
|
|
1956
|
+
`${direction === "undo" ? "undid" : "redid"} · rev ${target.revision}`,
|
|
1957
|
+
);
|
|
1958
|
+
} catch (error) {
|
|
1959
|
+
if (error instanceof SessionConflictError)
|
|
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;
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
async function setTransport(
|
|
1993
|
+
action: "play" | "pause" | "toggle",
|
|
1994
|
+
): Promise<void> {
|
|
1995
|
+
if (port.mode === "daemon") {
|
|
1996
|
+
// dawgd owns the only transport; its broadcast updates `clock`.
|
|
1997
|
+
await port.transport(action);
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
if (action === "play") {
|
|
2001
|
+
clock.play();
|
|
2002
|
+
await audio.play(score, clock.beatAt());
|
|
2003
|
+
} else if (action === "pause") {
|
|
2004
|
+
clock.pause();
|
|
2005
|
+
audio.stop();
|
|
2006
|
+
} else if (clock.playing) {
|
|
2007
|
+
clock.pause();
|
|
2008
|
+
audio.stop();
|
|
2009
|
+
} else {
|
|
2010
|
+
clock.play();
|
|
2011
|
+
await audio.play(score, clock.beatAt());
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
/**
|
|
2016
|
+
* Run one streaming tool-calling turn. Each validated tool call commits its
|
|
2017
|
+
* own revision through `agentHost`, so cancelling keeps every accepted change
|
|
2018
|
+
* and never leaves a half-applied call.
|
|
2019
|
+
*/
|
|
2020
|
+
async function runAgent(text: string): Promise<string | Receipt> {
|
|
2021
|
+
if (agentTurn) return warn("agent busy · Esc cancels");
|
|
2022
|
+
const selection = await currentProvider();
|
|
2023
|
+
if (selection.kind === "offline")
|
|
2024
|
+
return fail(
|
|
2025
|
+
`unrecognized · ${truncateForCard(text)} · ${selection.reason}`,
|
|
2026
|
+
);
|
|
2027
|
+
const turn = { controller: new AbortController(), steering: [] as string[] };
|
|
2028
|
+
agentTurn = turn;
|
|
2029
|
+
reportAgentActivity(`${providerName} · thinking…`);
|
|
2030
|
+
// xcb admits a pending account on its first call, which takes longer.
|
|
2031
|
+
let admitting = selection.kind === "xcb" && selection.admissionPending;
|
|
2032
|
+
if (admitting) tui.activity.setSpinner("admitting account…");
|
|
2033
|
+
try {
|
|
2034
|
+
const result = await runProviderTurn({
|
|
2035
|
+
selection,
|
|
2036
|
+
prompt: text,
|
|
2037
|
+
host: agentHost(turn, selection),
|
|
2038
|
+
signal: turn.controller.signal,
|
|
2039
|
+
onEvent: (event) => {
|
|
2040
|
+
if (admitting && event.type === "step" && event.step <= 1) return;
|
|
2041
|
+
if (admitting) {
|
|
2042
|
+
admitting = false;
|
|
2043
|
+
provider = undefined; // Re-read capabilities: now admitted.
|
|
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
|
+
}
|
|
2061
|
+
agentEventSink(event);
|
|
2062
|
+
},
|
|
2063
|
+
});
|
|
2064
|
+
return describeAgentEvent(result) ?? "agent finished";
|
|
2065
|
+
} finally {
|
|
2066
|
+
if (agentTurn === turn) agentTurn = undefined;
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
function authTone(line: string): "success" | "warning" | "info" {
|
|
2071
|
+
if (line.startsWith("✓")) return "success";
|
|
2072
|
+
if (/^(✗|\?)|not |no |could not|failed/i.test(line)) return "warning";
|
|
2073
|
+
return "info";
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
/**
|
|
2077
|
+
* The cached provider, re-resolved when `~/.config/dawg` config or
|
|
2078
|
+
* credentials change (two stats per call) and, while offline, at most every
|
|
2079
|
+
* 30 s so an account admitted elsewhere is picked up without a restart.
|
|
2080
|
+
*/
|
|
2081
|
+
function currentProvider(): Promise<ProviderSelection> {
|
|
2082
|
+
const fingerprint = providerFingerprint();
|
|
2083
|
+
const stale =
|
|
2084
|
+
fingerprint !== providerStamp.fingerprint ||
|
|
2085
|
+
(providerStamp.offline && Date.now() - providerStamp.at > 30_000);
|
|
2086
|
+
if (provider && stale) provider = undefined;
|
|
2087
|
+
if (!provider) {
|
|
2088
|
+
providerStamp = { fingerprint, at: Date.now(), offline: false };
|
|
2089
|
+
provider = selectProvider().catch((): ProviderSelection => ({
|
|
2090
|
+
kind: "offline",
|
|
2091
|
+
choice: "auto",
|
|
2092
|
+
reason: "provider unavailable; run `dawg login`",
|
|
2093
|
+
}));
|
|
2094
|
+
}
|
|
2095
|
+
return provider.then((selection) => {
|
|
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
|
+
}
|
|
2109
|
+
providerStamp.offline = selection.kind === "offline";
|
|
2110
|
+
return selection;
|
|
2111
|
+
});
|
|
2112
|
+
}
|
|
2113
|
+
|
|
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 {
|
|
2123
|
+
return {
|
|
2124
|
+
snapshot: () => ({
|
|
2125
|
+
score,
|
|
2126
|
+
revision: record.revision,
|
|
2127
|
+
focusedTrackId: requestedTrack,
|
|
2128
|
+
recentOperations: record.events.slice(-8).map((event) => {
|
|
2129
|
+
const payload = event.payload as { summary?: unknown } | null;
|
|
2130
|
+
return typeof payload?.summary === "string"
|
|
2131
|
+
? `${event.kind}: ${payload.summary}`
|
|
2132
|
+
: event.kind;
|
|
2133
|
+
}),
|
|
2134
|
+
}),
|
|
2135
|
+
media: mediaServices(),
|
|
2136
|
+
async commit(change) {
|
|
2137
|
+
// dawgd rebases operation intents onto newer revisions when nothing
|
|
2138
|
+
// they touch changed; the file port keeps the strict base check.
|
|
2139
|
+
if (port.mode !== "daemon" && change.baseRevision !== record.revision)
|
|
2140
|
+
throw new StaleRevisionError(change.baseRevision, record.revision);
|
|
2141
|
+
try {
|
|
2142
|
+
record = await port.appendOperations(
|
|
2143
|
+
{ ...record, revision: change.baseRevision },
|
|
2144
|
+
{
|
|
2145
|
+
kind: "agent.tool",
|
|
2146
|
+
payload: {
|
|
2147
|
+
tool: change.toolName,
|
|
2148
|
+
callId: change.callId,
|
|
2149
|
+
summary: change.summary,
|
|
2150
|
+
operations: change.operations,
|
|
2151
|
+
},
|
|
2152
|
+
},
|
|
2153
|
+
change.operations,
|
|
2154
|
+
change.next.toJSON(),
|
|
2155
|
+
);
|
|
2156
|
+
// A rebased commit lands on a newer score than `change.next`.
|
|
2157
|
+
score = scoreFromJSON(record.composition);
|
|
2158
|
+
if (clock.playing) void audio.play(score);
|
|
2159
|
+
} catch (error) {
|
|
2160
|
+
if (error instanceof SessionConflictError)
|
|
2161
|
+
throw new StaleRevisionError(
|
|
2162
|
+
change.baseRevision,
|
|
2163
|
+
record.revision + 1,
|
|
2164
|
+
);
|
|
2165
|
+
throw error;
|
|
2166
|
+
}
|
|
2167
|
+
if (change.operations.some((operation) => operation.type === "setTempo"))
|
|
2168
|
+
clock.setTempo(score.tempoBpm);
|
|
2169
|
+
return { revision: record.revision };
|
|
2170
|
+
},
|
|
2171
|
+
async transport(action) {
|
|
2172
|
+
await setTransport(action);
|
|
2173
|
+
try {
|
|
2174
|
+
record = await port.append(
|
|
2175
|
+
record,
|
|
2176
|
+
{
|
|
2177
|
+
kind: "transport",
|
|
2178
|
+
payload: { action, playing: clock.playing, beat: clock.beatAt() },
|
|
2179
|
+
},
|
|
2180
|
+
score.toJSON(),
|
|
2181
|
+
);
|
|
2182
|
+
} catch (error) {
|
|
2183
|
+
if (!(error instanceof SessionConflictError)) throw error;
|
|
2184
|
+
}
|
|
2185
|
+
},
|
|
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
|
+
),
|
|
2208
|
+
};
|
|
2209
|
+
}
|