@hraness/dawg 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -0
- package/DAWG.md +196 -11
- package/README.md +79 -30
- package/core/diff.ts +249 -0
- package/core/key.ts +43 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +324 -1
- package/core/sdk/eval-child.ts +113 -0
- package/core/sdk/eval.ts +257 -0
- package/core/sdk/print.ts +393 -0
- package/core/sdk/v1.ts +954 -0
- package/core/slug.ts +19 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +280 -14
- package/src/agent/brief.ts +23 -3
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/planner.ts +38 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +398 -1
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +27 -12
- package/src/agent/xcb.ts +233 -21
- package/src/audio/click.ts +125 -0
- package/src/audio/engine.ts +407 -33
- package/src/audio/live.ts +152 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +292 -0
- package/src/audio/samples.ts +683 -0
- package/src/audio/wav.ts +290 -76
- package/src/auth/cli.ts +146 -27
- package/src/auth/credentials.ts +167 -41
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +885 -128
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +25 -2
- package/src/auth/tui.ts +60 -43
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +24 -7
- package/src/commands/sample.ts +302 -0
- package/src/main.ts +1043 -166
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +173 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +292 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +28 -6
- package/src/session/attach.ts +3 -4
- package/src/session/daemon.ts +25 -8
- package/src/session/delta.ts +249 -0
- package/src/session/naming.ts +3 -37
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/menu.ts +1083 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +636 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +681 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +261 -18
- package/tui/drums.ts +44 -0
- package/tui/highway.ts +18 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +143 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project layout on disk:
|
|
3
|
+
*
|
|
4
|
+
* dawg.json project marker: format and SDK major
|
|
5
|
+
* tsconfig.json strict, `"dawg"` → `.dawg/sdk/v1.ts`
|
|
6
|
+
* song.ts `export default song({ ..., tracks: [...] })`
|
|
7
|
+
* tracks/<slug>/track.ts one `track({...})` per track (+ `samples/`)
|
|
8
|
+
* .dawg/sdk/v1.ts vendored SDK, refreshed when the shipped copy is newer
|
|
9
|
+
* .dawg/sdk/tsconfig.json compiler options the root config extends
|
|
10
|
+
* .dawg/tsbuild/ incremental typecheck state
|
|
11
|
+
* .dawg/sync.json hashes of files dawg wrote (echo suppression)
|
|
12
|
+
*
|
|
13
|
+
* `initProject` is idempotent, writes only under `dir`, and reports each
|
|
14
|
+
* file it created or refreshed exactly once.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
19
|
+
import { dirname, join, resolve } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
export const PROJECT_FILE = "dawg.json";
|
|
23
|
+
export const PROJECT_FORMAT = "dawg.project/v1";
|
|
24
|
+
export const SDK_RELATIVE_PATH = ".dawg/sdk/v1.ts";
|
|
25
|
+
export const SDK_TSCONFIG_RELATIVE_PATH = ".dawg/sdk/tsconfig.json";
|
|
26
|
+
|
|
27
|
+
/** The SDK source dawg ships (`core/sdk/v1.ts`). */
|
|
28
|
+
export const SHIPPED_SDK_PATH = join(
|
|
29
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
30
|
+
"..",
|
|
31
|
+
"..",
|
|
32
|
+
"core",
|
|
33
|
+
"sdk",
|
|
34
|
+
"v1.ts",
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
export type ProjectManifest = Readonly<{ format: string; sdk: number }>;
|
|
38
|
+
|
|
39
|
+
export type InitResult = Readonly<{
|
|
40
|
+
/** Project-relative paths created or refreshed, in write order. */
|
|
41
|
+
wrote: readonly string[];
|
|
42
|
+
/** SDK version now vendored in the project. */
|
|
43
|
+
sdkVersion: string;
|
|
44
|
+
}>;
|
|
45
|
+
|
|
46
|
+
const SDK_TSCONFIG = `${JSON.stringify(
|
|
47
|
+
{
|
|
48
|
+
compilerOptions: {
|
|
49
|
+
target: "ES2022",
|
|
50
|
+
module: "ESNext",
|
|
51
|
+
moduleResolution: "Bundler",
|
|
52
|
+
strict: true,
|
|
53
|
+
noUncheckedIndexedAccess: true,
|
|
54
|
+
noEmit: true,
|
|
55
|
+
skipLibCheck: true,
|
|
56
|
+
allowImportingTsExtensions: true,
|
|
57
|
+
types: [],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
null,
|
|
61
|
+
2,
|
|
62
|
+
)}\n`;
|
|
63
|
+
|
|
64
|
+
const ROOT_TSCONFIG = `${JSON.stringify(
|
|
65
|
+
{
|
|
66
|
+
extends: "./.dawg/sdk/tsconfig.json",
|
|
67
|
+
compilerOptions: { paths: { dawg: ["./.dawg/sdk/v1.ts"] } },
|
|
68
|
+
include: ["song.ts", "tracks/**/*.ts", ".dawg/sdk/*.ts"],
|
|
69
|
+
},
|
|
70
|
+
null,
|
|
71
|
+
2,
|
|
72
|
+
)}\n`;
|
|
73
|
+
|
|
74
|
+
const EMPTY_SONG = `import { song } from "dawg";
|
|
75
|
+
|
|
76
|
+
export default song({
|
|
77
|
+
tempo: 120,
|
|
78
|
+
meter: [4, 4],
|
|
79
|
+
bars: 4,
|
|
80
|
+
tracks: [],
|
|
81
|
+
});
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
const GITIGNORE_LINES = [".dawg/*", "!.dawg/sdk/"];
|
|
85
|
+
|
|
86
|
+
/** True when `dir` holds a `dawg.json`. */
|
|
87
|
+
export async function isProject(dir: string): Promise<boolean> {
|
|
88
|
+
return (await readManifest(dir)) !== undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Parses `dawg.json` from unknown; undefined when absent or unreadable. */
|
|
92
|
+
export async function readManifest(
|
|
93
|
+
dir: string,
|
|
94
|
+
): Promise<ProjectManifest | undefined> {
|
|
95
|
+
let text: string;
|
|
96
|
+
try {
|
|
97
|
+
text = await readFile(join(dir, PROJECT_FILE), "utf8");
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
if (text.length > 4096) return undefined;
|
|
102
|
+
try {
|
|
103
|
+
const value: unknown = JSON.parse(text);
|
|
104
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
105
|
+
const record = value as Record<string, unknown>;
|
|
106
|
+
if (record.format !== PROJECT_FORMAT) return undefined;
|
|
107
|
+
if (typeof record.sdk !== "number" || !Number.isInteger(record.sdk))
|
|
108
|
+
return undefined;
|
|
109
|
+
return Object.freeze({ format: PROJECT_FORMAT, sdk: record.sdk });
|
|
110
|
+
} catch {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Version string declared by an SDK source file, or undefined. */
|
|
116
|
+
export function sdkVersionOf(source: string): string | undefined {
|
|
117
|
+
const match = source.match(/SDK_VERSION = "(\d+)\.(\d+)\.(\d+)"/);
|
|
118
|
+
return match ? `${match[1]}.${match[2]}.${match[3]}` : undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function newer(candidate: string, current: string | undefined): boolean {
|
|
122
|
+
if (current === undefined) return true;
|
|
123
|
+
const a = candidate.split(".").map(Number);
|
|
124
|
+
const b = current.split(".").map(Number);
|
|
125
|
+
if (a[0] !== b[0]) return false; // a different major is never auto-applied
|
|
126
|
+
for (let index = 1; index < 3; index += 1)
|
|
127
|
+
if (a[index]! !== b[index]!) return a[index]! > b[index]!;
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type InitOptions = Readonly<{
|
|
132
|
+
/** SDK source to vendor; defaults to the shipped `core/sdk/v1.ts`. */
|
|
133
|
+
sdkSource?: string;
|
|
134
|
+
}>;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Creates or refreshes the project in `dir`. Existing `song.ts`,
|
|
138
|
+
* `tsconfig.json`, `.gitignore` entries and `dawg.json` are left alone;
|
|
139
|
+
* the vendored SDK is replaced only by a newer copy of the same major.
|
|
140
|
+
*/
|
|
141
|
+
export async function initProject(
|
|
142
|
+
dir: string,
|
|
143
|
+
options: InitOptions = {},
|
|
144
|
+
): Promise<InitResult> {
|
|
145
|
+
const wrote: string[] = [];
|
|
146
|
+
const sdkSource =
|
|
147
|
+
options.sdkSource ?? (await readFile(SHIPPED_SDK_PATH, "utf8"));
|
|
148
|
+
const shippedVersion = sdkVersionOf(sdkSource);
|
|
149
|
+
if (!shippedVersion) throw new Error("shipped SDK has no SDK_VERSION");
|
|
150
|
+
const major = Number(shippedVersion.split(".")[0]);
|
|
151
|
+
|
|
152
|
+
await mkdir(join(dir, ".dawg", "sdk"), { recursive: true });
|
|
153
|
+
await mkdir(join(dir, "tracks"), { recursive: true });
|
|
154
|
+
|
|
155
|
+
const vendored = await readOptional(join(dir, SDK_RELATIVE_PATH));
|
|
156
|
+
const vendoredVersion =
|
|
157
|
+
vendored === undefined ? undefined : sdkVersionOf(vendored);
|
|
158
|
+
let sdkVersion = vendoredVersion ?? shippedVersion;
|
|
159
|
+
if (vendored === undefined || newer(shippedVersion, vendoredVersion)) {
|
|
160
|
+
await writeAtomic(join(dir, SDK_RELATIVE_PATH), sdkSource);
|
|
161
|
+
wrote.push(SDK_RELATIVE_PATH);
|
|
162
|
+
sdkVersion = shippedVersion;
|
|
163
|
+
}
|
|
164
|
+
if (
|
|
165
|
+
(await readOptional(join(dir, SDK_TSCONFIG_RELATIVE_PATH))) !== SDK_TSCONFIG
|
|
166
|
+
) {
|
|
167
|
+
await writeAtomic(join(dir, SDK_TSCONFIG_RELATIVE_PATH), SDK_TSCONFIG);
|
|
168
|
+
wrote.push(SDK_TSCONFIG_RELATIVE_PATH);
|
|
169
|
+
}
|
|
170
|
+
if ((await readManifest(dir)) === undefined) {
|
|
171
|
+
await writeAtomic(
|
|
172
|
+
join(dir, PROJECT_FILE),
|
|
173
|
+
`${JSON.stringify({ format: PROJECT_FORMAT, sdk: major }, null, 2)}\n`,
|
|
174
|
+
);
|
|
175
|
+
wrote.push(PROJECT_FILE);
|
|
176
|
+
}
|
|
177
|
+
if ((await readOptional(join(dir, "tsconfig.json"))) === undefined) {
|
|
178
|
+
await writeAtomic(join(dir, "tsconfig.json"), ROOT_TSCONFIG);
|
|
179
|
+
wrote.push("tsconfig.json");
|
|
180
|
+
}
|
|
181
|
+
if ((await readOptional(join(dir, "song.ts"))) === undefined) {
|
|
182
|
+
await writeAtomic(join(dir, "song.ts"), EMPTY_SONG);
|
|
183
|
+
// Recorded as dawg-written so the first window lets the session win.
|
|
184
|
+
await writeAtomic(
|
|
185
|
+
join(dir, ".dawg", "sync.json"),
|
|
186
|
+
`${JSON.stringify(
|
|
187
|
+
{
|
|
188
|
+
files: {
|
|
189
|
+
"song.ts": createHash("sha256").update(EMPTY_SONG).digest("hex"),
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
null,
|
|
193
|
+
2,
|
|
194
|
+
)}\n`,
|
|
195
|
+
);
|
|
196
|
+
wrote.push("song.ts");
|
|
197
|
+
}
|
|
198
|
+
const gitignore = (await readOptional(join(dir, ".gitignore"))) ?? "";
|
|
199
|
+
const present = new Set(gitignore.split("\n").map((line) => line.trim()));
|
|
200
|
+
const missing = GITIGNORE_LINES.filter((line) => !present.has(line));
|
|
201
|
+
if (missing.length > 0) {
|
|
202
|
+
const prefix =
|
|
203
|
+
gitignore.length === 0 || gitignore.endsWith("\n") ? "" : "\n";
|
|
204
|
+
await writeAtomic(
|
|
205
|
+
join(dir, ".gitignore"),
|
|
206
|
+
`${gitignore}${prefix}${missing.join("\n")}\n`,
|
|
207
|
+
);
|
|
208
|
+
wrote.push(".gitignore");
|
|
209
|
+
}
|
|
210
|
+
return Object.freeze({ wrote: Object.freeze(wrote), sdkVersion });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** `dawg init [dir]`: creates the project and prints what it wrote. */
|
|
214
|
+
export async function runInitCommand(
|
|
215
|
+
argv: readonly string[],
|
|
216
|
+
cwd: string,
|
|
217
|
+
stdout: { write(text: string): unknown },
|
|
218
|
+
stderr: { write(text: string): unknown },
|
|
219
|
+
): Promise<number> {
|
|
220
|
+
const target =
|
|
221
|
+
argv[1] && !argv[1].startsWith("--") ? resolve(cwd, argv[1]) : cwd;
|
|
222
|
+
try {
|
|
223
|
+
const result = await initProject(target);
|
|
224
|
+
if (result.wrote.length === 0)
|
|
225
|
+
stdout.write(`project up to date (sdk ${result.sdkVersion})\n`);
|
|
226
|
+
else for (const path of result.wrote) stdout.write(`wrote ${path}\n`);
|
|
227
|
+
return 0;
|
|
228
|
+
} catch (error) {
|
|
229
|
+
stderr.write(
|
|
230
|
+
`init failed: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
231
|
+
);
|
|
232
|
+
return 1;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function readOptional(path: string): Promise<string | undefined> {
|
|
237
|
+
try {
|
|
238
|
+
return await readFile(path, "utf8");
|
|
239
|
+
} catch {
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Temp file + rename so a reader never sees a partial file. */
|
|
245
|
+
export async function writeAtomic(
|
|
246
|
+
path: string,
|
|
247
|
+
contents: string,
|
|
248
|
+
): Promise<void> {
|
|
249
|
+
await mkdir(dirname(path), { recursive: true });
|
|
250
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
251
|
+
await writeFile(temporary, contents, "utf8");
|
|
252
|
+
await rename(temporary, path);
|
|
253
|
+
}
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two-way sync between the project files and the session score.
|
|
3
|
+
*
|
|
4
|
+
* files → score: a change to `song.ts` or `tracks/<slug>/track.ts` (fs.watch
|
|
5
|
+
* plus a slow poll, debounced 150 ms) evaluates the project, adopts the
|
|
6
|
+
* session's note ids, diffs against the current score and commits one
|
|
7
|
+
* `files.apply` revision through the session port. A failed evaluation is
|
|
8
|
+
* reported as a card and leaves the score alone.
|
|
9
|
+
*
|
|
10
|
+
* score → files: a new score revision reprints the project and writes only
|
|
11
|
+
* the files whose bytes differ. Files whose last evaluation already equals
|
|
12
|
+
* the score are never rewritten, so an author's own formatting survives.
|
|
13
|
+
*
|
|
14
|
+
* Echo suppression: `.dawg/sync.json` records the hash of every file dawg
|
|
15
|
+
* wrote; a watcher event whose file still hashes the same is ignored, in
|
|
16
|
+
* this window and every other one.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { watch, type FSWatcher } from "node:fs";
|
|
21
|
+
import { readdir, readFile, rm, stat } from "node:fs/promises";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import {
|
|
24
|
+
adoptNoteIds,
|
|
25
|
+
applyScoreOperations,
|
|
26
|
+
diffScores,
|
|
27
|
+
} from "../../core/diff.ts";
|
|
28
|
+
import { encodeLoop } from "../../core/loop.ts";
|
|
29
|
+
import type { ScoreOperation, TrackScore } from "../../core/score.ts";
|
|
30
|
+
import {
|
|
31
|
+
evaluateProject,
|
|
32
|
+
formatDiagnostic,
|
|
33
|
+
type Diagnostic,
|
|
34
|
+
type EvalResult,
|
|
35
|
+
} from "../../core/sdk/eval.ts";
|
|
36
|
+
import { printProject } from "../../core/sdk/print.ts";
|
|
37
|
+
import { writeAtomic } from "./init.ts";
|
|
38
|
+
import { typecheckProject, type TypecheckResult } from "./typecheck.ts";
|
|
39
|
+
|
|
40
|
+
export const SYNC_DEBOUNCE_MS = 150;
|
|
41
|
+
export const SYNC_POLL_MS = 1_500;
|
|
42
|
+
const SYNC_STATE_FILE = ".dawg/sync.json";
|
|
43
|
+
const MAX_TRACKED_FILES = 512;
|
|
44
|
+
|
|
45
|
+
/** Header indicator state: `types ✓` or `types ✗ N`. */
|
|
46
|
+
export type TypesState = Readonly<{ ok: boolean; errors: number }>;
|
|
47
|
+
|
|
48
|
+
/** Result of reconciling an evaluated score with the session score. */
|
|
49
|
+
export type FilesApplyPlan = Readonly<{
|
|
50
|
+
/** The session score after the file change, with session note ids kept. */
|
|
51
|
+
next: TrackScore;
|
|
52
|
+
operations: readonly ScoreOperation[];
|
|
53
|
+
}>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Pure core of files → score: adopts `current`'s note ids into `evaluated`
|
|
57
|
+
* and returns the operations (and resulting score) that bring `current` to
|
|
58
|
+
* it. Empty `operations` means the files already match the score.
|
|
59
|
+
*/
|
|
60
|
+
export function applyFiles(
|
|
61
|
+
current: TrackScore,
|
|
62
|
+
evaluated: TrackScore,
|
|
63
|
+
): FilesApplyPlan {
|
|
64
|
+
const target = adoptNoteIds(current, evaluated);
|
|
65
|
+
const operations = diffScores(current, target);
|
|
66
|
+
const next =
|
|
67
|
+
operations.length === 0
|
|
68
|
+
? current
|
|
69
|
+
: applyScoreOperations(current, operations);
|
|
70
|
+
return Object.freeze({ next, operations });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What the sync needs from its window. */
|
|
74
|
+
export type SyncHost = Readonly<{
|
|
75
|
+
project: string;
|
|
76
|
+
/** Current session score. */
|
|
77
|
+
current(): TrackScore;
|
|
78
|
+
/**
|
|
79
|
+
* Commits the plan as one `files.apply` revision. Resolves with the
|
|
80
|
+
* score the session now holds (it may be ahead after a rebase).
|
|
81
|
+
*/
|
|
82
|
+
commit(plan: FilesApplyPlan, summary: string): Promise<TrackScore>;
|
|
83
|
+
card(
|
|
84
|
+
text: string,
|
|
85
|
+
tone: "info" | "success" | "warning" | "error",
|
|
86
|
+
hint?: string,
|
|
87
|
+
): void;
|
|
88
|
+
types(state: TypesState): void;
|
|
89
|
+
}>;
|
|
90
|
+
|
|
91
|
+
export type SyncOptions = Readonly<{
|
|
92
|
+
evaluate?: (project: string) => Promise<EvalResult>;
|
|
93
|
+
typecheck?: (project: string) => Promise<TypecheckResult>;
|
|
94
|
+
debounceMs?: number;
|
|
95
|
+
pollMs?: number;
|
|
96
|
+
/** Disable fs.watch and polling (tests drive `checkFiles` directly). */
|
|
97
|
+
watch?: boolean;
|
|
98
|
+
}>;
|
|
99
|
+
|
|
100
|
+
export type ProjectSync = Readonly<{
|
|
101
|
+
/** Call when the session score changed (local edit or another window). */
|
|
102
|
+
scoreChanged(score: TrackScore): void;
|
|
103
|
+
/**
|
|
104
|
+
* Evaluate the files now (debounce skipped); resolves when applied with a
|
|
105
|
+
* one-line outcome (`applied from files · 1 note`, `files rejected · …`,
|
|
106
|
+
* `files match the score`), the last one when nothing changed since.
|
|
107
|
+
*/
|
|
108
|
+
checkFiles(): Promise<string>;
|
|
109
|
+
/** Reprint now (debounce skipped); resolves when written. */
|
|
110
|
+
flushScore(): Promise<void>;
|
|
111
|
+
stop(): Promise<void>;
|
|
112
|
+
}>;
|
|
113
|
+
|
|
114
|
+
type SyncState = { files: Record<string, string> };
|
|
115
|
+
|
|
116
|
+
export function startProjectSync(
|
|
117
|
+
host: SyncHost,
|
|
118
|
+
options: SyncOptions = {},
|
|
119
|
+
): ProjectSync {
|
|
120
|
+
const evaluate =
|
|
121
|
+
options.evaluate ?? ((project: string) => evaluateProject(project));
|
|
122
|
+
const typecheck =
|
|
123
|
+
options.typecheck ?? ((project: string) => typecheckProject(project));
|
|
124
|
+
const debounceMs = options.debounceMs ?? SYNC_DEBOUNCE_MS;
|
|
125
|
+
const project = host.project;
|
|
126
|
+
const statePath = join(project, SYNC_STATE_FILE);
|
|
127
|
+
/** Hashes of the project files as of the last successful evaluation. */
|
|
128
|
+
let seen = new Map<string, string>();
|
|
129
|
+
/** Outcome of the last evaluation, returned when the files did not change. */
|
|
130
|
+
let lastOutcome = "files match the score";
|
|
131
|
+
/** Encoded score the files were last known to equal. */
|
|
132
|
+
let filesEqual = "";
|
|
133
|
+
let lastRejection = "";
|
|
134
|
+
let stopped = false;
|
|
135
|
+
let queue: Promise<void> = Promise.resolve();
|
|
136
|
+
let fileTimer: ReturnType<typeof setTimeout> | undefined;
|
|
137
|
+
let scoreTimer: ReturnType<typeof setTimeout> | undefined;
|
|
138
|
+
let pendingScore: TrackScore | undefined;
|
|
139
|
+
let watchers: FSWatcher[] = [];
|
|
140
|
+
let poll: ReturnType<typeof setInterval> | undefined;
|
|
141
|
+
|
|
142
|
+
const enqueue = <T>(task: () => Promise<T>): Promise<T> => {
|
|
143
|
+
const run = queue.then(task, task);
|
|
144
|
+
queue = run.then(
|
|
145
|
+
() => undefined,
|
|
146
|
+
() => undefined,
|
|
147
|
+
);
|
|
148
|
+
return run;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const readState = async (): Promise<SyncState> => {
|
|
152
|
+
try {
|
|
153
|
+
const value: unknown = JSON.parse(await readFile(statePath, "utf8"));
|
|
154
|
+
if (typeof value !== "object" || value === null) return { files: {} };
|
|
155
|
+
const files = (value as Record<string, unknown>).files;
|
|
156
|
+
if (typeof files !== "object" || files === null) return { files: {} };
|
|
157
|
+
const out: Record<string, string> = {};
|
|
158
|
+
for (const [path, hash] of Object.entries(
|
|
159
|
+
files as Record<string, unknown>,
|
|
160
|
+
))
|
|
161
|
+
if (
|
|
162
|
+
typeof hash === "string" &&
|
|
163
|
+
/^[0-9a-f]{64}$/.test(hash) &&
|
|
164
|
+
path.length < 512
|
|
165
|
+
)
|
|
166
|
+
out[path] = hash;
|
|
167
|
+
return { files: out };
|
|
168
|
+
} catch {
|
|
169
|
+
return { files: {} };
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const writeState = (state: SyncState) =>
|
|
173
|
+
writeAtomic(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Evaluates the files. In `files-win` mode (edits) the result is committed;
|
|
177
|
+
* in `score-wins` mode (startup with unchanged files) a difference reprints
|
|
178
|
+
* the files from the score instead.
|
|
179
|
+
*/
|
|
180
|
+
const checkFilesNow = async (
|
|
181
|
+
mode: "files-win" | "score-wins" = "files-win",
|
|
182
|
+
): Promise<string> => {
|
|
183
|
+
if (stopped) return lastOutcome;
|
|
184
|
+
const current = await hashProject(project);
|
|
185
|
+
if (mode === "files-win" && sameHashes(current, seen)) return lastOutcome;
|
|
186
|
+
const result = await evaluate(project);
|
|
187
|
+
if (!result.ok) {
|
|
188
|
+
const text = result.diagnostics.map(formatDiagnostic).join("; ");
|
|
189
|
+
if (text !== lastRejection) {
|
|
190
|
+
lastRejection = text;
|
|
191
|
+
host.card(
|
|
192
|
+
`files rejected · ${firstLine(text)}`,
|
|
193
|
+
"error",
|
|
194
|
+
"fix the file to apply",
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
void runTypecheck();
|
|
198
|
+
return (lastOutcome = `files rejected · ${text}`);
|
|
199
|
+
}
|
|
200
|
+
lastRejection = "";
|
|
201
|
+
seen = current;
|
|
202
|
+
const plan = applyFiles(host.current(), result.score);
|
|
203
|
+
if (plan.operations.length === 0) {
|
|
204
|
+
filesEqual = encodeLoop(host.current());
|
|
205
|
+
void runTypecheck();
|
|
206
|
+
return (lastOutcome = "files match the score");
|
|
207
|
+
}
|
|
208
|
+
if (mode === "score-wins") {
|
|
209
|
+
pendingScore = host.current();
|
|
210
|
+
await flushScoreNow();
|
|
211
|
+
void runTypecheck();
|
|
212
|
+
return (lastOutcome = "files reprinted from the score");
|
|
213
|
+
}
|
|
214
|
+
const summary = summarize(plan.operations);
|
|
215
|
+
try {
|
|
216
|
+
const committed = await host.commit(plan, summary);
|
|
217
|
+
filesEqual = encodeLoop(committed);
|
|
218
|
+
host.card(`applied from files · ${summary}`, "success");
|
|
219
|
+
lastOutcome = `applied from files · ${summary}`;
|
|
220
|
+
} catch (error) {
|
|
221
|
+
host.card(
|
|
222
|
+
`files not applied · ${firstLine(error instanceof Error ? error.message : String(error))}`,
|
|
223
|
+
"warning",
|
|
224
|
+
);
|
|
225
|
+
lastOutcome = `files not applied · ${error instanceof Error ? error.message : String(error)}`;
|
|
226
|
+
}
|
|
227
|
+
void runTypecheck();
|
|
228
|
+
return lastOutcome;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const flushScoreNow = async (): Promise<void> => {
|
|
232
|
+
if (stopped || !pendingScore) return;
|
|
233
|
+
const score = pendingScore;
|
|
234
|
+
pendingScore = undefined;
|
|
235
|
+
const encoded = encodeLoop(score);
|
|
236
|
+
if (encoded === filesEqual) return;
|
|
237
|
+
const printed = printProject(score);
|
|
238
|
+
const state = await readState();
|
|
239
|
+
const files = { ...state.files };
|
|
240
|
+
const expected = new Set(printed.files.map((file) => file.path));
|
|
241
|
+
const hashes = new Map<string, string>();
|
|
242
|
+
for (const file of printed.files) {
|
|
243
|
+
const hash = sha256(file.text);
|
|
244
|
+
hashes.set(file.path, hash);
|
|
245
|
+
const existing = await readOptional(join(project, file.path));
|
|
246
|
+
if (existing === file.text) continue;
|
|
247
|
+
await writeAtomic(join(project, file.path), file.text);
|
|
248
|
+
files[file.path] = hash;
|
|
249
|
+
}
|
|
250
|
+
// Track files we printed earlier for tracks that no longer exist.
|
|
251
|
+
for (const [path, hash] of Object.entries(state.files)) {
|
|
252
|
+
if (expected.has(path) || !path.startsWith("tracks/")) continue;
|
|
253
|
+
const existing = await readOptional(join(project, path));
|
|
254
|
+
if (existing !== undefined && sha256(existing) === hash)
|
|
255
|
+
await rm(join(project, path), { force: true });
|
|
256
|
+
delete files[path];
|
|
257
|
+
}
|
|
258
|
+
await writeState({ files });
|
|
259
|
+
seen = await hashProject(project);
|
|
260
|
+
filesEqual = encoded;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
let typecheckRunning = false;
|
|
264
|
+
let typecheckAgain = false;
|
|
265
|
+
const runTypecheck = async (): Promise<void> => {
|
|
266
|
+
if (stopped) return;
|
|
267
|
+
if (typecheckRunning) {
|
|
268
|
+
typecheckAgain = true;
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
typecheckRunning = true;
|
|
272
|
+
try {
|
|
273
|
+
const result = await typecheck(project);
|
|
274
|
+
host.types({ ok: result.ok, errors: result.diagnostics.length });
|
|
275
|
+
} catch {
|
|
276
|
+
host.types({ ok: false, errors: 1 });
|
|
277
|
+
} finally {
|
|
278
|
+
typecheckRunning = false;
|
|
279
|
+
if (typecheckAgain) {
|
|
280
|
+
typecheckAgain = false;
|
|
281
|
+
void runTypecheck();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const scheduleFiles = () => {
|
|
287
|
+
if (stopped) return;
|
|
288
|
+
if (fileTimer) clearTimeout(fileTimer);
|
|
289
|
+
fileTimer = setTimeout(() => {
|
|
290
|
+
fileTimer = undefined;
|
|
291
|
+
void enqueue(() => checkFilesNow("files-win"));
|
|
292
|
+
}, debounceMs);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
if (options.watch !== false) {
|
|
296
|
+
const onEvent = (_event: string, filename: string | Buffer | null) => {
|
|
297
|
+
const name = filename === null ? "" : String(filename);
|
|
298
|
+
if (name && !isProjectSource(name)) return;
|
|
299
|
+
scheduleFiles();
|
|
300
|
+
};
|
|
301
|
+
try {
|
|
302
|
+
watchers.push(watch(project, { persistent: false }, onEvent));
|
|
303
|
+
watchers.push(
|
|
304
|
+
watch(
|
|
305
|
+
join(project, "tracks"),
|
|
306
|
+
{ persistent: false, recursive: true },
|
|
307
|
+
onEvent,
|
|
308
|
+
),
|
|
309
|
+
);
|
|
310
|
+
} catch {
|
|
311
|
+
watchers.forEach((watcher) => watcher.close());
|
|
312
|
+
watchers = [];
|
|
313
|
+
}
|
|
314
|
+
poll = setInterval(
|
|
315
|
+
() => void enqueue(() => checkFilesNow("files-win")),
|
|
316
|
+
options.pollMs ?? SYNC_POLL_MS,
|
|
317
|
+
);
|
|
318
|
+
poll.unref?.();
|
|
319
|
+
}
|
|
320
|
+
// Startup: files edited since dawg last wrote them (or never recorded)
|
|
321
|
+
// win; otherwise the session score wins and reprints what files lack.
|
|
322
|
+
void enqueue(async () => {
|
|
323
|
+
const state = await readState();
|
|
324
|
+
const current = await hashProject(project);
|
|
325
|
+
const edited =
|
|
326
|
+
current.size === 0 ||
|
|
327
|
+
[...current].some(([path, hash]) => state.files[path] !== hash);
|
|
328
|
+
await checkFilesNow(edited ? "files-win" : "score-wins");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
return Object.freeze({
|
|
332
|
+
scoreChanged(score: TrackScore) {
|
|
333
|
+
if (stopped) return;
|
|
334
|
+
pendingScore = score;
|
|
335
|
+
if (scoreTimer) clearTimeout(scoreTimer);
|
|
336
|
+
scoreTimer = setTimeout(() => {
|
|
337
|
+
scoreTimer = undefined;
|
|
338
|
+
void enqueue(flushScoreNow);
|
|
339
|
+
}, debounceMs);
|
|
340
|
+
},
|
|
341
|
+
checkFiles: () => enqueue(() => checkFilesNow("files-win")),
|
|
342
|
+
flushScore: () => enqueue(flushScoreNow),
|
|
343
|
+
async stop() {
|
|
344
|
+
stopped = true;
|
|
345
|
+
if (fileTimer) clearTimeout(fileTimer);
|
|
346
|
+
if (scoreTimer) clearTimeout(scoreTimer);
|
|
347
|
+
if (poll) clearInterval(poll);
|
|
348
|
+
watchers.forEach((watcher) => watcher.close());
|
|
349
|
+
watchers = [];
|
|
350
|
+
await queue.catch(() => undefined);
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Project-relative source paths: `song.ts` and each track file under `tracks/`. */
|
|
356
|
+
export async function projectSourceFiles(project: string): Promise<string[]> {
|
|
357
|
+
const files = ["song.ts"];
|
|
358
|
+
let dirs: string[] = [];
|
|
359
|
+
try {
|
|
360
|
+
dirs = (await readdir(join(project, "tracks"), { withFileTypes: true }))
|
|
361
|
+
.filter((entry) => entry.isDirectory())
|
|
362
|
+
.map((entry) => entry.name)
|
|
363
|
+
.sort();
|
|
364
|
+
} catch {
|
|
365
|
+
dirs = [];
|
|
366
|
+
}
|
|
367
|
+
for (const dir of dirs.slice(0, MAX_TRACKED_FILES)) {
|
|
368
|
+
const path = `tracks/${dir}/track.ts`;
|
|
369
|
+
try {
|
|
370
|
+
if ((await stat(join(project, path))).isFile()) files.push(path);
|
|
371
|
+
} catch {
|
|
372
|
+
// A directory without a track file (samples only) is not a source.
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return files;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function isProjectSource(name: string): boolean {
|
|
379
|
+
return (
|
|
380
|
+
name === "song.ts" || /(^|\/)track\.ts$/.test(name) || !name.includes(".")
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function hashProject(project: string): Promise<Map<string, string>> {
|
|
385
|
+
const out = new Map<string, string>();
|
|
386
|
+
for (const path of await projectSourceFiles(project)) {
|
|
387
|
+
const text = await readOptional(join(project, path));
|
|
388
|
+
if (text !== undefined) out.set(path, sha256(text));
|
|
389
|
+
}
|
|
390
|
+
return out;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function sameHashes(a: Map<string, string>, b: Map<string, string>): boolean {
|
|
394
|
+
if (a.size !== b.size) return false;
|
|
395
|
+
for (const [path, hash] of a) if (b.get(path) !== hash) return false;
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function sha256(text: string): string {
|
|
400
|
+
return createHash("sha256").update(text).digest("hex");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function readOptional(path: string): Promise<string | undefined> {
|
|
404
|
+
try {
|
|
405
|
+
return await readFile(path, "utf8");
|
|
406
|
+
} catch {
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function firstLine(text: string): string {
|
|
412
|
+
const line = text.split("\n")[0] ?? "";
|
|
413
|
+
return line.length > 120 ? `${line.slice(0, 119)}…` : line;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** `2 notes, 1 track` style summary of a plan for the activity card. */
|
|
417
|
+
export function summarize(operations: readonly ScoreOperation[]): string {
|
|
418
|
+
const counts = new Map<string, number>();
|
|
419
|
+
for (const op of operations) {
|
|
420
|
+
const key = op.type.endsWith("Note")
|
|
421
|
+
? "note"
|
|
422
|
+
: op.type.endsWith("Track")
|
|
423
|
+
? "track"
|
|
424
|
+
: "setting";
|
|
425
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
426
|
+
}
|
|
427
|
+
return [...counts.entries()]
|
|
428
|
+
.map(([key, count]) => `${count} ${key}${count === 1 ? "" : "s"}`)
|
|
429
|
+
.join(", ");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export type { Diagnostic };
|