@audiofool/moondog 0.2.0-beta.2 → 0.2.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +5 -2
  2. package/package.json +1 -1
  3. package/src/core/apple-projection-domain-services.mjs +95 -27
  4. package/src/core/capability-catalog.mjs +12 -0
  5. package/src/core/listener-avoids.mjs +101 -0
  6. package/src/core/listening-profile-domain-services.mjs +44 -8
  7. package/src/core/moondog-application.mjs +238 -34
  8. package/src/core/spotify-listening-intent.mjs +5 -2
  9. package/src/core/track-title.mjs +107 -0
  10. package/src/i18n/messages/de.mjs +7 -0
  11. package/src/i18n/messages/en.mjs +7 -0
  12. package/src/i18n/messages/es.mjs +7 -0
  13. package/src/i18n/messages/fr.mjs +7 -0
  14. package/src/i18n/messages/it.mjs +7 -0
  15. package/src/i18n/messages/ja.mjs +7 -0
  16. package/src/i18n/messages/ko.mjs +7 -0
  17. package/src/i18n/messages/pt.mjs +7 -0
  18. package/src/i18n/messages/zh-Hant.mjs +7 -0
  19. package/src/i18n/messages/zh.mjs +7 -0
  20. package/src/i18n/screen/es.mjs +1 -0
  21. package/src/i18n/screen/ja.mjs +1 -0
  22. package/src/i18n/screen/pt.mjs +1 -0
  23. package/src/i18n/screen/zh.mjs +1 -0
  24. package/src/integrations/apple-music/catalog.mjs +52 -9
  25. package/src/integrations/netease/ncm-cli-player.mjs +10 -3
  26. package/src/integrations/spotify/catalog-resolver.mjs +1 -2
  27. package/src/integrations/spotify/web-api-client.mjs +3 -0
  28. package/src/memory/local-memory-store.mjs +73 -16
  29. package/src/profile/listener-corrections.mjs +22 -0
  30. package/src/profile/listener-profile-build.mjs +92 -9
  31. package/src/profile/listener-profile-worker.mjs +7 -0
  32. package/src/profile/listening-history-store.mjs +231 -24
  33. package/src/profile/listening-profile-projection.mjs +212 -10
  34. package/src/profile/moondog-suggestions.mjs +184 -0
  35. package/src/profile/profile-exploration.mjs +16 -8
  36. package/src/runtime/pi/agent-runtime.mjs +363 -118
  37. package/src/runtime/pi/profile-build-runtime.mjs +2 -1
  38. package/src/runtime/pi/skills/listener-profile/SKILL.md +2 -0
  39. package/src/surfaces/cli/web-command.mjs +1 -1
@@ -2,6 +2,9 @@ import { randomUUID } from "node:crypto";
2
2
  import { hostname } from "node:os";
3
3
  import { setTimeout as delay } from "node:timers/promises";
4
4
  import { compactListenerProfile, readListenerProfile } from "../profile/listener-profile-build.mjs";
5
+ import { mixCounts, songKeys } from "../profile/moondog-suggestions.mjs";
6
+ import { ARTIST_CREDIT_SEPARATORS } from "./listener-avoids.mjs";
7
+ import { textNamesSong } from "./track-title.mjs";
5
8
 
6
9
  import {
7
10
  readAppleMusicSourceStatus,
@@ -35,7 +38,7 @@ const spotifyPlaybackCapabilities = new Set([
35
38
  const neteaseCapabilities = new Set(["netease.search", "netease.play", "netease.control", "netease.now_playing"]);
36
39
 
37
40
  // What the model sees of a retained Spotify item: no provider identifiers.
38
- function modelSpotifyItem({ uri: _uri, album_uri: _albumUri, ...item }) {
41
+ function modelSpotifyItem({ uri: _uri, album_uri: _albumUri, isrc: _isrc, ...item }) {
39
42
  return structuredClone(item);
40
43
  }
41
44
 
@@ -53,6 +56,23 @@ function spotifyResolutionError(code, message) {
53
56
  return error;
54
57
  }
55
58
 
59
+ // A ledger entry's artists: its own list, or its credit whole and split into its artists.
60
+ const creditedArtists = (track) => Array.isArray(track.artists) && track.artists.length ? track.artists
61
+ : typeof track.artistCredit === "string" ? [track.artistCredit, ...track.artistCredit.split(ARTIST_CREDIT_SEPARATORS)] : [];
62
+
63
+ // The trusted songs of a pending plan that are still in it.
64
+ const keptTrusted = (draft, kept) => {
65
+ const refs = new Set(kept.map((track) => track.track_ref_id));
66
+ return draft.trustedTracks.filter((track) => refs.has(track.track_ref_id));
67
+ };
68
+
69
+ // The plan songs an Avoid left out of a play, queue or save, by name.
70
+ const skippedAvoided = (avoided) => avoided.length ? { skipped_avoided: avoided, skipped_avoided_count: avoided.length } : {};
71
+
72
+ // A play the listener's Avoid blocks: nothing is sent, and the model can say why.
73
+ const listenerAvoidError = () => spotifyResolutionError("listener_avoid_blocked",
74
+ "Nothing was sent. The listener asked to keep this artist or song out of what Moondog plays. Tell them so; they can lift it by asking.");
75
+
56
76
  // Only the host's current user message can grant a quick edit. Model tool
57
77
  // arguments and Spotify metadata never supply authorization. Exact ordinary
58
78
  // commands and quoted names work; ambiguity uses the existing preview path.
@@ -319,10 +339,15 @@ export class MoondogApplication {
319
339
  }
320
340
 
321
341
  async getListenerProfile(input) {
322
- if (!this.profileBuildReady()) return { state: "missing", total_claims: 0, claims: [], next_offset: null };
342
+ const getChoices = () => this.domainServices?.listenerChoices?.() ?? null;
343
+ if (!this.profileBuildReady()) {
344
+ const choices = getChoices();
345
+ return { state: "missing", total_claims: 0, claims: [], next_offset: null, ...(choices ? { listener_choices: choices } : {}) };
346
+ }
323
347
  return readListenerProfile({
324
348
  store: this.domainServices.getListenerProfileStore(),
325
349
  getInput: () => this.getProfileBuildContext().input,
350
+ getChoices,
326
351
  }, input);
327
352
  }
328
353
 
@@ -498,14 +523,29 @@ export class MoondogApplication {
498
523
  return this.neteasePlayer;
499
524
  }
500
525
 
501
- searchNetease({ query, limit, intent } = {}) {
502
- return this.requireNetease().search({ query, limit, intent });
526
+ async searchNetease({ query, limit, intent } = {}) {
527
+ const songs = await this.requireNetease().search({ query, limit, intent });
528
+ return Array.isArray(songs) ? this.#withSuggestedBefore(songs, song => ({ service: "netease",
529
+ providerTrackId: song.id ? `netease:${song.id}` : null, title: song.title, artists: song.artists })) : songs;
503
530
  }
504
531
 
505
532
  async playNetease({ items, mode, intent } = {}) {
506
533
  const player = this.requireNetease();
534
+ this.requireListenerAvoids();
507
535
  if (mode !== "queue") await this.quietOtherPlayers("netease");
508
- return player.play({ items, mode, intent });
536
+ // A found song lists its artists; a song asked for by name has only the name it was asked by.
537
+ const receipt = await player.play({ items, mode, intent, allow: (song) => this.avoidAllows(Array.isArray(song.artists)
538
+ ? { title: song.title, artist_credit: song.artists.join(", "), artists: song.artists } : song) });
539
+ if (["playing", "queued"].includes(receipt?.mode)) {
540
+ const songs = receipt.songs ?? [];
541
+ const slots = this.#mixSlots(songs.map((song) => ({ title: song.title, artists: song.artists })));
542
+ this.#noteSuggestions("netease", songs.map((song, index) => ({ action: receipt.mode === "queued" ? "netease.queue" : "netease.play",
543
+ providerTrackId: song.id ? `netease:${song.id}` : null, title: song.title, artists: song.artists, mixSlot: slots[index] })));
544
+ // One song needs no mix line.
545
+ const mix = songs.length > 1 ? mixCounts(slots) : null;
546
+ if (mix) return { ...receipt, mix };
547
+ }
548
+ return receipt;
509
549
  }
