@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
@@ -0,0 +1,285 @@
1
+ import de from "./messages/de.mjs";
2
+ import en from "./messages/en.mjs";
3
+ import es from "./messages/es.mjs";
4
+ import fr from "./messages/fr.mjs";
5
+ import it from "./messages/it.mjs";
6
+ import ja from "./messages/ja.mjs";
7
+ import ko from "./messages/ko.mjs";
8
+ import pt from "./messages/pt.mjs";
9
+ import zhHant from "./messages/zh-Hant.mjs";
10
+ import zh from "./messages/zh.mjs";
11
+ import esScreen from "./screen/es.mjs";
12
+ import jaScreen from "./screen/ja.mjs";
13
+ import ptScreen from "./screen/pt.mjs";
14
+ import zhScreen from "./screen/zh.mjs";
15
+
16
+ // English is the source language; every other catalog falls back to it.
17
+ export const DEFAULT_LOCALE = "en";
18
+ export const LOCALES = Object.freeze({
19
+ en: Object.freeze({ name: "English", nativeName: "English" }),
20
+ es: Object.freeze({ name: "Spanish", nativeName: "Español" }),
21
+ ja: Object.freeze({ name: "Japanese", nativeName: "日本語" }),
22
+ pt: Object.freeze({ name: "Brazilian Portuguese", nativeName: "Português (Brasil)" }),
23
+ zh: Object.freeze({ name: "Simplified Chinese", nativeName: "简体中文" }),
24
+ "zh-Hant": Object.freeze({ name: "Traditional Chinese", nativeName: "繁體中文" }),
25
+ ko: Object.freeze({ name: "Korean", nativeName: "한국어" }),
26
+ fr: Object.freeze({ name: "French", nativeName: "Français" }),
27
+ de: Object.freeze({ name: "German", nativeName: "Deutsch" }),
28
+ it: Object.freeze({ name: "Italian", nativeName: "Italiano" }),
29
+ });
30
+ const CATALOGS = { en, es, ja, pt, zh, "zh-Hant": zhHant, ko, fr, de, it };
31
+ // Screen text is keyed by its English sentence, gettext style; English needs no file.
32
+ // Replies come in every locale; the terminal's own screen text exists in these.
33
+ const SCREEN = { en: {}, es: esScreen, ja: jaScreen, pt: ptScreen, zh: zhScreen };
34
+ export const SCREEN_LOCALES = Object.freeze(Object.keys(SCREEN));
35
+ // Traditional Chinese falls back to Simplified before English.
36
+ const PARENT = { "zh-Hant": "zh" };
37
+
38
+ /** The Intl tag for numbers and plurals: Simplified is mainland, Traditional is Taiwan, Portuguese is Brazilian. */
39
+ export function intlLocale(code) {
40
+ return { zh: "zh-CN", "zh-Hant": "zh-TW", pt: "pt-BR" }[code] ?? code;
41
+ }
42
+
43
+ const TRADITIONAL_CHINESE = new Set(["hant", "tw", "hk", "mo"]);
44
+
45
+ /** "es_ES.UTF-8", "es-MX" and "ES" all mean Spanish; "zh_TW" and "zh-Hant" mean Traditional Chinese; anything unsupported is null. */
46
+ export function normalizeLocale(value) {
47
+ if (typeof value !== "string") return null;
48
+ const [language, ...rest] = value.trim().toLowerCase().split(/[-_.@]/u);
49
+ if (language === "zh") return !rest.includes("hans") && rest.some(part => TRADITIONAL_CHINESE.has(part)) ? "zh-Hant" : "zh";
50
+ return Object.hasOwn(LOCALES, language) ? language : null;
51
+ }
52
+
53
+ /** The locale a fresh install uses: an explicit override, then the system's own language. */
54
+ export function systemLocale(environment = process.env) {
55
+ for (const name of ["MOONDOG_LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"]) {
56
+ // The C locale is a deliberate choice of plain English, not an unset value.
57
+ if (/^(?:c|posix)(?:[.@]|$)/iu.test(environment[name] ?? "")) return DEFAULT_LOCALE;
58
+ const locale = normalizeLocale(environment[name]);
59
+ if (locale) return locale;
60
+ }
61
+ return DEFAULT_LOCALE;
62
+ }
63
+
64
+ // Each language's name in the languages Moondog speaks. Traditional Chinese
65
+ // comes before Chinese, because its names contain the word for Chinese.
66
+ const LANGUAGE_NAMES = [
67
+ ["zh-Hant", /\btraditional\s+chinese\b|\bchino\s+tradicional\b|\bchin[eê]s\s+tradicional\b|\bchinois\s+traditionnel\b|\btraditionell\p{L}*\s+chinesisch\b|\bcinese\s+tradizionale\b|繁體|繁体|正體|번체/iu],
68
+ ["zh", /\b(?:chinese|mandarin|chino|chin[eê]s|chinois|chinesisch|cinese)\b|中文|汉语|漢語|普通话|普通話|华语|華語|中国語|중국어/iu],
69
+ ["en", /\b(?:english|ingl[eéê]s|anglais|englisch|inglese)\b|英文|英语|英語|영어/iu],
70
+ ["es", /\b(?:spanish|espa[nñ]ol|castellano|espanhol|espagnol|spanisch|spagnolo)\b|西班牙|スペイン語|스페인어/iu],
71
+ ["pt", /\b(?:portuguese|portugu[eéê]s|portugais|portugiesisch|portoghese)\b|葡萄牙|ポルトガル語|포르투갈어/iu],
72
+ ["ja", /\b(?:japanese|japon[eéê]s|japonais|japanisch|giapponese)\b|日本語|日语|日語|日文|일본어/iu],
73
+ ["ko", /\b(?:korean|coreano|cor[eé]en|koreanisch)\b|韩语|韓語|韩文|韓文|韩国语|韓國語|韓国語|한국어/iu],
74
+ ["fr", /\b(?:french|franc[eéê]s|fran[cç]ais|franz[öo]sisch|francese)\b|法语|法語|法文|フランス語|프랑스어/iu],
75
+ ["de", /\b(?:german|alem[aá]n|alem[ãa]o|allemand|deutsch|tedesco)\b|德语|德語|德文|ドイツ語|독일어/iu],
76
+ ["it", /\b(?:italian|italiano|italien|italienisch)\b|意大利|義大利|イタリア語|이탈리아어/iu],
77
+ ];
78
+ // A Latin-script request names its language in one or two words ("in traditional Chinese").
79
+ const NAMED = String.raw`(\p{L}+(?:[ \t]+\p{L}+)?)`;
80
+ const EXPLICIT_REQUEST = new RegExp([
81
+ String.raw`(?<!\p{L})(?:reply|respond|answer|write|explain|speak|talk)(?:\s+(?:to me|this|it))?\s+in\s+${NAMED}`,
82
+ String.raw`(?<!\p{L})(?:responde(?:me)?|cont[eé]stame|escr[ií]beme|h[aá]blame|explica(?:me)?)\s+en\s+${NAMED}`,
83
+ String.raw`(?<!\p{L})(?:responda|responde|fale|escreva|explique)(?:-me)?\s+em\s+${NAMED}`,
84
+ String.raw`(?<!\p{L})(?:r[ée]ponds|r[ée]pondez|[ée]cris|[ée]crivez|parle|parlez|explique|expliquez)(?:-(?:moi|nous))?\s+en\s+${NAMED}`,
85
+ String.raw`(?<!\p{L})(?:antworte|antwortet|schreib|schreibe|sprich|erkl[äa]re?)(?:\s+(?:mir|uns|bitte|es|das))*\s+(?:auf|in)\s+${NAMED}`,
86
+ String.raw`(?<!\p{L})(?:rispondi(?:mi)?|scrivi(?:mi)?|parla(?:mi)?|spiega(?:mi)?)\s+in\s+${NAMED}`,
87
+ String.raw`(?:用|使用|以)\s*(\p{Script=Han}{2,4}?)\s*(?:跟我|和我|给我|給我|对我|對我)?\s*(?:回答|回复|回覆|回復|解释|解釋|说明|說明|输出|輸出|写|寫|讲|講|说|說)`,
88
+ String.raw`(\p{Script=Han}{2,4}|\p{Script=Katakana}{2,8}語)で(?:答|返事|返信|話|説明|書)`,
89
+ String.raw`((?:\p{Script=Hangul}+\s)?\p{Script=Hangul}{1,6}어)\s*로\s*(?:대답|답|말|설명|써|작성|응답|얘기|이야기)`,
90
+ ].join("|"), "giu");
91
+
92
+ // Common characters whose Simplified and Traditional forms differ, including the
93
+ // ones artist names and song titles use most. Text that uses only shared
94
+ // characters decides nothing.
95
+ const SIMPLIFIED = new Set("这们个来说时为会对后么过还发没问题听乐让给种点样经现从动进实与关开长东车门间爱气应头边见话认识办学体机无声华专辑队设电脑网络语写欢请谢帮换单张节数几吗调轻静觉场线团类风随暂继续该别荐试陈张刘杨黄赵吴郑许邓蒋孙马罗萧叶苏卢钟颜谭龙凤伟辉丽泪恋忆梦云飞岁湾国广岛离远谁娇录缘戏树桥涛剧艺术圣诞旧历压转响闹拥怀忧伤宝贝铁银钱号楼双归乡齐剑侠诗词兴举热愿读独奖优层简伦");
96
+ const TRADITIONAL = new Set("這們個來說時為會對後麼過還發沒問題聽樂讓給種點樣經現從動進實與關開長東車門間愛氣應頭邊見話認識辦學體機無聲華專輯隊設電腦網絡語寫歡請謝幫換單張節數幾嗎調輕靜覺場線團類風隨暫繼續該別薦試裝佇陳誇張劉楊黃趙吳鄭許鄧蔣孫馬羅蕭葉蘇盧鍾顏譚龍鳳偉輝麗淚戀憶夢雲飛歲灣國廣島離遠誰嬌錄緣戲樹橋濤劇藝術聖誕舊歷壓轉響鬧擁懷憂傷寶貝鐵銀錢號樓雙歸鄉齊劍俠詩詞興舉熱願讀獨獎優層簡倫");
97
+
98
+ function chineseLocale(prose, configured) {
99
+ let simplified = 0;
100
+ let traditional = 0;
101
+ for (const character of prose) {
102
+ if (SIMPLIFIED.has(character)) simplified += 1;
103
+ else if (TRADITIONAL.has(character)) traditional += 1;
104
+ }
105
+ if (simplified !== traditional) return traditional > simplified ? "zh-Hant" : "zh";
106
+ return configured === "zh-Hant" ? "zh-Hant" : "zh";
107
+ }
108
+
109
+ // Short function words and commands that are rare in the other Latin-script
110
+ // languages. Words two languages share (de, que, para, una, con) decide nothing.
111
+ const WORDS = {
112
+ en: new Set(["the", "an", "of", "and", "to", "play", "some", "song", "songs", "track", "tracks", "queue", "my", "i", "want",
113
+ "please", "what", "is", "are", "more", "like", "something", "music", "put", "next", "add", "find", "with", "for", "this",
114
+ "that", "it", "you", "can", "give", "thanks", "hi", "hello", "listen", "again", "stop", "skip", "pause", "who", "why", "how"]),
115
+ es: new Set(["la", "el", "los", "las", "una", "unos", "unas", "con", "pon", "ponme", "quiero", "canciones", "cancion",
116
+ "canción", "cola", "reproduce", "añade", "agrega", "otra", "otro", "más", "y", "mi", "mis", "es", "está", "del", "al",
117
+ "qué", "cómo", "dame", "escuchar", "gracias", "hola", "favor", "salta", "siguiente", "quién", "ahora", "vez"]),
118
+ pt: new Set(["você", "voce", "não", "nao", "uma", "umas", "um", "uns", "coloca", "coloque", "toque", "tocar", "adiciona",
119
+ "adicione", "quero", "músicas", "musicas", "fila", "mais", "também", "obrigado", "obrigada", "oi", "olá", "isso", "essa",
120
+ "esse", "da", "dos", "das", "na", "nas", "pra", "ela", "meu", "minha", "outra", "outro", "próxima", "agora", "ouvir",
121
+ "escutar", "põe", "bota", "pula", "favor", "tá", "está", "é"]),
122
+ fr: new Set(["le", "les", "des", "une", "et", "je", "j", "qu", "vous", "nous", "est", "pas", "mets", "mettre", "joue", "jouer",
123
+ "lance", "ajoute", "ajouter", "chanson", "chansons", "morceau", "morceaux", "musique", "attente", "quelque", "chose", "plus",
124
+ "encore", "merci", "bonjour", "salut", "plaît", "écouter", "écoute", "suivant", "suivante", "arrête", "quoi", "pourquoi",
125
+ "comment", "avec", "pour", "dans", "sur", "cette", "ça", "oui", "veux", "voudrais", "peux", "fais", "donne", "moi", "autre",
126
+ "maintenant", "aussi", "très", "trouve", "cherche", "montre", "recommande", "propose", "titre", "artiste", "groupe", "mon",
127
+ "aime", "jamais", "déjà", "nouveau", "nouvelle", "calme", "doux", "soir", "soirée", "enceinte", "téléphone", "ordinateur"]),
128
+ de: new Set(["der", "die", "den", "dem", "ein", "eine", "einen", "einem", "einer", "und", "ich", "du", "mir", "mich", "mein",
129
+ "meine", "meinen", "dir", "dich", "bitte", "spiel", "spiele", "spielen", "lied", "lieder", "musik", "warteschlange", "noch",
130
+ "mehr", "etwas", "wer", "wie", "warum", "ist", "nicht", "auch", "mit", "für", "von", "zu", "zum", "zur", "auf", "aus",
131
+ "danke", "hallo", "nächste", "nächsten", "nächstes", "jetzt", "gib", "zeig", "zeige", "suche", "finde", "füge", "füg",
132
+ "hinzu", "empfiehl", "mach", "kannst", "hast", "habe", "hab", "wieder", "nochmal", "nein", "sie", "wir", "uns", "einfach",
133
+ "ruhig", "ruhige", "ruhiges", "gerade", "gehört", "nie", "schon", "neue", "neues", "neuen", "bisschen", "paar", "titel",
134
+ "künstler", "lautsprecher", "handy", "fernseher", "pausiere", "stoppe", "überspringe", "weiter", "zurück", "lauter",
135
+ "leiser", "welche", "welcher", "welches", "heute", "abend", "oder", "aber", "sehr", "gut", "mal", "doch", "gern", "gerne"]),
136
+ it: new Set(["il", "gli", "di", "della", "delle", "dello", "degli", "dei", "che", "per", "metti", "mettimi", "mettere", "suona",
137
+ "suonami", "suonare", "riproduci", "aggiungi", "canzone", "canzoni", "brano", "brani", "coda", "qualcosa", "ancora",
138
+ "grazie", "ciao", "favore", "ascoltare", "ascolta", "prossima", "prossimo", "chi", "perché", "sono", "è", "voglio",
139
+ "vorrei", "puoi", "fammi", "dammi", "altra", "altro", "adesso", "anche", "molto", "bene", "mio", "mia", "miei", "mie",
140
+ "questa", "questo", "quella", "quello", "nella", "nel", "alla", "allo", "trova", "consiglia", "consigliami", "fai",
141
+ "proponi", "ferma", "sentire", "po", "già", "mai", "nuovo", "nuova", "nuove", "nuovi", "rilassante", "tranquillo",
142
+ "tranquilla", "stasera", "sera", "cassa", "telefono", "cellulare", "gruppo", "sentito", "ascoltato", "alza", "abbassa",
143
+ "piace", "piacciono"]),
144
+ };
145
+ const COMMANDS = {
146
+ en: new Set(["play", "queue", "put", "add", "find", "give", "skip", "pause", "stop", "show", "tell", "recommend", "make"]),
147
+ es: new Set(["pon", "ponme", "reproduce", "añade", "agrega", "busca", "dame", "salta", "muestra", "dime", "recomienda", "haz", "quiero"]),
148
+ pt: new Set(["coloca", "coloque", "toque", "adiciona", "adicione", "bota", "põe", "procura", "mostra", "recomenda", "faz", "quero", "pula", "me"]),
149
+ fr: new Set(["mets", "joue", "lance", "ajoute", "trouve", "cherche", "montre", "donne", "recommande", "propose", "fais", "arrête", "écoute", "je", "peux", "est"]),
150
+ de: new Set(["spiel", "spiele", "füge", "füg", "gib", "zeig", "zeige", "suche", "finde", "mach", "empfiehl", "stell", "setz", "leg", "überspringe", "pausiere", "stoppe", "ich", "kannst", "bitte"]),
151
+ it: new Set(["metti", "mettimi", "suona", "suonami", "riproduci", "aggiungi", "trova", "consiglia", "consigliami", "fammi", "dammi", "fai", "proponi", "ferma", "voglio", "vorrei", "ascolta", "puoi"]),
152
+ };
153
+ // Letters only one language writes are strong evidence; letters several share are weak evidence for each.
154
+ const MARKS = [
155
+ [/[¿¡ñ]/u, ["es"]],
156
+ [/[ãõ]/u, ["pt"]],
157
+ [/[äöüß]/u, ["de"]],
158
+ [/[œæëïÿîû]/u, ["fr"]],
159
+ [/[ìò]/u, ["it"]],
160
+ [/[çâêô]/u, ["pt", "fr"]],
161
+ [/[èù]/u, ["fr", "it"]],
162
+ [/à/u, ["pt", "fr", "it"]],
163
+ [/[áíóú]/u, ["es", "pt"]],
164
+ [/é/u, ["es", "pt", "fr", "it"]],
165
+ ];
166
+
167
+ function explicitLocale(prose) {
168
+ const requests = [...prose.matchAll(EXPLICIT_REQUEST)];
169
+ for (const match of requests.reverse()) {
170
+ const named = match.slice(1).find(Boolean);
171
+ const found = LANGUAGE_NAMES.find(([, pattern]) => pattern.test(named));
172
+ if (found) return found[0];
173
+ }
174
+ return null;
175
+ }
176
+
177
+ /**
178
+ * The language to answer one message in: an explicit request, then the
179
+ * message's own script and words, then the listener's configured language.
180
+ * Quoted titles never decide it, because song names travel across languages.
181
+ */
182
+ export function messageLocale(text, fallback = DEFAULT_LOCALE) {
183
+ const configured = normalizeLocale(fallback) ?? DEFAULT_LOCALE;
184
+ if (typeof text !== "string") return configured;
185
+ const prose = text
186
+ .replace(/^(?:Track|Artist): "(?:[^"\\\n]|\\.)*"[ \t]*$/gmu, "")
187
+ .replace(/"(?:[^"\\]|\\.)*"|“[^”]*”|«[^»]*»|「[^」]*」|『[^』]*』|《[^》]*》/gu, "");
188
+ const explicit = explicitLocale(prose);
189
+ if (explicit) return explicit;
190
+ // Hangul is only Korean and kana only Japanese; whichever the message uses more decides.
191
+ const hangul = prose.match(/\p{Script=Hangul}/gu)?.length ?? 0;
192
+ const kana = prose.match(/[\p{Script=Hiragana}\p{Script=Katakana}]/gu)?.length ?? 0;
193
+ if (hangul || kana) return hangul > kana ? "ko" : "ja";
194
+ // Han characters without kana are Chinese, Simplified or Traditional by their forms.
195
+ if (/\p{Script=Han}/u.test(prose)) return chineseLocale(prose, configured);
196
+ const lower = prose.normalize("NFC").toLocaleLowerCase();
197
+ const words = lower.match(/\p{L}+/gu) ?? [];
198
+ const scores = { en: 0, es: 0, pt: 0, fr: 0, de: 0, it: 0 };
199
+ for (const [pattern, locales] of MARKS) {
200
+ if (pattern.test(lower)) for (const locale of locales) scores[locale] += locales.length === 1 ? 3 : 1;
201
+ }
202
+ for (const locale of Object.keys(scores)) {
203
+ for (const word of words) if (WORDS[locale].has(word)) scores[locale] += 1;
204
+ if (COMMANDS[locale].has(words[0])) scores[locale] += 2;
205
+ }
206
+ const ranked = Object.entries(scores).sort(([, a], [, b]) => b - a);
207
+ return ranked[0][1] > ranked[1][1] ? ranked[0][0] : configured;
208
+ }
209
+
210
+ /** A lookup bound to one locale; a missing entry falls back to its parent language, then English. */
211
+ export function translator(locale) {
212
+ const code = normalizeLocale(locale) ?? DEFAULT_LOCALE;
213
+ const catalogs = [CATALOGS[code], CATALOGS[PARENT[code]], CATALOGS.en].filter(Boolean);
214
+ const t = (key, params = {}) => {
215
+ const entry = catalogs.map(catalog => catalog[key]).find(value => value !== undefined);
216
+ if (entry === undefined) throw new Error(`Unknown message: ${key}`);
217
+ return typeof entry === "function" ? entry(params) : entry;
218
+ };
219
+ t.locale = code;
220
+ return t;
221
+ }
222
+
223
+ export function messageCatalogKeys(locale) {
224
+ return Object.keys(CATALOGS[locale] ?? {});
225
+ }
226
+
227
+ const fill = (text, params) => text.replace(/\{(\w+)\}/gu, (match, name) => params[name] ?? match);
228
+
229
+ /**
230
+ * Screen text for one locale. Call tr("English sentence", { name }) with
231
+ * {name} placeholders, and tr.n(count, "{count} song", "{count} songs") for
232
+ * plurals. Keep the English as plain string literals: a test extracts them
233
+ * to check that every catalog translates every sentence.
234
+ */
235
+ const screenTranslators = new Map();
236
+
237
+ /** The language the terminal's screen text uses for a locale: its own, its parent's, or English. */
238
+ export function screenLocale(locale) {
239
+ const code = normalizeLocale(locale) ?? DEFAULT_LOCALE;
240
+ if (SCREEN[code]) return code;
241
+ return SCREEN[PARENT[code]] ? PARENT[code] : DEFAULT_LOCALE;
242
+ }
243
+
244
+ export function screenTranslator(locale) {
245
+ const code = screenLocale(locale);
246
+ if (!screenTranslators.has(code)) screenTranslators.set(code, createScreenTranslator(code));
247
+ return screenTranslators.get(code);
248
+ }
249
+
250
+ function createScreenTranslator(code) {
251
+ const catalog = SCREEN[code];
252
+ const numbers = new Intl.NumberFormat(intlLocale(code));
253
+ const plurals = new Intl.PluralRules(intlLocale(code));
254
+ const tr = (english, params = {}) => fill(typeof catalog[english] === "string" ? catalog[english] : english, params);
255
+ tr.n = (count, one, other, params = {}) => {
256
+ const values = { ...params, count: Number.isFinite(count) ? numbers.format(count) : count };
257
+ const entry = catalog[other];
258
+ if (typeof entry === "string") return fill(entry, values);
259
+ if (entry && typeof entry === "object") return fill(entry[plurals.select(count)] ?? entry.other, values);
260
+ return fill(count === 1 ? one : other, values);
261
+ };
262
+ tr.number = value => Number.isFinite(value) ? numbers.format(value) : value;
263
+ // For a sentence marked with N_() where it was defined, such as a command list.
264
+ tr.marked = (english, params = {}) => tr(english, params);
265
+ tr.locale = code;
266
+ return tr;
267
+ }
268
+
269
+ /** Marks an English sentence for extraction where no translator exists yet. */
270
+ export const N_ = text => text;
271
+
272
+ /** A translator that follows a changing locale, such as the /language choice. */
273
+ export function liveScreenTranslator(getLocale) {
274
+ const current = () => screenTranslator(getLocale());
275
+ const tr = (english, params) => current()(english, params);
276
+ tr.n = (...args) => current().n(...args);
277
+ tr.number = value => current().number(value);
278
+ tr.marked = (english, params) => current().marked(english, params);
279
+ Object.defineProperty(tr, "locale", { get: () => current().locale });
280
+ return tr;
281
+ }
282
+
283
+ export function screenCatalog(locale) {
284
+ return SCREEN[locale] ?? {};
285
+ }
@@ -0,0 +1,135 @@
1
+ // Deutsch. Keys match messages/en.mjs; a missing key falls back to English.
2
+ export default {
3
+ "discovery.reason.listenbrainz": ({ adjacent, seed, release }) => `Hördaten von ListenBrainz verbinden ${adjacent} mit ${seed}; aus „${release}“.`,
4
+ "discovery.reason.appleMusic": ({ queries, release, genre }) => `Apple-Music-Katalog: ${queries ? `Treffer für ${queries}` : "Kandidat"}; aus „${release}“${genre ? `, Katalog-Genre: ${genre}` : ""}.`,
5
+ "discovery.reason.external": ({ release }) => `Aus dem abgerufenen externen Katalog, auf „${release}“.`,
6
+ "discovery.order": ({ path }) => `In dieser Reihenfolge anhören: ${path}.`,
7
+ "discovery.unavailable": "Ich konnte die Dienste zur Musikentdeckung nicht erreichen, daher gibt es diesmal keine Empfehlungen. Sende die Anfrage noch einmal, um es erneut zu versuchen.",
8
+
9
+ "citations.heading": "Öffentliche Musikquellen (getrennt von deinen persönlichen Hördaten):",
10
+ "citations.item": ({ label, url, storefront, date }) => `- [${label}](<${url}>) - ${storefront}-Store, abgerufen am ${date}.`,
11
+ "citations.scope": "Geltungsbereich: Diese öffentlichen Katalogseiten belegen weder persönliche Vorlieben noch einen vollständigen Hörverlauf noch die Verfügbarkeit auf anderen Plattformen.",
12
+ "web.sources.heading": ({ providers }) => `Öffentliche Webquellen (${providers})`,
13
+ "web.sources.headingUnnamed": "Öffentliche Webquellen",
14
+ "web.sources.published": ({ date }) => `Datum der Quelle: ${date}`,
15
+ "web.sources.retrieved": ({ date }) => `abgerufen: ${date}`,
16
+
17
+ "external.neighbors": ({ seed }) => seed ? `ListenBrainz-Hörer-Nachbarn von ${seed}` : "ListenBrainz-Hörer-Nachbarn",
18
+ "external.listenbrainz": ({ neighbor, date }) => `${neighbor} (Wikidata-Identität, CC0, abgerufen am ${date}; das ist keine klangliche Ähnlichkeit)`,
19
+ "external.appleMusic": ({ date }) => `Titeltreffer bei Apple Music US (abgerufen am ${date})`,
20
+ "external.limit": "Ausgeschlossen ist nur, was in deiner importierten Bibliothek mit gleichem Titel und Künstler vorkommt; du hast diese Songs also nicht unbedingt noch nie gehört. Es sind Vorschläge zum Reinhören.",
21
+ "external.from": ({ parts, limit }) => `Aus ${parts.join(" und ")}. ${limit}`,
22
+
23
+ "failure.deviceList": ({ noMatch, names, code }) => `Der Gerätename ${noMatch ? "passte zu keinem Gerät" : "passte zu mehreren Geräten"}. Spotify zeigt gerade: ${names}. Sag „spiel es auf“ und dann den angezeigten Gerätenamen; der ausgewählte Song bleibt erhalten. Es wurde keine Wiedergabe-Anfrage gesendet. (${code})`,
24
+ "failure.networkRead": ({ notSent, code }) => `Ein Netzwerkfehler hat das Lesen des Geräte- und Wiedergabestatus von Spotify verhindert; die Vorbereitung wurde abgebrochen.${notSent ? " Die Wiedergabe-Anfrage wurde nicht gesendet." : ""} Prüfe die Verbindung zu Spotify und versuche es erneut. (${code})`,
25
+ "failure.readiness.spotify_device_selection_required": "Der ausgewählte Song bleibt erhalten, aber das Zielgerät steht noch nicht fest. Nenne das gewünschte Gerät; das vorherige Gerät wird nicht stattdessen verwendet.",
26
+ "failure.readiness.spotify_device_not_found": "Spotify sieht das Zielgerät nicht. Öffne Spotify darauf und bitte dann erneut um die Wiedergabe; es wurde kein anderes Gerät stattdessen verwendet.",
27
+ "failure.readiness.spotify_device_ambiguous": "Möglicherweise sind mehrere Spotify-Geräte verfügbar. Nenne den gewünschten Gerätenamen.",
28
+ "failure.readiness.spotify_device_restricted": "Das Zielgerät bestätigt keine Steuerung über die Spotify Web API. Wähle ein anderes verfügbares Gerät.",
29
+ "failure.readiness.spotify_device_changed": "Ein anderes Spotify-Gerät wurde aktiv. Wähle das gewünschte Gerät ausdrücklich; die Wiedergabe wurde nicht automatisch verschoben.",
30
+ "failure.readiness.spotify_device_state_unconfirmed": "Der Geräte- und Wiedergabestatus von Spotify ist unvollständig oder widersprüchlich. Prüfe das aktuelle Gerät in Spotify, bevor du es erneut versuchst.",
31
+ "failure.readiness.spotify_device_not_ready": "Das Zielgerät war innerhalb der Wartezeit nicht bereit. Öffne Spotify darauf und bitte dann erneut um denselben Song.",
32
+ "failure.readiness.spotify_playback_restricted": "Spotify erlaubt gerade nicht, die Wiedergabe fortzusetzen. Prüfe das Zielgerät.",
33
+ "failure.songNotSent": " Die Wiedergabe-Anfrage wurde nicht gesendet.",
34
+ "failure.preparationStopped": ({ evidence }) => `Die Prüfung des Wiedergabegeräts wurde nicht abgeschlossen (${evidence}); es wurde keine Wiedergabe-Anfrage gesendet. Prüfe den Gerätestatus in Spotify, bevor du es erneut versuchst.`,
35
+ "failure.queueRequestRequired": "Es wurden keine Songs zur Warteschlange hinzugefügt: Ich konnte diese Antwort keiner Anfrage für die Warteschlange zuordnen. Um fortzufahren, braucht es eine ausdrückliche Bitte, Musik zur Warteschlange hinzuzufügen.",
36
+ "failure.notSent": ({ code }) => `An Spotify wurde nichts gesendet: Ich konnte das keinem der gefundenen Songs zuordnen (${code}). Frag noch einmal mit Songtitel oder Künstler, dann suche ich erneut.`,
37
+ "failure.avoided": "An Spotify wurde nichts gesendet: Du wolltest diesen Künstler oder diesen Song nicht mehr hören. Sag mir jederzeit, wenn du das ändern willst.",
38
+ "failure.outcome.lookup": "Die Spotify-Suche wurde nicht abgeschlossen",
39
+ "failure.outcome.unknown": "Das Ergebnis der Spotify-Aktion ist unklar",
40
+ "failure.outcome.unconfirmed": "Die Spotify-Wiedergabeaktion wurde nicht bestätigt",
41
+ "failure.reason": ({ reason }) => `Spotify meldet: ${reason}.`,
42
+ "failure.reasonUnknown": "Die genaue Ursache ist nicht bestätigt.",
43
+ "failure.lookup": ({ outcome, evidence, reason }) => `${outcome} (${evidence}). ${reason} Es wurde keine Wiedergabe und nichts für die Warteschlange gesendet.`,
44
+ "failure.general": ({ targets, outcome, evidence, reason, playbackNotSent, recovered }) =>
45
+ `${targets ? `Ziel: ${targets}.\n` : ""}${outcome} (${evidence}). ${reason}${playbackNotSent ? " Die Wiedergabe-Anfrage wurde nicht gesendet." : ""} ${recovered ? "Eine begrenzte Gerätewiederherstellung ist beendet; es gab keinen weiteren Versuch." : "Die Aktion wurde nicht wiederholt."} Lies den Wiedergabestatus und die verfügbaren Geräte, um weiter einzugrenzen.`,
46
+
47
+ "queue.unverified": ({ one }) => `Spotify hat ${one ? "ihn" : "sie"} angenommen, aber ich konnte deine Warteschlange nicht lesen, um das zu prüfen.`,
48
+ "queue.confirmedPlaying": ({ one }) => one ? "Wiedergabe auf Spotify bestätigt." : "Auf Spotify bestätigt: Der erste läuft, die übrigen stehen in deiner Warteschlange.",
49
+ "queue.confirmed": ({ count }) => count === 1 ? "In deiner Spotify-Warteschlange bestätigt." : `In deiner Spotify-Warteschlange bestätigt: alle ${count}.`,
50
+ "queue.partlyVisible": ({ accepted, confirmed, truncated }) => `Spotify hat ${accepted} angenommen; ${confirmed} sind gerade in deiner Warteschlange zu sehen${truncated ? " (Spotify zeigt bei langen Warteschlangen nur einen Teil)" : ""}.`,
51
+ "queue.noPlayback": "Auf Spotify läuft gerade kein Song, der als Ausgangspunkt dienen kann; es wurde nichts hinzugefügt.",
52
+ "queue.noCandidates": "Nach dem Filtern blieben keine passenden Kandidaten übrig; es wurde nichts hinzugefügt.",
53
+ "queue.requested": ({ requested, accepted, shortfall }) => `${requested} angefragt; Spotify hat ${accepted} angenommen${shortfall ? `, ${shortfall} fehlen` : ""}.`,
54
+ "queue.similarSeed": ({ artist }) => `Künstler, die laut Hörerdaten ${artist} ähneln:`,
55
+ "queue.cancelled": "Nach dem Abbruch werden keine weiteren Songs hinzugefügt; angenommene Titel bleiben in der Warteschlange.",
56
+ "queue.stopped": ({ count, title, artist }) => `${count} zur Spotify-Warteschlange hinzugefügt, dann bei „${title}“ - ${artist} angehalten.`,
57
+ "queue.queued": ({ count }) => `${count} zur Spotify-Warteschlange hinzugefügt:`,
58
+ "queue.queuedSome": ({ count, unmatched }) => `${count} von ${count + unmatched} zur Spotify-Warteschlange hinzugefügt:`,
59
+ "queue.outcomeUnknown": "Spotify hat nicht bestätigt, ob der angehaltene Titel hinzugefügt wurde. Prüfe die Warteschlange, bevor du es erneut versuchst; dieser Schreibvorgang wurde nicht wiederholt.",
60
+ "queue.started": ({ title, device, more }) => `Es lief nichts, also habe ich „${title}“${device ? ` auf ${device}` : ""} gestartet${more ? " und den Rest dahinter eingereiht" : ""}.`,
61
+ "queue.unmatchedHeading": "Auf Spotify nicht gefunden:",
62
+ "queue.skippedDuplicates": ({ count }) => `${count} doppelte Titel übersprungen.`,
63
+ "queue.skippedUncertain": ({ count }) => `${count} Titel mit unklarem früherem Ergebnis übersprungen, um Doppelungen zu vermeiden; prüfe die Spotify-Warteschlange.`,
64
+ "queue.skippedAvoided": ({ count }) => `${count} Titel übersprungen, die du vermeiden wolltest.`,
65
+ "plan.avoidedLeftOut": ({ titles }) => `Weggelassen, weil du sie nicht mehr hören wolltest: ${titles}.`,
66
+ "queue.skippedKnown": ({ count }) => `${count} Titel aus deinem Hörverlauf übersprungen, wie gewünscht.`,
67
+ "queue.mix": ({ familiar = 0, returning = 0, heard = 0, known_artist: knownArtist = 0, new: fresh = 0 }) =>
68
+ `Laut deinem Hörverlauf: ${[familiar && `${familiar} gut bekannt`, returning && `${returning} nach längerer Zeit wieder`, heard && `${heard} ein- oder zweimal gehört`, knownArtist && `${knownArtist} neu von ${knownArtist === 1 ? "einem bekannten Künstler" : "bekannten Künstlern"}`, fresh && `${fresh} neu für dich`].filter(Boolean).join(", ")}.`,
69
+ "queue.observationTruncated": "Doppelungen wurden nur im begrenzten Ausschnitt der Warteschlange geprüft, den Spotify liefert; nicht sichtbare Einträge können noch doppelt sein.",
70
+ "queue.notAddedHeading": "Nicht hinzugefügt:",
71
+ "queue.earlierReceipt": "Das ist das Ergebnis der früheren Warteschlangen-Aktion; diese Klarstellung hat die Songs nicht erneut hinzugefügt.",
72
+
73
+ "playlist.partialUnknown": ({ name }) => `Spotify hat die private Playlist „${name}“ erstellt, aber das Hinzufügen der Titel wurde nicht bestätigt. Prüfe sie, bevor du entscheidest, ob du es erneut versuchst.`,
74
+ "playlist.partialEmpty": ({ name }) => `Spotify hat die private Playlist „${name}“ erstellt, aber keine Titel hinzugefügt. Die leere Playlist existiert jetzt; prüfe sie, bevor du entscheidest, ob du es erneut versuchst.`,
75
+ "playlist.saved": ({ name, count }) => `Als private Spotify-Playlist „${name}“ gespeichert (${count} Titel).`,
76
+
77
+ "action.playback.resume": "Wiedergabe-Anfrage",
78
+ "action.playback.pause": "Pause-Anfrage",
79
+ "action.playback.next": "Anfrage für den nächsten Titel",
80
+ "action.playback.previous": "Anfrage für den vorherigen Titel",
81
+ "action.playback.volume.set": "Lautstärkeänderung",
82
+ "action.playback.seek": "Änderung der Wiedergabeposition",
83
+ "action.playback.shuffle.set": "Zufallswiedergabe-Einstellung",
84
+ "action.playback.repeat.set": "Wiederholungs-Einstellung",
85
+ "action.playback.transfer": "Gerätewechsel",
86
+ "action.playback.queue.add": "Hinzufügen zur Warteschlange",
87
+ "action.library.remove": "Entfernen aus der Bibliothek",
88
+ "action.library.save": "Speichern in der Bibliothek",
89
+ "action.playlist.write": "Erstellen der Playlist",
90
+ "action.playlist.edit": "Änderung der Playlist",
91
+ "action.playlist.rename": "Umbenennen der Playlist",
92
+ "action.playlist.unfollow": "Entfernen der Playlist aus der Bibliothek",
93
+ "action.playlist.remove_track": "Entfernen eines Titels aus der Playlist",
94
+ "action.other": "gewünschte Änderung",
95
+ "write.unknown": ({ action, target }) => `Spotify hat die Aktion „${action}“${target ? ` für „${target}“` : ""} nicht bestätigt. Prüfe den Status, bevor du entscheidest, ob du es erneut versuchst.`,
96
+ "write.unfollowed": ({ name }) => `„${name}“ wurde aus deiner Spotify-Bibliothek entfernt (nicht mehr gefolgt). Für andere Hörer kann die Playlist weiter bestehen; sie wurde nicht für alle gelöscht.`,
97
+ "write.renamed": ({ name }) => `Spotify hat das Umbenennen der Playlist in „${name}“ angenommen.`,
98
+ "write.trackRemoved": ({ name }) => `Spotify hat das Entfernen eines Titels aus „${name}“ angenommen.`,
99
+ "write.accepted": ({ action, count, targets }) => `Spotify hat die Aktion „${action}“ angenommen${count === null ? "" : ` (${count} Titel)`}${targets ? `: ${targets}` : ""}.`,
100
+
101
+ "plan.heading.private_library": ({ count }) => `Auswahl mit ${count} Titeln aus deiner Bibliothek:`,
102
+ "plan.heading.private_history": ({ count }) => `${count} Titel zum Wiederhören aus deinem Verlauf:`,
103
+ "plan.heading.external_catalog": ({ count }) => `${count} Songs:`,
104
+ "plan.heading.mixed": ({ count }) => `${count} Songs:`,
105
+ "plan.heading.timeCapsule": ({ count }) => `Zeitreise in ${count} Stationen durch deine Hörjahre:`,
106
+ "plan.heading.historicalReturn": ({ count }) => `${count} Titel, die nach langer Pause zurückkamen:`,
107
+ "plan.heading.backToBack": ({ count }) => `${count} Titel, die du direkt hintereinander gehört hast:`,
108
+ "plan.externalTrack": ({ line, release }) => `${line}, aus „${release}“`,
109
+ "plan.rationale": ({ text }) => ` Begründung der Auswahl: ${text}`,
110
+ "plan.ordering": ({ text }) => `Begründung der Reihenfolge: ${text}`,
111
+ "plan.notSaved": "Noch ist nichts gespeichert oder zur Warteschlange hinzugefügt.",
112
+ "plan.previewing": "Die 30-Sekunden-Vorschauen laufen in deinem Player; die vollständigen Songs gibt es bei Apple Music.",
113
+ "plan.pending": "Grenze: Diese Auswahl wartet nur in der laufenden Sitzung auf Bestätigung, wurde nicht in Spotify geschrieben und hat keine Auswirkungen nach außen.",
114
+ "plan.backToBackBoundary": "Grenze der Deutung: Direkt aufeinanderfolgende Wiedergaben belegen weder den Wiederholungsmodus noch bewusstes Wiederhören noch Gefallen.",
115
+ "plan.validationScope": "Umfang der Prüfung: Der lokale Planer prüft Titelidentität, Zugehörigkeit zu den Kandidaten, Anzahl und Reihenfolge; Begründungen und Passung zur Situation bleiben Einschätzungen des Modells auf Grundlage der verfügbaren Metadaten.",
116
+ "plan.unvalidated": "Ich konnte diese Auswahl nicht prüfen, deshalb gebe ich keine Titelliste aus, die nicht mit vertrauenswürdigen Kandidaten abgeglichen wurde. Grenze die Suche ein oder erweitere sie und versuche es erneut.",
117
+
118
+ "edit.previewHeading": ({ name, before, after }) => `Vorschau der Änderung an der privaten Spotify-Playlist „${name}“ (${before} -> ${after} Titel):`,
119
+ "edit.status.added": "neu",
120
+ "edit.status.moved": "verschoben",
121
+ "edit.status.retained": "bleibt",
122
+ "edit.removeHeading": "Entfernen:",
123
+ "edit.summary": ({ added, removed, moved, retained }) => `Zusammenfassung: ${added} hinzugefügt, ${removed} entfernt, ${moved} verschoben, ${retained} unverändert an ihrem Platz.`,
124
+ "edit.boundary": "Grenze: Spotify wurde noch nicht geändert. Bestätige in deiner nächsten Nachricht ausdrücklich, damit Moondog genau diese gespeicherte Version schreibt.",
125
+ "edit.updated": ({ name, before, after }) => `Die private Spotify-Playlist „${name}“ wurde genau nach der bestätigten Vorschau aktualisiert${before === null ? ` (jetzt ${after} Titel)` : ` (${before} -> ${after} Titel)`}.`,
126
+
127
+ "selection.note": ({ playlist, track }) => `\n\nFür weitere Änderungen ausgewählt: Playlist ${playlist}; Song ${track}.`,
128
+ "memory.spotifyExcluded": "\n\nSpotify-Ergebnisse kommen nicht ins allgemeine Gedächtnis; nur deine eigenen, überprüften Worte konnten gespeichert werden, alles andere wurde nicht gespeichert.",
129
+ "memory.notSaved": "\n\nDie Antwort ist fertig, aber die Erinnerung wurde nicht gespeichert.",
130
+ "choice.like": ({ title, artist }) => { const name = title ? `„${title}“ von ${artist}` : artist; return `\n\nGespeichert: Du magst ${name}. Sag mir jederzeit, wenn du das ändern willst.`; },
131
+ "choice.avoid": ({ title, artist }) => { const name = title ? `„${title}“ von ${artist}` : artist; return `\n\nGespeichert: Ich lasse ${name} aus allem heraus, was ich spiele und vorschlage. Sag mir jederzeit, wenn du das ändern willst.`; },
132
+ "choice.retract": ({ title, artist }) => { const name = title ? `„${title}“ von ${artist}` : artist; return `\n\nZurückgenommen: deine frühere Wahl zu ${name}.`; },
133
+ "turn.cancelled": "Weitere Schritte abgebrochen.",
134
+ "turn.interrupted": "Die Antwort wurde unterbrochen. Die erfassten Spotify-Ergebnisse stehen unten; keine Aktion wurde wiederholt.",
135
+ };
@@ -0,0 +1,136 @@
1
+ // Host-written conversation text. English is the source; keys are stable IDs.
2
+ // Every value is a string or a function of named parameters.
3
+ export default {
4
+ "discovery.reason.listenbrainz": ({ adjacent, seed, release }) => `ListenBrainz listening data connects ${adjacent} with ${seed}; from "${release}".`,
5
+ "discovery.reason.appleMusic": ({ queries, release, genre }) => `Apple Music catalog ${queries ? `match for ${queries}` : "candidate"}; from "${release}"${genre ? `, catalog genre: ${genre}` : ""}.`,
6
+ "discovery.reason.external": ({ release }) => `From the retrieved external catalog, on "${release}".`,
7
+ "discovery.order": ({ path }) => `Explore in this order: ${path}.`,
8
+ "discovery.unavailable": "I couldn't reach the music discovery services, so this request produced no recommendations. Please resend this request to try again.",
9
+
10
+ "citations.heading": "Public music sources (separate from personal listening evidence):",
11
+ "citations.item": ({ label, url, storefront, date }) => `- [${label}](<${url}>) - ${storefront} storefront, retrieved ${date}.`,
12
+ "citations.scope": "Scope: these public catalog pages do not establish personal preference, complete listening history, or cross-platform availability.",
13
+ "web.sources.heading": ({ providers }) => `Public web sources (${providers})`,
14
+ "web.sources.headingUnnamed": "Public web sources",
15
+ "web.sources.published": ({ date }) => `source date: ${date}`,
16
+ "web.sources.retrieved": ({ date }) => `retrieved: ${date}`,
17
+
18
+ "external.neighbors": ({ seed }) => seed ? `ListenBrainz listener-neighbors of ${seed}` : "ListenBrainz listener-neighbors",
19
+ "external.listenbrainz": ({ neighbor, date }) => `${neighbor} (Wikidata identity, CC0, retrieved ${date}; this is not audio similarity)`,
20
+ "external.appleMusic": ({ date }) => `Apple Music US title matches (retrieved ${date})`,
21
+ "external.limit": "A title-and-artist match in your imported library is the only thing excluded, so this does not mean you have never heard them. These are suggestions for listening.",
22
+ "external.from": ({ parts, limit }) => `From ${parts.join(", and ")}. ${limit}`,
23
+
24
+ "failure.deviceList": ({ noMatch, names, code }) => `The device name ${noMatch ? "did not match" : "matched several devices"}. Spotify currently lists: ${names}. Say “play it on” followed by the displayed device name; the selected song is retained. No song play request was sent. (${code})`,
25
+ "failure.networkRead": ({ notSent, code }) => `A network error prevented reading Spotify device/playback state; preparation stopped.${notSent ? " The song play request was not sent." : ""} Check the connection to Spotify and retry. (${code})`,
26
+ "failure.readiness.spotify_device_selection_required": "The selected song is retained, but the target device is not established. Name the intended device; the previous device will not be substituted.",
27
+ "failure.readiness.spotify_device_not_found": "Spotify cannot see the target device. Open Spotify on it, then request playback again; no other device was substituted.",
28
+ "failure.readiness.spotify_device_ambiguous": "Several Spotify devices may be available. Specify the intended device name.",
29
+ "failure.readiness.spotify_device_restricted": "The target device does not confirm Spotify Web API control. Choose another available device.",
30
+ "failure.readiness.spotify_device_changed": "Another Spotify device became active. Select the intended device explicitly; playback was not moved automatically.",
31
+ "failure.readiness.spotify_device_state_unconfirmed": "Spotify device/playback state is incomplete or inconsistent. Check the current device in Spotify before trying again.",
32
+ "failure.readiness.spotify_device_not_ready": "The target device did not become ready within the bounded wait. Open Spotify on it, then request the same song again.",
33
+ "failure.readiness.spotify_playback_restricted": "Spotify currently disallows resuming playback. Check the target device.",
34
+ "failure.songNotSent": " The song play request was not sent.",
35
+ "failure.preparationStopped": ({ evidence }) => `Playback device checks did not complete (${evidence}); no song play request was sent. Check Spotify device state before trying again.`,
36
+ "failure.queueRequestRequired": "No songs were queued: I could not connect this reply to a queue request. An explicit request to add music to the playback queue is needed to continue.",
37
+ "failure.notSent": ({ code }) => `Nothing was sent to Spotify: I couldn't match that to the songs I found (${code}). Ask again with the song or artist name, and I'll search again.`,
38
+ "failure.avoided": "Nothing was sent to Spotify: you asked me to keep that artist or song out of what I play. Tell me any time to change it.",
39
+ "failure.outcome.lookup": "The Spotify lookup did not complete",
40
+ "failure.outcome.unknown": "The Spotify action outcome is uncertain",
41
+ "failure.outcome.unconfirmed": "The Spotify playback action was not confirmed",
42
+ "failure.reason": ({ reason }) => `Spotify reported: ${reason}.`,
43
+ "failure.reasonUnknown": "The specific cause is unconfirmed.",
44
+ "failure.lookup": ({ outcome, evidence, reason }) => `${outcome} (${evidence}). ${reason} No play or queue action was sent.`,
45
+ "failure.general": ({ targets, outcome, evidence, reason, playbackNotSent, recovered }) =>
46
+ `${targets ? `Target: ${targets}.\n` : ""}${outcome} (${evidence}). ${reason}${playbackNotSent ? " The song play request was not sent." : ""} ${recovered ? "One bounded device recovery ended; no further retry was made." : "The action was not retried."} Read playback state and available devices to diagnose further.`,
47
+
48
+ "queue.unverified": ({ one }) => `Spotify accepted ${one ? "it" : "them"}, but I couldn't read your queue to double-check.`,
49
+ "queue.confirmedPlaying": ({ one }) => one ? "Confirmed playing on Spotify." : "Confirmed on Spotify: the first is playing and the rest are in your queue.",
50
+ "queue.confirmed": ({ count }) => count === 1 ? "Confirmed in your Spotify queue." : `Confirmed in your Spotify queue: all ${count}.`,
51
+ "queue.partlyVisible": ({ accepted, confirmed, truncated }) => `Spotify accepted ${accepted}; ${confirmed} show in your queue right now${truncated ? " (Spotify only shows part of a long queue)" : ""}.`,
52
+ "queue.noPlayback": "Spotify has no current track to use as a seed; nothing was queued.",
53
+ "queue.noCandidates": "No eligible candidates remained after filtering; nothing was queued.",
54
+ "queue.requested": ({ requested, accepted, shortfall }) => `Requested ${requested}; Spotify accepted ${accepted}${shortfall ? `, ${shortfall} short` : ""}.`,
55
+ "queue.similarSeed": ({ artist }) => `Listener-derived artist similarity from ${artist}:`,
56
+ "queue.cancelled": "Stopped queueing after cancellation; accepted tracks remain in the queue.",
57
+ "queue.stopped": ({ count, title, artist }) => `Queued ${count} on Spotify, then stopped at "${title}" - ${artist}.`,
58
+ "queue.queued": ({ count }) => `Queued ${count} on Spotify:`,
59
+ "queue.queuedSome": ({ count, unmatched }) => `Queued ${count} of ${count + unmatched} on Spotify:`,
60
+ "queue.outcomeUnknown": "Spotify did not confirm whether the stopped track was added. Check the queue before trying again; this write was not replayed.",
61
+ "queue.started": ({ title, device, more }) => `Nothing was playing, so I started "${title}"${device ? ` on ${device}` : ""}${more ? " and lined up the rest" : ""}.`,
62
+ "queue.unmatchedHeading": "Could not match on Spotify:",
63
+ "queue.skippedDuplicates": ({ count }) => `Skipped ${count} duplicate tracks.`,
64
+ "queue.skippedUncertain": ({ count }) => `Skipped ${count} tracks with earlier uncertain queue outcomes to avoid replay; check the Spotify queue.`,
65
+ "queue.skippedAvoided": ({ count }) => `Skipped ${count} avoided tracks.`,
66
+ "plan.avoidedLeftOut": ({ titles }) => `Left out because you asked me to keep them out: ${titles}.`,
67
+ "queue.skippedKnown": ({ count }) => `Skipped ${count} tracks matched in retained history, as requested.`,
68
+ "queue.mix": ({ familiar = 0, returning = 0, heard = 0, known_artist: knownArtist = 0, new: fresh = 0 }) =>
69
+ `The mix: ${[familiar && `${familiar} you know well`, returning && `${returning} back after a while`, heard && `${heard} you've heard once or twice`, knownArtist && `${knownArtist} new from ${knownArtist === 1 ? "an artist" : "artists"} you know`, fresh && `${fresh} new to you`].filter(Boolean).join(", ")}.`,
70
+ "queue.observationTruncated": "Deduplication covers Spotify's bounded queue snapshot; unseen entries may still duplicate a selection.",
71
+ "queue.notAddedHeading": "Not added:",
72
+ "queue.earlierReceipt": "This is the earlier queue receipt; this clarification did not add the songs again.",
73
+
74
+ "playlist.partialUnknown": ({ name }) => `Spotify created the private playlist "${name}", but adding its tracks was not confirmed. Inspect it before deciding whether to retry.`,
75
+ "playlist.partialEmpty": ({ name }) => `Spotify created the private playlist "${name}", but did not add its tracks. The empty playlist now exists; inspect it before deciding whether to retry.`,
76
+ "playlist.saved": ({ name, count }) => `Saved as the private Spotify playlist "${name}" (${count} tracks).`,
77
+
78
+ "action.playback.resume": "playback request",
79
+ "action.playback.pause": "pause request",
80
+ "action.playback.next": "next-track request",
81
+ "action.playback.previous": "previous-track request",
82
+ "action.playback.volume.set": "volume change",
83
+ "action.playback.seek": "playback position change",
84
+ "action.playback.shuffle.set": "shuffle setting",
85
+ "action.playback.repeat.set": "repeat setting",
86
+ "action.playback.transfer": "device transfer",
87
+ "action.playback.queue.add": "queue addition",
88
+ "action.library.remove": "library removal",
89
+ "action.library.save": "library save",
90
+ "action.playlist.write": "playlist creation",
91
+ "action.playlist.edit": "playlist change",
92
+ "action.playlist.rename": "playlist rename",
93
+ "action.playlist.unfollow": "playlist removal from library",
94
+ "action.playlist.remove_track": "playlist track removal",
95
+ "action.other": "requested change",
96
+ "write.unknown": ({ action, target }) => `Spotify's ${action}${target ? ` for "${target}"` : ""} was not confirmed. Check its state before deciding whether to retry.`,
97
+ "write.unfollowed": ({ name }) => `Removed "${name}" from your Spotify library (unfollowed). The playlist may still exist for other listeners; it was not globally deleted.`,
98
+ "write.renamed": ({ name }) => `Spotify accepted the playlist rename to "${name}".`,
99
+ "write.trackRemoved": ({ name }) => `Spotify accepted removal of one track from "${name}".`,
100
+ "write.accepted": ({ action, count, targets }) => `Spotify accepted the ${action}${count === null ? "" : ` (${count} tracks)`}${targets ? `: ${targets}` : ""}.`,
101
+
102
+ "plan.heading.private_library": ({ count }) => `${count}-track plan from your library:`,
103
+ "plan.heading.private_history": ({ count }) => `${count}-track listen-again plan from your private history:`,
104
+ "plan.heading.external_catalog": ({ count }) => `${count} songs:`,
105
+ "plan.heading.mixed": ({ count }) => `${count} songs:`,
106
+ "plan.heading.timeCapsule": ({ count }) => `${count}-stop Listening Time Machine across your years:`,
107
+ "plan.heading.historicalReturn": ({ count }) => `${count}-track return path from your private history:`,
108
+ "plan.heading.backToBack": ({ count }) => `${count}-track played-back-to-back path from your private history:`,
109
+ "plan.externalTrack": ({ line, release }) => `${line}, from "${release}"`,
110
+ "plan.rationale": ({ text }) => ` Curatorial rationale: ${text}`,
111
+ "plan.ordering": ({ text }) => `Ordering rationale: ${text}`,
112
+ "plan.notSaved": "Nothing has been saved or queued yet.",
113
+ "plan.previewing": "The 30-second previews are playing in your player; the full songs are on Apple Music.",
114
+ "plan.pending": "Boundary: this plan is pending only in the current process, has not been written to Spotify, and has no external effects.",
115
+ "plan.backToBackBoundary": "Interpretation boundary: adjacent retained playback events do not prove repeat mode, intentional replay, or liking.",
116
+ "plan.validationScope": "Validation scope: the local planner validates track identity, candidate-set membership, count, and output order; curatorial reasons and situational fit remain model judgments based on available metadata.",
117
+ "plan.unvalidated": "I could not validate this playlist plan, so I will not return a track list that was not checked against trusted candidates. Please refine or broaden the search and try again.",
118
+
119
+ "edit.previewHeading": ({ name, before, after }) => `Spotify private playlist "${name}" edit preview (${before} -> ${after} tracks):`,
120
+ "edit.status.added": "add",
121
+ "edit.status.moved": "move",
122
+ "edit.status.retained": "keep",
123
+ "edit.removeHeading": "Remove:",
124
+ "edit.summary": ({ added, removed, moved, retained }) => `Change summary: ${added} added, ${removed} removed, ${moved} moved, ${retained} retained in place.`,
125
+ "edit.boundary": "Boundary: Spotify has not changed. Explicitly confirm in your next message before Moondog writes this exact host-retained version.",
126
+ "edit.updated": ({ name, before, after }) => `Updated the private Spotify playlist "${name}" from the exact confirmed preview${before === null ? ` (${after} tracks now)` : ` (${before} -> ${after} tracks)`}.`,
127
+
128
+ "selection.note": ({ playlist, track }) => `\n\nSelected for follow-up edits: playlist ${playlist}; song ${track}.`,
129
+ "memory.spotifyExcluded": "\n\nSpotify results are excluded from generic memory; only verified user quotes were eligible, and other claims were not saved.",
130
+ "memory.notSaved": "\n\nThe response completed, but the memory was not saved.",
131
+ "choice.like": ({ title, artist }) => { const name = title ? `"${title}" by ${artist}` : artist; return `\n\nSaved: you like ${name}. Tell me any time to change it.`; },
132
+ "choice.avoid": ({ title, artist }) => { const name = title ? `"${title}" by ${artist}` : artist; return `\n\nSaved: I'll keep ${name} out of what I play and suggest. Tell me any time to change it.`; },
133
+ "choice.retract": ({ title, artist }) => { const name = title ? `"${title}" by ${artist}` : artist; return `\n\nUndone: your earlier choice about ${name}.`; },
134
+ "turn.cancelled": "Cancelled further work.",
135
+ "turn.interrupted": "The response was interrupted. The recorded Spotify outcomes are below; no action was replayed.",
136
+ };