@audiofool/moondog 0.3.0-beta.6 → 0.3.0-beta.8
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 +2 -1
- package/package.json +1 -1
- package/scripts/moondog.mjs +15 -5
- package/src/core/capability-catalog.mjs +38 -0
- package/src/core/library-store.mjs +500 -0
- package/src/core/listening-now.mjs +7 -2
- package/src/core/moondog-application.mjs +379 -10
- package/src/i18n/index.mjs +6 -3
- package/src/i18n/preferences.mjs +24 -0
- package/src/i18n/screen/es.mjs +18 -0
- package/src/i18n/screen/ja.mjs +18 -0
- package/src/i18n/screen/pt.mjs +18 -0
- package/src/i18n/screen/zh.mjs +18 -0
- package/src/integrations/apple-music/music-app.mjs +9 -3
- package/src/integrations/apple-music/terminal-preview-player.mjs +22 -2
- package/src/integrations/spotify/service.mjs +2 -1
- package/src/integrations/stable-audio/local-generator.mjs +8 -1
- package/src/runtime/pi/agent-runtime.mjs +250 -20
- package/src/runtime/pi/model-switch.mjs +26 -0
- package/src/runtime/pi/model-transport.mjs +34 -4
- package/src/runtime/pi/reply-language.mjs +45 -0
- package/src/runtime/pi/tool-arguments.mjs +89 -0
- package/src/surfaces/cli/tui.mjs +86 -1
|
@@ -78,7 +78,8 @@ async function spotify(application, { signal }) {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
// Moondog's own player: Apple Music songs in the window or the terminal, and the tracks Moondog made.
|
|
81
|
-
const madeBy = (track) => ({ title: track.title, artists: track.generated ? ["Moondog"] : track.artist_credit ? [track.artist_credit] : []
|
|
81
|
+
const madeBy = (track) => ({ title: track.title, artists: track.generated ? ["Moondog"] : track.artist_credit ? [track.artist_credit] : [],
|
|
82
|
+
...(track.generated && track.id ? { made_track_id: track.id } : {}) });
|
|
82
83
|
function moondogPlayer(application) {
|
|
83
84
|
const now = application.musicPreviewPlayer.nowPlaying();
|
|
84
85
|
if (!now?.song?.title) return { state: "stopped" };
|
|
@@ -132,7 +133,7 @@ export async function readListeningNow(application, { signal } = {}) {
|
|
|
132
133
|
const entry = (track) => ({
|
|
133
134
|
title: String(track?.title ?? "").trim(),
|
|
134
135
|
artist_credit: String(track?.artist_credit ?? "").trim(),
|
|
135
|
-
...(track?.generated === true ? { generated: true } : {}),
|
|
136
|
+
...(track?.generated === true ? { generated: true, ...(typeof track.id === "string" && track.id ? { id: track.id } : {}) } : {}),
|
|
136
137
|
});
|
|
137
138
|
|
|
138
139
|
export function createPlayerNow() {
|
|
@@ -161,6 +162,10 @@ export function createPlayerNow() {
|
|
|
161
162
|
clear() {
|
|
162
163
|
queue = null;
|
|
163
164
|
},
|
|
165
|
+
/** The song playing and every one after it, for keeping the whole queue: read() shows only ten. */
|
|
166
|
+
remaining() {
|
|
167
|
+
return queue ? queue.tracks.slice(queue.index).map((track) => ({ ...track })) : [];
|
|
168
|
+
},
|
|
164
169
|
/** null when nothing is queued, or { state, song, position, count, next } with up to ten songs after it. */
|
|
165
170
|
read() {
|
|
166
171
|
if (!queue) return null;
|
|
@@ -4,7 +4,7 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
4
4
|
import { compactListenerProfile, readListenerProfile } from "../profile/listener-profile-build.mjs";
|
|
5
5
|
import { mixCounts, songKeys } from "../profile/moondog-suggestions.mjs";
|
|
6
6
|
import { ARTIST_CREDIT_SEPARATORS } from "./listener-avoids.mjs";
|
|
7
|
-
import { textNamesSong } from "./track-title.mjs";
|
|
7
|
+
import { foldedWords, normalizedTrackTitle, textNamesSong } from "./track-title.mjs";
|
|
8
8
|
import { acceptableRecordTitle, compileTaste, tasteSources } from "../profile/taste-compiler.mjs";
|
|
9
9
|
import { stableAudioPrompt } from "../integrations/stable-audio/local-generator.mjs";
|
|
10
10
|
|
|
@@ -19,9 +19,23 @@ import {
|
|
|
19
19
|
import { recoverArtistReleasesWithCrossCatalogIdentity } from "../integrations/cross-catalog-artist-identity.mjs";
|
|
20
20
|
import { webProviderName } from "../integrations/web/web-result.mjs";
|
|
21
21
|
import { musicAppSource, readListeningNow } from "./listening-now.mjs";
|
|
22
|
+
import { SAVED_PLAYLIST_ID } from "./library-store.mjs";
|
|
22
23
|
import { normalizeConversationFolder, normalizeConversationLabel, normalizeMemoryContent, shortConversationTitle } from "../memory/local-memory-store.mjs";
|
|
23
24
|
import { queueListeningIntent, queueCancellationRequested, unheardRequested } from "./spotify-listening-intent.mjs";
|
|
24
|
-
import { DEFAULT_LOCALE, messageLocale, normalizeLocale } from "../i18n/index.mjs";
|
|
25
|
+
import { DEFAULT_LOCALE, LOCALES, messageLocale, normalizeLocale } from "../i18n/index.mjs";
|
|
26
|
+
|
|
27
|
+
// Runs work over a list with at most limit calls at once, keeping the list's order.
|
|
28
|
+
async function mapLimited(list, limit, work) {
|
|
29
|
+
const results = new Array(list.length);
|
|
30
|
+
let next = 0;
|
|
31
|
+
await Promise.all(Array.from({ length: Math.min(limit, list.length) }, async () => {
|
|
32
|
+
while (next < list.length) {
|
|
33
|
+
const at = next++;
|
|
34
|
+
results[at] = await work(list[at], at);
|
|
35
|
+
}
|
|
36
|
+
}));
|
|
37
|
+
return results;
|
|
38
|
+
}
|
|
25
39
|
|
|
26
40
|
// How long a record's title in the wrong language waits to be named again before the agent's stays.
|
|
27
41
|
const TITLE_LANGUAGE_TIMEOUT_MS = 10_000;
|
|
@@ -239,10 +253,14 @@ export class MoondogApplication {
|
|
|
239
253
|
musicCatalog = null,
|
|
240
254
|
musicSimilarity = null,
|
|
241
255
|
musicPreviewPlayer = null,
|
|
256
|
+
languageSelection = null,
|
|
257
|
+
modelSwitch = null,
|
|
242
258
|
neteasePlayer = null,
|
|
243
259
|
localGenerator = null,
|
|
244
260
|
atmosphereStream = null,
|
|
245
261
|
musicApp = null,
|
|
262
|
+
// The listener's library (src/core/library-store.mjs), shared by every conversation and both surfaces.
|
|
263
|
+
libraryStore = null,
|
|
246
264
|
agentHostNotes = [],
|
|
247
265
|
artistIdentityResolver = null,
|
|
248
266
|
webResearch = null,
|
|
@@ -266,6 +284,7 @@ export class MoondogApplication {
|
|
|
266
284
|
// The language of the conversation so far. A reply such as "2" or "ok" carries none of
|
|
267
285
|
// its own, so it keeps this one rather than switching to the setting mid-conversation.
|
|
268
286
|
this.conversationLocale = null;
|
|
287
|
+
this.replyLanguagePin = null;
|
|
269
288
|
this.domainServices = domainServices;
|
|
270
289
|
this.domainServicesError =
|
|
271
290
|
typeof domainServicesError === "string" &&
|
|
@@ -285,6 +304,10 @@ export class MoondogApplication {
|
|
|
285
304
|
// Hosts that can play audio pass { play(tracks, { album }) }: the website, and the
|
|
286
305
|
// terminal when the system has a player.
|
|
287
306
|
this.musicPreviewPlayer = musicPreviewPlayer;
|
|
307
|
+
// Shared with every surface and conversation of this core, so a language chosen in words is saved and shown everywhere.
|
|
308
|
+
this.languageSelection = languageSelection;
|
|
309
|
+
// The listener's model menu, and the way a model asked for in words reaches the surface that runs this agent.
|
|
310
|
+
this.modelSwitch = modelSwitch;
|
|
288
311
|
// NetEase Cloud Music through ncm-cli (src/integrations/netease), shared by every conversation.
|
|
289
312
|
this.neteasePlayer = neteasePlayer;
|
|
290
313
|
// Stable Audio 3 on this Mac (src/integrations/stable-audio), shared by every conversation, so one track is made at a time.
|
|
@@ -294,6 +317,7 @@ export class MoondogApplication {
|
|
|
294
317
|
// The Music app on this Mac (src/integrations/apple-music/music-app.mjs), read for what the
|
|
295
318
|
// listener plays there themselves.
|
|
296
319
|
this.musicApp = musicApp;
|
|
320
|
+
this.libraryStore = libraryStore;
|
|
297
321
|
// Hosts can tell the agent where it runs; the terminal passes none.
|
|
298
322
|
this.hostNotes = Array.isArray(agentHostNotes) ? agentHostNotes.filter((note) => typeof note === "string") : [];
|
|
299
323
|
this.artistIdentityResolver = artistIdentityResolver;
|
|
@@ -598,6 +622,210 @@ export class MoondogApplication {
|
|
|
598
622
|
return { state: "playing", track: { id, title, seconds: receipt.audio?.duration_seconds ?? null }, chosen_by: named ? "listener" : "moondog" };
|
|
599
623
|
}
|
|
600
624
|
|
|
625
|
+
// The listener's library: their playlists and the songs and made tracks they kept, side by side.
|
|
626
|
+
libraryReady() {
|
|
627
|
+
return Boolean(this.libraryStore);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
requireLibrary() {
|
|
631
|
+
if (!this.libraryStore) throw Object.assign(new Error("The library is not available here."), { code: "library_not_ready" });
|
|
632
|
+
return this.libraryStore;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
libraryView() {
|
|
636
|
+
return this.libraryReady() ? this.libraryStore.view() : null;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// One playlist with its entries; a made track says whether its audio is still on this Mac.
|
|
640
|
+
async libraryPlaylist(id) {
|
|
641
|
+
const playlist = this.requireLibrary().playlist(id);
|
|
642
|
+
if (!playlist) return null;
|
|
643
|
+
for (const entry of playlist.entries) {
|
|
644
|
+
if (entry.kind === "made") entry.audio_kept = Boolean(await this.localGenerator?.trackFile?.(entry.made_id));
|
|
645
|
+
}
|
|
646
|
+
return playlist;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// The song or made track one listening source plays now, as a library entry.
|
|
650
|
+
#entryOfSong(song) {
|
|
651
|
+
if (!song?.title) return null;
|
|
652
|
+
if (song.made_track_id) return { kind: "made", id: song.made_track_id, title: song.title };
|
|
653
|
+
const artist = Array.isArray(song.artists) ? song.artists.join(", ") : "";
|
|
654
|
+
if (!artist) return null;
|
|
655
|
+
const album = typeof song.album === "string" ? song.album : song.album?.name;
|
|
656
|
+
return { kind: "song", title: song.title, artist_credit: artist, ...(album ? { album } : {}) };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// What to save: the song playing now, what plays from now on (its playlist or queue), songs the agent names,
|
|
660
|
+
// or a made track by id.
|
|
661
|
+
async #entriesToSave({ from, songs, madeTrackId }, { signal } = {}) {
|
|
662
|
+
if (madeTrackId) {
|
|
663
|
+
const receipt = await this.localGenerator?.receipt?.(madeTrackId);
|
|
664
|
+
if (!receipt) throw Object.assign(new Error("There is no track Moondog made with that id on this Mac."), { code: "made_track_not_found" });
|
|
665
|
+
if (!(await this.localGenerator.trackFile(madeTrackId))) {
|
|
666
|
+
throw Object.assign(new Error("Only the receipt of that track is left, so it can't be kept."), { code: "made_track_audio_not_kept" });
|
|
667
|
+
}
|
|
668
|
+
return [{ kind: "made", id: madeTrackId, title: String(receipt.title ?? "Moondog") }];
|
|
669
|
+
}
|
|
670
|
+
if (Array.isArray(songs) && songs.length) {
|
|
671
|
+
return songs.map((song) => ({ kind: "song", title: song.title, artist_credit: song.artist ?? song.artist_credit, ...(song.album ? { album: song.album } : {}) }));
|
|
672
|
+
}
|
|
673
|
+
const now = await readListeningNow(this, { signal });
|
|
674
|
+
const active = now.sources.find((source) => source.service === now.active);
|
|
675
|
+
if (!active?.song) throw Object.assign(new Error("Nothing is playing, so there is nothing to save."), { code: "library_nothing_playing" });
|
|
676
|
+
if (from === "queue") {
|
|
677
|
+
// What plays from now on: Moondog's whole remaining queue (its status shows only the next ten); the Music
|
|
678
|
+
// app's playlist from the song playing, which it places; otherwise the song and the service's own queue.
|
|
679
|
+
const remaining = active.service === "moondog" ? this.musicPreviewPlayer?.remaining?.() : null;
|
|
680
|
+
let playing = active.from;
|
|
681
|
+
// The Music app's status reads a window around the playing song; a longer playlist is read on to its end.
|
|
682
|
+
if (active.service === "apple_music" && playing?.tracks?.length && Number.isInteger(playing.tracks_total) &&
|
|
683
|
+
(playing.first_position ?? 1) - 1 + playing.tracks.length < playing.tracks_total && typeof this.musicApp?.remainingPlaylist === "function") {
|
|
684
|
+
const rest = await this.musicApp.remainingPlaylist().then((now) => musicAppSource(now).from, () => null);
|
|
685
|
+
if (rest?.tracks?.length && Number.isInteger(rest.position) && Number.isInteger(rest.first_position)) playing = rest;
|
|
686
|
+
}
|
|
687
|
+
const tracks = playing?.tracks;
|
|
688
|
+
const placed = tracks?.length && Number.isInteger(playing.position) && Number.isInteger(playing.first_position);
|
|
689
|
+
const list = remaining?.length ? remaining.map((track) => ({ title: track.title, artists: track.generated ? ["Moondog"] : [track.artist_credit].filter(Boolean),
|
|
690
|
+
...(track.generated && track.id ? { made_track_id: track.id } : {}) }))
|
|
691
|
+
: placed ? tracks.slice(Math.max(0, playing.position - playing.first_position))
|
|
692
|
+
: [active.song, ...(active.next ?? [])];
|
|
693
|
+
return list.map((song) => this.#entryOfSong(song)).filter(Boolean);
|
|
694
|
+
}
|
|
695
|
+
const one = this.#entryOfSong(active.song);
|
|
696
|
+
if (!one) throw Object.assign(new Error("The song playing now has no artist to keep it by."), { code: "library_nothing_playing" });
|
|
697
|
+
return [one];
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Keeps songs or made tracks in the library: in Saved when no playlist is named, in a playlist by id, or
|
|
702
|
+
* in a new playlist by name. by says who chose to keep them: the listener, or Moondog at their request.
|
|
703
|
+
*/
|
|
704
|
+
async saveToLibrary({ from = "now_playing", songs, madeTrackId, playlistId, playlistName, folderId, artwork } = {}, { by = "listener", signal } = {}) {
|
|
705
|
+
const library = this.requireLibrary();
|
|
706
|
+
const entries = await this.#entriesToSave({ from, songs, madeTrackId }, { signal });
|
|
707
|
+
if (!entries.length) throw Object.assign(new Error("There is nothing to save."), { code: "library_nothing_playing" });
|
|
708
|
+
// The window passes the cover it shows for the song playing now.
|
|
709
|
+
if (artwork && from === "now_playing" && !songs?.length && !madeTrackId && entries[0].kind === "song") entries[0].refs = { artwork_url: artwork };
|
|
710
|
+
if (!playlistId && playlistName) {
|
|
711
|
+
const made = library.savePlaylist({ name: playlistName, folderId: folderId ?? null, origin: by === "moondog" ? "moondog" : "listener", entries, addedBy: by });
|
|
712
|
+
return { playlist: { id: made.id, name: library.playlist(made.id).name, created: true }, added: made.added, already: made.already,
|
|
713
|
+
titles: entries.map((entry) => entry.title) };
|
|
714
|
+
}
|
|
715
|
+
const target = playlistId ?? SAVED_PLAYLIST_ID;
|
|
716
|
+
const added = library.addEntries(target, entries, { addedBy: by });
|
|
717
|
+
const playlist = library.playlist(target);
|
|
718
|
+
return { playlist: { id: playlist.id, name: playlist.saved ? "Saved" : playlist.name, ...(playlist.saved ? { saved: true } : {}) },
|
|
719
|
+
added: added.added, already: added.already, titles: entries.map((entry) => entry.title) };
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** Playlists by name for the agent, or the newest ones when no words are given. */
|
|
723
|
+
findLibraryPlaylists(query) {
|
|
724
|
+
return this.requireLibrary().findPlaylists(query).map(({ id, name, count, made, origin, saved }) => ({
|
|
725
|
+
id, name: saved ? "Saved" : name, count, made, origin, ...(saved ? { saved: true } : {}) }));
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Plays a playlist from the library, from its first entry or from one entry. It plays on the listener's
|
|
730
|
+
* chosen service: Moondog's own player holds Apple Music songs and made tracks in one queue; Spotify and
|
|
731
|
+
* NetEase play its songs, and its made tracks wait for Moondog's own player (left_out_made says how many).
|
|
732
|
+
* The listener's own playlist is their pick, so nothing of it goes in the suggestions ledger.
|
|
733
|
+
*/
|
|
734
|
+
async playLibrary({ playlistId, entryId, shuffle = false } = {}, { signal } = {}) {
|
|
735
|
+
const playlist = await this.libraryPlaylist(playlistId);
|
|
736
|
+
if (!playlist) throw Object.assign(new Error("That playlist is not in the library."), { code: "library_playlist_not_found" });
|
|
737
|
+
const named = { id: playlist.id, name: playlist.saved ? "Saved" : playlist.name };
|
|
738
|
+
let entries = playlist.entries;
|
|
739
|
+
const start = entryId ? entries.findIndex((entry) => entry.id === entryId) : 0;
|
|
740
|
+
entries = entries.slice(Math.max(0, start));
|
|
741
|
+
if (shuffle) entries = entries.map((entry) => [Math.random(), entry]).sort((a, b) => a[0] - b[0]).map(([, entry]) => entry);
|
|
742
|
+
const avoided = entries.filter((entry) => entry.kind === "song" && !this.avoidAllows({ title: entry.title, artist_credit: entry.artist_credit }));
|
|
743
|
+
entries = entries.filter((entry) => !avoided.includes(entry) && (entry.kind === "song" || entry.audio_kept));
|
|
744
|
+
const extra = avoided.length ? { skipped_avoided_count: avoided.length } : {};
|
|
745
|
+
if (!entries.length) return { state: "empty", playlist: named, ...extra };
|
|
746
|
+
const songs = entries.filter((entry) => entry.kind === "song");
|
|
747
|
+
const service = this.playbackService;
|
|
748
|
+
// Only made tracks, or Apple Music chosen: Moondog's own player, with both in one queue.
|
|
749
|
+
if ((!songs.length || service === "apple_music") && typeof this.musicPreviewPlayer?.playList === "function") {
|
|
750
|
+
const refs = [];
|
|
751
|
+
const avoidedFound = [];
|
|
752
|
+
const found = await mapLimited(entries.slice(0, 50), 6, async (entry) => {
|
|
753
|
+
if (entry.kind === "made") return { generated: true, id: entry.made_id, title: entry.title, file: await this.localGenerator.trackFile(entry.made_id) };
|
|
754
|
+
if (!this.musicCatalog) return null;
|
|
755
|
+
const result = await this.musicCatalog.findPreview({ title: entry.title, artist: entry.artist_credit }).catch(() => null);
|
|
756
|
+
if (result?.state !== "resolved") return null;
|
|
757
|
+
// The catalog's recording can credit more than the entry named (a feature, a longer title); an Avoid still holds.
|
|
758
|
+
if (!this.avoidAllows(result.track)) { avoidedFound.push(entry.id); return null; }
|
|
759
|
+
if (result.track.catalog_id) refs.push({ entryId: entry.id, refs: { apple_music_id: String(result.track.catalog_id),
|
|
760
|
+
...(entry.refs?.artwork_url || !result.track.artwork_url ? {} : { artwork_url: result.track.artwork_url }) } });
|
|
761
|
+
return result.track;
|
|
762
|
+
});
|
|
763
|
+
signal?.throwIfAborted();
|
|
764
|
+
const tracks = found.filter(Boolean);
|
|
765
|
+
const notFound = found.length - tracks.length - avoidedFound.length;
|
|
766
|
+
const skipped = avoided.length + avoidedFound.length;
|
|
767
|
+
Object.assign(extra, skipped ? { skipped_avoided_count: skipped } : {});
|
|
768
|
+
if (!tracks.length) return { state: "not_found", playlist: named, not_found: notFound, ...extra };
|
|
769
|
+
this.libraryStore.rememberRefs(refs);
|
|
770
|
+
// A playlist replaces Moondog's stream and quiets the other players, as any music Moondog starts does.
|
|
771
|
+
await this.#endStream("replaced");
|
|
772
|
+
await this.quietOtherPlayers("apple_music", { signal });
|
|
773
|
+
signal?.throwIfAborted();
|
|
774
|
+
this.musicPreviewPlayer.playList(tracks);
|
|
775
|
+
return { state: "playing", player: "moondog", playlist: named, playing: tracks.length, not_found: notFound,
|
|
776
|
+
whole_songs: this.musicPreviewPlayer.wholeSongs?.() === true, ...extra };
|
|
777
|
+
}
|
|
778
|
+
const leftOut = entries.length - songs.length;
|
|
779
|
+
if (service === "spotify") {
|
|
780
|
+
if (!this.spotifyPlaybackReady()) throw Object.assign(new Error("Spotify is not ready to play here."), { code: "spotify_not_ready" });
|
|
781
|
+
const list = songs.slice(0, 100);
|
|
782
|
+
const refs = [];
|
|
783
|
+
const avoidedFound = [];
|
|
784
|
+
// A recording's own title and credit can name more than the entry does (a feature); an Avoid holds against it.
|
|
785
|
+
const allowed = (title, credit) => this.avoidAllows({ title, artist_credit: credit });
|
|
786
|
+
const uris = await mapLimited(list, 4, async (entry) => {
|
|
787
|
+
// A cached recording plays without a search, once its own credit passes today's Avoids.
|
|
788
|
+
if (entry.refs?.spotify_uri && entry.refs.spotify_credit) {
|
|
789
|
+
if (allowed(entry.refs.spotify_title ?? entry.title, entry.refs.spotify_credit)) return entry.refs.spotify_uri;
|
|
790
|
+
avoidedFound.push(entry.id);
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
const { matched } = await this.spotifyResolveSongs([{ title: entry.title, artist: entry.artist_credit }], { signal }).catch(() => ({ matched: [] }));
|
|
794
|
+
// A match reaches the model without its URI; the host keeps it under the match's reference.
|
|
795
|
+
const ref = matched[0]?.item_ref_id;
|
|
796
|
+
const known = ref ? this.spotifyHostReadItems().find((item) => item.item_ref_id === ref) : null;
|
|
797
|
+
const uri = typeof known?.uri === "string" ? known.uri : typeof matched[0]?.uri === "string" ? matched[0].uri : null;
|
|
798
|
+
if (!uri) return null;
|
|
799
|
+
const title = known?.name ?? matched[0]?.name ?? entry.title;
|
|
800
|
+
const credit = (known?.artists ?? matched[0]?.artists ?? []).join(", ") || entry.artist_credit;
|
|
801
|
+
refs.push({ entryId: entry.id, refs: { spotify_uri: uri, spotify_title: title, spotify_credit: credit } });
|
|
802
|
+
if (!allowed(title, credit)) { avoidedFound.push(entry.id); return null; }
|
|
803
|
+
return uri;
|
|
804
|
+
});
|
|
805
|
+
signal?.throwIfAborted();
|
|
806
|
+
this.libraryStore.rememberRefs(refs);
|
|
807
|
+
const playable = uris.filter(Boolean);
|
|
808
|
+
const skipped = avoided.length + avoidedFound.length;
|
|
809
|
+
Object.assign(extra, skipped ? { skipped_avoided_count: skipped } : {});
|
|
810
|
+
const unfound = list.length - playable.length - avoidedFound.length;
|
|
811
|
+
if (!playable.length) return { state: "not_found", playlist: named, not_found: unfound, left_out_made: leftOut, ...extra };
|
|
812
|
+
await this.quietOtherPlayers("spotify", { signal });
|
|
813
|
+
const receipt = await this.#resumeSpotifySelection({ uris: playable }, { signal, played: [] });
|
|
814
|
+
return { state: receipt.state === "accepted" ? "playing" : receipt.state, player: "spotify", playlist: named, playing: playable.length,
|
|
815
|
+
not_found: unfound, left_out_made: leftOut, ...extra };
|
|
816
|
+
}
|
|
817
|
+
if (service === "netease" && this.neteaseReady()) {
|
|
818
|
+
const player = this.requireNetease();
|
|
819
|
+
await this.quietOtherPlayers("netease", { signal });
|
|
820
|
+
const receipt = await player.play({ items: songs.slice(0, 20).map((entry) => ({ title: entry.title, artist: entry.artist_credit })), mode: "play",
|
|
821
|
+
intent: "播放听者自己的歌单", allow: (song) => this.avoidAllows(Array.isArray(song.artists)
|
|
822
|
+
? { title: song.title, artist_credit: song.artists.join(", "), artists: song.artists } : song) });
|
|
823
|
+
return { state: receipt?.mode === "playing" ? "playing" : "not_found", player: "netease", playlist: named,
|
|
824
|
+
playing: receipt?.songs?.length ?? 0, left_out_made: leftOut, ...extra };
|
|
825
|
+
}
|
|
826
|
+
throw Object.assign(new Error("No player here can play this playlist."), { code: "library_no_player" });
|
|
827
|
+
}
|
|
828
|
+
|
|
601
829
|
// Makes one instrumental track from the listener's taste and plays it in Moondog's own player.
|
|
602
830
|
// The agent proposes sound descriptors, each with its source; the taste compiler keeps only the
|
|
603
831
|
// ones it can trace and none that names an artist or a song. The track and its receipt stay on
|
|
@@ -938,6 +1166,52 @@ export class MoondogApplication {
|
|
|
938
1166
|
return this.playbackSelection.subscribe(listener);
|
|
939
1167
|
}
|
|
940
1168
|
|
|
1169
|
+
// What the agent can tell the listener about its own setup, and what it can change when asked.
|
|
1170
|
+
async settingsStatus(runtime = {}) {
|
|
1171
|
+
const playback = this.playbackStatus();
|
|
1172
|
+
const choices = await this.modelSwitch?.choices().catch(() => []) ?? [];
|
|
1173
|
+
return {
|
|
1174
|
+
model: { provider: runtime.provider ?? null, model: runtime.model ?? null, ...(runtime.effort ? { effort: runtime.effort } : {}),
|
|
1175
|
+
...(this.modelSwitch ? { change: "moondog_settings model", choices } : { change: "not_by_agent" }),
|
|
1176
|
+
where: "Settings › Models in the window, or /model in the terminal" },
|
|
1177
|
+
music_service: { selected: playback.selected_service ?? null, effective: playback.effective_service ?? null,
|
|
1178
|
+
available: (playback.services ?? []).filter((entry) => entry.available).map((entry) => entry.service),
|
|
1179
|
+
change: "moondog_playback_service" },
|
|
1180
|
+
reply_language: { current: this.locale, choices: Object.keys(LOCALES), change: "moondog_settings reply_language" },
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// The reply language the listener asked for in words: used from the next reply on, saved like
|
|
1185
|
+
// the language picker's choice, and shown by each surface.
|
|
1186
|
+
async setReplyLanguage(code, runtime = {}) {
|
|
1187
|
+
const locale = normalizeLocale(code);
|
|
1188
|
+
if (!locale) throw Object.assign(new Error("Unsupported reply language."), { code: "unsupported_reply_language" });
|
|
1189
|
+
const previous = this.locale;
|
|
1190
|
+
this.locale = locale;
|
|
1191
|
+
this.conversationLocale = locale;
|
|
1192
|
+
// Asked for in words, the language holds in this conversation whatever language the listener writes in.
|
|
1193
|
+
this.replyLanguagePin = locale;
|
|
1194
|
+
await this.languageSelection?.change(locale);
|
|
1195
|
+
return { ...await this.settingsStatus(runtime), changed: { setting: "reply_language", from: previous, to: locale } };
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// A model the listener asked for in words, from their model menu: by provider/model, model ID or
|
|
1199
|
+
// name. It takes over when this reply ends; a model not set up, or not signed in, is refused.
|
|
1200
|
+
async requestModel(wanted, runtime = {}) {
|
|
1201
|
+
if (!this.modelSwitch) throw Object.assign(new Error("This host cannot change the model from a conversation."), { code: "model_change_unavailable" });
|
|
1202
|
+
const choices = await this.modelSwitch.choices();
|
|
1203
|
+
const key = String(wanted ?? "").trim().toLowerCase();
|
|
1204
|
+
const folded = (value) => String(value).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
|
|
1205
|
+
const match = choices.find((choice) => choice.model.toLowerCase() === key) ??
|
|
1206
|
+
choices.find((choice) => choice.model.split("/").slice(1).join("/").toLowerCase() === key) ??
|
|
1207
|
+
choices.find((choice) => folded(choice.name) === folded(key));
|
|
1208
|
+
if (!match) throw Object.assign(new Error(`That model is not in the listener's model menu. Choose one of: ${choices.map((choice) => choice.model).join(", ") || "none"}. Others are added in Settings › Models or with /model.`), { code: "model_not_listed" });
|
|
1209
|
+
if (!match.ready) throw Object.assign(new Error(`${match.model} is in the menu but not signed in. The listener signs in from Settings › Models or with /auth.`), { code: "model_not_signed_in" });
|
|
1210
|
+
const [provider, ...rest] = match.model.split("/");
|
|
1211
|
+
this.modelSwitch.request(this, { provider, model: rest.join("/") });
|
|
1212
|
+
return { ...await this.settingsStatus(runtime), changed: { setting: "model", from: `${runtime.provider}/${runtime.model}`, to: match.model, takes_effect: "after this reply" } };
|
|
1213
|
+
}
|
|
1214
|
+
|
|
941
1215
|
// A host-only in-memory change, kept for hosts with no saved settings.
|
|
942
1216
|
setPlaybackService(service) {
|
|
943
1217
|
return this.playbackSelection.set(service);
|
|
@@ -2073,6 +2347,43 @@ export class MoondogApplication {
|
|
|
2073
2347
|
truncated: result.truncated === true || raw.length > 10 };
|
|
2074
2348
|
}
|
|
2075
2349
|
|
|
2350
|
+
// Songs the listener named, by title and artist: the host searches Spotify for each one and
|
|
2351
|
+
// keeps the result whose artist and title match, so the model passes no references and makes
|
|
2352
|
+
// no second call. A song without a clear match is never guessed; its closest results come back
|
|
2353
|
+
// with item_ref_id values, to choose from and play by reference.
|
|
2354
|
+
async spotifyResolveSongs(songs, { signal, otherSources = [] } = {}) {
|
|
2355
|
+
// Songs and a reference together are two choices of what to play; neither wins silently.
|
|
2356
|
+
if (otherSources.length) throw spotifyResolutionError("spotify_playback_source_conflict", "Choose one playback source for resume: songs, or one reference. Nothing was played.");
|
|
2357
|
+
if (!Array.isArray(songs) || songs.length < 1 || songs.length > 12 ||
|
|
2358
|
+
songs.some((song) => typeof song?.title !== "string" || !song.title.trim() || typeof song?.artist !== "string" || !song.artist.trim())) {
|
|
2359
|
+
throw spotifyResolutionError("invalid_spotify_songs", "Give from 1 to 12 songs, each with a title and an artist.");
|
|
2360
|
+
}
|
|
2361
|
+
const fold = (value) => foldedWords(normalizedTrackTitle(value));
|
|
2362
|
+
const matched = [];
|
|
2363
|
+
const unmatched = [];
|
|
2364
|
+
for (const song of songs) {
|
|
2365
|
+
const result = await this.spotifySearchTracks({ query: `${song.title} ${song.artist}`, type: "track", limit: 10 }, { signal });
|
|
2366
|
+
const tracks = result.items.filter((item) => item.type === "track" && item.track_ref_id);
|
|
2367
|
+
const wantedTitle = foldedWords(song.title);
|
|
2368
|
+
const wantedCore = fold(song.title);
|
|
2369
|
+
// The same artist: equal names, or one credited artist of a joint credit ("伍佰" in
|
|
2370
|
+
// "伍佰 & China Blue"). A name inside another act's name ("Adele Tribute Band") is another act.
|
|
2371
|
+
const credited = (name) => [name, ...String(name).split(ARTIST_CREDIT_SEPARATORS)].map(foldedWords).filter(Boolean);
|
|
2372
|
+
const wantedArtists = new Set(credited(song.artist));
|
|
2373
|
+
const byArtist = tracks.filter((item) => item.artists.some((artist) => credited(artist).some((name) => wantedArtists.has(name))));
|
|
2374
|
+
// The exact title first. A version the listener named (a live take, a duet) must match as
|
|
2375
|
+
// named; otherwise the same song in another version (a remaster) is the song asked for.
|
|
2376
|
+
// Nothing looser is guessed: a shared prefix ("One", "One Tree Hill") is another song.
|
|
2377
|
+
const versionNamed = wantedTitle !== wantedCore;
|
|
2378
|
+
const pick = byArtist.find((item) => foldedWords(item.name) === wantedTitle) ??
|
|
2379
|
+
(versionNamed ? undefined : byArtist.find((item) => fold(item.name) === wantedCore));
|
|
2380
|
+
if (pick) matched.push(pick);
|
|
2381
|
+
else unmatched.push({ title: song.title, artist: song.artist,
|
|
2382
|
+
candidates: tracks.slice(0, 5).map(({ item_ref_id, name, artists, album }) => ({ item_ref_id, name, artists, ...(album ? { album } : {}) })) });
|
|
2383
|
+
}
|
|
2384
|
+
return { matched, unmatched };
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2076
2387
|
spotifyDiscoveryAllowed(item) {
|
|
2077
2388
|
return this.avoidAllows({ title: item.name, artist_credit: item.artists.join(", "), artists: item.artists });
|
|
2078
2389
|
}
|
|
@@ -2196,7 +2507,18 @@ export class MoondogApplication {
|
|
|
2196
2507
|
// named in their language; otherwise a sensible batch from the candidates.
|
|
2197
2508
|
const requested = intent?.requested ?? count ?? Math.min(itemRefs.length, 12);
|
|
2198
2509
|
if (!Number.isInteger(requested) || requested < 1 || requested > 12) throw spotifyResolutionError("spotify_queue_count_limit", "A queue request supports 1 to 12 songs. No write was sent.");
|
|
2199
|
-
|
|
2510
|
+
// A reference the host never issued (a model's slip) is left out and counted; only a batch
|
|
2511
|
+
// with no usable reference at all is refused.
|
|
2512
|
+
const chosen = itemRefs.map((ref) => { try { return this.requireSpotifyReadItem(ref, ["track"]); } catch { return null; } }).filter(Boolean);
|
|
2513
|
+
const skippedUnavailable = itemRefs.length - chosen.length;
|
|
2514
|
+
if (!chosen.length) this.requireSpotifyReadItem(itemRefs[0], ["track"]);
|
|
2515
|
+
// The rest of this request's verified discovery results by the artists the model picked stand
|
|
2516
|
+
// behind its picks, in the order found, so a pick skipped as a duplicate or an Avoid still
|
|
2517
|
+
// leaves the count asked for. A song by another artist may have been left out on purpose.
|
|
2518
|
+
const pickedArtists = new Set(chosen.flatMap(item => item.artists));
|
|
2519
|
+
const backups = transaction.discoveryPoolStarted ? (this.spotifyReadSelections.get("discovery") ?? [])
|
|
2520
|
+
.filter(item => item.type === "track" && item.uri && !itemRefs.includes(item.item_ref_id) && item.artists.some(artist => pickedArtists.has(artist))) : [];
|
|
2521
|
+
const items = [...chosen, ...backups].slice(0, 36);
|
|
2200
2522
|
this.requireSpotifyWriteScopes(["user-modify-playback-state"]);
|
|
2201
2523
|
this.requireSpotifyScopes(["user-read-playback-state"]);
|
|
2202
2524
|
transaction.queueBatchAttempted = true;
|
|
@@ -2221,9 +2543,12 @@ export class MoondogApplication {
|
|
|
2221
2543
|
knownTrack: intent?.excludeKnown ? track => this.domainServices?.isKnownDiscoveryTrack?.(track) === true : null });
|
|
2222
2544
|
if (!receipt.queued.length && !receipt.stopped) transaction.queueBatchAttempted = false;
|
|
2223
2545
|
const result = { ...receipt, action: "queue.batch", requested, queued_count: receipt.queued.length,
|
|
2224
|
-
shortfall: requested - receipt.queued.length, queue_observation_truncated: observed.truncated === true
|
|
2546
|
+
shortfall: requested - receipt.queued.length, queue_observation_truncated: observed.truncated === true,
|
|
2547
|
+
...(skippedUnavailable ? { skipped_unavailable_count: skippedUnavailable } : {}) };
|
|
2225
2548
|
this.spotifyDisplayedChoices = null;
|
|
2226
2549
|
if (this.spotifyQueueRequest && (receipt.queued.length || receipt.stopped)) Object.assign(this.spotifyQueueRequest, { attempted: true, receipt: structuredClone(result) });
|
|
2550
|
+
// Kept on the request itself, so a fill finds the batch even when no queue intent was parsed.
|
|
2551
|
+
if (receipt.queued.length) transaction.batchReceipt = structuredClone(result);
|
|
2227
2552
|
return result;
|
|
2228
2553
|
}
|
|
2229
2554
|
|
|
@@ -2347,7 +2672,24 @@ export class MoondogApplication {
|
|
|
2347
2672
|
|
|
2348
2673
|
async spotifyAddToQueue(input, { signal } = {}) {
|
|
2349
2674
|
signal?.throwIfAborted();
|
|
2350
|
-
|
|
2675
|
+
// A batch that came up short may be filled by single adds of songs it did not queue, one per
|
|
2676
|
+
// missing song; anything else after a batch is refused as before.
|
|
2677
|
+
const transaction = this.pendingPlaylistPromptTransaction;
|
|
2678
|
+
const batch = transaction?.queueBatchAttempted && transaction.batchReceipt?.action === "queue.batch" ? transaction.batchReceipt : null;
|
|
2679
|
+
// The retained receipt takes in each accepted fill, so its queued songs are the count so far.
|
|
2680
|
+
const missing = batch ? (batch.requested ?? 0) - (batch.queued?.length ?? 0) : 0;
|
|
2681
|
+
// Either supported reference names the song: a read's item_ref_id, or a retained or resolved track_ref_id.
|
|
2682
|
+
const fillRef = input?.itemRefId ?? input?.trackRefId;
|
|
2683
|
+
const resolved = batch && input?.trackRefId !== undefined ? this.spotifyResolutions.get(input.trackRefId) : null;
|
|
2684
|
+
const candidate = !batch || fillRef === undefined ? null
|
|
2685
|
+
: this.spotifyHostReadItems().find((entry) => entry.item_ref_id === fillRef || entry.track_ref_id === fillRef) ??
|
|
2686
|
+
(resolved?.uri ? { type: "track", uri: resolved.uri, name: resolved.matched?.title ?? "", artists: resolved.matched?.artists ?? [] } : null);
|
|
2687
|
+
// The same song is the same title by the same primary artist, as the batch itself decides.
|
|
2688
|
+
const candidateSongs = candidate ? songKeys({ title: candidate.name, artist_credit: candidate.artists?.join(", ") ?? "", artists: candidate.artists ?? [] }) : [];
|
|
2689
|
+
const fillsBatch = missing > 0 && candidate?.type === "track" &&
|
|
2690
|
+
!batch.queued.some((queued) => songKeys(queued).some((key) => candidateSongs.includes(key))) &&
|
|
2691
|
+
!(transaction.batchFillUris ?? []).includes(candidate.uri);
|
|
2692
|
+
this.requireSpotifyNonRemovalAction({ batchFill: fillsBatch });
|
|
2351
2693
|
let uri;
|
|
2352
2694
|
if (input?.itemRefId !== undefined) {
|
|
2353
2695
|
if (input.trackRefId !== undefined || input.uri !== undefined) throw spotifyResolutionError("spotify_queue_selection_conflict", "Choose one queue item.");
|
|
@@ -2360,11 +2702,37 @@ export class MoondogApplication {
|
|
|
2360
2702
|
const item = this.spotifyHostReadItems().find(item => item.uri === uri);
|
|
2361
2703
|
if (item && !this.#spotifyItemAllowed(item)) throw listenerAvoidError();
|
|
2362
2704
|
const track = { track_ref_id: "single", title: item?.name ?? "the selected track", artist_credit: item?.artists?.join(", ") ?? "", artists: item?.artists ?? [], ...(item?.isrc ? { isrc: item.isrc } : {}) };
|
|
2705
|
+
// A fill keeps the batch's own rules: nothing already in Spotify's queue or playing, and only
|
|
2706
|
+
// unheard songs when the listener asked for those.
|
|
2707
|
+
let fillRules = {};
|
|
2708
|
+
if (fillsBatch) {
|
|
2709
|
+
const service = this.requireSpotifyService();
|
|
2710
|
+
const [observed, player] = await Promise.all([service.queue({ signal }), service.currentPlayer({ signal }).catch(() => null)]);
|
|
2711
|
+
signal?.throwIfAborted();
|
|
2712
|
+
const seenUris = this.#observedQueueUris(observed, player);
|
|
2713
|
+
if (player?.item?.uri) seenUris.add(player.item.uri);
|
|
2714
|
+
fillRules = { seenUris, ...(transaction.queueIntent?.excludeKnown ? { knownTrack: (entry) => this.domainServices?.isKnownDiscoveryTrack?.(entry) === true } : {}) };
|
|
2715
|
+
}
|
|
2363
2716
|
// One entry takes the same path as a batch: one device decision, start
|
|
2364
2717
|
// playback when nothing is playing, and a check of Spotify's real queue.
|
|
2365
|
-
const receipt = await this.#queueSpotifyTracks([track], new Map([["single", uri]]), { signal, deviceId: input.deviceId, rethrow: true });
|
|
2718
|
+
const receipt = await this.#queueSpotifyTracks([track], new Map([["single", uri]]), { signal, deviceId: input.deviceId, rethrow: true, ...fillRules });
|
|
2719
|
+
if (fillsBatch && !receipt.queued.length) {
|
|
2720
|
+
throw spotifyResolutionError("spotify_queue_fill_skipped", receipt.skipped_known_count
|
|
2721
|
+
? "That song is one the listener already knows, and they asked for unheard songs only. Nothing was queued."
|
|
2722
|
+
: "That song is already in Spotify's queue or playing. Nothing was queued; choose another song for the missing place.");
|
|
2723
|
+
}
|
|
2366
2724
|
this.recentSimilarQueueUris.set(uri, Date.now());
|
|
2367
2725
|
while (this.recentSimilarQueueUris.size > 100) this.recentSimilarQueueUris.delete(this.recentSimilarQueueUris.keys().next().value);
|
|
2726
|
+
if (fillsBatch) {
|
|
2727
|
+
transaction.batchFillUris = [...(transaction.batchFillUris ?? []), uri];
|
|
2728
|
+
// The receipt a later "queue, not playlist" reply shows says what is queued now.
|
|
2729
|
+
for (const kept of [batch, this.spotifyQueueRequest?.receipt?.action === "queue.batch" ? this.spotifyQueueRequest.receipt : null]) {
|
|
2730
|
+
if (!kept) continue;
|
|
2731
|
+
kept.queued = [...kept.queued, ...receipt.queued];
|
|
2732
|
+
kept.queued_count = kept.queued.length;
|
|
2733
|
+
kept.shortfall = Math.max(0, (kept.requested ?? 0) - kept.queued.length);
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2368
2736
|
return { provider: "spotify", ok: true, effect: "write_external", action: "playback.queue.add", state: "accepted",
|
|
2369
2737
|
...(item?.name ? { targets: [{ title: item.name, artist_credit: item.artists.join(", ") }] } : {}),
|
|
2370
2738
|
...(receipt.device ? { device: receipt.device } : {}), ...(receipt.started_playback ? { started_playback: true } : {}),
|
|
@@ -2500,12 +2868,12 @@ export class MoondogApplication {
|
|
|
2500
2868
|
}
|
|
2501
2869
|
}
|
|
2502
2870
|
|
|
2503
|
-
requireSpotifyNonRemovalAction({ quickEdit = false } = {}) {
|
|
2871
|
+
requireSpotifyNonRemovalAction({ quickEdit = false, batchFill = false } = {}) {
|
|
2504
2872
|
const requested = this.pendingPlaylistPromptTransaction?.queueIntent?.requested;
|
|
2505
2873
|
if (requested != null && (!Number.isInteger(requested) || requested < 1 || requested > 12)) {
|
|
2506
2874
|
throw spotifyResolutionError("spotify_queue_count_limit", "Specify one exact queue count from 1 to 12 songs. No write was sent.");
|
|
2507
2875
|
}
|
|
2508
|
-
if (this.pendingPlaylistPromptTransaction?.queueBatchAttempted) throw spotifyResolutionError("spotify_queue_batch_already_attempted", "The requested queue batch was already attempted. Keep its receipt; do not replay or add another action.");
|
|
2876
|
+
if (this.pendingPlaylistPromptTransaction?.queueBatchAttempted && !batchFill) throw spotifyResolutionError("spotify_queue_batch_already_attempted", "The requested queue batch was already attempted. Keep its receipt; do not replay or add another action.");
|
|
2509
2877
|
if (this.pendingPlaylistPromptTransaction?.quickEditIntent && !quickEdit) {
|
|
2510
2878
|
throw spotifyResolutionError("spotify_quick_edit_action_conflict", "This request authorizes only its exact quick playlist edit. An earlier preview or another Spotify action needs its own explicit request.");
|
|
2511
2879
|
}
|
|
@@ -3617,7 +3985,8 @@ export class MoondogApplication {
|
|
|
3617
3985
|
musicPreviewReady: this.musicPreviewReady(),
|
|
3618
3986
|
tasteChoicesReady: this.tasteChoicesReady(),
|
|
3619
3987
|
localGenerationReady: this.localGenerationReady(),
|
|
3620
|
-
}).filter(descriptor => !this.#playbackServiceExcludes(descriptor.capability_id)
|
|
3988
|
+
}).filter(descriptor => !this.#playbackServiceExcludes(descriptor.capability_id) &&
|
|
3989
|
+
(this.libraryReady() || !descriptor.capability_id.startsWith("collection.")));
|
|
3621
3990
|
}
|
|
3622
3991
|
|
|
3623
3992
|
async findArtistReleases(input) {
|
|
@@ -4008,7 +4377,7 @@ export class MoondogApplication {
|
|
|
4008
4377
|
this.pendingPlaylistPromptTransaction = null;
|
|
4009
4378
|
} finally {
|
|
4010
4379
|
const owned = this.ownsServices
|
|
4011
|
-
? [this.memoryStore, this.domainServices, this.musicCatalog, this.musicSimilarity, this.musicPreviewPlayer, this.webResearch]
|
|
4380
|
+
? [this.memoryStore, this.libraryStore, this.domainServices, this.musicCatalog, this.musicSimilarity, this.musicPreviewPlayer, this.webResearch]
|
|
4012
4381
|
: [this.domainServices];
|
|
4013
4382
|
for (const service of owned) service?.close?.();
|
|
4014
4383
|
}
|
package/src/i18n/index.mjs
CHANGED
|
@@ -134,6 +134,7 @@ const WORDS = {
|
|
|
134
134
|
"künstler", "lautsprecher", "handy", "fernseher", "pausiere", "stoppe", "überspringe", "weiter", "zurück", "lauter",
|
|
135
135
|
"leiser", "welche", "welcher", "welches", "heute", "abend", "oder", "aber", "sehr", "gut", "mal", "doch", "gern", "gerne"]),
|
|
136
136
|
it: new Set(["il", "gli", "di", "della", "delle", "dello", "degli", "dei", "che", "per", "metti", "mettimi", "mettere", "suona",
|
|
137
|
+
"sposta", "spostare", "spostato", "trasferisci", "sul", "sulla", "sullo", "sui", "sugli", "ho", "hai", "fatto", "riproduzione",
|
|
137
138
|
"suonami", "suonare", "riproduci", "aggiungi", "canzone", "canzoni", "brano", "brani", "coda", "qualcosa", "ancora",
|
|
138
139
|
"grazie", "ciao", "favore", "ascoltare", "ascolta", "prossima", "prossimo", "chi", "perché", "sono", "è", "voglio",
|
|
139
140
|
"vorrei", "puoi", "fammi", "dammi", "altra", "altro", "adesso", "anche", "molto", "bene", "mio", "mia", "miei", "mie",
|
|
@@ -148,7 +149,7 @@ const COMMANDS = {
|
|
|
148
149
|
pt: new Set(["coloca", "coloque", "toque", "adiciona", "adicione", "bota", "põe", "procura", "mostra", "recomenda", "faz", "quero", "pula", "me"]),
|
|
149
150
|
fr: new Set(["mets", "joue", "lance", "ajoute", "trouve", "cherche", "montre", "donne", "recommande", "propose", "fais", "arrête", "écoute", "je", "peux", "est"]),
|
|
150
151
|
de: new Set(["spiel", "spiele", "füge", "füg", "gib", "zeig", "zeige", "suche", "finde", "mach", "empfiehl", "stell", "setz", "leg", "überspringe", "pausiere", "stoppe", "ich", "kannst", "bitte"]),
|
|
151
|
-
it: new Set(["metti", "mettimi", "suona", "suonami", "riproduci", "aggiungi", "trova", "consiglia", "consigliami", "fammi", "dammi", "fai", "proponi", "ferma", "voglio", "vorrei", "ascolta", "puoi"]),
|
|
152
|
+
it: new Set(["metti", "mettimi", "suona", "suonami", "riproduci", "aggiungi", "sposta", "trasferisci", "trova", "consiglia", "consigliami", "fammi", "dammi", "fai", "proponi", "ferma", "voglio", "vorrei", "ascolta", "puoi"]),
|
|
152
153
|
};
|
|
153
154
|
// Letters only one language writes are strong evidence; letters several share are weak evidence for each.
|
|
154
155
|
const MARKS = [
|
|
@@ -179,13 +180,15 @@ function explicitLocale(prose) {
|
|
|
179
180
|
* message's own script and words, then the listener's configured language.
|
|
180
181
|
* Quoted titles never decide it, because song names travel across languages.
|
|
181
182
|
*/
|
|
182
|
-
|
|
183
|
+
// explicit: false reads only the language a text is written in, for Moondog's own replies, where
|
|
184
|
+
// "I'll answer in Spanish" is not a request.
|
|
185
|
+
export function messageLocale(text, fallback = DEFAULT_LOCALE, { explicit: readRequest = true } = {}) {
|
|
183
186
|
const configured = normalizeLocale(fallback) ?? DEFAULT_LOCALE;
|
|
184
187
|
if (typeof text !== "string") return configured;
|
|
185
188
|
const prose = text
|
|
186
189
|
.replace(/^(?:Track|Artist): "(?:[^"\\\n]|\\.)*"[ \t]*$/gmu, "")
|
|
187
190
|
.replace(/"(?:[^"\\]|\\.)*"|“[^”]*”|«[^»]*»|「[^」]*」|『[^』]*』|《[^》]*》/gu, "");
|
|
188
|
-
const explicit = explicitLocale(prose);
|
|
191
|
+
const explicit = readRequest ? explicitLocale(prose) : null;
|
|
189
192
|
if (explicit) return explicit;
|
|
190
193
|
// Hangul is only Korean and kana only Japanese; whichever the message uses more decides.
|
|
191
194
|
const hangul = prose.match(/\p{Script=Hangul}/gu)?.length ?? 0;
|
package/src/i18n/preferences.mjs
CHANGED
|
@@ -32,3 +32,27 @@ export async function writeLanguagePreference(locale, environment = process.env)
|
|
|
32
32
|
export async function resolveListenerLocale(environment = process.env) {
|
|
33
33
|
return normalizeLocale(environment.MOONDOG_LANGUAGE) ?? await readLanguagePreference(environment) ?? systemLocale(environment);
|
|
34
34
|
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The reply language every surface and conversation of one core shares. A change, from the
|
|
38
|
+
* language picker or from the agent when the listener asks in words, is saved once and heard by
|
|
39
|
+
* each surface that shows the language.
|
|
40
|
+
*/
|
|
41
|
+
export function createLanguageSelection({ save } = {}) {
|
|
42
|
+
const listeners = new Set();
|
|
43
|
+
return {
|
|
44
|
+
async change(locale) {
|
|
45
|
+
const value = normalizeLocale(locale);
|
|
46
|
+
if (!value) throw new Error(`Unsupported language: ${locale}`);
|
|
47
|
+
await save?.(value);
|
|
48
|
+
for (const listener of listeners) {
|
|
49
|
+
try { listener(value); } catch { /* A surface cannot undo a saved choice. */ }
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
},
|
|
53
|
+
subscribe(listener) {
|
|
54
|
+
listeners.add(listener);
|
|
55
|
+
return () => listeners.delete(listener);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|