510
550
 
511
551
  async controlNetease({ action, positionSeconds } = {}) {
@@ -642,29 +682,45 @@ export class MoondogApplication {
642
682
  throw new Error("Give either up to six songs or one album.");
643
683
  }
644
684
  let found;
685
+ let avoided = 0;
645
686
  if (album) {
646
687
  const result = await this.musicCatalog.findAlbumPreviews({ title: album.title, artist: album.artist });
647
- found = { kind: "album", album: result.album, played: result.tracks ?? [], not_found: result.state === "resolved" ? [] : [album] };
688
+ const songs = result.tracks ?? [];
689
+ const played = songs.filter((track) => this.avoidAllows(track));
690
+ avoided = songs.length - played.length;
691
+ found = { kind: "album", album: result.album, played, not_found: result.state === "resolved" ? [] : [album] };
648
692
  } else {
649
693
  const played = [];
650
694
  const notFound = [];
651
695
  const leftOut = [];
652
696
  for (const wanted of tracks.slice(0, 6)) {
653
697
  if (byExcluded(wanted.artist)) { leftOut.push(wanted); continue; }
698
+ if (!this.avoidAllows({ title: wanted.title, artist_credit: wanted.artist })) { avoided += 1; continue; }
654
699
  const result = await this.musicCatalog.findPreview({ title: wanted.title, artist: wanted.artist });
655
700
  if (result.state !== "resolved") notFound.push(wanted);
656
701
  else if (byExcluded(result.track.artist_credit)) leftOut.push(wanted);
702
+ else if (!this.avoidAllows(result.track)) avoided += 1;
657
703
  else played.push(result.track);
658
704
  }
659
705
  found = { kind: "tracks", played, not_found: notFound, left_out: leftOut };
660
706
  }
707
+ if (avoided) found.skipped_avoided_count = avoided;
661
708
  if (found.played.length) {
662
709
  await this.quietOtherPlayers("apple_music");
663
710
  await this.musicPreviewPlayer.play(structuredClone(found.played), found.album ? { album: structuredClone(found.album) } : {});
664
711
  }
665
712
  // The desktop window plays whole songs where the listener's Apple Music allows it; a terminal plays previews.
666
713
  const whole = this.musicPreviewPlayer.wholeSongs?.() === true;
667
- return { state: found.played.length ? "resolved" : "not_found", ...found, whole_songs: whole };
714
+ const slots = this.#mixSlots(found.played.map((track) => ({ title: track.title, artist_credit: track.artist_credit })));
715
+ // An album the listener named is theirs, every song of it.
716
+ const albumArtists = [found.album?.artist_credit, album?.artist].filter((name) => typeof name === "string");
717
+ if (!(found.album && (this.#listenerNamed(found.album.title, albumArtists) || this.#listenerNamed(album.title, albumArtists)))) {
718
+ this.#noteSuggestions("apple_music", found.played.map((track, index) => ({ action: whole ? "apple_music.play" : "apple_music.preview",
719
+ providerTrackId: track.catalog_id ? `apple_music:${track.catalog_id}` : null, isrc: track.isrc, title: track.title,
720
+ artistCredit: track.artist_credit, mixSlot: slots[index] })));
721
+ }
722
+ const mix = found.played.length > 1 ? mixCounts(slots) : null;
723
+ return { state: found.played.length ? "resolved" : "not_found", ...found, ...(mix ? { mix } : {}), whole_songs: whole };
668
724
  }
669
725
 
