@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
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.2.0-beta.2</p>
7
+ <p align="center">Source candidate 0.2.0-beta.3</p>
8
8
 
9
9
  <p align="center"><strong>A listening room that shows its work.</strong></p>
10
10
 
@@ -131,7 +131,7 @@ Playing a song a lot does not, on its own, establish how much it matters to you.
131
131
  | --- | --- |
132
132
  | Find a song or artist | Type to filter, then open it. |
133
133
  | See why it is there | Read what you played, what that cannot prove, and what you told Moondog, each on its own. |
134
- | Make a choice | Choose **I like this** (Like) or **Keep it out** (Avoid), and the profile updates in place. |
134
+ | Make a choice | Choose **I like this** (Like) or **Keep it out** (Avoid), and the profile updates in place. You can also say it to the agent, for example “never play this again”. |
135
135
  | Change your mind | Choose **Undo my choice**, and the original listening history never changes. |
136
136
  | Find more like this | Start an editable request for three songs, from the track you picked. |
137
137
 
@@ -155,6 +155,8 @@ It also sees what you already play: the song and playlist in the Music app on a
155
155
  macOS asks once before Moondog may read the Music app.
156
156
  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.
157
157
  What it reads about the wider music world stays separate from what it knows about you.
158
+ Under a queue or a set of songs, one line names the mix: how many you know well, how many come back after a while, and how many are new to you.
159
+ Songs Moondog chose for you do not count as your own taste.
158
160
 
159
161
  | Optional connection | What it adds |
160
162
  | --- | --- |
@@ -199,6 +201,7 @@ Your profile, and anything Moondog remembers, carry over.
199
201
  - [Private Tasteprint](docs/PRIVATE_TASTEPRINT.md): what the evidence can support, and what a report reveals about you.
200
202
  - [Product charter](docs/PRODUCT_CHARTER.md) and [public roadmap](docs/ROADMAP.md): where Moondog is headed, and what it will not do.
201
203
  - [Voice](docs/VOICE.md): how Moondog talks, and where Pink Floyd shows up.
204
+ - [The listening loop](docs/LISTENING_LOOP.md): how the agent keeps your choices, your recent listening, and its own picks apart.
202
205
  - [Runtime architecture](docs/ADR_0001_AGENT_RUNTIME_AND_CLI.md) and [data contracts](contracts/README.md): how the pieces fit together.
203
206
  - [Contribution guide](CONTRIBUTING.md): local setup, checks, and how private data is kept out.
204
207
  - [Release status](docs/PUBLIC_RELEASE_READINESS.md): what is published, and what the checks cover.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@audiofool/moondog",
3
- "version": "0.2.0-beta.2",
3
+ "version": "0.2.0-beta.3",
4
4
  "type": "module",
5
5
  "description": "Local-first personal AI music agent.",
6
6
  "license": "Apache-2.0",
@@ -1,7 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { exploreProfileCatalog, profileExplorationInput } from "../profile/profile-exploration.mjs";
3
- import { createListenerProfileInput } from "../profile/listener-profile-build.mjs";
3
+ import { createListenerProfileInput, savedListenerChoices } from "../profile/listener-profile-build.mjs";
4
4
  import { lyricSeedsFromProfile } from "./lyric-profile.mjs";
5
+ import { cachedAvoidGate, createAvoidGate, listeningVersion } from "./listener-avoids.mjs";
6
+ import { recordListenerChoice } from "../profile/listener-corrections.mjs";
7
+ import { normalizedTrackTitle } from "./track-title.mjs";
5
8
  import { collectionCoverage } from "../profile/music-providers.mjs";
6
9
  import { lstat } from "node:fs/promises";
7
10
  import path from "node:path";
@@ -812,9 +815,10 @@ function normalizedArtistNames(value) {
812
815
  .filter(Boolean);
813
816
  }
814
817
 
