@audiofool/moondog 0.1.0 → 0.2.0-beta.3
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/LICENSE +202 -0
- package/README.md +156 -68
- package/contracts/v1/definitions.schema.json +1 -169
- package/package.json +7 -23
- package/scripts/contract-lib.mjs +0 -7
- package/scripts/contract-semantics.mjs +0 -1904
- package/scripts/moondog.mjs +285 -74
- package/src/core/apple-library-source-status.mjs +0 -16
- package/src/core/apple-projection-domain-services.mjs +253 -53
- package/src/core/application-update.mjs +320 -0
- package/src/core/capability-catalog.mjs +290 -55
- package/src/core/listener-avoids.mjs +101 -0
- package/src/core/listening-now.mjs +108 -0
- package/src/core/listening-profile-domain-services.mjs +189 -41
- package/src/core/listening-upkeep.mjs +31 -0
- package/src/core/moondog-application.mjs +2115 -107
- package/src/core/playback-preferences.mjs +97 -0
- package/src/core/spotify-listening-intent.mjs +124 -0
- package/src/core/synthetic-domain-services.mjs +24 -3
- package/src/core/track-title.mjs +107 -0
- package/src/demo/moondog-demo.mjs +4 -22
- package/src/i18n/index.mjs +285 -0
- package/src/i18n/messages/de.mjs +135 -0
- package/src/i18n/messages/en.mjs +136 -0
- package/src/i18n/messages/es.mjs +135 -0
- package/src/i18n/messages/fr.mjs +135 -0
- package/src/i18n/messages/it.mjs +135 -0
- package/src/i18n/messages/ja.mjs +135 -0
- package/src/i18n/messages/ko.mjs +135 -0
- package/src/i18n/messages/pt.mjs +135 -0
- package/src/i18n/messages/zh-Hant.mjs +135 -0
- package/src/i18n/messages/zh.mjs +135 -0
- package/src/i18n/preferences.mjs +34 -0
- package/src/i18n/screen/es.mjs +967 -0
- package/src/i18n/screen/ja.mjs +967 -0
- package/src/i18n/screen/pt.mjs +967 -0
- package/src/i18n/screen/zh.mjs +967 -0
- package/src/importers/apple-music-library/index.mjs +0 -16
- package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
- package/src/integrations/apple-music/catalog.mjs +166 -14
- package/src/integrations/apple-music/music-app.mjs +232 -0
- package/src/integrations/apple-music/storefront.mjs +28 -0
- package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
- package/src/integrations/lastfm/history.mjs +221 -0
- package/src/integrations/lastfm/settings.mjs +75 -0
- package/src/integrations/netease/ncm-cli-player.mjs +328 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
- package/src/integrations/public-playlists.mjs +2 -1
- package/src/integrations/spotify/account-data-profile.mjs +14 -6
- package/src/integrations/spotify/authentication.mjs +149 -15
- package/src/integrations/spotify/catalog-resolver.mjs +9 -6
- package/src/integrations/spotify/connection.mjs +2 -1
- package/src/integrations/spotify/credential-store.mjs +4 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
- package/src/integrations/spotify/history-archive.mjs +1 -1
- package/src/integrations/spotify/library-snapshot.mjs +227 -0
- package/src/integrations/spotify/recent-activity.mjs +2 -0
- package/src/integrations/spotify/service.mjs +873 -69
- package/src/integrations/spotify/web-api-client.mjs +410 -127
- package/src/integrations/web/brave-web.mjs +45 -0
- package/src/integrations/web/codex-web.mjs +15 -82
- package/src/integrations/web/deepseek-web.mjs +81 -0
- package/src/integrations/web/web-research.mjs +119 -0
- package/src/integrations/web/web-result.mjs +215 -0
- package/src/integrations/youtube-music/takeout.mjs +3 -1
- package/src/memory/local-memory-store.mjs +542 -57
- package/src/profile/history-import.mjs +42 -6
- package/src/profile/listener-corrections.mjs +22 -0
- package/src/profile/listener-profile-build.mjs +461 -0
- package/src/profile/listener-profile-store.mjs +130 -0
- package/src/profile/listener-profile-verification.mjs +68 -0
- package/src/profile/listener-profile-worker.mjs +179 -0
- package/src/profile/listening-history-store.mjs +640 -17
- package/src/profile/listening-profile-projection.mjs +454 -86
- package/src/profile/local-music-data.mjs +1 -1
- package/src/profile/moondog-suggestions.mjs +184 -0
- package/src/profile/music-providers.mjs +11 -7
- package/src/profile/profile-exploration.mjs +92 -0
- package/src/profile/recorded-measures.mjs +24 -0
- package/src/profile/spotify-archive-taste.mjs +0 -4
- package/src/runtime/pi/agent-runtime.mjs +2536 -692
- package/src/runtime/pi/authentication.mjs +34 -1
- package/src/runtime/pi/configured-runtime.mjs +22 -6
- package/src/runtime/pi/conversation-name.mjs +53 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
- package/src/runtime/pi/model-catalog.mjs +28 -5
- package/src/runtime/pi/model-settings.mjs +202 -0
- package/src/runtime/pi/model-transport.mjs +10 -0
- package/src/runtime/pi/persistent-credential-store.mjs +67 -31
- package/src/runtime/pi/profile-build-runtime.mjs +161 -0
- package/src/runtime/pi/public-model-catalog.mjs +288 -0
- package/src/runtime/pi/runtime-settings.mjs +10 -2
- package/src/runtime/pi/skills/listener-profile/SKILL.md +48 -0
- package/src/runtime/pi/tool-trace.mjs +37 -0
- package/src/surfaces/cli/auth-command.mjs +12 -4
- package/src/surfaces/cli/brand-components.mjs +310 -132
- package/src/surfaces/cli/brand-theme.mjs +51 -28
- package/src/surfaces/cli/command-completions.mjs +58 -42
- package/src/surfaces/cli/format-output.mjs +199 -388
- package/src/surfaces/cli/history-import-view.mjs +292 -139
- package/src/surfaces/cli/import-guides.mjs +2 -0
- package/src/surfaces/cli/listener-profile-command.mjs +92 -0
- package/src/surfaces/cli/listening-menu.mjs +7 -4
- package/src/surfaces/cli/profile-command.mjs +30 -21
- package/src/surfaces/cli/seasons-chart.mjs +87 -0
- package/src/surfaces/cli/spotify-command.mjs +54 -6
- package/src/surfaces/cli/taste-profile-model.mjs +78 -79
- package/src/surfaces/cli/taste-profile-view.mjs +33 -26
- package/src/surfaces/cli/terminal-art.mjs +140 -35
- package/src/surfaces/cli/tui.mjs +1066 -357
- package/src/surfaces/cli/update-command.mjs +32 -0
- package/src/surfaces/cli/web-command.mjs +53 -14
- package/src/surfaces/html/tasteprint.mjs +43 -70
- package/contracts/v1/capability-policy.schema.json +0 -572
- package/contracts/v1/profile-evidence.schema.json +0 -237
- package/contracts/v1/tool-invocation.schema.json +0 -318
- package/contracts/v1/tool-receipt.schema.json +0 -1066
- package/scripts/demo-studio.mjs +0 -58
- package/src/core/apple-data-migration.mjs +0 -50
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
- package/src/demo/moondog-tasteprint-demo.mjs +0 -631
- package/src/integrations/spotify/index.mjs +0 -80
- package/src/surfaces/web/showcase.mjs +0 -272
- package/src/surfaces/web/studio-page.mjs +0 -4148
- package/src/surfaces/web/studio.mjs +0 -2288
package/scripts/moondog.mjs
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
|
+
import { createApplicationUpdater } from "../src/core/application-update.mjs";
|
|
6
|
+
import { runUpdateCommand } from "../src/surfaces/cli/update-command.mjs";
|
|
5
7
|
|
|
6
8
|
import { MoondogApplication } from "../src/core/moondog-application.mjs";
|
|
7
9
|
import { openLocalMemoryStore } from "../src/memory/local-memory-store.mjs";
|
|
@@ -13,6 +15,8 @@ import { readAppleMusicSourceStatus } from "../src/core/apple-library-source-sta
|
|
|
13
15
|
import { createListeningProfileDomainServices } from "../src/core/listening-profile-domain-services.mjs";
|
|
14
16
|
import { runMemoryReflection } from "../src/memory/reflection-worker.mjs";
|
|
15
17
|
import { createConfiguredRuntime } from "../src/runtime/pi/configured-runtime.mjs";
|
|
18
|
+
import { createModelSettings } from "../src/runtime/pi/model-settings.mjs";
|
|
19
|
+
import { createToolTrace } from "../src/runtime/pi/tool-trace.mjs";
|
|
16
20
|
import { writePiRuntimeSelection } from "../src/runtime/pi/runtime-settings.mjs";
|
|
17
21
|
import {
|
|
18
22
|
formatLocalResult,
|
|
@@ -23,10 +27,10 @@ import {
|
|
|
23
27
|
} from "../src/surfaces/cli/format-output.mjs";
|
|
24
28
|
import { runAuthCommand } from "../src/surfaces/cli/auth-command.mjs";
|
|
25
29
|
import { runDataCommand } from "../src/surfaces/cli/data-command.mjs";
|
|
26
|
-
import { migrateLegacyAppleMusicData } from "../src/core/apple-data-migration.mjs";
|
|
27
30
|
import { runCatalogCommand } from "../src/surfaces/cli/catalog-command.mjs";
|
|
28
31
|
import { runProfileCommand } from "../src/surfaces/cli/profile-command.mjs";
|
|
29
|
-
import {
|
|
32
|
+
import { runListenerProfileCommand, savedProfileActions } from "../src/surfaces/cli/listener-profile-command.mjs";
|
|
33
|
+
import { createWebResearch } from "../src/integrations/web/web-research.mjs";
|
|
30
34
|
import { runWebCommand } from "../src/surfaces/cli/web-command.mjs";
|
|
31
35
|
import { openSpotifyConnection } from "../src/integrations/spotify/connection.mjs";
|
|
32
36
|
import {
|
|
@@ -38,12 +42,17 @@ import {
|
|
|
38
42
|
import { runSpotifyCommand } from "../src/surfaces/cli/spotify-command.mjs";
|
|
39
43
|
import { runListenBrainzCommand } from "../src/surfaces/cli/listenbrainz-command.mjs";
|
|
40
44
|
import { openListeningHistoryStore } from "../src/profile/listening-history-store.mjs";
|
|
41
|
-
import { prepareHistoryImport,
|
|
45
|
+
import { commitSpotifyQuickImport, prepareHistoryImport, prepareSpotifyQuickImport } from "../src/profile/history-import.mjs";
|
|
42
46
|
import { persistAppleLibraryImport, refreshAppleLibraryImport } from "../src/profile/apple-library-import.mjs";
|
|
43
47
|
import { IMPORT_GUIDE_URLS } from "../src/surfaces/cli/import-guides.mjs";
|
|
48
|
+
import { readLastfmConfiguration, writeLastfmConfiguration } from "../src/integrations/lastfm/settings.mjs";
|
|
44
49
|
import { runMoondogTui } from "../src/surfaces/cli/tui.mjs";
|
|
45
50
|
import { createLyricService } from "../src/core/lyric-service.mjs";
|
|
51
|
+
import { startListeningUpkeep } from "../src/core/listening-upkeep.mjs";
|
|
46
52
|
import { createAppleMusicCatalog } from "../src/integrations/apple-music/catalog.mjs";
|
|
53
|
+
import { createTerminalPreviewPlayer } from "../src/integrations/apple-music/terminal-preview-player.mjs";
|
|
54
|
+
import { createNeteasePlayer } from "../src/integrations/netease/ncm-cli-player.mjs";
|
|
55
|
+
import { createMusicAppPlayer } from "../src/integrations/apple-music/music-app.mjs";
|
|
47
56
|
import {
|
|
48
57
|
createOpenMusicSimilarity,
|
|
49
58
|
createWikidataArtistResolver,
|
|
@@ -58,7 +67,8 @@ import {
|
|
|
58
67
|
SPOTIFY_ARCHIVE_SET_MAXIMUM,
|
|
59
68
|
} from "../src/profile/spotify-archive-taste.mjs";
|
|
60
69
|
import { deriveArtistReleaseHintsFromSpotifyArchive } from "../src/profile/spotify-archive-catalog-hints.mjs";
|
|
61
|
-
import {
|
|
70
|
+
import { resolveListenerLocale, writeLanguagePreference } from "../src/i18n/preferences.mjs";
|
|
71
|
+
import { createPlaybackSelection, resolvePlaybackService, watchPlaybackPreference, writePlaybackPreference } from "../src/core/playback-preferences.mjs";
|
|
62
72
|
|
|
63
73
|
const localCommands = new Set([
|
|
64
74
|
"status",
|
|
@@ -152,7 +162,6 @@ function projectionErrorCode(error) {
|
|
|
152
162
|
}
|
|
153
163
|
|
|
154
164
|
async function loadDomainServices() {
|
|
155
|
-
await migrateLegacyAppleMusicData();
|
|
156
165
|
const listeningHistoryStore = await openListeningHistoryStore();
|
|
157
166
|
let domainServicesError = null;
|
|
158
167
|
let subjectId = null;
|
|
@@ -250,7 +259,7 @@ async function loadSpotifyConnection() {
|
|
|
250
259
|
}
|
|
251
260
|
|
|
252
261
|
async function spotifyRecentSyncContext(args) {
|
|
253
|
-
if (!new Set(["sync-recent", "import-history"]).has(args[0])) return {};
|
|
262
|
+
if (!new Set(["sync-recent", "sync-library", "import-history"]).has(args[0])) return {};
|
|
254
263
|
const recentActivityStore = await openListeningHistoryStore();
|
|
255
264
|
try {
|
|
256
265
|
const appleSubjectId = await optionalAppleMusicSubjectId();
|
|
@@ -267,6 +276,22 @@ async function spotifyRecentSyncContext(args) {
|
|
|
267
276
|
}
|
|
268
277
|
}
|
|
269
278
|
|
|
279
|
+
// Reads the latest Spotify plays into a listener's existing history. It never starts one:
|
|
280
|
+
// a listener who has imported nothing yet still chooses when to begin.
|
|
281
|
+
async function syncRecentSpotifyListening({ signal } = {}) {
|
|
282
|
+
const recentActivityStore = await openListeningHistoryStore();
|
|
283
|
+
try {
|
|
284
|
+
const appleSubjectId = await optionalAppleMusicSubjectId();
|
|
285
|
+
const subjectId = recentActivityStore.localSubjectId(appleSubjectId ? { preferredSubjectId: appleSubjectId } : {});
|
|
286
|
+
if (!subjectId) return null;
|
|
287
|
+
return await runSpotifyCommand({
|
|
288
|
+
args: ["sync-recent"], json: true, stdout: { write() {} }, signal, recentActivityStore, subjectId,
|
|
289
|
+
});
|
|
290
|
+
} finally {
|
|
291
|
+
recentActivityStore.close();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
270
295
|
async function runSpotifySurface(options) {
|
|
271
296
|
const syncContext = await spotifyRecentSyncContext(options.args);
|
|
272
297
|
try {
|
|
@@ -300,7 +325,7 @@ async function runListenBrainzSurface(options) {
|
|
|
300
325
|
}
|
|
301
326
|
}
|
|
302
327
|
|
|
303
|
-
async function prepareTuiHistoryImport(filePath, { recentPage, provider } = {}) {
|
|
328
|
+
async function prepareTuiHistoryImport(filePath, { recentPage, librarySnapshot, libraryUnavailable, provider, onProgress, signal } = {}) {
|
|
304
329
|
const historyStore = await openListeningHistoryStore();
|
|
305
330
|
try {
|
|
306
331
|
const appleSubjectId = await optionalAppleMusicSubjectId();
|
|
@@ -308,8 +333,8 @@ async function prepareTuiHistoryImport(filePath, { recentPage, provider } = {})
|
|
|
308
333
|
...(appleSubjectId ? { preferredSubjectId: appleSubjectId } : {}),
|
|
309
334
|
create: true,
|
|
310
335
|
});
|
|
311
|
-
const prepared = recentPage ?
|
|
312
|
-
filePath, subjectId, provider, capturedAt: new Date().toISOString(),
|
|
336
|
+
const prepared = recentPage ? prepareSpotifyQuickImport({ page: recentPage, librarySnapshot, libraryUnavailable, subjectId }) : await prepareHistoryImport({
|
|
337
|
+
filePath, subjectId, provider, capturedAt: new Date().toISOString(), onProgress, signal,
|
|
313
338
|
});
|
|
314
339
|
let closed = false;
|
|
315
340
|
let committed = false;
|
|
@@ -320,8 +345,8 @@ async function prepareTuiHistoryImport(filePath, { recentPage, provider } = {})
|
|
|
320
345
|
if (closed || committed) throw new Error("That preview is gone. Open it again before importing.");
|
|
321
346
|
const options = { args: ["import-history", filePath], stdout: { write() {} }, subjectId };
|
|
322
347
|
// Commit the exact inspected bundle, even if the source file later changes.
|
|
323
|
-
const receipt = prepared.provider === "spotify-
|
|
324
|
-
? historyStore
|
|
348
|
+
const receipt = prepared.provider === "spotify-quick"
|
|
349
|
+
? commitSpotifyQuickImport(historyStore, prepared)
|
|
325
350
|
: prepared.provider === "apple-music-library"
|
|
326
351
|
? await persistAppleLibraryImport(prepared.bundle)
|
|
327
352
|
: prepared.provider === "spotify"
|
|
@@ -425,6 +450,28 @@ async function emitTasteprintArtifact(
|
|
|
425
450
|
}
|
|
426
451
|
|
|
427
452
|
async function main() {
|
|
453
|
+
const updater = createApplicationUpdater();
|
|
454
|
+
const argv = process.argv.slice(2);
|
|
455
|
+
if (argv.length === 1 && ["--version", "-v"].includes(argv[0])) {
|
|
456
|
+
process.stdout.write(`${await updater.version()}\n`);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const installUpdate = async (args) => {
|
|
460
|
+
const controller = new AbortController();
|
|
461
|
+
const abort = () => controller.abort();
|
|
462
|
+
process.once("SIGINT", abort);
|
|
463
|
+
process.once("SIGTERM", abort);
|
|
464
|
+
try {
|
|
465
|
+
const result = await runUpdateCommand({ args, updater, signal: controller.signal,
|
|
466
|
+
onProgress: text => process.stdout.write(`${sanitizeTerminalText(text)}\n`) });
|
|
467
|
+
process.stdout.write(`${sanitizeTerminalText(result.text)}\n`);
|
|
468
|
+
if (result.result?.state === "unavailable") process.exitCode = 1;
|
|
469
|
+
} finally {
|
|
470
|
+
process.removeListener("SIGINT", abort);
|
|
471
|
+
process.removeListener("SIGTERM", abort);
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
if (argv[0] === "update") { await installUpdate(argv.slice(1)); return; }
|
|
428
475
|
const options = parseArguments(process.argv.slice(2));
|
|
429
476
|
|
|
430
477
|
if (options.command === "help" || options.command === "--help" || options.command === "-h") {
|
|
@@ -432,11 +479,13 @@ async function main() {
|
|
|
432
479
|
return;
|
|
433
480
|
}
|
|
434
481
|
|
|
482
|
+
await updater.assertNotUpdating();
|
|
483
|
+
|
|
435
484
|
if (options.command === "web") {
|
|
436
485
|
if (options.dryRun || options.offline || options.html || options.card || options.save || options.from || options.output) {
|
|
437
486
|
throw new Error("Usage: moondog web status|search|read [arguments] [--json].");
|
|
438
487
|
}
|
|
439
|
-
const webResearch = await
|
|
488
|
+
const webResearch = await createWebResearch();
|
|
440
489
|
const controller = new AbortController();
|
|
441
490
|
const abort = () => controller.abort();
|
|
442
491
|
process.once("SIGINT", abort);
|
|
@@ -451,31 +500,6 @@ async function main() {
|
|
|
451
500
|
return;
|
|
452
501
|
}
|
|
453
502
|
|
|
454
|
-
if (options.command === "studio") {
|
|
455
|
-
const demoOnly =
|
|
456
|
-
options.rest.length === 1 && options.rest[0] === "--demo";
|
|
457
|
-
if (
|
|
458
|
-
(options.rest.length > 0 && !demoOnly) ||
|
|
459
|
-
(demoOnly && options.from) ||
|
|
460
|
-
options.json ||
|
|
461
|
-
options.dryRun ||
|
|
462
|
-
options.offline ||
|
|
463
|
-
options.html ||
|
|
464
|
-
options.card ||
|
|
465
|
-
options.save ||
|
|
466
|
-
options.output
|
|
467
|
-
) {
|
|
468
|
-
throw new Error(
|
|
469
|
-
"Usage: moondog studio [--demo | --from <spotify-history.zip> [--from <spotify-history.zip>]].",
|
|
470
|
-
);
|
|
471
|
-
}
|
|
472
|
-
await runMoondogStudio({
|
|
473
|
-
demoOnly,
|
|
474
|
-
archivePaths: options.fromArchives,
|
|
475
|
-
});
|
|
476
|
-
return;
|
|
477
|
-
}
|
|
478
|
-
|
|
479
503
|
if (options.command === "demo-history") {
|
|
480
504
|
if (
|
|
481
505
|
options.rest.length > 0 ||
|
|
@@ -543,7 +567,6 @@ async function main() {
|
|
|
543
567
|
}
|
|
544
568
|
|
|
545
569
|
if (options.command === "data") {
|
|
546
|
-
await migrateLegacyAppleMusicData();
|
|
547
570
|
if (
|
|
548
571
|
options.dryRun ||
|
|
549
572
|
options.offline ||
|
|
@@ -564,7 +587,7 @@ async function main() {
|
|
|
564
587
|
return;
|
|
565
588
|
}
|
|
566
589
|
|
|
567
|
-
if (options.command === "profile" && options.rest.length > 0) {
|
|
590
|
+
if (options.command === "profile" && options.rest.length > 0 && !savedProfileActions.has(options.rest[0])) {
|
|
568
591
|
if (
|
|
569
592
|
options.dryRun ||
|
|
570
593
|
options.offline ||
|
|
@@ -767,6 +790,36 @@ async function main() {
|
|
|
767
790
|
return;
|
|
768
791
|
}
|
|
769
792
|
|
|
793
|
+
if (options.command === "profile" && savedProfileActions.has(options.rest[0])) {
|
|
794
|
+
if (options.dryRun || options.offline || options.html || options.card || options.save || options.output || options.from) {
|
|
795
|
+
throw new Error("Usage: moondog profile <build|saved|explain> [--json].");
|
|
796
|
+
}
|
|
797
|
+
const application = new MoondogApplication(await loadDomainServices());
|
|
798
|
+
const controller = new AbortController();
|
|
799
|
+
const abort = () => controller.abort();
|
|
800
|
+
process.once("SIGINT", abort);
|
|
801
|
+
try {
|
|
802
|
+
// Saved reads only need the profile domain. The builder connects its own
|
|
803
|
+
// model when new analysis is actually required.
|
|
804
|
+
const stages = { synthesis: "Writing your profile", check: "Checking the draft",
|
|
805
|
+
revision: "Revising after the check", complete: "Saving your profile" };
|
|
806
|
+
let stage;
|
|
807
|
+
await runListenerProfileCommand({ application, args: options.rest, json: options.json,
|
|
808
|
+
signal: controller.signal, onProgress: options.json ? undefined : progress => {
|
|
809
|
+
if (progress.model_retry) {
|
|
810
|
+
process.stderr.write(`Retrying the model, ${progress.model_retry.attempt} of ${progress.model_retry.maxRetries}\n`);
|
|
811
|
+
} else if (progress.phase !== stage) {
|
|
812
|
+
stage = progress.phase;
|
|
813
|
+
process.stderr.write(`${stages[stage] ?? "Building your profile"}\n`);
|
|
814
|
+
}
|
|
815
|
+
} });
|
|
816
|
+
} finally {
|
|
817
|
+
process.removeListener("SIGINT", abort);
|
|
818
|
+
application.close();
|
|
819
|
+
}
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
|
|
770
823
|
const domainState = await loadDomainServices();
|
|
771
824
|
const memoryStore = await openLocalMemoryStore();
|
|
772
825
|
const spotifyConnection = await loadSpotifyConnection();
|
|
@@ -775,20 +828,64 @@ async function main() {
|
|
|
775
828
|
const musicSimilarity = createOpenMusicSimilarity({
|
|
776
829
|
identityResolver: artistIdentityResolver,
|
|
777
830
|
});
|
|
778
|
-
const webResearch = await
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
831
|
+
const webResearch = await createWebResearch();
|
|
832
|
+
// The desktop app (app/) stands in for the terminal: it registers its screen and
|
|
833
|
+
// preview player here before the room starts. Without it, nothing changes.
|
|
834
|
+
const host = globalThis[Symbol.for("moondog.host")] ?? null;
|
|
835
|
+
// The listening room plays previews through the system's player when there is one.
|
|
836
|
+
// One-shot commands get none: they would exit in the middle of the first song.
|
|
837
|
+
// In the desktop app, the window's own player plays them instead.
|
|
838
|
+
const previewPlayer = options.command ? null : host ? host.musicPreviewPlayer : await createTerminalPreviewPlayer();
|
|
839
|
+
// What the listener plays in the Music app on this Mac, read but never opened.
|
|
840
|
+
const musicApp = createMusicAppPlayer();
|
|
841
|
+
// NetEase Cloud Music plays through ncm-cli in the terminal and the window alike. Its tools
|
|
842
|
+
// appear once ncm-cli says it is signed in; only listeners who chose it wait for that check.
|
|
843
|
+
const neteasePlayer = createNeteasePlayer();
|
|
844
|
+
const playbackService = await resolvePlaybackService();
|
|
845
|
+
if (playbackService === "netease") await neteasePlayer.check();
|
|
846
|
+
const playbackSelection = createPlaybackSelection({ service: playbackService, save: writePlaybackPreference });
|
|
847
|
+
// The terminal and the window each run this core on one playback.json. A service chosen on the
|
|
848
|
+
// other side reaches this side's picker and agents, and stops the previews this side plays.
|
|
849
|
+
const unwatchPlayback = watchPlaybackPreference(playbackSelection, {
|
|
850
|
+
onChange: (service) => { if (service !== "apple_music") previewPlayer?.stop?.(); },
|
|
851
|
+
});
|
|
852
|
+
// The main application and every conversation the window opens beside it are built from
|
|
853
|
+
// this one list, so they cannot drift apart. Each passes in what may change after launch:
|
|
854
|
+
// its domain services, the Spotify connection, the language and the music service.
|
|
855
|
+
// Spotify's four-week top artists, as the listening upkeep last read them.
|
|
856
|
+
const spotifyTopArtists = { read_at: null, artists: [] };
|
|
857
|
+
const applicationOptions = ({ domainServices, domainServicesError, spotifyConnection, locale, playbackService }) => ({
|
|
858
|
+
domainServices, domainServicesError, spotifyConnection, locale, playbackService, playbackSelection,
|
|
859
|
+
webResearch, memoryStore, musicCatalog, musicPreviewPlayer: previewPlayer, neteasePlayer, musicSimilarity, artistIdentityResolver,
|
|
860
|
+
spotifyTopArtists, musicApp,
|
|
787
861
|
});
|
|
862
|
+
const application = new MoondogApplication(applicationOptions({
|
|
863
|
+
...domainState, spotifyConnection, locale: await resolveListenerLocale(), playbackService,
|
|
864
|
+
}));
|
|
865
|
+
// The conversations the window runs beside the main one, each with its own application and agent.
|
|
866
|
+
const conversations = new Set();
|
|
867
|
+
// An import replaces the domain services under the main application and every open
|
|
868
|
+
// conversation. The old ones close last, once every fork over them has been replaced.
|
|
869
|
+
const replaceDomainServices = (next) => {
|
|
870
|
+
const previous = application.domainServices;
|
|
871
|
+
application.domainServices = next.domainServices;
|
|
872
|
+
application.domainServicesError = next.domainServicesError;
|
|
873
|
+
for (const { application: conversation } of conversations) {
|
|
874
|
+
const fork = conversation.domainServices;
|
|
875
|
+
conversation.domainServices = next.domainServices?.fork?.() ?? null;
|
|
876
|
+
conversation.domainServicesError = next.domainServicesError;
|
|
877
|
+
fork?.close?.();
|
|
878
|
+
}
|
|
879
|
+
previous?.close?.();
|
|
880
|
+
};
|
|
788
881
|
|
|
789
882
|
let lyrics = null;
|
|
883
|
+
let listeningUpkeep = null;
|
|
884
|
+
let exitRequest;
|
|
790
885
|
try {
|
|
791
|
-
|
|
886
|
+
// The command keeps a local trace of the agent's tool calls in the state directory.
|
|
887
|
+
const toolTrace = createToolTrace(process.env);
|
|
888
|
+
const runtime = await createConfiguredRuntime(application, process.env, { toolTrace });
|
|
792
889
|
const runtimeStatus = runtime.publicStatus();
|
|
793
890
|
|
|
794
891
|
if (localCommands.has(options.command)) {
|
|
@@ -861,38 +958,140 @@ async function main() {
|
|
|
861
958
|
throw new Error(`Unknown command: ${options.command}`);
|
|
862
959
|
}
|
|
863
960
|
|
|
864
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
961
|
+
if (!host && (!process.stdin.isTTY || !process.stdout.isTTY)) {
|
|
865
962
|
throw new Error(
|
|
866
963
|
"The listening room needs an interactive terminal. For a quick check without one, try moondog status.",
|
|
867
964
|
);
|
|
868
965
|
}
|
|
869
966
|
lyrics = await createLyricService().catch(() => null);
|
|
870
|
-
|
|
967
|
+
// Each step stands alone, so a Spotify outage does not hold back a due rebuild.
|
|
968
|
+
listeningUpkeep = startListeningUpkeep({
|
|
969
|
+
run: async ({ signal }) => {
|
|
970
|
+
const spotify = application.spotifyReady() ? application.spotifyConnection : null;
|
|
971
|
+
const granted = (scope) => spotify !== null && !spotify.missingScopes?.().includes(scope);
|
|
972
|
+
if (granted("user-read-recently-played")) {
|
|
973
|
+
const synced = await syncRecentSpotifyListening({ signal }).catch(() => null);
|
|
974
|
+
// Open domain services read new plays as they land; only a first history needs opening.
|
|
975
|
+
if (synced?.inserted_events > 0 && !application.domainServices) replaceDomainServices(await loadDomainServices());
|
|
976
|
+
}
|
|
977
|
+
if (granted("user-top-read")) {
|
|
978
|
+
const top = await spotify.service.topItems({ type: "artists", timeRange: "short_term", limit: 10 }, { signal })
|
|
979
|
+
.catch(() => null);
|
|
980
|
+
if (top) {
|
|
981
|
+
spotifyTopArtists.read_at = new Date().toISOString();
|
|
982
|
+
spotifyTopArtists.artists = top.items.flatMap((artist) => typeof artist.name === "string" ? [artist.name.slice(0, 256)] : []);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
signal.throwIfAborted();
|
|
986
|
+
await application.refreshListenerProfile({ signal });
|
|
987
|
+
},
|
|
988
|
+
});
|
|
989
|
+
// The window answers in several sessions at the same time: each busy session gets its own
|
|
990
|
+
// application on the shared services, with its own fork of the domain services, and its own
|
|
991
|
+
// agent. The terminal never opens one.
|
|
992
|
+
const openConversation = async ({ sessionId = null, reason = "user_new" } = {}) => {
|
|
993
|
+
// Two agents writing one session would mix their turns.
|
|
994
|
+
const bound = [application, ...[...conversations].map((conversation) => conversation.application)];
|
|
995
|
+
if (sessionId && bound.some((candidate) => candidate.memorySession?.session_id === sessionId)) {
|
|
996
|
+
throw new Error("That conversation is already open.");
|
|
997
|
+
}
|
|
998
|
+
const conversationApplication = new MoondogApplication({
|
|
999
|
+
...applicationOptions({
|
|
1000
|
+
domainServices: application.domainServices?.fork?.() ?? null,
|
|
1001
|
+
domainServicesError: application.domainServicesError,
|
|
1002
|
+
spotifyConnection: application.spotifyConnection,
|
|
1003
|
+
locale: application.locale,
|
|
1004
|
+
playbackService: application.playbackService,
|
|
1005
|
+
}),
|
|
1006
|
+
ownsServices: false,
|
|
1007
|
+
});
|
|
1008
|
+
const conversation = {
|
|
1009
|
+
application: conversationApplication,
|
|
1010
|
+
runtime: null,
|
|
1011
|
+
close() {
|
|
1012
|
+
conversations.delete(conversation);
|
|
1013
|
+
conversationApplication.close();
|
|
1014
|
+
},
|
|
1015
|
+
};
|
|
1016
|
+
// It joins before its agent is built, so an import in the meantime reaches it too.
|
|
1017
|
+
conversations.add(conversation);
|
|
1018
|
+
try {
|
|
1019
|
+
// The agent reads the session's turns when it is built, so the session comes first.
|
|
1020
|
+
if (sessionId) conversationApplication.resumeSession(sessionId);
|
|
1021
|
+
else conversationApplication.startNewSession(reason);
|
|
1022
|
+
conversation.runtime = await createConfiguredRuntime(conversationApplication, process.env, { toolTrace });
|
|
1023
|
+
return conversation;
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
conversation.close();
|
|
1026
|
+
throw error;
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
// The desktop app draws its own window on this same application and agent.
|
|
1030
|
+
exitRequest = await (host?.runRoom ?? runMoondogTui)({
|
|
871
1031
|
application,
|
|
872
1032
|
runtime,
|
|
1033
|
+
toolTrace,
|
|
1034
|
+
openConversation,
|
|
1035
|
+
terminal: host?.terminal,
|
|
1036
|
+
previewPlayer,
|
|
873
1037
|
lyrics,
|
|
1038
|
+
checkUpdates: options => updater.startupCheck(options),
|
|
1039
|
+
runUpdate: (args, options) => runUpdateCommand({ args, updater, deferInstall: true, ...options }),
|
|
874
1040
|
prepareImport: prepareTuiHistoryImport,
|
|
875
|
-
prepareRecentImport: async () =>
|
|
876
|
-
recentPage
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
1041
|
+
prepareRecentImport: async ({ onProgress } = {}) => {
|
|
1042
|
+
const recentPage = await runSpotifySurface({ args: ["recent", "--limit", "50"], stdout: { write() {} } });
|
|
1043
|
+
let librarySnapshot = null;
|
|
1044
|
+
let libraryUnavailable = null;
|
|
1045
|
+
try {
|
|
1046
|
+
librarySnapshot = await runSpotifySurface({ args: ["library"], stdout: { write() {} }, onProgress });
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
// An older recent-plays-only sign-in can still import recent plays.
|
|
1049
|
+
if (error?.code !== "spotify_scope_insufficient") throw error;
|
|
1050
|
+
libraryUnavailable = "missing_permission";
|
|
1051
|
+
}
|
|
1052
|
+
return prepareTuiHistoryImport(undefined, { recentPage, librarySnapshot, libraryUnavailable });
|
|
1053
|
+
},
|
|
880
1054
|
openImportHelp,
|
|
1055
|
+
// The room asks whether a Last.fm key is set, and saves one the listener pastes.
|
|
1056
|
+
lastfm: {
|
|
1057
|
+
status: async () => {
|
|
1058
|
+
const access = await readLastfmConfiguration().catch(() => undefined);
|
|
1059
|
+
return { configured: Boolean(access?.apiKey), source: access?.source ?? null };
|
|
1060
|
+
},
|
|
1061
|
+
save: async (apiKey) => ({ configured: true, source: (await writeLastfmConfiguration(apiKey)).source }),
|
|
1062
|
+
},
|
|
1063
|
+
saveLanguage: locale => writeLanguagePreference(locale),
|
|
1064
|
+
// The service picker and the agent use the same persisted selection, shared by every conversation.
|
|
1065
|
+
savePlaybackService: async (service) => {
|
|
1066
|
+
const result = await application.selectPlaybackService(service, { strict: false });
|
|
1067
|
+
if (result.refused) throw new Error(result.refused);
|
|
1068
|
+
return result.selected_service;
|
|
1069
|
+
},
|
|
881
1070
|
refreshImportedData: async (receipt) => {
|
|
882
1071
|
if (receipt?.provider === "apple-music-library") await refreshAppleLibraryImport();
|
|
883
|
-
|
|
884
|
-
const previous = application.domainServices;
|
|
885
|
-
application.domainServices = next.domainServices;
|
|
886
|
-
application.domainServicesError = next.domainServicesError;
|
|
887
|
-
previous?.close?.();
|
|
1072
|
+
replaceDomainServices(await loadDomainServices());
|
|
888
1073
|
},
|
|
889
|
-
|
|
1074
|
+
// The window also rebuilds the agent of each conversation it opened.
|
|
1075
|
+
// A choice made while a reply runs is saved here, before that reply ends, so quitting does not drop it.
|
|
1076
|
+
saveRuntimeSelection: (selection) => writePiRuntimeSelection(selection),
|
|
1077
|
+
rebuildRuntime: async (selection, target = application) => {
|
|
890
1078
|
if (selection) await writePiRuntimeSelection(selection);
|
|
891
|
-
|
|
1079
|
+
// A key saved with /auth, or a Codex sign-in since, takes effect with the new runtime's tools.
|
|
1080
|
+
await webResearch.refresh();
|
|
1081
|
+
return createConfiguredRuntime(target, process.env, { selection, toolTrace });
|
|
892
1082
|
},
|
|
1083
|
+
// The desktop window lists models and signs in here, with no terminal.
|
|
1084
|
+
modelSettings: createModelSettings({ environment: process.env }),
|
|
1085
|
+
// A host with its own terminal streams takes the sign-in prompts. The
|
|
1086
|
+
// desktop window has none and uses modelSettings instead.
|
|
893
1087
|
runAuth: (provider) =>
|
|
894
1088
|
runAuthCommand({
|
|
895
1089
|
args: ["login", provider],
|
|
1090
|
+
...(host?.terminalStreams && {
|
|
1091
|
+
input: host.terminalStreams.input,
|
|
1092
|
+
output: host.terminalStreams.output,
|
|
1093
|
+
progressOutput: host.terminalStreams.output,
|
|
1094
|
+
}),
|
|
896
1095
|
}),
|
|
897
1096
|
runWeb: (args, options) => runWebCommand({ args, webResearch, ...options }),
|
|
898
1097
|
runProfileAction: (args) => runProfileCommand({
|
|
@@ -901,12 +1100,14 @@ async function main() {
|
|
|
901
1100
|
resolvePreferredSubjectId: optionalAppleMusicSubjectId,
|
|
902
1101
|
output: { write() {} },
|
|
903
1102
|
}),
|
|
904
|
-
runProfile: async (args) => {
|
|
1103
|
+
runProfile: async (args, controls = {}) => {
|
|
905
1104
|
const values = [...args];
|
|
906
1105
|
const json = values.at(-1) === "--json";
|
|
907
1106
|
if (json) values.pop();
|
|
908
1107
|
let output = "";
|
|
909
|
-
|
|
1108
|
+
const command = savedProfileActions.has(values[0]) ? runListenerProfileCommand : runProfileCommand;
|
|
1109
|
+
await command({
|
|
1110
|
+
application, ...controls,
|
|
910
1111
|
args: values,
|
|
911
1112
|
json,
|
|
912
1113
|
commandPrefix: "/profile",
|
|
@@ -915,7 +1116,7 @@ async function main() {
|
|
|
915
1116
|
});
|
|
916
1117
|
return json ? `\`\`\`json\n${output.trim()}\n\`\`\`` : output.trim();
|
|
917
1118
|
},
|
|
918
|
-
runSpotify: async (args, { signal } = {}) => {
|
|
1119
|
+
runSpotify: async (args, { signal, stderr } = {}) => {
|
|
919
1120
|
let output = "";
|
|
920
1121
|
const capture = {
|
|
921
1122
|
write(value) {
|
|
@@ -926,24 +1127,30 @@ async function main() {
|
|
|
926
1127
|
args,
|
|
927
1128
|
signal,
|
|
928
1129
|
stdout: capture,
|
|
929
|
-
stderr: args[0] === "login" ? process.stderr : capture,
|
|
1130
|
+
stderr: stderr ?? (args[0] === "login" ? host?.terminalStreams?.output ?? process.stderr : capture),
|
|
930
1131
|
resolutionCache: await spotifyResolutionCacheIfConfigured(),
|
|
931
1132
|
});
|
|
932
1133
|
if (["import-history", "sync-recent"].includes(args[0])) {
|
|
933
|
-
|
|
934
|
-
const previous = application.domainServices;
|
|
935
|
-
application.domainServices = next.domainServices;
|
|
936
|
-
application.domainServicesError = next.domainServicesError;
|
|
937
|
-
previous?.close?.();
|
|
1134
|
+
replaceDomainServices(await loadDomainServices());
|
|
938
1135
|
}
|
|
939
|
-
|
|
1136
|
+
const connection = await loadSpotifyConnection();
|
|
1137
|
+
application.setSpotifyConnection(connection);
|
|
1138
|
+
for (const conversation of conversations) conversation.application.setSpotifyConnection(connection);
|
|
940
1139
|
return output.trim();
|
|
941
1140
|
},
|
|
942
1141
|
});
|
|
943
1142
|
} finally {
|
|
1143
|
+
unwatchPlayback();
|
|
1144
|
+
listeningUpkeep?.stop();
|
|
944
1145
|
lyrics?.close();
|
|
1146
|
+
// Conversations close first: their domain services are forks of the main application's.
|
|
1147
|
+
for (const conversation of conversations) conversation.close();
|
|
945
1148
|
application.close();
|
|
946
1149
|
}
|
|
1150
|
+
if (exitRequest?.update) {
|
|
1151
|
+
closeSpotifyResolutionCache();
|
|
1152
|
+
await installUpdate(["--channel", exitRequest.update.channel]);
|
|
1153
|
+
}
|
|
947
1154
|
}
|
|
948
1155
|
|
|
949
1156
|
main()
|
|
@@ -951,4 +1158,8 @@ main()
|
|
|
951
1158
|
process.stderr.write(`moondog: ${sanitizeTerminalText(error.message)}\n`);
|
|
952
1159
|
process.exitCode = 1;
|
|
953
1160
|
})
|
|
954
|
-
.finally(() =>
|
|
1161
|
+
.finally(() => {
|
|
1162
|
+
closeSpotifyResolutionCache();
|
|
1163
|
+
// The desktop app closes when its room does.
|
|
1164
|
+
globalThis[Symbol.for("moondog.host")]?.closed?.();
|
|
1165
|
+
});
|
|
@@ -1,28 +1,12 @@
|
|
|
1
1
|
import { lstat, readFile, readdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
3
|
|
|
5
4
|
import { resolveMoondogStateDirectory } from "./state-directory.mjs";
|
|
6
5
|
|
|
7
|
-
const repositoryRoot = path.resolve(
|
|
8
|
-
path.dirname(fileURLToPath(import.meta.url)),
|
|
9
|
-
"../..",
|
|
10
|
-
);
|
|
11
|
-
|
|
12
|
-
// Earlier versions kept Apple Music data inside the source checkout.
|
|
13
|
-
export const legacyAppleMusicImportsRoot = path.join(
|
|
14
|
-
repositoryRoot,
|
|
15
|
-
"data",
|
|
16
|
-
"imports",
|
|
17
|
-
"apple-music-library",
|
|
18
|
-
);
|
|
19
|
-
|
|
20
6
|
export function defaultAppleMusicImportsRootFor(environment = process.env) {
|
|
21
7
|
return path.join(resolveMoondogStateDirectory(environment), "apple-music-library", "imports");
|
|
22
8
|
}
|
|
23
9
|
|
|
24
|
-
export const defaultAppleMusicImportsRoot = defaultAppleMusicImportsRootFor();
|
|
25
|
-
|
|
26
10
|
export function resolveAppleMusicImportsRoot(environment = process.env) {
|
|
27
11
|
const configured = environment.MOONDOG_APPLE_IMPORTS_ROOT?.trim();
|
|
28
12
|
if (!configured) return defaultAppleMusicImportsRootFor(environment);
|