670
726
  musicDiscoveryReady() {
@@ -737,7 +793,7 @@ export class MoondogApplication {
737
793
  ...(stored ? { stored_plays: stored } : {}),
738
794
  ...(spotify ? { spotify_top_artists_4_weeks: {
739
795
  read_at: spotify.read_at, artists: spotify.artists,
740
- basis: "Spotify's affinity ranking over about four weeks, on every device; not play counts.",
796
+ basis: "Spotify's affinity ranking over about four weeks, on every device; not play counts. It also counts songs Moondog played or queued, which stored_plays.moondog_started_plays counts apart.",
741
797
  } } : {}),
742
798
  };
743
799
  }
@@ -1500,6 +1556,7 @@ export class MoondogApplication {
1500
1556
  typeof item.album_uri === "string" && /^spotify:album:[A-Za-z0-9]{1,128}$/u.test(item.album_uri) ? item.album_uri :
1501
1557
  this.spotifyKnownItems.get(result.item_ref_id)?.album_uri;
1502
1558
  if (albumUri) result.album_uri = albumUri;
1559
+ if (typeof item.isrc === "string" && /^[A-Z]{2}[A-Z0-9]{3}[0-9]{7}$/iu.test(item.isrc)) result.isrc = item.isrc.toUpperCase();
1503
1560
  this.spotifyRefsByUri.set(item.uri, result.item_ref_id);
1504
1561
  // The newest read of an item wins; the oldest items leave first past the bound.
1505
1562
  this.spotifyKnownItems.delete(result.item_ref_id);
@@ -1569,7 +1626,7 @@ export class MoondogApplication {
1569
1626
  return albumUri ? { contextUri: albumUri, offsetUri: uri } : { uris: [uri] };
1570
1627
  }
1571
1628
 
1572
- #resumeSpotifySelection(parameters, { signal } = {}) {
1629
+ #resumeSpotifySelection(parameters, { signal, played = null } = {}) {
1573
1630
  const transaction = this.pendingPlaylistPromptTransaction;
1574
1631
  // An album started at one song plays that song: it counts as the same selection as
1575
1632
  // the song alone, and its target names the song, not the album.
@@ -1587,10 +1644,20 @@ export class MoondogApplication {
1587
1644
  return Promise.resolve({ ...receipt, already: true });
1588
1645
  }
1589
1646
  const items = (songs ?? (context ? [context] : [])).map(uri => this.spotifyHostReadItems().find(item => item.uri === uri)).filter(Boolean).map(item => structuredClone(item));
1647
+ if (!items.every(item => this.#spotifyItemAllowed(item))) throw listenerAvoidError();
1590
1648
  const attempt = parameters.uris || parameters.contextUri ? { parameters: structuredClone(parameters), items,
1591
1649
  observedAt: this.spotifyContextClock(), turn: this.spotifyContextTurn, epoch: this.spotifyContextEpoch, outcome: "unconfirmed" } : null;
1592
1650
  if (attempt) this.spotifyPlaybackAttempt = attempt;
1593
1651
  const targets = items.map(item => ({ title: item.name ?? "Spotify item", artist_credit: item.artists.join(", ") }));
1652
+ // The songs this selection starts. An album started whole or at one song goes on playing,
1653
+ // so it goes in the ledger by its release. A playlist or an artist names no song until it plays.
1654
+ const album = parameters.contextUri?.startsWith("spotify:album:")
1655
+ ? parameters.offsetUri ? items.find(item => item.uri === parameters.offsetUri && item.type === "track" && item.album)
1656
+ : items.find(item => item.uri === parameters.contextUri && item.type === "album")
1657
+ : null;
1658
+ played ??= [...(songs ?? []).flatMap(uri => items.filter(item => item.uri === uri && item.type === "track").slice(0, 1)
1659
+ .map(item => ({ providerTrackId: uri, isrc: item.isrc, title: item.name, artists: item.artists }))),
1660
+ ...(album ? [{ action: "play.album", release: album.type === "album" ? album.name : album.album, artists: album.artists }] : [])];
1594
1661
  const explicitDevice = parameters.deviceId !== undefined;
1595
1662
  return this.requireSpotifyService().resume(parameters, { signal,
1596
1663
  preferredDeviceId: this.spotifyPreferredPlaybackDevice,
@@ -1604,6 +1671,7 @@ export class MoondogApplication {
1604
1671
  },
1605
1672
  }).then(receipt => {
1606
1673
  const result = { ...receipt, ...(targets.length ? { targets } : {}) };
1674
+ if (receipt.state === "accepted") this.#noteSuggestions("spotify", played.map(track => ({ action: "play", ...track })));
1607
1675
  if (key && transaction && receipt.state === "accepted") {
1608
1676
  transaction.lastAcceptedPlaybackSelection = key;
1609
1677
  transaction.lastAcceptedPlaybackReceipt = { ...structuredClone(result), deviceId: attempt?.parameters.deviceId };
@@ -1710,9 +1778,56 @@ export class MoondogApplication {
1710
1778
  }
1711
1779
 
1712
1780
  spotifyDiscoveryAllowed(item) {
1713
- if (!this.domainServices?.filterDiscoveryTracks && this.domainServicesError && !["projection_not_built", "projection_subject_unavailable"].includes(this.domainServicesError)) throw spotifyResolutionError("spotify_discovery_preferences_unavailable", "The listener's preferences could not be loaded. Restore the profile before personalized queueing.");
1714
- const credits = [...new Set([item.artists.join(", "), ...item.artists])];
1715
- return credits.every(artist_credit => this.domainServices?.filterDiscoveryTracks?.([{ title: item.name, artist_credit }]).length !== 0);
1781
+ return this.avoidAllows({ title: item.name, artist_credit: item.artists.join(", "), artists: item.artists });
1782
+ }
1783
+
1784
+ // A song by its title and artists, an album or an artist by its artists. A playlist, a show or an
1785
+ // episode names no artist the listener avoids.
1786
+ #spotifyItemAllowed(item) {
1787
+ const artists = Array.isArray(item?.artists) ? item.artists : [];
1788
+ if (item?.type === "track") return this.spotifyDiscoveryAllowed({ name: item.name, artists });
1789
+ if (item?.type === "album") return this.avoidAllows({ artist_credit: artists.join(", "), artists });
1790
+ if (item?.type === "artist") return this.avoidAllows({ artist_credit: item.name, artists: [item.name] });
1791
+ return true;
1792
+ }
1793
+
1794
+ // A pending plan's songs, checked again when it plays, queues or is saved, and read as the
1795
+ // candidate gate read them: a history candidate names its first credited artist whole, so
1796
+ // "Earth, Wind & Fire" stays one artist. A song avoided since the plan is left out and named
1797
+ // in the receipt; only a plan with nothing left is refused.
1798
+ #withoutAvoidedPlanTracks(tracks) {
1799
+ const avoided = tracks.filter((track) => {
1800
+ if (!track) return false;
1801
+ const artists = Array.isArray(track.artists) ? track.artists
1802
+ : track.candidate_scope === "private_history" ? [track.artist_credit] : null;
1803
+ return !this.avoidAllows({ title: track.title, artist_credit: track.artist_credit, ...(artists ? { artists } : {}) });
1804
+ });
1805
+ if (avoided.length && avoided.length === tracks.length) throw listenerAvoidError();
1806
+ return { kept: tracks.filter((track) => !avoided.includes(track)),
1807
+ avoided: avoided.map((track) => ({ title: track.title, artist_credit: track.artist_credit })) };
1808
+ }
1809
+
1810
+ // Avoids that exist but could not be loaded stop every play path, rather than letting them through.
1811
+ requireListenerAvoids() {
1812
+ if (!this.domainServices?.filterDiscoveryTracks && this.domainServicesError && !["projection_not_built", "projection_subject_unavailable"].includes(this.domainServicesError)) throw spotifyResolutionError("listener_avoids_unavailable", "The listener's Avoids could not be loaded, so nothing was played. Restore the profile first.");
1813
+ }
1814
+
1815
+ // The listener's Avoids, checked the same way on every play path: { title, artist_credit, artists }.
1816
+ avoidAllows(track) {
1817
+ this.requireListenerAvoids();
1818
+ return this.domainServices?.filterDiscoveryTracks?.([track]).length !== 0;
1819
+ }
1820
+
1821
+ // A Like, an Avoid or its retraction that the listener stated, saved as the profile command
1822
+ // saves it, so the profile, the Avoid check and the saved reading all see it at once.
1823
+ recordTasteChoice({ entityType, label, artistCredit, stance } = {}) {
1824
+ if (!this.tasteChoicesReady()) throw new Error("Choices can be saved once listening history is imported.");
1825
+ return this.domainServices.recordListenerChoice({ entityType, label, artistCredit, stance });
1826
+ }
1827
+
1828
+ tasteChoicesReady() {
1829
+ return typeof this.domainServices?.recordListenerChoice === "function" &&
1830
+ this.domainServices.listenerChoicesReady?.() !== false;
1716
1831
  }
1717
1832
 
1718
1833
  consumeSpotifyDiscoveryQueries(count) {
@@ -1761,7 +1876,8 @@ export class MoondogApplication {
1761
1876
  truncated ||= result.truncated === true || result.has_more === true;
1762
1877
  }
1763
1878
  const items = this.spotifyReadSelections.get("discovery") ?? [];
1764
- return { provider: "spotify", items: items.map(modelSpotifyItem),
1879
+ return { provider: "spotify", items: this.#withSuggestedBefore(items, item => ({ providerTrackId: item.uri, isrc: item.isrc, title: item.name, artists: item.artists }))
1880
+ .map(modelSpotifyItem),
1765
1881
  queries_used: used + queries.length, queries_remaining: 6 - used - queries.length,
1766
1882
  state: items.length ? "verified_candidates" : failures.length ? "unavailable" : "no_matches",
1767
1883
  failures, skipped_avoided_count: skippedAvoids, skipped_known_count: skippedKnown, truncated,
@@ -1803,11 +1919,11 @@ export class MoondogApplication {
1803
1919
  throw error;
1804
1920
  }
1805
1921
  const seenUris = this.#observedQueueUris(observed, player);
1806
- const tracks = items.map(item => ({ track_ref_id: item.item_ref_id, title: item.name, artist_credit: item.artists.join(", "), artists: item.artists }));
1922
+ const tracks = items.map(item => ({ track_ref_id: item.item_ref_id, title: item.name, artist_credit: item.artists.join(", "), artists: item.artists, ...(item.isrc ? { isrc: item.isrc } : {}) }));
1807
1923
  const resolved = new Map(items.map(item => [item.item_ref_id, item.uri]));
1808
1924
  this.transientSpotifyContext = true;
1809
1925
  const filters = { filterDiscoveryTracks: tracks => tracks.filter(track => this.spotifyDiscoveryAllowed({ name: track.title, artists: track.artists })) };
1810
- const receipt = await this.#queueSpotifyTracks(tracks, resolved, { signal, deviceId, seenUris, limit: requested, domainServices: filters,
1926
+ const receipt = await this.#queueSpotifyTracks(tracks, resolved, { signal, deviceId, seenUris, limit: requested, domainServices: filters, ledgerAction: "queue.batch",
1811
1927
  knownTrack: intent?.excludeKnown ? track => this.domainServices?.isKnownDiscoveryTrack?.(track) === true : null });
1812
1928
  if (!receipt.queued.length && !receipt.stopped) transaction.queueBatchAttempted = false;
1813
1929
  const result = { ...receipt, action: "queue.batch", requested, queued_count: receipt.queued.length,
@@ -1833,6 +1949,57 @@ export class MoondogApplication {
1833
1949
  return seen;
1834
1950
  }
1835
1951
 
1952
+ // What Moondog itself started goes in the listening history's ledger, written from accepted
1953
+ // host receipts only, so its picks never come back as the listener's own plays. A ledger
1954
+ // that cannot be written never undoes the playback. On every path, song by song, a song or
1955
+ // album the listener named in this turn or the two before it is their own choice and stays
1956
+ // out of the ledger, and the songs Moondog added around it stay Moondog's.
1957
+ #noteSuggestions(service, tracks) {
1958
+ tracks = this.#withoutListenerNamed(tracks);
1959
+ if (!tracks.length || typeof this.domainServices?.recordMoondogSuggestions !== "function") return;
1960
+ const requestedAt = new Date(this.spotifyContextClock()).toISOString();
1961
+ const entries = tracks.slice(0, 50);
1962
+ const slots = entries.some((track) => track.mixSlot === undefined)
1963
+ ? this.#mixSlots(entries.map((track) => ({ title: track.title, artists: track.artists, artist_credit: track.artistCredit })))
1964
+ : [];
1965
+ try {
1966
+ this.domainServices.recordMoondogSuggestions(entries.map((track, index) => ({ service, requestedAt, mixSlot: slots[index], ...track })));
1967
+ } catch { /* The music still plays; only its origin goes unrecorded. */ }
1968
+ }
1969
+
1970
+ // An album row goes with the song it started at: named song or named album, it is the listener's.
1971
+ #withoutListenerNamed(tracks) {
1972
+ const named = (track) => this.#listenerNamed(track.title ?? track.release, creditedArtists(track));
1973
+ const songs = tracks.filter((track) => track.title);
1974
+ if (songs.some(named)) return songs.filter((track) => !named(track));
1975
+ return tracks.filter((track) => track.title || !named(track));
1976
+ }
1977
+
1978
+ // Named means one message names the title with one of its artists, or quotes the title.
1979
+ #listenerNamed(title, artists) {
1980
+ return typeof title === "string" &&
1981
+ (this.pendingPlaylistPromptTransaction?.listenerTexts ?? []).some((text) => textNamesSong(text, title, artists));
1982
+ }
1983
+
1984
+ // Which candidates Moondog suggested in earlier sessions, so the model can vary its picks.
1985
+ #withSuggestedBefore(items, track) {
1986
+ let history = null;
1987
+ try {
1988
+ history = items.length ? this.domainServices?.moondogSuggestionHistory?.(items.map(track)) : null;
1989
+ } catch { /* Without the ledger, candidates carry no history. */ }
1990
+ return items.map((item, index) => history?.[index] ? { ...item, suggested_before: history[index] } : item);
1991
+ }
1992
+
1993
+ // Where each song sits in the listener's own plays; unknown without a history.
1994
+ #mixSlots(tracks) {
1995
+ try {
1996
+ const slots = tracks.length ? this.domainServices?.listeningMix?.(tracks) : null;
1997
+ return Array.isArray(slots) && slots.length === tracks.length ? slots : tracks.map(() => "unknown");
1998
+ } catch {
1999
+ return tracks.map(() => "unknown");
2000
+ }
2001
+ }
2002
+
1836
2003
  // Spotify's own queue is the truth. An accepted write can take a moment to
1837
2004
  // appear, so look twice before calling an entry unconfirmed.
1838
2005
  async #verifyQueued(uris, { signal } = {}) {
@@ -1893,7 +2060,8 @@ export class MoondogApplication {
1893
2060
  if (queueCancellationRequested(this.pendingPlaylistPromptTransaction?.userText)) throw spotifyResolutionError("spotify_queue_cancelled", "The listener asked to stop queueing. No write was sent.");
1894
2061
  if (this.pendingPlaylistPromptTransaction?.queueIntent && this.spotifyQueueRequest) this.spotifyQueueRequest.attempted = true;
1895
2062
  const item = this.spotifyHostReadItems().find(item => item.uri === uri);
1896
- const track = { track_ref_id: "single", title: item?.name ?? "the selected track", artist_credit: item?.artists?.join(", ") ?? "" };
2063
+ if (item && !this.#spotifyItemAllowed(item)) throw listenerAvoidError();
2064
+ 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 } : {}) };
1897
2065
  // One entry takes the same path as a batch: one device decision, start
