@audiofool/moondog 0.1.0 → 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 (125) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +156 -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 +253 -53
  10. package/src/core/application-update.mjs +320 -0
  11. package/src/core/capability-catalog.mjs +290 -55
  12. package/src/core/listener-avoids.mjs +101 -0
  13. package/src/core/listening-now.mjs +108 -0
  14. package/src/core/listening-profile-domain-services.mjs +189 -41
  15. package/src/core/listening-upkeep.mjs +31 -0
  16. package/src/core/moondog-application.mjs +2115 -107
  17. package/src/core/playback-preferences.mjs +97 -0
  18. package/src/core/spotify-listening-intent.mjs +124 -0
  19. package/src/core/synthetic-domain-services.mjs +24 -3
  20. package/src/core/track-title.mjs +107 -0
  21. package/src/demo/moondog-demo.mjs +4 -22
  22. package/src/i18n/index.mjs +285 -0
  23. package/src/i18n/messages/de.mjs +135 -0
  24. package/src/i18n/messages/en.mjs +136 -0
  25. package/src/i18n/messages/es.mjs +135 -0
  26. package/src/i18n/messages/fr.mjs +135 -0
  27. package/src/i18n/messages/it.mjs +135 -0
  28. package/src/i18n/messages/ja.mjs +135 -0
  29. package/src/i18n/messages/ko.mjs +135 -0
  30. package/src/i18n/messages/pt.mjs +135 -0
  31. package/src/i18n/messages/zh-Hant.mjs +135 -0
  32. package/src/i18n/messages/zh.mjs +135 -0
  33. package/src/i18n/preferences.mjs +34 -0
  34. package/src/i18n/screen/es.mjs +967 -0
  35. package/src/i18n/screen/ja.mjs +967 -0
  36. package/src/i18n/screen/pt.mjs +967 -0
  37. package/src/i18n/screen/zh.mjs +967 -0
  38. package/src/importers/apple-music-library/index.mjs +0 -16
  39. package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
  40. package/src/integrations/apple-music/catalog.mjs +166 -14
  41. package/src/integrations/apple-music/music-app.mjs +232 -0
  42. package/src/integrations/apple-music/storefront.mjs +28 -0
  43. package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
  44. package/src/integrations/lastfm/history.mjs +221 -0
  45. package/src/integrations/lastfm/settings.mjs +75 -0
  46. package/src/integrations/netease/ncm-cli-player.mjs +328 -0
  47. package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
  48. package/src/integrations/public-playlists.mjs +2 -1
  49. package/src/integrations/spotify/account-data-profile.mjs +14 -6
  50. package/src/integrations/spotify/authentication.mjs +149 -15
  51. package/src/integrations/spotify/catalog-resolver.mjs +9 -6
  52. package/src/integrations/spotify/connection.mjs +2 -1
  53. package/src/integrations/spotify/credential-store.mjs +4 -0
  54. package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
  55. package/src/integrations/spotify/history-archive.mjs +1 -1
  56. package/src/integrations/spotify/library-snapshot.mjs +227 -0
  57. package/src/integrations/spotify/recent-activity.mjs +2 -0
  58. package/src/integrations/spotify/service.mjs +873 -69
  59. package/src/integrations/spotify/web-api-client.mjs +410 -127
  60. package/src/integrations/web/brave-web.mjs +45 -0
  61. package/src/integrations/web/codex-web.mjs +15 -82
  62. package/src/integrations/web/deepseek-web.mjs +81 -0
  63. package/src/integrations/web/web-research.mjs +119 -0
  64. package/src/integrations/web/web-result.mjs +215 -0
  65. package/src/integrations/youtube-music/takeout.mjs +3 -1
  66. package/src/memory/local-memory-store.mjs +542 -57
  67. package/src/profile/history-import.mjs +42 -6
  68. package/src/profile/listener-corrections.mjs +22 -0
  69. package/src/profile/listener-profile-build.mjs +461 -0
  70. package/src/profile/listener-profile-store.mjs +130 -0
  71. package/src/profile/listener-profile-verification.mjs +68 -0
  72. package/src/profile/listener-profile-worker.mjs +179 -0
  73. package/src/profile/listening-history-store.mjs +640 -17
  74. package/src/profile/listening-profile-projection.mjs +454 -86
  75. package/src/profile/local-music-data.mjs +1 -1
  76. package/src/profile/moondog-suggestions.mjs +184 -0
  77. package/src/profile/music-providers.mjs +11 -7
  78. package/src/profile/profile-exploration.mjs +92 -0
  79. package/src/profile/recorded-measures.mjs +24 -0
  80. package/src/profile/spotify-archive-taste.mjs +0 -4
  81. package/src/runtime/pi/agent-runtime.mjs +2536 -692
  82. package/src/runtime/pi/authentication.mjs +34 -1
  83. package/src/runtime/pi/configured-runtime.mjs +22 -6
  84. package/src/runtime/pi/conversation-name.mjs +53 -0
  85. package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
  86. package/src/runtime/pi/model-catalog.mjs +28 -5
  87. package/src/runtime/pi/model-settings.mjs +202 -0
  88. package/src/runtime/pi/model-transport.mjs +10 -0
  89. package/src/runtime/pi/persistent-credential-store.mjs +67 -31
  90. package/src/runtime/pi/profile-build-runtime.mjs +161 -0
  91. package/src/runtime/pi/public-model-catalog.mjs +288 -0
  92. package/src/runtime/pi/runtime-settings.mjs +10 -2
  93. package/src/runtime/pi/skills/listener-profile/SKILL.md +48 -0
  94. package/src/runtime/pi/tool-trace.mjs +37 -0
  95. package/src/surfaces/cli/auth-command.mjs +12 -4
  96. package/src/surfaces/cli/brand-components.mjs +310 -132
  97. package/src/surfaces/cli/brand-theme.mjs +51 -28
  98. package/src/surfaces/cli/command-completions.mjs +58 -42
  99. package/src/surfaces/cli/format-output.mjs +199 -388
  100. package/src/surfaces/cli/history-import-view.mjs +292 -139
  101. package/src/surfaces/cli/import-guides.mjs +2 -0
  102. package/src/surfaces/cli/listener-profile-command.mjs +92 -0
  103. package/src/surfaces/cli/listening-menu.mjs +7 -4
  104. package/src/surfaces/cli/profile-command.mjs +30 -21
  105. package/src/surfaces/cli/seasons-chart.mjs +87 -0
  106. package/src/surfaces/cli/spotify-command.mjs +54 -6
  107. package/src/surfaces/cli/taste-profile-model.mjs +78 -79
  108. package/src/surfaces/cli/taste-profile-view.mjs +33 -26
  109. package/src/surfaces/cli/terminal-art.mjs +140 -35
  110. package/src/surfaces/cli/tui.mjs +1066 -357
  111. package/src/surfaces/cli/update-command.mjs +32 -0
  112. package/src/surfaces/cli/web-command.mjs +53 -14
  113. package/src/surfaces/html/tasteprint.mjs +43 -70
  114. package/contracts/v1/capability-policy.schema.json +0 -572
  115. package/contracts/v1/profile-evidence.schema.json +0 -237
  116. package/contracts/v1/tool-invocation.schema.json +0 -318
  117. package/contracts/v1/tool-receipt.schema.json +0 -1066
  118. package/scripts/demo-studio.mjs +0 -58
  119. package/src/core/apple-data-migration.mjs +0 -50
  120. package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
  121. package/src/demo/moondog-tasteprint-demo.mjs +0 -631
  122. package/src/integrations/spotify/index.mjs +0 -80
  123. package/src/surfaces/web/showcase.mjs +0 -272
  124. package/src/surfaces/web/studio-page.mjs +0 -4148
  125. package/src/surfaces/web/studio.mjs +0 -2288
