@audiofool/moondog 0.3.0-beta.1 → 0.3.0-beta.10

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 (46) hide show
  1. package/README.md +2 -1
  2. package/package.json +1 -1
  3. package/scripts/moondog.mjs +138 -10
  4. package/src/core/capability-catalog.mjs +49 -0
  5. package/src/core/library-store.mjs +500 -0
  6. package/src/core/listening-now.mjs +80 -6
  7. package/src/core/moondog-application.mjs +570 -32
  8. package/src/i18n/index.mjs +6 -3
  9. package/src/i18n/messages/de.mjs +1 -0
  10. package/src/i18n/messages/en.mjs +2 -0
  11. package/src/i18n/messages/es.mjs +1 -0
  12. package/src/i18n/messages/fr.mjs +1 -0
  13. package/src/i18n/messages/it.mjs +1 -0
  14. package/src/i18n/messages/ja.mjs +1 -0
  15. package/src/i18n/messages/ko.mjs +1 -0
  16. package/src/i18n/messages/pt.mjs +1 -0
  17. package/src/i18n/messages/zh-Hant.mjs +1 -0
  18. package/src/i18n/messages/zh.mjs +1 -0
  19. package/src/i18n/preferences.mjs +24 -0
  20. package/src/i18n/screen/es.mjs +92 -0
  21. package/src/i18n/screen/ja.mjs +92 -0
  22. package/src/i18n/screen/pt.mjs +92 -0
  23. package/src/i18n/screen/zh.mjs +92 -0
  24. package/src/integrations/apple-music/music-app.mjs +9 -3
  25. package/src/integrations/apple-music/terminal-preview-player.mjs +31 -1
  26. package/src/integrations/netease/ncm-cli-player.mjs +12 -6
  27. package/src/integrations/playlist-links.mjs +96 -0
  28. package/src/integrations/public-playlists.mjs +10 -4
  29. package/src/integrations/spotify/service.mjs +22 -1
  30. package/src/integrations/stable-audio/cover.mjs +324 -0
  31. package/src/integrations/stable-audio/local-generator.mjs +259 -87
  32. package/src/integrations/stable-audio/setup.mjs +653 -0
  33. package/src/integrations/stable-audio/sources.mjs +106 -0
  34. package/src/profile/listening-history-store.mjs +1 -1
  35. package/src/profile/listening-profile-projection.mjs +1 -1
  36. package/src/profile/music-import-bundle.mjs +3 -1
  37. package/src/profile/music-providers.mjs +3 -0
  38. package/src/profile/taste-compiler.mjs +14 -5
  39. package/src/runtime/pi/agent-runtime.mjs +324 -31
  40. package/src/runtime/pi/model-switch.mjs +26 -0
  41. package/src/runtime/pi/model-transport.mjs +34 -4
  42. package/src/runtime/pi/record-name.mjs +80 -0
  43. package/src/runtime/pi/reply-language.mjs +45 -0
  44. package/src/runtime/pi/tool-arguments.mjs +89 -0
  45. package/src/surfaces/cli/command-completions.mjs +15 -1
  46. package/src/surfaces/cli/tui.mjs +404 -18
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h1 align="center">Moondog</h1>
6
6
 
7
- <p align="center">Source candidate 0.3.0-beta.1</p>
7
+ <p align="center">Source candidate 0.3.0-beta.10</p>
8
8
 
9
9
  <p align="center"><strong>A listening room that shows its work.</strong></p>
10
10
 
@@ -203,6 +203,7 @@ Your profile, and anything Moondog remembers, carry over.
203
203
  - [Product charter](docs/PRODUCT_CHARTER.md) and [public roadmap](docs/ROADMAP.md): where Moondog is headed, and what it will not do.
204
204
  - [Voice](docs/VOICE.md): how Moondog talks, and where Pink Floyd shows up.
205
205
  - [The listening loop](docs/LISTENING_LOOP.md): how the agent keeps your choices, your recent listening, and its own picks apart.
206
+ - [The agent harness](docs/AGENT_HARNESS.md): how the tools keep the model's part small, so every supported model can play what you ask, and how each model is measured.
206
207
  - [Runtime architecture](docs/ADR_0001_AGENT_RUNTIME_AND_CLI.md) and [data contracts](contracts/README.md): how the pieces fit together.