1898
2066
  // playback when nothing is playing, and a check of Spotify's real queue.
1899
2067
  const receipt = await this.#queueSpotifyTracks([track], new Map([["single", uri]]), { signal, deviceId: input.deviceId, rethrow: true });
@@ -1949,7 +2117,7 @@ export class MoondogApplication {
1949
2117
  const resolvedByRef = new Map((resolution.resolutions ?? [])
1950
2118
  .filter((entry) => entry.status === "resolved" && /^spotify:track:[A-Za-z0-9]{1,128}$/u.test(entry.spotify?.uri ?? ""))
1951
2119
  .map((entry) => [entry.track_ref_id, entry.spotify.uri]));
1952
- const receipt = await this.#queueSpotifyTracks(tracks, resolvedByRef, { signal, seenUris, limit: count, domainServices });
2120
+ const receipt = await this.#queueSpotifyTracks(tracks, resolvedByRef, { signal, seenUris, limit: count, domainServices, ledgerAction: "queue.similar" });
1953
2121
  this.recordPromptDiscoverySource(registered.source);
1954
2122
  const result = { ...receipt, action: "queue.similar", requested: count, queued_count: receipt.queued.length,
1955
2123
  shortfall: count - receipt.queued.length, seed_artist: seedArtist, queue_observation_truncated: observed.truncated === true };
@@ -2731,7 +2899,8 @@ export class MoondogApplication {
2731
2899
  const details = spotifyPlaylistDetails({ name, description });
2732
2900
  this.requireSpotifyWriteScopes(["playlist-modify-private"]);
2733
2901
  const uris = this.requireSpotifyTrackUris(trackRefs);
2734
- return this.writeSpotifyPlaylist(details, uris, { signal });
2902
+ const planned = new Map((this.pendingSpotifyPlaylist?.plan?.tracks ?? []).map((track) => [track.track_ref_id, track]));
2903
+ return this.writeSpotifyPlaylist(details, uris, { signal, tracks: trackRefs.map((ref) => planned.get(ref) ?? null) });
2735
2904
  }
2736
2905
 
2737
2906
  pendingSpotifyPlaylistStatus() {
@@ -2786,8 +2955,9 @@ export class MoondogApplication {
2786
2955
  this.requireSpotifyScopes(["user-read-private"]);
2787
2956
  this.requireSpotifyWriteScopes(["playlist-modify-private"]);
2788
2957
  const draft = this.pendingSpotifyPlaylist;
2958
+ const { kept, avoided } = this.#withoutAvoidedPlanTracks(draft.plan.tracks);
2789
2959
  const result = await this.requireSpotifyResolver().resolve(
2790
- draft.trustedTracks,
2960
+ keptTrusted(draft, kept),
2791
2961
  { signal },
2792
2962
  );
2793
2963
  signal?.throwIfAborted();
@@ -2800,7 +2970,7 @@ export class MoondogApplication {
2800
2970
  );
2801
2971
  }
2802
2972
  }
2803
- const uris = draft.plan.tracks.map((track) =>
2973
+ const uris = kept.map((track) =>
2804
2974
  resolvedByRef.get(track.track_ref_id),
2805
2975
  );
2806
2976
  if (uris.some((uri) => typeof uri !== "string")) {
@@ -2809,7 +2979,7 @@ export class MoondogApplication {
2809
2979
  "The pending playlist could not be fully resolved on Spotify, so no playlist was created.",
2810
2980
  );
2811
2981
  }
2812
- return this.writeSpotifyPlaylist(details, uris, { signal });
2982
+ return this.writeSpotifyPlaylist(details, uris, { signal, tracks: kept, avoided });
2813
2983
  }
2814
2984
 
2815
2985
  async spotifyPlayPendingPlan({ deviceId } = {}, { signal } = {}) {
@@ -2824,14 +2994,15 @@ export class MoondogApplication {
2824
2994
  }
2825
2995
  this.requireSpotifyScopes(["user-read-private"]);
2826
2996
  const draft = this.pendingSpotifyPlaylist;
2827
- const result = await this.requireSpotifyResolver().resolve(draft.trustedTracks, { signal });
2997
+ const { kept, avoided } = this.#withoutAvoidedPlanTracks(draft.plan.tracks);
2998
+ const result = await this.requireSpotifyResolver().resolve(keptTrusted(draft, kept), { signal });
2828
2999
  signal?.throwIfAborted();
2829
3000
  const resolvedByRef = new Map(
2830
3001
  (result.resolutions ?? [])
2831
3002
  .filter((resolution) => resolution.status === "resolved" && resolution.spotify?.uri)
2832
3003
  .map((resolution) => [resolution.track_ref_id, resolution.spotify.uri]),
2833
3004
  );
2834
- const uris = draft.plan.tracks.map((track) => resolvedByRef.get(track.track_ref_id));
3005
+ const uris = kept.map((track) => resolvedByRef.get(track.track_ref_id));
2835
3006
  if (uris.some((uri) => typeof uri !== "string")) {
2836
3007
  throw spotifyResolutionError(
2837
3008
  "spotify_pending_playback_unresolved",
@@ -2841,8 +3012,10 @@ export class MoondogApplication {
2841
3012
  const receipt = await this.#resumeSpotifySelection({
2842
3013
  uris,
2843
3014
  ...(deviceId ? { deviceId } : {}),
2844
- }, { signal });
2845
- return { ...receipt, track_count: uris.length };
3015
+ }, { signal, played: kept.map((track, index) => ({
3016
+ providerTrackId: uris[index], title: track.title, artistCredit: track.artist_credit,
3017
+ })) });
3018
+ return { ...receipt, track_count: uris.length, ...skippedAvoided(avoided) };
2846
3019
  }
