@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.
Files changed (125) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +156 -68
  3. package/contracts/v1/definitions.schema.json +1 -169
  4. package/package.json +7 -23
  5. package/scripts/contract-lib.mjs +0 -7
  6. package/scripts/contract-semantics.mjs +0 -1904
  7. package/scripts/moondog.mjs +285 -74
  8. package/src/core/apple-library-source-status.mjs +0 -16
  9. package/src/core/apple-projection-domain-services.mjs +253 -53
  10. package/src/core/application-update.mjs +320 -0
  11. package/src/core/capability-catalog.mjs +290 -55
  12. package/src/core/listener-avoids.mjs +101 -0
  13. package/src/core/listening-now.mjs +108 -0
  14. package/src/core/listening-profile-domain-services.mjs +189 -41
  15. package/src/core/listening-upkeep.mjs +31 -0
  16. package/src/core/moondog-application.mjs +2115 -107
  17. package/src/core/playback-preferences.mjs +97 -0
  18. package/src/core/spotify-listening-intent.mjs +124 -0
  19. package/src/core/synthetic-domain-services.mjs +24 -3
  20. package/src/core/track-title.mjs +107 -0
  21. package/src/demo/moondog-demo.mjs +4 -22
  22. package/src/i18n/index.mjs +285 -0
  23. package/src/i18n/messages/de.mjs +135 -0
  24. package/src/i18n/messages/en.mjs +136 -0
  25. package/src/i18n/messages/es.mjs +135 -0
  26. package/src/i18n/messages/fr.mjs +135 -0
  27. package/src/i18n/messages/it.mjs +135 -0
  28. package/src/i18n/messages/ja.mjs +135 -0
  29. package/src/i18n/messages/ko.mjs +135 -0
  30. package/src/i18n/messages/pt.mjs +135 -0
  31. package/src/i18n/messages/zh-Hant.mjs +135 -0
  32. package/src/i18n/messages/zh.mjs +135 -0
  33. package/src/i18n/preferences.mjs +34 -0
  34. package/src/i18n/screen/es.mjs +967 -0
  35. package/src/i18n/screen/ja.mjs +967 -0
  36. package/src/i18n/screen/pt.mjs +967 -0
  37. package/src/i18n/screen/zh.mjs +967 -0
  38. package/src/importers/apple-music-library/index.mjs +0 -16
  39. package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
  40. package/src/integrations/apple-music/catalog.mjs +166 -14
  41. package/src/integrations/apple-music/music-app.mjs +232 -0
  42. package/src/integrations/apple-music/storefront.mjs +28 -0
  43. package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
  44. package/src/integrations/lastfm/history.mjs +221 -0
  45. package/src/integrations/lastfm/settings.mjs +75 -0
  46. package/src/integrations/netease/ncm-cli-player.mjs +328 -0
  47. package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
  48. package/src/integrations/public-playlists.mjs +2 -1
  49. package/src/integrations/spotify/account-data-profile.mjs +14 -6
  50. package/src/integrations/spotify/authentication.mjs +149 -15
  51. package/src/integrations/spotify/catalog-resolver.mjs +9 -6
  52. package/src/integrations/spotify/connection.mjs +2 -1
  53. package/src/integrations/spotify/credential-store.mjs +4 -0
  54. package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
  55. package/src/integrations/spotify/history-archive.mjs +1 -1
  56. package/src/integrations/spotify/library-snapshot.mjs +227 -0
  57. package/src/integrations/spotify/recent-activity.mjs +2 -0
  58. package/src/integrations/spotify/service.mjs +873 -69
  59. package/src/integrations/spotify/web-api-client.mjs +410 -127
  60. package/src/integrations/web/brave-web.mjs +45 -0
  61. package/src/integrations/web/codex-web.mjs +15 -82
  62. package/src/integrations/web/deepseek-web.mjs +81 -0
  63. package/src/integrations/web/web-research.mjs +119 -0
  64. package/src/integrations/web/web-result.mjs +215 -0
  65. package/src/integrations/youtube-music/takeout.mjs +3 -1
  66. package/src/memory/local-memory-store.mjs +542 -57
  67. package/src/profile/history-import.mjs +42 -6
  68. package/src/profile/listener-corrections.mjs +22 -0
  69. package/src/profile/listener-profile-build.mjs +461 -0
  70. package/src/profile/listener-profile-store.mjs +130 -0
  71. package/src/profile/listener-profile-verification.mjs +68 -0
  72. package/src/profile/listener-profile-worker.mjs +179 -0
  73. package/src/profile/listening-history-store.mjs +640 -17
  74. package/src/profile/listening-profile-projection.mjs +454 -86
  75. package/src/profile/local-music-data.mjs +1 -1
  76. package/src/profile/moondog-suggestions.mjs +184 -0
  77. package/src/profile/music-providers.mjs +11 -7
  78. package/src/profile/profile-exploration.mjs +92 -0
  79. package/src/profile/recorded-measures.mjs +24 -0
  80. package/src/profile/spotify-archive-taste.mjs +0 -4
  81. package/src/runtime/pi/agent-runtime.mjs +2536 -692
  82. package/src/runtime/pi/authentication.mjs +34 -1
  83. package/src/runtime/pi/configured-runtime.mjs +22 -6
  84. package/src/runtime/pi/conversation-name.mjs +53 -0
  85. package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
  86. package/src/runtime/pi/model-catalog.mjs +28 -5
  87. package/src/runtime/pi/model-settings.mjs +202 -0
  88. package/src/runtime/pi/model-transport.mjs +10 -0
  89. package/src/runtime/pi/persistent-credential-store.mjs +67 -31
  90. package/src/runtime/pi/profile-build-runtime.mjs +161 -0
  91. package/src/runtime/pi/public-model-catalog.mjs +288 -0
  92. package/src/runtime/pi/runtime-settings.mjs +10 -2
  93. package/src/runtime/pi/skills/listener-profile/SKILL.md +48 -0
  94. package/src/runtime/pi/tool-trace.mjs +37 -0
  95. package/src/surfaces/cli/auth-command.mjs +12 -4
  96. package/src/surfaces/cli/brand-components.mjs +310 -132
  97. package/src/surfaces/cli/brand-theme.mjs +51 -28
  98. package/src/surfaces/cli/command-completions.mjs +58 -42
  99. package/src/surfaces/cli/format-output.mjs +199 -388
  100. package/src/surfaces/cli/history-import-view.mjs +292 -139
  101. package/src/surfaces/cli/import-guides.mjs +2 -0
  102. package/src/surfaces/cli/listener-profile-command.mjs +92 -0
  103. package/src/surfaces/cli/listening-menu.mjs +7 -4
  104. package/src/surfaces/cli/profile-command.mjs +30 -21
  105. package/src/surfaces/cli/seasons-chart.mjs +87 -0
  106. package/src/surfaces/cli/spotify-command.mjs +54 -6
  107. package/src/surfaces/cli/taste-profile-model.mjs +78 -79
  108. package/src/surfaces/cli/taste-profile-view.mjs +33 -26
  109. package/src/surfaces/cli/terminal-art.mjs +140 -35
  110. package/src/surfaces/cli/tui.mjs +1066 -357
  111. package/src/surfaces/cli/update-command.mjs +32 -0
  112. package/src/surfaces/cli/web-command.mjs +53 -14
  113. package/src/surfaces/html/tasteprint.mjs +43 -70
  114. package/contracts/v1/capability-policy.schema.json +0 -572
  115. package/contracts/v1/profile-evidence.schema.json +0 -237
  116. package/contracts/v1/tool-invocation.schema.json +0 -318
  117. package/contracts/v1/tool-receipt.schema.json +0 -1066
  118. package/scripts/demo-studio.mjs +0 -58
  119. package/src/core/apple-data-migration.mjs +0 -50
  120. package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
  121. package/src/demo/moondog-tasteprint-demo.mjs +0 -631
  122. package/src/integrations/spotify/index.mjs +0 -80
  123. package/src/surfaces/web/showcase.mjs +0 -272
  124. package/src/surfaces/web/studio-page.mjs +0 -4148
  125. package/src/surfaces/web/studio.mjs +0 -2288
