@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.
- package/README.md +30 -4
- package/package.json +1 -1
- package/scripts/moondog.mjs +148 -14
- package/src/core/apple-projection-domain-services.mjs +4 -0
- package/src/core/capability-catalog.mjs +49 -0
- package/src/core/library-store.mjs +500 -0
- package/src/core/listening-now.mjs +80 -6
- package/src/core/listening-profile-domain-services.mjs +4 -0
- package/src/core/moondog-application.mjs +578 -32
- package/src/i18n/index.mjs +6 -3
- package/src/i18n/messages/de.mjs +1 -0
- package/src/i18n/messages/en.mjs +2 -0
- package/src/i18n/messages/es.mjs +1 -0
- package/src/i18n/messages/fr.mjs +1 -0
- package/src/i18n/messages/it.mjs +1 -0
- package/src/i18n/messages/ja.mjs +1 -0
- package/src/i18n/messages/ko.mjs +1 -0
- package/src/i18n/messages/pt.mjs +1 -0
- package/src/i18n/messages/zh-Hant.mjs +1 -0
- package/src/i18n/messages/zh.mjs +1 -0
- package/src/i18n/preferences.mjs +24 -0
- package/src/i18n/screen/es.mjs +94 -0
- package/src/i18n/screen/ja.mjs +94 -0
- package/src/i18n/screen/pt.mjs +94 -0
- package/src/i18n/screen/zh.mjs +94 -0
- package/src/integrations/apple-music/music-app.mjs +9 -3
- package/src/integrations/apple-music/terminal-preview-player.mjs +31 -1
- package/src/integrations/netease/ncm-cli-player.mjs +12 -6
- package/src/integrations/playlist-links.mjs +102 -0
- package/src/integrations/public-playlists.mjs +39 -8
- package/src/integrations/spotify/service.mjs +22 -1
- package/src/integrations/stable-audio/cover.mjs +324 -0
- package/src/integrations/stable-audio/local-generator.mjs +259 -87
- package/src/integrations/stable-audio/setup.mjs +653 -0
- package/src/integrations/stable-audio/sources.mjs +106 -0
- package/src/integrations/web/deepseek-web.mjs +1 -1
- package/src/integrations/web/web-research.mjs +84 -17
- package/src/integrations/web/web-result.mjs +60 -12
- package/src/profile/listening-history-store.mjs +55 -1
- package/src/profile/listening-profile-projection.mjs +1 -1
- package/src/profile/music-import-bundle.mjs +3 -1
- package/src/profile/music-providers.mjs +3 -0
- package/src/profile/taste-compiler.mjs +14 -5
- package/src/runtime/pi/agent-runtime.mjs +359 -37
- package/src/runtime/pi/configured-runtime.mjs +3 -0
- package/src/runtime/pi/image-input.mjs +148 -0
- package/src/runtime/pi/model-roles.mjs +121 -0
- package/src/runtime/pi/model-settings.mjs +63 -2
- package/src/runtime/pi/model-switch.mjs +26 -0
- package/src/runtime/pi/model-transport.mjs +34 -4
- package/src/runtime/pi/record-name.mjs +80 -0
- package/src/runtime/pi/reply-language.mjs +45 -0
- package/src/runtime/pi/runtime-settings.mjs +4 -2
- package/src/runtime/pi/tool-arguments.mjs +89 -0
- package/src/surfaces/cli/command-completions.mjs +15 -1
- package/src/surfaces/cli/model-roles-command.mjs +60 -0
- 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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
package/scripts/moondog.mjs
CHANGED
|
@@ -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
|
|
855
|
-
//
|
|
856
|
-
//
|
|
857
|
-
|
|
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
|
|
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"
|
|
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
|
|
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) =>
|
|
1120
|
-
|
|
1121
|
-
|
|
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",
|