2847
3020
 
2848
3021
  async spotifyQueuePendingPlan({ deviceId } = {}, { signal } = {}) {
@@ -2856,8 +3029,9 @@ export class MoondogApplication {
2856
3029
  }
2857
3030
  this.requireSpotifyScopes(["user-read-private"]);
2858
3031
  const draft = this.pendingSpotifyPlaylist;
3032
+ const { kept, avoided } = this.#withoutAvoidedPlanTracks(draft.plan.tracks);
2859
3033
  const result = await this.requireSpotifyResolver().resolve(
2860
- draft.trustedTracks,
3034
+ keptTrusted(draft, kept),
2861
3035
  { signal },
2862
3036
  );
2863
3037
  signal?.throwIfAborted();
@@ -2867,15 +3041,19 @@ export class MoondogApplication {
2867
3041
  resolvedByRef.set(resolution.track_ref_id, resolution.spotify.uri);
2868
3042
  }
2869
3043
  }
2870
- const receipt = await this.#queueSpotifyTracks(draft.plan.tracks, resolvedByRef, { signal, deviceId });
3044
+ const receipt = await this.#queueSpotifyTracks(kept, resolvedByRef, { signal, deviceId, ledgerAction: "queue.plan" });
2871
3045
  if (receipt.state === "no_candidates") {
2872
3046
  throw spotifyResolutionError("spotify_pending_queue_unmatched", "None of the pending tracks matched a Spotify track, so nothing was queued.");
2873
3047
  }