@@ -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 { createCodexWebResearch } from "../src/integrations/web/codex-web.mjs";
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, prepareSpotifyRecentImport } from "../src/profile/history-import.mjs";
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 { runMoondogStudio } from "../src/surfaces/web/studio.mjs";
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 ? prepareSpotifyRecentImport({ page: recentPage, subjectId }) : await prepareHistoryImport({
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-recent"
324
- ? historyStore.ingest(prepared.bundle)
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 createCodexWebResearch();
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 createCodexWebResearch();
779
- const application = new MoondogApplication({
780
- ...domainState,
781
- webResearch,
782
- memoryStore,
783
- spotifyConnection,
784
- musicCatalog,
785
- musicSimilarity,
786
- artistIdentityResolver,
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
- const runtime = await createConfiguredRuntime(application);
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
- await runMoondogTui({
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 () => prepareTuiHistoryImport(undefined, {
876
- recentPage: await runSpotifySurface({
877
- args: ["recent", "--limit", "50"], stdout: { write() {} },
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
- const next = await loadDomainServices();
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
- rebuildRuntime: async (selection) => {
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
- return createConfiguredRuntime(application, process.env, { selection });
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
- await runProfileCommand({
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
- const next = await loadDomainServices();
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
- application.setSpotifyConnection(await loadSpotifyConnection());
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(() => closeSpotifyResolutionCache());
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);