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

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 (57) hide show
  1. package/README.md +30 -4
  2. package/package.json +1 -1
  3. package/scripts/moondog.mjs +148 -14
  4. package/src/core/apple-projection-domain-services.mjs +4 -0
  5. package/src/core/capability-catalog.mjs +49 -0
  6. package/src/core/library-store.mjs +500 -0
  7. package/src/core/listening-now.mjs +80 -6
  8. package/src/core/listening-profile-domain-services.mjs +4 -0
  9. package/src/core/moondog-application.mjs +578 -32
  10. package/src/i18n/index.mjs +6 -3
  11. package/src/i18n/messages/de.mjs +1 -0
  12. package/src/i18n/messages/en.mjs +2 -0
  13. package/src/i18n/messages/es.mjs +1 -0
  14. package/src/i18n/messages/fr.mjs +1 -0
  15. package/src/i18n/messages/it.mjs +1 -0
  16. package/src/i18n/messages/ja.mjs +1 -0
  17. package/src/i18n/messages/ko.mjs +1 -0
  18. package/src/i18n/messages/pt.mjs +1 -0
  19. package/src/i18n/messages/zh-Hant.mjs +1 -0
  20. package/src/i18n/messages/zh.mjs +1 -0
  21. package/src/i18n/preferences.mjs +24 -0
  22. package/src/i18n/screen/es.mjs +94 -0
  23. package/src/i18n/screen/ja.mjs +94 -0
  24. package/src/i18n/screen/pt.mjs +94 -0
  25. package/src/i18n/screen/zh.mjs +94 -0
  26. package/src/integrations/apple-music/music-app.mjs +9 -3
  27. package/src/integrations/apple-music/terminal-preview-player.mjs +31 -1
  28. package/src/integrations/netease/ncm-cli-player.mjs +12 -6
  29. package/src/integrations/playlist-links.mjs +102 -0
  30. package/src/integrations/public-playlists.mjs +39 -8
  31. package/src/integrations/spotify/service.mjs +22 -1
  32. package/src/integrations/stable-audio/cover.mjs +324 -0
  33. package/src/integrations/stable-audio/local-generator.mjs +259 -87
  34. package/src/integrations/stable-audio/setup.mjs +653 -0
  35. package/src/integrations/stable-audio/sources.mjs +106 -0
  36. package/src/integrations/web/deepseek-web.mjs +1 -1
  37. package/src/integrations/web/web-research.mjs +84 -17
  38. package/src/integrations/web/web-result.mjs +60 -12
  39. package/src/profile/listening-history-store.mjs +55 -1
  40. package/src/profile/listening-profile-projection.mjs +1 -1
  41. package/src/profile/music-import-bundle.mjs +3 -1
  42. package/src/profile/music-providers.mjs +3 -0
  43. package/src/profile/taste-compiler.mjs +14 -5
  44. package/src/runtime/pi/agent-runtime.mjs +359 -37
  45. package/src/runtime/pi/configured-runtime.mjs +3 -0
  46. package/src/runtime/pi/image-input.mjs +148 -0
  47. package/src/runtime/pi/model-roles.mjs +121 -0
  48. package/src/runtime/pi/model-settings.mjs +63 -2
  49. package/src/runtime/pi/model-switch.mjs +26 -0
  50. package/src/runtime/pi/model-transport.mjs +34 -4
  51. package/src/runtime/pi/record-name.mjs +80 -0
  52. package/src/runtime/pi/reply-language.mjs +45 -0
  53. package/src/runtime/pi/runtime-settings.mjs +4 -2
  54. package/src/runtime/pi/tool-arguments.mjs +89 -0
  55. package/src/surfaces/cli/command-completions.mjs +15 -1
  56. package/src/surfaces/cli/model-roles-command.mjs +60 -0
  57. package/src/surfaces/cli/tui.mjs +419 -19
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.11</p>
8
8
 