2874
- return receipt;
3048
+ return avoided.length ? { ...receipt, skipped_avoided: avoided, skipped_avoided_count: receipt.skipped_avoided_count + avoided.length } : receipt;
2875
3049
  }
2876
3050
 
2877
- async #queueSpotifyTracks(tracks, resolvedByRef, { signal, deviceId, seenUris = null, limit = tracks.length, domainServices = null, knownTrack = null, rethrow = false } = {}) {
3051
+ async #queueSpotifyTracks(tracks, resolvedByRef, { signal, deviceId, seenUris = null, limit = tracks.length, domainServices = null, knownTrack = null, rethrow = false, ledgerAction = "queue.add" } = {}) {
2878
3052
  const label = (track) => ({ title: track.title, artist_credit: track.artist_credit });
3053
+ // A remaster or a second copy of the same song is a duplicate too, not only the same URI.
3054
+ const seenSongs = new Set();
3055
+ const sameSong = (track) => [...songKeys(track), track.isrc ? `isrc:${track.isrc}` : null].filter(Boolean);
3056
+ const accepted = [];
2879
3057
  const queued = [];
2880
3058
  const unmatched = [];
2881
3059
  let skippedDuplicates = 0;
@@ -2891,13 +3069,14 @@ export class MoondogApplication {
2891
3069
  let outcomeUnknown = false;
2892
3070
  let target = null;
2893
3071
  let started = null;
3072
+ let startedUri = null;
2894
3073
  const acceptedUris = [];
2895
3074
  for (const [index, track] of tracks.entries()) {
2896
3075
  if (queued.length >= limit) break;
2897
3076
  if (signal?.aborted) { stopped = label(track); stopIndex = index; break; }
2898
3077
  const uri = resolvedByRef.get(track.track_ref_id);
2899
3078
  if (typeof uri !== "string") { unmatched.push(label(track)); continue; }
2900
- if (seenUris?.has(uri)) { skippedDuplicates++; continue; }
3079
+ if (seenUris?.has(uri) || sameSong(track).some(key => seenSongs.has(key))) { skippedDuplicates++; continue; }
2901
3080
  if (this.recentUncertainQueueUris.has(uri)) { skippedUncertain++; continue; }
2902
3081
  let dispatching = false;
2903
3082
  try {
@@ -2911,6 +3090,7 @@ export class MoondogApplication {
2911
3090
  dispatching = true;
2912
3091
  const receipt = await service.resume({ uris: [uri], ...device }, { signal });
2913
3092
  started = label(track);
3093
+ startedUri = uri;
2914
3094
  target = { ...target, active: true, ...(receipt?.device ? { device: receipt.device } : {}) };
2915
3095
  };
2916
3096
  if (target.active === false) await start();
@@ -2930,6 +3110,8 @@ export class MoondogApplication {
2930
3110
  }
2931
3111
  acceptedUris.push(uri);
2932
3112
  queued.push(label(track));
3113
+ accepted.push({ track, uri });
3114
+ for (const key of sameSong(track)) seenSongs.add(key);
2933
3115
  if (this.pendingPlaylistPromptTransaction) this.pendingPlaylistPromptTransaction.queueWriteCount = (this.pendingPlaylistPromptTransaction.queueWriteCount ?? 0) + 1;
2934
3116
  seenUris?.add(uri);
2935
3117
  if (seenUris) {
@@ -2958,9 +3140,16 @@ export class MoondogApplication {
2958
3140
  if (queued.length === 0 && !outcomeUnknown) signal?.throwIfAborted();
2959
3141
  const state = stopped ? (queued.length ? "partial" : outcomeUnknown ? "unknown" : "failed")
2960
3142
  : queued.length ? "accepted" : "no_candidates";
3143
+ // Each song is placed in the listener's own history before it lands in the ledger.
3144
+ const slots = this.#mixSlots(accepted.map(({ track }) => track));
3145
+ this.#noteSuggestions("spotify", accepted.map(({ track, uri }, index) => ({
3146
+ action: uri === startedUri ? "play" : ledgerAction, providerTrackId: uri, isrc: track.isrc,
3147
+ title: track.title, artists: track.artists, artistCredit: track.artist_credit, mixSlot: slots[index],
3148
+ })));
3149
+ const mix = mixCounts(slots);
2961
3150
  const verification = acceptedUris.length && !signal?.aborted ? await this.#verifyQueued(acceptedUris, { signal }) : null;
2962
3151
  return { provider: "spotify", ok: !["unknown", "failed"].includes(state), effect: "write_external", action: "playback.queue.add",
2963
- state, queued, unmatched, not_added: stopIndex >= 0 ? tracks.slice(stopIndex + 1, stopIndex + Math.max(1, limit - queued.length)).map(label) : [],
3152
+ state, queued, ...(mix ? { mix } : {}), unmatched, not_added: stopIndex >= 0 ? tracks.slice(stopIndex + 1, stopIndex + Math.max(1, limit - queued.length)).map(label) : [],
2964
3153
  ...(target?.device ? { device: target.device } : {}), ...(started ? { started_playback: started } : {}),
2965
3154
  ...(verification ? { verification } : {}),
2966
3155
  ...(stopIndex >= 0 ? { not_added_count: Math.min(tracks.length - stopIndex - 1, Math.max(0, limit - queued.length - 1)) } : {}),
@@ -2972,9 +3161,14 @@ export class MoondogApplication {
2972
3161
  ...(signal?.aborted ? { cancelled: true } : {}) };
2973
3162
  }
2974
3163
 
2975
- async writeSpotifyPlaylist(details, uris, { signal } = {}) {
3164
+ async writeSpotifyPlaylist(details, uris, { signal, tracks = [], avoided: leftOut = [] } = {}) {
2976
3165
  signal?.throwIfAborted();
2977
3166
  this.requireSpotifyPlaylistCreationFlow();
3167
+ const { kept, avoided } = this.#withoutAvoidedPlanTracks(tracks);
3168
+ if (avoided.length) {
3169
+ uris = uris.filter((_uri, index) => kept.includes(tracks[index]));
3170
+ tracks = kept;
3171
+ }
2978
3172
  if (this.pendingPlaylistPromptTransaction) this.pendingPlaylistPromptTransaction.playlistWriteAttempted = true;
2979
3173
  this.invalidateSpotifyQuickEditContext();
2980
3174
  try {
@@ -2982,12 +3176,14 @@ export class MoondogApplication {
2982
3176
  ...details,
2983
3177
  uris,
2984
3178
  }, { signal });
3179
+ this.#noteSuggestions("spotify", tracks.flatMap((track, index) => track ? [{ action: "playlist.write",
3180
+ providerTrackId: uris[index], title: track.title, artistCredit: track.artist_credit }] : []));
2985
3181
  if (this.pendingPlaylistPromptTransaction) {
2986
3182
  this.pendingPlaylistPromptTransaction.externalized = true;
2987
3183
  }