818
+ // A remaster or live take of a song counts as the song (src/core/track-title.mjs).
815
819
  export function exactTitleArtistMatch(left, right) {
816
- const leftTitle = normalizeText(left.title);
817
- const rightTitle = normalizeText(right.title);
820
+ const leftTitle = normalizedTrackTitle(left.title);
821
+ const rightTitle = normalizedTrackTitle(right.title);
818
822
  const leftArtists = normalizedArtistNames(left.artist_credit);
819
823
  const rightArtists = new Set(normalizedArtistNames(right.artist_credit));
820
824
  return (
@@ -883,13 +887,14 @@ export function safeExternalSource(raw) {
883
887
  fail("external_candidate_invalid", "External catalog source metadata is invalid.");
884
888
  }
885
889
  if (raw.provider === "apple_music") {
886
- if (raw.catalog !== "itunes_search_api" || raw.storefront !== "US") {
890
+ // The storefront follows the listener's account or region, as a two-letter country code.
891
+ if (raw.catalog !== "itunes_search_api" || typeof raw.storefront !== "string" || !/^[A-Z]{2}$/u.test(raw.storefront)) {
887
892
  fail("external_candidate_invalid", "External catalog source metadata is invalid.");
888
893
  }
889
894
  return {
890
895
  provider: "apple_music",
891
896
  catalog: "itunes_search_api",
892
- storefront: "US",
897
+ storefront: raw.storefront,
893
898
  retrieved_at: raw.retrieved_at,
894
899
  coverage: cleanText(raw.coverage, 500, "external_candidate_invalid"),
895
900
  };
@@ -985,6 +990,8 @@ function preferenceSignalLabel(observation = {}) {
985
990
  return labels.join(", ") || "explicit provider preference signal";
986
991
  }
987
992
 
993
+ const avoidGates = new WeakMap();
994
+
988
995
  export function listenerAssertionAvoids(
989
996
  listening,
990
997
  entityType,
@@ -992,19 +999,11 @@ export function listenerAssertionAvoids(
992
999
  artistCredit = "",
993
1000
  ) {
994
1001
  const avoids = listening?.listener_assertions?.avoids ?? [];
995
- const normalizedLabel = normalizeText(label ?? "");
996
- const normalizedArtist = normalizeText(artistCredit ?? "");
997
- return avoids.some((avoid) => {
998
- if (avoid.entity_type === "artist") {
999
- const avoidedArtist = normalizeText(avoid.label ?? "");
1000
- return entityType === "artist"
1001
- ? avoidedArtist === normalizedLabel
1002
- : avoidedArtist === normalizedArtist;
1003
- }
1004
- return entityType === "track" &&
1005
- normalizeText(avoid.label ?? "") === normalizedLabel &&
1006
- normalizeText(avoid.artist_credit ?? "") === normalizedArtist;
1007
- });
1002
+ let gate = avoidGates.get(avoids);
1003
+ if (!gate) avoidGates.set(avoids, gate = createAvoidGate(avoids));
1004
+ return entityType === "artist"
1005
+ ? gate.avoidsArtist(label)
1006
+ : gate.avoidsTrack({ title: label, artist_credit: artistCredit });
1008
1007
  }
1009
1008
 
1010
1009
  function normalizeAppleCorrectionLabels(listening, projection) {
@@ -1211,6 +1210,8 @@ export class AppleProjectionDomainServices {
1211
1210
  #subjectId;
1212
1211
  #candidateSets = new Map();
1213
1212
  #profileBuildInput;
1213
+ #avoids = {};
1214
+ #status = {};
1214
1215
  #ownsStores = true;
1215
1216
 
1216
1217
  constructor({ projection, listeningHistoryStore = null, subjectId = null }) {
@@ -1246,7 +1247,17 @@ export class AppleProjectionDomainServices {
1246
1247
  };
1247
1248
  }
1248
1249
 
1250
+ // Every model request reads the status, so it is kept until the library or the listening data changes.
1249
1251
  profileStatus() {
1252
+ const version = typeof this.#projection.logicalDigest === "function"
1253
+ ? `${this.#projection.logicalDigest()}:${listeningVersion(this.#listeningHistoryStore)}` : undefined;
1254
+ if (version === undefined || this.#status.version !== version) {
1255
+ this.#status = { version, value: this.#readProfileStatus() };
1256
+ }
1257
+ return structuredClone(this.#status.value);
1258
+ }
1259
+
1260
+ #readProfileStatus() {
1250
1261
  const summary = this.#projection.getProfileSummary({ maxItems: 1 });
1251
1262
  const listening = this.#listeningHistoryStore?.profileSummary({
1252
1263
  subjectId: this.#subjectId,
@@ -1284,6 +1295,43 @@ export class AppleProjectionDomainServices {
1284
1295
  return this.#listeningHistoryStore?.recentAttention?.({ subjectId: this.#subjectId }) ?? null;
1285
1296
  }
1286
1297
 
1298
+ // Moondog's suggestions ledger and the declared mix (src/profile/moondog-suggestions.mjs).
1299
+ recordMoondogSuggestions(entries) {
1300
+ return this.#listeningHistoryStore?.recordMoondogSuggestions?.({ entries }) ?? null;
1301
+ }
1302
+
1303
+ moondogSuggestionHistory(tracks) {
1304
+ return this.#listeningHistoryStore?.moondogSuggestionHistory?.({ subjectId: this.#subjectId, tracks }) ?? null;
1305
+ }
1306
+
1307
+ // A song the dated plays do not place but the Apple library has played counts as known: three
1308
+ // or more library plays as familiar, fewer as heard. Library counts carry no dates.
1309
+ listeningMix(tracks) {
1310
+ const slots = this.#listeningHistoryStore?.listeningMix?.({ subjectId: this.#subjectId, tracks }) ??
1311
+ tracks.map(() => "unknown");
1312
+ return slots.map((slot, index) => {
1313
+ if (!["new", "known_artist", "unknown"].includes(slot)) return slot;
1314
+ const plays = this.#libraryPlayCount(tracks[index]);
1315
+ return plays >= 3 ? "familiar" : plays > 0 ? "heard" : slot;
1316
+ });
1317
+ }
1318
+
1319
+ #libraryPlayCount(track) {
1320
+ const wanted = { title: track?.title, artist_credit: track?.artist_credit ?? (Array.isArray(track?.artists) ? track.artists.join(", ") : "") };
1321
+ if (typeof wanted.title !== "string" || !wanted.title.trim()) return 0;
1322
+ try {
1323
+ const results = this.#projection.searchLibrary({
1324
+ // Without its version suffix, so a remaster finds the library's copy.
1325
+ query: Array.from(normalizedTrackTitle(wanted.title)).slice(0, 256).join(""), limit: 25, offset: 0,
1326
+ filters: { artists: [], genres: [], familiarity: [], preferenceSignals: [] },
1327
+ });
1328
+ return Math.max(0, ...results.tracks.map(safeTrack).filter((candidate) => exactTitleArtistMatch(wanted, candidate))
1329
+ .map((candidate) => candidate.observation_summary.familiarity.play_count ?? 0));
1330
+ } catch {
1331
+ return 0; // Without the library, the dated plays alone place the song.
1332
+ }
1333
+ }
1334
+
1287
1335
  rediscoveryReady() {
1288
1336
  return (
1289
1337
  this.#listeningHistoryStore !== null &&
@@ -1764,16 +1812,36 @@ export class AppleProjectionDomainServices {
1764
1812
  };
1765
1813
  }
1766
1814
 
1815
+ // Every play path asks for each candidate, so the gate is kept until the data changes.
1767
1816
  filterDiscoveryTracks(tracks) {
1768
1817
  const store = this.#listeningHistoryStore;
1769
- const avoids = store?.activeAvoidances?.({ subjectId: this.#subjectId }) ??
1770
- store?.profileSummary?.({ subjectId: this.#subjectId })?.listener_assertions?.avoids ?? [];
1771
- const listening = normalizeAppleCorrectionLabels(
1772
- { listener_assertions: { avoids } }, this.#projection,
1773
- );
1774
- return tracks.filter((track) => !listenerAssertionAvoids(
1775
- listening, "track", track.title, track.artist_credit,
1776
- ));
1818
+ const version = store?.profileDataVersion
1819
+ ? `${this.#projection.logicalDigest?.() ?? ""}:${listeningVersion(store)}` : undefined;
1820
+ const gate = cachedAvoidGate(this.#avoids, version, () => {
1821
+ const avoids = store?.activeAvoidances?.({ subjectId: this.#subjectId }) ??
1822
+ store?.profileSummary?.({ subjectId: this.#subjectId })?.listener_assertions?.avoids ?? [];
1823
+ return normalizeAppleCorrectionLabels({ listener_assertions: { avoids } }, this.#projection)
1824
+ .listener_assertions.avoids;
1825
+ });
1826
+ return tracks.filter((track) => !gate.avoidsTrack(track));
1827
+ }
1828
+
1829
+ // Choices live with the listening history, so a library alone cannot save them.
1830
+ listenerChoicesReady() {
1831
+ return this.#listeningHistoryStore !== null;
1832
+ }
1833
+
1834
+ recordListenerChoice(choice) {
1835
+ if (!this.#listeningHistoryStore) throw new Error("Choices can be saved once listening history is imported.");
1836
+ return recordListenerChoice(this.#listeningHistoryStore, { ...choice, subjectId: this.#subjectId });
1837
+ }
1838
+
1839
+ // The choices in force, cheap enough to read when there is no saved reading.
1840
+ listenerChoices() {
1841
+ const store = this.#listeningHistoryStore;
1842
+ if (typeof store?.countActiveListenerCorrections !== "function") return null;
1843
+ return savedListenerChoices(store.listListenerCorrections({ subjectId: this.#subjectId, limit: 30 }),
1844
+ store.countActiveListenerCorrections({ subjectId: this.#subjectId }));
1777
1845
  }
1778
1846
 
1779
1847
  isKnownDiscoveryTrack(track) {
@@ -1985,7 +2053,7 @@ export class AppleProjectionDomainServices {
1985
2053
 
1986
2054
  getProfileBuildContext() {
1987
2055
  const appleDigest = this.#projection.logicalDigest();
1988
- const version = `${appleDigest}:${this.#listeningHistoryStore.profileDataVersion()}`;
2056
+ const version = `${appleDigest}:${listeningVersion(this.#listeningHistoryStore)}`;
1989
2057
  if (this.#profileBuildInput?.version !== version) {
1990
2058
  const listening = this.#listeningHistoryStore.profileCatalog({ subjectId: this.#subjectId });
1991
2059
  const normalized = normalizeAppleCorrectionLabels({ listener_assertions: {
@@ -341,6 +341,12 @@ const catalog = [
341
341
  label: N_("Forget something"),
342
342
  },
343
343
  },
344
+ {
345
+ id: "taste.choice", version: "1", state: "enabled", effect: "write_local",
346
+ description: "Save a lasting Like or Avoid for an artist or song, or undo one, when the listener states it in their own words.",
347
+ requires_taste_choices: true,
348
+ agent_tool: { name: "moondog_taste_choice", label: N_("Save your choice") },
349
+ },
344
350
  {
345
351
  id: "spotify.history.import",
346
352
  version: "1",
@@ -629,7 +635,11 @@ function withRuntimeAvailability(
629
635
  profileExplorationReady,
630
636
  profileBuildReady,
631
637
  musicPreviewReady,
638
+ tasteChoicesReady,
632
639
  ) {
640
+ if (capability.requires_taste_choices && !tasteChoicesReady) {
641
+ return { ...capability, state: "blocked", blocked_by: "taste_choices_not_ready" };
642
+ }
633
643
  if (capability.requires_profile_build && !profileBuildReady) {
634
644
  return { ...capability, state: "blocked", blocked_by: "profile_build_not_ready" };
635
645
  }
@@ -758,6 +768,7 @@ export function listCapabilities({
758
768
  profileExplorationReady = false,
759
769
  profileBuildReady = false,
760
770
  musicPreviewReady = false,
771
+ tasteChoicesReady = false,
761
772
  } = {}) {
762
773
  return structuredClone(
763
774
  catalog.map((capability) =>
@@ -779,6 +790,7 @@ export function listCapabilities({
779
790
  profileExplorationReady,
780
791
  profileBuildReady,
781
792
  musicPreviewReady,
793
+ tasteChoicesReady,
782
794
  ),
783
795
  ),
784
796
  );
@@ -0,0 +1,101 @@
1
+ // One Avoid check for every play path. Names fold case, accents and spacing, so "Drake " and
2
+ // "Drake" are one artist, and an artist Avoid matches a whole credit or any artist in it.
3
+
4
+ // Collaboration separators in artist credits. Full-width forms fold to these before splitting,
5
+ // but callers that do not fold text first can still split on them. A word separator needs a
6
+ // space before it, which a separator before it may already have taken.
7
+ export const ARTIST_CREDIT_SEPARATORS =
8
+ /\s*(?:,|,|;|;|&|&|\/|、|和|与)\s*|(?<=\s)(?:featuring|feat\.?|ft\.?|with)\s+/iu;
9
+
10
+ // "x" also joins artists ("Future x Drake"), but it ends some names ("Lil Nas X"). Only this
11
+ // check splits on it, because it also reads each run of artists as one name.
12
+ const separated = new RegExp(`(${ARTIST_CREDIT_SEPARATORS.source}|(?<=\\s)(?:x|×)\\s+)`, "iu");
13
+
14
+ // Features named in a title: "Song (feat. Drake)", "Song [with Drake]" or "Song feat. Drake".
15
+ const titleFeature =
16
+ /\s*[((【[]\s*(?:featuring|feat\.?|ft\.?|with)\s+([^))】\]]+)[))】\]]|\s+(?:-\s+)?(?:featuring|feat\.|ft\.)\s+(.+)$/giu;
17
+
18
+ export function avoidKey(value) {
19
+ if (typeof value !== "string") return "";
20
+ // Only Latin, Greek and Cyrillic accents fold: kana voicing marks change the word.
21
+ return value.normalize("NFKD").replace(/[̀-ͯ]/gu, "")
22
+ .toLocaleLowerCase("und").replace(/\s+/gu, " ").trim();
23
+ }
24
+
25
+ // A title without its features, and the artists those features name.
26
+ function titleParts(title) {
27
+ const featured = [];
28
+ const core = typeof title === "string"
29
+ ? title.replace(titleFeature, (_whole, bracketed, trailing) => { featured.push(bracketed ?? trailing); return ""; })
30
+ : title;
31
+ return { core: avoidKey(core), featured };
32
+ }
33
+
34
+ // Every way to read free-text credits as one artist: the whole credit, each credited artist, and
35
+ // each run of them, so a band named "Simon & Garfunkel" stays one name inside a longer credit.
36
+ // A source's own list of artists names each one whole, so its names are not split.
37
+ function creditKeys(credit, artists = [], featured = []) {
38
+ const listed = (Array.isArray(artists) ? artists : []).map(avoidKey).filter(Boolean);
39
+ const keys = new Set(listed);
40
+ if (listed.length) keys.add(avoidKey(credit));
41
+ for (const name of [...(listed.length ? [] : [credit]), ...featured]) {
42
+ const pieces = avoidKey(name).split(separated);
43
+ for (let start = 0; start < pieces.length; start += 2) {
44
+ for (let end = start; end < pieces.length; end += 2) {
45
+ const key = pieces.slice(start, end + 1).join("").trim();
46
+ if (key) keys.add(key);
47
+ }
48
+ }
49
+ }
50
+ keys.delete("");
51
+ return keys;
52
+ }
53
+
54
+ const overlaps = (left, right) => [...left].some((key) => right.has(key));
55
+
56
+ /**
57
+ * Builds the check from Avoid records ({ entity_type, label, artist_credit }). A track Avoid
58
+ * matches its title, without any features, with any of its credited artists.
59
+ */
60
+ export function createAvoidGate(avoids = []) {
61
+ const artists = new Set();
62
+ const tracks = new Map();
63
+ for (const avoid of Array.isArray(avoids) ? avoids : []) {
64
+ const label = avoidKey(avoid?.label);
65
+ if (!label) continue;
66
+ if (avoid.entity_type === "artist") artists.add(label);
67
+ else if (avoid.entity_type === "track") {
68
+ const { core, featured } = titleParts(avoid.label);
69
+ const title = core || label;
70
+ tracks.set(title, [...(tracks.get(title) ?? []), creditKeys(avoid.artist_credit, [], featured)]);
71
+ }
72
+ }
73
+ const avoidsArtist = (keys) => overlaps(keys, artists);
74
+ return {
75
+ size: artists.size + tracks.size,
76
+ avoidsArtist: (name, names = []) => avoidsArtist(creditKeys(name, names)),
77
+ // A track as { title, artist_credit, artists }, where artists is a source's own list.
78
+ avoidsTrack({ title, artist_credit: credit, artists: names } = {}) {
79
+ const { core, featured } = titleParts(title);
80
+ const keys = creditKeys(credit, names, featured);
81
+ if (avoidsArtist(keys)) return true;
82
+ return (tracks.get(core || avoidKey(title)) ?? []).some((avoided) =>
83
+ avoided.size ? overlaps(avoided, keys) : keys.size === 0);
84
+ },
85
+ };
86
+ }
87
+
88
+ // The gate for the store's current data, rebuilt only when that data changes.
89
+ export function cachedAvoidGate(cache, version, readAvoids) {
90
+ if (version === undefined || cache.version !== version) {
91
+ cache.version = version;
92
+ cache.gate = createAvoidGate(readAvoids());
93
+ }
94
+ return cache.gate;
95
+ }
96
+
97
+ // The store's data version without its own ledger writes, which change no projection until
98
+ // their plays arrive (listeningDataVersion in listening-history-store.mjs).
99
+ export function listeningVersion(store) {
100
+ return store?.listeningDataVersion?.() ?? store?.profileDataVersion?.();
101
+ }
@@ -1,14 +1,15 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { exploreProfileCatalog, profileExplorationInput } from "../profile/profile-exploration.mjs";
3
- import { createListenerProfileInput } from "../profile/listener-profile-build.mjs";
3
+ import { createListenerProfileInput, savedListenerChoices } from "../profile/listener-profile-build.mjs";
4
4
  import { lyricSeedsFromProfile } from "./lyric-profile.mjs";
5
+ import { cachedAvoidGate, listeningVersion } from "./listener-avoids.mjs";
6
+ import { recordListenerChoice } from "../profile/listener-corrections.mjs";
5
7
 
6
8
  import { isUuid } from "./uuid-v5.mjs";
7
9
  import { createListeningHistoryProfileProjection } from "../profile/spotify-archive-taste.mjs";
8
10
  import {
9
11
  assertExternalPlanDiversity,
10
12
  exactTitleArtistMatch,
11
- listenerAssertionAvoids,
12
13
  safeExternalSource,
13
14
  safeExternalTrack,
14
15
  } from "./apple-projection-domain-services.mjs";
@@ -423,6 +424,8 @@ export class ListeningProfileDomainServices {
423
424
  #subjectId;
424
425
  #candidateSets = new Map();
425
426
  #profileBuildInput;
427
+ #avoids = {};
428
+ #status = {};
426
429
  #ownsStore = true;
427
430
 
428
431
  constructor({ listeningHistoryStore, subjectId } = {}) {
@@ -462,7 +465,16 @@ export class ListeningProfileDomainServices {
462
465
  };
463
466
  }
464
467
 
468
+ // Every model request reads the status, so it is kept until the listening data changes.
465
469
  profileStatus() {
470
+ const version = listeningVersion(this.#store);
471
+ if (version === undefined || this.#status.version !== version) {
472
+ this.#status = { version, value: this.#readProfileStatus() };
473
+ }
474
+ return structuredClone(this.#status.value);
475
+ }
476
+
477
+ #readProfileStatus() {
466
478
  const source = this.#store.subjectDataStatus({
467
479
  subjectId: this.#subjectId,
468
480
  });
@@ -493,6 +505,19 @@ export class ListeningProfileDomainServices {
493
505
  return this.#store.recentAttention?.({ subjectId: this.#subjectId }) ?? null;
494
506
  }
495
507
 
508
+ // Moondog's suggestions ledger and the declared mix (src/profile/moondog-suggestions.mjs).
509
+ recordMoondogSuggestions(entries) {
510
+ return this.#store.recordMoondogSuggestions?.({ entries }) ?? null;
511
+ }
512
+
513
+ moondogSuggestionHistory(tracks) {
514
+ return this.#store.moondogSuggestionHistory?.({ subjectId: this.#subjectId, tracks }) ?? null;
515
+ }
516
+
517
+ listeningMix(tracks) {
518
+ return this.#store.listeningMix?.({ subjectId: this.#subjectId, tracks }) ?? null;
519
+ }
520
+
496
521
  rediscoveryReady() {
497
522
  return typeof this.#store.rediscoveryCandidates === "function";
498
523
  }
@@ -553,12 +578,23 @@ export class ListeningProfileDomainServices {
553
578
  return { candidateSetId, tracks: prepared };
554
579
  }
555
580
 
581
+ // Every play path asks for each candidate, so the gate is kept until the data changes.
556
582
  filterDiscoveryTracks(tracks) {
557
- const avoids = this.#store.activeAvoidances?.({ subjectId: this.#subjectId }) ??
558
- this.#store.profileSummary({ subjectId: this.#subjectId })?.listener_assertions?.avoids ?? [];
559
- return tracks.filter((track) => !listenerAssertionAvoids(
560
- { listener_assertions: { avoids } }, "track", track.title, track.artist_credit,
561
- ));
583
+ const gate = cachedAvoidGate(this.#avoids, listeningVersion(this.#store), () =>
584
+ this.#store.activeAvoidances?.({ subjectId: this.#subjectId }) ??
585
+ this.#store.profileSummary({ subjectId: this.#subjectId })?.listener_assertions?.avoids ?? []);
586
+ return tracks.filter((track) => !gate.avoidsTrack(track));
587
+ }
588
+
589
+ recordListenerChoice(choice) {
590
+ return recordListenerChoice(this.#store, { ...choice, subjectId: this.#subjectId });
591
+ }
592
+
593
+ // The choices in force, cheap enough to read when there is no saved reading.
594
+ listenerChoices() {
595
+ if (typeof this.#store.countActiveListenerCorrections !== "function") return null;
596
+ return savedListenerChoices(this.#store.listListenerCorrections({ subjectId: this.#subjectId, limit: 30 }),
597
+ this.#store.countActiveListenerCorrections({ subjectId: this.#subjectId }));
562
598
  }
563
599
 
564
600
  isKnownDiscoveryTrack(track) {
@@ -652,7 +688,7 @@ export class ListeningProfileDomainServices {
652
688
  }
653
689
 
654
690
  getProfileBuildContext() {
655
- const version = this.#store.profileDataVersion();
691
+ const version = listeningVersion(this.#store);
656
692
  if (this.#profileBuildInput?.version !== version) {
657
693
  this.#profileBuildInput = { version, input: createListenerProfileInput({
658
694
  subjectId: this.#subjectId,