@audiofool/moondog 0.1.0 → 0.2.0-beta.2

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 (121) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +153 -68
  3. package/contracts/v1/definitions.schema.json +1 -169
  4. package/package.json +7 -23
  5. package/scripts/contract-lib.mjs +0 -7
  6. package/scripts/contract-semantics.mjs +0 -1904
  7. package/scripts/moondog.mjs +285 -74
  8. package/src/core/apple-library-source-status.mjs +0 -16
  9. package/src/core/apple-projection-domain-services.mjs +168 -36
  10. package/src/core/application-update.mjs +320 -0
  11. package/src/core/capability-catalog.mjs +278 -55
  12. package/src/core/listening-now.mjs +108 -0
  13. package/src/core/listening-profile-domain-services.mjs +153 -41
  14. package/src/core/listening-upkeep.mjs +31 -0
  15. package/src/core/moondog-application.mjs +1908 -104
  16. package/src/core/playback-preferences.mjs +97 -0
  17. package/src/core/spotify-listening-intent.mjs +121 -0
  18. package/src/core/synthetic-domain-services.mjs +24 -3
  19. package/src/demo/moondog-demo.mjs +4 -22
  20. package/src/i18n/index.mjs +285 -0
  21. package/src/i18n/messages/de.mjs +128 -0
  22. package/src/i18n/messages/en.mjs +129 -0
  23. package/src/i18n/messages/es.mjs +128 -0
  24. package/src/i18n/messages/fr.mjs +128 -0
  25. package/src/i18n/messages/it.mjs +128 -0
  26. package/src/i18n/messages/ja.mjs +128 -0
  27. package/src/i18n/messages/ko.mjs +128 -0
  28. package/src/i18n/messages/pt.mjs +128 -0
  29. package/src/i18n/messages/zh-Hant.mjs +128 -0
  30. package/src/i18n/messages/zh.mjs +128 -0
  31. package/src/i18n/preferences.mjs +34 -0
  32. package/src/i18n/screen/es.mjs +966 -0
  33. package/src/i18n/screen/ja.mjs +966 -0
  34. package/src/i18n/screen/pt.mjs +966 -0
  35. package/src/i18n/screen/zh.mjs +966 -0
  36. package/src/importers/apple-music-library/index.mjs +0 -16
  37. package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
  38. package/src/integrations/apple-music/catalog.mjs +123 -14
  39. package/src/integrations/apple-music/music-app.mjs +232 -0
  40. package/src/integrations/apple-music/storefront.mjs +28 -0
  41. package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
  42. package/src/integrations/lastfm/history.mjs +221 -0
  43. package/src/integrations/lastfm/settings.mjs +75 -0
  44. package/src/integrations/netease/ncm-cli-player.mjs +321 -0
  45. package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
  46. package/src/integrations/public-playlists.mjs +2 -1
  47. package/src/integrations/spotify/account-data-profile.mjs +14 -6
  48. package/src/integrations/spotify/authentication.mjs +149 -15
  49. package/src/integrations/spotify/catalog-resolver.mjs +8 -4
  50. package/src/integrations/spotify/connection.mjs +2 -1
  51. package/src/integrations/spotify/credential-store.mjs +4 -0
  52. package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
  53. package/src/integrations/spotify/history-archive.mjs +1 -1
  54. package/src/integrations/spotify/library-snapshot.mjs +227 -0
  55. package/src/integrations/spotify/recent-activity.mjs +2 -0
  56. package/src/integrations/spotify/service.mjs +873 -69
  57. package/src/integrations/spotify/web-api-client.mjs +407 -127
  58. package/src/integrations/web/brave-web.mjs +45 -0
  59. package/src/integrations/web/codex-web.mjs +15 -82
  60. package/src/integrations/web/deepseek-web.mjs +81 -0
  61. package/src/integrations/web/web-research.mjs +119 -0
  62. package/src/integrations/web/web-result.mjs +215 -0
  63. package/src/integrations/youtube-music/takeout.mjs +3 -1
  64. package/src/memory/local-memory-store.mjs +469 -41
  65. package/src/profile/history-import.mjs +42 -6
  66. package/src/profile/listener-profile-build.mjs +378 -0
  67. package/src/profile/listener-profile-store.mjs +130 -0
  68. package/src/profile/listener-profile-verification.mjs +68 -0
  69. package/src/profile/listener-profile-worker.mjs +172 -0
  70. package/src/profile/listening-history-store.mjs +424 -8
  71. package/src/profile/listening-profile-projection.mjs +242 -76
  72. package/src/profile/local-music-data.mjs +1 -1
  73. package/src/profile/music-providers.mjs +11 -7
  74. package/src/profile/profile-exploration.mjs +84 -0
  75. package/src/profile/recorded-measures.mjs +24 -0
  76. package/src/profile/spotify-archive-taste.mjs +0 -4
  77. package/src/runtime/pi/agent-runtime.mjs +2204 -605
  78. package/src/runtime/pi/authentication.mjs +34 -1
  79. package/src/runtime/pi/configured-runtime.mjs +22 -6
  80. package/src/runtime/pi/conversation-name.mjs +53 -0
  81. package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
  82. package/src/runtime/pi/model-catalog.mjs +28 -5
  83. package/src/runtime/pi/model-settings.mjs +202 -0
  84. package/src/runtime/pi/model-transport.mjs +10 -0
  85. package/src/runtime/pi/persistent-credential-store.mjs +67 -31
  86. package/src/runtime/pi/profile-build-runtime.mjs +160 -0
  87. package/src/runtime/pi/public-model-catalog.mjs +288 -0
  88. package/src/runtime/pi/runtime-settings.mjs +10 -2
  89. package/src/runtime/pi/skills/listener-profile/SKILL.md +46 -0
  90. package/src/runtime/pi/tool-trace.mjs +37 -0
  91. package/src/surfaces/cli/auth-command.mjs +12 -4
  92. package/src/surfaces/cli/brand-components.mjs +310 -132
  93. package/src/surfaces/cli/brand-theme.mjs +51 -28
  94. package/src/surfaces/cli/command-completions.mjs +58 -42
  95. package/src/surfaces/cli/format-output.mjs +199 -388
  96. package/src/surfaces/cli/history-import-view.mjs +292 -139
  97. package/src/surfaces/cli/import-guides.mjs +2 -0
  98. package/src/surfaces/cli/listener-profile-command.mjs +92 -0
  99. package/src/surfaces/cli/listening-menu.mjs +7 -4
  100. package/src/surfaces/cli/profile-command.mjs +30 -21
  101. package/src/surfaces/cli/seasons-chart.mjs +87 -0
  102. package/src/surfaces/cli/spotify-command.mjs +54 -6
  103. package/src/surfaces/cli/taste-profile-model.mjs +78 -79
  104. package/src/surfaces/cli/taste-profile-view.mjs +33 -26
  105. package/src/surfaces/cli/terminal-art.mjs +140 -35
  106. package/src/surfaces/cli/tui.mjs +1066 -357
  107. package/src/surfaces/cli/update-command.mjs +32 -0
  108. package/src/surfaces/cli/web-command.mjs +53 -14
  109. package/src/surfaces/html/tasteprint.mjs +43 -70
  110. package/contracts/v1/capability-policy.schema.json +0 -572
  111. package/contracts/v1/profile-evidence.schema.json +0 -237
  112. package/contracts/v1/tool-invocation.schema.json +0 -318
  113. package/contracts/v1/tool-receipt.schema.json +0 -1066
  114. package/scripts/demo-studio.mjs +0 -58
  115. package/src/core/apple-data-migration.mjs +0 -50
  116. package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
  117. package/src/demo/moondog-tasteprint-demo.mjs +0 -631
  118. package/src/integrations/spotify/index.mjs +0 -80
  119. package/src/surfaces/web/showcase.mjs +0 -272
  120. package/src/surfaces/web/studio-page.mjs +0 -4148
  121. package/src/surfaces/web/studio.mjs +0 -2288