@@ -1,5 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { exploreProfileCatalog, profileExplorationInput } from "../profile/profile-exploration.mjs";
3
+ import { createListenerProfileInput, savedListenerChoices } from "../profile/listener-profile-build.mjs";
2
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";
3
8
  import { collectionCoverage } from "../profile/music-providers.mjs";
4
9
  import { lstat } from "node:fs/promises";
5
10
  import path from "node:path";
@@ -408,6 +413,11 @@ function safeHistoryCount(value) {
408
413
  return value;
409
414
  }
410
415
 
416
+ // A pick leaves out minutes or skips that its plays never recorded.
417
+ function optionalHistoryCount(context, key) {
418
+ return context[key] === undefined ? {} : { [key]: safeHistoryCount(context[key]) };
419
+ }
420
+
411
421
  function safeHistoryExternalRefs(value) {
412
422
  if (value === undefined) return [];
413
423
  if (!Array.isArray(value)) {
@@ -487,10 +497,13 @@ function safeHistoryTrack(raw) {
487
497
  if (hasRediscovery) {
488
498
  const rediscovery = raw.rediscovery;
489
499
  const engagedPlayCount = safeHistoryCount(rediscovery.engaged_play_count);
490
- const explicitSkips = safeHistoryCount(rediscovery.explicit_skips);
491
- const listeningMinutes = safeHistoryCount(rediscovery.listening_minutes);
500
+ const explicitSkips = optionalHistoryCount(rediscovery, "explicit_skips");
501
+ const listeningMinutes = optionalHistoryCount(rediscovery, "listening_minutes");
492
502
  const quietDays = safeHistoryCount(rediscovery.quiet_days);
493
- if (engagedPlayCount > playCount || explicitSkips > playCount) {
503
+ if (
504
+ engagedPlayCount > playCount ||
505
+ (explicitSkips.explicit_skips ?? 0) > playCount
506
+ ) {
494
507
  fail("history_candidate_invalid", "A listening-history candidate is invalid.");
495
508
  }
496
509
  const rediscoverySignal = cleanText(
@@ -512,7 +525,9 @@ function safeHistoryTrack(raw) {
512
525
  const peakFields = [
513
526
  rediscovery.peak_year,
514
527
  rediscovery.peak_year_play_count,
515
- rediscovery.peak_year_listening_minutes,
528
+ ...(rediscovery.peak_year_listening_minutes === undefined
529
+ ? []
530
+ : [rediscovery.peak_year_listening_minutes]),
516
531
  ];
517
532
  if (peakFields.some((value) => value !== undefined)) {
518
533
  if (
@@ -524,9 +539,9 @@ function safeHistoryTrack(raw) {
524
539
  }
525
540
  }
526
541
  track.rediscovery = {
527
- listening_minutes: listeningMinutes,
542
+ ...listeningMinutes,
528
543
  engaged_play_count: engagedPlayCount,
529
- explicit_skips: explicitSkips,
544
+ ...explicitSkips,
530
545
  first_played_at: firstPlayedAt,
531
546
  last_played_at: lastPlayedAt,
532
547
  quiet_days: quietDays,
@@ -535,8 +550,7 @@ function safeHistoryTrack(raw) {
535
550
  ? {
536
551
  peak_year: rediscovery.peak_year,
537
552
  peak_year_play_count: rediscovery.peak_year_play_count,
538
- peak_year_listening_minutes:
539
- rediscovery.peak_year_listening_minutes,
553
+ ...optionalHistoryCount(rediscovery, "peak_year_listening_minutes"),
540
554
  }
541
555
  : {}),
542
556
  evidence_id: rediscovery.evidence_id.toLowerCase(),
@@ -546,9 +560,10 @@ function safeHistoryTrack(raw) {
546
560
  const engagedPlayCount = safeHistoryCount(
547
561
  historicalReturn.engaged_play_count,
548
562
  );
549
- const explicitSkips = safeHistoryCount(historicalReturn.explicit_skips);
550
- const listeningMinutes = safeHistoryCount(
551
- historicalReturn.listening_minutes,
563
+ const explicitSkips = optionalHistoryCount(historicalReturn, "explicit_skips");
564
+ const listeningMinutes = optionalHistoryCount(
565
+ historicalReturn,
566
+ "listening_minutes",
552
567
  );
553
568
  const returnCount = safeHistoryCount(historicalReturn.return_count);
554
569
  const longestGapDays = safeHistoryCount(
@@ -577,7 +592,7 @@ function safeHistoryTrack(raw) {
577
592
  latestReturnGapDays < 1 ||
578
593
  latestReturnGapDays > longestGapDays ||
579
594
  engagedPlayCount > playCount ||
580
- explicitSkips > playCount ||
595
+ (explicitSkips.explicit_skips ?? 0) > playCount ||
581
596
  Date.parse(firstPlayedAt) > Date.parse(latestReturnAt) ||
582
597
  Date.parse(latestReturnAt) > Date.parse(lastPlayedAt) ||
583
598
  !rediscoverySignals.has(historicalReturnSignal) ||
@@ -586,9 +601,9 @@ function safeHistoryTrack(raw) {
586
601
  fail("history_candidate_invalid", "A listening-history candidate is invalid.");
587
602
  }
588
603
  track.historical_return = {
589
- listening_minutes: listeningMinutes,
604
+ ...listeningMinutes,
590
605
  engaged_play_count: engagedPlayCount,
591
- explicit_skips: explicitSkips,
606
+ ...explicitSkips,
592
607
  first_played_at: firstPlayedAt,
593
608
  last_played_at: lastPlayedAt,
594
609
  return_count: returnCount,
@@ -605,12 +620,14 @@ function safeHistoryTrack(raw) {
605
620
  const yearEngagedPlayCount = safeHistoryCount(
606
621
  capsule.year_engaged_play_count,
607
622
  );
608
- const yearListeningMinutes = safeHistoryCount(
609
- capsule.year_listening_minutes,
623
+ const yearListeningMinutes = optionalHistoryCount(
624
+ capsule,
625
+ "year_listening_minutes",
610
626
  );
611
- const yearExplicitSkips = safeHistoryCount(capsule.year_explicit_skips);
612
- const lifetimeListeningMinutes = safeHistoryCount(
613
- capsule.lifetime_listening_minutes,
627
+ const yearExplicitSkips = optionalHistoryCount(capsule, "year_explicit_skips");
628
+ const lifetimeListeningMinutes = optionalHistoryCount(
629
+ capsule,
630
+ "lifetime_listening_minutes",
614
631
  );
615
632
  const representativeSignal = cleanText(
616
633
  capsule.representative_signal,
@@ -622,8 +639,9 @@ function safeHistoryTrack(raw) {
622
639
  year > 9999 ||
623
640
  yearPlayCount > playCount ||
624
641
  yearEngagedPlayCount > yearPlayCount ||
625
- yearExplicitSkips > yearPlayCount ||
626
- yearListeningMinutes > lifetimeListeningMinutes ||
642
+ (yearExplicitSkips.year_explicit_skips ?? 0) > yearPlayCount ||
643
+ (yearListeningMinutes.year_listening_minutes ?? 0) >
644
+ (lifetimeListeningMinutes.lifetime_listening_minutes ?? Infinity) ||
627
645
  !rediscoverySignals.has(representativeSignal) ||
628
646
  !isUuid(capsule.evidence_id)
629
647
  ) {
@@ -633,16 +651,16 @@ function safeHistoryTrack(raw) {
633
651
  year,
634
652
  year_play_count: yearPlayCount,
635
653
  year_engaged_play_count: yearEngagedPlayCount,
636
- year_listening_minutes: yearListeningMinutes,
637
- year_explicit_skips: yearExplicitSkips,
638
- lifetime_listening_minutes: lifetimeListeningMinutes,
654
+ ...yearListeningMinutes,
655
+ ...yearExplicitSkips,
656
+ ...lifetimeListeningMinutes,
639
657
  representative_signal: representativeSignal,
640
658
  evidence_id: capsule.evidence_id.toLowerCase(),
641
659
  };
642
660
  } else {
643
661
  const context = raw.back_to_back;
644
662
  const engagedPlayCount = safeHistoryCount(context.engaged_play_count);
645
- const explicitSkips = safeHistoryCount(context.explicit_skips);
663
+ const explicitSkips = optionalHistoryCount(context, "explicit_skips");
646
664
  const burstCount = safeHistoryCount(context.burst_count);
647
665
  const maximumConsecutivePlays = safeHistoryCount(
648
666
  context.maximum_consecutive_plays,
@@ -664,7 +682,7 @@ function safeHistoryTrack(raw) {
664
682
  playsInBursts < burstCount * 2 ||
665
683
  playsInBursts > playCount ||
666
684
  engagedPlayCount > playCount ||
667
- explicitSkips > playCount ||
685
+ (explicitSkips.explicit_skips ?? 0) > playCount ||
668
686
  !backToBackSignals.has(sequenceSignal) ||
669
687
  !isUuid(context.evidence_id)
670
688
  ) {
@@ -672,7 +690,7 @@ function safeHistoryTrack(raw) {
672
690
  }
673
691
  track.back_to_back = {
674
692
  engaged_play_count: engagedPlayCount,
675
- explicit_skips: explicitSkips,
693
+ ...explicitSkips,
676
694
  burst_count: burstCount,
677
695
  maximum_consecutive_plays: maximumConsecutivePlays,
678
696
  plays_in_bursts: playsInBursts,
@@ -797,9 +815,10 @@ function normalizedArtistNames(value) {
797
815
  .filter(Boolean);
798
816
  }
799
817
 
818
+ // A remaster or live take of a song counts as the song (src/core/track-title.mjs).
800
819
  export function exactTitleArtistMatch(left, right) {
801
- const leftTitle = normalizeText(left.title);
802
- const rightTitle = normalizeText(right.title);
820
+ const leftTitle = normalizedTrackTitle(left.title);
821
+ const rightTitle = normalizedTrackTitle(right.title);
803
822
  const leftArtists = normalizedArtistNames(left.artist_credit);
804
823
  const rightArtists = new Set(normalizedArtistNames(right.artist_credit));
805
824
  return (
@@ -868,13 +887,14 @@ export function safeExternalSource(raw) {
868
887
  fail("external_candidate_invalid", "External catalog source metadata is invalid.");
869
888
  }
870
889
  if (raw.provider === "apple_music") {
871
- 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)) {
872
892
  fail("external_candidate_invalid", "External catalog source metadata is invalid.");
873
893
  }
874
894
  return {
875
895
  provider: "apple_music",
876
896
  catalog: "itunes_search_api",
877
- storefront: "US",
897
+ storefront: raw.storefront,
878
898
  retrieved_at: raw.retrieved_at,
879
899
  coverage: cleanText(raw.coverage, 500, "external_candidate_invalid"),
880
900
  };
@@ -970,26 +990,20 @@ function preferenceSignalLabel(observation = {}) {
970
990
  return labels.join(", ") || "explicit provider preference signal";
971
991
  }
972
992
 
973
- function listenerAssertionAvoids(
993
+ const avoidGates = new WeakMap();
994
+
995
+ export function listenerAssertionAvoids(
974
996
  listening,
975
997
  entityType,
976
998
  label,
977
999
  artistCredit = "",
978
1000
  ) {
979
1001
  const avoids = listening?.listener_assertions?.avoids ?? [];
980
- const normalizedLabel = normalizeText(label ?? "");
981
- const normalizedArtist = normalizeText(artistCredit ?? "");
982
- return avoids.some((avoid) => {
983
- if (avoid.entity_type === "artist") {
984
- const avoidedArtist = normalizeText(avoid.label ?? "");
985
- return entityType === "artist"
986
- ? avoidedArtist === normalizedLabel
987
- : avoidedArtist === normalizedArtist;
988
- }
989
- return entityType === "track" &&
990
- normalizeText(avoid.label ?? "") === normalizedLabel &&
991
- normalizeText(avoid.artist_credit ?? "") === normalizedArtist;
992
- });
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 });
993
1007
  }
994
1008
 
995
1009
  function normalizeAppleCorrectionLabels(listening, projection) {
@@ -1195,6 +1209,10 @@ export class AppleProjectionDomainServices {
1195
1209
  #listeningHistoryStore;
1196
1210
  #subjectId;
1197
1211
  #candidateSets = new Map();
1212
+ #profileBuildInput;
1213
+ #avoids = {};
1214
+ #status = {};
1215
+ #ownsStores = true;
1198
1216
 
1199
1217
  constructor({ projection, listeningHistoryStore = null, subjectId = null }) {
1200
1218
  if (!projection) throw new TypeError("A trusted Apple projection is required");
@@ -1206,6 +1224,19 @@ export class AppleProjectionDomainServices {
1206
1224
  this.#subjectId = subjectId?.toLowerCase() ?? null;
1207
1225
  }
1208
1226
 
1227
+ // Each conversation that runs beside another gets a fork: the same projection and history
1228
+ // with its own candidate sets, so one conversation's prompt never clears another's. Only
1229
+ // the original closes them.
1230
+ fork() {
1231
+ const fork = new AppleProjectionDomainServices({
1232
+ projection: this.#projection,
1233
+ listeningHistoryStore: this.#listeningHistoryStore,
1234
+ subjectId: this.#subjectId,
1235
+ });
1236
+ fork.#ownsStores = false;
1237
+ return fork;
1238
+ }
1239
+
1209
1240
  status() {
1210
1241
  return {
1211
1242
  state: "ready",
@@ -1216,7 +1247,17 @@ export class AppleProjectionDomainServices {
1216
1247
  };
1217
1248
  }
1218
1249
 
1250
+ // Every model request reads the status, so it is kept until the library or the listening data changes.
1219
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() {
1220
1261
  const summary = this.#projection.getProfileSummary({ maxItems: 1 });
1221
1262
  const listening = this.#listeningHistoryStore?.profileSummary({
1222
1263
  subjectId: this.#subjectId,
@@ -1246,6 +1287,51 @@ export class AppleProjectionDomainServices {
1246
1287
  };
1247
1288
  }
1248
1289
 
1290
+ listeningCoverage() {
1291
+ return this.#listeningHistoryStore?.listeningCoverage?.({ subjectId: this.#subjectId }) ?? null;
1292
+ }
1293
+
1294
+ recentAttention() {
1295
+ return this.#listeningHistoryStore?.recentAttention?.({ subjectId: this.#subjectId }) ?? null;
1296
+ }
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
+
1249
1335
  rediscoveryReady() {
1250
1336
  return (
1251
1337
  this.#listeningHistoryStore !== null &&
@@ -1726,7 +1812,43 @@ export class AppleProjectionDomainServices {
1726
1812
  };
1727
1813
  }
1728
1814
 
1729
- registerExternalCandidateSet({ tracks, source } = {}) {
1815
+ // Every play path asks for each candidate, so the gate is kept until the data changes.
1816
+ filterDiscoveryTracks(tracks) {
1817
+ const store = this.#listeningHistoryStore;
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 }));
1845
+ }
1846
+
1847
+ isKnownDiscoveryTrack(track) {
1848
+ return (this.#listeningHistoryStore?.listenedTracks?.({ subjectId: this.#subjectId }) ?? []).some(heard => exactTitleArtistMatch(track, heard));
1849
+ }
1850
+
1851
+ registerExternalCandidateSet({ tracks, source, excludeKnown = true } = {}) {
1730
1852
  if (
1731
1853
  !Array.isArray(tracks) ||
1732
1854
  tracks.length < 1 ||
@@ -1747,7 +1869,8 @@ export class AppleProjectionDomainServices {
1747
1869
  }
1748
1870
  const accepted = [];
1749
1871
  let excludedLibraryMatches = 0;
1750
- for (const track of prepared) {
1872
+ const allowed = this.filterDiscoveryTracks(prepared);
1873
+ for (const track of allowed) {
1751
1874
  const query = Array.from(track.title).slice(0, 256).join("");
1752
1875
  const libraryResults = this.#projection.searchLibrary({
1753
1876
  query,
@@ -1763,14 +1886,14 @@ export class AppleProjectionDomainServices {
1763
1886
  const libraryMatch = libraryResults.tracks
1764
1887
  .map(safeTrack)
1765
1888
  .some((candidate) => exactTitleArtistMatch(track, candidate));
1766
- if (libraryMatch) {
1889
+ if (excludeKnown && libraryMatch) {
1767
1890
  excludedLibraryMatches += 1;
1768
1891
  continue;
1769
1892
  }
1770
1893
  accepted.push({
1771
1894
  ...track,
1772
1895
  knownness: {
1773
- imported_library: "not_found_by_exact_title_artist",
1896
+ imported_library: libraryMatch ? "found_by_exact_title_artist" : "not_found_by_exact_title_artist",
1774
1897
  listening_history: "not_checked",
1775
1898
  },
1776
1899
  });
@@ -1780,6 +1903,7 @@ export class AppleProjectionDomainServices {
1780
1903
  candidate_set_id: null,
1781
1904
  candidate_scope: "external_catalog",
1782
1905
  result_count: 0,
1906
+ excluded_avoided_matches: prepared.length - allowed.length,
1783
1907
  excluded_library_matches: excludedLibraryMatches,
1784
1908
  expires_on: "prompt_end",
1785
1909
  source: trustedSource,
@@ -1797,6 +1921,7 @@ export class AppleProjectionDomainServices {
1797
1921
  candidate_set_id: candidateSetId,
1798
1922
  candidate_scope: "external_catalog",
1799
1923
  result_count: accepted.length,
1924
+ excluded_avoided_matches: prepared.length - allowed.length,
1800
1925
  excluded_library_matches: excludedLibraryMatches,
1801
1926
  expires_on: "prompt_end",
1802
1927
  source: trustedSource,
@@ -1893,6 +2018,59 @@ export class AppleProjectionDomainServices {
1893
2018
  };
1894
2019
  }
1895
2020
 
2021
+ #appleProfileCatalog(knownAvoids) {
2022
+ if (typeof this.#projection.getProfileCatalog !== "function") return null;
2023
+ const { listener_assertions: { avoids } } = normalizeAppleCorrectionLabels({
2024
+ listener_assertions: {
2025
+ avoids: knownAvoids ?? this.#listeningHistoryStore?.activeAvoidances?.({ subjectId: this.#subjectId }) ?? [],
2026
+ },
2027
+ }, this.#projection);
2028
+ return this.#projection.getProfileCatalog({ excludePreference: (track) =>
2029
+ listenerAssertionAvoids({ listener_assertions: { avoids } }, "track", track.label, track.artist_credit) });
2030
+ }
2031
+
2032
+ profileExplorationReady() {
2033
+ return typeof this.#projection.getProfileCatalog === "function";
2034
+ }
2035
+
2036
+ async exploreProfile(argumentsValue) {
2037
+ const input = profileExplorationInput(argumentsValue);
2038
+ const listening = this.#listeningHistoryStore?.profileCatalog({ subjectId: this.#subjectId });
2039
+ return exploreProfileCatalog({
2040
+ listening,
2041
+ apple: this.#appleProfileCatalog(listening?.sections.listener_avoids), input,
2042
+ });
2043
+ }
2044
+
2045
+ profileBuildReady() {
2046
+ return typeof this.#listeningHistoryStore?.listenerProfileStorage === "function" &&
2047
+ typeof this.#projection.logicalDigest === "function";
2048
+ }
2049
+
2050
+ getListenerProfileStore() {
2051
+ return this.#listeningHistoryStore.listenerProfileStorage({ subjectId: this.#subjectId });
2052
+ }
2053
+
2054
+ getProfileBuildContext() {
2055
+ const appleDigest = this.#projection.logicalDigest();
2056
+ const version = `${appleDigest}:${listeningVersion(this.#listeningHistoryStore)}`;
2057
+ if (this.#profileBuildInput?.version !== version) {
2058
+ const listening = this.#listeningHistoryStore.profileCatalog({ subjectId: this.#subjectId });
2059
+ const normalized = normalizeAppleCorrectionLabels({ listener_assertions: {
2060
+ preferences: listening.sections.listener_preferences, avoids: listening.sections.listener_avoids,
2061
+ } }, this.#projection).listener_assertions;
2062
+ listening.sections.listener_preferences = normalized.preferences;
2063
+ listening.sections.listener_avoids = normalized.avoids;
2064
+ this.#profileBuildInput = { version, input: createListenerProfileInput({
2065
+ subjectId: this.#subjectId,
2066
+ evidenceRevision: [appleDigest,
2067
+ this.#listeningHistoryStore.profileEvidenceRevision({ subjectId: this.#subjectId })],
2068
+ listening, apple: this.#appleProfileCatalog(listening.sections.listener_avoids),
2069
+ }) };
2070
+ }
2071
+ return { store: this.getListenerProfileStore(), input: this.#profileBuildInput.input };
2072
+ }
2073
+
1896
2074
  async getProfileSummary(argumentsValue) {
1897
2075
  const input = modelArguments(argumentsValue, new Set(["maxItems"]));
1898
2076
  const bounded = boundedLimit(
@@ -1990,13 +2168,16 @@ export class AppleProjectionDomainServices {
1990
2168
  play_count: item.play_count,
1991
2169
  evidence_id: item.evidence_id,
1992
2170
  })) ?? [];
1993
- const appleArtistFacets = facetItems(
2171
+ const appleCatalog = this.#appleProfileCatalog();
2172
+ const appleArtistFacets = (appleCatalog
2173
+ ? appleCatalog.sections.apple_artists.filter((item) => item.preferred_tracks > 0)
2174
+ : facetItems(
1994
2175
  preferenceItems,
1995
2176
  (item) => item.artist_credit,
1996
2177
  bounded,
1997
- ).filter(
2178
+ )).filter(
1998
2179
  (item) => !listenerAssertionAvoids(listening, "artist", item.name),
1999
- );
2180
+ ).slice(0, bounded);
2000
2181
  const spotifyArtistFacets = [
2001
2182
  ...(listening?.curated_preferences.followed_artists ?? []),
2002
2183
  ...(listening?.listening_behavior.enduring_artists ?? []),
@@ -2009,7 +2190,9 @@ export class AppleProjectionDomainServices {
2009
2190
  name: item.name,
2010
2191
  evidence_id: item.evidence_id,
2011
2192
  }));
2012
- const appleGenreFacets = facetItems(
2193
+ const appleGenreFacets = appleCatalog
2194
+ ? appleCatalog.sections.apple_genres.filter((item) => item.preferred_tracks > 0).slice(0, bounded)
2195
+ : facetItems(
2013
2196
  preferenceItems,
2014
2197
  (item) => item.labels?.provider_genre,
2015
2198
  bounded,
@@ -2109,6 +2292,17 @@ export class AppleProjectionDomainServices {
2109
2292
  if (!isUuid(cleanedId)) {
2110
2293
  fail("invalid_evidence_id", "Profile evidence ID is invalid.");
2111
2294
  }
2295
+ const catalog = this.#appleProfileCatalog();
2296
+ const facet = [...(catalog?.sections.apple_artists ?? []), ...(catalog?.sections.apple_genres ?? [])]
2297
+ .find((item) => item.evidence_id === cleanedId);
2298
+ if (facet) return {
2299
+ evidence_id: facet.evidence_id,
2300
+ claim: { dimension: "taste.library_facet", value: facet.name, direction: "supports" },
2301
+ 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.`,
2302
+ derivation: { kind: "rule", name: "whole-apple-library-facet", version: "1" },
2303
+ confidence: 0.9,
2304
+ interpretation_limit: "A full-library metadata aggregation. Play counts show familiarity; provider preferences are snapshot states, and genre names do not describe measured audio.",
2305
+ };
2112
2306
  const raw = this.#projection.explainProfileEvidence({
2113
2307
  evidenceId: cleanedId,
2114
2308
  });
@@ -2258,6 +2452,11 @@ export class AppleProjectionDomainServices {
2258
2452
  "The playlist track count does not match the explicit user intent.",
2259
2453
  );
2260
2454
  }
2455
+ const external = selections.map((selection) => trustedTracks.get(selection.trackRefId))
2456
+ .filter((track) => track.candidate_scope === "external_catalog");
2457
+ if (this.filterDiscoveryTracks(external).length !== external.length) {
2458
+ fail("discovery_track_avoided", "An external discovery is currently marked Avoid. Choose another track.");
2459
+ }
2261
2460
  const tracks = selections.map((selection, index) => {
2262
2461
  const track = trustedTracks.get(selection.trackRefId);
2263
2462
  const result = {
@@ -2315,6 +2514,7 @@ export class AppleProjectionDomainServices {
2315
2514
 
2316
2515
  close() {
2317
2516
  this.resetCandidateSets();
2517
+ if (!this.#ownsStores) return;
2318
2518
  try {
2319
2519
  this.#projection.close();
2320
2520
  } finally {