2988
3184
  this.pendingSpotifyPlaylist = null;
2989
3185
  this.pendingPlaylistRevisionCandidateSet = null;
2990
- return receipt;
3186
+ return { ...receipt, ...skippedAvoided([...leftOut, ...avoided]) };
2991
3187
  } catch (error) {
2992
3188
  if (error?.outcomeUnknown === true || ["playlist_created_without_tracks", "playlist_created_tracks_unknown"].includes(error?.code)) {
2993
3189
  if (this.pendingPlaylistPromptTransaction) {
@@ -3080,6 +3276,7 @@ export class MoondogApplication {
3080
3276
  musicSimilarityReady: this.musicSimilarityReady(),
3081
3277
  ...this.webLookupOptions(),
3082
3278
  musicPreviewReady: this.musicPreviewReady(),
3279
+ tasteChoicesReady: this.tasteChoicesReady(),
3083
3280
  }).map(capability => capability.state === "enabled" && this.#playbackServiceExcludes(capability.id)
3084
3281
  ? { ...capability, state: "blocked", blocked_by: capability.id === "listening.now" ? "no_readable_player" : capability.id === "music_app.control" ? "no_music_app" : capability.id === "playback.service" ? "no_playback_service"
3085
3282
  : neteaseCapabilities.has(capability.id) ? "netease_not_ready" : "playback_service_not_spotify" } : capability),
@@ -3114,6 +3311,7 @@ export class MoondogApplication {
3114
3311
  musicSimilarityReady: this.musicSimilarityReady(),
3115
3312
  ...this.webLookupOptions(),
3116
3313
  musicPreviewReady: this.musicPreviewReady(),
3314
+ tasteChoicesReady: this.tasteChoicesReady(),
3117
3315
  }).filter(descriptor => !this.#playbackServiceExcludes(descriptor.capability_id));
3118
3316
  }
3119
3317
 
@@ -3324,6 +3522,9 @@ export class MoondogApplication {
3324
3522
  if (transaction) transaction.playlistPlanAttempted = true;
3325
3523
  this.invalidateSpotifyQuickEditContext();
3326
3524
  const plan = await this.requirePlaylistServices().buildPlaylistPlan(input);
3525
+ // The host declares the blend from the listener's own plays; the model never writes it.
3526
+ const mix = Array.isArray(plan?.tracks) ? mixCounts(this.#mixSlots(plan.tracks)) : null;
3527
+ if (mix) plan.mix = mix;
3327
3528
  const validatedPlaylistTrackRefs = Array.isArray(plan?.tracks)
3328
3529
  ? plan.tracks.map((track) => track.track_ref_id)
3329
3530
  : null;
@@ -3401,7 +3602,8 @@ export class MoondogApplication {
3401
3602
  };
3402
3603
  }
3403
3604
 
3404
- beginPrompt({ text } = {}) {
3605
+ // recentTexts are the listener's messages of the turns before this one, newest last.
3606
+ beginPrompt({ text, recentTexts = [] } = {}) {
3405
3607
  if (this.pendingPlaylistPromptTransaction) {
3406
3608
  throw new Error("A Moondog prompt state transaction is already active.");
3407
3609
  }
@@ -3413,6 +3615,8 @@ export class MoondogApplication {
3413
3615
  if (queueIntent) this.spotifyQueueRequest = { ...(queueIntent.clarification_only ? previousQueue : {}), ...queueIntent, observedAt: this.spotifyContextClock(), turn: this.spotifyContextTurn, epoch: this.spotifyContextEpoch };
3414
3616
  this.pendingPlaylistPromptTransaction = {
3415
3617
  userText: text,
3618
+ // What the listener said in this turn and the two before it: a song named there is theirs.
3619
+ listenerTexts: [...(Array.isArray(recentTexts) ? recentTexts : []).slice(-2), text].filter((value) => typeof value === "string"),
3416
3620
  queueIntent,
3417
3621
  removalConfirmationRequested: /^Confirm removal of (?:playlist|track|album|episode|show) .+ from my library[.!。!]?$/u.test(text?.trim() ?? ""),
3418
3622
  pendingSpotifyRemoval: structuredClone(this.pendingSpotifyRemoval),
@@ -69,10 +69,13 @@ export function queueCancellationRequested(text) {
69
69
  /^(?:キャンセル|やめて|止めて|中止)(?:して)?(?:ください)?[。!!]?$|(?:キュー|再生待ち)[^。!?]{0,20}(?:入れないで|追加しないで|やめて)|追加しないで/u.test(value);
70
70
  }
71
71
 
72
- /** The listener asked for music they have not heard, in any supported language. */
72
+ /**
73
+ * The listener asked for music they have not heard, in any supported language. "Not heard" counts
74
+ * only when it ends there or says yet, before or ever: "haven't heard in a while" asks to come back.
75
+ */
73
76
  export function unheardRequested(text) {
74
77
  return typeof text === "string" &&
75
- /没听过|从未听|不要听过|\bunheard\b|\bnever heard\b|outside.*library|曲库之外|nunca he (?:escuchado|o[ií]do)|que no (?:he|haya) (?:escuchado|o[ií]do)|sin escuchar|nuevas para m[ií]|nunca ouvi|que eu (?:ainda )?n[aã]o (?:ouvi|conhe[cç]o)|novas pra mim|novas para mim|聴いたことのない|聞いたことのない|知らない曲|未聴/iu.test(text);
78
+ /没听过|从未听|不要听过|\bunheard\b|\bnever (?:heard|listened to)\b|\b(?:haven't|have not|'ve not|not)\s+(?:yet\s+|ever\s+)?(?:heard|listened to)(?:\s+(?:yet|before|of|ever|please)\b|\s*(?:$|[,.;!?,。;!?]))|\bnew (?:to|for) me\b|\bunfamiliar\b|outside.*library|曲库之外|nunca he (?:escuchado|o[ií]do)|que no (?:he|haya) (?:escuchado|o[ií]do)|sin escuchar|nuevas para m[ií]|nunca ouvi|que eu (?:ainda )?n[aã]o (?:ouvi|conhe[cç]o)|novas pra mim|novas para mim|聴いたことのない|聞いたことのない|知らない曲|未聴/iu.test(text.replace(/[\u2018\u2019]/gu, "'"));
76
79
  }
77
80
 
78
81
  function queueRefinement(value, previous) {