9
9
  <p align="center"><strong>A listening room that shows its work.</strong></p>
10
10
 
@@ -44,7 +44,8 @@ See [updating and release channels](docs/UPDATES.md), including the one-time upg
44
44
 
45
45
  [Moondog for Mac](app/README.md) is a native window on the same core as the terminal room.
46
46
  It shares your profile, settings, sign-ins, and conversations with `moondog`, and draws its own conversation, now playing, and up next.
47
- Run it from this checkout with `cd app && npm ci && npm start`.
47
+ Download the notarized build for Apple silicon from the [project page](https://moondog.audiofool.ai), or run it from this checkout with `cd app && npm ci && npm start`.
48
+ It updates itself, on a Stable or Beta channel you choose in Settings.
48
49
 
49
50
  The [project page](https://moondog.audiofool.ai) plays a short scene in this window, in a browser, with a made-up listener ([how](site/README.md)).
50
51
 
@@ -147,11 +148,17 @@ OpenAI takes your ChatGPT subscription or an API key.
147
148
  In the Mac app, any service that speaks OpenAI's or Anthropic's API can be added as an endpoint by its address, and `/endpoint` does it in the terminal.
148
149
  You can switch models in the middle of a conversation.
149
150
  The [model setup guide](docs/TERMINAL_GUIDE.md#enable-agent-conversation) lists each provider and the environment variable it reads.
151
+ `/model roles` shows the models and services behind side tasks: a vision model for images, and the web search service.
152
+ Moondog takes images too: an image path in your message in the terminal, or a pasted or dropped image in the Mac app.
153
+ You can also change settings in your own words, such as which model answers or the language it replies in.
150
154
 
151
155
  While an answer is on the way, each step appears in the conversation as it starts.
152
156
  The steps stay above the reply, with a count of what finished, what failed, and what was not confirmed.
153
157
  The agent can use musical knowledge and web research to find candidates, then verify the recordings on Spotify before playing or queueing them.
154
158
  Without Spotify, it plays 30-second Apple Music previews from the terminal instead, each next to a link to the full song.
159
+ With NetEase Music, it plays whole songs through NetEase's own `ncm-cli`.
160
+ In the Mac app, Apple Music plays whole songs with a subscription.
161
+ `/service` chooses where Moondog plays.
155
162
  It also sees what you already play: the song and playlist in the Music app on a Mac, or on Spotify's active device, so “more like this playlist” needs no names.
156
163
  macOS asks once before Moondog may read the Music app.
157
164
  For example, “Pon doce de salsa clásica a la cola” asks it to add up to twelve verified songs directly; your history guides the choice without limiting it to your library.
@@ -164,13 +171,28 @@ Songs Moondog chose for you do not count as your own taste.
164
171
  | A model through Pi | Conversation, discovery, and playlists built together. |
165
172
  | A web search service | `/web` lookups with links to their sources, through DeepSeek (`/auth deepseek`, which also reads pages), Brave Search (`/auth brave`), or your own signed-in Codex CLI. |
166
173
  | Spotify sign-in | Playback, and private playlists made only when you ask. |
174
+ | NetEase Music | Whole songs through its own `ncm-cli`, for listeners in mainland China. |
167
175
 
168
176
  Your profile never touches the network.
169
177
  The agent talks only to the services you set up.
170
178
  `/web` sends the question or the link you give it, and nothing from your profile, history, or files.
179
+ An image you attach goes to the model that reads it.
171
180
  A Spotify ZIP does not need a Spotify sign-in.
172
181
  Read the [connection and data details](docs/TERMINAL_GUIDE.md) before you connect anything.
173
182
 
183
+ ## Your library
184
+
185
+ `/library` keeps songs and the tracks Moondog made side by side: Saved, then your playlists.
186
+ `/library save` keeps what is playing, and `/library play Night drive` plays a playlist on the service you chose.
187
+ `/library import <link>` copies a Spotify, Apple Music, NetEase Music or QQ Music playlist into it.
188
+ A playlist you made on Spotify also shapes your taste; for any other one, you decide.
189
+
190
+ ## Music it makes
191
+
192
+ On an Apple silicon Mac, Moondog can make instrumental tracks offline with Stable Audio 3, powered by Stability AI.
193
+ `/generation download medium` sets it up after you agree to its licenses, and `/generation on` turns it on.
194
+ Ask for a track, or type `/onair` and Moondog keeps making music from your taste until you stop it.
195
+
174
196
  ## Getting around
175
197
 
176
198
  | Command or key | What it does |
@@ -186,7 +208,10 @@ Read the [connection and data details](docs/TERMINAL_GUIDE.md) before you connec
186
208
  | Enter while Moondog is working | Queue the next message. It sends when this one finishes. |
187
209
  | `/resume`, `/new` | Pick up a saved conversation, or start a fresh one. |
188
210
  | `/home` | Back to the sleeve, without clearing the conversation. |
189
- | `/skip`, `/stop` | Skip to the next song preview, or stop the previews. |
211
+ | `/skip`, `/stop` | Skip to the next song preview or stream track, or stop them. |
212
+ | `/library` | Your saved songs and playlists. |
213
+ | `/onair` | Go on air, or come off it. |
214
+ | `/rewind`, `/branch` | Change an earlier message, or go on in a new conversation. |
190
215
  | `/lyrics` | See which line is on the sleeve, and which song it came from. |
191
216
  | `/theme paper` | Black ink on white. |
192
217
  | `/theme charcoal` | Moonlight on a black sky. |
@@ -203,6 +228,7 @@ Your profile, and anything Moondog remembers, carry over.
203
228
  - [Product charter](docs/PRODUCT_CHARTER.md) and [public roadmap](docs/ROADMAP.md): where Moondog is headed, and what it will not do.
204
229
  - [Voice](docs/VOICE.md): how Moondog talks, and where Pink Floyd shows up.
205
230
  - [The listening loop](docs/LISTENING_LOOP.md): how the agent keeps your choices, your recent listening, and its own picks apart.
231
+ - [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
232
  - [Runtime architecture](docs/ADR_0001_AGENT_RUNTIME_AND_CLI.md) and [data contracts](contracts/README.md): how the pieces fit together.
207
233
  - [Contribution guide](CONTRIBUTING.md): local setup, checks, and how private data is kept out.
208
234
  - [Release status](docs/PUBLIC_RELEASE_READINESS.md): what is published, and what the checks cover.
@@ -212,4 +238,4 @@ Your profile, and anything Moondog remembers, carry over.
212
238
 
213
239
  Moondog is open source under the [Apache License 2.0](LICENSE).
214
240
  Copyright 2026 Audiofool.
215
- Music making runs Stable Audio 3, which you install yourself; [NOTICE](NOTICE) holds its attribution, and [licenses/](licenses/stability-ai-community-license.md) holds the Stability AI Community License.
241
+ Music making runs Stable Audio 3, which Moondog downloads from Stability AI when you ask, or your own install; [NOTICE](NOTICE) holds its attribution, and [licenses/](licenses/stability-ai-community-license.md) holds the Stability AI Community License.
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.11",
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,
@@ -32,8 +33,10 @@ import { runCatalogCommand } from "../src/surfaces/cli/catalog-command.mjs";
32
33
  import { runProfileCommand } from "../src/surfaces/cli/profile-command.mjs";
33
34
  import { runListenerProfileCommand, savedProfileActions } from "../src/surfaces/cli/listener-profile-command.mjs";
34
35
  import { createWebResearch } from "../src/integrations/web/web-research.mjs";
36
+ import { runModelRolesCommand } from "../src/surfaces/cli/model-roles-command.mjs";
35
37
  import { runWebCommand } from "../src/surfaces/cli/web-command.mjs";
36
38
  import { openSpotifyConnection } from "../src/integrations/spotify/connection.mjs";
39
+ import { preferredIdleDevice, thisComputerName } from "../src/integrations/spotify/service.mjs";
37
40
  import {
38
41
  openSpotifyResolutionCache,
39
42
  } from "../src/integrations/spotify/resolution-cache.mjs";
@@ -43,6 +46,7 @@ import {
43
46
  import { runSpotifyCommand } from "../src/surfaces/cli/spotify-command.mjs";
44
47
  import { runListenBrainzCommand } from "../src/surfaces/cli/listenbrainz-command.mjs";
45
48
  import { openListeningHistoryStore } from "../src/profile/listening-history-store.mjs";
49
+ import { musicHash, prepareMusicBundle } from "../src/profile/music-import-bundle.mjs";
46
50
  import { commitSpotifyQuickImport, prepareHistoryImport, prepareSpotifyQuickImport } from "../src/profile/history-import.mjs";
47
51
  import { persistAppleLibraryImport, refreshAppleLibraryImport } from "../src/profile/apple-library-import.mjs";
48
52
  import { IMPORT_GUIDE_URLS } from "../src/surfaces/cli/import-guides.mjs";
@@ -54,6 +58,7 @@ import { createAppleMusicCatalog } from "../src/integrations/apple-music/catalog
54
58
  import { createTerminalPreviewPlayer } from "../src/integrations/apple-music/terminal-preview-player.mjs";
55
59
  import { createNeteasePlayer } from "../src/integrations/netease/ncm-cli-player.mjs";
56
60
  import { createAtmosphereStream, createLocalGenerator } from "../src/integrations/stable-audio/local-generator.mjs";
61
+ import { createStableAudioSetup } from "../src/integrations/stable-audio/setup.mjs";
57
62
  import { createMusicAppPlayer } from "../src/integrations/apple-music/music-app.mjs";
58
63
  import {
59
64
  createOpenMusicSimilarity,
@@ -69,7 +74,8 @@ import {
69
74
  SPOTIFY_ARCHIVE_SET_MAXIMUM,
70
75
  } from "../src/profile/spotify-archive-taste.mjs";
71
76
  import { deriveArtistReleaseHintsFromSpotifyArchive } from "../src/profile/spotify-archive-catalog-hints.mjs";
72
- import { resolveListenerLocale, writeLanguagePreference } from "../src/i18n/preferences.mjs";
77
+ import { createLanguageSelection, resolveListenerLocale, writeLanguagePreference } from "../src/i18n/preferences.mjs";
78
+ import { createModelSwitch } from "../src/runtime/pi/model-switch.mjs";
73
79
  import { createPlaybackSelection, resolvePlaybackService, watchPlaybackPreference, writePlaybackPreference } from "../src/core/playback-preferences.mjs";
74
80
  import { createFirstRun } from "../src/core/first-run.mjs";
75
81
 
@@ -146,6 +152,28 @@ function parseArguments(argv) {
146
152
  };
147
153
  }
148
154
 
155
+ // A playlist imported into the library adds its songs to the listener's taste evidence, as a kept playlist, the
156
+ // same way a public playlist imported from the Import guide does. Its source names where it came from.
157
+ const PLAYLIST_TASTE_SOURCES = { spotify: "spotify_playlist", apple_music: "apple_music_playlist", netease: "netease", qq_music: "qq_music" };
158
+ async function addPlaylistToTaste(found) {
159
+ const provider = PLAYLIST_TASTE_SOURCES[found.provider];
160
+ if (!provider) throw new Error("This playlist's service can't shape the profile.");
161
+ const historyStore = await openListeningHistoryStore();
162
+ try {
163
+ const appleSubjectId = await optionalAppleMusicSubjectId();
164
+ const subjectId = historyStore.localSubjectId({ ...(appleSubjectId ? { preferredSubjectId: appleSubjectId } : {}), create: true });
165
+ const rows = found.tracks.map((track, index) => ({ id: track.id, title: track.title, artist: track.artist, ...(track.album ? { album: track.album } : {}),
166
+ playlistId: found.id, playlistName: found.name, position: index + 1, member: "playlist-link.json", listenerMade: found.owned === true }));
167
+ const bytes = Buffer.from(JSON.stringify({ provider, id: found.id, name: found.name, rows }));
168
+ const prepared = prepareMusicBundle({ provider, subjectId, capturedAt: new Date().toISOString(), rows, digest: musicHash(bytes), size: bytes.length,
169
+ members: ["playlist-link.json"], fileName: found.name,
170
+ scopeNote: "A playlist the listener imported into their library. It shows what they keep, not what they played." });
171
+ return historyStore.ingestImport(prepared.bundle);
172
+ } finally {
173
+ historyStore.close();
174
+ }
175
+ }
176
+
149
177
  async function optionalAppleMusicSubjectId() {
150
178
  const source = await readAppleMusicSourceStatus();
151
179
  if (source.state === "missing") return null;
@@ -452,6 +480,76 @@ async function emitTasteprintArtifact(
452
480
  return result;
453
481
  }
454
482
 
483
+ // While the terminal's stream runs, the Music app is read this often. Only a change from not playing
484
+ // to playing ends the stream, and only in a read that began after the stream last changed and past its
485
+ // start: the song the first track pauses keeps playing while that track is made.
486
+ const STREAM_MUSIC_APP_MS = 5_000;
487
+ function watchMusicAppForStream(stream, { musicApp, interval = STREAM_MUSIC_APP_MS }) {
488
+ if (musicApp?.available !== true) return () => {};
489
+ let timer = null;
490
+ let reading = false;
491
+ let playing = null;
492
+ let phase = null;
493
+ let changedAt = 0;
494
+ const read = async () => {
495
+ if (reading) return;
496
+ reading = true;
497
+ const began = Date.now();
498
+ try {
499
+ const now = await musicApp.nowPlaying().catch(() => null);
500
+ const view = stream.view();
501
+ if (!view || began <= changedAt) return;
502
+ const playingNow = now?.state === "playing";
503
+ if (playingNow && playing === false && view.phase !== "starting") stream.stop("other_music");
504
+ playing = playingNow;
505
+ } finally {
506
+ reading = false;
507
+ }
508
+ };
509
+ const unsubscribe = stream.subscribe(({ view }) => {
510
+ changedAt = Date.now();
511
+ // The first track just paused the Music app, so playing at a later read is news.
512
+ if (phase === "starting" && view && view.phase !== "starting") playing = false;
513
+ phase = view?.phase ?? null;
514
+ if (view && !timer) {
515
+ playing = null;
516
+ timer = setInterval(() => void read(), interval);
517
+ timer.unref?.();
518
+ } else if (!view && timer) {
519
+ clearInterval(timer);
520
+ timer = null;
521
+ }
522
+ });
523
+ return () => {
524
+ unsubscribe();
525
+ clearInterval(timer);
526
+ timer = null;
527
+ };
528
+ }
529
+
530
+ // Whether a /spotify command starts Spotify on this Mac: play on the device it resumes on (the active one,
531
+ // or with none active the one it would pick), or a move onto this Mac with --play or while Spotify plays (a
532
+ // move keeps playing what plays). A command its own checks refuse, or reads that fail, start nothing here.
533
+ async function spotifyStartsHere(application, args, { signal } = {}) {
534
+ if (!application.spotifyReady?.()) return false;
535
+ // A trailing --json changes only how the result is written.
536
+ if (args.at(-1) === "--json") args = args.slice(0, -1);
537
+ const play = args[0] === "play" && args.length <= 2;
538
+ const transfer = args[0] === "transfer" && args.length >= 2 && args.length <= 3 && (args.length === 2 || args[2] === "--play");
539
+ if (!play && !transfer) return false;
540
+ const service = application.requireSpotifyService();
541
+ const [listed, player] = await Promise.all([service.devices({ signal }).catch(() => null), service.currentPlayer({ signal }).catch(() => null)]);
542
+ const devices = listed?.devices ?? [];
543
+ if (transfer) {
544
+ const device = devices.find((entry) => entry.id === args[1]);
545
+ const playing = player?.state === "available" && player.is_playing === true;
546
+ return Boolean(device) && application.spotifyOnThisMac(device) && (args[2] === "--play" || playing);
547
+ }
548
+ const active = player?.device?.is_active === true ? player.device : devices.find((device) => device.is_active === true);
549
+ const device = active ?? preferredIdleDevice(devices, { thisComputer: await thisComputerName() });
550
+ return Boolean(device) && application.spotifyOnThisMac(device);
551
+ }
552
+
455
553
  async function main() {
456
554
  const updater = createApplicationUpdater();
457
555
  const argv = process.argv.slice(2);
@@ -830,6 +928,9 @@ async function main() {
830
928
 
831
929
  const domainState = await loadDomainServices();
832
930
  const memoryStore = await openLocalMemoryStore();
931
+ // The listener's library: Saved, their playlists and folders, on one file the terminal and the window share.
932
+ // A library that can't open leaves the rest of Moondog working, without it.
933
+ const libraryStore = await openLibraryStore().catch(() => null);
833
934
  const spotifyConnection = await loadSpotifyConnection();
834
935
  const musicCatalog = createAppleMusicCatalog();
835
936
  const artistIdentityResolver = createWikidataArtistResolver();
@@ -851,10 +952,14 @@ async function main() {
851
952
  const neteasePlayer = createNeteasePlayer();
852
953
  const playbackService = await resolvePlaybackService();
853
954
  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;
955
+ // Moondog makes music on this Mac with Stable Audio 3 once the listener turns it on, in a room
956
+ // that can play what it makes: Moondog's own install, downloaded when they ask, or their folder.
957
+ // The terminal and the window each keep one generator, so each makes one track at a time; a
958
+ // choice saved on one side is checked again on the other.
959
+ const localGenerator = previewPlayer ? createLocalGenerator({ keeps: (id) => libraryStore?.holdsMade(id) === true }) : null;
960
+ const generationSetup = localGenerator ? createStableAudioSetup() : null;
961
+ // A model set up or removed, here or on the other side, is checked again.
962
+ const unwatchGenerationSetup = generationSetup?.subscribe(() => { void localGenerator.check().catch(() => {}); }) ?? (() => {});
858
963
  await localGenerator?.check();
859
964
  void localGenerator?.sweep().catch(() => {});
860
965
  const unwatchGeneration = localGenerator?.watch() ?? (() => {});
@@ -869,11 +974,20 @@ async function main() {
869
974
  const unobserveStream = atmosphereStream ? previewPlayer.subscribe?.((event) => atmosphereStream.observe(event)) : null;
870
975
  // Music making turned off, here or on the other surface, ends the stream.
871
976
  const unwatchStreamGeneration = atmosphereStream ? localGenerator.subscribe((status) => { if (status.state !== "ready") atmosphereStream.stop("generation_off"); }) : null;
977
+ // The window watches the Music app for its own stream; the terminal has no window, so while its
978
+ // stream runs it reads the Music app itself, and music the listener starts there ends the stream.
979
+ const unwatchStreamMusicApp = atmosphereStream && !host ? watchMusicAppForStream(atmosphereStream, { musicApp }) : null;
872
980
  const playbackSelection = createPlaybackSelection({ service: playbackService, save: writePlaybackPreference });
981
+ // A reply language the listener asks for in words is saved like the picker's and reaches every surface.
982
+ const languageSelection = createLanguageSelection({ save: (locale) => writeLanguagePreference(locale) });
983
+ // The listener's model menu, shared by the window, the terminal and a model asked for in words.
984
+ const modelSettings = createModelSettings({ environment: process.env });
985
+ const modelSwitch = createModelSwitch({ list: () => modelSettings.list() });
873
986
  // 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.
987
+ // other side reaches this side's picker and agents, and stops the Apple Music songs this side plays.
988
+ // Moondog's stream is not a service: its made tracks play on.
875
989
  const unwatchPlayback = watchPlaybackPreference(playbackSelection, {
876
- onChange: (service) => { if (service !== "apple_music") { atmosphereStream?.stop("service_change"); previewPlayer?.stop?.(); } },
990
+ onChange: (service) => { if (service !== "apple_music" && previewPlayer?.holdsCatalogSongs?.() !== false) previewPlayer?.stop?.(); },
877
991
  });
878
992
  // The main application and every conversation the window opens beside it are built from
879
993
  // this one list, so they cannot drift apart. Each passes in what may change after launch:
@@ -881,9 +995,11 @@ async function main() {
881
995
  // Spotify's four-week top artists, as the listening upkeep last read them.
882
996
  const spotifyTopArtists = { read_at: null, artists: [] };
883
997
  const applicationOptions = ({ domainServices, domainServicesError, spotifyConnection, locale, playbackService }) => ({
884
- domainServices, domainServicesError, spotifyConnection, locale, playbackService, playbackSelection,
998
+ domainServices, domainServicesError, spotifyConnection, locale, playbackService, playbackSelection, languageSelection, modelSwitch,
885
999
  webResearch, memoryStore, musicCatalog, musicPreviewPlayer: previewPlayer, neteasePlayer, localGenerator, atmosphereStream, musicSimilarity, artistIdentityResolver,
886
- spotifyTopArtists, musicApp,
1000
+ spotifyTopArtists, musicApp, libraryStore,
1001
+ // A playlist that shapes the taste reloads the profile services at once, so the evidence counts in this session.
1002
+ playlistTaste: async (found) => { const receipt = await addPlaylistToTaste(found); replaceDomainServices(await loadDomainServices()); return receipt; },
887
1003
  });
888
1004
  const application = new MoondogApplication(applicationOptions({
889
1005
  ...domainState, spotifyConnection, locale: await resolveListenerLocale(), playbackService,
@@ -1083,7 +1199,12 @@ async function main() {
1083
1199
  status: () => localGenerator.status(),
1084
1200
  check: () => localGenerator.check(),
1085
1201
  save: (settings) => localGenerator.save(settings),
1202
+ // Uses an installed model of Moondog's own install.
1203
+ use: async (model) => (generationSetup?.status()?.models?.[model]?.state === "ready"
1204
+ ? localGenerator.save({ source: "managed", model }) : localGenerator.check()),
1086
1205
  subscribe: (listener) => localGenerator.subscribe(listener),
1206
+ // Download, cancel and remove, shared by the window and the terminal.
1207
+ setup: generationSetup,
1087
1208
  },
1088
1209
  // The room asks whether a Last.fm key is set, and saves one the listener pastes.
1089
1210
  lastfm: {
@@ -1113,12 +1234,16 @@ async function main() {
1113
1234
  return createConfiguredRuntime(target, process.env, { selection, toolTrace });
1114
1235
  },
1115
1236
  // The desktop window lists models and signs in here, with no terminal.
1116
- modelSettings: createModelSettings({ environment: process.env }),
1237
+ modelSettings,
1117
1238
  // A model chosen or a sign-in made on the other surface. Model variables fix the model at
1118
- // launch, so a saved choice has nothing to change then.
1119
- watchModelSettings: (onChange) => (process.env.MOONDOG_PROVIDER !== undefined || process.env.MOONDOG_MODEL !== undefined
1120
- ? () => {}
1121
- : watchRuntimeSettings({ environment: process.env, onChange })),
1239
+ // launch, so a saved choice has nothing to change then; a role chosen elsewhere still does.
1240
+ watchModelSettings: (onChange) => {
1241
+ const fixed = process.env.MOONDOG_PROVIDER !== undefined || process.env.MOONDOG_MODEL !== undefined;
1242
+ return watchRuntimeSettings({ environment: process.env, onChange: (selection, changed) => {
1243
+ if (changed === "roles") return void webResearch.refresh().then(() => onChange(selection, changed), () => {});
1244
+ if (!fixed) onChange(selection, changed);
1245
+ } });
1246
+ },
1122
1247
  // The one-time music service and model setup, shared by the terminal and the window.
1123
1248
  firstRun: createFirstRun({ environment: process.env }),
1124
1249
  // A host with its own terminal streams takes the sign-in prompts. The
@@ -1144,6 +1269,7 @@ async function main() {
1144
1269
  }),
1145
1270
  }),
1146
1271
  runWeb: (args, options) => runWebCommand({ args, webResearch, ...options }),
1272
+ runModelRoles: (args) => runModelRolesCommand({ args: args.map(String), modelSettings, webResearch }),
1147
1273
  runProfileAction: (args) => runProfileCommand({
1148
1274
  args,
1149
1275
  commandPrefix: "/profile",
@@ -1167,6 +1293,9 @@ async function main() {
1167
1293
  return json ? `\`\`\`json\n${output.trim()}\n\`\`\`` : output.trim();
1168
1294
  },
1169
1295
  runSpotify: async (args, { signal, stderr } = {}) => {
1296
+ // /spotify play, and a move onto this Mac that plays there, start Spotify here: the other players go
1297
+ // quiet first. A command its own checks will refuse, or a move to another device, quiets nothing.
1298
+ if (await spotifyStartsHere(application, args, { signal })) await application.quietOtherPlayers("spotify", { signal });
1170
1299
  let output = "";
1171
1300
  const capture = {
1172
1301
  write(value) {
@@ -1192,6 +1321,10 @@ async function main() {
1192
1321
  } finally {
1193
1322
  unwatchPlayback();
1194
1323
  unwatchGeneration();
1324
+ unwatchGenerationSetup();
1325
+ // A download or uv run this process owns would keep it alive after the room closes; it stops and
1326
+ // resumes next time. One owned by the other window or terminal runs on.
1327
+ await generationSetup?.close?.();
1195
1328
  // A made track still playing is stopped while the generator listens, so its outcome is kept.
1196
1329
  if (localGenerator) {
1197
1330
  previewPlayer?.stop?.();
@@ -1200,6 +1333,7 @@ async function main() {
1200
1333
  unobservePlayer?.();
1201
1334
  unobserveStream?.();
1202
1335
  unwatchStreamGeneration?.();
1336
+ unwatchStreamMusicApp?.();
1203
1337
  atmosphereStream?.stop("closed");
1204
1338
  listeningUpkeep?.stop();
1205
1339
  lyrics?.close();
@@ -1296,6 +1296,10 @@ export class AppleProjectionDomainServices {
1296
1296
  return this.#listeningHistoryStore?.recentAttention?.({ subjectId: this.#subjectId }) ?? null;
1297
1297
  }
1298
1298
 
1299
+ listeningDays() {
1300
+ return this.#listeningHistoryStore?.listeningDays?.({ subjectId: this.#subjectId }) ?? null;
1301
+ }
1302
+
1299
1303
  // Moondog's suggestions ledger and the declared mix (src/profile/moondog-suggestions.mjs).
1300
1304
  recordMoondogSuggestions(entries) {
1301
1305
  return this.#listeningHistoryStore?.recordMoondogSuggestions?.({ entries }) ?? null;
@@ -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",