@@ -1,4 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { exploreProfileCatalog, profileExplorationInput } from "../profile/profile-exploration.mjs";
3
+ import { createListenerProfileInput } from "../profile/listener-profile-build.mjs";
2
4
  import { lyricSeedsFromProfile } from "./lyric-profile.mjs";
3
5
  import { collectionCoverage } from "../profile/music-providers.mjs";
4
6
  import { lstat } from "node:fs/promises";
@@ -408,6 +410,11 @@ function safeHistoryCount(value) {
408
410
  return value;
409
411
  }
410
412
 
413
+ // A pick leaves out minutes or skips that its plays never recorded.
414
+ function optionalHistoryCount(context, key) {
415
+ return context[key] === undefined ? {} : { [key]: safeHistoryCount(context[key]) };
416
+ }
417
+
411
418
  function safeHistoryExternalRefs(value) {
412
419
  if (value === undefined) return [];
413
420
  if (!Array.isArray(value)) {
@@ -487,10 +494,13 @@ function safeHistoryTrack(raw) {
487
494
  if (hasRediscovery) {
488
495
  const rediscovery = raw.rediscovery;
489
496
  const engagedPlayCount = safeHistoryCount(rediscovery.engaged_play_count);
490
- const explicitSkips = safeHistoryCount(rediscovery.explicit_skips);
491
- const listeningMinutes = safeHistoryCount(rediscovery.listening_minutes);
497
+ const explicitSkips = optionalHistoryCount(rediscovery, "explicit_skips");
498
+ const listeningMinutes = optionalHistoryCount(rediscovery, "listening_minutes");
492
499
  const quietDays = safeHistoryCount(rediscovery.quiet_days);
493
- if (engagedPlayCount > playCount || explicitSkips > playCount) {
500
+ if (
501
+ engagedPlayCount > playCount ||
502
+ (explicitSkips.explicit_skips ?? 0) > playCount
503
+ ) {
494
504
  fail("history_candidate_invalid", "A listening-history candidate is invalid.");
495
505
  }
496
506
  const rediscoverySignal = cleanText(
@@ -512,7 +522,9 @@ function safeHistoryTrack(raw) {
512
522
  const peakFields = [
513
523
  rediscovery.peak_year,
514
524
  rediscovery.peak_year_play_count,
515
- rediscovery.peak_year_listening_minutes,
525
+ ...(rediscovery.peak_year_listening_minutes === undefined
526
+ ? []
527
+ : [rediscovery.peak_year_listening_minutes]),
516
528
  ];
517
529
  if (peakFields.some((value) => value !== undefined)) {
518
530
  if (
@@ -524,9 +536,9 @@ function safeHistoryTrack(raw) {
524
536
  }
525
537
  }
526
538
  track.rediscovery = {
527
- listening_minutes: listeningMinutes,
539
+ ...listeningMinutes,
528
540
  engaged_play_count: engagedPlayCount,
529
- explicit_skips: explicitSkips,
541
+ ...explicitSkips,
530
542
  first_played_at: firstPlayedAt,
531
543
  last_played_at: lastPlayedAt,
532
544
  quiet_days: quietDays,
@@ -535,8 +547,7 @@ function safeHistoryTrack(raw) {
535
547
  ? {
536
548
  peak_year: rediscovery.peak_year,
537
549
  peak_year_play_count: rediscovery.peak_year_play_count,
538
- peak_year_listening_minutes:
539
- rediscovery.peak_year_listening_minutes,
550
+ ...optionalHistoryCount(rediscovery, "peak_year_listening_minutes"),
540
551
  }
541
552
  : {}),
542
553
  evidence_id: rediscovery.evidence_id.toLowerCase(),
@@ -546,9 +557,10 @@ function safeHistoryTrack(raw) {
546
557
  const engagedPlayCount = safeHistoryCount(
547
558
  historicalReturn.engaged_play_count,
548
559
  );
549
- const explicitSkips = safeHistoryCount(historicalReturn.explicit_skips);
550
- const listeningMinutes = safeHistoryCount(
551
- historicalReturn.listening_minutes,
560
+ const explicitSkips = optionalHistoryCount(historicalReturn, "explicit_skips");
561
+ const listeningMinutes = optionalHistoryCount(
562
+ historicalReturn,
563
+ "listening_minutes",
552
564
  );
553
565
  const returnCount = safeHistoryCount(historicalReturn.return_count);
554
566
  const longestGapDays = safeHistoryCount(
@@ -577,7 +589,7 @@ function safeHistoryTrack(raw) {
577
589
  latestReturnGapDays < 1 ||
578
590
  latestReturnGapDays > longestGapDays ||
579
591
  engagedPlayCount > playCount ||
580
- explicitSkips > playCount ||
592
+ (explicitSkips.explicit_skips ?? 0) > playCount ||
581
593
  Date.parse(firstPlayedAt) > Date.parse(latestReturnAt) ||
582
594
  Date.parse(latestReturnAt) > Date.parse(lastPlayedAt) ||
583
595
  !rediscoverySignals.has(historicalReturnSignal) ||
@@ -586,9 +598,9 @@ function safeHistoryTrack(raw) {
586
598
  fail("history_candidate_invalid", "A listening-history candidate is invalid.");
587
599
  }
588
600
  track.historical_return = {
589
- listening_minutes: listeningMinutes,
601
+ ...listeningMinutes,
590
602
  engaged_play_count: engagedPlayCount,
591
- explicit_skips: explicitSkips,
603
+ ...explicitSkips,
592
604
  first_played_at: firstPlayedAt,
593
605
  last_played_at: lastPlayedAt,
594
606
  return_count: returnCount,
@@ -605,12 +617,14 @@ function safeHistoryTrack(raw) {
605
617
  const yearEngagedPlayCount = safeHistoryCount(
606
618
  capsule.year_engaged_play_count,
607
619
  );
608
- const yearListeningMinutes = safeHistoryCount(
609
- capsule.year_listening_minutes,
620
+ const yearListeningMinutes = optionalHistoryCount(
621
+ capsule,
622
+ "year_listening_minutes",
610
623
  );
611
- const yearExplicitSkips = safeHistoryCount(capsule.year_explicit_skips);
612
- const lifetimeListeningMinutes = safeHistoryCount(
613
- capsule.lifetime_listening_minutes,
624
+ const yearExplicitSkips = optionalHistoryCount(capsule, "year_explicit_skips");
625
+ const lifetimeListeningMinutes = optionalHistoryCount(
626
+ capsule,
627
+ "lifetime_listening_minutes",
614
628
  );
615
629
  const representativeSignal = cleanText(
616
630
  capsule.representative_signal,
@@ -622,8 +636,9 @@ function safeHistoryTrack(raw) {
622
636
  year > 9999 ||
623
637
  yearPlayCount > playCount ||
624
638
  yearEngagedPlayCount > yearPlayCount ||
625
- yearExplicitSkips > yearPlayCount ||
626
- yearListeningMinutes > lifetimeListeningMinutes ||
639
+ (yearExplicitSkips.year_explicit_skips ?? 0) > yearPlayCount ||
640
+ (yearListeningMinutes.year_listening_minutes ?? 0) >
641
+ (lifetimeListeningMinutes.lifetime_listening_minutes ?? Infinity) ||
627
642
  !rediscoverySignals.has(representativeSignal) ||
628
643
  !isUuid(capsule.evidence_id)
629
644
  ) {
@@ -633,16 +648,16 @@ function safeHistoryTrack(raw) {
633
648
  year,
634
649
  year_play_count: yearPlayCount,
635
650
  year_engaged_play_count: yearEngagedPlayCount,
636
- year_listening_minutes: yearListeningMinutes,
637
- year_explicit_skips: yearExplicitSkips,
638
- lifetime_listening_minutes: lifetimeListeningMinutes,
651
+ ...yearListeningMinutes,
652
+ ...yearExplicitSkips,
653
+ ...lifetimeListeningMinutes,
639
654
  representative_signal: representativeSignal,
640
655
  evidence_id: capsule.evidence_id.toLowerCase(),
641
656
  };
642
657
  } else {
643
658
  const context = raw.back_to_back;
644
659
  const engagedPlayCount = safeHistoryCount(context.engaged_play_count);
645
- const explicitSkips = safeHistoryCount(context.explicit_skips);
660
+ const explicitSkips = optionalHistoryCount(context, "explicit_skips");
646
661
  const burstCount = safeHistoryCount(context.burst_count);
647
662
  const maximumConsecutivePlays = safeHistoryCount(
648
663
  context.maximum_consecutive_plays,
@@ -664,7 +679,7 @@ function safeHistoryTrack(raw) {
664
679
  playsInBursts < burstCount * 2 ||
665
680
  playsInBursts > playCount ||
666
681
  engagedPlayCount > playCount ||
667
- explicitSkips > playCount ||
682
+ (explicitSkips.explicit_skips ?? 0) > playCount ||
668
683
  !backToBackSignals.has(sequenceSignal) ||
669
684
  !isUuid(context.evidence_id)
670
685
  ) {
@@ -672,7 +687,7 @@ function safeHistoryTrack(raw) {
672
687
  }
673
688
  track.back_to_back = {
674
689
  engaged_play_count: engagedPlayCount,
675
- explicit_skips: explicitSkips,
690
+ ...explicitSkips,
676
691
  burst_count: burstCount,
677
692
  maximum_consecutive_plays: maximumConsecutivePlays,
678
693
  plays_in_bursts: playsInBursts,
@@ -970,7 +985,7 @@ function preferenceSignalLabel(observation = {}) {
970
985
  return labels.join(", ") || "explicit provider preference signal";
971
986
  }
972
987
 
973
- function listenerAssertionAvoids(
988
+ export function listenerAssertionAvoids(
974
989
  listening,
975
990
  entityType,
976
991
  label,
@@ -1195,6 +1210,8 @@ export class AppleProjectionDomainServices {
1195
1210
  #listeningHistoryStore;
1196
1211
  #subjectId;
1197
1212
  #candidateSets = new Map();
1213
+ #profileBuildInput;
1214
+ #ownsStores = true;
1198
1215
 
1199
1216
  constructor({ projection, listeningHistoryStore = null, subjectId = null }) {
1200
1217
  if (!projection) throw new TypeError("A trusted Apple projection is required");
@@ -1206,6 +1223,19 @@ export class AppleProjectionDomainServices {
1206
1223
  this.#subjectId = subjectId?.toLowerCase() ?? null;
1207
1224
  }
1208
1225
 
1226
+ // Each conversation that runs beside another gets a fork: the same projection and history
1227
+ // with its own candidate sets, so one conversation's prompt never clears another's. Only
1228
+ // the original closes them.
1229
+ fork() {
1230
+ const fork = new AppleProjectionDomainServices({
1231
+ projection: this.#projection,
1232
+ listeningHistoryStore: this.#listeningHistoryStore,
1233
+ subjectId: this.#subjectId,
1234
+ });
1235
+ fork.#ownsStores = false;
1236
+ return fork;
1237
+ }
1238
+
1209
1239
  status() {
1210
1240
  return {
1211
1241
  state: "ready",
@@ -1246,6 +1276,14 @@ export class AppleProjectionDomainServices {
1246
1276
  };
1247
1277
  }
1248
1278
 
1279
+ listeningCoverage() {
1280
+ return this.#listeningHistoryStore?.listeningCoverage?.({ subjectId: this.#subjectId }) ?? null;
1281
+ }
1282
+
1283
+ recentAttention() {
1284
+ return this.#listeningHistoryStore?.recentAttention?.({ subjectId: this.#subjectId }) ?? null;
1285
+ }
1286
+
1249
1287
  rediscoveryReady() {
1250
1288
  return (
1251
1289
  this.#listeningHistoryStore !== null &&
@@ -1726,7 +1764,23 @@ export class AppleProjectionDomainServices {
1726
1764
  };
1727
1765
  }
1728
1766
 
1729
- registerExternalCandidateSet({ tracks, source } = {}) {
1767
+ filterDiscoveryTracks(tracks) {
1768
+ 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
+ ));
1777
+ }
1778
+
1779
+ isKnownDiscoveryTrack(track) {
1780
+ return (this.#listeningHistoryStore?.listenedTracks?.({ subjectId: this.#subjectId }) ?? []).some(heard => exactTitleArtistMatch(track, heard));
1781
+ }
1782
+
1783
+ registerExternalCandidateSet({ tracks, source, excludeKnown = true } = {}) {
1730
1784
  if (
1731
1785
  !Array.isArray(tracks) ||
1732
1786
  tracks.length < 1 ||
@@ -1747,7 +1801,8 @@ export class AppleProjectionDomainServices {
1747
1801
  }
1748
1802
  const accepted = [];
1749
1803
  let excludedLibraryMatches = 0;
1750
- for (const track of prepared) {
1804
+ const allowed = this.filterDiscoveryTracks(prepared);
1805
+ for (const track of allowed) {
1751
1806
  const query = Array.from(track.title).slice(0, 256).join("");
1752
1807
  const libraryResults = this.#projection.searchLibrary({
1753
1808
  query,
@@ -1763,14 +1818,14 @@ export class AppleProjectionDomainServices {
1763
1818
  const libraryMatch = libraryResults.tracks
1764
1819
  .map(safeTrack)
1765
1820
  .some((candidate) => exactTitleArtistMatch(track, candidate));
1766
- if (libraryMatch) {
1821
+ if (excludeKnown && libraryMatch) {
1767
1822
  excludedLibraryMatches += 1;
1768
1823
  continue;
1769
1824
  }
1770
1825
  accepted.push({
1771
1826
  ...track,
1772
1827
  knownness: {
1773
- imported_library: "not_found_by_exact_title_artist",
1828
+ imported_library: libraryMatch ? "found_by_exact_title_artist" : "not_found_by_exact_title_artist",
1774
1829
  listening_history: "not_checked",
1775
1830
  },
1776
1831
  });
@@ -1780,6 +1835,7 @@ export class AppleProjectionDomainServices {
1780
1835
  candidate_set_id: null,
1781
1836
  candidate_scope: "external_catalog",
1782
1837
  result_count: 0,
1838
+ excluded_avoided_matches: prepared.length - allowed.length,
1783
1839
  excluded_library_matches: excludedLibraryMatches,
1784
1840
  expires_on: "prompt_end",
1785
1841
  source: trustedSource,
@@ -1797,6 +1853,7 @@ export class AppleProjectionDomainServices {
1797
1853
  candidate_set_id: candidateSetId,
1798
1854
  candidate_scope: "external_catalog",
1799
1855
  result_count: accepted.length,
1856
+ excluded_avoided_matches: prepared.length - allowed.length,
1800
1857
  excluded_library_matches: excludedLibraryMatches,
1801
1858
  expires_on: "prompt_end",
1802
1859
  source: trustedSource,
@@ -1893,6 +1950,59 @@ export class AppleProjectionDomainServices {
1893
1950
  };
1894
1951
  }
1895
1952
 
1953
+ #appleProfileCatalog(knownAvoids) {
1954
+ if (typeof this.#projection.getProfileCatalog !== "function") return null;
1955
+ const { listener_assertions: { avoids } } = normalizeAppleCorrectionLabels({
1956
+ listener_assertions: {
1957
+ avoids: knownAvoids ?? this.#listeningHistoryStore?.activeAvoidances?.({ subjectId: this.#subjectId }) ?? [],
1958
+ },
1959
+ }, this.#projection);
1960
+ return this.#projection.getProfileCatalog({ excludePreference: (track) =>
1961
+ listenerAssertionAvoids({ listener_assertions: { avoids } }, "track", track.label, track.artist_credit) });
1962
+ }
1963
+
1964
+ profileExplorationReady() {
1965
+ return typeof this.#projection.getProfileCatalog === "function";
1966
+ }
1967
+
1968
+ async exploreProfile(argumentsValue) {
1969
+ const input = profileExplorationInput(argumentsValue);
1970
+ const listening = this.#listeningHistoryStore?.profileCatalog({ subjectId: this.#subjectId });
1971
+ return exploreProfileCatalog({
1972
+ listening,
1973
+ apple: this.#appleProfileCatalog(listening?.sections.listener_avoids), input,
1974
+ });
1975
+ }
1976
+
1977
+ profileBuildReady() {
1978
+ return typeof this.#listeningHistoryStore?.listenerProfileStorage === "function" &&
1979
+ typeof this.#projection.logicalDigest === "function";
1980
+ }
1981
+
1982
+ getListenerProfileStore() {
1983
+ return this.#listeningHistoryStore.listenerProfileStorage({ subjectId: this.#subjectId });
1984
+ }
1985
+
1986
+ getProfileBuildContext() {
1987
+ const appleDigest = this.#projection.logicalDigest();
1988
+ const version = `${appleDigest}:${this.#listeningHistoryStore.profileDataVersion()}`;
1989
+ if (this.#profileBuildInput?.version !== version) {
1990
+ const listening = this.#listeningHistoryStore.profileCatalog({ subjectId: this.#subjectId });
1991
+ const normalized = normalizeAppleCorrectionLabels({ listener_assertions: {
1992
+ preferences: listening.sections.listener_preferences, avoids: listening.sections.listener_avoids,
1993
+ } }, this.#projection).listener_assertions;
1994
+ listening.sections.listener_preferences = normalized.preferences;
1995
+ listening.sections.listener_avoids = normalized.avoids;
1996
+ this.#profileBuildInput = { version, input: createListenerProfileInput({
1997
+ subjectId: this.#subjectId,
1998
+ evidenceRevision: [appleDigest,
1999
+ this.#listeningHistoryStore.profileEvidenceRevision({ subjectId: this.#subjectId })],
2000
+ listening, apple: this.#appleProfileCatalog(listening.sections.listener_avoids),
2001
+ }) };
2002
+ }
2003
+ return { store: this.getListenerProfileStore(), input: this.#profileBuildInput.input };
2004
+ }
2005
+
1896
2006
  async getProfileSummary(argumentsValue) {
1897
2007
  const input = modelArguments(argumentsValue, new Set(["maxItems"]));
1898
2008
  const bounded = boundedLimit(
@@ -1990,13 +2100,16 @@ export class AppleProjectionDomainServices {
1990
2100
  play_count: item.play_count,
1991
2101
  evidence_id: item.evidence_id,
1992
2102
  })) ?? [];
1993
- const appleArtistFacets = facetItems(
2103
+ const appleCatalog = this.#appleProfileCatalog();
2104
+ const appleArtistFacets = (appleCatalog
2105
+ ? appleCatalog.sections.apple_artists.filter((item) => item.preferred_tracks > 0)
2106
+ : facetItems(
1994
2107
  preferenceItems,
1995
2108
  (item) => item.artist_credit,
1996
2109
  bounded,
1997
- ).filter(
2110
+ )).filter(
1998
2111
  (item) => !listenerAssertionAvoids(listening, "artist", item.name),
1999
- );
2112
+ ).slice(0, bounded);
2000
2113
  const spotifyArtistFacets = [
2001
2114
  ...(listening?.curated_preferences.followed_artists ?? []),
2002
2115
  ...(listening?.listening_behavior.enduring_artists ?? []),
@@ -2009,7 +2122,9 @@ export class AppleProjectionDomainServices {
2009
2122
  name: item.name,
2010
2123
  evidence_id: item.evidence_id,
2011
2124
  }));
2012
- const appleGenreFacets = facetItems(
2125
+ const appleGenreFacets = appleCatalog
2126
+ ? appleCatalog.sections.apple_genres.filter((item) => item.preferred_tracks > 0).slice(0, bounded)
2127
+ : facetItems(
2013
2128
  preferenceItems,
2014
2129
  (item) => item.labels?.provider_genre,
2015
2130
  bounded,
@@ -2109,6 +2224,17 @@ export class AppleProjectionDomainServices {
2109
2224
  if (!isUuid(cleanedId)) {
2110
2225
  fail("invalid_evidence_id", "Profile evidence ID is invalid.");
2111
2226
  }
2227
+ const catalog = this.#appleProfileCatalog();
2228
+ const facet = [...(catalog?.sections.apple_artists ?? []), ...(catalog?.sections.apple_genres ?? [])]
2229
+ .find((item) => item.evidence_id === cleanedId);
2230
+ if (facet) return {
2231
+ evidence_id: facet.evidence_id,
2232
+ claim: { dimension: "taste.library_facet", value: facet.name, direction: "supports" },
2233
+ basis_summary: `${facet.preferred_tracks} tracks with positive provider preferences among ${facet.library_tracks} library tracks, after active Avoids. ${facet.play_count} aggregate plays across ${facet.tracks_with_play_count} tracks with known counts.`,
2234
+ derivation: { kind: "rule", name: "whole-apple-library-facet", version: "1" },
2235
+ confidence: 0.9,
2236
+ interpretation_limit: "A full-library metadata aggregation. Play counts show familiarity; provider preferences are snapshot states, and genre names do not describe measured audio.",
2237
+ };
2112
2238
  const raw = this.#projection.explainProfileEvidence({
2113
2239
  evidenceId: cleanedId,
2114
2240
  });
@@ -2258,6 +2384,11 @@ export class AppleProjectionDomainServices {
2258
2384
  "The playlist track count does not match the explicit user intent.",
2259
2385
  );
2260
2386
  }
2387
+ const external = selections.map((selection) => trustedTracks.get(selection.trackRefId))
2388
+ .filter((track) => track.candidate_scope === "external_catalog");
2389
+ if (this.filterDiscoveryTracks(external).length !== external.length) {
2390
+ fail("discovery_track_avoided", "An external discovery is currently marked Avoid. Choose another track.");
2391
+ }
2261
2392
  const tracks = selections.map((selection, index) => {
2262
2393
  const track = trustedTracks.get(selection.trackRefId);
2263
2394
  const result = {
@@ -2315,6 +2446,7 @@ export class AppleProjectionDomainServices {
2315
2446
 
2316
2447
  close() {
2317
2448
  this.resetCandidateSets();
2449
+ if (!this.#ownsStores) return;
2318
2450
  try {
2319
2451
  this.#projection.close();
2320
2452
  } finally {