207
208
  - [Contribution guide](CONTRIBUTING.md): local setup, checks, and how private data is kept out.
208
209
  - [Release status](docs/PUBLIC_RELEASE_READINESS.md): what is published, and what the checks cover.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@audiofool/moondog",
3
- "version": "0.3.0-beta.1",
3
+ "version": "0.3.0-beta.10",
4
4
  "type": "module",
5
5
  "description": "Local-first personal AI music agent.",
6
6
  "license": "Apache-2.0",
@@ -7,6 +7,7 @@ import { runUpdateCommand } from "../src/surfaces/cli/update-command.mjs";
7
7
 
8
8
  import { MoondogApplication } from "../src/core/moondog-application.mjs";
9
9
  import { openLocalMemoryStore } from "../src/memory/local-memory-store.mjs";
10
+ import { openLibraryStore } from "../src/core/library-store.mjs";
10
11
  import {
11
12
  openAppleProjectionDomainServices,
12
13
  resolveAppleMusicSubjectId,
@@ -34,6 +35,7 @@ import { runListenerProfileCommand, savedProfileActions } from "../src/surfaces/
34
35
  import { createWebResearch } from "../src/integrations/web/web-research.mjs";
35
36
  import { runWebCommand } from "../src/surfaces/cli/web-command.mjs";
36
37
  import { openSpotifyConnection } from "../src/integrations/spotify/connection.mjs";
38
+ import { preferredIdleDevice, thisComputerName } from "../src/integrations/spotify/service.mjs";
37
39
  import {
38
40
  openSpotifyResolutionCache,
39
41
  } from "../src/integrations/spotify/resolution-cache.mjs";
@@ -43,6 +45,7 @@ import {
43
45
  import { runSpotifyCommand } from "../src/surfaces/cli/spotify-command.mjs";
44
46
  import { runListenBrainzCommand } from "../src/surfaces/cli/listenbrainz-command.mjs";
45
47
  import { openListeningHistoryStore } from "../src/profile/listening-history-store.mjs";
48
+ import { musicHash, prepareMusicBundle } from "../src/profile/music-import-bundle.mjs";
46
49
  import { commitSpotifyQuickImport, prepareHistoryImport, prepareSpotifyQuickImport } from "../src/profile/history-import.mjs";
47
50
  import { persistAppleLibraryImport, refreshAppleLibraryImport } from "../src/profile/apple-library-import.mjs";
48
51
  import { IMPORT_GUIDE_URLS } from "../src/surfaces/cli/import-guides.mjs";
@@ -54,6 +57,7 @@ import { createAppleMusicCatalog } from "../src/integrations/apple-music/catalog
54
57
  import { createTerminalPreviewPlayer } from "../src/integrations/apple-music/terminal-preview-player.mjs";
55
58
  import { createNeteasePlayer } from "../src/integrations/netease/ncm-cli-player.mjs";
56
59
  import { createAtmosphereStream, createLocalGenerator } from "../src/integrations/stable-audio/local-generator.mjs";
60
+ import { createStableAudioSetup } from "../src/integrations/stable-audio/setup.mjs";
57
61
  import { createMusicAppPlayer } from "../src/integrations/apple-music/music-app.mjs";
58
62
  import {
59
63
  createOpenMusicSimilarity,
@@ -69,7 +73,8 @@ import {
69
73
  SPOTIFY_ARCHIVE_SET_MAXIMUM,
70
74
  } from "../src/profile/spotify-archive-taste.mjs";
71
75
  import { deriveArtistReleaseHintsFromSpotifyArchive } from "../src/profile/spotify-archive-catalog-hints.mjs";
72
- import { resolveListenerLocale, writeLanguagePreference } from "../src/i18n/preferences.mjs";
76
+ import { createLanguageSelection, resolveListenerLocale, writeLanguagePreference } from "../src/i18n/preferences.mjs";
77
+ import { createModelSwitch } from "../src/runtime/pi/model-switch.mjs";
73
78
  import { createPlaybackSelection, resolvePlaybackService, watchPlaybackPreference, writePlaybackPreference } from "../src/core/playback-preferences.mjs";
74
79
  import { createFirstRun } from "../src/core/first-run.mjs";
75
80
 
@@ -146,6 +151,28 @@ function parseArguments(argv) {
146
151
  };
147
152
  }
148
153
 
154
+ // A playlist imported into the library adds its songs to the listener's taste evidence, as a kept playlist, the
155
+ // same way a public playlist imported from the Import guide does. Its source names where it came from.
156
+ const PLAYLIST_TASTE_SOURCES = { spotify: "spotify_playlist", apple_music: "apple_music_playlist", netease: "netease", qq_music: "qq_music" };
157
+ async function addPlaylistToTaste(found) {
158
+ const provider = PLAYLIST_TASTE_SOURCES[found.provider];
159
+ if (!provider) throw new Error("This playlist's service can't shape the profile.");
160
+ const historyStore = await openListeningHistoryStore();
161
+ try {
162
+ const appleSubjectId = await optionalAppleMusicSubjectId();
163
+ const subjectId = historyStore.localSubjectId({ ...(appleSubjectId ? { preferredSubjectId: appleSubjectId } : {}), create: true });
164
+ const rows = found.tracks.map((track, index) => ({ id: track.id, title: track.title, artist: track.artist, ...(track.album ? { album: track.album } : {}),
165
+ playlistId: found.id, playlistName: found.name, position: index + 1, member: "playlist-link.json", listenerMade: found.owned === true }));
166
+ const bytes = Buffer.from(JSON.stringify({ provider, id: found.id, name: found.name, rows }));
167
+ const prepared = prepareMusicBundle({ provider, subjectId, capturedAt: new Date().toISOString(), rows, digest: musicHash(bytes), size: bytes.length,
168
+ members: ["playlist-link.json"], fileName: found.name,
169
+ scopeNote: "A playlist the listener imported into their library. It shows what they keep, not what they played." });
170
+ return historyStore.ingestImport(prepared.bundle);
171
+ } finally {
172
+ historyStore.close();
173
+ }
174
+ }
175
+
149
176
  async function optionalAppleMusicSubjectId() {
150
177
  const source = await readAppleMusicSourceStatus();
151
178
  if (source.state === "missing") return null;
@@ -452,6 +479,76 @@ async function emitTasteprintArtifact(
452
479
  return result;
453
480
  }
454
481
 
482
+ // While the terminal's stream runs, the Music app is read this often. Only a change from not playing
483
+ // to playing ends the stream, and only in a read that began after the stream last changed and past its
484
+ // start: the song the first track pauses keeps playing while that track is made.
485
+ const STREAM_MUSIC_APP_MS = 5_000;
486
+ function watchMusicAppForStream(stream, { musicApp, interval = STREAM_MUSIC_APP_MS }) {
487
+ if (musicApp?.available !== true) return () => {};
488
+ let timer = null;
489
+ let reading = false;
490
+ let playing = null;
491
+ let phase = null;
492
+ let changedAt = 0;
493
+ const read = async () => {
494
+ if (reading) return;
495
+ reading = true;
496
+ const began = Date.now();
497
+ try {
498
+ const now = await musicApp.nowPlaying().catch(() => null);
499
+ const view = stream.view();
500
+ if (!view || began <= changedAt) return;
501
+ const playingNow = now?.state === "playing";
502
+ if (playingNow && playing === false && view.phase !== "starting") stream.stop("other_music");
503
+ playing = playingNow;
504
+ } finally {
505
+ reading = false;
506
+ }
507
+ };
508
+ const unsubscribe = stream.subscribe(({ view }) => {
509
+ changedAt = Date.now();
510
+ // The first track just paused the Music app, so playing at a later read is news.
511
+ if (phase === "starting" && view && view.phase !== "starting") playing = false;
512
+ phase = view?.phase ?? null;
513
+ if (view && !timer) {
514
+ playing = null;
515
+ timer = setInterval(() => void read(), interval);
516
+ timer.unref?.();
517
+ } else if (!view && timer) {
518
+ clearInterval(timer);
519
+ timer = null;
520
+ }
521
+ });
522
+ return () => {
523
+ unsubscribe();
524
+ clearInterval(timer);
525
+ timer = null;
526
+ };
527
+ }
528
+
529
+ // Whether a /spotify command starts Spotify on this Mac: play on the device it resumes on (the active one,
530
+ // or with none active the one it would pick), or a move onto this Mac with --play or while Spotify plays (a
531
+ // move keeps playing what plays). A command its own checks refuse, or reads that fail, start nothing here.
532
+ async function spotifyStartsHere(application, args, { signal } = {}) {
533
+ if (!application.spotifyReady?.()) return false;
534
+ // A trailing --json changes only how the result is written.
535
+ if (args.at(-1) === "--json") args = args.slice(0, -1);
536
+ const play = args[0] === "play" && args.length <= 2;
537
+ const transfer = args[0] === "transfer" && args.length >= 2 && args.length <= 3 && (args.length === 2 || args[2] === "--play");
538
+ if (!play && !transfer) return false;
539
+ const service = application.requireSpotifyService();
540
+ const [listed, player] = await Promise.all([service.devices({ signal }).catch(() => null), service.currentPlayer({ signal }).catch(() => null)]);
541
+ const devices = listed?.devices ?? [];
542
+ if (transfer) {
543
+ const device = devices.find((entry) => entry.id === args[1]);
544
+ const playing = player?.state === "available" && player.is_playing === true;
545
+ return Boolean(device) && application.spotifyOnThisMac(device) && (args[2] === "--play" || playing);
546
+ }
547
+ const active = player?.device?.is_active === true ? player.device : devices.find((device) => device.is_active === true);
548
+ const device = active ?? preferredIdleDevice(devices, { thisComputer: await thisComputerName() });
549
+ return Boolean(device) && application.spotifyOnThisMac(device);
550
+ }
551
+
455
552
  async function main() {
456
553
  const updater = createApplicationUpdater();
457
554
  const argv = process.argv.slice(2);
@@ -830,6 +927,9 @@ async function main() {
830
927
 
831
928
  const domainState = await loadDomainServices();
832
929
  const memoryStore = await openLocalMemoryStore();
930
+ // The listener's library: Saved, their playlists and folders, on one file the terminal and the window share.
931
+ // A library that can't open leaves the rest of Moondog working, without it.
932
+ const libraryStore = await openLibraryStore().catch(() => null);
833
933
  const spotifyConnection = await loadSpotifyConnection();
834
934
  const musicCatalog = createAppleMusicCatalog();
835
935
  const artistIdentityResolver = createWikidataArtistResolver();
@@ -851,10 +951,14 @@ async function main() {
851
951
  const neteasePlayer = createNeteasePlayer();
852
952
  const playbackService = await resolvePlaybackService();
853
953
  if (playbackService === "netease") await neteasePlayer.check();
854
- // Moondog makes music on this Mac with the listener's own Stable Audio 3 once they turn it on, in a
855
- // room that can play what it makes. The terminal and the window each keep one generator, so each
856
- // makes one track at a time; a choice saved on one side is checked again on the other.
857
- const localGenerator = previewPlayer ? createLocalGenerator() : null;
954
+ // Moondog makes music on this Mac with Stable Audio 3 once the listener turns it on, in a room
955
+ // that can play what it makes: Moondog's own install, downloaded when they ask, or their folder.
956
+ // The terminal and the window each keep one generator, so each makes one track at a time; a
957
+ // choice saved on one side is checked again on the other.
958
+ const localGenerator = previewPlayer ? createLocalGenerator({ keeps: (id) => libraryStore?.holdsMade(id) === true }) : null;
959
+ const generationSetup = localGenerator ? createStableAudioSetup() : null;
960
+ // A model set up or removed, here or on the other side, is checked again.
961
+ const unwatchGenerationSetup = generationSetup?.subscribe(() => { void localGenerator.check().catch(() => {}); }) ?? (() => {});
858
962
  await localGenerator?.check();
859
963
  void localGenerator?.sweep().catch(() => {});
860
964
  const unwatchGeneration = localGenerator?.watch() ?? (() => {});
@@ -869,11 +973,20 @@ async function main() {
869
973
  const unobserveStream = atmosphereStream ? previewPlayer.subscribe?.((event) => atmosphereStream.observe(event)) : null;
870
974
  // Music making turned off, here or on the other surface, ends the stream.
871
975
  const unwatchStreamGeneration = atmosphereStream ? localGenerator.subscribe((status) => { if (status.state !== "ready") atmosphereStream.stop("generation_off"); }) : null;
976
+ // The window watches the Music app for its own stream; the terminal has no window, so while its
977
+ // stream runs it reads the Music app itself, and music the listener starts there ends the stream.
978
+ const unwatchStreamMusicApp = atmosphereStream && !host ? watchMusicAppForStream(atmosphereStream, { musicApp }) : null;
872
979
  const playbackSelection = createPlaybackSelection({ service: playbackService, save: writePlaybackPreference });
980
+ // A reply language the listener asks for in words is saved like the picker's and reaches every surface.
981
+ const languageSelection = createLanguageSelection({ save: (locale) => writeLanguagePreference(locale) });
982
+ // The listener's model menu, shared by the window, the terminal and a model asked for in words.
983
+ const modelSettings = createModelSettings({ environment: process.env });
984
+ const modelSwitch = createModelSwitch({ list: () => modelSettings.list() });
873
985
  // The terminal and the window each run this core on one playback.json. A service chosen on the
874
- // other side reaches this side's picker and agents, and stops the previews this side plays.
986
+ // other side reaches this side's picker and agents, and stops the Apple Music songs this side plays.
987
+ // Moondog's stream is not a service: its made tracks play on.
875
988
  const unwatchPlayback = watchPlaybackPreference(playbackSelection, {
876
- onChange: (service) => { if (service !== "apple_music") { atmosphereStream?.stop("service_change"); previewPlayer?.stop?.(); } },
989
+ onChange: (service) => { if (service !== "apple_music" && previewPlayer?.holdsCatalogSongs?.() !== false) previewPlayer?.stop?.(); },
877
990
  });
878
991
  // The main application and every conversation the window opens beside it are built from
879
992
  // this one list, so they cannot drift apart. Each passes in what may change after launch:
@@ -881,9 +994,11 @@ async function main() {
881
994
  // Spotify's four-week top artists, as the listening upkeep last read them.
882
995
  const spotifyTopArtists = { read_at: null, artists: [] };
883
996
  const applicationOptions = ({ domainServices, domainServicesError, spotifyConnection, locale, playbackService }) => ({
884
- domainServices, domainServicesError, spotifyConnection, locale, playbackService, playbackSelection,
997
+ domainServices, domainServicesError, spotifyConnection, locale, playbackService, playbackSelection, languageSelection, modelSwitch,
885
998
  webResearch, memoryStore, musicCatalog, musicPreviewPlayer: previewPlayer, neteasePlayer, localGenerator, atmosphereStream, musicSimilarity, artistIdentityResolver,
886
- spotifyTopArtists, musicApp,
999
+ spotifyTopArtists, musicApp, libraryStore,
1000
+ // A playlist that shapes the taste reloads the profile services at once, so the evidence counts in this session.
1001
+ playlistTaste: async (found) => { const receipt = await addPlaylistToTaste(found); replaceDomainServices(await loadDomainServices()); return receipt; },
887
1002
  });
888
1003
  const application = new MoondogApplication(applicationOptions({
889
1004
  ...domainState, spotifyConnection, locale: await resolveListenerLocale(), playbackService,
@@ -1083,7 +1198,12 @@ async function main() {
1083
1198
  status: () => localGenerator.status(),
1084
1199
  check: () => localGenerator.check(),
1085
1200
  save: (settings) => localGenerator.save(settings),
1201
+ // Uses an installed model of Moondog's own install.
1202
+ use: async (model) => (generationSetup?.status()?.models?.[model]?.state === "ready"
1203
+ ? localGenerator.save({ source: "managed", model }) : localGenerator.check()),
1086
1204
  subscribe: (listener) => localGenerator.subscribe(listener),
1205
+ // Download, cancel and remove, shared by the window and the terminal.
1206
+ setup: generationSetup,
1087
1207
  },
1088
1208
  // The room asks whether a Last.fm key is set, and saves one the listener pastes.
1089
1209
  lastfm: {
@@ -1113,7 +1233,7 @@ async function main() {
1113
1233
  return createConfiguredRuntime(target, process.env, { selection, toolTrace });
1114
1234
  },
1115
1235
  // The desktop window lists models and signs in here, with no terminal.
1116
- modelSettings: createModelSettings({ environment: process.env }),
1236
+ modelSettings,
1117
1237
  // A model chosen or a sign-in made on the other surface. Model variables fix the model at
1118
1238
  // launch, so a saved choice has nothing to change then.
1119
1239
  watchModelSettings: (onChange) => (process.env.MOONDOG_PROVIDER !== undefined || process.env.MOONDOG_MODEL !== undefined
@@ -1167,6 +1287,9 @@ async function main() {
1167
1287
  return json ? `\`\`\`json\n${output.trim()}\n\`\`\`` : output.trim();
1168
1288
  },
1169
1289
  runSpotify: async (args, { signal, stderr } = {}) => {
1290
+ // /spotify play, and a move onto this Mac that plays there, start Spotify here: the other players go
1291
+ // quiet first. A command its own checks will refuse, or a move to another device, quiets nothing.
1292
+ if (await spotifyStartsHere(application, args, { signal })) await application.quietOtherPlayers("spotify", { signal });
1170
1293
  let output = "";
1171
1294
  const capture = {
1172
1295
  write(value) {
@@ -1192,6 +1315,10 @@ async function main() {
1192
1315
  } finally {
1193
1316
  unwatchPlayback();
1194
1317
  unwatchGeneration();
1318
+ unwatchGenerationSetup();
1319
+ // A download or uv run this process owns would keep it alive after the room closes; it stops and
1320
+ // resumes next time. One owned by the other window or terminal runs on.
1321
+ await generationSetup?.close?.();
1195
1322
  // A made track still playing is stopped while the generator listens, so its outcome is kept.
1196
1323
  if (localGenerator) {
1197
1324
  previewPlayer?.stop?.();
@@ -1200,6 +1327,7 @@ async function main() {
1200
1327
  unobservePlayer?.();
1201
1328
  unobserveStream?.();
1202
1329
  unwatchStreamGeneration?.();
1330
+ unwatchStreamMusicApp?.();
1203
1331
  atmosphereStream?.stop("closed");
1204
1332
  listeningUpkeep?.stop();
1205
1333
  lyrics?.close();
@@ -7,6 +7,11 @@ const catalog = [
7
7
  description: "Inspect or change the current music service through the same saved selection as the host's service picker.",
8
8
  agent_tool: { name: "moondog_playback_service", label: N_("Choose a music service") },
9
9
  },
10
+ {
11
+ id: "settings", version: "1", state: "enabled", effect: "write_local",
12
+ description: "Read Moondog's own setup (model, music service, reply language) and change the settings the listener may change by asking.",
13
+ agent_tool: { name: "moondog_settings", label: N_("Read or change Moondog's settings") },
14
+ },
10
15
  {
11
16
  id: "spotify.discovery.search", version: "1", state: "enabled", effect: "read_external", requires_spotify: true,
12
17
  description: "Verify open-world music hypotheses with bounded Spotify queries, retaining a shared candidate pool.",
@@ -601,6 +606,50 @@ const catalog = [
601
606
  label: N_("Save the playlist to Spotify"),
602
607
  },
603
608
  },
609
+ {
610
+ id: "collection.list",
611
+ version: "1",
612
+ state: "enabled",
613
+ effect: "read_local",
614
+ description: "Find playlists in the listener's own library on this Mac, by name, with what each holds.",
615
+ agent_tool: {
616
+ name: "moondog_collection_list",
617
+ label: N_("Look in the library"),
618
+ },
619
+ },
620
+ {
621
+ id: "collection.save",
622
+ version: "1",
623
+ state: "enabled",
624
+ effect: "write_local",
625
+ description: "Keep songs or tracks Moondog made in the listener's library on this Mac: in Saved, in one of their playlists, or in a new playlist.",
626
+ agent_tool: {
627
+ name: "moondog_collection_save",
628
+ label: N_("Save to the library"),
629
+ },
630
+ },
631
+ {
632
+ id: "collection.import",
633
+ version: "1",
634
+ state: "enabled",
635
+ effect: "write_local",
636
+ description: "Import a playlist link from Spotify, Apple Music, NetEase Music or QQ Music into the listener's library on this Mac.",
637
+ agent_tool: {
638
+ name: "moondog_collection_import",
639
+ label: N_("Import a playlist"),
640
+ },
641
+ },
642
+ {
643
+ id: "collection.play",
644
+ version: "1",
645
+ state: "enabled",
646
+ effect: "play_library",
647
+ description: "Play a playlist from the listener's library on the music service they chose.",
648
+ agent_tool: {
649
+ name: "moondog_collection_play",
650
+ label: N_("Play from the library"),
651
+ },
652
+ },
604
653
  {
605
654
  id: "music.generate.local",
606
655
  version: "1",