@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
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typechecks a project with the TypeScript compiler dawg depends on.
|
|
3
|
+
*
|
|
4
|
+
* TypeScript 7 is a native binary with no JavaScript watch API, so each check
|
|
5
|
+
* is one `tsc --noEmit --incremental` run whose build info lives in
|
|
6
|
+
* `.dawg/tsbuild/`; a warm check on a small project takes tens of
|
|
7
|
+
* milliseconds. Diagnostics come back as `file:line:col message`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { realpathSync } from "node:fs";
|
|
11
|
+
import { mkdir } from "node:fs/promises";
|
|
12
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import type { Diagnostic } from "../../core/sdk/eval.ts";
|
|
15
|
+
import type { CommandRunner } from "../auth/runner.ts";
|
|
16
|
+
import { systemRunner } from "../auth/runner.ts";
|
|
17
|
+
|
|
18
|
+
export const TYPECHECK_TIMEOUT_MS = 60_000;
|
|
19
|
+
const MAX_DIAGNOSTICS = 200;
|
|
20
|
+
|
|
21
|
+
export type TypecheckResult = Readonly<{
|
|
22
|
+
ok: boolean;
|
|
23
|
+
diagnostics: readonly Diagnostic[];
|
|
24
|
+
ms: number;
|
|
25
|
+
}>;
|
|
26
|
+
|
|
27
|
+
export type TypecheckOptions = Readonly<{
|
|
28
|
+
runner?: CommandRunner;
|
|
29
|
+
/** Path of the `tsc` executable; resolved from the dependency by default. */
|
|
30
|
+
tsc?: string;
|
|
31
|
+
}>;
|
|
32
|
+
|
|
33
|
+
/** Native `tsc` from `@typescript/typescript-<platform>-<arch>`, else `tsc` on PATH. */
|
|
34
|
+
export function resolveTsc(
|
|
35
|
+
runner: CommandRunner = systemRunner,
|
|
36
|
+
): string | undefined {
|
|
37
|
+
const name = `@typescript/typescript-${process.platform}-${process.arch}/package.json`;
|
|
38
|
+
try {
|
|
39
|
+
const url = import.meta.resolve(name);
|
|
40
|
+
return join(dirname(fileURLToPath(url)), "lib", "tsc");
|
|
41
|
+
} catch {
|
|
42
|
+
return runner.which("tsc");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Runs `tsc` on `<project>/tsconfig.json` and parses its diagnostics. */
|
|
47
|
+
export async function typecheckProject(
|
|
48
|
+
project: string,
|
|
49
|
+
options: TypecheckOptions = {},
|
|
50
|
+
): Promise<TypecheckResult> {
|
|
51
|
+
const started = performance.now();
|
|
52
|
+
const runner = options.runner ?? systemRunner;
|
|
53
|
+
const tsc = options.tsc ?? resolveTsc(runner);
|
|
54
|
+
if (!tsc)
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
diagnostics: [{ message: "typescript is not installed (no tsc found)" }],
|
|
58
|
+
ms: elapsed(started),
|
|
59
|
+
};
|
|
60
|
+
const stateDir = join(project, ".dawg", "tsbuild");
|
|
61
|
+
await mkdir(stateDir, { recursive: true });
|
|
62
|
+
const result = await runner.run(
|
|
63
|
+
tsc,
|
|
64
|
+
[
|
|
65
|
+
"--noEmit",
|
|
66
|
+
"--pretty",
|
|
67
|
+
"false",
|
|
68
|
+
"--incremental",
|
|
69
|
+
"--tsBuildInfoFile",
|
|
70
|
+
join(stateDir, "project.tsbuildinfo"),
|
|
71
|
+
"-p",
|
|
72
|
+
join(project, "tsconfig.json"),
|
|
73
|
+
],
|
|
74
|
+
{
|
|
75
|
+
timeoutMs: TYPECHECK_TIMEOUT_MS,
|
|
76
|
+
maxOutputBytes: 1024 * 1024,
|
|
77
|
+
env: { NO_COLOR: "1" },
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
if (result.killed)
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
diagnostics: [{ message: "typecheck timed out" }],
|
|
84
|
+
ms: elapsed(started),
|
|
85
|
+
};
|
|
86
|
+
const diagnostics = parseTscOutput(
|
|
87
|
+
`${result.stdout}\n${result.stderr}`,
|
|
88
|
+
project,
|
|
89
|
+
);
|
|
90
|
+
if (result.code !== 0 && diagnostics.length === 0)
|
|
91
|
+
diagnostics.push({
|
|
92
|
+
message: `tsc exited with code ${result.code}${
|
|
93
|
+
result.stderr.trim() ? `: ${result.stderr.trim().split("\n")[0]}` : ""
|
|
94
|
+
}`,
|
|
95
|
+
});
|
|
96
|
+
return {
|
|
97
|
+
ok: result.code === 0 && diagnostics.length === 0,
|
|
98
|
+
diagnostics,
|
|
99
|
+
ms: elapsed(started),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Parses `file(line,col): error TSnnnn: message` lines. tsc prints paths
|
|
105
|
+
* relative to its working directory; they come back project-relative.
|
|
106
|
+
*/
|
|
107
|
+
export function parseTscOutput(
|
|
108
|
+
output: string,
|
|
109
|
+
project: string,
|
|
110
|
+
cwd: string = process.cwd(),
|
|
111
|
+
): Diagnostic[] {
|
|
112
|
+
const out: Diagnostic[] = [];
|
|
113
|
+
const roots = [project];
|
|
114
|
+
try {
|
|
115
|
+
roots.push(realpathSync(project));
|
|
116
|
+
} catch {
|
|
117
|
+
// An unreadable project keeps the literal path.
|
|
118
|
+
}
|
|
119
|
+
const relativize = (file: string): string => {
|
|
120
|
+
const absolute = isAbsolute(file) ? file : resolve(cwd, file);
|
|
121
|
+
for (const root of roots) {
|
|
122
|
+
const rel = relative(root, absolute);
|
|
123
|
+
if (rel && !rel.startsWith("..") && !isAbsolute(rel)) return rel;
|
|
124
|
+
}
|
|
125
|
+
return file;
|
|
126
|
+
};
|
|
127
|
+
for (const raw of output.split("\n")) {
|
|
128
|
+
const line = raw.trimEnd();
|
|
129
|
+
const match = line.match(/^(.*?)\((\d+),(\d+)\): error (TS\d+): (.*)$/);
|
|
130
|
+
if (match) {
|
|
131
|
+
const file = relativize(match[1]!);
|
|
132
|
+
out.push({
|
|
133
|
+
file,
|
|
134
|
+
line: Number(match[2]),
|
|
135
|
+
col: Number(match[3]),
|
|
136
|
+
message: `${match[5]} (${match[4]})`,
|
|
137
|
+
});
|
|
138
|
+
} else {
|
|
139
|
+
const bare = line.match(/^error (TS\d+): (.*)$/);
|
|
140
|
+
if (bare) out.push({ message: `${bare[2]} (${bare[1]})` });
|
|
141
|
+
}
|
|
142
|
+
if (out.length >= MAX_DIAGNOSTICS) break;
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function elapsed(started: number): number {
|
|
148
|
+
return Math.round(performance.now() - started);
|
|
149
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dawg render <out.wav>`: renders a session, the project files (`song.ts`,
|
|
3
|
+
* in a project with no `--session`), or a `track.loop/v1` file to
|
|
4
|
+
* a stereo 16-bit PCM WAV through the same deterministic renderer playback
|
|
5
|
+
* uses, so two renders of one score are byte-identical. It reads the session
|
|
6
|
+
* record from disk and never starts dawgd or plays audio.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
import { scoreFromJSON, type TrackScore } from "../core/score.ts";
|
|
12
|
+
import { decodeLoop } from "../core/loop.ts";
|
|
13
|
+
import { renderScoreWav } from "./audio/wav.ts";
|
|
14
|
+
import { SampleLibrary, hasSamplerTracks } from "./audio/samples.ts";
|
|
15
|
+
import { evaluateProject, formatDiagnostic } from "../core/sdk/eval.ts";
|
|
16
|
+
import { isProject } from "./project/init.ts";
|
|
17
|
+
import { resolveSessionArg } from "./session/attach.ts";
|
|
18
|
+
import {
|
|
19
|
+
loadSession,
|
|
20
|
+
readCurrentSessionId,
|
|
21
|
+
sessionPaths,
|
|
22
|
+
} from "./session/store.ts";
|
|
23
|
+
|
|
24
|
+
export const RENDER_USAGE =
|
|
25
|
+
"usage: dawg render <out.wav> [--session <name|id>] [--import <file.track.json>]";
|
|
26
|
+
|
|
27
|
+
const MAX_LOOP_FILE_BYTES = 512 * 1024;
|
|
28
|
+
|
|
29
|
+
type Output = { write(text: string): unknown };
|
|
30
|
+
|
|
31
|
+
export async function runRenderCommand(
|
|
32
|
+
argv: readonly string[],
|
|
33
|
+
workspace: string,
|
|
34
|
+
stdout: Output,
|
|
35
|
+
stderr: Output,
|
|
36
|
+
): Promise<number> {
|
|
37
|
+
const rest = argv.slice(1);
|
|
38
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
39
|
+
stdout.write(`${RENDER_USAGE}\n`);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
const options = new Map<string, string>();
|
|
43
|
+
const positional: string[] = [];
|
|
44
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
45
|
+
const arg = rest[index]!;
|
|
46
|
+
if (arg === "--session" || arg === "--import") {
|
|
47
|
+
const value = rest[index + 1];
|
|
48
|
+
if (value === undefined || value.startsWith("--")) {
|
|
49
|
+
stderr.write(`${arg} needs a value · ${RENDER_USAGE}\n`);
|
|
50
|
+
return 2;
|
|
51
|
+
}
|
|
52
|
+
options.set(arg, value);
|
|
53
|
+
index += 1;
|
|
54
|
+
} else if (arg.startsWith("--")) {
|
|
55
|
+
stderr.write(`unknown option · ${arg} · ${RENDER_USAGE}\n`);
|
|
56
|
+
return 2;
|
|
57
|
+
} else positional.push(arg);
|
|
58
|
+
}
|
|
59
|
+
const target = positional[0];
|
|
60
|
+
if (positional.length !== 1 || !target || !/\.wav$/i.test(target)) {
|
|
61
|
+
stderr.write(`${RENDER_USAGE}\n`);
|
|
62
|
+
return 2;
|
|
63
|
+
}
|
|
64
|
+
let score: TrackScore;
|
|
65
|
+
try {
|
|
66
|
+
score = await loadScore(workspace, options);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
stderr.write(
|
|
69
|
+
`render failed · ${error instanceof Error ? error.message : String(error)}\n`,
|
|
70
|
+
);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
let samples;
|
|
74
|
+
if (hasSamplerTracks(score)) {
|
|
75
|
+
samples = await new SampleLibrary({ projectRoot: workspace }).load(score);
|
|
76
|
+
for (const problem of samples.problems)
|
|
77
|
+
stderr.write(`sample ${problem.level} · ${problem.message}\n`);
|
|
78
|
+
}
|
|
79
|
+
const wav = renderScoreWav(score, { samples });
|
|
80
|
+
const path = resolve(workspace, target);
|
|
81
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
82
|
+
await writeFile(temporary, wav);
|
|
83
|
+
await rename(temporary, path);
|
|
84
|
+
const sha = createHash("sha256").update(wav).digest("hex");
|
|
85
|
+
stdout.write(`rendered · ${target} · ${wav.byteLength} bytes · ${sha}\n`);
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function loadScore(
|
|
90
|
+
workspace: string,
|
|
91
|
+
options: ReadonlyMap<string, string>,
|
|
92
|
+
): Promise<TrackScore> {
|
|
93
|
+
const importPath = options.get("--import");
|
|
94
|
+
if (importPath !== undefined) {
|
|
95
|
+
const contents = await readFile(resolve(workspace, importPath));
|
|
96
|
+
if (contents.byteLength > MAX_LOOP_FILE_BYTES)
|
|
97
|
+
throw new Error("loop import exceeds 512 KiB");
|
|
98
|
+
return decodeLoop(contents.toString("utf8"));
|
|
99
|
+
}
|
|
100
|
+
const query = options.get("--session");
|
|
101
|
+
if (query === undefined && (await isProject(workspace))) {
|
|
102
|
+
const evaluated = await evaluateProject(workspace);
|
|
103
|
+
if (!evaluated.ok)
|
|
104
|
+
throw new Error(
|
|
105
|
+
`${evaluated.diagnostics.map(formatDiagnostic).join("; ").slice(0, 400)} · fix song.ts and retry (dawg check)`,
|
|
106
|
+
);
|
|
107
|
+
return evaluated.score;
|
|
108
|
+
}
|
|
109
|
+
const sessionId =
|
|
110
|
+
query === undefined
|
|
111
|
+
? await readCurrentSessionId(workspace)
|
|
112
|
+
: await resolveSessionArg(workspace, query);
|
|
113
|
+
if (sessionId === undefined)
|
|
114
|
+
throw new Error("no session here · run dawg first or pass --import");
|
|
115
|
+
const record = await loadSession<unknown>(
|
|
116
|
+
sessionPaths(workspace, sessionId),
|
|
117
|
+
).catch(() => {
|
|
118
|
+
throw new Error(`no session named "${query ?? sessionId}" · dawg sessions`);
|
|
119
|
+
});
|
|
120
|
+
return scoreFromJSON(record.composition);
|
|
121
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Glue between the TUI and sessions: resolving `--session <name|id>`,
|
|
3
|
+
* choosing a track for a newly opened window, materializing draft tracks,
|
|
4
|
+
* forking, and the auto-namer's port-backed target. Kept out of main.ts so
|
|
5
|
+
* the entry point only wires these together.
|
|
6
|
+
*/
|
|
7
|
+
import { isDrumInstrument } from "../../core/drums.ts";
|
|
8
|
+
import type { TrackScore } from "../../core/score.ts";
|
|
9
|
+
import {
|
|
10
|
+
ambiguousSessionMessage,
|
|
11
|
+
listSessions,
|
|
12
|
+
resolveSession,
|
|
13
|
+
type SessionSummary,
|
|
14
|
+
} from "./list.ts";
|
|
15
|
+
import {
|
|
16
|
+
forkName,
|
|
17
|
+
normalizeSessionName,
|
|
18
|
+
uniqueName,
|
|
19
|
+
type SessionMeta,
|
|
20
|
+
} from "./meta.ts";
|
|
21
|
+
import type { NamingTarget } from "./naming.ts";
|
|
22
|
+
import type { SessionPort } from "./port.ts";
|
|
23
|
+
import {
|
|
24
|
+
createForkSession,
|
|
25
|
+
inheritedEvents,
|
|
26
|
+
setCurrentSession,
|
|
27
|
+
type SessionEvent,
|
|
28
|
+
type SessionRecord,
|
|
29
|
+
} from "./store.ts";
|
|
30
|
+
|
|
31
|
+
export const ALL_TRACKS_OPEN_HINT = "all tracks open · new track";
|
|
32
|
+
|
|
33
|
+
export class SessionLookupError extends Error {
|
|
34
|
+
public constructor(message: string) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "SessionLookupError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Maps `--session <name|id>` to the id of an existing session. An unknown
|
|
42
|
+
* value throws (a typo must not create a new session; `--new` does that);
|
|
43
|
+
* an ambiguous name throws with the candidate list.
|
|
44
|
+
*/
|
|
45
|
+
export async function resolveSessionArg(
|
|
46
|
+
workspace: string,
|
|
47
|
+
query: string,
|
|
48
|
+
sessions?: readonly SessionSummary[],
|
|
49
|
+
): Promise<string> {
|
|
50
|
+
const listed = sessions ?? (await listSessions(workspace));
|
|
51
|
+
const result = resolveSession(listed, query);
|
|
52
|
+
if (result.status === "found") return result.session.sessionId;
|
|
53
|
+
if (result.status === "ambiguous")
|
|
54
|
+
throw new SessionLookupError(
|
|
55
|
+
ambiguousSessionMessage(query, result.candidates),
|
|
56
|
+
);
|
|
57
|
+
throw new SessionLookupError(`no session named "${query}" · dawg sessions`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type TrackAttachment = {
|
|
61
|
+
trackId: string;
|
|
62
|
+
/** Reserved id not yet in the score; created on the first edit. */
|
|
63
|
+
draft: boolean;
|
|
64
|
+
/** How the track was chosen, for the activity strip. */
|
|
65
|
+
reason: "explicit" | "claimed" | "draft";
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `--track` always wins (focus is published but not exclusive). Otherwise
|
|
70
|
+
* atomically claim the first track no live window has focused, or a draft.
|
|
71
|
+
*/
|
|
72
|
+
export async function attachTrack<T>(
|
|
73
|
+
port: SessionPort<T>,
|
|
74
|
+
score: TrackScore,
|
|
75
|
+
explicit: string | undefined,
|
|
76
|
+
): Promise<TrackAttachment> {
|
|
77
|
+
if (explicit !== undefined) {
|
|
78
|
+
await port.focus(explicit).catch(() => undefined);
|
|
79
|
+
return { trackId: explicit, draft: false, reason: "explicit" };
|
|
80
|
+
}
|
|
81
|
+
const claim = await port.claimOrDraft(score.tracks.map((track) => track.id));
|
|
82
|
+
return {
|
|
83
|
+
trackId: claim.trackId,
|
|
84
|
+
draft: claim.draft,
|
|
85
|
+
reason: claim.draft ? "draft" : "claimed",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Score with `trackId` present: a draft becomes a real track here. */
|
|
90
|
+
export function withTrack(score: TrackScore, trackId: string): TrackScore {
|
|
91
|
+
if (score.tracks.some((track) => track.id === trackId)) return score;
|
|
92
|
+
const draft = trackId.match(/^track-(\d+)$/);
|
|
93
|
+
return score.withTracks([
|
|
94
|
+
...score.tracks,
|
|
95
|
+
{
|
|
96
|
+
id: trackId,
|
|
97
|
+
name: draft ? `track ${draft[1]}` : trackId,
|
|
98
|
+
instrument: isDrumInstrument(trackId) ? "kit" : "sine",
|
|
99
|
+
},
|
|
100
|
+
]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Copies `source` into a new session named per the fork rules. */
|
|
104
|
+
export async function forkSession<T>(
|
|
105
|
+
workspace: string,
|
|
106
|
+
source: SessionRecord<T>,
|
|
107
|
+
requested: string | undefined,
|
|
108
|
+
): Promise<SessionRecord<T>> {
|
|
109
|
+
const others = (await listSessions(workspace))
|
|
110
|
+
.filter((session) => !session.error)
|
|
111
|
+
.map((session) => session.name);
|
|
112
|
+
const name =
|
|
113
|
+
requested !== undefined && requested.trim().length > 0
|
|
114
|
+
? uniqueName(normalizeSessionName(requested), others)
|
|
115
|
+
: forkName(source.meta.name, others);
|
|
116
|
+
const record = await createForkSession(workspace, source, name);
|
|
117
|
+
await setCurrentSession(workspace, record.sessionId);
|
|
118
|
+
return record;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const inheritedCache = new Map<string, Promise<SessionEvent[]>>();
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The event log undo/redo replays: the fork ancestry's prefix (see
|
|
125
|
+
* `inheritedEvents`), then this session's own events. Undo in a fork can
|
|
126
|
+
* therefore step back past the fork point into the parent's edits. The
|
|
127
|
+
* inherited prefix never changes, so it is cached per lineage.
|
|
128
|
+
*/
|
|
129
|
+
export async function historyEvents<T>(
|
|
130
|
+
workspace: string,
|
|
131
|
+
record: SessionRecord<T>,
|
|
132
|
+
): Promise<SessionEvent[]> {
|
|
133
|
+
const fork = record.meta.forkOf;
|
|
134
|
+
if (!fork) return record.events;
|
|
135
|
+
const key = `${workspace}\0${record.sessionId}\0${fork.sessionId}@${fork.revision}`;
|
|
136
|
+
let inherited = inheritedCache.get(key);
|
|
137
|
+
if (!inherited) {
|
|
138
|
+
inherited = inheritedEvents(workspace, record.meta, record.sessionId);
|
|
139
|
+
if (inheritedCache.size > 32) inheritedCache.clear();
|
|
140
|
+
inheritedCache.set(key, inherited);
|
|
141
|
+
}
|
|
142
|
+
return [...(await inherited), ...record.events];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The auto-namer's view of the live session. */
|
|
146
|
+
export function namingTarget<T>(
|
|
147
|
+
workspace: string,
|
|
148
|
+
current: () => { port: SessionPort<T>; record: SessionRecord<T> },
|
|
149
|
+
adopt: (meta: SessionMeta) => void,
|
|
150
|
+
): NamingTarget {
|
|
151
|
+
return {
|
|
152
|
+
meta: () => current().record.meta,
|
|
153
|
+
updateMeta: async (patch, expect) => {
|
|
154
|
+
const result = await current().port.updateMeta(patch, expect);
|
|
155
|
+
adopt(result.meta);
|
|
156
|
+
return result;
|
|
157
|
+
},
|
|
158
|
+
otherNames: async () => {
|
|
159
|
+
const id = current().record.sessionId;
|
|
160
|
+
return (await listSessions(workspace))
|
|
161
|
+
.filter((session) => !session.error && session.sessionId !== id)
|
|
162
|
+
.map((session) => session.name);
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** `/resume` picker text: numbered recent sessions. */
|
|
168
|
+
export function pickerLines(
|
|
169
|
+
sessions: readonly SessionSummary[],
|
|
170
|
+
currentId: string,
|
|
171
|
+
format: (session: SessionSummary) => string,
|
|
172
|
+
limit = 9,
|
|
173
|
+
): string[] {
|
|
174
|
+
return sessions
|
|
175
|
+
.filter((session) => !session.error)
|
|
176
|
+
.slice(0, limit)
|
|
177
|
+
.map(
|
|
178
|
+
(session, index) =>
|
|
179
|
+
`${index + 1}${session.sessionId === currentId ? "*" : " "} ${format(session)}`,
|
|
180
|
+
);
|
|
181
|
+
}
|